diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 2bc7c3b7..d1f9e4b0 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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" }, diff --git a/apps/mobile/src/components/MobileWebClipCapture.tsx b/apps/mobile/src/components/MobileWebClipCapture.tsx new file mode 100644 index 00000000..6763a643 --- /dev/null +++ b/apps/mobile/src/components/MobileWebClipCapture.tsx @@ -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 ( + + 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} + /> + + ); +} + +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, + }, +}); diff --git a/apps/mobile/src/lib/mobile-web-clip.test.ts b/apps/mobile/src/lib/mobile-web-clip.test.ts index 6ae04b56..49e92ff9 100644 --- a/apps/mobile/src/lib/mobile-web-clip.test.ts +++ b/apps/mobile/src/lib/mobile-web-clip.test.ts @@ -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: ` +
+

这是浏览器渲染后提取的正文。

+ 文章配图 + 相关文章 +
+ `, + }, + { 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 () => { diff --git a/apps/mobile/src/lib/mobile-web-clip.ts b/apps/mobile/src/lib/mobile-web-clip.ts index f02fc6a8..efbfc0f1 100644 --- a/apps/mobile/src/lib/mobile-web-clip.ts +++ b/apps/mobile/src/lib/mobile-web-clip.ts @@ -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(/]*>([\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) => { diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index d2bea8b9..e4877c89 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -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(null); + const [incomingClipCaptureUrl, setIncomingClipCaptureUrl] = useState(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 = ({ 正在剪藏文章 正在提取标题、正文和图片链接… + {incomingClipCaptureUrl ? ( + + ) : null} diff --git a/bun.lock b/bun.lock index 55b71df6..412f50a7 100644 --- a/bun.lock +++ b/bun.lock @@ -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", },