fix(mobile): render WeChat articles before clipping
This commit is contained in:
1 parent
ae50c746be
commit
e69d4dca28
6 files changed
+302
-14
No files matched your search
@@ -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: "渲染后的公众号标题 & 更多",
|
||||
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(
|
||||
"",
|
||||
);
|
||||
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 () => {
|
||||
|
||||
@@ -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\n\n`;
|
||||
return `\n\n\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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user