fix(mobile): render WeChat articles before clipping

This commit is contained in:
tianma-if committed 2026-07-31 13:02:12 +08:00
1 parent ae50c746be
commit e69d4dca28
6 files changed
+302 -14

No files matched your search

+1
View File
@@ -51,6 +51,7 @@
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.1",
"react-native-worklets": "~0.10.0",
"semver": "^7.8.5"
},
@@ -0,0 +1,158 @@
import { useEffect, useRef } from "react";
import { StyleSheet, View } from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import type { MobileRenderedWebPage } from "../lib/mobile-web-clip";
type MobileWebClipCaptureProps = {
onCaptured: (page: MobileRenderedWebPage) => void;
onFailed: (message: string) => void;
url: string;
};
type CaptureMessage =
| {
contentHtml: string;
finalUrl: string;
status: "captured";
title: string;
}
| {
message: string;
status: "failed";
};
const CAPTURE_SCRIPT = `
(function () {
var attempts = 0;
var completed = false;
function post(payload) {
if (completed) return;
completed = true;
window.ReactNativeWebView.postMessage(JSON.stringify(payload));
}
function text(selector) {
var element = document.querySelector(selector);
return element && element.textContent ? element.textContent.trim() : "";
}
function capture() {
var content = document.getElementById("js_content")
|| document.querySelector("article")
|| document.querySelector("main");
var contentText = content && content.textContent ? content.textContent.trim() : "";
if (content && contentText) {
var titleMeta = document.querySelector('meta[property="og:title"], meta[name="twitter:title"]');
post({
status: "captured",
title: (titleMeta && titleMeta.getAttribute("content"))
|| text("#activity-name")
|| text(".rich_media_title")
|| document.title
|| "",
contentHtml: content.innerHTML,
finalUrl: window.location.href
});
return;
}
var errorText = text(".weui-msg__title") || text(".weui-msg__desc");
if (errorText) {
post({ status: "failed", message: errorText });
return;
}
attempts += 1;
if (attempts >= 50) {
post({ status: "failed", message: "页面加载完成,但没有找到可剪藏的正文。" });
return;
}
window.setTimeout(capture, 250);
}
capture();
})();
true;
`;
export default function MobileWebClipCapture({
onCaptured,
onFailed,
url,
}: MobileWebClipCaptureProps) {
const completedRef = useRef(false);
useEffect(() => {
completedRef.current = false;
const timeout = setTimeout(() => {
if (!completedRef.current) {
completedRef.current = true;
onFailed("微信文章加载超时。");
}
}, 20_000);
return () => clearTimeout(timeout);
}, [onFailed, url]);
const fail = (message: string) => {
if (completedRef.current) return;
completedRef.current = true;
onFailed(message);
};
const handleMessage = (event: WebViewMessageEvent) => {
if (completedRef.current) return;
try {
const message = JSON.parse(event.nativeEvent.data) as CaptureMessage;
if (message.status === "captured" && message.contentHtml.trim()) {
completedRef.current = true;
onCaptured({
contentHtml: message.contentHtml,
finalUrl: message.finalUrl,
title: message.title,
});
return;
}
fail(message.status === "failed" ? message.message : "没有找到可剪藏的正文。");
} catch {
fail("无法解析微信文章正文。");
}
};
return (
<View pointerEvents="none" style={styles.host}>
<WebView
domStorageEnabled
injectedJavaScript={CAPTURE_SCRIPT}
javaScriptEnabled
mixedContentMode="compatibility"
onError={(event) => fail(event.nativeEvent.description || "微信文章加载失败。")}
onHttpError={(event) => fail(`微信文章请求失败(HTTP ${event.nativeEvent.statusCode})。`)}
onMessage={handleMessage}
originWhitelist={["https://*", "http://*"]}
setSupportMultipleWindows={false}
sharedCookiesEnabled
source={{ uri: url }}
style={styles.webView}
/>
</View>
);
}
const styles = StyleSheet.create({
host: {
height: 2,
left: 0,
opacity: 0.01,
overflow: "hidden",
position: "absolute",
top: 0,
width: 2,
},
webView: {
height: 2,
width: 2,
},
});
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import {
buildMobileWebClipDraft,
buildMobileWebClipDraftFromRenderedPage,
extractPageTitle,
getSharedWebUrl,
htmlToMarkdown,
@@ -52,6 +53,33 @@ describe("mobile web clip", () => {
expect(draft.contentMarkdown).toContain("2026-07-31T00:00:00.000Z");
});
test("builds a WeChat clip from a rendered WebView page", () => {
const draft = buildMobileWebClipDraftFromRenderedPage(
"https://mp.weixin.qq.com/s/rendered",
{
title: "渲染后的公众号标题 &amp; 更多",
finalUrl: "https://mp.weixin.qq.com/s/rendered",
contentHtml: `
<section>
<p>这是浏览器渲染后提取的正文。</p>
<img data-src="//mmbiz.qpic.cn/example.jpg" alt="文章配图">
<a href="/s/related">相关文章</a>
</section>
`,
},
{ capturedAt: new Date("2026-07-31T00:00:00.000Z") },
);
expect(draft.title).toBe("渲染后的公众号标题 & 更多");
expect(draft.contentMarkdown).toContain("这是浏览器渲染后提取的正文。");
expect(draft.contentMarkdown).toContain(
"![文章配图](https://mmbiz.qpic.cn/example.jpg)",
);
expect(draft.contentMarkdown).toContain(
"[相关文章](https://mp.weixin.qq.com/s/related)",
);
});
test("keeps the source URL when fetching fails", async () => {
const draft = await buildMobileWebClipDraft("https://mp.weixin.qq.com/s/offline", {
fetcher: async () => {
+51 -8
View File
@@ -11,6 +11,12 @@ export type MobileWebClipDraft = {
title: string;
};
export type MobileRenderedWebPage = {
contentHtml: string;
finalUrl?: string;
title?: string;
};
const URL_PATTERN = /https?:\/\/[^\s<>"'))]+/i;
const WECHAT_ARTICLE_HOSTS = new Set(["mp.weixin.qq.com"]);
const BLOCK_TAGS = new Set([
@@ -87,6 +93,7 @@ export const buildMobileWebClipDraft = async (
const fetcher = options.fetcher ?? fetch;
const capturedAt = options.capturedAt ?? new Date();
let html = "";
let finalUrl = sourceUrl;
try {
const response = await fetcher(sourceUrl, {
@@ -97,6 +104,7 @@ export const buildMobileWebClipDraft = async (
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
finalUrl = response.url || sourceUrl;
html = await response.text();
} catch {
return buildFallbackDraft(sourceUrl, capturedAt);
@@ -106,13 +114,30 @@ export const buildMobileWebClipDraft = async (
const articleHtml = isWeChatArticleUrl(sourceUrl)
? extractElementInnerHtmlById(html, "js_content")
: extractFirstElementInnerHtml(html, ["article", "main"]);
const body = htmlToMarkdown(articleHtml || "");
return buildMobileWebClipDraftFromRenderedPage(
sourceUrl,
{
contentHtml: articleHtml,
finalUrl,
title,
},
{ capturedAt },
);
};
export const buildMobileWebClipDraftFromRenderedPage = (
sourceUrl: string,
page: MobileRenderedWebPage,
options: {
capturedAt?: Date;
} = {},
): MobileWebClipDraft => {
const capturedAt = options.capturedAt ?? new Date();
const title = normalizeInlineText(page.title ?? "") || hostnameTitle(sourceUrl);
const body = htmlToMarkdown(page.contentHtml, page.finalUrl || sourceUrl);
if (!body) {
return {
...buildFallbackDraft(sourceUrl, capturedAt),
title,
};
return { ...buildFallbackDraft(sourceUrl, capturedAt), title };
}
return {
@@ -145,7 +170,7 @@ export const extractPageTitle = (html: string) => {
return titleMatch ? normalizeInlineText(stripTags(titleMatch[1])) : "";
};
export const htmlToMarkdown = (html: string) => {
export const htmlToMarkdown = (html: string, baseUrl?: string) => {
if (!html.trim()) {
return "";
}
@@ -159,8 +184,12 @@ export const htmlToMarkdown = (html: string) => {
if (!src || src.startsWith("data:")) {
return "";
}
const resolvedSrc = resolveWebUrl(src, baseUrl);
if (!resolvedSrc) {
return "";
}
const alt = normalizeInlineText(readAttribute(tag, "alt") || "图片");
return `\n\n![${escapeMarkdownText(alt)}](${src})\n\n`;
return `\n\n![${escapeMarkdownText(alt)}](${resolvedSrc})\n\n`;
});
value = value.replace(/<a\b[^>]*>([\s\S]*?)<\/a\s*>/gi, (full, inner: string) => {
@@ -169,7 +198,10 @@ export const htmlToMarkdown = (html: string) => {
if (!href || /^javascript:/i.test(href)) {
return label;
}
return `[${escapeMarkdownText(label || href)}](${href})`;
const resolvedHref = resolveWebUrl(href, baseUrl);
return resolvedHref
? `[${escapeMarkdownText(label || resolvedHref)}](${resolvedHref})`
: label;
});
value = value
@@ -277,6 +309,17 @@ const readAttribute = (tag: string, name: string) => {
const stripTags = (value: string) => value.replace(/<[^>]*>/g, " ");
const normalizeInlineText = (value: string) => decodeHtmlEntities(value).replace(/\s+/g, " ").trim();
const escapeMarkdownText = (value: string) => value.replace(/([\\[\]])/g, "\\$1");
const resolveWebUrl = (value: string, baseUrl?: string) => {
try {
const url = baseUrl ? new URL(value, baseUrl) : new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return "";
}
return /^https?:\/\//i.test(value) ? value : url.toString();
} catch {
return "";
}
};
const decodeHtmlEntities = (value: string) =>
value.replace(/&(#x?[0-9a-f]+|[a-z][a-z0-9]+);?/gi, (entity, key: string) => {
+63 -6
View File
@@ -108,6 +108,7 @@ import { AccountSecurityPanel } from "./AccountSecurityModal";
import { beginEditorStartup, markStartup, recordEditorStartup } from "../lib/startup-performance";
import { prepareUploadAsset } from "../lib/mobile-image-upload";
import EditorRuntimePrewarm from "../components/EditorRuntimePrewarm";
import MobileWebClipCapture from "../components/MobileWebClipCapture";
import { showEdgeEverKeyboard } from "../../modules/edgeever-keyboard";
import LocalTiptapEditor, { type LocalTiptapEditorRef } from "../components/LocalTiptapEditor";
import { resolveMobileThemeStyles, useMobileTheme, type MobileResolvedTheme } from "../lib/mobile-theme";
@@ -116,7 +117,10 @@ import { MobileMermaidDiagram, MobileMermaidProvider } from "../components/Mobil
import { getMobileMarkdownFenceLanguage, trimMobileMarkdownFenceContent } from "../lib/mobile-mermaid";
import {
buildMobileWebClipDraft,
buildMobileWebClipDraftFromRenderedPage,
getSharedWebUrl,
isWeChatArticleUrl,
type MobileRenderedWebPage,
type MobileSharedPayload,
type MobileWebClipDraft,
} from "../lib/mobile-web-clip";
@@ -202,6 +206,7 @@ export const WorkspaceScreen = ({
const [searchText, setSearchText] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [incomingClipDraft, setIncomingClipDraft] = useState<MobileWebClipDraft | null>(null);
const [incomingClipCaptureUrl, setIncomingClipCaptureUrl] = useState<string | null>(null);
const [isImportingShare, setIsImportingShare] = useState(false);
const [notesActionsOpen, setNotesActionsOpen] = useState(false);
const [notebookPickerOpen, setNotebookPickerOpen] = useState(false);
@@ -564,6 +569,43 @@ export const WorkspaceScreen = ({
setCreateOpen(true);
};
const openIncomingClipDraft = useCallback((draft: MobileWebClipDraft) => {
beginEditorStartup();
setIncomingClipDraft(draft);
setActiveView("notes");
setMemoView("notebook");
setCreateOpen(true);
}, []);
const finishIncomingShare = useCallback(() => {
setIncomingClipCaptureUrl(null);
setIsImportingShare(false);
onIncomingShareHandledRef.current?.();
}, []);
const handleRenderedClipCaptured = useCallback((page: MobileRenderedWebPage) => {
if (!incomingClipCaptureUrl) return;
openIncomingClipDraft(
buildMobileWebClipDraftFromRenderedPage(incomingClipCaptureUrl, page),
);
finishIncomingShare();
}, [finishIncomingShare, incomingClipCaptureUrl, openIncomingClipDraft]);
const handleRenderedClipFailed = useCallback((message: string) => {
const sourceUrl = incomingClipCaptureUrl;
if (!sourceUrl) return;
setIncomingClipCaptureUrl(null);
void buildMobileWebClipDraft(sourceUrl)
.then((draft) => {
openIncomingClipDraft(draft);
Alert.alert(
"正文剪藏失败",
`${message} 已保留文章链接,你可以稍后重新分享重试。`,
);
})
.finally(finishIncomingShare);
}, [finishIncomingShare, incomingClipCaptureUrl, openIncomingClipDraft]);
useEffect(() => {
const sourceUrl = incomingShareUrl;
if (!sourceUrl) {
@@ -591,16 +633,18 @@ export const WorkspaceScreen = ({
let active = true;
processedShareUrlRef.current = sourceUrl;
setIsImportingShare(true);
if (isWeChatArticleUrl(sourceUrl)) {
setIncomingClipCaptureUrl(sourceUrl);
return () => {
active = false;
};
}
void buildMobileWebClipDraft(sourceUrl)
.then((draft) => {
if (!active) {
return;
}
beginEditorStartup();
setIncomingClipDraft(draft);
setActiveView("notes");
setMemoView("notebook");
setCreateOpen(true);
openIncomingClipDraft(draft);
})
.catch(() => {
if (active) {
@@ -617,7 +661,13 @@ export const WorkspaceScreen = ({
return () => {
active = false;
};
}, [incomingSharePayloads.length, incomingShareUrl, notebooks.length, notebooksQuery.isSuccess]);
}, [
incomingSharePayloads.length,
incomingShareUrl,
notebooks.length,
notebooksQuery.isSuccess,
openIncomingClipDraft,
]);
useEffect(() => {
if (selectedMemo && !selectedMemo.isDeleted) {
@@ -1219,6 +1269,13 @@ export const WorkspaceScreen = ({
<Text style={styles.shareImportTitle}>正在剪藏文章</Text>
<Text style={styles.shareImportDescription}>正在提取标题、正文和图片链接…</Text>
</View>
{incomingClipCaptureUrl ? (
<MobileWebClipCapture
onCaptured={handleRenderedClipCaptured}
onFailed={handleRenderedClipFailed}
url={incomingClipCaptureUrl}
/>
) : null}
</View>
</Modal>
+1
View File
@@ -139,6 +139,7 @@
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-webview": "13.16.1",
"react-native-worklets": "~0.10.0",
"semver": "^7.8.5",
},