import { createHash, createHmac, randomUUID } from "node:crypto"; import type { JsonValue } from "@geo/shared-types"; import { resolveDesktopApiURL } from "../transport/api-client"; import { fetchImageBlob, normalizeArticleHtml, sessionCookieHeader, sessionFetchJson, } from "./common"; import type { PublishAdapter, PublishAdapterContext } from "./base"; type JuejinPublishType = "publish" | "draft"; type JuejinProfileResponse = { data?: { bui_user?: { user_id?: string; screen_name?: string; avatar_large?: string; avatar_url?: string; }; }; }; type JuejinProfileUser = NonNullable["bui_user"]>; type JuejinDraftCreateResponse = { err_no?: number; err_msg?: string; data?: { id?: string | number; }; }; type JuejinArticleListResponse = { err_no?: number; err_msg?: string; data?: unknown; }; type JuejinImageXTokenResponse = { err_no?: number; err_msg?: string; data?: { token?: { AccessKeyId?: string; SecretAccessKey?: string; SessionToken?: string; ExpiredTime?: string; }; }; }; type JuejinImageXToken = { accessKeyId: string; secretAccessKey: string; sessionToken: string; }; type JuejinImageXApplyUploadResponse = { Result?: { UploadAddress?: { StoreInfos?: Array<{ StoreUri?: string; Auth?: string; }>; UploadHosts?: string[]; SessionKey?: string; }; }; }; type JuejinImageXCommitUploadResponse = { Result?: { Results?: Array<{ Uri?: string; UriStatus?: number; }>; }; }; type JuejinImageURLResponse = { err_no?: number; err_msg?: string; data?: { main_url?: string; backup_url?: string; }; }; type JuejinEditorResult = { success: boolean; message?: string; url?: string; }; type JuejinPublishedArticle = { id: string; url: string; }; const JUEJIN_ORIGIN = "https://juejin.cn"; const JUEJIN_API_ORIGIN = "https://api.juejin.cn"; const JUEJIN_IMAGEX_ORIGIN = "https://imagex.bytedanceapi.com"; const JUEJIN_IMAGEX_AID = "2608"; const JUEJIN_IMAGEX_SERVICE_ID = "73owjymdk6"; const JUEJIN_DEFAULT_CATEGORY_ID = "6809637772874219534"; const JUEJIN_DEFAULT_TAG_IDS = ["6809641083107016712", "6809640681271721997"]; const JUEJIN_IMAGE_SKIP_PATTERNS = [ "juejin.cn", "p1-juejin", "p3-juejin", "p6-juejin", "p9-juejin", "byteimg.com", ]; function stringId(value: unknown): string { if (typeof value === "number") { return Number.isFinite(value) ? String(value) : ""; } if (typeof value !== "string") { return ""; } return value.trim(); } function juejinPostURL(articleId: string): string { return `${JUEJIN_ORIGIN}/post/${encodeURIComponent(articleId)}`; } function articleFromJuejinURL(value?: string | null): JuejinPublishedArticle | null { const match = /\/post\/(\d+)/.exec(value || ""); if (!match?.[1]) { return null; } return { id: match[1], url: juejinPostURL(match[1]), }; } function normalizePublishType(payload: Record): JuejinPublishType { return payload.publish_type === "draft" || payload.publishType === "draft" ? "draft" : "publish"; } function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; } return value.map((item) => stringId(item)).filter(Boolean); } function juejinHeaders( context: PublishAdapterContext, extra: Record = {}, ): Promise> { return sessionCookieHeader(context.session, "juejin.cn") .catch(() => "") .then((cookie) => ({ accept: "application/json, text/plain, */*", origin: JUEJIN_ORIGIN, referer: `${JUEJIN_ORIGIN}/`, ...(cookie ? { cookie } : {}), ...extra, })); } async function fetchProfile(context: PublishAdapterContext): Promise { const response = await sessionFetchJson( context.session, `${JUEJIN_API_ORIGIN}/user_api/v1/user/get_profile`, { method: "POST", credentials: "include", headers: await juejinHeaders(context, { "content-type": "application/json", }), body: "{}", }, ).catch(() => null); return response?.data?.bui_user ?? null; } async function fetchCsrfToken(context: PublishAdapterContext): Promise { const headers = await juejinHeaders(context, { "x-secsdk-csrf-request": "1", "x-secsdk-csrf-version": "1.2.10", }); const response = await context.session.fetch(`${JUEJIN_API_ORIGIN}/user_api/v1/sys/token`, { method: "HEAD", credentials: "include", headers, }).catch(() => null); const token = response?.headers.get("x-ware-csrf-token") ?? ""; const parts = token.split(","); return parts.length >= 2 && parts[1] ? parts[1] : null; } function extractMarkdownImageSources(markdown: string): string[] { const sources = new Set(); for (const match of markdown.matchAll(/!\[[^\]]*]\((?:<([^>\s]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\)/g)) { const source = (match[1] || match[2] || "").trim(); if (source) { sources.add(source); } } for (const match of markdown.matchAll(/]*src=(['"])(.*?)\1[^>]*>/gi)) { const source = match[2]?.trim(); if (source) { sources.add(source); } } return [...sources]; } function shouldSkipImageSource(source: string): boolean { return JUEJIN_IMAGE_SKIP_PATTERNS.some((pattern) => source.includes(pattern)); } async function uploadMarkdownImages( markdown: string, uploader: (sourceUrl: string) => Promise, ): Promise { const sources = extractMarkdownImageSources(markdown); if (!sources.length) { return markdown; } const uploaded = new Map(); for (const source of sources) { if (shouldSkipImageSource(source)) { continue; } const target = await uploader(source); if (target) { uploaded.set(source, target); } } let next = markdown; for (const [from, to] of uploaded.entries()) { next = next.split(from).join(to); } return next; } function stripHtml(value: string): string { return value .replace(//gi, "\n") .replace(/<\/(?:p|div|section|article|h[1-6]|li|tr)>/gi, "\n") .replace(/<[^>]+>/g, "") .replace(/ /g, " ") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, "\"") .replace(/'/g, "'") .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); } function htmlTableToMarkdown(tableHtml: string): string { const rows: string[][] = []; for (const rowMatch of tableHtml.matchAll(/]*>([\s\S]*?)<\/tr>/gi)) { const cells: string[] = []; for (const cellMatch of (rowMatch[1] ?? "").matchAll(/<(?:th|td)\b[^>]*>([\s\S]*?)<\/(?:th|td)>/gi)) { cells.push(stripHtml(cellMatch[1] ?? "").replace(/\|/g, "\\|")); } if (cells.length > 0) { rows.push(cells); } } if (!rows.length) { return ""; } const width = Math.max(...rows.map((row) => row.length)); const normalizeRow = (row: string[]) => { const cells = Array.from({ length: width }, (_item, index) => row[index] ?? ""); return `| ${cells.join(" | ")} |`; }; return [ normalizeRow(rows[0] ?? []), normalizeRow(Array.from({ length: width }, () => "---")), ...rows.slice(1).map(normalizeRow), ].join("\n"); } function htmlToMarkdown(html: string): string { let next = html.trim(); next = next.replace(/]*>[\s\S]*?<\/table>/gi, (tableHtml) => `\n\n${htmlTableToMarkdown(tableHtml)}\n\n`); next = next.replace(/]*alt=(['"])(.*?)\1[^>]*src=(['"])(.*?)\3[^>]*>/gi, (_match, _q1, alt, _q2, src) => `\n![${stripHtml(alt)}](${String(src).trim()})\n`, ); next = next.replace(/]*src=(['"])(.*?)\1[^>]*alt=(['"])(.*?)\3[^>]*>/gi, (_match, _q1, src, _q2, alt) => `\n![${stripHtml(alt)}](${String(src).trim()})\n`, ); next = next.replace(/]*src=(['"])(.*?)\1[^>]*>/gi, (_match, _q, src) => `\n![](${String(src).trim()})\n`); next = next.replace(/]*>([\s\S]*?)<\/h1>/gi, (_match, content) => `\n# ${stripHtml(content)}\n`); next = next.replace(/]*>([\s\S]*?)<\/h2>/gi, (_match, content) => `\n## ${stripHtml(content)}\n`); next = next.replace(/]*>([\s\S]*?)<\/h3>/gi, (_match, content) => `\n### ${stripHtml(content)}\n`); next = next.replace(/]*>([\s\S]*?)<\/li>/gi, (_match, content) => `\n- ${stripHtml(content)}`); next = next.replace(/]*>([\s\S]*?)<\/p>/gi, (_match, content) => `\n${stripHtml(content)}\n`); return stripHtml(next); } function articleMarkdown(context: PublishAdapterContext): string { const markdown = context.article.markdown_content?.trim(); if (markdown) { return markdown; } const html = context.article.html_content?.trim(); if (html) { return htmlToMarkdown(normalizeArticleHtml(context.article)); } return ""; } function buildAssetURLCandidates(sourceUrl: string): string[] { const trimmed = sourceUrl.trim(); if (!trimmed) { return []; } if (/^(data|blob):/i.test(trimmed)) { return [trimmed]; } const candidates: string[] = []; const pushCandidate = (value: string) => { if (!candidates.includes(value)) { candidates.push(value); } }; try { const parsed = new URL(trimmed, resolveDesktopApiURL("/")); if (parsed.pathname.startsWith("/api/")) { pushCandidate(new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`)).toString()); } pushCandidate(parsed.toString()); } catch { pushCandidate(trimmed); } return candidates; } async function fetchAssetBlob(sourceUrl: string): Promise { for (const candidate of buildAssetURLCandidates(sourceUrl)) { const blob = await fetchImageBlob(candidate); if (blob) { return blob; } } return null; } function sha256Hex(value: string): string { return createHash("sha256").update(value).digest("hex"); } function hmacSha256(key: Buffer | string, value: string): Buffer { return createHmac("sha256", key).update(value).digest(); } function formatAmzDate(date: Date): string { return date.toISOString().replace(/[:-]|\.\d{3}/g, ""); } function formatDateStamp(date: Date): string { return date.toISOString().slice(0, 10).replace(/-/g, ""); } function signAWS4(input: { method: string; url: string; accessKeyId: string; secretAccessKey: string; securityToken?: string; region?: string; service?: string; headers?: Record; body?: string; }): Record { const region = input.region ?? "cn-north-1"; const service = input.service ?? "imagex"; const body = input.body ?? ""; const url = new URL(input.url); const now = new Date(); const amzDate = formatAmzDate(now); const dateStamp = formatDateStamp(now); const query = [...url.searchParams.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join("&"); const signedHeaders: Record = { "x-amz-date": amzDate, ...(input.securityToken ? { "x-amz-security-token": input.securityToken } : {}), ...(input.headers ?? {}), }; const signedHeaderNames = Object.keys(signedHeaders) .map((key) => key.toLowerCase()) .sort() .join(";"); const canonicalHeaders = Object.entries(signedHeaders) .map(([key, value]) => `${key.toLowerCase()}:${value.trim()}`) .sort() .join("\n") + "\n"; const canonicalRequest = [ input.method.toUpperCase(), url.pathname || "/", query, canonicalHeaders, signedHeaderNames, sha256Hex(body), ].join("\n"); const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`; const stringToSign = [ "AWS4-HMAC-SHA256", amzDate, credentialScope, sha256Hex(canonicalRequest), ].join("\n"); const kDate = hmacSha256(`AWS4${input.secretAccessKey}`, dateStamp); const kRegion = hmacSha256(kDate, region); const kService = hmacSha256(kRegion, service); const kSigning = hmacSha256(kService, "aws4_request"); const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex"); const authorization = `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaderNames}, Signature=${signature}`; return { authorization, "x-amz-date": amzDate, ...(input.securityToken ? { "x-amz-security-token": input.securityToken } : {}), }; } function crc32(data: Uint8Array): string { let crc = 0xFFFFFFFF; const table = crc32Table(); for (const byte of data) { crc = (crc >>> 8) ^ table[(crc ^ byte) & 0xFF]; } return ((crc ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, "0"); } let cachedCrc32Table: Uint32Array | null = null; function crc32Table(): Uint32Array { if (cachedCrc32Table) { return cachedCrc32Table; } const table = new Uint32Array(256); for (let index = 0; index < 256; index += 1) { let value = index; for (let bit = 0; bit < 8; bit += 1) { value = (value & 1) ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1); } table[index] = value; } cachedCrc32Table = table; return table; } async function mainFetchJson(input: string, init?: RequestInit): Promise { const response = await fetch(input, init); const text = await response.text(); if (!response.ok) { throw new Error(text || `request_failed_${response.status}`); } if (!text) { return {} as T; } return JSON.parse(text) as T; } function randomJuejinUUID(): string { return randomUUID().replace(/-/g, "").slice(0, 16) + Date.now().toString(); } async function fetchImageXToken(context: PublishAdapterContext, uuid: string): Promise { const response = await sessionFetchJson( context.session, `${JUEJIN_API_ORIGIN}/imagex/v2/gen_token?aid=${JUEJIN_IMAGEX_AID}&uuid=${encodeURIComponent(uuid)}&client=web`, { method: "GET", credentials: "include", headers: await juejinHeaders(context), }, ); if (response.err_no && response.err_no !== 0) { throw new Error(response.err_msg || "juejin_image_token_failed"); } const token = response.data?.token; if (!token?.AccessKeyId || !token.SecretAccessKey) { throw new Error("juejin_image_token_failed"); } return { accessKeyId: token.AccessKeyId, secretAccessKey: token.SecretAccessKey, sessionToken: token.SessionToken ?? "", }; } async function applyImageUpload(token: JuejinImageXToken) { const url = `${JUEJIN_IMAGEX_ORIGIN}/?Action=ApplyImageUpload&Version=2018-08-01&ServiceId=${JUEJIN_IMAGEX_SERVICE_ID}`; const signedHeaders = signAWS4({ method: "GET", url, accessKeyId: token.accessKeyId, secretAccessKey: token.secretAccessKey, securityToken: token.sessionToken, }); const response = await mainFetchJson(url, { method: "GET", headers: signedHeaders, }); const uploadAddress = response.Result?.UploadAddress; const storeInfo = uploadAddress?.StoreInfos?.[0]; const uploadHost = uploadAddress?.UploadHosts?.[0]; if (!uploadAddress?.SessionKey || !storeInfo?.StoreUri || !storeInfo.Auth || !uploadHost) { throw new Error("juejin_image_apply_upload_failed"); } return { sessionKey: uploadAddress.SessionKey, storeUri: storeInfo.StoreUri, auth: storeInfo.Auth, uploadHost, }; } async function uploadToTOS(upload: Awaited>, blob: Blob): Promise { const buffer = Buffer.from(await blob.arrayBuffer()); const response = await fetch(`https://${upload.uploadHost}/${upload.storeUri}`, { method: "PUT", headers: { authorization: upload.auth, "content-type": blob.type || "application/octet-stream", "content-crc32": crc32(new Uint8Array(buffer)), }, body: blob, }); if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error(text || `juejin_image_tos_upload_failed_${response.status}`); } } async function commitImageUpload(token: JuejinImageXToken, sessionKey: string): Promise { const url = `${JUEJIN_IMAGEX_ORIGIN}/?Action=CommitImageUpload&Version=2018-08-01&SessionKey=${encodeURIComponent(sessionKey)}&ServiceId=${JUEJIN_IMAGEX_SERVICE_ID}`; const signedHeaders = signAWS4({ method: "POST", url, accessKeyId: token.accessKeyId, secretAccessKey: token.secretAccessKey, securityToken: token.sessionToken, }); const response = await mainFetchJson(url, { method: "POST", headers: { ...signedHeaders, "content-length": "0", }, }); if (!response.Result) { throw new Error("juejin_image_commit_upload_failed"); } } async function resolveImageURL(context: PublishAdapterContext, uuid: string, storeUri: string): Promise { const response = await sessionFetchJson( context.session, `${JUEJIN_API_ORIGIN}/imagex/v2/get_img_url?aid=${JUEJIN_IMAGEX_AID}&uuid=${encodeURIComponent(uuid)}&uri=${encodeURIComponent(storeUri)}&img_type=private`, { method: "GET", credentials: "include", headers: await juejinHeaders(context), }, ); if (response.err_no && response.err_no !== 0) { throw new Error(response.err_msg || "juejin_image_url_failed"); } const imageUrl = response.data?.main_url || response.data?.backup_url; if (!imageUrl) { throw new Error("juejin_image_url_failed"); } return imageUrl; } async function uploadImage(context: PublishAdapterContext, uuid: string, sourceUrl: string): Promise { const blob = await fetchAssetBlob(sourceUrl); if (!blob || !blob.type.startsWith("image/")) { return null; } const token = await fetchImageXToken(context, uuid); const upload = await applyImageUpload(token); await uploadToTOS(upload, blob); await commitImageUpload(token, upload.sessionKey); return await resolveImageURL(context, uuid, upload.storeUri); } async function createDraft( context: PublishAdapterContext, payload: Record, markdown: string, uuid: string, ): Promise { const csrfToken = await fetchCsrfToken(context).catch(() => null); const categoryId = stringId(payload.category_id ?? payload.categoryId) || JUEJIN_DEFAULT_CATEGORY_ID; const tagIds = normalizeStringArray(payload.tag_ids ?? payload.tagIds); const coverUrl = context.article.cover_asset_url?.trim() ? await uploadImage(context, uuid, context.article.cover_asset_url).catch(() => null) : null; const response = await sessionFetchJson( context.session, `${JUEJIN_API_ORIGIN}/content_api/v1/article_draft/create`, { method: "POST", credentials: "include", headers: await juejinHeaders(context, { "content-type": "application/json", ...(csrfToken ? { "x-secsdk-csrf-token": csrfToken } : {}), }), body: JSON.stringify({ category_id: categoryId, tag_ids: tagIds.length ? tagIds : JUEJIN_DEFAULT_TAG_IDS, link_url: "", cover_image: coverUrl ?? "", title: context.article.title?.trim() || "未命名文章", brief_content: "", edit_type: 10, html_content: "deprecated", mark_content: markdown, theme_ids: [], pics: [], }), }, ); if (response.err_no && response.err_no !== 0) { throw new Error(response.err_msg || "juejin_create_draft_failed"); } const draftId = stringId(response.data?.id); if (!draftId) { throw new Error(response.err_msg || "juejin_create_draft_failed"); } return draftId; } function normalizeCompactText(value: string | null | undefined): string { return (value || "").trim().replace(/\s+/g, ""); } async function clickJuejinButtonByText( page: NonNullable["page"], selectors: string[], texts: string[], timeoutMs: number, ): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { for (const selector of selectors) { const locator = page.locator(selector); const count = await locator.count().catch(() => 0); for (let index = 0; index < count; index += 1) { const candidate = locator.nth(index); const text = await candidate.innerText({ timeout: 500 }) .catch(async () => await candidate.textContent({ timeout: 500 }).catch(() => "")); const compactText = normalizeCompactText(text); if (!texts.some((target) => compactText.includes(normalizeCompactText(target)))) { continue; } await candidate.click({ timeout: 5_000 }); return true; } } await page.waitForTimeout(150); } return false; } async function publishDraft(context: PublishAdapterContext, draftId: string): Promise { const page = context.playwright?.page; if (!page) { return { success: false, message: "juejin_playwright_page_missing", }; } const draftURL = `${JUEJIN_ORIGIN}/editor/drafts/${encodeURIComponent(draftId)}`; await page.goto(draftURL, { waitUntil: "domcontentloaded", timeout: 45_000, }); await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined); const firstClicked = await clickJuejinButtonByText( page, [".publish-popup .xitu-btn", ".publish-popup button", "button", ".xitu-btn", "[role='button']"], ["发布"], 20_000, ); if (!firstClicked) { return { success: false, message: "juejin_publish_button_missing", url: page.url(), }; } await page.waitForTimeout(2_000); const previousURL = page.url(); const confirmed = await clickJuejinButtonByText( page, [".btn-container button", ".byte-modal button", ".semi-modal button", "button", "[role='button']"], ["确定并发布", "确认发布", "确定"], 12_000, ); if (!confirmed) { return { success: false, message: "juejin_confirm_button_missing", url: page.url(), }; } await Promise.race([ page.waitForURL((url) => url.toString() !== previousURL, { timeout: 6_000 }).catch(() => undefined), page.waitForLoadState("domcontentloaded", { timeout: 6_000 }).catch(() => undefined), ]); await page.waitForTimeout(1_500); return { success: true, url: page.url(), }; } function findArticleListItems(data: unknown): Array> { if (Array.isArray(data)) { return data.filter((item): item is Record => Boolean(item) && typeof item === "object"); } if (data && typeof data === "object") { const record = data as Record; for (const key of ["list", "article_list", "articles"]) { const value = record[key]; if (Array.isArray(value)) { return value.filter((item): item is Record => Boolean(item) && typeof item === "object"); } } } return []; } function extractArticleFromListResponse(response: JuejinArticleListResponse | null, draftId: string): JuejinPublishedArticle | null { const items = findArticleListItems(response?.data); for (const item of items) { const articleInfo = item.article_info && typeof item.article_info === "object" ? item.article_info as Record : {}; const matchedDraftId = stringId(articleInfo.draft_id ?? item.draft_id); if (matchedDraftId !== draftId) { continue; } const articleURL = articleFromJuejinURL( stringId(item.article_url ?? item.url ?? articleInfo.article_url ?? articleInfo.url), ); const articleId = stringId(item.article_id) || articleURL?.id || stringId(articleInfo.article_id ?? item.id); if (!articleId) { return null; } return { id: articleId, url: articleURL?.url ?? juejinPostURL(articleId), }; } return null; } async function findPublishedArticle( context: PublishAdapterContext, draftId: string, uuid: string, ): Promise { for (let attempt = 0; attempt < 6; attempt += 1) { if (attempt > 0) { await new Promise((resolve) => { setTimeout(resolve, 1_500); }); } const response = await sessionFetchJson( context.session, `${JUEJIN_API_ORIGIN}/content_api/v1/article/list_by_user?aid=${JUEJIN_IMAGEX_AID}&uuid=${encodeURIComponent(uuid)}&spider=0`, { method: "POST", credentials: "include", headers: await juejinHeaders(context, { "content-type": "application/json", }), body: JSON.stringify({ audit_status: null, keyword: "", page_size: 10, page_no: 1, }), }, ).catch(() => null); const article = extractArticleFromListResponse(response, draftId); if (article) { return article; } } return null; } function buildResultPayload( publishType: JuejinPublishType, draftId: string, mediaName: string, externalManageUrl: string, externalArticleUrl: string | null, articleId = draftId, ): Record { return { platform: "juejin", media_name: mediaName, external_article_id: articleId, draft_id: draftId, external_manage_url: externalManageUrl, external_article_url: externalArticleUrl, publish_type: publishType, }; } function buildFailureResult(error: unknown): ReturnType extends Promise ? T : never { const message = error instanceof Error ? error.message : String(error); if (message === "juejin_not_logged_in") { return { status: "failed", summary: "稀土掘金登录态失效,无法执行发布。", error: { code: "juejin_not_logged_in", message, }, }; } if (message === "article_content_empty") { return { status: "failed", summary: "文章内容为空,无法推送到稀土掘金。", error: { code: "article_content_empty", message, }, }; } if (message === "juejin_create_draft_failed" || message.includes("创建草稿失败")) { return { status: "failed", summary: "稀土掘金草稿创建失败。", error: { code: "juejin_create_draft_failed", message, }, }; } if (message === "juejin_playwright_page_missing" || message.includes("button_missing")) { return { status: "failed", summary: "稀土掘金发布确认按钮未就绪,请打开掘金编辑器确认账号状态后重试。", error: { code: "juejin_editor_unavailable", message, }, }; } if (message === "juejin_publish_result_missing") { return { status: "failed", summary: "稀土掘金已执行发布,但未在文章列表中查到新文章,请手动检查掘金创作中心。", error: { code: "juejin_publish_result_missing", message, }, }; } return { status: "failed", summary: "稀土掘金发布失败。", error: { code: "juejin_publish_failed", message, }, }; } export const juejinAdapter: PublishAdapter = { platform: "juejin", executionMode: "playwright", async publish(context, payload) { try { context.reportProgress("juejin.detect_login"); const user = await fetchProfile(context); if (!user?.user_id || !user.screen_name) { throw new Error("juejin_not_logged_in"); } const uuid = randomJuejinUUID(); let markdown = articleMarkdown(context); if (!markdown) { throw new Error("article_content_empty"); } context.reportProgress("juejin.upload_content_images"); markdown = await uploadMarkdownImages(markdown, async (sourceUrl) => await uploadImage(context, uuid, sourceUrl).catch(() => null), ); context.reportProgress("juejin.create_draft"); const draftId = await createDraft(context, payload, markdown, uuid); const draftUrl = `${JUEJIN_ORIGIN}/editor/drafts/${encodeURIComponent(draftId)}`; const publishType = normalizePublishType(payload); if (publishType === "draft") { return { status: "succeeded", payload: buildResultPayload("draft", draftId, user.screen_name, draftUrl, draftUrl), summary: "稀土掘金草稿保存成功。", }; } context.reportProgress("juejin.submit_editor"); const editorResult = await publishDraft(context, draftId); if (!editorResult.success) { throw new Error(editorResult.message || "juejin_publish_failed"); } context.reportProgress("juejin.resolve_article_url"); const article = articleFromJuejinURL(editorResult.url) ?? await findPublishedArticle(context, draftId, uuid); if (!article) { throw new Error("juejin_publish_result_missing"); } return { status: "succeeded", payload: buildResultPayload("publish", draftId, user.screen_name, draftUrl, article.url, article.id), summary: "稀土掘金发布成功。", }; } catch (error) { return buildFailureResult(error); } }, };