toutiao-writer 架构复盘:从 0 到稳定运行

第一次部署 toutiao-writer 那晚,日志里成片出现 302 跳转与“登录态已失效”,我才意识到它根本不是爬虫,而是一个需要维护长会话、处理反爬、保持发布节奏的有状态客户端。接下来三个月,我们把它从一锅脚本炖成了一栋能单节点稳定运行的小楼。

副标题:项目日记与月度报告 · 独立开发日记

本文为 vergex.cn 原创首发,转载请注明出处。

第一次部署 toutiao-writer 那晚,日志里成片出现 302 跳转与“登录态已失效”,我才意识到它根本不是爬虫,而是一个需要维护长会话、处理反爬、保持发布节奏的有状态客户端。接下来三个月,我们把它从一锅脚本炖成了一栋能单节点稳定运行的小楼。

1. 先摆正定位:它不是爬虫,是有状态发布客户端

很多人对内容发布助手的第一个直觉是“抓个接口、拼个表单、POST 过去”。但头条创作者后台的登录态由多层票据共同维持:前端缓存的 ttwid、Cookie 里的 sessionid、接口签名 X-SS-Stub / __ac_signature,以及页面 JavaScript 动态生成的 msToken。这些票据有生命周期,互相依赖,而且账号之间不能混用。

因此,我们在架构里把“账号”作为一等公民。每个账号对应一个独立的浏览器 Context(Playwright 的 BrowserContext),里面有独立的 Cookie、LocalStorage、IndexedDB 和指纹。调度器只负责把任务分配给某个账号的队列,真正维护登录态的是 worker 里的 AccountSession 对象。

关键设计是“会话预热 + 心跳保活”。worker 启动时会先检查每个账号的登录页,看是否需要二次验证或滑块;每 10 分钟访问一次创作者中心首页,刷新票据有效期。如果某个账号出现连续 401/403,就立即把该账号标记为 needs_reauth,并把它下面的任务全部转回队列,而不是让其他账号“顺便”顶上去——跨账号复用 Cookie 是我们早期踩过最痛的坑,会触发平台的风控关联。

完整版架构图与源码可在站点下载,下面只放简化后的核心思路。

2. 凭据管理:从明文 Cookie 到加密令牌桶

上线第一周,有同事在日志里直接打印了 Cookie: sessionid=xxx,我当晚就撤掉了所有脚本,重写凭据层。内容平台的 Cookie 本质上就是“密码”,不能以任何明文形式落在磁盘、环境变量或日志里。

我们的方案是用 age(Filippo Valsorda 的现代加密工具)做静态加密,运行时解密到内存,程序退出即销毁。生产环境只保留公钥用于加密新凭据,私钥通过 KMS 或安全注入,不进入镜像。


# 1. 生成密钥对,私钥交给 KMS 或安全注入
age-keygen -o writer.key

# 2. 用公钥加密账号凭据文件
age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrjn2hx5ydc4q7lnp2g \
    -o accounts.json.age accounts.json

# 3. 运行前解密到 stdout,通过管道注入程序,不落盘
age -d -i /run/secrets/writer_priv accounts.json.age | python -m toutiao_writer

代码里再用 Python 的 pydantic.SecretStr 包裹敏感字段,打印对象时只显示 ********。另外,我们规定任何包含凭据的结构体都不实现明文 __repr__,避免 Sentry 等错误追踪平台无意收集。

轮换策略也很关键:每隔 30 天,脚本会自动刷新一次 ttwid 和 sessionid,并用新的公钥重新加密文件提交到私有仓库。这样即使旧密文泄露,里面的票据也已失效。

3. 请求链路:双层降级,不要把鸡蛋放一个篮子里

创作者后台的接口分两类:一类是页面渲染依赖的 HTML/JS,一类是发布流程调用的私有 API。我们把整条链路设计成 L0 → L1 → L2 的降级结构:

层级 实现方式 可靠性 速度 维护成本 适用场景
L0 Playwright 页面自动化 高 慢(5–15s/步) 中 登录、滑块、图文混排发布
L1 私有 API + 有效签名 中 快(<1s) 高 常规图文/视频发布
L2 人工兜底队列 极高 极慢 低 风控拦截、平台改版

L1 的问题是签名算法会随前端版本迭代变化。我们并不尝试在 Python 里复刻整个签名生成逻辑,而是在浏览器侧注入一段 JS,调用页面全局对象里现成的签名函数,把结果通过 page.evaluate 传回 Python。这样既享受了 API 的速度,又避免了自己维护一个永远追不上官方的签名库。

L2 兜底队列目前只在两种情况下触发:一是连续 3 次发布返回需要人工二次确认(如品牌合作声明),二是 24 小时内同一账号触发 5 次以上滑块。人工队列的数据结构非常简单,就是 pending_manual_review 表,运营在后台点一下“确认发布”即可重入 L0。

4. 反爬与流量整形:WAF 不是敌人,节奏才是

内容平台的反爬系统对“快”和“乱”最敏感。我们早期失败率高,不是因为请求被识别成机器人,而是因为并发太高、时间分布太均匀、UA 变得太频繁。解决后,我们把流量整形拆成三条规则:

  • 账号级并发度严格为 1:每个账号同一时刻只能有一个活跃任务。用 asyncio.Semaphore(value=1) 绑定到 AccountSession 实例,而不是全局锁。
  • 全局令牌桶 + 抖动:每账号每秒最多 0.5 个请求,抖动用 random.expovariate 生成,避免固定周期。
  • 指纹一致性:每个账号固定 User-Agent、视口、时区、navigator.language,不随任务变化。

import asyncio, random, time
from dataclasses import dataclass, field

@dataclass
class AccountThrottle:
    name: str
    sem: asyncio.Semaphore = field(default_factory=lambda: asyncio.Semaphore(1))
    last_call: float = field(default=0.0)
    min_interval: float = 2.0  # 每秒最多 0.5 次

    async def acquire(self):
        await self.sem.acquire()
        jitter = random.expovariate(1.0 / self.min_interval)
        sleep = max(0, self.min_interval + jitter - (time.monotonic() - self.last_call))
        if sleep > 0:
            await asyncio.sleep(sleep)

    def release(self):
        self.last_call = time.monotonic()
        self.sem.release()

Playwright 启动参数也做了微调,但不要堆砌无意义 flag。我们只用:


context = browser.new_context(
    viewport={"width": 1280, "height": 720},
    user_agent=account.ua,
    locale="zh-CN",
    timezone_id="Asia/Shanghai",
)
page = context.new_page()
page.add_init_script("""
    Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
""")

这里有个避坑点:不要通过 launch_args 传 --disable-blink-features=AutomationControlled 后,又在 navigator.webdriver 上留 true。前后矛盾会让风控模型把你标成“试图隐藏自动化特征的低质量脚本”。我们的经验是,统一把 webdriver 置为 undefined 并关闭 enable-automation 参数后,同样的账号在两周内的验证触发次数明显下降——但具体比例因账号权重而异,建议只观测自己账号的滑块触发率,不要照搬任何所谓“成功率数字”。

5. 任务队列与幂等:为什么 SQLite 差点让我们翻车

第一个版本用 SQLite + WAL 模式做队列。单机跑没问题,但一旦 scheduler 和 worker 同时写入,即使开了 WAL 也会遇到 database is locked。换成 Postgres 后,我们又踩了第二个坑:用自增 ID 当幂等键,导致任务重试时重复发布同一篇文章。

内容发布必须是幂等的。我们的幂等键不是数据库 ID,而是 平台 + 账号 + 内容 slug + 时间窗口。数据库层面用部分唯一索引保证同一状态下只能有一个待执行或执行中的任务:


CREATE TABLE publish_job (
    id BIGSERIAL PRIMARY KEY,
    platform TEXT NOT NULL,
    account_id TEXT NOT NULL,
    content_slug TEXT NOT NULL,
    time_window DATE NOT NULL,
    status TEXT NOT NULL CHECK (status IN (
        'queued', 'running', 'await_review', 'done', 'failed'
    )),
    payload_hash TEXT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_unique_active_job
ON publish_job (platform, account_id, content_slug, time_window)
WHERE status IN ('queued', 'running', 'await_review');

payload_hash 用于检测内容是否被运营修改。如果同一 slug 的内容变了,我们并不直接覆盖旧任务,而是把旧任务标记为 superseded,再创建一个新任务。这样审计链完整,也避免“我以为更新了,其实发的是旧版”的惨案。

另一个避坑点:不要对 status 做全表唯一索引,否则 done 状态会阻止未来同一篇文章的再次发布(比如修改后重发)。部分索引只锁活跃任务,finished 的任务可以重复出现。

6. 可观测性:不能只追状态码,要追踪发布生命周期

早期日志只有 POST /publish -> 200,但运营经常问“为什么文章显示发布成功,后台却找不到?”后来我们把一次发布拆成 8 个阶段:queued → session_warmed → preflight_passed → draft_saved → media_uploaded → submit_clicked → audit_polling → done / failed。

每个阶段都记录 trace_id、account_id、stage、duration_ms、next_stage。用 Python 的 structlog 输出 JSON,直接丢进 Loki。关键规则:任何日志都不允许包含完整 Cookie、Token 或手机号。我们写了一个 redact 中间件,命中 sessionid|ttwid|msToken|phone 等关键字的字段自动替换成 <REDACTED>。

监控指标分三类:

  • 业务指标:发布成功率、各阶段耗时 P99、人工审核占比。
  • 账号健康指标:登录态失效次数、滑块触发次数、连续失败账号数。
  • 系统指标:worker 内存、浏览器 Context 数量、队列深度。

这里再提醒一个避坑点:不要用浏览器自动化里的 page.content() 截取整页 HTML 作为错误日志。创作者后台页面很大,且可能包含用户隐私数据;同时大段 HTML 会让日志系统索引爆炸。我们只在失败时记录 page.url、关键 DOM 元素的文本(如错误提示),以及控制台的最近 20 条消息。

7. 部署与交付:单节点也能跑成“集群”

我们不想一开始就上 K8s,所以选择了 Docker Compose 单节点部署,但通过进程职责拆分和重启策略,让系统具备一定的故障自愈能力。服务拆分如下:

  • scheduler:定时扫描内容库,把到期内容生成 publish_job。
  • worker:消费队列,维护账号会话,执行发布。
  • browser-pool:长期运行一个 Playwright 浏览器实例,worker 通过 ws://browser-pool:3000 连接,减少浏览器冷启动时间。
  • db:Postgres 16。
  • otel-collector:可选,收集 trace 和 metrics。

docker-compose.yml 关键片段:


services:
  worker:
    image: toutiao-writer:latest
    command: ["python", "-m", "toutiao_writer.worker"]
    env_file: .env
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: '1.5'
          memory: 1G
    depends_on:
      db:
        condition: service_healthy
      browser-pool:
        condition: service_started

  browser-pool:
    image: mcr.microsoft.com/playwright:v1.48.0-jammy
    command: [
      "playwright", "run-server",
      "--port", "3000",
      "--host", "0.0.0.0"
    ]
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: '2.0'
          memory: 2G

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: writer
      POSTGRES_PASSWORD_FILE: /run/secrets/db_pass
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U writer -d writer"]
      interval: 10s
      timeout: 3s
      retries: 5

volumes:
  pgdata:

浏览器池单独部署后,worker 崩溃重启不会连带关闭浏览器;worker 用 async_playwright().start() 连接远程 browser,连接串通过环境变量注入。资源限制必须加,我们曾经因为某个 worker 内存泄漏把整机拖垮,memory: 1G 加上 Docker 的 OOM kill 反而让服务自动恢复了。

完整版源码(含 Docker Compose、migrations、可运行的 worker 模板)请在站点下载。

8. 反模式清单与自查表

如果你也在做类似的有状态发布客户端,发布前建议逐条核对:

  • [ ] 不在日志、配置文件、Git 仓库中存放明文 Cookie 或密码。
  • [ ] 每个账号使用独立的浏览器 Context,不混用 Cookie。
  • [ ] 幂等键不依赖数据库自增 ID,而是业务唯一标识。
  • [ ] 对 running / queued 状态使用部分唯一索引,而不是全表唯一索引。
  • [ ] 账号级并发度设为 1,并用令牌桶控制全局节奏。
  • [ ] 失败后不立即无限制重试,而是按指数退避进入冷却。
  • [ ] 日志中对敏感字段做 redaction,不截全页 HTML。
  • [ ] 部署时给每个容器加资源限制和 healthcheck。
  • [ ] 平台改版或签名算法更新时,先切到 L0 页面自动化兜底。
  • [ ] 保留人工审核队列,作为最后一道防线。

核心总结:toutiao-writer 稳定运行的关键,不是某个神奇的反爬技巧,而是把“账号会话、凭据安全、流量节奏、幂等队列、可观测性”五个基础面做成可审计、可回滚的工程实践。基础面稳了,发布成功率自然会上去。

技术深度解析

原创《AI 开发者工具调研报告》(月度大杀器)

2026-10-22 20:18:56

技术深度解析

DeepSeek到底什么意思?全面解析与实战教程

2026-10-24 18:18:02

0 条回复 A文章作者 M管理员
VergeX|科技前沿
    暂无讨论,说说你的看法吧
❯
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索