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(//gi, ""); next = next.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, ""); next = next.replace(/\sstyle="[^"]*"/gi, ""); next = next.replace(/\sdata-[a-z-]+="[^"]*"/gi, ""); next = next.replace(/]*>\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(); for (const match of html.matchAll(/]*src=(['"])(.*?)\1[^>]*>/gi)) { const src = match[2]?.trim(); if (src) { sources.add(src); } } return [...sources]; } export async function fetchImageBlob(sourceUrl: string): Promise { 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, ): Promise<{ html: string; uploaded: Map }> { const sources = extractImageSources(html); if (!sources.length) { return { html, uploaded: new Map(), }; } const uploaded = new Map(); 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 { 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( session: Session, input: string, init?: RequestInit, ): Promise { 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; body?: string; credentials?: "include" | "same-origin" | "omit"; } export async function sessionCookieFetchJson( session: Session, url: string, init?: { method?: string; headers?: Record; body?: string }, ): Promise { 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 = { "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( webContents: WebContents, url: string, init?: PageFetchInit, ): Promise { 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 { const cookies = await session.cookies.get({ domain }); return cookies.find((item) => item.name === name)?.value ?? ""; } export async function sessionCookieHeader(session: Session, domain: string): Promise { 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 { 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 { if (!view.webContents.isLoading()) { return; } while (view.webContents.isLoading()) { if (signal?.aborted) { throw new Error("adapter_aborted"); } await new Promise((resolve) => { setTimeout(resolve, 120); }); } }