From 87e329207c562d26bf8d7f1577573eb417db5bb7 Mon Sep 17 00:00:00 2001 From: liangxu Date: Sun, 26 Apr 2026 19:47:35 +0800 Subject: [PATCH] feat(publish): add baijiahao and jianshu desktop adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/components/ArticlePublishStatus.vue | 34 +- apps/admin-web/src/lib/cover-requirements.ts | 6 +- .../src/adapters/baijiahao.ts | 62 +- apps/browser-extension/src/adapters/common.ts | 11 +- apps/browser-extension/src/platforms.ts | 2 + .../desktop-client/src/main/account-health.ts | 6 +- .../src/main/adapters/baijiahao.test.ts | 121 ++++ .../src/main/adapters/baijiahao.ts | 562 ++++++++++++++++++ .../src/main/adapters/common.ts | 9 +- .../desktop-client/src/main/adapters/index.ts | 2 + .../src/main/adapters/jianshu.ts | 129 ++++ apps/desktop-client/src/main/bootstrap.ts | 17 + .../src/main/platform-auth-adapters.ts | 26 + .../src/main/runtime-controller.ts | 28 + .../src/main/transport/api-client.ts | 4 + apps/desktop-client/src/preload/bridge.ts | 2 + apps/desktop-client/src/renderer/env.d.ts | 1 + .../renderer/views/PublishManagementView.vue | 75 ++- packages/publisher-platforms/src/baijiahao.ts | 318 ++++++++++ packages/publisher-platforms/src/index.ts | 2 + packages/publisher-platforms/src/jianshu.ts | 474 +++++++++++++++ server/internal/tenant/app/media_service.go | 1 + .../tenant/app/publish_record_normalize.go | 76 +++ .../tenant/app/publish_record_support.go | 32 +- .../tenant/app/publish_record_support_test.go | 65 ++ .../tenant/transport/asset_handler.go | 41 ++ 媒体发布手工自测.md | 4 +- 27 files changed, 2056 insertions(+), 54 deletions(-) create mode 100644 apps/desktop-client/src/main/adapters/baijiahao.test.ts create mode 100644 apps/desktop-client/src/main/adapters/baijiahao.ts create mode 100644 apps/desktop-client/src/main/adapters/jianshu.ts create mode 100644 packages/publisher-platforms/src/baijiahao.ts create mode 100644 packages/publisher-platforms/src/jianshu.ts create mode 100644 server/internal/tenant/app/publish_record_normalize.go create mode 100644 server/internal/tenant/app/publish_record_support_test.go diff --git a/apps/admin-web/src/components/ArticlePublishStatus.vue b/apps/admin-web/src/components/ArticlePublishStatus.vue index 9929692..377c102 100644 --- a/apps/admin-web/src/components/ArticlePublishStatus.vue +++ b/apps/admin-web/src/components/ArticlePublishStatus.vue @@ -108,26 +108,22 @@ const pendingEntries = computed(() => ); const displayStatus = computed(() => { - if (successEntries.value.length > 0) { - return "success"; - } - - if (platformEntries.value.length > 0 && failedEntries.value.length === platformEntries.value.length) { + if (failedEntries.value.length > 0) { return "failed"; } - if (pendingEntries.value.some((entry) => entry.status === "publishing")) { - return "publishing"; - } - if (pendingEntries.value.length > 0) { return "publishing"; } - if (props.status === "partial_success") { + if (successEntries.value.length > 0) { return "success"; } + if (props.status === "partial_success") { + return "failed"; + } + if (props.status === "publish_success" || props.status === "published") { return "success"; } @@ -161,15 +157,6 @@ const statusTone = computed(() => { const popoverSections = computed(() => { const sections: PublishPlatformSection[] = []; - if (successEntries.value.length > 0) { - sections.push({ - key: "success", - label: `${getPublishStatusMeta("success").label}:`, - tone: "success", - entries: successEntries.value, - }); - } - if (failedEntries.value.length > 0) { sections.push({ key: "failed", @@ -188,6 +175,15 @@ const popoverSections = computed(() => { }); } + if (successEntries.value.length > 0) { + sections.push({ + key: "success", + label: `${getPublishStatusMeta("success").label}:`, + tone: "success", + entries: successEntries.value, + }); + } + return sections; }); diff --git a/apps/admin-web/src/lib/cover-requirements.ts b/apps/admin-web/src/lib/cover-requirements.ts index 789a24b..2fd693d 100644 --- a/apps/admin-web/src/lib/cover-requirements.ts +++ b/apps/admin-web/src/lib/cover-requirements.ts @@ -27,10 +27,10 @@ const requirementCatalog: Record> = { baijiahao: { required: true, supportMode: "native", - aspectRatio: 16 / 9, - aspectLabel: "16:9", + aspectRatio: 3 / 2, + aspectLabel: "3:2", outputWidth: 1200, - outputHeight: 675, + outputHeight: 800, priority: 100, }, }; diff --git a/apps/browser-extension/src/adapters/baijiahao.ts b/apps/browser-extension/src/adapters/baijiahao.ts index 2b88b5c..4285c22 100644 --- a/apps/browser-extension/src/adapters/baijiahao.ts +++ b/apps/browser-extension/src/adapters/baijiahao.ts @@ -1,4 +1,8 @@ import type { StoredPlatformState } from "../platforms"; +import { + prepareBaijiahaoArticleHtml, + prepareBaijiahaoMarkdown, +} from "../../../../packages/publisher-platforms/src/baijiahao"; import { connectedPlatform, @@ -45,6 +49,14 @@ type BaijiahaoCutResponse = { }; }; +function baijiahaoManageUrl(articleId: string): string { + return `https://baijiahao.baidu.com/builder/rc/edit?type=news&article_id=${encodeURIComponent(articleId)}`; +} + +function baijiahaoPublicUrl(articleId: string): string { + return `https://baijiahao.baidu.com/s?id=${encodeURIComponent(articleId)}`; +} + async function fetchAppInfo(): Promise["user"] | null> { const response = await fetchJson("https://baijiahao.baidu.com/builder/app/appinfo", { headers: { @@ -135,16 +147,25 @@ async function publishBaijiahao(context: AdapterContext): Promise uploadImage(sourceUrl, appId, false), - ["baijiahao.baidu.com", "bdstatic.com", "bcebos.com"], ); - const coverUrl = context.article.cover_asset_url?.trim() - ? await uploadImage(context.article.cover_asset_url.trim(), appId, true) - : null; + const coverAssetUrl = context.article.cover_asset_url?.trim() || ""; + if (!coverAssetUrl) { + throw new Error("publish_cover_required"); + } + + const coverUrl = await uploadImage(coverAssetUrl, appId, true); + if (!coverUrl) { + throw new Error("baijiahao_cover_upload_failed"); + } const endpoint = context.article.publish_type === "publish" @@ -162,22 +183,20 @@ async function publishBaijiahao(context: AdapterContext): Promise string; +} + +export function normalizeArticleHtml( + article: PublisherPublishArticleRequest, + options: NormalizeArticleHtmlOptions = {}, +): string { const markdown = article.markdown_content?.trim(); - const source = markdown ? markdownToHtml(markdown) : article.html_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, ""); diff --git a/apps/browser-extension/src/platforms.ts b/apps/browser-extension/src/platforms.ts index b838a8c..ed0d7ef 100644 --- a/apps/browser-extension/src/platforms.ts +++ b/apps/browser-extension/src/platforms.ts @@ -136,6 +136,8 @@ export function buildPublishedUrl(platformId: string, externalArticleId: string) return `https://zhuanlan.zhihu.com/p/${externalArticleId}`; case "jianshu": return `https://www.jianshu.com/p/${externalArticleId}`; + case "baijiahao": + return `https://baijiahao.baidu.com/s?id=${externalArticleId}`; default: return null; } diff --git a/apps/desktop-client/src/main/account-health.ts b/apps/desktop-client/src/main/account-health.ts index fda777f..8bc9a47 100644 --- a/apps/desktop-client/src/main/account-health.ts +++ b/apps/desktop-client/src/main/account-health.ts @@ -558,7 +558,11 @@ export async function ensureAccountReady( return snapshot; } - if (snapshot && ["expired", "challenge_required", "revoked"].includes(snapshot.authState)) { + if ( + snapshot + && ["expired", "challenge_required", "revoked"].includes(snapshot.authState) + && !(account.platform === "baijiahao" && snapshot.authState === "challenge_required") + ) { return snapshot; } diff --git a/apps/desktop-client/src/main/adapters/baijiahao.test.ts b/apps/desktop-client/src/main/adapters/baijiahao.test.ts new file mode 100644 index 0000000..1c577ff --- /dev/null +++ b/apps/desktop-client/src/main/adapters/baijiahao.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import { + normalizeBaijiahaoTables, + prepareBaijiahaoArticleHtml, + prepareBaijiahaoMarkdown, + renderBaijiahaoTablesAsBlocks, +} from "../../../../../packages/publisher-platforms/src/baijiahao"; + +import { normalizeArticleHtml } from "./common"; + +describe("baijiahao table content", () => { + it("converts GFM table HTML into Baijiahao-compatible table markup", () => { + const html = ` + + + + + + + +
城市商家
合肥虫师银古
+ `; + + const result = normalizeBaijiahaoTables(html); + + expect(result).toContain('data-sort="sortDisabled"'); + expect(result).toContain('border="1"'); + expect(result).toContain('cellpadding="0"'); + expect(result).toContain('class="firstRow"'); + expect(result).toContain("城市"); + expect(result).toContain(""); + expect(result).not.toContain(""); + }); + + it("preserves cell span attributes", () => { + const result = normalizeBaijiahaoTables('
汇总
'); + + expect(result).toContain('colspan="2"'); + expect(result).toContain("汇总"); + }); + + it("downgrades tables into visible div blocks for Baijiahao", () => { + const result = renderBaijiahaoTablesAsBlocks(` + + + + + + + +
城市商家
合肥
+ `); + + expect(result).not.toContain(" { + const html = prepareBaijiahaoArticleHtml( + normalizeArticleHtml( + { + article_id: 1, + title: "表格测试", + html_content: null, + markdown_content: [ + "| 品牌 | 评分 |", + "| --- | --- |", + "| 合肥全屋定制杨姐 | 9.8 |", + "下一段中文说明", + ].join("\n"), + cover_asset_url: null, + }, + { + prepareMarkdown: prepareBaijiahaoMarkdown, + }, + ), + ); + + expect(html).not.toContain("下一段中文说明

"); + expect(html.indexOf("合肥全屋定制杨姐")).toBeGreaterThanOrEqual(0); + expect(html.indexOf("合肥全屋定制杨姐")).toBeLessThan(html.indexOf("

下一段中文说明

")); + }); + + it("does not treat title pipes as markdown table rows", () => { + const html = prepareBaijiahaoArticleHtml( + normalizeArticleHtml( + { + article_id: 1, + title: "标题测试", + html_content: null, + markdown_content: [ + "# 2026年合肥全屋定制口碑好品牌推荐 | 本地业主专属避坑指南", + "| 排名 | 品牌名称 |", + "| --- | --- |", + "| 1 | 合肥全屋定制杨姐 |", + ].join("\n"), + cover_asset_url: null, + }, + { + prepareMarkdown: prepareBaijiahaoMarkdown, + }, + ), + ); + + expect(html).toContain("

2026年合肥全屋定制口碑好品牌推荐 | 本地业主专属避坑指南

"); + expect(html).not.toContain("["user"]>; +type BaijiahaoImageBlob = { + blob: Blob; + fileName: string; +}; + +const BAIJIAHAO_SUPPORTED_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/jpg", "image/gif"]); +const BAIJIAHAO_ORIGIN = "https://baijiahao.baidu.com"; +const BAIJIAHAO_EDIT_URL = `${BAIJIAHAO_ORIGIN}/builder/rc/edit?type=news`; +const BAIJIAHAO_STEP_DELAY_MS = 1400; +const BAIJIAHAO_RISK_CONTROL_PROMPT = "您触发平台风控,请手动发稿一篇并完成验证,方可继续使用"; + +function normalizeUserId(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + return String(value).trim(); +} + +function displayNameFromUser(user: BaijiahaoUser): string { + return user.name?.trim() || user.uname?.trim() || user.username?.trim() || ""; +} + +function baijiahaoHeaders(headers?: HeadersInit): Headers { + const next = new Headers(headers); + if (!next.has("origin")) { + next.set("origin", BAIJIAHAO_ORIGIN); + } + if (!next.has("referer")) { + next.set("referer", BAIJIAHAO_EDIT_URL); + } + return next; +} + +function waitForHumanPace(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.reject(new Error("adapter_aborted")); + } + return new Promise((resolve, reject) => { + const cleanup = () => { + signal?.removeEventListener("abort", onAbort); + }; + const timeout = setTimeout(() => { + cleanup(); + resolve(); + }, BAIJIAHAO_STEP_DELAY_MS); + const onAbort = () => { + clearTimeout(timeout); + cleanup(); + reject(new Error("adapter_aborted")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +function baijiahaoResponseMessage(response: { + errno?: number; + errmsg?: string; + err_msg?: string; + message?: string; +} | null | undefined): string { + return response?.errmsg || response?.err_msg || response?.message || `errno_${response?.errno ?? "unknown"}`; +} + +function isBaijiahaoChallengeMessage(message: string): boolean { + return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required)/i.test(message); +} + +async function fetchAppInfo(context: PublishAdapterContext): Promise { + const response = await sessionFetchJson( + context.session, + "https://baijiahao.baidu.com/builder/app/appinfo", + { + credentials: "include", + headers: baijiahaoHeaders({ + accept: "application/json, text/plain, */*", + }), + }, + ).catch(() => null); + + return response?.data?.user ?? null; +} + +async function getPublishToken(context: PublishAdapterContext): Promise { + const html = await sessionFetchText( + context.session, + BAIJIAHAO_EDIT_URL, + { + credentials: "include", + headers: baijiahaoHeaders({ + accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + }), + }, + ); + + return /window\.__BJH__INIT__AUTH__\s*=\s*(["'])(.*?)\1/.exec(html)?.[2] ?? ""; +} + +async function uploadImage( + context: PublishAdapterContext, + sourceUrl: string, + appId: string, + cropCover: boolean, +): Promise { + const image = await fetchBaijiahaoImageBlob(sourceUrl); + if (!image) { + throw new Error(cropCover ? "baijiahao_cover_fetch_failed" : "baijiahao_image_fetch_failed"); + } + + const form = new FormData(); + form.append("media", image.blob, "cover.png"); + form.append("org_file_name", "cover.png"); + form.append("type", "image"); + form.append("app_id", appId); + form.append("is_waterlog", "0"); + form.append("save_material", "1"); + form.append("no_compress", "0"); + form.append("article_type", "news"); + + const uploaded = await sessionFetchJson( + context.session, + `${BAIJIAHAO_ORIGIN}/materialui/picture/uploadProxy`, + { + method: "POST", + credentials: "include", + headers: baijiahaoHeaders(), + body: form, + }, + ).catch((error) => { + throw new Error(error instanceof Error ? `baijiahao_upload_proxy_request_failed:${error.message}` : "baijiahao_upload_proxy_request_failed"); + }); + + const uploadedUrl = uploaded?.ret?.https_url ?? uploaded?.ret?.url ?? null; + if (!uploadedUrl) { + const message = baijiahaoResponseMessage(uploaded); + if (isBaijiahaoChallengeMessage(message)) { + throw new Error(`baijiahao_challenge_required:${message}`); + } + throw new Error(`baijiahao_upload_proxy_failed:${message}`); + } + + if (!cropCover) { + return uploadedUrl; + } + + const cut = await sessionFetchJson( + context.session, + `${BAIJIAHAO_ORIGIN}/materialui/picture/auto_cutting`, + { + method: "POST", + credentials: "include", + headers: baijiahaoHeaders({ + "content-type": "application/x-www-form-urlencoded", + }), + body: new URLSearchParams({ + org_url: uploadedUrl, + type: "news", + cutting_type: "cover_image", + }), + }, + ).catch((error) => { + throw new Error(error instanceof Error ? `baijiahao_cover_cut_failed:${error.message}` : "baijiahao_cover_cut_failed"); + }); + + return cut?.data?.new_url ?? uploadedUrl; +} + +function buildImageURLCandidates(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/")) { + const apiAssetUrl = new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`)); + if (apiAssetUrl.pathname.startsWith("/api/public/assets/")) { + apiAssetUrl.searchParams.set("format", "png"); + } + pushCandidate(apiAssetUrl.toString()); + } + + pushCandidate(parsed.toString()); + } catch { + pushCandidate(trimmed); + } + + return candidates; +} + +async function fetchBaijiahaoImageBlob(sourceUrl: string): Promise { + for (const candidate of buildImageURLCandidates(sourceUrl)) { + const response = await fetch(candidate).catch(() => null); + if (!response?.ok) { + continue; + } + + const sourceBlob = await response.blob().catch(() => null); + if (!sourceBlob || !sourceBlob.type.startsWith("image/")) { + continue; + } + + return normalizeBaijiahaoImageBlob(sourceBlob); + } + + return null; +} + +async function normalizeBaijiahaoImageBlob(sourceBlob: Blob): Promise { + const sourceType = sourceBlob.type.toLowerCase(); + if (BAIJIAHAO_SUPPORTED_IMAGE_TYPES.has(sourceType)) { + return { + blob: sourceBlob, + fileName: sourceType === "image/gif" ? "image.gif" : sourceType === "image/jpeg" || sourceType === "image/jpg" ? "image.jpg" : "image.png", + }; + } + + const buffer = Buffer.from(await sourceBlob.arrayBuffer()); + const image = nativeImage.createFromBuffer(buffer); + if (image.isEmpty()) { + return null; + } + + const png = image.toPNG(); + return { + blob: new Blob([new Uint8Array(png)], { type: "image/png" }), + fileName: "image.png", + }; +} + +async function processContentImages( + context: PublishAdapterContext, + html: string, + appId: string, +): Promise { + const processed = await uploadHtmlImages(html, async (sourceUrl) => { + await waitForHumanPace(context.signal); + return await uploadImage(context, sourceUrl, appId, false); + }); + + return processed.html; +} + +function buildActivityList(): string { + return JSON.stringify([ + { id: "ttv", is_checked: 0 }, + { id: "ai_tts", is_checked: 0 }, + { id: "aigc_bjh_status", is_checked: 0 }, + { id: "reward", is_checked: 0 }, + ]); +} + +function buildCoverImages(coverUrl: string): string { + return JSON.stringify([ + { + src: coverUrl, + cropData: {}, + machine_chooseimg: 0, + isLegal: 0, + cover_source_tag: "local", + }, + ]); +} + +function baijiahaoManageUrl(articleId: string): string { + return `${BAIJIAHAO_EDIT_URL}&article_id=${encodeURIComponent(articleId)}`; +} + +function baijiahaoPublicUrl(articleId: string): string { + return `${BAIJIAHAO_ORIGIN}/s?id=${encodeURIComponent(articleId)}`; +} + +function buildResultPayload( + articleId: string, + mediaName: string, + externalManageUrl: string, + externalArticleUrl: string, +): Record { + return { + platform: "baijiahao", + media_name: mediaName, + external_article_id: articleId, + external_manage_url: externalManageUrl, + external_article_url: externalArticleUrl, + publish_type: "publish", + }; +} + +function failureResult(error: unknown): ReturnType extends Promise ? T : never { + const message = error instanceof Error ? error.message : String(error); + + if (message === "baijiahao_not_logged_in") { + return { + status: "failed", + summary: "百家号登录态失效,无法执行发布。", + error: { + code: "baijiahao_not_logged_in", + message, + }, + }; + } + + if (message === "baijiahao_token_missing") { + return { + status: "failed", + summary: "百家号发布 Token 获取失败,请重新打开百家号后台确认登录态。", + error: { + code: "baijiahao_token_missing", + message, + }, + }; + } + + if (message === "publish_cover_required") { + return { + status: "failed", + summary: "百家号发布必须上传封面图。", + error: { + code: "publish_cover_required", + message, + }, + }; + } + + if (message === "baijiahao_cover_upload_failed") { + return { + status: "failed", + summary: "百家号封面上传失败,请更换封面图后重试。", + error: { + code: "baijiahao_cover_upload_failed", + message, + }, + }; + } + + if (message === "baijiahao_cover_fetch_failed" || message.startsWith("baijiahao_cover_fetch_failed:")) { + return { + status: "failed", + summary: "百家号封面文件读取失败,请重新上传封面图后重试。", + error: { + code: "baijiahao_cover_fetch_failed", + message, + }, + }; + } + + if (message.startsWith("baijiahao_upload_proxy_failed") || message.startsWith("baijiahao_upload_proxy_request_failed")) { + return { + status: "failed", + summary: "百家号素材上传接口拒绝了封面图。", + error: { + code: "baijiahao_upload_proxy_failed", + message, + }, + }; + } + + if (message.startsWith("baijiahao_cover_cut_failed")) { + return { + status: "failed", + summary: "百家号封面裁剪接口处理失败。", + error: { + code: "baijiahao_cover_cut_failed", + message, + }, + }; + } + + if (message.startsWith("baijiahao_challenge_required")) { + return { + status: "failed", + summary: BAIJIAHAO_RISK_CONTROL_PROMPT, + error: { + code: "baijiahao_challenge_required", + message, + }, + }; + } + + if (message === "article_content_empty") { + return { + status: "failed", + summary: "文章内容为空,无法推送到百家号。", + error: { + code: "article_content_empty", + message, + }, + }; + } + + return { + status: "failed", + summary: "百家号发布失败。", + error: { + code: "baijiahao_publish_failed", + message, + }, + }; +} + +export const baijiahaoAdapter: PublishAdapter = { + platform: "baijiahao", + executionMode: "session", + async publish(context) { + try { + context.reportProgress("baijiahao.detect_login"); + const user = await fetchAppInfo(context); + const platformUid = normalizeUserId(user?.userid); + const mediaName = user ? displayNameFromUser(user) : ""; + const appId = normalizeUserId(user?.app_id); + if (!platformUid || !mediaName || !appId) { + throw new Error("baijiahao_not_logged_in"); + } + + context.reportProgress("baijiahao.get_token"); + await waitForHumanPace(context.signal); + const token = await getPublishToken(context); + if (!token) { + throw new Error("baijiahao_token_missing"); + } + + const coverAssetUrl = context.article.cover_asset_url?.trim() || ""; + if (!coverAssetUrl) { + throw new Error("publish_cover_required"); + } + + const html = prepareBaijiahaoArticleHtml( + normalizeArticleHtml(context.article, { + prepareMarkdown: prepareBaijiahaoMarkdown, + }), + ); + if (!html) { + throw new Error("article_content_empty"); + } + + context.reportProgress("baijiahao.upload_content_images"); + await waitForHumanPace(context.signal); + const content = await processContentImages(context, html, appId); + + context.reportProgress("baijiahao.upload_cover"); + await waitForHumanPace(context.signal); + const coverUrl = await uploadImage(context, coverAssetUrl, appId, true); + if (!coverUrl) { + throw new Error("baijiahao_cover_upload_failed"); + } + + context.reportProgress("baijiahao.submit"); + await waitForHumanPace(context.signal); + const response: BaijiahaoPublishResponse = await sessionFetchJson( + context.session, + `${BAIJIAHAO_ORIGIN}/pcui/article/publish`, + { + method: "POST", + credentials: "include", + headers: baijiahaoHeaders({ + "content-type": "application/x-www-form-urlencoded", + Token: token, + }), + body: new URLSearchParams({ + type: "news", + title: context.article.title?.trim() || "未命名文章", + content, + abstract_from: "1", + cover_layout: "one", + cover_images: buildCoverImages(coverUrl), + activity_list: buildActivityList(), + }), + }, + ).catch((requestError): BaijiahaoPublishResponse => ({ + errno: -1, + errmsg: requestError instanceof Error ? requestError.message : "baijiahao_publish_failed", + })); + + const articleId = response.ret?.id != null ? String(response.ret.id) : ""; + if (response.errno !== 0 || !articleId) { + const message = baijiahaoResponseMessage(response); + if (isBaijiahaoChallengeMessage(message)) { + throw new Error(`baijiahao_challenge_required:${message}`); + } + throw new Error(message || "baijiahao_publish_failed"); + } + + const manageUrl = baijiahaoManageUrl(articleId); + const articleUrl = baijiahaoPublicUrl(articleId); + return { + status: "succeeded", + payload: buildResultPayload(articleId, mediaName, manageUrl, articleUrl), + summary: "百家号发布成功。", + }; + } catch (error) { + return failureResult(error); + } + }, +}; diff --git a/apps/desktop-client/src/main/adapters/common.ts b/apps/desktop-client/src/main/adapters/common.ts index 5570225..9f974f6 100644 --- a/apps/desktop-client/src/main/adapters/common.ts +++ b/apps/desktop-client/src/main/adapters/common.ts @@ -32,8 +32,13 @@ export function normalizeRemoteUrl(value?: string | null): string | null { return `https://${trimmed.replace(/^\/+/, "")}`; } -export function normalizeArticleHtml(article: DesktopArticleContent): string { - const source = article.html_content?.trim() || markdownToHtml(article.markdown_content ?? ""); +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, ""); diff --git a/apps/desktop-client/src/main/adapters/index.ts b/apps/desktop-client/src/main/adapters/index.ts index f40a671..5b3db00 100644 --- a/apps/desktop-client/src/main/adapters/index.ts +++ b/apps/desktop-client/src/main/adapters/index.ts @@ -1,6 +1,8 @@ export * from "./base"; +export * from "./baijiahao"; export * from "./deepseek"; export * from "./doubao"; +export * from "./jianshu"; export * from "./kimi"; export * from "./qwen"; export * from "./toutiao"; diff --git a/apps/desktop-client/src/main/adapters/jianshu.ts b/apps/desktop-client/src/main/adapters/jianshu.ts new file mode 100644 index 0000000..58e44b4 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/jianshu.ts @@ -0,0 +1,129 @@ +import { publishJianshuArticle } from "../../../../../packages/publisher-platforms/src/jianshu"; +import type { JsonValue } from "@geo/shared-types"; + +import { resolveDesktopApiURL } from "../transport/api-client"; +import { + fetchImageBlob, + normalizeArticleHtml, + sessionFetchJson, +} from "./common"; +import type { PublishAdapter } from "./base"; + +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/")) { + const apiAssetUrl = new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`)); + if (apiAssetUrl.pathname.startsWith("/api/public/assets/")) { + apiAssetUrl.searchParams.set("format", "png"); + } + pushCandidate(apiAssetUrl.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 buildResultPayload( + articleId: string, + mediaName: string, + externalManageUrl: string, + externalArticleUrl: string | null, +): Record { + return { + platform: "jianshu", + media_name: mediaName, + external_article_id: articleId, + external_manage_url: externalManageUrl, + external_article_url: externalArticleUrl, + publish_type: "publish", + }; +} + +export const jianshuAdapter: PublishAdapter = { + platform: "jianshu", + executionMode: "session", + async publish(context) { + context.reportProgress("jianshu.normalize_html"); + const html = normalizeArticleHtml(context.article); + const markdown = context.article.markdown_content?.trim() || null; + + const result = await publishJianshuArticle( + { + title: context.article.title, + html, + markdown, + coverAssetUrl: context.article.cover_asset_url, + }, + { + fetchJson: (input, init) => sessionFetchJson(context.session, input, init), + fetchImageBlob: fetchAssetBlob, + reportProgress(stage) { + context.reportProgress(`jianshu.${stage}`); + }, + }, + ); + + if (!result.success) { + const summary = + result.code === "jianshu_not_logged_in" + ? "简书登录态失效,无法执行发布。" + : result.code === "article_content_empty" + ? "文章内容为空,无法推送到简书。" + : result.code === "jianshu_no_notebook" + ? "未找到简书文集,请先在简书创建一个文集。" + : result.message || "简书发布失败。"; + + return { + status: "failed", + summary, + error: { + code: result.code, + message: result.message, + }, + }; + } + + return { + status: "succeeded", + payload: buildResultPayload( + result.articleId, + result.mediaName, + result.externalManageUrl, + result.externalArticleUrl, + ), + summary: "简书发布成功。", + }; + }, +}; diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index c8ec0f4..ce93198 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -2,6 +2,7 @@ import { hostname } from "node:os"; import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { shell } from "electron"; import { BrowserWindow, app, ipcMain, nativeTheme } from "electron/main"; import type { BrowserWindow as ElectronBrowserWindow, @@ -269,6 +270,15 @@ function safeHandle( }); } +function isSafeExternalUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + function registerBridgeHandlers(): void { ipcMain.handle("desktop:ping", () => "pong"); ipcMain.handle("desktop:device-info", () => ({ @@ -309,6 +319,13 @@ function registerBridgeHandlers(): void { safeHandle("desktop:retry-publish-task", async (_event, taskId: string) => { return retryDesktopPublishTask(taskId); }); + safeHandle("desktop:open-external-url", async (_event, url: string) => { + if (!isSafeExternalUrl(url)) { + throw new Error("unsupported_external_url"); + } + await shell.openExternal(url); + return null; + }); ipcMain.handle( "desktop:runtime-session-sync", (_event, session: DesktopRuntimeSessionSyncRequest | null) => { diff --git a/apps/desktop-client/src/main/platform-auth-adapters.ts b/apps/desktop-client/src/main/platform-auth-adapters.ts index 391a597..c5f06d5 100644 --- a/apps/desktop-client/src/main/platform-auth-adapters.ts +++ b/apps/desktop-client/src/main/platform-auth-adapters.ts @@ -101,6 +101,20 @@ function classifyWenxinFailure(input: PlatformFailureInput): PlatformFailureClas return classifyFailure(input); } +function classifyBaijiahaoFailure(input: PlatformFailureInput): PlatformFailureClassification { + const code = typeof input.error?.code === "string" ? input.error.code : ""; + if (code === "baijiahao_challenge_required") { + return "challenge"; + } + if (code === "baijiahao_not_logged_in" || code === "baijiahao_token_missing") { + return "auth_failure"; + } + if (code.startsWith("baijiahao_")) { + return "ok"; + } + return classifyFailure(input); +} + const genericAdapter: PlatformAdapter = { async probe(account, partition) { return await probePublishAccountSession(account, partition); @@ -131,6 +145,16 @@ const wenxinAdapter: PlatformAdapter = { classifyFailure: classifyWenxinFailure, }; +const baijiahaoAdapter: PlatformAdapter = { + async probe(account, partition) { + return await probePublishAccountSession(account, partition); + }, + async silentRefresh(account, partition) { + return await silentRefreshPublishAccountSession(account, partition); + }, + classifyFailure: classifyBaijiahaoFailure, +}; + const adapterRegistry = new Map( [ ...aiPlatformCatalog.map((platform) => platform.id), @@ -153,6 +177,8 @@ const adapterRegistry = new Map( ? doubaoAdapter : platform === "wenxin" ? wenxinAdapter + : platform === "baijiahao" + ? baijiahaoAdapter : genericAdapter, ]), ); diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index 7b7e1e0..0c27051 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -17,7 +17,9 @@ import type { import { getAIPlatformCatalogItem, isAIPlatformId } from "@geo/shared-types"; import { + baijiahaoAdapter, getMonitorAdapter, + jianshuAdapter, toutiaoAdapter, zhihuAdapter, type AdapterExecutionResult, @@ -139,6 +141,7 @@ const authRequiredMonitorPlatforms = new Set(["yuanbao", "kimi", "deepseek", "do const publishReservedSlots = 1; const minimumForegroundTotalConcurrency = 2; const maximumRuntimeTotalConcurrency = 4; +const BAIJIAHAO_RISK_CONTROL_PROMPT = "您触发平台风控,请手动发稿一篇并完成验证,方可继续使用"; interface RuntimeTaskRecord { id: string; @@ -298,6 +301,9 @@ function accountActionRequiredSummary( authReason: string | null | undefined, ): string { const label = runtimePlatformLabel(platform); + if (platform === "baijiahao") { + return BAIJIAHAO_RISK_CONTROL_PROMPT; + } if (authReason === "risk_control") { return `${label} 账号疑似触发风控,请打开平台处理验证或等待限制解除后再继续执行。`; } @@ -319,6 +325,14 @@ function maybeRecordAccountAccessAlert( } if (next.authState === "challenge_required" && next.authReason === "risk_control") { + if (accountIdentity.platform === "baijiahao") { + recordActivity( + "warn", + "百家号触发平台风控", + BAIJIAHAO_RISK_CONTROL_PROMPT, + ); + return; + } recordActivity( "warn", `${runtimePlatformLabel(accountIdentity.platform)} 触发风控`, @@ -328,6 +342,14 @@ function maybeRecordAccountAccessAlert( } if (next.authState === "challenge_required") { + if (accountIdentity.platform === "baijiahao") { + recordActivity( + "warn", + "百家号触发平台风控", + BAIJIAHAO_RISK_CONTROL_PROMPT, + ); + return; + } recordActivity( "warn", `${runtimePlatformLabel(accountIdentity.platform)} 需要人工验证`, @@ -2837,9 +2859,15 @@ function selectPublishAdapter(platform: string): PublishAdapter | null { if (platform === toutiaoAdapter.platform) { return toutiaoAdapter; } + if (platform === baijiahaoAdapter.platform) { + return baijiahaoAdapter; + } if (platform === zhihuAdapter.platform) { return zhihuAdapter; } + if (platform === jianshuAdapter.platform) { + return jianshuAdapter; + } return null; } diff --git a/apps/desktop-client/src/main/transport/api-client.ts b/apps/desktop-client/src/main/transport/api-client.ts index 4fd87f3..e5706e4 100644 --- a/apps/desktop-client/src/main/transport/api-client.ts +++ b/apps/desktop-client/src/main/transport/api-client.ts @@ -143,6 +143,10 @@ function resolveDesktopURL(path: string): string { return new URL(path, transportSession.baseURL).toString(); } +export function resolveDesktopApiURL(path: string): string { + return resolveDesktopURL(path); +} + function resolveObservedRequestURL(path: string | undefined, baseURL: string): string { if (!path) { return baseURL; diff --git a/apps/desktop-client/src/preload/bridge.ts b/apps/desktop-client/src/preload/bridge.ts index 433d50b..f046c7c 100644 --- a/apps/desktop-client/src/preload/bridge.ts +++ b/apps/desktop-client/src/preload/bridge.ts @@ -101,6 +101,8 @@ const desktopBridge = { ipcRenderer.invoke("desktop:list-publish-tasks", params) as Promise, retryPublishTask: (taskId: string) => ipcRenderer.invoke("desktop:retry-publish-task", taskId) as Promise, + openExternalUrl: (url: string) => + ipcRenderer.invoke("desktop:open-external-url", url) as Promise, syncRuntimeSession: (session: DesktopRuntimeSessionSyncRequest | null) => ipcRenderer.invoke("desktop:runtime-session-sync", session) as Promise, releaseRuntimeSession: (revoke?: boolean) => diff --git a/apps/desktop-client/src/renderer/env.d.ts b/apps/desktop-client/src/renderer/env.d.ts index 431a250..0caf0d7 100644 --- a/apps/desktop-client/src/renderer/env.d.ts +++ b/apps/desktop-client/src/renderer/env.d.ts @@ -28,6 +28,7 @@ declare global { unbindPublishAccount(accountId: string, syncVersion: number): Promise; listPublishTasks(params?: ListDesktopPublishTasksParams): Promise; retryPublishTask(taskId: string): Promise; + openExternalUrl(url: string): Promise; syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise; releaseRuntimeSession(revoke?: boolean): Promise; setWindowMode(mode: "login" | "main"): Promise; diff --git a/apps/desktop-client/src/renderer/views/PublishManagementView.vue b/apps/desktop-client/src/renderer/views/PublishManagementView.vue index c37b2ca..09e9789 100644 --- a/apps/desktop-client/src/renderer/views/PublishManagementView.vue +++ b/apps/desktop-client/src/renderer/views/PublishManagementView.vue @@ -1,5 +1,11 @@