一条可重复执行的每日内容流水线:抓当天热点、写成"不像 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 手动导出(无桌面环境/服务器)
- 浏览器安装 Cookie-Editor 扩展;
- 登录
https://mp.toutiao.com; - 打开扩展 → Export → JSON(Header/Export format 选 JSON);
- 转成下面的统一格式,覆盖写入
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 字)
正文第一段。
正文第二段。
三条硬规则:
- 第一行必须是
# 标题,且正文区严禁再出现其它一级标题,否则会被当成草稿元数据解析; - 正文不含任何 Markdown——不加粗、不用列表符、不用引用,任何标记都会原样展示给读者;
- 标题超过 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 就不再点"确认发布",防止重复提交
下面是发布流程的完整时序图,标注每一步的失败分支与对应退出码:
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) # 查重失败 → 中止,绝不当作"无重复"放行
两个关键细节:
- 查重匹配必须归一化。标题里的全角引号在
page.content()里会被 HTML 实体化,直接字符串匹配会漏;从 DOM 取textContent并用正则去掉所有标点引号,只留文字数字,才能可靠命中。 - 查重失败 ≠ 没有重复。旧版脚本把"查重页打不开"当"无重复"直接发,正是重复发布的根因。新版返回
(checked, found)二元组,checked=False一律退出码 8 中止;只有显式加--force才强制发布。
七、发布前质量门禁(五项自检)
python scripts/check_article_quality.py <草稿路径>
| 检查项 | 规则 | 为什么 |
|---|---|---|
| 编造第一人称亲历 | 命中"我朋友/我表弟/我同事/我当时在"等即 FAIL | AI 味最重的破绽,读者判定编造则信任崩塌 |
| 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 后仍有 redirect | goto 加重试 + 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 选择器失效 | 头条改版,重新抓选择器 |
十、总结
这条流水线的真正门槛不在"写",而在三处工程细节:
- 连接:App-Bound Encryption 堵死了直接读 Cookie 的路,必须走"浏览器内导出明文"或"扫码自抓",登录态文件等同密码,只存本地、定期刷新;
- 防重:查重必须归一化匹配,且查重失败一律中止,把"发重复"从运气问题变成机制问题;
- 门禁:质量自检五项全过才准发布,用退出码把"不许发"写成硬约束。
三者就位后,每日内容生产才可以从"人盯着发"变成"定时自动发、人来审"。
合规提醒:热点评论只做公开视角观点输出,不获取、不描述任何私密影像;事实以公开报道为准,不编造不传谣;涉及隐私与未成年人的话题直接放弃选题。
(本文所有代码均为脱敏后的可复用实现,不含任何账号、密码、Cookie 值。读者按自身环境替换路径即可运行。)
本文详解面向今日头条号的AI内容自动化发布流水线,涵盖扫码登录态获取、Cookie安全注入、DOM级页面操作、防重复发布机制及五项质量门禁。重点突破App-Bound Encryption限制,实现稳定登录态管理;通过归一化标题查重与退出码驱动的硬性校验,保障发布安全;结合定时调度与多源热点验证,构建可复用、可审计、合规的内容生产系统。
1239

被折叠的 条评论
为什么被折叠?



