linux.do 对纯 AI 生成的水帖是零容忍的。社区准则里那条写得很硬:不可以直接使用 AI 生成、润色的文字内容,要发就截图发出来。

我写了个浏览器插件,在发帖框旁边加一个按钮,点一下把草稿丢给你自己配的大模型,让它对照社区准则判个风险等级,再告诉你这帖该发哪个板块。先是油猴脚本,同一天又做了 Chrome 扩展版。

这篇不讲怎么写扩展。讲的是我手里正好有一份完整的 PRD,和同一天写完的实现,两份东西可以逐条对着看——计划里的哪些东西活到了上线,哪些没有,以及活下来的那部分里,哪一个是我事后才发现根本没人检查过。

一、01:18 的 PRD,10:48 的代码

时间线是文件系统和 git 一起记下来的,不是我回忆的:

06-15 01:18  PRD.md 写完(93 行,5 章,F1–F4 四个功能,3 阶段路线图)
06-15 10:48  manifest.json / background.js 落盘
06-15 10:50  content.js / popup.js / popup.html 落盘
06-15 11:11  第一个提交:feat: initial release ... v2.0
06-15 11:20  第二个提交:docs: add README with Greasy Fork link and MIT LICENSE

这个仓库的全部 git 历史,跨度 8 分 54 秒。两个提交,之后再没有第三次 push。

代码规模:

content.js      294 行   注入页面、抓草稿、拼 prompt、弹结果
popup.js        157 行   配置面板
background.js    42 行   Service Worker,转发 API 请求
────────────────────────
                493 行   JS 合计
popup.html      139 行
manifest.json    22 行
README.md        72 行
────────────────────────
                726 行   仓库全部

一份 93 行的 PRD,对上 493 行 JS。下面逐条对。

二、PRD 里最聪明的功能,被它要对照的规则文本否决了

PRD 的 F2 写了三个审查维度,其中第二个是这么写的:

「AI 味」纯度检测:评估文本是否包含 ChatGPT 常见高频词汇(如“总而言之”、“不可否认”、“正如前面所说”、“双刃剑”),并给出“真人率”百分比。

这是整份 PRD 里我自己觉得最有意思的一条。也是唯一一条在代码里完全找不到的。

$ grep -riE '真人率|AI 味|ai-taste' .
(无匹配)

上线版本的 system prompt 只让模型输出四个字段:风险等级、触犯条款、板块建议、风险说明。没有百分比,没有词频统计,没有“真人率”。

有意思的是否决它的理由,就打在同一个文件里,往上 70 行content.js 里有个 getFallbackGuidelines(),是我手敲的一份社区准则副本,开头三条是这样的:

【总体基调】
1. 不可以傲慢。哪怕你技术再牛,也不欢迎傲慢。
2. 不可以搞破坏。任何可能导致论坛故障或死亡的行为,都不受欢迎。
3. 不可以直接使用AI生成、润色内容。AI生成的文字内容只接受截图发出!

社区的规则是二值的:是不是 AI 写的。不是“AI 味浓不浓”的连续量。给用户一个“真人率 87%”,等于把一条二值红线翻译成了一个渐变的安全感——87% 听起来像是可以发,但按规则它和 12% 一样违规。

这个功能不是难做所以砍掉的,是答错题所以砍掉的。而我在敲那 34 行兜底规则的时候,答案已经打在屏幕上了,我只是没回头去改 PRD。

这是我从这个项目里拿走的第一条:需求文档写在动手之前,而动手过程中最先接触到的往往就是那份“真正的规格说明”——这里是社区准则原文。要对照的规则文本自己会否决掉一部分需求,前提是你写完代码回头再读一遍 PRD。我没读。

PRD 的其余部分对得上:F1 注入按钮做了,F3 板块推荐做了,F4 自定义 API 配置做了,OpenAI 和 Anthropic 两套协议都支持。

三、唯一没写进 PRD 的设计

PRD 只说“对照社区准则”,没说准则从哪儿来。这是个被跳过的问题——在 prompt 里硬编一份就完事了。

代码里做的比这个多。getLatestGuidelines() 会从论坛现拉一份准则,缓存 24 小时,失败了才回落到手写副本:

async function getLatestGuidelines() {
  const ONE_DAY = 24 * 60 * 60 * 1000;
  const cache = await chrome.storage.local.get({ guidelines_text: null, guidelines_ts: 0 });

  if (cache.guidelines_text && (Date.now() - cache.guidelines_ts < ONE_DAY)) {
    return cache.guidelines_text;
  }

  try {
    const res = await fetch('/guidelines');
    if (!res.ok) throw new Error('网络请求失败');
    const html = await res.text();
    const doc = new DOMParser().parseFromString(html, 'text/html');
    const main = doc.querySelector('#main-outlet') || doc.body;
    let text = main.innerText.replace(/\s+/g, ' ').trim();
    if (text.length > 4000) text = text.substring(0, 4000) + '...(已截断)';

    await chrome.storage.local.set({ guidelines_text: text, guidelines_ts: Date.now() });
    console.log('[护航助手] 社区准则云端同步成功');
    return text;
  } catch (err) {
    console.warn('[护航助手] 获取准则失败,使用本地兜底:', err);
    return cache.guidelines_text || getFallbackGuidelines();
  }
}

这段是整个仓库里唯一一个我认为算得上“设计”的东西。理由也站得住:社区准则是会改的,硬编一份等于让插件按去年的规矩审今年的帖子;从 content script 里 fetch('/guidelines') 是同源请求,不用申请任何额外权限,白拿。

它也是唯一一个我到现在都不确定跑没跑起来的东西。

四、也是唯一没被检查的分支

问题在这一行:

if (!res.ok) throw new Error('网络请求失败');

这里校验的是传输层:服务器有没有回 200。没有任何一行校验回来的东西是不是准则。

Discourse 是客户端渲染的应用。fetch('/guidelines') 大概率拿到的是应用外壳,#main-outlet 里没有正文——但 HTTP 状态码是 200,res.ok 为真,不会进 catch。于是:

  • text 变成空串或者一小段无关文本
  • 被写进缓存
  • console.log('社区准则云端同步成功') 照常打印
  • 空白的准则被塞进 system prompt 的 """ 之间
  • 模型在没有任何判定标尺的情况下,开始给用户判风险等级

那 34 行、781 个字符的手写兜底,是这个仓库里最费工的资产——它只在 fetch 抛异常时触发,也就是断网或者被 Cloudflare 拦下的时候。而这恰恰是最不可能发生的失败。真正会发生的失败(200 但内容不对)走的是成功路径。

一个更难看的推论:if (cache.guidelines_text && ...) 里空串是假值,所以缓存永远命中不了,每次点按钮都会重新发一次注定拿不到东西的请求。24 小时缓存这个设计,在它最可能落入的状态下是不生效的。

修它需要一行:

if (text.length < 200) throw new Error('准则正文疑似未渲染');

我没写,因为我没想过要写。写兜底的时候我脑子里的失败模型是“网断了”,而不是“拿回来的东西不对”。这两种失败在代码里长得完全不一样,前者会喊,后者会假装成功。

这里我要把话说死到我能证实的程度:Discourse 那部分是我的判断,不是我验证过的结论。 我试着从命令行抓 linux.do/guidelines,拿到的是 Cloudflare 的 403,验证不了真实浏览器里带着登录态发这个请求会返回什么。所以我不说“这个功能是坏的”,我说的是:这条分支的正确性从来没有被任何东西检查过——没有断言、没有测试、没有一次人工确认——而且它被设计成了失败时不会喊。

区别在于:如果它恰好是对的,那是运气,不是工程。

五、路线图是按「哪一步更好生成」执行的

PRD 第 5 章写了三个阶段:

Phase 1(油猴脚本 MVP):抓文本 + 自定义 API + 基础 prompt,结果直接用 alert 或一个极其简单的 div。先发帖给社区佬友内测。 Phase 2(功能完善):加入板块推荐、AI 味词汇高亮标红、以及优雅的 UI 弹窗。 Phase 3(生态迁移)若反馈极佳,封装为 Chrome 扩展程序,利用 Sidepanel API 提供沉浸式体验。

实际发生的:Phase 1 和 Phase 3 在同一天做完。Phase 2 到今天没做。

Phase 3 的前置条件是“若反馈极佳”。它做完的时候,油猴脚本在 Greasy Fork 上线不到几小时,总安装量至今是 8。前置条件不可能满足,但后置动作先执行了。

Phase 2 没做的证据留在代码里,是一段死代码。content.js 第 4 行:

function escapeHtml(str) {
  if (!str) return '';
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}

全仓库零调用。它是为那个“优雅的 UI 弹窗”准备的——要往 DOM 里插模型返回的文本,就得转义。弹窗没做,转义函数留下了。它是被放弃的 Phase 2 唯一的化石。

上线版本怎么显示结果的?

alert(`【AI 佬友发布护航评估报告】\n\n${result}`);

alertcontent.js 里出现 4 次。PRD 3.1 章设计的“红黄绿三色灯看板”,最后是 alert 文本里的三个 emoji。PRD 里要求的流式输出(grep stream 无匹配)和“3 秒内响应”也没有——background.js 是一次性 fetch,超时设的 20 秒。

为什么是这个顺序?我想清楚的答案是:Phase 3 是机械的,Phase 2 不是。

从油猴脚本迁到 Manifest V3,是一次结构确定的翻译:GM_setValue 换成 chrome.storage.syncGM_xmlhttpRequest 换成 Service Worker 里的 fetch 加消息通道,加一份 manifest.json。输入输出都清楚,一轮就能出结果。而“优雅的弹窗”没有验收标准,要反复看、反复调,没人催,也没有任何东西会因为它没做而报错。

在 AI 参与的项目里,路线图会自动重排成「哪一步更好生成」的顺序,而不是「哪一步更值钱」。 这两个顺序恰好在这里是反的:迁移做完了,用户看到的还是 1990 年代的 alert 框。8 次安装里有多少人是被那个 alert 劝退的,我不知道。

六、代码里剩下的三处代价

一、三处重复的正则,是唯一能证明有人真的用过它的证据。

content.js:157popup.js:62popup.js:122,同一段代码抄了三遍:

url.replace(/^https:\/\/https:\/\//, 'https://')

这是在修“用户把 https:// 粘贴了两遍”。没人会凭空写这个补丁——它一定是有人(我自己,或者某个填中转地址的用户)真的粘错过,才会被加进去,而且加了三个地方,说明三个入口都踩到了。

493 行代码里,唯一带着真实使用痕迹的,是一个处理粘贴错误的正则。 功能本身有没有被认真用过,我没有任何证据。

二、两份“保持同构”的实现。

popup.js 里有个函数叫 buildTestRequest,上面挂着我自己写的注释:

// --- 构建测试请求参数(与 content.js 中 buildRequestParams 保持同构)---

parseResponse 也是一样,和 content.js 里的 parseAIResponse 逐行重复。原因是真的:MV3 里 content script 和 popup 是两个隔离的执行上下文,不上打包器就没法共享模块,两份都得有。

但“保持同构”是一句靠人记住的约定。改一处忘一处,症状是“测试连接通过,实际用报错”——一个只会在两个入口不一致时才出现的 bug。这是“同构”这个词第一次在我自己的代码里当免责声明用。 726 行的项目不上打包器是对的,代价就是这个,写清楚就行。

三、host_permissions 只写了论坛。

background.js 顶上的注释说得很直白:

// 唯一职责:绕过 CORS 限制,代理 AI API 请求

manifest.json 里:

"host_permissions": ["https://linux.do/*"]

而这个 Service Worker 要 POST 的是用户在配置面板里填的任意 base_url——默认是 https://api.openai.com/v1/chat/completions

Manifest V3 下,扩展只对 host_permissions 里声明过的主机免除 CORS 检查。API 的域名一个都没声明。把 fetch 从 content script 挪到 Service Worker 确实解决了一半问题(请求来源从 https://linux.do 变成了 chrome-extension://<id>,不再受论坛的策略影响),但另一半靠的是对方 API 自己发不发放行头——这是运气,不是那句注释声称的“绕过”。

同样标清楚:我没有在真实浏览器里加载这个扩展跑过验证。 这是读 manifest 和读代码得出的判断,不是实测结论。它和上一条是同一个毛病——代码里写下的是“我以为的机制”,没有任何一步去确认机制真的是那样。

七、结果

分发侧的数据,今天(2026-09-03)从两个平台各自的页面上拿的:

Greasy Fork(油猴脚本版 v1.1.2)
  创建于       2026-06-15
  更新于       2026-06-15    ← 同一天,之后没动过
  总安装量     8
  日安装量     0
  评分         0

GitHub(Chrome 扩展版 v2.0.0)
  提交         2
  star         0
  fork         0
  issue / PR   0
  仓库创建     2026-06-15 16:12:06 UTC
  最后 push    2026-06-15 16:20:55 UTC   ← 相隔 8 分 49 秒

版本号那件事也说一下:油猴脚本是 1.1.2,扩展版直接叫 2.0.0。这不是 semver 上的延续,是我想让“扩展版”听起来像个新东西。同一天做的两个壳,一个 1.1.2,一个 2.0.0,中间没有任何 1.2 到 1.9。

PRD 里写“先发帖给社区佬友内测”,Phase 3 的门槛写“若反馈极佳”。8 次安装,0 条评分,0 个 issue。门槛没跨过去,门后面的活先干完了。

八、我从这个项目里拿走的

一、代码里最先接触到的那份原始规格,会否决掉一部分需求。 这里是社区准则原文——它把“AI 味浓度”这个连续量,明确成了二值红线。我在敲兜底规则时已经读到了答案,但 PRD 停在了凌晨那一版。写完代码回头重读一遍需求文档,成本极低,我没做。

二、兜底代码要按“会发生的失败”写,不是按“想得到的失败”写。 getLatestGuidelines() 的 catch 分支为断网准备,而现实里更可能的是 200 加一份空壳。区别是致命的:断网会喊,内容不对会假装成功,还会打一行“同步成功”的日志。日志说了成功,不代表拿到的东西是对的——它只证明请求没抛异常。 一个内容合理性断言就能把这类失败从静默变成可见。

三、有 AI 之后,路线图会按「好生成」重排。 Phase 3 的架构迁移是确定性翻译,一轮就出;Phase 2 的 UI 打磨没有验收标准,也没有任何东西会因为它缺席而报错。于是难的做完了,用户看到的还是 alert 框。判断一件事该不该先做,要看它在用户那边值多少,不能看它在编辑器里出得多顺。

九、查不到的

fetch('/guidelines') 在真实浏览器里到底返回什么。 我从命令行抓只拿到 Cloudflare 的 403。这决定了第四节里那条静默失败路径是“确实在发生”还是“只是没被挡住”,我现在给不出答案。要验证只需要装上扩展、开控制台点一次按钮——我写这篇的时候没有再去装。

8 次安装里有没有一个人真的点过那个按钮。 没有任何遥测(这是有意的,PRD 的核心原则就是数据不外传),所以安装量之外我什么都不知道。0 条评分、0 个 issue 只说明没人来说话,不说明没人用过。

油猴脚本从 1.0 到 1.1.2 中间改了什么。 Greasy Fork 上创建和更新都是 2026-06-15,中间的版本我本地没留,那份代码不在这个仓库里。扩展版的两个提交是全新起点,不是从脚本的历史上长出来的。

PRD 是几点开始写的。 文件系统只记录最后修改时间 01:18。前面花了多久、改过几版,没有记录。


代码在 Ike-li/linux-do-guard-extension,MIT。油猴脚本版在 Greasy Fork,原版脚本和 ziyuxingyuan 合作。如果你要装,先看第四节——在我补上那个内容断言之前,你没法从界面上分辨模型是在对照准则判你的帖子,还是在对照一片空白。