本文档描述系统要实现的功能和业务逻辑,供开发者从零设计和实现。 版本: v3.1(在 v3 基础上扩展多维度采集 + 商户信息丰富化 + 可控性管理)
| 版本 | 主要变化 |
|---|---|
| v2 | 插件化采集 + 统一清洗 |
| v3 | 前置确认清单 / shared 基础设施层 / MySQL / 规则细节修正 / 监控评估 |
| v3.1 | 多维度采集蓝图(8 插件)/ Enrichment 层 / 商户实体聚合 / 可控性与配额管理 |
| 变化 | 动机 |
|---|---|
| 采集维度从 2 个插件扩到 8 个插件蓝图(分 M1/M2/M3 落地) | 单维度漏网严重,多维度能交叉印证提升置信度 |
| 新增 Enrichment 层(raw→clean→enriched) | clean 只能说"是真商户",enriched 才能说"这是什么样的商户" |
| 新增 商户实体聚合 | 同一商户常有多个 TG 号 / 域名,需要聚合成一个实体 |
| 新增 可控性与配额管理 | 任何时候都要能回答"系统在做什么 / 花了多少钱 / 能不能立即停" |
| 数据模型从 9 张扩到 15 张 | 持久化实体、enrichment、配额、审计、审核队列 |
| 新增 canary 灰度 + 紧急开关(kill switch) | 新插件上线前必须小数据量验证;出合规问题能一键停 |
这三件事不确认就开始写代码,等于在错的地基上盖楼。
1. 合规边界
2. 月度预算(美元)
3. 代理池策略
三个问题没答案前,本文档以下内容按"合规 OK / 有最低预算 / 代理接口先留桩"默认值进行。
一句话:从多个维度找到 TG 商户,把联系方式和业务画像全扒下来,清洗、聚合、丰富后输出一张可以直接联系的客户表。
系统要找的是在 TG 上提供产品或服务的人或组织。
判定标准(满足任意一条即算商户):
不算商户的:聊天用户、新闻频道、系统 bot。
当前只做机场 / VPN / 科学上网。行业规则可配置,以后可扩展。
| 字段 | 说明 |
|---|---|
| 实体 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,这是多维度的直接收益。
| # | 插件 | 数据源 | 产出密度 | 合规风险 | 优先级 | 里程碑 |
|---|---|---|---|---|---|---|
| 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,实体聚合自动合并
关键词 → [searchcache 查缓存] → [搜索 API] → URL 列表
│
┌──────────┴─────────┐
▼ ▼
URL 是 t.me/xxx URL 是网页
直接提取 username 进入抓取流程
│ │
│ ┌─────────┴────────┐
│ ▼ ▼
│ 抓 HTML(三层 fallback) 丢弃黑名单域
│ │
│ 解析 HTML,正则提取
│ t.me / 邮箱 / 电话
│ │
└──────┬───┘
▼
emit → merchants_raw
第一步:关键词搜索
keywords 表读 enabled=true 的关键词search_cache:key = (engine, keyword, page),TTL 默认 7 天search_cache第二步:URL 分拣
| URL 类型 | 判断 | 处理 |
|---|---|---|
t.me/xxx |
URL 以 t.me/ 或 telegram.me/ 开头 |
直接提取 username,emit |
t.me/joinchat/xxx、t.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 解析
a[href^="https://t.me/"] 和 a[href^="tg://"] → 抽 usernamea[href^="mailto:"] → 抽邮箱中文判断(修正 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 号 / 订单号 / 时间戳误判)
| 方案 | 免费额度 | 付费 | 用法 |
|---|---|---|---|
| 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 表)可配置:config.yaml 里 search.provider = brave | serper | bing | ddg。
M1 网页插件稳定运行 2 周以上再启动这个。
seed_channels → [tgpool 拿账号] → 进频道 → 读历史消息(最近 500 条)
│
每条消息:正则快扫 + 关键词预筛
│
▼
触发条件 → AI 精确提取
│
AI 结果 → 回源校验
│
▼
emit → merchants_raw
seed_channels 表拿 status=pending 的频道tgpool.Acquire() 拿一个可用的 TG 账号channels.channel_id 缓存channels.last_message_id@\w+ / t.me/\w+ / 邮箱 / 电话 — 命中任一才进下一步AI 结果校验(修正 v2 的"正则回原文精确匹配"):
校验规则:取 AI 输出的每个联系方式(username / email / phone),
去掉标点、空白、@、+86 等前缀,提取核心 token,
去原文里做"去格式化后的子串匹配"
命中即接受;否则丢弃
例:AI 输出 "@abc_123",原文 "加 V:abc_123 / 加Q:456"
token = "abc_123" → 子串匹配 → 接受
通过校验的联系方式组装 MerchantData → emit
本章列出 M2/M3 阶段要做的 6 个补充插件。所有插件共用第七章的 shared 基础设施,统一实现 Collector 接口,所以"新增一个维度 = 新建一个目录"。本章只给要点,详细实现在对应里程碑启动时展开。
为什么:web_search 烧 API 额度,但大型导航站列表是已知的(airportlist.top、vpn.nav.vip 等),直接爬比搜索更高效完整。
流程:
directory_whitelist维护成本:规则失效监控(网站改版会导致选择器失效,需要每日产出量监控告警)。
为什么:种子频道只有少数几个,但 TG 生态内的转发链和 @mention 链能发现大量新频道。
流程:
@channel_name 和"Forwarded from xxx"channels 表,source='discovered',status=pending约束:新频道加入前过滤 bot、违法关键词、非中文频道。
复用:不实现新的采集逻辑,只是生产新的种子给 tg_channel。
为什么:大量机场项目放 GitHub,README 直接写 TG 联系方式。GitHub API 免费 5000 req/hour,几乎无限制。
流程:
"t.me" language:Markdown、telegram 机场、VPN 订阅 等产出特点:数量不多但质量高(README 一般是项目主动填的)。
为什么:V2EX、hostloc、Reddit 的"机场推荐"帖是高质量发现源,尤其携带用户真实评价。
流程:
产出特点:密度中等,但携带用户评价这种独家信号。
为什么:机场商户常注册多个域名(主域名被墙就换备用)。通过 SSL 证书透明日志(crt.sh)可以反查同一组织申请的其他域名。
流程:
https://crt.sh/?q=%25example.com&output=json产出特点:主要用于补全已知商户的备用域名,偶尔发现新商户。
为什么:中国境内合规商户需要 ICP 备案。从已知商户的 ICP 号反查同一主体备案的其他域名,能发现关联商户。
流程:
产出特点:只对备案商户有效(机场类目很多不备案,覆盖率低),但一旦命中置信度高。
internal/shared/)职责:
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 |
限速策略:
flood_wait 状态,切下一个flood_wait,调度器 5 分钟后重试职责:
proxypool.Next() 拿代理GET https://example.com,连续 3 次失败下线direct — 不走代理(默认,方便本地开发)static_list — 静态 IP 列表(YAML 配置)bright_data / iproyal — 商用住宅代理(按流量)day 1 只实现 direct,但接口必须先留好。
持久化到 search_cache 表。见第四章"搜索 API 选择与缓存"。
type Client interface {
Get(ctx context.Context, url string, opts ...Option) (*Response, error)
}
每层通过 proxypool.Next() 拿出口。chromedp 层有单独并发闸门(默认 3,见第十二章)。
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
纯函数、无状态、插件和处理端共用。
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。见第十三章。
type Audit interface {
Log(actor, action, targetType, targetID string, payload any) error
}
系统级动作都写 audit_logs 表。见第十三章。
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 就优雅退出。见第十三章。
clean 只能告诉你"这是个真商户",enriched 才能告诉你"这是个什么样的商户"。没有 Enrichment,销售拿到的列表只有 TG 号和名字,无法分档、无法个性化触达。
merchants_clean 里 status=valid 的记录merchant_enrichment 表(每个 merchant_id 一条)A. 官网元数据(HTTP 请求)
| 字段 | 说明 |
|---|---|
site_alive |
官网 HTTP 2xx |
site_ssl_days |
SSL 证书剩余天数 |
site_title |