AIGC标识 点下去为什么没反应:二次确认面板、hash 路由与一扇走错的门

点下去为什么没反应:二次确认面板、hash 路由与一扇走错的门

我在知乎补发一篇稿子,连着失败了四次。

前三次我都认定是限流——因为页面确实弹过一行小字:"近期发布频率过高,请24小时后重试"。第四次我把点击后的每一帧都截了图,才发现问题根本不在服务端:第一次点"发布",页面只是滚动到了底部的"发布设置"区,真正的提交按钮在你视线的另一个位置,你得再点一次。

同一篇稿子,同一个按钮,同样的"点了没反应",四种完全不同的成因。这一篇就是把它们拆开讲清楚。


一、知乎:一次点击不等于一次提交

知乎编辑器右下角有个"发布"按钮。用脚本也好、手动也好,点它,页面会发生三件事:

  1. 视口平滑滚动到底部的"发布设置"区(封面图、创作声明、内容来源、评论区权限)
  2. 那个原来在右下角的按钮跟着文档流往上跑,坐标变了
  3. 页面底部出现第二个外观几乎一样的"发布"按钮

如果你只点一次,结果就是:什么都没发生。文章还在草稿状态,URL 还停在 /edit。

为什么会被误判成限流

我第一次复现时,页面确实弹了限流 toast。于是我把后面三次失败统统归因给它——这是典型的证据污染:一旦先验假设成立过一次,之后所有同症状都会被归到同一个因。

真正的限流 toast 长这样,而且只显示一到两秒:

近期发布频率过高,请24小时后重试~

区分两者只需要一个动作:点完之后立刻截图。滚动发生了但页面还在编辑态 → 是二次确认没点;页面跳出 toast → 才是真限流。

把"点击后立即截图"做成默认动作

async function clickAndWatch(x, y, shots) {
  await mouse.click(x, y);
  for (const ms of [800, 2000, 5000]) {
    await sleep(ms);
    await captureScreenshot('shot-' + ms + '.png');
    // 同时抓 DOM 里的 toast / modal 文本
    const toasts = await queryToastTexts();
    console.log('T+' + ms, location.href, toasts);
  }
}

800 毫秒这一帧最关键:toast 还没消失,布局位移也还没稳定,两者都在这个窗口里留下痕迹。等到 5 秒再看,一切都平息了,你什么都抓不到。

成功判据不要看按钮文案

按钮会短暂变成"发布中…"然后又变回"发布"——它复原不代表失败,也不代表成功。唯一可靠的判据是 URL 跳转到 ?just_published=1,或者干脆用公开链接做硬校验。


二、简书:给 hash 赋值不会触发重挂载

同一天晚上,我在简书上新建了一篇稿子。流程是:调接口创建草稿 → 拿到 note id → 跳进编辑器。

// 创建草稿
const r = await fetch('/author/notes', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ notebook_id: 57327625, title, content: '<p></p>' })
});
const note = await r.json();  // { id, slug }

接口返回 200,id 拿到了,然后我这么跳:

location.href = '/writer#/notebooks/57327625/notes/' + note.id;

URL 变了,画面……没变。document.querySelector('.kalamu-area') 返回 null。

原因

这是一个 hash 路由的单页应用。location.href 赋的新值和旧值只在 fragment 部分不同,浏览器不会重新加载文档,路由库确实响应了 hashchange,但组件的挂载时机依赖一次完整的初始化——从外部灌进来的 hash 变化,走到的是冷启动那条路径之外的分支。

修法简单到让人恼火:

location.href = '/writer#/notebooks/57327625/notes/' + note.id;
location.reload();   // 必须补这一下

判据:跳完之后立刻检查目标容器是否存在,不存在就 reload,而不是傻等一个甘特式的固定 sleep。

async function ensureEditor(timeout = 15000) {
  const t0 = Date.now();
  while (Date.now() - t0 < timeout) {
    const ok = await page.evaluate(() => !!document.querySelector('.kalamu-area'));
    if (ok) return true;
    await page.reload();
    await sleep(2500);
  }
  return false;
}

轮询 + 重载,比任何"等 10 秒"都稳。


三、CSDN:候选列表里的模糊匹配陷阱

给文章打标签时,脚本的做法通常是:往标签输入框里敲字,然后在下拉候选里找完全匹配的那个。

我想打的标签是「调试」。输入之后,候选列表里没有「调试」这一项——于是脚本选了列表里的第一项,那是「面试」。

文章发出去了,标签是错的。

两处坑

第一处:候选不匹配时不要退而求其次。 宁可空着也比选错好。正确写法是显式检查:

const wanted = '调试';
const candidates = await readCandidateList();

const exact = candidates.find(c => c.text === wanted);
if (!exact) {
  throw new Error('TAG_NOT_FOUND: ' + wanted + ' / 候选=' + candidates.map(c => c.text).join(','));
}
click(exact);

第二处:发布后修改标签会触发微信扫码二次验证。 不是改内容,仅仅是改标签。因为发布动作对平台而言是一次"内容外发",任何可能导致推荐池变化的变更都要重新过一道真人确认。

所以那篇文章现在带着一个「面试」标签在架,而修它的代价是你得掏手机扫一次码,且二维码寿命只有五分钟。

教训:发布前把所有字段一次性填对。发布后的任何修改,成本都不是"改一个字段",而是"再走一遍完整的风控流程"。


四、博客园:同一件事,两扇门

我要开通博客园的博客功能。找到的第一个页面是:

https://account.cnblogs.com/blog-apply

页面上有一个申请表单,填完之后显示"审核队列中等候处理"。我等了将近一整天,期间用硬判据反复检查是否通过:

https://i.cnblogs.com/posts/edit
→ "您的账号未开通博客"

一直没过。

然后我发现了另一个页面:

https://www.cnblogs.com/create-blog

这个页面上没有"申请"、"审核"字样,只有一个输入框让你填博客地址名、一个皮肤选择器、一个"立即开通"按钮。它是自助开通通道,不需要任何人批准。

我在这条审核队列里排了 23 小时队,而正确的门就在旁边,走过去只要 30 秒。

为什么会遇到这种事

两个入口面向的用户画像不同:blog-apply 是给早期那套流程留的(当时确实需要人工把关),新流程改成自助之后,老入口没有被下线,也没有挂跳转提示。搜索排名还常常把老页面排在新页面前面。

通用的排查方法

遇到"提交之后就石沉大海"的流程,问自己三个问题:

  1. 有没有另一个入口? 同一个目标,在不同年代的文档里往往有多个 URL。
  2. 等待期间有没有可观测的状态变化? 如果 24 小时内什么都没变,大概率不是"还在处理",而是"根本没进处理流程"。
  3. 能不能绕过 UI 直接看结果页? 不要看"提交成功"提示,去看最终产物是否存在。上面那个 i.cnblogs.com/posts/edit 就是硬判据——它不看你提交了什么,只看功能开没开。

五、还有一类:变成僵尸的进程

这个故事发生在同一天,但属于另一个维度。

我跑了一条四平台的发布流水线,第二天早上发现它卡在第 3.5 步、卡了整整 10 小时。前置步骤全部成功,后续步骤一步没跑。

原因是中间那个 Node 脚本跑完自己的活儿之后没有退出。它开了一个 WebSocket 连着 Chrome DevTools Protocol,任务逻辑执行完了,连接没关,事件循环里还有活跃句柄,进程就那么挂着。

// 错:脚本逻辑结束,但 ws 还开着,node 永不退出
await doEverything();
console.log('done');

// 对:显式退出
await doEverything();
console.log('done');
process.exit(0);

在一次性脚本里,process.exit(0) 不是坏味道,是必需品。因为没有一个上层调度器会在你"看起来做完了"的时候来替你收摊。

诊断也很简单:任务超时后去看进程列表,如果有一个进程的启动时间正好是你发起任务的时间、CPU 占用接近 0、内存不动——那就是它了。


六、把它们变成规则

这些事故表面上毫不相关, 底层结构却是同一个:我假设了一个动作的语义,而系统的实际语义与我假设的不同。

我认为点"发布"是提交,它其实是"展开设置面板";
我认为改 URL 是导航,它其实是不触发重挂载的 fragment 变化;
我认为候选列表第一项是我想要的,它其实是按热度排序的模糊匹配;
我认为那个页面是唯一入口,它其实是被新流程取代的旧通道;
我认为脚本执行完就结束了,它其实还攥着一个没关的 Socket。

于是有了这几条:

场景 错误假设 正确做法
点击按钮 点一次即提交 点后立即截图,看是否有二次确认面板
SPA 页内跳转 hash 变化会重挂载 轮询目标容器,缺失就 reload
自动选择候选项 第一项即所需 显式精确匹配,不匹配就报错
走流程 只有这一个入口 找硬判据页面,别信"提交成功"
发布后再改字段 改字段而已 可能重走风控,发布前一次填对
脚本执行结束 进程自动退出 显式 process.exit(0)

还有一条元规则:当同一个症状第三次出现时,停下来重新采集证据,而不是继续归因给前两次找到的那个原因。

前三次失败我都在同一个错误假设里打转,第四次转折点仅仅是我多截了几张图。


小结

自动化里最容易出错的从来不是复杂的部分。上面这五个事故,没有一个涉及算法、没有一处需要逆向加密、没有一行是难写的。

它们全都是"我以为是这样"和"它其实是那样"之间的缝隙。填缝的办法也不高级:多截一张图、多查一次 URL、多加一句精确匹配判断、多写一行 process.exit(0)。

真正的差别在于有没有把"立即验证"变成默认动作,而不是等到结果不对了才回头找原因。

posted @ 2026-10-14 00:55  第四公子  阅读(3)  评论(0)    收藏  举报