从零搭建 AI 自动化头条发布流水线:热点 → 写作 → 配图 → 质检 → 发布全链路(自动赚钱)

本文详解面向今日头条号的AI内容自动化发布流水线,涵盖扫码登录态获取、Cookie安全注入、DOM级页面操作、防重复发布机制及五项质量门禁。重点突破App-Bound Encryption限制,实现稳定登录态管理;通过归一化标题查重与退出码驱动的硬性校验,保障发布安全;结合定时调度与多源热点验证,构建可复用、可审计、合规的内容生产系统。

一条可重复执行的每日内容流水线:抓当天热点、写成"不像 AI 写"的文章、配三张无水印图、过质量门禁、自动发布到头条号。本文重点讲清与头条号的三种连接方式、发布页的完整操作链路和防重复发布设计。文中所有代码均已脱敏,不含任何账号、密码与 Cookie 值,可直接复用。


一、整体架构:五步流水线

Step1 抓热点  ──►  Step2 写作(去AI味铁律)  ──►  Step3 配图(去水印)
      │                                              │
      ▼                                              ▼
Step5 发布(playwright)  ◄──  Step4 质量门禁(五项全过才准发)

核心设计原则:质量门禁先于发布。 发布前必须跑自检脚本,五项全过才能发;发布脚本自带同名查重,查重失败宁可中止也不放行。这是与"生成完就发"的根本区别。


二、环境准备

pip install playwright pillow
python -m playwright install chromium

目录约定:

<工作区>/
├── .workbuddy/
│   └── toutiao_session.json      # 登录态(cookie),敏感文件,勿提交 git
└── 头条草稿/
    ├── 2026-10-09.md             # 当天 A 篇(无后缀)
    ├── 2026-10-09_1.png          # A 篇配图 1/2/3
    ├── 2026-10-09_B.md           # 当天 B 篇
    └── 2026-10-09_B_1.png

易踩坑:Windows 文件名大小写不敏感,同一天多篇时 _b 会静默覆盖 _B。约定 A=无后缀、B=_B、C=_C,写盘前先 ls 校验。


三、核心:与头条号的三种连接方式

3.1 前置认知:为什么不能直接读浏览器 Cookie

新版 Chrome/Edge 启用了 App-Bound Encryption:会话级 Cookie 由系统级密钥加密后写入 Default/Network/Cookies 数据库,外部进程读出来是密文,直接拿去请求会被判无效。

结论:必须在已登录的浏览器内部把明文 Cookie 导出来(浏览器插件可以),或让自动化浏览器自己走一遍扫码登录。这就是下面三种方案的设计依据。

3.2 方式一:扫码抓登录态(推荐,一次约 2-3 个月过期)

原理:Playwright 启动有头浏览器 → 打开头条号创作平台 → 你扫码登录 → 脚本每秒轮询 Cookie 列表,数量达标且出现关键登录 Cookie 即判定成功 → 落盘 JSON。

关键判定字段:Cookie 数量 > 12,且名称中含 sessionid / passport_csrf / sid_tt 任一。

完整代码(capture_session.py):

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""交互式扫码抓取头条号登录态,保存到 <工作区>/.workbuddy/toutiao_session.json"""
import json, time, os, sys
from pathlib import Path

WORKSPACE = Path(__file__).resolve().parent
_env_ws = os.environ.get("TOUTIAO_WORKSPACE", "").strip()
if _env_ws:
    WORKSPACE = Path(_env_ws).expanduser().resolve()
SESSION_FILE = WORKSPACE / ".workbuddy" / "toutiao_session.json"

def main():
    from playwright.sync_api import sync_playwright
    try:
        wait_seconds = int(os.environ.get("TOUTIAO_LOGIN_WAIT", "180"))
    except ValueError:
        wait_seconds = 180

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)   # 必须 headed,要扫码
        context = browser.new_context(viewport={"width": 1400, "height": 900})
        page = context.new_page()
        page.goto("https://mp.toutiao.com", wait_until="domcontentloaded")
        print(f"请扫码登录头条号,{wait_seconds} 秒内自动检测并保存。", flush=True)

        saved = False
        for _ in range(wait_seconds):
            time.sleep(1)
            try:
                cookies = context.cookies()
                names = [c["name"] for c in cookies]
                if len(cookies) > 12 and any(
                    ("sessionid" in n) or ("passport_csrf" in n) or ("sid_tt" in n)
                    for n in names
                ):
                    out = {
                        "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                                      "AppleWebKit/537.36 (KHTML, like Gecko) "
                                      "Chrome/126.0.0.0 Safari/537.36",
                        "cookies": cookies,
                        "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
                    }
                    SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
                    SESSION_FILE.write_text(
                        json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
                    print(f"OK: 已保存 {len(cookies)} 条 cookie 到 {SESSION_FILE}", flush=True)
                    saved = True
                    break
            except Exception:
                pass

        if not saved:
            print("未检测到登录成功,请改用 Cookie-Editor 手动导出后覆盖该 JSON。")
        time.sleep(3)
        browser.close()
    sys.exit(0 if saved else 1)

if __name__ == "__main__":
    main()

运行:

python scripts/capture_session.py
# 可用 TOUTIAO_WORKSPACE 指定工作区;TOUTIAO_LOGIN_WAIT 指定等待秒数

3.3 方式二:Cookie-Editor 手动导出(无桌面环境/服务器)

  1. 浏览器安装 Cookie-Editor 扩展;
  2. 登录 https://mp.toutiao.com;
  3. 打开扩展 → Export → JSON(Header/Export format 选 JSON);
  4. 转成下面的统一格式,覆盖写入 toutiao_session.json:
{
  "user_agent": "浏览器 UA 字符串",
  "cookies": [
    {
      "name": "cookie 名",
      "value": "cookie 值",
      "domain": ".toutiao.com",
      "path": "/",
      "expirationDate": 1790000000,
      "httpOnly": true,
      "secure": true,
      "sameSite": "Lax"
    }
  ],
  "updated_at": "2026-10-09T08:00:00"
}

字段说明:user_agent 必须与导出用的浏览器一致;sameSite 取 Lax/Strict/None 之一。这个文件等于登录态本体,等同密码,不要提交到 git、不要贴到任何公开地方。

3.4 方式三:复用本机浏览器 profile(兜底,成功率低)

把本机已登录的 Edge/Chrome profile 复制到临时目录,用 launch_persistent_context 打开。受 App-Bound Encryption 限制,对创作者后台的深层鉴权大概率失败,只作降级尝试。

ctx = p.chromium.launch_persistent_context(
    user_data_dir=str(TMP_PROFILE_DIR),
    executable_path=exe_path,
    headless=False,
    args=["--no-first-run", "--no-default-browser-check"],
)

复制时要排除 Cache / GPUCache / Service Worker / Local Storage 等目录,并跳过 Local State 复制失败的容忍处理,否则经常整目录拷不过去。

3.5 登录态注入与反检测(发布脚本的核心)

发布脚本拿到 toutiao_session.json 后这样建立会话:

_SS = {"lax": "Lax", "strict": "Strict", "none": "None", "no_restriction": "None"}

def cookies_to_playwright(cookies):
    out = []
    for c in cookies or []:
        item = {
            "name": c.get("name"),
            "value": c.get("value"),
            "domain": c.get("domain", ".toutiao.com"),
            "path": c.get("path", "/"),
        }
        exp = c.get("expirationDate") or c.get("expires")
        if exp is not None:
            try: item["expires"] = float(exp)
            except Exception: pass
        item["httpOnly"] = bool(c.get("httpOnly", False))
        item["secure"]   = bool(c.get("secure", False))
        ss = c.get("sameSite")
        if ss and str(ss).lower() in _SS:
            item["sameSite"] = _SS[str(ss).lower()]
        out.append(item)
    return out

def open_session_context(session):
    p = sync_playwright().start()
    browser = p.chromium.launch(headless=True, args=[
        "--no-sandbox", "--disable-dev-shm-usage",
        "--disable-blink-features=AutomationControlled"])
    context = browser.new_context(
        user_agent=session.get("user_agent") or DEFAULT_UA,
        viewport={"width": 1280, "height": 900}, bypass_csp=True)
    context.add_cookies(cookies_to_playwright(session.get("cookies")))
    page = context.new_page()
    # 反检测:抹掉 webdriver 痕迹、伪造网络环境
    page.add_init_script("""
        Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
        Object.defineProperty(navigator, 'connection', {
            get: () => ({effectiveType: '4g', saveData: false, type: 'wifi',
                         downlink: 10, downlinkMax: 10, rtt: 50})});
        if (!window.chrome) window.chrome = { runtime: {} };
    """)
    page.goto("https://mp.toutiao.com", wait_until="domcontentloaded", timeout=30000)
    time.sleep(3)
    if "login" in page.url.lower():
        print("Cookie 已过期,需重新抓取登录态")
        return None
    return p, context, page

登录态有效期的判定:不是看 Cookie 过期时间,而是打开首页后 URL 是否被重定向到登录页(login / passport 出现在 URL 中)。实测 Cookie 约 75 天就会失效,建议定时任务每周检测一次、失效即扫码刷新。


四、草稿格式与配图规范

4.1 草稿文件格式

---
date: 2026-10-09
选题: 事件描述
来源热度: 来源 + 日期
合规说明: 合规处理说明
配图路径: 文件名1, 文件名2, 文件名3
---
# 文章标题(18-28 字)

正文第一段。

正文第二段。

三条硬规则:

  1. 第一行必须是 # 标题,且正文区严禁再出现其它一级标题,否则会被当成草稿元数据解析;
  2. 正文不含任何 Markdown——不加粗、不用列表符、不用引用,任何标记都会原样展示给读者;
  3. 标题超过 30 字会被头条拒绝,脚本自动截断并告警。

4.2 配图与去水印

AI 生图工具会在右下角叠加"图片由 AI 生成"标识,保存后必须裁掉:

from PIL import Image
im = Image.open(src)
w, h = im.size
# 裁掉底部约 60px 水印区,再缩放回原尺寸,肉眼无差
im.crop((0, 0, w, h - 60)).resize((w, h), Image.LANCZOS).save(dst)

命名规则:与草稿同名 + _1/_2/_3.png(如 2026-10-09_1.png),脚本按前缀自动关联,最多取 3 张。


五、发布流程全解(页面选择器 + API 监听)

发布页地址:

https://mp.toutiao.com/profile_v4/graphic/publish?from=toutiao_pc

5.1 关键 URL 与接口

用途地址
图文发布页https://mp.toutiao.com/profile_v4/graphic/publish
作品管理页(查重/校验)https://mp.toutiao.com/profile_v4/manage/content/all
发布提交接口POST /mp/agw/article/publish(页面不跳转,以 API 200 为成功标志)

5.2 关键 DOM 选择器

元素选择器 / 定位方式
标题输入框textarea[placeholder*="标题"]
正文编辑器.ProseMirror(contenteditable)
封面模式切换.article-cover-radio-group 内文本 三图 / 单图
封面添加槽位.article-cover-add
图片上传抽屉.byte-drawer-wrapper(内含 input[type=file])
抽屉确定按钮抽屉内文本含 确定/完成/确认 且未 disabled
发布按钮文本 预览并发布,兜底 .publish-btn-last
二次确认文本 确认发布

5.3 执行顺序(每一步的坑与对策)

① 打开发布页并清障

page.goto(GRAPHIC_PUBLISH_URL, wait_until="load", timeout=45000)
time.sleep(6)
# 头条的 AI 助手抽屉遮罩会拦截标题/正文/封面的所有点击,先隐藏
page.evaluate("""() => {
    document.querySelectorAll('.byte-drawer-wrapper, .byte-drawer-mask')
        .forEach(e => { e.style.display='none'; });
}""")

② 会话失效二次校验:仅以 URL 是否跳到 passport/login 判断,页面含"登录"字样不算。

③ 填标题:用 type() 逐字输入以触发框架 dirty 状态;输完回读校验,中文引号可能被输入框截断,不一致时用 JS 兜底:

t = page.locator('textarea[placeholder*="标题"]').first
t.click(); t.fill(""); t.type(title, delay=15)
actual = t.input_value()
if actual != title:
    escaped = json.dumps(title)
    page.evaluate(f"""() => {{
        const el = document.querySelector('textarea[placeholder*="标题"]');
        if (el) {{ el.value = {escaped};
            el.dispatchEvent(new Event('input', {{bubbles:true}}));
            el.dispatchEvent(new Event('change', {{bubbles:true}})); }}
    }}""")

④ 填正文:.ProseMirror 是 contenteditable,fill() 不生效,必须模拟键盘:

ed = page.locator(".ProseMirror").first
ed.click()
page.keyboard.press("Control+A"); page.keyboard.press("Delete")
page.keyboard.type(body, delay=5)

⑤ 上传三图封面(最脆的一环):

# 选三图模式
page.locator('.article-cover-radio-group').locator('text=三图').first.click()
for img in images[:3]:
    page.locator(".article-cover-add").first.click()
    # 只取「当前可见抽屉内」的 file input(先算全局 index 再 nth)
    idx = page.evaluate("""() => {
        const all = Array.from(document.querySelectorAll('input[type="file"]'));
        const d = Array.from(document.querySelectorAll('.byte-drawer-wrapper'))
            .find(el => window.getComputedStyle(el).display !== 'none');
        if (!d) return 0;
        const inner = d.querySelector('input[type="file"]');
        return inner ? all.indexOf(inner) : 0;
    }""")
    page.locator('input[type="file"]').nth(idx).set_input_files(img)
    # 轮询等抽屉内"确定"按钮可点后点击(最长 20s)

三个必守纪律:

  • 绝不用 remove() 删抽屉节点——会连带销毁其中的 input[type=file],导致后续 set_input_files 全部超时;Esc 关不掉时只能 display:none 隐藏;
  • 每张图开始前都要关一次抽屉,否则残留遮罩会拦截下一张的点击;
  • clear_overlays() 的调用时机:只在点"发布/确认发布"前调用,不要在点 .article-cover-add 之前调用。

⑥ 监听发布 API、点击发布

publish_api_ok = {"called": False, "status": None}
def on_publish_resp(resp):
    if "/mp/agw/article/publish" in resp.url and resp.request.method == "POST":
        publish_api_ok.update(called=True, status=resp.status)
page.on("response", on_publish_resp)

page.get_by_text("预览并发布").first.click()
# API 已 200 就不再点"确认发布",防止重复提交

下面是发布流程的完整时序图,标注每一步的失败分支与对应退出码:

头条发布接口发布页发布脚本头条发布接口发布页发布脚本alt[页面加载失败 / 超时]alt[URL 含 login]alt[JS 兜底仍失败]alt[回读不一致(中文引号被截断)]alt[正文输入失败]alt[上传超时 /抽屉节点被移除]loop[三张封面(images[:3])]alt[API 响应 200][API 未响应 / 非 200]打开发布页 goto(GRAPHIC_PUBLISH_URL)1退出码 4(被重定向到登录页)2隐藏 AI 助手抽屉遮罩(display:none)3二次校验会话(URL 是否含 passport/login)4退出码 4(会话失效,需刷新登录态)5填标题 type() 逐字输入6回读校验 input_value()7JS 兜底写入 + 触发 input/change8退出码 5(标题填写失败)9填正文 Control+A + Delete + keyboard.type()10退出码 6(正文填写失败)11关闭残留抽屉(Esc×3 / display:none)12点击 .article-cover-add13定位当前可见抽屉内 file input(nth(idx))14set_input_files(img)15退出码 7(封面上传失败)16点击抽屉"确定"按钮17监听 POST /mp/agw/article/publish18点击「预览并发布」19发布成功(退出码 0)20退出码 7(发布点击失败,需排查选择器)21

5.4 成功判据(必须同时满足)

  • 日志出现 封面已填充图片数: 3
  • 日志出现 发布 API 响应: 200
  • 作品管理页出现该文章标题
  • 退出码 0

注意:退出码 0 但 API 状态为 None = 并未真正发布,需排查后重跑(重跑有查重保护,不会重复发)。

5.5 退出码约定

退出码含义处理
0发布成功 / 查重命中已跳过安全
2无可用登录态(Cookie 过期)重新扫码抓态
3未找到草稿文件检查路径
4发布页被重定向到登录页刷新登录态
5 / 6 / 7标题 / 正文 / 发布点击失败看截图排查选择器
8查重未能有效执行中止,绝不放行(见下节)

六、防重复发布设计(本流水线的安全核心)

发布前先去作品管理页做同名查重:

def has_duplicate(page, title):
    """
    返回 (checked, found):
      checked=False 表示查重没能有效执行(会话失效/页面异常)
      found=True    表示已有同名文章
    """
    for attempt in range(3):                       # 载入后常有二次 redirect,需重试
        try:
            page.goto("https://mp.toutiao.com/profile_v4/manage/content/all",
                      wait_until="domcontentloaded", timeout=30000)
            break
        except Exception:
            time.sleep(4)
    page.wait_for_load_state("networkidle", timeout=30000)
    time.sleep(7)
    if "manage/content" not in page.url:           # 被弹去登录页 = 会话失效
        return False, False
    titles = page.evaluate("""() => {
        const out = [];
        document.querySelectorAll('a, div, span, p, h1, h2, h3').forEach(el => {
            const t = (el.textContent || '').trim();
            if (t && t.length <= 120) out.push(t);
        });
        return out;
    }""")
    if not titles:
        return False, False
    def norm(s):                                   # 归一化:去标点/引号/空白
        return re.sub(r"[^\w\u4e00-\u9fff]", "", s)
    marker = norm(title)[:12] or norm(title)
    for t in titles:
        if marker and marker in norm(t):
            return True, True
    return True, False

调用方的铁律:

checked, dup = has_duplicate(page, title)
if dup:
    sys.exit(0)                    # 已存在,跳过(安全)
if not checked and not force:
    sys.exit(8)                    # 查重失败 → 中止,绝不当作"无重复"放行

两个关键细节:

  1. 查重匹配必须归一化。标题里的全角引号在 page.content() 里会被 HTML 实体化,直接字符串匹配会漏;从 DOM 取 textContent 并用正则去掉所有标点引号,只留文字数字,才能可靠命中。
  2. 查重失败 ≠ 没有重复。旧版脚本把"查重页打不开"当"无重复"直接发,正是重复发布的根因。新版返回 (checked, found) 二元组,checked=False 一律退出码 8 中止;只有显式加 --force 才强制发布。

七、发布前质量门禁(五项自检)

python scripts/check_article_quality.py <草稿路径>
检查项规则为什么
编造第一人称亲历命中"我朋友/我表弟/我同事/我当时在"等即 FAILAI 味最重的破绽,读者判定编造则信任崩塌
AI 套话词命中"值得深思/总而言之/在这个时代/让我们一起"等即 FAIL一眼 AI 体
超长段落每段 ≤ 60 字手机端阅读压迫感,直接压完读率
标题字数18-28 字信息量铺不满则点击率低
结尾真问题末段必须是问句撬动评论区二次推流

核心判定代码:

FAKE_FIRST_PERSON = ["我朋友", "我一个朋友", "我有个朋友", "我表弟", "我表哥",
                     "我表姐", "我同事", "我邻居", "我房东", "我室友", "我老乡",
    "我领导", "我昨天去", "我今天去", "我当时在", "我记得那天"]
AI_SUBS = ["值得深思", "引发共鸣", "时代浪潮", "在当下", "引发思考", "值得一提",
           "不得不说", "总而言之", "我们不难发现", "这大概就是", "加油", "致敬",
           "让我们一起", "在这个时代", "答案是", "你会发现"]

# 编造第一人称:按句切片,句中命中触发词即命中
for kw in FAKE_FIRST_PERSON:
    for mm in re.finditer(r"[^。!?\n]*%s[^。!?\n]*" % re.escape(kw), body):
        hits.append(mm.group(0).strip())

写作侧配套铁律(与自检一一对应):

  • 禁止编造第一人称亲历,改用三选一:公开视角直述 / 可核查的数据报道 / 直接讲事件本身。注意「我」表达观点允许,「我」编造亲历禁止;
  • 每段 ≤ 60 字,一段只说一两句;
  • 标题 18-28 字,补具体场景或数字;
  • 一篇只写一件事;
  • 结尾抛真问题(“要是你,你选哪个?”),不抛"大家怎么看"。

八、定时调度:每天自动跑两次

Linux(crontab,早场 8:00 / 晚场 19:30):

0 8 * * *  cd /opt/toutiao && /usr/bin/python3 run_daily.py morning >> logs/morning.log 2>&1
30 19 * * * cd /opt/toutiao && /usr/bin/python3 run_daily.py evening >> logs/evening.log 2>&1

Windows:任务计划程序 → 创建基本任务 → 每天两次 → 操作选"启动程序",程序填 Python 路径、参数填脚本路径、起始于填工作区目录。

调度脚本里建议固化的顺序:抓热点(多源交叉验证时效)→ 写稿 → 自检(五项全过)→ 查重 → 发布 → 记录结果。任何一步失败即终止当天该场次,不发半成品。

时效性陷阱:搜索返回的"实时热点"聚合页经常是往年旧快照,页面里的"今天"是抓取时的相对时间。定题前必须用正文里的明确发布日期做多源交叉验证,只认当前年份的报道,聚合页只能用于发现线索。


九、踩坑记录(速查表)

症状原因处理
会话已失效,被重定向到登录页(退出码 2)Cookie 过期(实测约 75 天)重新扫码抓态
退出码 8「查重未能有效执行」查重页被弹去登录页 / 取不到标题保护机制生效,不是 bug;刷新登录态后重跑,确认无重复才 --force
重复发布同一篇旧版查重失败时静默降级放行已改为失败即退出码 8 中止
Navigation ... interrupted by another navigation作品管理页 domcontentloaded 后仍有 redirectgoto 加重试 + networkidle 等待
image-upload ... intercepts pointer events(退出码 7)图片抽屉未关,拦截后续点击每张图前 Esc×3 → 无效再 display:none;切勿 remove 节点
图片全传失败 / set_input_files 超时抽屉节点被提前移除,file input 被销毁检查清理逻辑,勿在点 .article-cover-add 前删抽屉
只有 1 张图成功残留抽屉拦截第 2 张确认每张图开始前都关抽屉
标题回显与输入不一致中文引号被输入框截断读 input_value() 校验,不一致用 JS 兜底
无封面模式可选.article-cover-radio-group 选择器失效头条改版,重新抓选择器

十、总结

这条流水线的真正门槛不在"写",而在三处工程细节:

  1. 连接:App-Bound Encryption 堵死了直接读 Cookie 的路,必须走"浏览器内导出明文"或"扫码自抓",登录态文件等同密码,只存本地、定期刷新;
  2. 防重:查重必须归一化匹配,且查重失败一律中止,把"发重复"从运气问题变成机制问题;
  3. 门禁:质量自检五项全过才准发布,用退出码把"不许发"写成硬约束。

三者就位后,每日内容生产才可以从"人盯着发"变成"定时自动发、人来审"。

合规提醒:热点评论只做公开视角观点输出,不获取、不描述任何私密影像;事实以公开报道为准,不编造不传谣;涉及隐私与未成年人的话题直接放弃选题。


(本文所有代码均为脱敏后的可复用实现,不含任何账号、密码、Cookie 值。读者按自身环境替换路径即可运行。)

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

aifannihao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值