diff --git a/src/__tests__/adapters-behavior.test.ts b/src/__tests__/adapters-behavior.test.ts
index a913698..ebbdda5 100644
--- a/src/__tests__/adapters-behavior.test.ts
+++ b/src/__tests__/adapters-behavior.test.ts
@@ -116,6 +116,27 @@ describe("adapter behavior", () => {
expect(processed).toContain("plain article");
});
+ it("wechat postProcess promotes js_content into a focused article document", () => {
+ const html = `
Fallback
+ Article Title
+ Author Name
+
+
real article body
+
![]()
+
+ outside noise
+ `;
+
+ const processed = wechatAdapter.postProcess!(html);
+
+ expect(processed).toContain('data-adapter="wechat"');
+ expect(processed).toContain("Article Title
");
+ expect(processed).toContain("作者: Author Name");
+ expect(processed).toContain("real article body");
+ expect(processed).toContain('src="https://mmbiz.qpic.cn/mmbiz_png/a/640"');
+ expect(processed).not.toContain("outside noise");
+ });
+
it("keeps feishu adapter as no-op extraction", async () => {
expect(feishuAdapter.alwaysBrowser).toBe(true);
expect(await feishuAdapter.extract({} as any, new Map())).toBeNull();
diff --git a/src/__tests__/cache-edge.test.ts b/src/__tests__/cache-edge.test.ts
index 37dac33..033b308 100644
--- a/src/__tests__/cache-edge.test.ts
+++ b/src/__tests__/cache-edge.test.ts
@@ -74,6 +74,31 @@ describe("cache edge behavior", () => {
expect(result).toBeNull();
});
+ it("returns null for empty cached content", async () => {
+ const { env, mocks } = createCacheEnv();
+ mocks.kvGet.mockResolvedValueOnce(JSON.stringify({
+ content: " ",
+ method: "readability+turndown",
+ title: "",
+ }));
+
+ const result = await getCached(env, "https://example.com/empty-cache", "markdown");
+
+ expect(result).toBeNull();
+ });
+
+ it("does not store empty cache content", async () => {
+ const { env, mocks } = createCacheEnv();
+
+ await setCache(env, "https://example.com/empty-write", "markdown", {
+ content: "",
+ method: "readability+turndown",
+ title: "",
+ });
+
+ expect(mocks.kvPut).not.toHaveBeenCalled();
+ });
+
it("does not use hot cache when ttl is zero", async () => {
const { env, mocks } = createCacheEnv();
const url = "https://example.com/no-hot-cache";
diff --git a/src/__tests__/index-mocked-branches.test.ts b/src/__tests__/index-mocked-branches.test.ts
index 8fd9efc..7788df5 100644
--- a/src/__tests__/index-mocked-branches.test.ts
+++ b/src/__tests__/index-mocked-branches.test.ts
@@ -34,6 +34,9 @@ const mocked = vi.hoisted(() => ({
fetchViaProxy: vi.fn(),
fetchViaProxyPool: vi.fn(),
},
+ browserUse: {
+ fetchViaBrowserUse: vi.fn(),
+ },
}));
vi.mock("cloudflare:sockets", () => ({
@@ -77,6 +80,10 @@ vi.mock("../proxy", () => ({
fetchViaProxyPool: mocked.proxy.fetchViaProxyPool,
}));
+vi.mock("../browser-use", () => ({
+ fetchViaBrowserUse: mocked.browserUse.fetchViaBrowserUse,
+}));
+
import worker from "../index";
import { createProxyRetrySignal } from "../browser/proxy-retry";
@@ -145,6 +152,7 @@ beforeEach(() => {
attempts: 1,
errors: [],
});
+ mocked.browserUse.fetchViaBrowserUse.mockResolvedValue(null);
});
describe("index mocked branch coverage", () => {
@@ -208,6 +216,55 @@ describe("index mocked branch coverage", () => {
expect(payload.message).toContain("configure PROXY_URL");
});
+ it("does not cache empty output when anonymous always-browser fallbacks return no content", async () => {
+ mocked.browser.alwaysNeedsBrowser.mockReturnValue(true);
+ mocked.converter.htmlToMarkdown.mockImplementation((html: string) => ({
+ markdown: html.trim() ? "# md body" : "",
+ title: "",
+ contentHtml: html,
+ }));
+
+ const req = new Request("https://md.example.com/https://example.com/needs-browser?raw=true", {
+ headers: { Accept: "application/json" },
+ });
+ const res = await worker.fetch(req, createMockEnv().env, mockCtx());
+ const payload = await res.json() as { error?: string; message?: string };
+
+ expect(res.status).toBe(502);
+ expect(payload.error).toBe("Fetch Failed");
+ expect(payload.message).toContain("requires browser or proxy access");
+ expect(mocked.cache.setCache).not.toHaveBeenCalled();
+ });
+
+ it("uses static WeChat fetch for anonymous users when remote browser and proxy produce no content", async () => {
+ mocked.browser.alwaysNeedsBrowser.mockReturnValue(true);
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response("wechat article
", {
+ status: 200,
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ mocked.converter.htmlToMarkdown.mockImplementation((html: string) => ({
+ markdown: html.includes("wechat article") ? "# wechat article" : "",
+ title: "wechat",
+ contentHtml: "wechat article",
+ }));
+
+ const req = new Request("https://md.example.com/https://mp.weixin.qq.com/s/abc?raw=true", {
+ headers: { Accept: "text/markdown" },
+ });
+ const res = await worker.fetch(req, createMockEnv().env, mockCtx());
+
+ expect(res.status).toBe(200);
+ expect(await res.text()).toContain("proxied:# wechat article");
+ expect(fetchMock).toHaveBeenCalled();
+ const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
+ expect((init.headers as Record)["User-Agent"]).toContain("MicroMessenger");
+ expect((init.headers as Record).Referer).toBe("https://mp.weixin.qq.com/");
+ expect(res.headers.get("X-Markdown-Fallbacks")).toContain("wechat_static_fallback");
+ });
+
it("retries through proxy and succeeds after PROXY_RETRY signal", async () => {
mocked.browser.alwaysNeedsBrowser.mockReturnValue(true);
mocked.browser.fetchWithBrowser.mockRejectedValueOnce(
diff --git a/src/browser/adapters/wechat.ts b/src/browser/adapters/wechat.ts
index 9b96a26..0969fdf 100644
--- a/src/browser/adapters/wechat.ts
+++ b/src/browser/adapters/wechat.ts
@@ -2,6 +2,64 @@ import type { SiteAdapter, ExtractResult } from "../../types";
import { WECHAT_UA } from "../../config";
import { STEALTH_SCRIPT } from "../stealth";
import { createProxyRetrySignal } from "../proxy-retry";
+import { parseHTML } from "linkedom";
+
+function escapeHtml(value: string): string {
+ return value.replace(/[&<>"']/g, (ch) => {
+ switch (ch) {
+ case "&": return "&";
+ case "<": return "<";
+ case ">": return ">";
+ case '"': return """;
+ default: return "'";
+ }
+ });
+}
+
+function normalizeText(value: string | null | undefined): string {
+ return (value || "").replace(/\s+/g, " ").trim();
+}
+
+function extractWechatArticleHtml(html: string): string | null {
+ if (!html.includes("js_content")) return null;
+
+ try {
+ const { document } = parseHTML(html);
+ const content = document.querySelector("#js_content") as any;
+ if (!content) return null;
+
+ content.querySelectorAll?.("img[data-src]").forEach((img: any) => {
+ const real = img.getAttribute("data-src");
+ if (real) img.setAttribute("src", real);
+ });
+
+ const title = normalizeText(
+ (document.querySelector("#activity-name") as any)?.textContent ||
+ (document.querySelector("meta[property='og:title']") as any)?.getAttribute?.("content") ||
+ document.title,
+ );
+ const author = normalizeText((document.querySelector("#js_name") as any)?.textContent);
+ const publishTime = normalizeText(
+ (document.querySelector("[data-wechat-meta='publish_time']") as any)?.textContent,
+ );
+
+ const metaParts: string[] = [];
+ if (author) metaParts.push(`作者: ${escapeHtml(author)}
`);
+ if (publishTime) metaParts.push(`${escapeHtml(publishTime)}
`);
+
+ return [
+ "",
+ `${escapeHtml(title)}`,
+ "",
+ title ? `${escapeHtml(title)}
` : "",
+ ...metaParts,
+ content.outerHTML || content.innerHTML || "",
+ "",
+ ].join("");
+ } catch {
+ return null;
+ }
+}
export const wechatAdapter: SiteAdapter = {
match(url: string): boolean {
@@ -97,6 +155,42 @@ export const wechatAdapter: SiteAdapter = {
});
})()
`);
+
+ const articleHtml = await page.evaluate(`
+ (function() {
+ function esc(s) {
+ return String(s || '').replace(/[&<>"']/g, function(ch) {
+ return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[ch];
+ });
+ }
+ function txt(sel) {
+ var el = document.querySelector(sel);
+ return el ? (el.textContent || '').replace(/\\s+/g, ' ').trim() : '';
+ }
+ var content = document.getElementById('js_content');
+ if (!content) return '';
+ content.querySelectorAll('img[data-src]').forEach(function(img) {
+ var real = img.getAttribute('data-src');
+ if (real) img.setAttribute('src', real);
+ });
+ var title = txt('#activity-name') || document.title || '';
+ var author = txt('#js_name');
+ var publish = txt('[data-wechat-meta="publish_time"]');
+ var parts = [
+ '' + esc(title) + ''
+ ];
+ if (title) parts.push('' + esc(title) + '
');
+ if (author) parts.push('作者: ' + esc(author) + '
');
+ if (publish) parts.push('' + esc(publish) + '
');
+ parts.push(content.outerHTML || content.innerHTML || '');
+ parts.push('');
+ return parts.join('');
+ })()
+ `);
+ if (articleHtml && articleHtml.length > 200) {
+ return { html: articleHtml };
+ }
+
const html = await page.content();
return { html };
},
@@ -150,6 +244,6 @@ export const wechatAdapter: SiteAdapter = {
},
);
- return result;
+ return extractWechatArticleHtml(result) || result;
},
};
diff --git a/src/cache/index.ts b/src/cache/index.ts
index a8e99de..f9a431b 100644
--- a/src/cache/index.ts
+++ b/src/cache/index.ts
@@ -126,6 +126,7 @@ function parseCachedPayload(raw: string): CachedPayload | null {
if (
!parsed ||
typeof parsed.content !== "string" ||
+ parsed.content.trim().length === 0 ||
typeof parsed.method !== "string" ||
typeof parsed.title !== "string"
) {
@@ -311,6 +312,8 @@ export async function setCache(
engine?: string,
): Promise {
try {
+ if (!data.content.trim()) return;
+
const key = await cacheKey(url, format, selector, engine);
const effectiveTtl = ttl ?? getTtlForUrl(url);
diff --git a/src/handlers/convert.ts b/src/handlers/convert.ts
index 9066387..24dcc05 100644
--- a/src/handlers/convert.ts
+++ b/src/handlers/convert.ts
@@ -199,6 +199,7 @@ export function asFetchConvertError(error: unknown): ConvertError {
export function isLikelyChallengeHtml(body: string): boolean {
const lower = body.toLowerCase();
return (
+ isWechatVerificationHtml(body) ||
(lower.includes("passport.weibo") ||
lower.includes("qrcode_login") ||
lower.includes("login_type")) ||
@@ -213,6 +214,14 @@ export function isLikelyChallengeHtml(body: string): boolean {
);
}
+export function isWechatVerificationHtml(body: string): boolean {
+ return (
+ body.includes("wappoc_appmsgcaptcha") ||
+ body.includes("当前环境异常") ||
+ body.includes("完成验证后即可继续访问")
+ );
+}
+
// ─── CF REST API helpers ─────────────────────────────────────
export function getCfRestConfig(env: Env): CfRestConfig | null {
@@ -499,7 +508,9 @@ async function tryFetchAndParse(
}
// 早期浏览器路径 — 对总是需要浏览器的站点跳过多余的静态获取
- if (!finalHtml && alwaysNeedsBrowser(targetUrl) && browserAllowed) {
+ const requiresBrowser = alwaysNeedsBrowser(targetUrl);
+
+ if (!finalHtml && requiresBrowser && browserAllowed) {
const result = await tryBrowserRendering(
targetUrl, env, host, fallbacks, abortSignal, progress,
);
@@ -508,7 +519,7 @@ async function tryFetchAndParse(
method = "browser+readability+turndown";
browserRendered = true;
}
- } else if (!finalHtml && alwaysNeedsBrowser(targetUrl) && !browserAllowed) {
+ } else if (!finalHtml && requiresBrowser && !browserAllowed) {
// Anonymous 用户无 browser 权限 — 先走 Browser Use 远程浏览器,再降级住宅代理
if (env.BROWSER_USE_API_KEY) {
throwIfAborted(abortSignal);
@@ -529,6 +540,36 @@ async function tryFetchAndParse(
method = "proxy+readability+turndown";
}
}
+
+ // WeChat often serves usable article HTML to a MicroMessenger UA. Use this
+ // as a low-cost fallback for anonymous users before failing the request.
+ if (!finalHtml && targetUrl.includes("mp.weixin.qq.com")) {
+ const staticResult = await tryStaticFetch(
+ targetUrl, env, host, format, selector, forceBrowser, noCache, engine,
+ fallbacks, browserRendered, paywallDetected, sourceContentType,
+ resolvedUrl, method, progress, abortSignal, browserAllowed,
+ );
+ if (staticResult.earlyReturn) {
+ return staticResult.earlyReturn;
+ }
+ finalHtml = staticResult.finalHtml;
+ method = staticResult.method;
+ resolvedUrl = staticResult.resolvedUrl;
+ browserRendered = staticResult.browserRendered;
+ paywallDetected = staticResult.paywallDetected;
+ sourceContentType = staticResult.sourceContentType;
+ if (finalHtml) {
+ fallbacks.add("wechat_static_fallback");
+ }
+ }
+
+ if (!finalHtml) {
+ throw new ConvertError(
+ "Fetch Failed",
+ "This URL requires browser or proxy access, but no content was returned by the available fallback methods.",
+ 502,
+ );
+ }
} else if (!finalHtml) {
// 3. 静态获取
const staticResult = await tryStaticFetch(
@@ -549,6 +590,21 @@ async function tryFetchAndParse(
// 7. 去除