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:
@@ -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: "简书发布成功。",
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user