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(
+ "",
+ );
+ 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\n\n`;
+ return `\n\n\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",
},