知识星球 PDF 下载器 (zsxq-downloader)
适用场景
- 用户是某个知识星球成员,希望每天自动把星球内发布的 PDF 资料下载到本地。
- 目标星球需要微信扫码登录,且对高频机器人下载有限制。
关键约束(务必遵守)
- 微信扫码登录无法完全无人化:知识星球的登录二维码每次刷新且需用手机微信扫码。
本技能采用「会话保持」策略:
- 首次运行由真人扫码,登录态(cookie / localStorage / IndexedDB)持久化保存到本机 profile 目录。
- 后续每日运行复用该登录态,只要会话未过期即可无人值守。
- 一旦检测到被踢回登录页(会话过期或被风控),立即暂停并通过通知展示二维码给用户,
等待最多 10 分钟,超时则本次中止,绝不死循环重试、绝不暴力刷新。 -
反爬限速 / 风控对抗(核心):知识星球无公开"每日固定配额",限流是混合风控(接口 QPS + 短时间下载频次 + 账号行为 + IP)。脚本针对性措施(详见脚本风控类
DayQuota/BurstGuard/Backoff):
- 串行无并发:单个浏览器上下文单线程顺序下载,严禁多线程/多进程抢同一账号+IP(并发高直接 429)。
- 短窗口突发抑制(最易触发点):任意滑动 60 秒内下载个数不超过BURST_PER_MIN(默认 12,远低于实测 15-25 个就触发 429 的阈值);触及则自动补睡到窗口外(BurstGuard)。
- 单文件间隔随机抖动:默认MIN_INTERVAL=45~MAX_INTERVAL=180秒(45s-3min),加 8% 概率的偶发长停顿(额外 +30~90s),节奏不可预测,拟真人分心。
- 自然日上限 + 跨天重置:max_per_run(默认 199,落在个人号稳妥区 200-300 内留余量),按自然日计数(downloaded_state.json的daily字段),次日 0 点自动清零续下;避免"单次运行"语义误解。
- 429 / 限流退避:下载异常时判定疑似限流,触发指数退避(Backoff:60s→120s→240s…封顶 1800s),退避期间计入短窗口,不盲抢;成功后回落。
- 仅在工作时间下载:默认09:00-12:00, 13:30-18:00(午休除外),把 199 个摊到全天多段,避免短时间集中爆发;非时段自动 sleep 到下一时段起点再继续;可用ZSXQ_WORK_HOURS覆盖。
- 只下载「尚未下载过」的 PDF:以星球ID_文件名为唯一标识,存于 state 文件去重;启动时还会递归扫描D:\报告(含子目录)已存在的 PDF 自动计入已下载,杜绝重复抓取历史内容。
- 信息流滚动采用增量、带随机等待(等价于分页拉元数据,不触发下载风控),禁止瞬间拉到底。
- 使用 stealth 浏览器(真实 UA、指纹伪装、点击前随机微移鼠标),不要暴露 headless 特征。 -
行业分类与落盘(默认开启):
- 下载前按文件名/标题关键词模糊匹配到 12 个通用行业之一(classify_industry,顺序敏感:金融 > 半导体 > 科技互联网 > 汽车 > 制造 > 出海 > 消费 > 医疗 > 教育 > 文娱 > 地产 > 能源),未命中归「其他」。
- 落盘路径:D:\报告\<行业>\文件名(文件名原样,仅做 Windows 非法字符清洗,不要额外追加.pdf——download.suggested_filename已含扩展名,重复追加会生成.pdf.pdf这类 bug)。
- 命中率低时可重排INDUSTRY_KEYWORDS顺序或补充关键词(冲突词如「数字化」「电商」靠顺序兜底)。
依赖与工具
- 本技能使用 Playwright (Python) 直接驱动 stealth Chromium,无需 stealth-browser / agent-browser 等外部技能。
- 运行环境为 Windows,下载目录默认
D:\报告(不存在则创建)。 - 国内网络环境必须配置镜像源(默认 PyPI 源在沙箱中返回空响应):
- pip 安装:
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple playwright - Chromium 浏览器下载:
export PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright后执行python -m playwright install chromium - Git(如未来需要):从
https://npmmirror.com/mirrors/git-for-windows/下载
关键实现要点(已踩坑)
- 登录态判断:必须基于会话 cookie(
has_session:匹配 name 含 token/access/wsid/uid/session/zsxq/auth 且 value 长度>8 的 cookie,排除 sajssdk/sensorsdata/SERVER_ID 等埋点)。不能用"页面是否含微信扫码文本"判断——未登录时知识星球展示的是星球公开预览/加入页(显示"加入星球 ¥88"和部分文件预览),会误判为已登录。 - 二维码渲染流程(重要,已验证):
- gotohttps://wx.zsxq.com/login
- 勾选协议复选框(input[type='checkbox'])
- 点击绿色按钮button.get-qr-btn(class 为get-qr-btn,文字"获取登录二维码")
- 此操作会弹出协议确认 modal(含"我已阅读并同意知识星球《用户协议》"和同意/不同意按钮)
- 在 modal 中点击文字精确为"同意"的 BUTTON(用 JS evaluate 找innerText.trim()==='同意'且后代最少的元素,排除"我已阅读并同意"复选框 label 和"不同意"按钮)
- 等待约 4 秒,二维码图片加载到.qr-code-area(300×300 容器)
- 截图.qr-code-area元素到qr.png呈现给用户 - 扫码等待:在同一个 persistent context 中轮询
context.cookies(),每 5 秒检查一次has_session,最多 10 分钟;每 ~120 秒才重新渲染一次二维码(微信二维码约 5 分钟有效,过于频繁刷新反而导致用户刚看到的码立刻失效)。超时则暂停,不死循环。 - 下载触发(重要,已实测):
- 信息流中 PDF 附件的真实结构是:.file-pdf只是图标 div,真正可点击的文件项是其最近的.item祖先,文件名在.file-name内(直接读.file/ 读.file-pdf的 innerText 都是空的!)。
- 点击.file-pdf(或.item)会打开「同页预览弹窗」,弹窗内有<div class="btn download">下载文件</div>。
- 必须用受信的.click()(Playwright 元素句柄.click())去点.btn.download才能触发浏览器下载;用page.evaluate("el.click()")这种非受信点击会被浏览器拦截,下载事件永不触发。
- 下载用page.expect_download(timeout=20000)捕获,再download.save_as(dest)落盘到D:\报告。
- 关闭预览弹窗(极重要,已踩坑):.file-preview-container这个预览浮层没有关闭按钮,Escape 也关不掉。只能通过点击弹窗背景空白(内容区之外,例如对.file-preview-container元素做click(position={"x":8,"y":8}))来关闭。务必每次下载后关闭,否则悬浮层会拦截下一次点击(报subtree intercepts pointer events,每次 30s 超时,整批卡死)。脚本close_preview()已实现;关不掉则重载页面清理。 - 候选收集:
page.query_selector_all(".file-pdf")取全部图标;对每个用el.evaluate抽取最近 .item 内的 .file-name 文本与归属 .topic 的日期文本;按文件名去重;日期来自 topic 文本中的YYYY-MM-DD HH:MM。默认ZSXQ_DAYS_BACK=0即「不限日期,全部未下载的都纳入」,增量完全由 state 去重实现(scan_existing_downloads递归扫描整个D:\报告目录避免重复)。 - 独立扫码登录界面(自动弹窗):
browser_profile/login_console.html是一个自包含网页控制台,轮询同目录下的status.json(脚本实时写出)与qr.png,自动刷新二维码、显示倒计时与下载进度。需配合本地 HTTP 服务访问(file://因 CORS 无法 fetch)。启动方式见下「运行流程」第 0 步。
- ⚠️ Windows 路径坑:python -m http.server --directory必须传 Windows 原生路径(如C:/Users/...),传 Git-Bash 风格/c/Users/...会被 Windows 版 Python 当作非法路径导致全部 404。
运行流程
- (可选但推荐)启动扫码登录控制台:先起本地 HTTP 服务托管
browser_profile/目录,再打开控制台页面。
- 服务命令(注意 Windows 原生路径!):
python -m http.server 8771 --bind 127.0.0.1 --directory C:/Users/用户名/.workbuddy/skills/zsxq-downloader/browser_profile
- 打开http://127.0.0.1:8771/login_console.html—— 自动轮询status.json与qr.png,
实时显示二维码、倒计时与下载进度("自动弹窗 + 独立界面")。
- 一键脚本:scripts/open_console.bat(自动起服务并弹出默认浏览器窗口)。 - 读取配置:
group_url、download_dir、state_file、profile_dir、max_per_run、scan_wait_seconds。
1.5 正式下载前建议先simulate试跑:python scripts/zsxq_download.py simulate—— 登录→加载信息流→收集→去重→仅输出「将下载清单与数量」,不下载、不改状态。确认目标星球、数量与上限无误后再执行run,避免误下/漏下。 - 启动 stealth 浏览器,加载持久化 profile(
profile_dir),accept_downloads=True。 - 导航到
group_url,增量滚动加载内容(每屏随机等待)。 - 判断登录态:
- 未登录 → 渲染二维码(写入qr.png+status.jsonphase=waiting_scan)→ 控制台/通知展示 →
轮询等待最多scan_wait_seconds(每 120s 刷新二维码)→ 用户扫码成功后继续。
- 已登录:直接进入第 5 步。 - 增量抓取:
.file-pdf取全部图标 → 抽取.file-name文件名与归属.topic日期 → 启动扫描D:\报告已存在文件 → 按星球ID_文件名去重(默认不限日期,全部未下载的都纳入;ZSXQ_DAYS_BACK>0可限定最近 N 天)。 - 逐个下载:真实
.click()点开.file-pdf打开预览弹窗 → 真实.click()点.btn.download触发下载 →
expect_download落盘到download_dir\<行业>\文件名(文件名原样,勿重复加.pdf)→ 关闭预览弹窗(点背景空白)。
两次下载之间调用human_sleep_between_downloads(45-180s 随机 + 抖动 + 偶发长停顿 + 短窗口抑制 + 工作时段检查)。
达到max_per_run(每自然日 199)即停止本次运行(剩余留待次日续下)。 - 收尾:更新 state 文件(记录已下载标识 + 时间),写
status.jsonphase=done,输出本次新增清单与总数。 - 异常:
- 会话失效 → 控制台/通知展示二维码,等待用户扫码。
- 单文件下载失败 → 记录并跳过,不阻塞整体。
- 今日无新增 PDF → 输出"今日无新增",正常结束(非错误)。
- 触发限速/验证码 → 本批次立即停止,提示次日继续,不硬扛。
配置项(可在调用时覆盖)
group_url: https://wx.zsxq.com/group/51128448884844download_dir: D:\报告state_file: <profile_dir>/downloaded_state.jsonprofile_dir: ~/.workbuddy/skills/zsxq-downloader/browser_profilemax_per_run: 199(每自然日上限,环境变量ZSXQ_MAX覆盖;个人号稳妥区 200-300 内留余量)BURST_PER_MIN: 12(任意 60s 内最多下载数,短窗口突发抑制,ZSXQ_BURST_PER_MIN覆盖;远低于 15-25 触发阈值)scan_wait_seconds: 600ZSXQ_MIN_INTERVAL/ZSXQ_MAX_INTERVAL: 单次下载间隔下限/上限(默认 45/180 秒 = 45s-3min,含抖动+偶发长停顿)ZSXQ_BACKOFF_BASE/ZSXQ_BACKOFF_MAX: 限流退避起始/上限(默认 60/1800 秒,指数翻倍)ZSXQ_WORK_HOURS: 工作时间窗口,默认"9:0-12:0,13:30-18:0"ZSXQ_DAYS_BACK: 0(默认不限日期,全部未下载的都纳入;设为 2 可仅回看最近 2 天,用于限定补抓)- 运行命令:
python scripts/zsxq_download.py simulate(试跑确认)|run(每日正式下载)|check(检测登录态) - 目标星球固定为【全球行业报告库】
group/51128448884844,登录后verify_joined校验「已加入」(检测"加入星球"文字、命中星球名即通过)。
状态文件格式 (downloaded_state.json)
{
"last_run": "2026-08-23T09:00:00+08:00",
"downloaded": {
"51128448884844_主题ID_文件名.pdf": "2026-08-23T09:05:12+08:00"
}
}
安全与合规提示
- 仅用于下载本人已加入星球的资料、个人留存用途,请勿批量转发或商用。
- 保持低频率、真人节奏,降低账号被限流/封禁风险。
- 登录态含敏感凭证,profile 目录不要提交到公开仓库。