无人值守AI内容矩阵实战(7):Playwright 发布器避坑——编辑器竞态、索引延迟与风控

无人值守AI内容矩阵实战(7):Playwright 发布器避坑------编辑器竞态、列表索引延迟、风控,我踩了整整两天

上周三早上七点,我照例打开后台看昨天的产出,CSDN 那篇《SSE 还是 WebSocket》的正文第一行赫然是:

欢迎使用 Markdown 编辑器

我自己的正文被挤到了第二行,标题下面还多出一段我从来没写过的"本编辑器支持 Markdown 语法......"。那一下我脑子是空的:脚本明明返回了 ok: true,URL 也拿到了,为什么线上是这个鬼样子?

后来我把最近四篇存量文章挨个打开体检,发现有三篇被同一个模板污染了,其中一篇的模板残留甚至塞在代码块中间,把一段 Python 示例直接劈成两半。这篇文章就是那两天的复盘:一个用 Playwright 往三个平台发文章的小工具,是怎么被 CSDN 的编辑器竞态、头条号的列表索引延迟、以及各家的风控一层层教做人的。

先说清楚边界:文中所有域名都是 example.com 这类示例域名,路径统一写成 ~/workspace,不含任何真实账号、仓库或内网地址,只讲通用的技术做法。

一、CSDN:先渲染空白页,再异步灌模板

1.1 症状:固定等待撞上竞态

我第一版的发布器逻辑朴素到可爱:打开编辑器页面,waitForSelector('.editor'),然后 fill 正文,点发布。

本地跑十次有九次是好的。上了定时任务之后,大约每五次有一次正文开头多出模板文字。我一开始以为是 CSDN 的编辑器"自动补全",直到我打开 DEBUG=1 的截图序列才看明白:

编辑器页面分两个阶段。第一阶段 DOM 里已经有 contenteditable 了,但内容是空的;第二阶段,一个 XHR 返回编辑器配置,前端才把"欢迎使用 Markdown 编辑器"这段欢迎语灌进去。我的 fill 恰好落在两个阶段中间------我把正文写进去了,然后模板后到,直接覆盖/前置了我的内容。

这就是典型的竞态:你以为的"页面加载完成",只是"骨架完成"。

1.2 三次尝试,两次失败

第一个想法是加固定等待:await page.waitForTimeout(3000)。这招在本地测试机上百发百中,在 CI 上立刻翻车------那台机器的网络慢,三秒不够;而网络好的时候又白等三秒,一天三篇不算什么,但调试时极其烦人。更糟的是,固定等待只是把概率从 20% 降到 5%,它不解决问题,只推迟问题。

第二个想法是等"编辑器里出现文字"。听起来对,实际上更危险:模板本身就是文字,我等的条件被模板满足了,正文照样被覆盖。

第三个想法才走通:轮询等"稳定",而不是等"某个元素出现"。

python 复制代码
import time
from playwright.sync_api import Page

TEMPLATE_MARKERS = ("欢迎使用 Markdown 编辑器", "本编辑器支持 Markdown 语法")

def wait_editor_stable(page: Page, timeout_ms: int = 15000, interval_ms: int = 250) -> str:
    """连续 N 次读到完全相同的编辑器内容,才认为编辑器稳定了。"""
        deadline = time.time() + timeout_ms / 1000
            last = None
                stable_hits = 0
                    while time.time() < deadline:
                            cur = page.evaluate(
                                        "() => { const el = document.querySelector('.editor__inner');"
                                                    " return el ? el.innerText : null; }"
                                                            )
                                                                    if cur is not None and cur == last:
                                                                                stable_hits += 1
                                                                                            if stable_hits >= 3:          # 连续 3 次(约 750ms)不变
                                                                                                            return cur
                                                                                                                    else:
                                                                                                                                stable_hits = 0
                                                                                                                                        last = cur
                                                                                                                                                page.wait_for_timeout(interval_ms)
                                                                                                                                                    raise RuntimeError("编辑器在超时内未进入稳定状态")
                                                                                                                                                    ```
这段代码的关键不是"等多久",而是"等什么":等的是内容不再变化这个状态,而不是某个时间点。模板灌入之后内容会再变一次,所以我至少要多等一个周期才能确认稳定。

### 1.3 清空要清干净,别只按 Ctrl+A

编辑器稳定之后,第二步是清空。这里我犯了第二个错:Ctrl+A 然后 Delete。

在 contenteditable 里,Ctrl+A 的选中范围取决于焦点落在哪个节点,有时候它只选中了当前段落,有时候选中了整个文档,有时候干脆什么都没选中。我的日志里出现过"清空后还剩一行空行"和"清空后模板还在"两种截然不同的结果,原因就是焦点位置不同。

稳定可靠的写法是直接操作 DOM,再补一次键盘兜底:

```python
def clear_editor(page: Page) -> None:
    page.click(".editor__inner")
        page.evaluate(
                """() => {
                            const el = document.querySelector('.editor__inner');
                                        if (!el) return;
                                                    el.focus();
                                                                const sel = window.getSelection();
                                                                            const range = document.createRange();
                                                                                        range.selectNodeContents(el);
                                                                                                    sel.removeAllRanges();
                                                                                                                sel.addRange(range);
                                                                                                                        }"""
                                                                                                                            )
                                                                                                                                page.keyboard.press("Delete")
                                                                                                                                    # 兜底:如果 DOM 还留着空段落标签,再删一次
                                                                                                                                        page.evaluate(
                                                                                                                                                """() => {
                                                                                                                                                            const el = document.querySelector('.editor__inner');
                                                                                                                                                                        if (el && el.innerText.trim() === '') el.innerHTML = '';
                                                                                                                                                                                }"""
                                                                                                                                                                                    )
                                                                                                                                                                                    ```
### 1.4 双保险:模板残留硬校验 + 长度闸门

即使清空和等待都做对了,我还是不放心。因为这类问题最恶心的地方在于它不报错。所以我加了两道闸门,任何一道不通过就中止发布并告警。

第一道是模板残留硬校验:写入正文后,重新读回编辑器全文,只要命中任何一个模板标记,直接抛错。

```python
def assert_no_template(page: Page) -> None:
    text = page.evaluate(
            "() => document.querySelector('.editor__inner').innerText"
                )
                    for marker in TEMPLATE_MARKERS:
                            if marker in text:
                                        raise RuntimeError(f"正文混入编辑器模板:{marker!r}")
                                        ```
第二道是长度闸门。我统计过,自己的文章正文一般在 4000~9000 字,如果写进去之后读回来的长度小于原稿的 85%,说明有内容被吞了;如果大于 115%,说明多了东西(模板、或者重复粘贴)。

```python
def assert_length_sane(original: str, actual: str, low=0.85, high=1.15) -> None:
    ratio = len(actual) / max(len(original), 1)
        if not (low <= ratio <= high):
                raise RuntimeError(f"正文长度异常:原稿 {len(original)} 字,编辑器内 {len(actual)} 字({ratio:.0%})")
                ```
这两道闸门上线之后,同样的故障又出现过一次,但这次它在发布前就被拦住了,我在日志里看到的是明确的 正文混入编辑器模板,而不是第二天早上一脸懵。

### 1.5 存量文章的体检与在线修复

已经发出去的三篇怎么办?删了重发会被判重复,不删又难看。我写了个修复脚本,流程是:备份 → 清空 → 重填 → 双重校验 → 绕过 CDN 缓存复检。

```python
def repair_article(page: Page, url: str, clean_body: str, backup_dir: str) -> None:
    page.goto(url, wait_until="domcontentloaded")
        wait_editor_stable(page)
            page.screenshot(path=f"{backup_dir}/{int(time.time())}.png")
    # 备份旧正文,万一修复失败还能贴回去
        old = page.evaluate("() => document.querySelector('.editor__inner').innerText")
            with open(f"{backup_dir}/old-body.md", "w", encoding="utf-8") as f:
                    f.write(old)
    clear_editor(page)
        page.keyboard.insert_text(clean_body)   # 合成粘贴,比逐字符 type 稳
            wait_editor_stable(page)
    assert_no_template(page)
        assert_length_sane(clean_body, page.evaluate("() => document.querySelector('.editor__inner').innerText"))
            page.click("button:has-text('保存')")
            ```
修完之后有个坑:我刷新页面看到的还是旧内容。不是没保存成功,是 CDN 缓存。复检时我给 URL 拼了个时间戳参数强制回源,才看到真实结果。

## 二、头条号:发布成功不等于能拿到 URL

### 2.1 作品列表有索引延迟

头条的发布流程是:点发布 → 返回一个"发布成功"的提示 → 文章进入作品列表。我的脚本需要拿到文章 URL 做登记,于是发布后立刻去作品列表抓最新一条。

结果是:抓到的经常是上一篇。

原因不复杂,作品列表是异步渲染的,而且后端索引有延迟------发布接口返回成功的瞬间,列表接口可能还没把新文章算进去。我一开始用 waitForTimeout(2000) 硬等,命中率大概七成,剩下三成抓错,导致我的台账里有两篇的 URL 指向了同一篇文章。

### 2.2 挂起等待 XHR 真正返回

正确的做法是监听列表接口本身,而不是等一个固定的秒数:

```python
def wait_list_xhr(page: Page, timeout_ms: int = 20000):
    with page.expect_response(
            lambda r: "/api/pc/list/feed" in r.url and r.status == 200,
                    timeout=timeout_ms,
                        ) as resp_info:
                                page.reload()
                                    return resp_info.value.json()
                                    ```
这里有个细节:必须先注册监听,再触发刷新,顺序反了就会漏掉响应。

### 2.3 递增重试 + 标题前缀双重匹配

即使拿到了列表数据,索引延迟也可能让新文章还没出现。所以我做了递增重试:第一次等 2 秒,第二次 4 秒,第三次 8 秒,最多四次。

匹配也不能只看"第一条",因为列表里可能混着别的文章。我用标题前缀做双重匹配------既要求标题包含我这次写的前缀(比如系列名),又要求发布时间晚于本次发布动作的开始时间。

```python
def find_published_url(items, title_prefix: str, started_at: float) -> str | None:
    for item in items:
            title = item.get("title", "")
                    ts = item.get("publish_time", 0)
                            if title_prefix in title and ts >= started_at - 60:
                                        return item.get("url")
                                            return None
                                            ```
如果四次重试都匹配不到,我的处理是不登记 URL,只登记状态并告警。宁可台账里缺一个链接,也不能把上一篇的链接错记成这一篇------错记比缺失更毒,因为它会让后续的绩效回流全部串行。

## 三、知乎:风控严,低频 + 合成粘贴

知乎我一开始也想用同样的方式发,第一次就撞墙:提交后页面跳回首页,没有任何报错。加网络监听才看到接口返回了一个风控提示。

试了几轮之后我总结出两条:

第一是低频。同一账号短时间连续发文,触发风控的概率极高。我把知乎的发布频率压到每天最多一篇,且和其他平台错开时间。

第二是合成粘贴写入。逐字符 type 会产生密集的键盘事件,行为特征太像脚本;而 keyboard.insert_text 走的是粘贴通道,事件序列更接近真人。

```python
page.click(".public-DraftEditor-content")
page.keyboard.insert_text(body_markdown)

另外,所有平台我都统一了一条:调试必须带已登录的 profile。匿名访问下,平台对自动化特征的风控严得多,你会看到各种莫名其妙的跳转和空白页,浪费大量时间在错误的方向上。用 storage_state 把登录态存下来复用,既是无人值守的前提,也是调试时少走弯路的前提。

四、把三个坑归纳成一套通用模式

踩完这三家,我发现它们其实是同一类问题的三种表现:

平台 表象 根因 我的对策
CSDN 正文混入模板 编辑器两阶段渲染,固定等待撞竞态 轮询等稳定 + 硬校验 + 长度闸门
头条号 抓到上一篇的 URL 列表异步渲染 + 索引延迟 监听 XHR + 递增重试 + 前缀双重匹配
知乎 提交后静默跳首页 账号风控 低频 + 合成粘贴 + 已登录 profile

共同点是:它们都不报错。浏览器自动化里最贵的成本不是写选择器,而是"脚本说成功、线上是错的"这种静默失败。所以我后来给自己定了三条规矩:

第一条,任何"写"操作之后必须有"读回来校验",不能信任动作本身的返回值。

第二条,兜底文案要克制。我之前在掘金那边踩过一模一样的坑:发布失败时我抛的是"未跳转到文章页",结果真正的原因是服务端返回了账号受限,被我的兜底文案彻底盖住,我白查了半天选择器。能拿到一手错误就原样抛出来。

第三条,关键步骤留证据。截图、编辑器全文 dump、网络日志,DEBUG=1 时全存下来。当自动化开始骗你的时候,你唯一能依靠的就是它当时到底看见了什么。

这套东西现在跑在我自己的机器上,三个平台每天各一篇,出问题的次数从每周三四次降到现在两周一次,而且每次都能在发布前被闸门拦住。剩下那一次,通常是平台自己改了 DOM,那就只能重新校准选择器了------这大概是浏览器自动化永远躲不掉的税。

如果你也在写类似的发布器,欢迎在评论区聊聊你踩过最隐蔽的静默失败,我猜大概率也是"返回成功但内容不对"这一类。

相关推荐
wuyk5551 小时前
PyQt6-第06讲-主窗口框架
python·pyqt
ss.li1 小时前
rppg相关论文阅读笔记10.22
论文阅读·笔记·python
tryCbest1 小时前
Python之Flask项目部署到阿里云
python·阿里云·flask
心态还需努力呀1 小时前
把 Zettlr 搬上鸿蒙 PC:墨匣学术写作编辑器移植与上架全记录
华为·编辑器·harmonyos
liwulin05061 小时前
【PYTORCH】迷你DECODER-ONLY模型
pytorch·python·深度学习
努力学习的小廉1 小时前
UE5 入门系列(二):编辑器基础操作速通
ue5·编辑器
全栈练习生1 小时前
Agent工程化三层架构
python·ai
高洁012 小时前
边缘AI与大模型轻量化:让智能跑进每一个终端
人工智能·python·深度学习·机器学习·迁移学习
MoRanzhi12033 小时前
第6篇(中)· 使用 PyQt5 实现 2048 高性能计算版:无头后台求解与三档运行模式
python·pyqt·数据持久化·多进程·进程间通讯·后台任务·断点续跑