87e329207c
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>
347 lines
8.3 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
}
|