Files
geo/apps/desktop-client/src/main/adapters/common.ts
T
root 87e329207c feat(publish): add baijiahao and jianshu desktop adapters
Wires up Baijiahao (百家号) and Jianshu (简书) as first-class desktop
publish targets, with risk-control prompts surfaced in the runtime
controller and a normalized error message in publish records. Adds
external-link buttons in the publish management table, an asset
format conversion endpoint for cover image compatibility, and
reorders publish-status display priority so failures take precedence
over partial successes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:47:35 +08:00

347 lines
8.3 KiB
TypeScript

import type { DesktopArticleContent } from "@geo/shared-types";
import type { Session, WebContents, WebContentsView } from "electron/main";
import { marked } from "marked";
import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from "../user-agent";
export function normalizeText(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
export function normalizeRemoteUrl(value?: string | null): string | null {
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
if (/^(data|blob):/i.test(trimmed)) {
return trimmed;
}
if (/^https?:\/\//i.test(trimmed)) {
return trimmed;
}
if (/^\/\//.test(trimmed)) {
return `https:${trimmed}`;
}
if (/^[a-z][a-z\d+\-.]*:/i.test(trimmed)) {
return trimmed;
}
return `https://${trimmed.replace(/^\/+/, "")}`;
}
export interface NormalizeArticleHtmlOptions {
prepareMarkdown?: (markdown: string) => string;
}
export function normalizeArticleHtml(article: DesktopArticleContent, options: NormalizeArticleHtmlOptions = {}): string {
const markdown = article.markdown_content?.trim();
const source = markdown ? markdownToHtml(options.prepareMarkdown?.(markdown) ?? markdown) : article.html_content?.trim() || "";
let next = source.trim();
next = next.replace(/<section\b/gi, "<div").replace(/<\/section>/gi, "</div>");
next = next.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, "");
next = next.replace(/\sstyle="[^"]*"/gi, "");
next = next.replace(/\sdata-[a-z-]+="[^"]*"/gi, "");
next = next.replace(/<figure[^>]*>\s*(<img[\s\S]*?>)\s*<\/figure>/gi, "$1");
return next.trim();
}
function markdownToHtml(markdown: string): string {
const source = markdown.trim();
if (!source) {
return "";
}
return marked.parse(source, {
async: false,
gfm: true,
breaks: false,
}) as string;
}
export function extractImageSources(html: string): string[] {
const sources = new Set<string>();
for (const match of html.matchAll(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi)) {
const src = match[2]?.trim();
if (src) {
sources.add(src);
}
}
return [...sources];
}
export async function fetchImageBlob(sourceUrl: string): Promise<Blob | null> {
const normalizedUrl = normalizeRemoteUrl(sourceUrl);
if (!normalizedUrl) {
return null;
}
const response = await fetch(normalizedUrl).catch(() => null);
if (!response?.ok) {
return null;
}
const blob = await response.blob().catch(() => null);
if (!blob || !blob.type.startsWith("image/")) {
return null;
}
return blob;
}
export async function uploadHtmlImages(
html: string,
uploader: (sourceUrl: string) => Promise<string | null>,
): Promise<{ html: string; uploaded: Map<string, string> }> {
const sources = extractImageSources(html);
if (!sources.length) {
return {
html,
uploaded: new Map(),
};
}
const uploaded = new Map<string, string>();
for (const source of sources) {
const target = await uploader(source);
if (target) {
uploaded.set(source, target);
}
}
let next = html;
for (const [from, to] of uploaded.entries()) {
next = next.split(from).join(to);
}
return { html: next, uploaded };
}
export async function sessionFetchText(
session: Session,
input: string,
init?: RequestInit,
): Promise<string> {
const headers = new Headers(init?.headers);
if (!headers.has("user-agent")) {
headers.set("user-agent", STANDARD_USER_AGENT);
}
if (!headers.has("accept-language")) {
headers.set("accept-language", STANDARD_ACCEPT_LANGUAGES);
}
const response = await session.fetch(input, {
...init,
headers,
});
const text = await response.text();
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`);
}
return text;
}
export async function sessionFetchJson<T>(
session: Session,
input: string,
init?: RequestInit,
): Promise<T> {
const text = await sessionFetchText(session, input, init);
if (!text) {
return {} as T;
}
return JSON.parse(text) as T;
}
export interface PageFetchInit {
method?: string;
headers?: Record<string, string>;
body?: string;
credentials?: "include" | "same-origin" | "omit";
}
export async function sessionCookieFetchJson<T>(
session: Session,
url: string,
init?: { method?: string; headers?: Record<string, string>; body?: string },
): Promise<T | null> {
let parsedURL: URL;
try {
parsedURL = new URL(url);
} catch {
return null;
}
let cookieHeader = "";
try {
const cookies = await session.cookies.get({ url });
cookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
} catch {
cookieHeader = "";
}
const headers: Record<string, string> = {
"user-agent": STANDARD_USER_AGENT,
"accept-language": STANDARD_ACCEPT_LANGUAGES,
accept: "application/json, text/plain, */*",
referer: `${parsedURL.protocol}//${parsedURL.host}/`,
origin: `${parsedURL.protocol}//${parsedURL.host}`,
};
if (cookieHeader) {
headers.cookie = cookieHeader;
}
if (init?.headers) {
for (const [key, value] of Object.entries(init.headers)) {
headers[key.toLowerCase()] = value;
}
}
let response: Response;
try {
response = await fetch(url, {
method: init?.method ?? "GET",
headers,
body: init?.body,
});
} catch {
return null;
}
if (!response.ok) {
return null;
}
let text: string;
try {
text = await response.text();
} catch {
return null;
}
if (!text) {
return {} as T;
}
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}
export async function pageFetchJson<T>(
webContents: WebContents,
url: string,
init?: PageFetchInit,
): Promise<T | null> {
if (!webContents || webContents.isDestroyed()) {
return null;
}
const effectiveInit: PageFetchInit = {
credentials: "include",
...init,
};
const script = `(async () => {
try {
const response = await fetch(${JSON.stringify(url)}, ${JSON.stringify(effectiveInit)});
if (!response.ok) {
return JSON.stringify({ __ok: false, status: response.status });
}
const text = await response.text();
if (!text) {
return JSON.stringify({ __ok: true, empty: true });
}
return JSON.stringify({ __ok: true, body: text });
} catch (error) {
return JSON.stringify({ __ok: false, error: String(error && error.message || error) });
}
})()`;
let serialized: string;
try {
serialized = (await webContents.executeJavaScript(script, true)) as string;
} catch {
return null;
}
if (typeof serialized !== "string" || !serialized) {
return null;
}
let envelope: { __ok: boolean; body?: string; empty?: boolean; status?: number; error?: string };
try {
envelope = JSON.parse(serialized);
} catch {
return null;
}
if (!envelope.__ok) {
return null;
}
if (envelope.empty) {
return {} as T;
}
if (!envelope.body) {
return null;
}
try {
return JSON.parse(envelope.body) as T;
} catch {
return null;
}
}
export async function sessionCookieValue(
session: Session,
domain: string,
name: string,
): Promise<string> {
const cookies = await session.cookies.get({ domain });
return cookies.find((item) => item.name === name)?.value ?? "";
}
export async function sessionCookieHeader(session: Session, domain: string): Promise<string> {
const cookies = await session.cookies.get({ domain });
return cookies.map((item) => `${item.name}=${item.value}`).join("; ");
}
export async function ensureViewLoaded(
view: WebContentsView,
url: string,
signal?: AbortSignal,
): Promise<void> {
if (signal?.aborted) {
throw new Error("adapter_aborted");
}
if (view.webContents.isLoading()) {
await waitForLoadStop(view, signal);
}
const currentURL = view.webContents.getURL();
if (currentURL === url) {
return;
}
await view.webContents.loadURL(url);
await waitForLoadStop(view, signal);
}
async function waitForLoadStop(view: WebContentsView, signal?: AbortSignal): Promise<void> {
if (!view.webContents.isLoading()) {
return;
}
while (view.webContents.isLoading()) {
if (signal?.aborted) {
throw new Error("adapter_aborted");
}
await new Promise((resolve) => {
setTimeout(resolve, 120);
});
}
}