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>
This commit is contained in:
2026-04-26 19:47:35 +08:00
parent ae63f60def
commit 87e329207c
27 changed files with 2056 additions and 54 deletions
@@ -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;
}
@@ -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 = `
<table>
<thead>
<tr><th>城市</th><th>商家</th></tr>
</thead>
<tbody>
<tr><td>合肥</td><td>虫师银古</td></tr>
</tbody>
</table>
`;
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("<strong>城市</strong>");
expect(result).toContain("<td");
expect(result).toContain("虫师银古");
expect(result).not.toContain("<thead>");
expect(result).not.toContain("<th>");
});
it("preserves cell span attributes", () => {
const result = normalizeBaijiahaoTables('<table><tbody><tr><td colspan="2">汇总</td></tr></tbody></table>');
expect(result).toContain('colspan="2"');
expect(result).toContain("汇总");
});
it("downgrades tables into visible div blocks for Baijiahao", () => {
const result = renderBaijiahaoTablesAsBlocks(`
<table>
<thead>
<tr><th>城市</th><th>商家</th></tr>
</thead>
<tbody>
<tr><td colspan="2">合肥</td></tr>
</tbody>
</table>
`);
expect(result).not.toContain("<table");
expect(result).not.toContain("<thead");
expect(result).not.toContain("<th");
expect(result).toContain("display: flex");
expect(result).toContain("城市");
expect(result).toContain("商家");
expect(result).toContain("合肥");
expect(result).toContain("flex: 2 2 0");
});
it("keeps the paragraph after a markdown table outside the downgraded table", () => {
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("<table");
expect(html).toContain("display: flex");
expect(html).toContain("<p>下一段中文说明</p>");
expect(html.indexOf("合肥全屋定制杨姐")).toBeGreaterThanOrEqual(0);
expect(html.indexOf("合肥全屋定制杨姐")).toBeLessThan(html.indexOf("<p>下一段中文说明</p>"));
});
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("<h1>2026年合肥全屋定制口碑好品牌推荐 | 本地业主专属避坑指南</h1>");
expect(html).not.toContain("<table");
expect(html).toContain("display: flex");
expect(html).toContain("合肥全屋定制杨姐");
});
});
@@ -0,0 +1,562 @@
import { Buffer } from "node:buffer";
import type { JsonValue } from "@geo/shared-types";
import {
prepareBaijiahaoArticleHtml,
prepareBaijiahaoMarkdown,
} from "../../../../../packages/publisher-platforms/src/baijiahao";
import { nativeImage } from "electron";
import {
normalizeArticleHtml,
sessionFetchJson,
sessionFetchText,
uploadHtmlImages,
} from "./common";
import type { PublishAdapter, PublishAdapterContext } from "./base";
import { resolveDesktopApiURL } from "../transport/api-client";
type BaijiahaoAppInfoResponse = {
data?: {
user?: {
userid?: string | number;
name?: string;
uname?: string;
username?: string;
avatar?: string;
app_id?: string | number;
};
};
};
type BaijiahaoPublishResponse = {
errno?: number;
errmsg?: string;
ret?: {
id?: string | number;
};
};
type BaijiahaoUploadResponse = {
errno?: number;
errmsg?: string;
err_msg?: string;
ret?: {
https_url?: string;
url?: string;
};
};
type BaijiahaoCutResponse = {
data?: {
new_url?: string;
};
};
type BaijiahaoUser = NonNullable<NonNullable<BaijiahaoAppInfoResponse["data"]>["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<void> {
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<BaijiahaoUser | null> {
const response = await sessionFetchJson<BaijiahaoAppInfoResponse>(
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<string> {
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<string | null> {
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<BaijiahaoUploadResponse>(
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<BaijiahaoCutResponse>(
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<BaijiahaoImageBlob | null> {
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<BaijiahaoImageBlob | null> {
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<string> {
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<string, JsonValue> {
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<PublishAdapter["publish"]> extends Promise<infer T> ? 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<BaijiahaoPublishResponse>(
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);
}
},
};
@@ -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(/<section\b/gi, "<div").replace(/<\/section>/gi, "</div>");
next = next.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, "");
@@ -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";
@@ -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<Blob | null> {
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<string, JsonValue> {
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: "简书发布成功。",
};
},
};
+17
View File
@@ -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<Args extends unknown[], Result>(
});
}
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) => {
@@ -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<string, PlatformAdapter>(
[
...aiPlatformCatalog.map((platform) => platform.id),
@@ -153,6 +177,8 @@ const adapterRegistry = new Map<string, PlatformAdapter>(
? doubaoAdapter
: platform === "wenxin"
? wenxinAdapter
: platform === "baijiahao"
? baijiahaoAdapter
: genericAdapter,
]),
);
@@ -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;
}
@@ -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;
@@ -101,6 +101,8 @@ const desktopBridge = {
ipcRenderer.invoke("desktop:list-publish-tasks", params) as Promise<DesktopPublishTaskListResponse>,
retryPublishTask: (taskId: string) =>
ipcRenderer.invoke("desktop:retry-publish-task", taskId) as Promise<CreatePublishJobResponse>,
openExternalUrl: (url: string) =>
ipcRenderer.invoke("desktop:open-external-url", url) as Promise<null>,
syncRuntimeSession: (session: DesktopRuntimeSessionSyncRequest | null) =>
ipcRenderer.invoke("desktop:runtime-session-sync", session) as Promise<null>,
releaseRuntimeSession: (revoke?: boolean) =>
+1
View File
@@ -28,6 +28,7 @@ declare global {
unbindPublishAccount(accountId: string, syncVersion: number): Promise<null>;
listPublishTasks(params?: ListDesktopPublishTasksParams): Promise<DesktopPublishTaskListResponse>;
retryPublishTask(taskId: string): Promise<CreatePublishJobResponse>;
openExternalUrl(url: string): Promise<null>;
syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise<null>;
releaseRuntimeSession(revoke?: boolean): Promise<null>;
setWindowMode(mode: "login" | "main"): Promise<null>;
@@ -1,5 +1,11 @@
<script setup lang="ts">
import { ClockCircleOutlined, ReloadOutlined, SearchOutlined, SendOutlined } from "@ant-design/icons-vue";
import {
ClockCircleOutlined,
ExportOutlined,
ReloadOutlined,
SearchOutlined,
SendOutlined,
} from "@ant-design/icons-vue";
import type { DesktopPublishTaskListResponse, DesktopTaskInfo, JsonValue } from "@geo/shared-types";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
@@ -190,6 +196,19 @@ async function retryTask(taskId: string) {
}
}
async function openExternalUrl(url: string | null) {
if (!url) {
return;
}
actionError.value = null;
try {
await window.desktopBridge.app.openExternalUrl(url);
} catch (err) {
actionError.value = err instanceof Error ? err.message : "打开外链失败";
}
}
function applySearch() {
currentPage.value = 1;
appliedTitle.value = searchTitle.value.trim();
@@ -396,8 +415,36 @@ const tableColumns = [
<span class="subtitle mono-detail">
尝试 {{ record.attempts }}
<template v-if="record.leaseExpiresAt"> · 租约 {{ formatRelativeTime(record.leaseExpiresAt) }} 到期</template>
<template v-else-if="record.externalArticleUrl"> · 已生成外链</template>
<template v-else-if="record.externalManageUrl"> · 已生成管理链接</template>
<template v-else-if="record.externalArticleUrl">
·
<a-tooltip :title="record.externalArticleUrl" overlay-class-name="external-url-tooltip">
<a
class="external-task-link"
:href="record.externalArticleUrl"
target="_blank"
rel="noopener noreferrer"
@click.prevent.stop="openExternalUrl(record.externalArticleUrl)"
>
<ExportOutlined />
打开外链
</a>
</a-tooltip>
</template>
<template v-else-if="record.externalManageUrl">
·
<a-tooltip :title="record.externalManageUrl" overlay-class-name="external-url-tooltip">
<a
class="external-task-link"
:href="record.externalManageUrl"
target="_blank"
rel="noopener noreferrer"
@click.prevent.stop="openExternalUrl(record.externalManageUrl)"
>
<ExportOutlined />
打开管理页
</a>
</a-tooltip>
</template>
</span>
</div>
</div>
@@ -628,6 +675,23 @@ const tableColumns = [
color: #94a3b8;
}
.external-task-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: #2563eb;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
text-decoration: none;
}
.external-task-link:hover {
color: #1d4ed8;
text-decoration: underline;
}
.info-stack .strong-text {
color: #1e293b;
font-weight: 500;
@@ -709,6 +773,11 @@ const tableColumns = [
background: #ecfdf5;
}
:global(.external-url-tooltip .ant-tooltip-inner) {
max-width: min(520px, 80vw);
overflow-wrap: anywhere;
}
/* Responsiveness */
@media (max-width: 1024px) {
.hero-header {