TG商户采集系统-需求方案书.md 62 KB

TG 商户采集系统 — 需求方案书(v3.1)

本文档描述系统要实现的功能和业务逻辑,供开发者从零设计和实现。 版本: v3.1(在 v3 基础上扩展多维度采集 + 商户信息丰富化 + 可控性管理)


零、版本说明与前置确认清单

版本演进

版本 主要变化
v2 插件化采集 + 统一清洗
v3 前置确认清单 / shared 基础设施层 / MySQL / 规则细节修正 / 监控评估
v3.1 多维度采集蓝图(8 插件)/ Enrichment 层 / 商户实体聚合 / 可控性与配额管理

v3.1 相对 v3 的变化

变化 动机
采集维度从 2 个插件扩到 8 个插件蓝图(分 M1/M2/M3 落地) 单维度漏网严重,多维度能交叉印证提升置信度
新增 Enrichment 层(raw→clean→enriched) clean 只能说"是真商户",enriched 才能说"这是什么样的商户"
新增 商户实体聚合 同一商户常有多个 TG 号 / 域名,需要聚合成一个实体
新增 可控性与配额管理 任何时候都要能回答"系统在做什么 / 花了多少钱 / 能不能立即停"
数据模型从 9 张扩到 15 张 持久化实体、enrichment、配额、审计、审核队列
新增 canary 灰度 + 紧急开关(kill switch) 新插件上线前必须小数据量验证;出合规问题能一键停

前置确认清单(写代码前必须回答)

这三件事不确认就开始写代码,等于在错的地基上盖楼。

1. 合规边界

  • 部署所在地?(决定适用法域)
  • 目标客群所在地?(决定 PIPL / GDPR / 所在国法律是否适用)
  • 输出用途?(内部研究 / 冷触达销售 / 二次分发 — 每一种法律风险不同)
  • 目标行业为"机场/VPN/科学上网"时,业务本身的合法性由业务方确认,采集系统只负责技术实现

2. 月度预算(美元)

  • 搜索 API:Brave 免费 5000 次/月大概率一周内耗光,需确认是否可切 Serper(建议预算 $10–30/月起步)
  • AI API(TG 插件启用后):按关键词密度估,建议 $20/月起步
  • 代理池:如果启用住宅代理,$50–200/月;不启用则只能单 IP 低并发运行
  • 服务器 + chromedp 内存:建议 2 vCPU / 4 GB 起,chromedp 单页峰值 300 MB
  • Whois / ICP 查询 API:$10/月起(Enrichment 启用后)

3. 代理池策略

  • 方案 A:不用代理 → 网页采集日抓量上限约 1000 页,且只能跑中低反爬站
  • 方案 B:自建 IP 池(国内 VPS) → 成本低但维护重
  • 方案 C:买住宅代理(Bright Data / IPRoyal / Oxylabs) → 推荐,按流量计费
  • day 1 必须预留代理接口,即使最初跑单 IP,也不要把 http.Client 写死

三个问题没答案前,本文档以下内容按"合规 OK / 有最低预算 / 代理接口先留桩"默认值进行。


一、系统目标

一句话:从多个维度找到 TG 商户,把联系方式和业务画像全扒下来,清洗、聚合、丰富后输出一张可以直接联系的客户表。

什么是"商户"

系统要找的是在 TG 上提供产品或服务的人或组织

判定标准(满足任意一条即算商户):

  • 有 TG 联系方式(@xxx 或 t.me/xxx)并且有商业意图(接单、代理、价格、购买、咨询、客服、官网、订阅)
  • 被导航站收录(导航站本身就是商户目录)

不算商户的:聊天用户、新闻频道、系统 bot。

目标行业(当前)

当前只做机场 / VPN / 科学上网。行业规则可配置,以后可扩展。

输入 / 输出

  • 输入:一组关键词 + 一组种子 TG 频道 + 一份导航站白名单
  • 输出:商户实体表(merchant_entities)+ 每个实体的 enrichment 画像
字段 说明
实体 ID 聚合后的唯一商户标识
商户名 主显示名
所有 TG 号 主号 + 备用号 + 客服号
所有官网 主域 + 备用域
邮箱 / 电话 联系方式
来源维度数 被多少个维度发现
行业标签 机场 / VPN 等
等级 Hot / Warm / Cold
业务画像 价格档位 / 支付方式 / 节点地区 / 技术栈
活跃度 官网存活 / TG 最新消息时间
人工备注 可选

二、整体架构

系统分四层:采集端(插件)共享基础设施处理端(流水线)丰富化

┌────────────── 采集端(插件,互不依赖) ──────────────────────┐
│  web_search  web_directory  tg_channel  tg_snowball          │
│  github      forum          cert_trans  icp_reverse          │
│      └──────────────────┬──────────────────┘                 │
│                         ▼                                    │
│                 merchants_raw                                 │
└─────────────────────────┬────────────────────────────────────┘
                          │
                          ▼
┌─────────────── 共享基础设施(shared/) ──────────────────────┐
│  tgpool / proxypool / searchcache / httpclient / extractor   │
│  quota / audit / killswitch                                  │
└─────────────────────────┬────────────────────────────────────┘
                          │
                          ▼
┌────────────── 处理端(固定流水线) ──────────────────────────┐
│  死号预检 → 黑名单 → 去重 → 实体聚合 → (TG验证) → 打标签      │
│                                   │                          │
│                                   ▼                          │
│                     merchants_clean + merchant_entities      │
└─────────────────────────┬────────────────────────────────────┘
                          │
                          ▼
┌────────────────── 丰富化(Enrichment) ──────────────────────┐
│  HTTP探测 / Whois / ICP / TG profile / 文本画像              │
│                                   │                          │
│                                   ▼                          │
│                       merchant_enrichment                    │
└──────────────────────────────────────────────────────────────┘

插件隔离规则

规则 1  每个采集插件是独立的 Go 包(internal/plugins/<name>/)
规则 2  插件之间零依赖:A 不能 import B 的任何符号
规则 3  插件和处理端都可以 import internal/shared/* 的共享组件
规则 4  所有采集插件的产出走同一个标准格式(MerchantData)
规则 5  新增插件 = 新建目录 + 实现 Collector 接口 + 配置里注册,不改任何已有代码
规则 6  shared/ 里的包不依赖任何具体插件(否则环形依赖)
规则 7  新插件上线必须先 canary 模式跑通人工审核(见第十三章)

标准产出格式

type MerchantData struct {
    TgUsername   string   // 必填,没有就不入库
    TgLink       string
    MerchantName string
    Website      string
    Email        string
    Phone        string
    SourceType   string   // web_search / web_directory / tg_channel / github / ...
    SourceName   string
    SourceURL    string
    OriginalText string
    IndustryTag  string
    FetchedAt    time.Time
    Canary       bool     // v3.1:canary 数据只进 raw,不进 clean
}

硬约束:没有 TgUsername 的商户不入库。

插件接口

type Collector interface {
    Name() string
    Run(ctx context.Context, cfg map[string]any, emit func(MerchantData)) error
    Stop() error
}

三、采集维度蓝图

单靠网页+TG 两个维度漏网严重。v3.1 规划 8 个维度,分三期落地。同一商户经常出现在多个维度,多维度互补正是提高召回率和置信度的核心手段。

多维度的价值

场景 单维度的问题 多维度的解法
商户只在私密 TG 群出现 Web 搜不到 TG 滚雪球
商户只在 GitHub README TG 看不到 github 插件
官网被墙但频道活跃 Web 抓失败 TG + forum
主域名被封换备用域 Web 搜不到新域 cert_transparency
验证商户真实性 单一来源可能伪造 跨维度交叉印证

source_count ≥ 2 的商户自动进入 Hot,这是多维度的直接收益。

插件蓝图(8 个维度)

# 插件 数据源 产出密度 合规风险 优先级 里程碑
1 web_search Google/Bing/Brave/Serper 搜索结果 ★★★★★ P0 M1
2 web_directory 已知导航站白名单主动爬取 ★★★★★ P0 M1
3 tg_channel TG 频道历史消息 ★★★ P1 M2
4 tg_snowball 从已发现频道滚雪球(转发源、@mention ★★★★ P1 M2
5 github_search GitHub code/README 里的 t.me 链接 ★★ P2 M2
6 forum_scraper V2EX/hostloc/Reddit 等论坛帖子 ★★★ P2 M3
7 cert_transparency 证书透明日志反查同组织域名 ★★ P3 M3
8 icp_reverse 通过 ICP 备案号反查同主体域名 ★★ P3 M3

里程碑与验收标准

里程碑 范围 周期 验收
M1 web_search + web_directory + 处理端 + Enrichment 最小集 + 可控性骨架 2 周 Hot 商户 ≥ 100,Precision ≥ 85%
M2 + tg_channel + tg_snowball + github_search + 实体聚合 1 个月 总商户 ≥ 500,source_count≥2 占比 ≥ 30%
M3 + forum + cert_transparency + icp_reverse + 全量可控性仪表 2 个月 按实体去重后 ≥ 800 家商户

不跳级:M1 没稳定前不做 M2;M2 没稳定前不做 M3。每个新插件上线必须先走 canary 模式(见第十三章)。

维度互补性示例

商户 A:
  web_search       → 2 个导航站发现
  web_directory    → airportlist.top 收录
  tg_snowball      → 频道 @vpn_nav 转发
  github_search    → awesome-vpn-cn README
  cert_transparency → 发现备用域名 a-vpn.net
  source_count = 5 → Hot,置信度 High,实体聚合自动合并

四、采集插件 A:网页搜索采集(web_search,P0)

为什么是第一优先级

  • 一个导航站几秒出 50 个商户,效率最高
  • 没有 TG 账号封禁风险
  • 导航站里的商户是别人整理好的,质量高

数据流

关键词 → [searchcache 查缓存] → [搜索 API] → URL 列表
                                                  │
                                       ┌──────────┴─────────┐
                                       ▼                    ▼
                                 URL 是 t.me/xxx        URL 是网页
                                 直接提取 username      进入抓取流程
                                       │                    │
                                       │          ┌─────────┴────────┐
                                       │          ▼                  ▼
                                       │    抓 HTML(三层 fallback)  丢弃黑名单域
                                       │          │
                                       │     解析 HTML,正则提取
                                       │     t.me / 邮箱 / 电话
                                       │          │
                                       └──────┬───┘
                                              ▼
                                       emit → merchants_raw

详细逻辑

第一步:关键词搜索

  1. keywords 表读 enabled=true 的关键词
  2. 先查 search_cache:key = (engine, keyword, page),TTL 默认 7 天
  3. 缓存未命中 → 通过 quota 中心拿到额度(见第十三章) → 调搜索 API
  4. 结果写回 search_cache
  5. 每个关键词默认 3–5 页 × 10 条;每次请求之间随机等 2–5 秒

第二步:URL 分拣

URL 类型 判断 处理
t.me/xxx URL 以 t.me/telegram.me/ 开头 直接提取 username,emit
t.me/joinchat/xxxt.me/+xxx 邀请链接 标记 invalid,丢弃
黑名单域(twitter/google/youtube 等 80+) 域名精确匹配 丢弃
其他网页 进入第三步

第三步:网页抓取(三层 fallback)

shared/httpclient 统一暴露:

层 1  net/http + colly(默认)
      超时 10s,失败或 403/429 → 升层
层 2  utls 自定义 TLS 指纹(绕 Cloudflare 类反爬)
      超时 15s,失败或 JS 渲染空 body → 升层
层 3  chromedp(Headless Chrome)
      超时 30s,失败 → 放弃

每层都走同一个 proxypool.Next() 拿出口 IP。并发上限见第十二章。

第四步:HTML 解析

  1. 收到 HTML 后先判断是否中文站(见下方"中文判断"修正)
  2. 用 goquery 遍历:
    • 所有 a[href^="https://t.me/"]a[href^="tg://"] → 抽 username
    • 所有 a[href^="mailto:"] → 抽邮箱
    • 正文纯文本对电话正则匹配(带上下文关键词过滤)
  3. 导航站判断(见下方"导航站启发式"修正)
  4. 每个候选 username 组装 MerchantData → emit

中文判断(修正 v2 的 3000 字规则)

策略 1(默认)  解析 HTML 后取 <title> + <meta description> + 前 5000 字符可见文本
               统计中文字符数 / 总字符数,比例 ≥ 15% 判定为中文站
策略 2(补充) 若 HTTP 层返回的是 JS 空壳(<body> 少于 200 字符),
               直接升级到 chromedp 渲染后再判断,不误杀
告警          若策略 1 和 2 都不过关但页面里有 t.me/@username,
               仍走 emit,只是在 original_text 里标记 lang=unknown

导航站启发式(修正 v2 的 ">5 个 t.me")

是导航站的充分条件(满足任一即视为高质量导航站):
  a) 页面上 ≥ 8 个 t.me 链接 且 分布在不同 DOM 父节点(避免评论区灌水)
  b) URL / title 含 "导航 / nav / 机场推荐 / 订阅" 等关键词
  c) 有规律的卡片式布局(<ul><li> 或 <table>,同级节点里重复出现 t.me)

非导航站处理:仍抽取所有 t.me,但标记 SourceType=web_casual
             清洗阶段这类商户降权(不会直接 Hot)

电话号码正则(修正 v2 误匹配问题)

不要用裸 1[3-9]\d{9}。命中时必须满足以下条件之一:
  a) 正则前/后 20 字符内有关键词:电话|手机|tel|phone|联系|客服
  b) 命中位置在 <a href="tel:..."> 里
  c) 位置属于 meta / schema.org Contact 块
否则丢弃(避免 QQ 号 / 订单号 / 时间戳误判)

搜索 API 选择与缓存

方案 免费额度 付费 用法
Brave Search API 5000 次/月 $5/1000 次 起步用
Serper.dev 2500 次(一次性) $50/50000 次 免费耗完后切
Bing Web Search $3/1000 次 备用
DuckDuckGo lite 无限 免费 兜底

强制缓存 + 强制配额

  • 同一 (engine, keyword, page) 在 7 天内只允许调一次 API(search_cache 表)
  • 调用前必须通过 quota 中心检查(见第十三章)

可配置config.yamlsearch.provider = brave | serper | bing | ddg


五、采集插件 B:TG 频道采集(tg_channel,P1)

M1 网页插件稳定运行 2 周以上再启动这个。

数据流

seed_channels → [tgpool 拿账号] → 进频道 → 读历史消息(最近 500 条)
                                             │
                                  每条消息:正则快扫 + 关键词预筛
                                             │
                                             ▼
                                  触发条件 → AI 精确提取
                                             │
                                      AI 结果 → 回源校验
                                             │
                                             ▼
                                      emit → merchants_raw

详细逻辑

  1. seed_channels 表拿 status=pending 的频道
  2. tgpool.Acquire() 拿一个可用的 TG 账号
  3. ResolveUsername 前先查 channels.channel_id 缓存
  4. 读最近 500 条消息,断点续传记在 channels.last_message_id
  5. 消息预筛:
    • 系统消息 / 非中文 / 长度 < 10 → 跳过
    • 正则扫 @\w+ / t.me/\w+ / 邮箱 / 电话 — 命中任一才进下一步
  6. AI 提取(调 DeepSeek / GLM):
    • 提示词要求结构化 JSON 输出
    • 超时 5 秒 / 失败 → 正则兜底
  7. AI 结果校验(修正 v2 的"正则回原文精确匹配")

    校验规则:取 AI 输出的每个联系方式(username / email / phone),
           去掉标点、空白、@、+86 等前缀,提取核心 token,
           去原文里做"去格式化后的子串匹配"
           命中即接受;否则丢弃
    例:AI 输出 "@abc_123",原文 "加 V:abc_123 / 加Q:456"
       token = "abc_123" → 子串匹配 → 接受
    
  8. 通过校验的联系方式组装 MerchantData → emit


六、其他采集插件概要

本章列出 M2/M3 阶段要做的 6 个补充插件。所有插件共用第七章的 shared 基础设施,统一实现 Collector 接口,所以"新增一个维度 = 新建一个目录"。本章只给要点,详细实现在对应里程碑启动时展开。

6.1 web_directory — 导航站主动爬取(P0,M1)

为什么:web_search 烧 API 额度,但大型导航站列表是已知的(airportlist.top、vpn.nav.vip 等),直接爬比搜索更高效完整。

流程

  1. 维护导航站白名单表 directory_whitelist
  2. 每个站点配置抓取规则(URL 模板、列表页选择器、卡片 CSS)
  3. 按 cron 定时抓(默认每天一次)
  4. 用 shared/httpclient 抓页面,goquery 按规则解析
  5. 新商户 → emit;已知商户 → 更新 last_seen_at

维护成本:规则失效监控(网站改版会导致选择器失效,需要每日产出量监控告警)。

6.2 tg_snowball — TG 滚雪球(P1,M2)

为什么:种子频道只有少数几个,但 TG 生态内的转发链和 @mention 链能发现大量新频道。

流程

  1. 扫描已采集的 TG 消息,提取所有 @channel_name 和"Forwarded from xxx"
  2. 新频道自动加入 channels 表,source='discovered',status=pending
  3. 由 tg_channel 插件继续采集
  4. 每日汇总新发现频道数作为指标

约束:新频道加入前过滤 bot、违法关键词、非中文频道。

复用:不实现新的采集逻辑,只是生产新的种子给 tg_channel。

6.3 github_search — GitHub 代码搜索(P2,M2)

为什么:大量机场项目放 GitHub,README 直接写 TG 联系方式。GitHub API 免费 5000 req/hour,几乎无限制。

流程

  1. 用 GitHub Code Search API 搜 "t.me" language:Markdowntelegram 机场VPN 订阅
  2. 拿 repo 列表 → 逐个读 README / description / topics
  3. 正则提取 t.me 链接
  4. 关联 repo metadata(star、last_push)作为活跃度信号

产出特点:数量不多但质量高(README 一般是项目主动填的)。

6.4 forum_scraper — 论坛采集(P2,M3)

为什么:V2EX、hostloc、Reddit 的"机场推荐"帖是高质量发现源,尤其携带用户真实评价。

流程

  1. 配置论坛白名单 + 每个论坛的采集规则(API 或 HTML 抓)
  2. 定期抓置顶帖 + 最新主题列表
  3. 帖子正文 + 评论都进 extractor
  4. 用户评价作为"社区口碑"信号供 Enrichment 使用

产出特点:密度中等,但携带用户评价这种独家信号。

6.5 cert_transparency — 证书透明日志反查(P3,M3)

为什么:机场商户常注册多个域名(主域名被墙就换备用)。通过 SSL 证书透明日志(crt.sh)可以反查同一组织申请的其他域名。

流程

  1. 从已发现商户拿主域名
  2. 查 crt.sh:https://crt.sh/?q=%25example.com&output=json
  3. 提取 SAN 里的其他域名
  4. 新域名丢给 web_search 做二次采集
  5. 新域名若指向新 TG → emit

产出特点:主要用于补全已知商户的备用域名,偶尔发现新商户。

6.6 icp_reverse — ICP 备案号反查(P3,M3)

为什么:中国境内合规商户需要 ICP 备案。从已知商户的 ICP 号反查同一主体备案的其他域名,能发现关联商户。

流程

  1. 从 Enrichment 层拿商户的 ICP 备案号
  2. 查 ICP 反查 API(beianx.cn 或自建)
  3. 同主体的其他域名 → 标记为 related_domains
  4. 含 TG 的新域名 → emit

产出特点:只对备案商户有效(机场类目很多不备案,覆盖率低),但一旦命中置信度高。


七、共享基础设施(internal/shared/

7.1 tgpool — TG 账号池

职责:

  • 管理多个 TG 账号的 session、健康状态、FloodWait 倒计时
  • 提供 Acquire(ctx) → Account / Release(acc) 接口
  • 调用端不感知限速:超限自动切号,全部账号都限速时挂起调用者

状态持久化到 tg_accounts 表:

字段 类型 说明
id int 主键
phone VARBINARY AES-GCM 加密存储
api_id int my.telegram.org 申请
api_hash VARBINARY 加密存储
session_path string session 文件路径
status string active / flood_wait / banned / disabled
flood_wait_until datetime FloodWait 解除时间
resolve_count_today int 今日 ResolveUsername 计数
last_used_at datetime

限速策略:

  • 全局请求频率 ≤ 30 次/分钟(所有账号汇总)
  • FloodWait < 60s → 账号原地等
  • FloodWait 60–300s → 账号进 flood_wait 状态,切下一个
  • FloodWait > 300s → 标记 flood_wait,调度器 5 分钟后重试
  • 所有账号都不可用 → 调用方 block 等待,最长 30 分钟后报错

7.2 proxypool — 代理出口池

职责:

  • 抽象"出口 IP"概念,插件和 httpclient 通过 proxypool.Next() 拿代理
  • 健康检查:每 5 分钟对每个代理做一次 GET https://example.com,连续 3 次失败下线
  • 支持三种后端:
    • direct — 不走代理(默认,方便本地开发)
    • static_list — 静态 IP 列表(YAML 配置)
    • bright_data / iproyal — 商用住宅代理(按流量)

day 1 只实现 direct,但接口必须先留好

7.3 searchcache — 搜索结果缓存

持久化到 search_cache 表。见第四章"搜索 API 选择与缓存"。

7.4 httpclient — 三层 fallback 客户端

type Client interface {
    Get(ctx context.Context, url string, opts ...Option) (*Response, error)
}

每层通过 proxypool.Next() 拿出口。chromedp 层有单独并发闸门(默认 3,见第十二章)。

7.5 extractor — 正则提取器

package extractor

func TgUsernames(text string) []string
func Emails(text string) []string
func Phones(text string, ctxWindow int) []string
func PriceTiers(text string) []PriceTier   // v3.1 Enrichment 使用
func PaymentMethods(text string) []string
func ServerRegions(text string) []string

纯函数、无状态、插件和处理端共用。

7.6 quota — 配额中心(v3.1 新增)

type Quota interface {
    Check(resource string, amount int64) error   // 超限返回 ErrQuotaExceeded
    Consume(resource string, amount int64) error
    Usage(resource string) (used, budget int64)
}

所有外部付费资源(search API / AI tokens / proxy 流量 / tg requests / whois 查询)调用前必须过 quota.Check。见第十三章。

7.7 audit — 操作审计(v3.1 新增)

type Audit interface {
    Log(actor, action, targetType, targetID string, payload any) error
}

系统级动作都写 audit_logs 表。见第十三章。

7.8 killswitch — 紧急开关(v3.1 新增)

type KillSwitch interface {
    IsEngaged(domain string) bool  // domain: "collectors" / "enrichment" / "all"
    Engage(domain, reason, actor string) error
    Release(domain, actor string) error
}

所有插件和 Enrichment 在每一轮循环开始时查询 IsEngaged("collectors"),true 就优雅退出。见第十三章。


八、Enrichment 层(商户信息丰富化)

clean 只能告诉你"这是个真商户",enriched 才能告诉你"这是个什么样的商户"。没有 Enrichment,销售拿到的列表只有 TG 号和名字,无法分档、无法个性化触达。

输入 / 输出

  • 输入merchants_clean 里 status=valid 的记录
  • 输出merchant_enrichment 表(每个 merchant_id 一条)

丰富字段(15 项,分四组)

A. 官网元数据(HTTP 请求)

字段 说明
site_alive 官网 HTTP 2xx
site_ssl_days SSL 证书剩余天数
site_title