开发

常见问题

  1. 支持的站点列表在哪里?

人类可读的支持站点列表位于仓库的 sites.md 文件。它由主 JSON 文件(其中保存了支持的站点列表)自动生成。

机器可读的 JSON 文件 data.json 位于 resources 目录下。

  1. 支持哪些检查账号存在性的方式?

支持以下几种方式(对应 data.json 中的 checkType 值):

  • message —— 最可靠的方式,检查 HTML 响应中是否包含 presenceStrs 中任一字符串,同时不包含 absenceStrs 中的任何字符串

  • status_code —— 检查响应的状态码是否为 2XX

  • response_url —— 检查响应是否未发生重定向,且状态码为 2XX

备注

对于某些反爬虫返回的特殊 HTTP 状态码(例如 LinkedIn 的 HTTP 999),Maigret 会原生地将其视为标准的“未找到 / 不存在”信号,而不是抛出基础设施层面的服务器错误,从而优雅地避免误报。

检查机制的具体细节请见 checking.py。

备注

Maigret 目前使用 Majestic Million 数据集来对站点人气进行排序,以替代已停服的 Alexa Rank API。为了与既有配置和解析器保持向后兼容,data.json 以及内部站点模型中的排名字段仍然沿用 alexaRank 和 alexa_rank。

镜像站与 ``--top-sites``: 使用 --top-sites N 限定扫描范围时,Maigret 还会额外纳入*镜像*站点(即 source 字段指向 Twitter、Instagram 等父平台的条目),只要在“也将已禁用站点纳入排名”的前提下,该父平台位于 Majestic Million 的前 N 名内。详见 命令行选项 中 --top-sites 下的 Mirrors 段落。

测试

建议使用 Python 3.10 进行测试。

安装测试依赖:

poetry install --with dev

使用以下命令对 Maigret 进行检查:

# run linter and typing checks
# order of checks:
# - critical syntax errors or undefined names
# - flake checks
# - mypy checks
make lint

# run black formatter
make format

# run testing with coverage html report
# current test coverage is 58%
make test

# open html report
open htmlcov/index.html

离线运行测试

部分测试会访问真实站点,这也是 CI 使用 --reruns 3 --reruns-delay 5 运行 pytest 的原因。它们都带有 slow 标记,取消选择该标记后,剩下的测试在完全没有网络的情况下也能通过:

pytest tests -m "not slow"

构建发行版软件包时应使用这条命令,因为发行版的构建环境没有网络访问权限。基于 0.6.4 的实测:离线运行完整测试集会有 9 个测试失败,而 -m "not slow" 通过 403 个、取消选择 24 个。

如果你新增了访问网络的测试,请为它加上 slow 标记——否则你会在无声无息之间,破坏所有下游打包者的离线构建。

# get flamechart of imports to estimate startup time make speed

站点命名规范

站点名称是 data.json 中的键,也会出现在面向用户的报告中。请遵循以下规则:

  • 默认使用首字母大写(Title Case):Product Hunt、Hacker News。

  • 仅当品牌方本身就这样书写时,才使用全小写:kofi、note、hi5。

  • 不要带域名后缀(calendly.com → Calendly);除非该域名本身就是品牌名的一部分:last.fm、VC.ru、Archive.org。

  • 除非品牌是缩写,否则不要全大写:VK、CNET、ICQ、IFTTT。

  • 名称中不要带 www. 或 https:// 前缀。

  • 如果品牌名本身带空格,允许保留空格:Star Citizen、Google Maps。

  • 名称中可以使用 {username} 模板:{username}.tilda.ws。

拿不准时,看看该服务在自家主页上是怎么称呼自己的。

如何修复误报

如果你要处理站点数据库,别忘了启用统计信息更新的 git hook —— 对应命令为:git config --local core.hooksPath .githooks/。

请务必在 maigret 的 git 仓库目录下执行 git 提交,否则该 hook 找不到对应的统计更新脚本。

  1. 确定出问题的站点。

如果你已经知道是哪个站点存在误报,并打算专门修它,可以直接跳到下一步。

否则,用一个随机用户名(例如 laiuhi3h4gi3u4hgt)跑一次搜索,然后看结果即可。你也可以使用社区 Telegram 机器人。

  1. 在浏览器中打开该账号链接,并检查:

  • 如果站点已彻底下线,将其从列表中移除

  • 如果站点仍在运行但页面已变化,在 data.json 中相应地更新检查方式

  • 如果站点必须登录才能查看主页,则禁用对它的检查

  1. 在 data.json 中找到该站点。

如果原先的 checkType 不是 message,而你打算修这个检查,请按下面的方式更新:把 checkType 改为 message;在 absenceStrs 中填入一个仅出现在“账号不存在”响应 HTML 中的关键词;在 presenceStrs 中填入一个仅出现在“账号存在”响应 HTML 中的关键词。

如果你不太能挑出合适的关键词,可以通过 --submit 选项传入账号 URL,让 Maigret 自动检测:

maigret --submit https://my.mail.ru/bk/alex

要禁用对某站点的检查,将 disabled 设为 true,或直接运行:

maigret --self-check --site My.Mail.ru@bk.ru

要基于响应 HTML 来调试检查方式,可以运行:

maigret soxoj --site My.Mail.ru@bk.ru -d 2> response.txt

在不同场景下,data.json 中还有几个有用的可选字段:

  • engine —— 针对某类站点(如论坛)的预定义检查;参见 JSON 文件中的 engines 节

  • headers —— 一组额外的请求头,会随请求一并发送给该站点

  • requestHeadOnly —— 如果对该站点用 HEAD 请求即可完成检查,将其设为 true

  • regexCheck —— 用于校验用户名是否合法的正则,适用于频繁出现误报的场景

  • requestMethod —— 指定使用的 HTTP 方法(例如 POST)。默认情况下,Maigret 原生使用 GET 或 HEAD。

  • requestPayload —— 用于 POST 请求的 JSON 负载字典(例如 {"username": "{username}"}),在对接 GraphQL 或现代 JSON API 时非常有用。

  • protection —— 在该站点上检测到的防护类型列表(详见下文)。

protection(站点防护跟踪)

protection 字段用于记录站点使用了哪种反爬虫防护。Maigret 会读取该字段,并在有对应绕过机制可用时自动启用它。

标签分为两类:

  • 有实际效果的标签。 Maigret 会根据该标签更换 HTTP 客户端或请求头。目前只有 tls_fingerprint(会切换到带 Chrome 级 TLS 的 curl_cffi)。

  • 仅供记录的标签。 Maigret 不会因该标签改变行为;它只是记录该站点“为什么难”,以便未来引入对应的求解器后,可以直接定位到正确的站点集合,不必再重新审计一遍。

在“仅供记录”这一类标签内部,还有一道进一步的划分,决定该站点是否被设为 disabled: true:

  • ip_reputation 是唯一一个保留站点为启用状态的记录类标签。它的意思是“对大多数用户可用,但从数据中心 / 云 IP 访问会失败”。如果禁用了,那些拥有干净 IP 的用户就会被静默地挡在外面,看不到这个本来正常的站点。修复方法在 Maigret 之外(住宅 IP 或 --proxy)。

  • cf_js_challenge、cf_firewall、aws_waf_js_challenge、ddos_guard_challenge、custom_bot_protection、js_challenge 都与 disabled: true 搭配出现。它们的含义是“目前对所有人都不能用”;标签本身用于标明防护提供方,这样一旦相应绕过手段就绪,所有打了该标签的站点就可以一次性重新启用。

支持的取值:

  • tls_fingerprint (有实际效果;站点保持启用) —— 该站点会对 TLS 握手(JA3/JA4)进行指纹识别,阻挡非浏览器客户端。Maigret 会自动改用带 Chrome 浏览器模拟的 curl_cffi 来绕过。需要 curl_cffi 包(已作为依赖一并安装)。示例:Instagram、NPM、Codepen、Kickstarter、Letterboxd。

  • ip_reputation (仅供记录;站点保持启用) —— 不论请求头还是 TLS,只要来自数据中心 / 云 IP 都会被站点拦截。无法自动绕过;请从普通网络(而不是数据中心)上运行 Maigret,或使用 --proxy。该站点不会被标为 disabled,因为对住宅 IP 的用户它仍然可用。示例:Reddit、Patreon、Figma、OnlyFans。

  • cf_js_challenge (仅供记录;与 ``disabled: true`` 搭配) —— Cloudflare Managed Challenge / Turnstile JS 挑战。症状:HTTP 403,响应头含 cf-mitigated: challenge;响应体中含有 challenges.cloudflare.com、_cf_chl_opt、window._cf_chl 或 "Just a moment"。无法通过 curl_cffi TLS 模拟绕过(已在 Chrome 123/124/131、Safari 17/18、Firefox 133/135、Edge 101 等多个版本上验证 —— 全部返回同样的 403 挑战页);必须由真实浏览器执行挑战 JS 才能获得 clearance cookie。在引入 CF 挑战求解器之前,这些站点会一直保持 disabled: true。示例:DMOJ、Elakiri、Fanlore、Bdoutdoors、TheStudentRoom、forum.hr。

  • cf_firewall (仅供记录;与 ``disabled: true`` 搭配) —— Cloudflare 防火墙规则 / 机器人分数级封禁(WAF action=block,不是 action=challenge)。症状:由 Cloudflare 返回的 HTTP 403(server: cloudflare、含 cf-ray 响应头),且响应体中没有 JS 挑战相关标记 —— 内容通常是 "Access denied"、"Attention Required",或一个简单的 1015/1016/1020 错误页。与 ip_reputation 不同,仅靠住宅 IP 也不足以绕过 —— Cloudflare 会综合机器人分数、TLS 指纹、UA、ASN 以及站点方的自定义规则进行判定,因此即便从住宅线路上用 curl_cffi 模拟 Chrome,依然会拿到 403。这些站点会一直保持 disabled: true,直到为其找到针对性的绕过方式(cookie、真实浏览器,或住宅 + 干净会话)。示例:Fark、Fodors、Huntingnet、Hunttalk。

  • aws_waf_js_challenge (仅供记录;与 ``disabled: true`` 搭配) —— 站点使用 AWS WAF 并启用了 JavaScript 挑战。症状:HTTP 202、响应体为空,且响应头含 x-amzn-waf-action: challenge(一个需要执行 CAPTCHA / 挑战 JS 包才能获得 token 的挑战)。无论是 curl_cffi 的 TLS 模拟,还是更换 User-Agent,都无法绕过 —— 需要真实浏览器,或官方的 AWS WAF challenge-solver SDK。在求解器接入前,这些站点会一直保持 disabled: true。示例:Dreamwidth。

  • ddos_guard_challenge (仅供记录;与 ``disabled: true`` 搭配) —— DDoS-Guard(ddos-guard.net)反爬虫页。症状:HTTP 403,响应头含 server: ddos-guard,响应体内含 "DDoS-Guard"。DDoS-Guard 会按不同的源 IP 对 UA 进行指纹识别,因此仅在一个环境中替换 User-Agent 并不能在其它环境通用;需要支持执行 JS 的绕过手段,或专门针对 DDoS-Guard 的求解器。在求解器接入前,这些站点会一直保持 disabled: true。示例:ForumHouse。

  • js_challenge (仅供记录;与 ``disabled: true`` 搭配) —— 针对无法识别提供方的 JavaScript 挑战系统的兜底标签(即:不是 Cloudflare、AWS WAF 或其它已知厂商的、自研挑战页)。只要能从响应头或响应体特征中确定提供方,就应优先使用对应的具体厂商标签。

  • custom_bot_protection (仅供记录;与 ``disabled: true`` 搭配) —— 针对非 JS 挑战类、自研 / 内部反爬虫系统(非 Cloudflare、非 AWS WAF、非 DDoS-Guard)的兜底标签。典型症状:由站点自身源站返回 HTTP 403(server: nginx、AWS ELB 等),并带有定制的拦截页;无论 TLS 指纹还是住宅 IP 都同样会被挡。无法通用地绕过;需要逐站点研究(cookie、会话、代理地理位置等)。示例:Hackerearth("HackerEarth Guardian")、FreelanceJob(nginx 层封禁)。

原则:优先使用厂商特定的防护标签。 当某站点被一个可识别的反爬虫厂商封禁时,务必在标签中记录该厂商(cf_js_challenge、cf_firewall、aws_waf_js_challenge、ddos_guard_challenge,以及未来可能新增的 sucuri_challenge、incapsula_challenge 等)。通用的 js_challenge 和 custom_bot_protection 标签仅留给自研 / 无法识别的系统。原因在于:绕过求解器在本质上是厂商相关的(一个 Cloudflare Turnstile 求解器对 AWS WAF 没用);提前在标签里记下厂商,就能在某种厂商求解器接入的瞬间一次性把所有相关站点全部启用,而无需再次审计每一个被禁用的站点。其它防护类别在能识别厂商时,也适用同一原则。

示例:

"Instagram": {
    "url": "https://www.instagram.com/{username}/",
    "checkType": "message",
    "presenseStrs": ["\"routePath\":\"\\/"],
    "absenceStrs": ["\"routePath\":null"],
    "protection": ["tls_fingerprint"]
}

urlProbe(可选的主页探测 URL)

默认情况下,Maigret 会向与 url 相同的地址(也就是面向公众的主页链接模板)发起 HTTP 请求。

如果你在 data.json 中设置了 urlProbe,Maigret 会抓取该 URL 来做存在性检查(API、GraphQL、JSON 接口等);而报告和 ``url_user`` 仍然使用 url —— 即用户应当打开的、人类可读的主页地址。

占位符:{username}、{urlMain}、{urlSubpath}(与 url 一致)。示例:GitHub 的 url 是 https://github.com/{username},而 urlProbe 是 https://api.github.com/users/{username};Picsart 的网页主页是 https://picsart.com/u/{username},探测地址则是 https://api.picsart.com/users/show/{username}.json。

警告

url 必须始终是人类可打开的主页——它会展示给用户,并作为可点击的结果链接输出。切勿在 url 中放入 API / JSON / GraphQL 端点;那应放在 urlProbe 中。如果检查需要 API 端点,请在 url 中保留可浏览的主页,并将 API 地址作为 urlProbe(例如 Weibo:url 为 https://weibo.com/n/{username},``urlProbe`` 为 https://weibo.com/ajax/profile/info?screen_name={username})。

实现位置:checking.py 中的 make_site_result。

借助 LLM 修复站点检查

备注

仓库根目录下的 LLM/ 目录中,以 Markdown 形式保存了编辑站点检查的详细指南:检查清单、关于 checkType / data.json / urlProbe 的完整说明、处理误报的方法、寻找公开 JSON API 的思路,以及面向 socid_extractor 的改动提案日志。

主要文件:

每当代码或 data.json 中的检查逻辑发生改动时,这些文件都应同步更新。

激活机制

激活机制用于向那些需要额外认证(cookie、JWT token、自定义请求头等)的站点发起请求。

我们以 Maigret 数据库中的 Vimeo 站点检查记录为例:

"Vimeo": {
    "tags": [
        "us",
        "video"
    ],
    "headers": {
        "Authorization": "jwt eyJ0..."
    },
    "activation": {
        "url": "https://vimeo.com/_rv/viewer",
        "marks": [
            "Something strange occurred. Please get in touch with the app's creator."
        ],
        "method": "vimeo"
    },
    "urlProbe": "https://api.vimeo.com/users/{username}?fields=name...",
    "checkType": "status_code",
    "alexaRank": 148,
    "urlMain": "https://vimeo.com/",
    "url": "https://vimeo.com/{username}",
    "usernameClaimed": "blue",
    "usernameUnclaimed": "noonewouldeverusethis7"
},

其激活方法如下:

def vimeo(site, logger, cookies={}):
    headers = dict(site.headers)
    if "Authorization" in headers:
        del headers["Authorization"]
    import requests

    r = requests.get(site.activation["url"], headers=headers)
    jwt_token = r.json()["jwt"]
    site.headers["Authorization"] = "jwt " + jwt_token

当 JWT token 失效时,激活流程的工作方式如下:

  1. 站点检查会带上失效的 token,向 urlProbe 发起 HTTP 请求

  2. 响应中包含 activation / marks 字段指定的错误信息

  3. 一旦检测到该错误,就会触发 vimeo 对应的激活函数

  4. 激活函数获取新的 JWT token,并将其更新到该站点检查记录中

  5. 下一次站点检查(无论是重试还是新一次 Maigret 运行)使用新的有效 token,检查随即成功

第 5 步之所以能跨多次运行生效,是因为获取到的令牌被写入每个用户各自的缓存,而不是写回站点数据库——参见 激活令牌缓存。

激活机制的实现示例位于 activation.py 中。

如何发布新版本的 Maigret

需要协作者权限,请联系 Soxoj 获取。

发布新版本时,需要先在仓库中创建一个新分支,提升版本号并补全实际的changelog;然后再创建一次 release,GitHub Action 会自动构建并发布一个新的PyPI 包。

1. Make a new branch locally with a new version name. Check the current version number here: https://pypi.org/project/maigret/. Increase only patch version (third number) if there are no breaking changes.

git checkout -b 0.4.0

2. Update Maigret version in three files manually. All three must be in sync — an earlier bump missed docs/source/conf.py and it fell behind by a release.

  • pyproject.toml — single line version = "X.Y.Z" under [tool.poetry]. Leave every [tool.poetry.dependencies] entry alone: python-bidi = "^0.6.3" is a third-party version that happens to look like ours.

  • maigret/__version__.py —— 一行 __version__ = 'X.Y.Z'。

  • docs/source/conf.py —— 两个 Sphinx 字段。release 为完整版本号('X.Y.Z');version 为简写的 major.minor('X.Y',不包含补丁号)。两处都要更新。

snap/snapcraft.yaml is not in this list any more. It carries adopt-info: maigret and reads the version out of maigret/__version__.py at build time, so it follows automatically.

After editing, check that nothing was missed:

grep -rn '<old version>' --include='*.toml' --include='*.py' \
  --include='*.yaml' --include='*.yml' . | grep -v CHANGELOG
  1. 在 CHANGELOG.md 文件开头新增一个空的段落,并写入当天日期:

## [0.4.0] - 2022-01-03
  1. 获取自动生成的 release notes:

  • 打开 https://github.com/soxoj/maigret/releases/new

  • 点击 Choose a tag,输入 v0.4.0(你的版本号)

  • 点击 Create new tag

  • 点击 + Auto-generate release notes

  • 复制下方描述文本框中的全部内容

  • Paste it to empty text section in CHANGELOG.md

  • 关闭新建 release 的页面

Keep the ## What's Changed heading, the ## New Contributors section and the **Full Changelog** line — every existing section in CHANGELOG.md has them.

The same notes can be generated without opening the browser, which is handy when you want them in a file:

gh api repos/soxoj/maigret/releases/generate-notes \
  -f tag_name=v0.4.0 -f previous_tag_name=v0.3.9 -f target_commitish=main \
  --jq '.body' > notes.md

This is the same generator the + Auto-generate release notes button calls, so the contributor handles are the real GitHub logins. Do not assemble the list from git log instead: authors who commit without a @users.noreply.github.com address have no derivable handle there, and guessing from the display name produces entries like @Danilo Salve that are not valid GitHub users.

  1. 提交所有改动,push 上去,并创建 pull request

git add -p
git commit -m 'Bump to YOUR VERSION'
git push origin head
  1. 合并 pull request

  2. 创建新的 release

  • 再次打开 https://github.com/soxoj/maigret/releases/new

  • 点击 Choose a tag

  • 按 v0.4.0 格式输入当前实际版本号

  • 点击 Create new tag

  • Set Target to your version branch (0.4.0), not main

  • 同时把当前版本号填入 Release title 字段

  • 点击 + Auto-generate release notes

  • 点击 "Publish release" 按钮

警告

Target defaults to main, and that default is wrong here. The tag must point at a commit that already contains the bumped version — otherwise the tag lands on the tip of main, where the version is still the previous one, and python-publish.yml builds and tries to upload a package with the old version number. PyPI rejects it as a duplicate and the release job fails.

This is why the release tags of v0.6.1, v0.6.2 and v0.6.3 all point at commits on their version branches rather than at main. Merging the pull request from step 6 first is not required — pointing the tag at the version branch is what matters.

To check a published tag: git show v0.4.0:maigret/__version__.py must print the new version.

The whole step can be done from the command line instead. --target takes a branch name or a commit SHA:

gh release create v0.4.0 --target 0.4.0 --title v0.4.0 --notes-file notes.md

If a release was published against the wrong target, delete it together with its tag and recreate it — the release: published event fires again and PyPI upload is retried:

gh release delete v0.4.0 --cleanup-tag --yes
gh release create v0.4.0 --target 0.4.0 --title v0.4.0 --notes-file notes.md
  1. 就这些 —— 接下来只需等候推送到 PyPI。你可以在 Action 页面查看进度:https://github.com/soxoj/maigret/actions/workflows/python-publish.yml

文档更新

文档由 docs 目录自动生成、自动部署。

手动更新文档的步骤如下:

  1. 修改 docs/source 目录下的某个 .rst 文件。

  2. 在 docs 目录下执行 python -m pip install -e . 进行安装。

  3. 在 docs 目录下的终端中运行 make singlehtml。

  4. 在浏览器中打开 build/singlehtml/index.html 查看效果。

  5. 如果你修改了任何英文 .rst 正文(而不只是代码块),请刷新各语言的翻译目录 —— 见下文 翻译 一节。跳过这一步会让非英语版本在被改动的字符串上静默回退到英文。

  6. 如果一切正常,提交并把改动推到 GitHub。

翻译

文档通过 Sphinx 的标准 gettext 工作流进行翻译。英文 .rst 文件是事实来源(source of truth);翻译以 .po 目录形式存放在 docs/source/locale/<lang>/LC_MESSAGES/ 之下(目前有 zh_CN 和 fr)。

修改任何英文 .rst 文件后,请刷新一次翻译目录,使已有的翻译与新字符串保持对齐:

cd docs
make intl-update LANG=zh_CN

该命令会先通过 sphinx-build -b gettext 重新生成 .pot 文件,再用 sphinx-intl update 把更新合并到各语言的 .po 文件中。新出现的英文字符串会以空的 msgstr "" 形式列出;发生变化的字符串则会被打上 #, fuzzy 标记,需要由译者复核并重新翻译。

在本地预览翻译后的构建:

make html-zh_CN
open build/html_zh_CN/index.html

CJK 转义空格的坑

reStructuredText 的内联标记(粗体、行内代码、超链接)要求两侧都有空白或标点才能正确闭合。英文里这是自然的:总会跟上一个空格或句点。但在中文 / 日文 / 韩文译文里,下一个字符常常是没有分隔符的 CJK 字符,docutils 就会发出如下警告:

<translated>:1: WARNING: Inline strong start-string without end-string.
<translated>:1: WARNING: Inline interpreted text or phrase reference start-string without end-string.

修复办法是在闭合标记与下一个 CJK 字符之间显式插入一个 RST 转义空格 —— 反斜杠加一个空格。在渲染后的 .rst 里写作 \<space>;在 .po 的 msgstr 内必须写作 \\<space>,因为 .po 解析器会吃掉一层反斜杠。

# WRONG — warning, markup leaks past the bold
msgstr "让**所有**检查请求通过指定代理"

# RIGHT — regular space breaks markup cleanly
msgstr "让 **所有** 检查请求通过指定代理"

# RIGHT — escape-space when no visual space is wanted
msgstr "让\\ **所有**\\ 检查请求通过指定代理"

同样的规则也适用于行内代码后紧跟 CJK 字符的情况,以及超链接后紧跟 CJK 圆括号的情况 —— 一律插入 \\<space>。修改任何 .po 文件后请运行 make html-zh_CN;这类警告只会在构建阶段暴露。

如需新增一种语言,执行 make intl-update LANG=<code>``(例如 ``ja、de、pt_BR)即可生成一份空目录。Read the Docs 会把每种语言作为一个独立项目发布,并挂在父项目下(参见 Localization 指南);维护者需要在 RTD 管理界面中创建一次该翻译项目,设置其语言,并将其标记为主项目 maigret 的翻译版本,这样才能启用语言切换器。

路线图

警告

该路线图需要更新,以反映项目当前状态以及未来计划。

https://i.imgur.com/kk8cFdR.png