上一篇《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[渲染出完整正文]
用户视角只有三件事:
- 原文章标签页完全不动 —— 不注入、不改 DOM、不打断阅读;
- 插件在旁边新开一个干净的阅读页,标题、日期、正文齐全;
- 万一接口挂了,页面会明确报错,并给一个「返回原文章」的链接。
二、先划边界:哪条线没踩
这部分是我想先讲清楚的,因为「绕过付费墙」这个说法容易被误读。
| 这个插件做了 | 它没有做 |
|---|---|
请求站点对游客公开的 /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 会员站,花一分钟自检:
- 用游客态浏览器(无痕窗口)打开:
https://你的站/wp-json/wp/v2/posts?per_page=1&_fields=id,slug,content
看看返回的是不是全文。 - 如果是全文 → 在
rest_authentication_errors里给游客直接 401,只放行oembed/1.0(那个只给嵌入信息,不含正文)。 - 顺手看看
/wp-json/wp/v2/users、/media、以及 RSS feed 是不是也在裸奔——内容保护必须把所有平行出口一起关,只关一个等于没关。
七、结语
插件本体几十行,写它前后不到一小时。真正值得写下来的其实是三件事:
- MV3 里 background 没有 DOM,「读数据」和「渲染」必须分开;
- 要在新标签页跑自己的代码,必须用
chrome.runtime.getURL()的扩展页面,别用data:URL; - 以及最重要的一条:付费墙如果只挡页面,就等于没挡。
版本 v0.3.0,Manifest V3,权限仅
tabs+ 单站 host 权限。本文仅作技术复盘与站长自查用途。
