写了个 Chrome 插件:合法读到 WordPress 会员站的全文(MV3 实现笔记)

📖 本文共 4,167 字,阅读需要 14 分钟

上一篇《WordPress 会员站的隐形漏洞:付费墙挡住了人,却挡不住 API》里我提到一件事:很多站的付费墙只挡住了「页面」这一个出口,/wp-json/wp/v2/posts 照样把全文原样送出来。

文章结尾留了个问题——能不能把这件事做成一个好用的工具,而不用每次手动 curl?

于是就有了这个几十行的小插件。

一、它到底做什么

打开某行业媒体站(本文代号 E 站)的任意一篇文章后:

flowchart LR
    A[文章页 content.js<br/>从 URL 取出 slug] -->|runtime.sendMessage| B[background.js<br/>Service Worker]
    B -->|chrome.tabs.create| C[扩展自己的页面<br/>article.html?slug=...]
    C -->|fetch REST API| D["/wp-json/wp/v2/posts?slug=xxx"]
    D -->|content.rendered| E[DOMParser 清洗]
    E --> F[渲染出完整正文]

用户视角只有三件事:

  1. 原文章标签页完全不动 —— 不注入、不改 DOM、不打断阅读;
  2. 插件在旁边新开一个干净的阅读页,标题、日期、正文齐全;
  3. 万一接口挂了,页面会明确报错,并给一个「返回原文章」的链接。

二、先划边界:哪条线没踩

这部分是我想先讲清楚的,因为「绕过付费墙」这个说法容易被误读。

这个插件做了 它没有做
请求站点对游客公开的 /wp-json/wp/v2/posts 不伪造身份、不注入 cookie 去冒充会员
在扩展自己的页面里渲染接口返回的 HTML 不破解前端的模糊块、不 patch 站点 JS
只要 tabs 权限 + 该站两个域名的 host 权限 不要 <all_urls>、不碰其它站、不上报任何数据

一句话:它是用站方自己公开的接口,把浏览器里已经拿到、只是被 JS 遮住的内容读出来。

不是撬锁,是那扇门本来就没锁。

不过话说回来——如果这个站的付费墙是真金白银卖的,那这个插件确实能让墙形同虚设。所以我这篇文章后半段其实是写给站长的自查清单,而不是教你白嫖。

三、MV3 的骨架:四个部件

Manifest V3 里我把职责切得很干净:

{
  "manifest_version": 3,
  "name": "Ecotextile Full Article",
  "version": "0.3.0",
  "permissions": ["tabs"],
  "host_permissions": [
    "https://www.XXXDD.com/*",
    "https://XXXDD.com/*"
  ],
  "background": { "service_worker": "background.js" },
  "content_scripts": [{
    "matches": ["https://www.XXXDD.com/*", "https://XXXDD.com/*"],
    "js": ["content.js"],
    "run_at": "document_idle"
  }]
}

四个部件的分工:

部件 职责 关键限制
content.js 在文章页里认出「这是一篇文章」,取出 slug 跑在隔离世界,最好别做重活
background.js 只负责开新标签页 Service Worker,没有 DOM
article.html 扩展自己的页面,真正的渲染宿主 有完整 DOM 和站点权限
article.js 调接口 → 清洗 → 渲染 同上

四、四个真坑(这版就是被它们逼出来的)

坑 1:DOMParser is not defined

第一版我把「取数据 + 清洗 HTML」都塞在 background.js 里,直接报这个错。

原因很直白:MV3 的 background 是 Service Worker,没有 window、没有 document。 凡是依赖 DOM 的操作都不能放在那儿。

→ 拆开:background 只做「开页面」(chrome.tabs.create),清洗和渲染搬到扩展页面。

坑 2:data:text/html 开出来的 Tab 不是扩展页面

为了让新标签页能跑自己的脚本,我试过把整页 HTML 拼成 data:text/html,... 塞进 chrome.tabs.create。结果那个页面既没有扩展的 origin,也用不了扩展的 DOM 环境,脚本和权限全废。

→ 正确姿势是让扩展页面自己成为一个地址:

// background.js
chrome.runtime.onMessage.addListener((message) => {
  if (message?.type !== "OPEN_FULL_ARTICLE") return;

  const url = chrome.runtime.getURL(
    "article.html?slug=" + encodeURIComponent(message.slug) +
    "&source=" + encodeURIComponent(message.sourceUrl || "")
  );

  chrome.tabs.create({ url, active: true });
});

这样打开的页面是 chrome-extension://... 的正式扩展页面:有完整 DOM,也能凭 host_permissions 跨域 fetch。

坑 3:怎么在多级路径里稳定认出 slug

E 站的文章 URL 形如 /分类/子分类/<slug>/,而站点还有 login、faqs、contact 这类固定页。策略是「取最后一段 + 拉黑名单」:

// content.js
const path = location.pathname.replace(/\/+$/, "");
const parts = path.split("/").filter(Boolean);
if (parts.length < 2) return;            // 首页/一级目录直接放弃

const slug = parts[parts.length - 1];
if (!slug || slug.length < 3) return;

const ignored = new Set([
  "login", "faqs", "contact", "about-us", "privacy-statement",
  "terms-conditions", "membership-account", "magazine"
]);
if (ignored.has(slug.toLowerCase())) return;

chrome.runtime.sendMessage({
  type: "OPEN_FULL_ARTICLE",
  slug,
  sourceUrl: location.href
});

简单粗暴但够用:宁可漏判,也不要误判(误判的后果是每打开一个页面都弹一个标签页)。

坑 4:清洗接口返回的 HTML

REST 给的是 content.rendered,是一段可信度不明的 HTML——里面可能有 script、iframe、form。这里正好用上扩展页面有真 DOM 的优势:

function cleanHtml(html) {
  const doc = new DOMParser().parseFromString(
    `<div id="root">${html}</div>`,
    "text/html"
  );
  const root = doc.getElementById("root");

  root.querySelectorAll("script, style, iframe, form, noscript")
    .forEach(el => el.remove());

  root.querySelectorAll("a").forEach(a => {
    a.target = "_blank";
    a.rel = "noopener noreferrer";
  });

  return root.innerHTML;
}

两个细节:

  • DOMParser 只解析不执行:script 不会被跑、img 不会自动发请求(src 只有插入真文档才加载),所以先解析、删干净、再 innerHTML 落地是安全的顺序;
  • 所有外链强制新窗口 + noopener noreferrer,避免阅读页被导航走,也避免 window.opener 被反向利用。

五、为什么这套东西真的能跑通

回看上一篇的核心结论:会员插件的限制基本都挂在 the_content 过滤器上,那是主题渲染层;而 REST API 走的是另一条平行路径,默认不经过这个过滤器。

flowchart LR
    A[数据库 post_content<br/>完整正文] --> B[路径 1: 主题渲染]
    A --> C[路径 2: REST API]
    B --> D[the_content 过滤器<br/>会员插件在这里拦截]
    D --> E[页面输出: 只给引导段]
    C --> F[content.rendered]
    F --> G[API 输出: 全文]

所以这个插件里没有任何黑科技:

  • 没破解加密;
  • 没冒充会员;
  • 只是访问了一个默认不认证的公开接口。

真正需要修的从来不是浏览器端,而是服务端那扇没关的门。

六、给站长的三行自查

如果你自己也在跑 WordPress 会员站,花一分钟自检:

  1. 用游客态浏览器(无痕窗口)打开:
    https://你的站/wp-json/wp/v2/posts?per_page=1&_fields=id,slug,content
    看看返回的是不是全文。
  2. 如果是全文 → 在 rest_authentication_errors 里给游客直接 401,只放行 oembed/1.0(那个只给嵌入信息,不含正文)。
  3. 顺手看看 /wp-json/wp/v2/users、/media、以及 RSS feed 是不是也在裸奔——内容保护必须把所有平行出口一起关,只关一个等于没关。

七、结语

插件本体几十行,写它前后不到一小时。真正值得写下来的其实是三件事:

  • MV3 里 background 没有 DOM,「读数据」和「渲染」必须分开;
  • 要在新标签页跑自己的代码,必须用 chrome.runtime.getURL() 的扩展页面,别用 data: URL;
  • 以及最重要的一条:付费墙如果只挡页面,就等于没挡。

版本 v0.3.0,Manifest V3,权限仅 tabs + 单站 host 权限。本文仅作技术复盘与站长自查用途。

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部