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:
@@ -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<PublishPlatformSection[]>(() => {
|
||||
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<PublishPlatformSection[]>(() => {
|
||||
});
|
||||
}
|
||||
|
||||
if (successEntries.value.length > 0) {
|
||||
sections.push({
|
||||
key: "success",
|
||||
label: `${getPublishStatusMeta("success").label}:`,
|
||||
tone: "success",
|
||||
entries: successEntries.value,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
});
|
||||
|
||||
|
||||
@@ -27,10 +27,10 @@ const requirementCatalog: Record<string, Partial<PlatformCoverRequirement>> = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<NonNullable<BaijiahaoAppInfoResponse["data"]>["user"] | null> {
|
||||
const response = await fetchJson<BaijiahaoAppInfoResponse>("https://baijiahao.baidu.com/builder/app/appinfo", {
|
||||
headers: {
|
||||
@@ -135,16 +147,25 @@ async function publishBaijiahao(context: AdapterContext): Promise<PlatformPublis
|
||||
throw new Error("baijiahao_token_missing");
|
||||
}
|
||||
|
||||
const content = normalizeArticleHtml(context.article);
|
||||
const content = prepareBaijiahaoArticleHtml(
|
||||
normalizeArticleHtml(context.article, {
|
||||
prepareMarkdown: prepareBaijiahaoMarkdown,
|
||||
}),
|
||||
);
|
||||
const processed = await uploadHtmlImages(
|
||||
content,
|
||||
async (sourceUrl) => 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<PlatformPublis
|
||||
title: context.article.title,
|
||||
content: processed.html,
|
||||
abstract_from: "1",
|
||||
cover_layout: coverUrl ? "one" : "",
|
||||
cover_images: coverUrl
|
||||
? JSON.stringify([
|
||||
{
|
||||
src: coverUrl,
|
||||
cropData: {},
|
||||
machine_chooseimg: 0,
|
||||
isLegal: 0,
|
||||
cover_source_tag: "local",
|
||||
},
|
||||
])
|
||||
: "",
|
||||
cover_layout: "one",
|
||||
cover_images: JSON.stringify([
|
||||
{
|
||||
src: coverUrl,
|
||||
cropData: {},
|
||||
machine_chooseimg: 0,
|
||||
isLegal: 0,
|
||||
cover_source_tag: "local",
|
||||
},
|
||||
]),
|
||||
activity_list: JSON.stringify([
|
||||
{ id: "ttv", is_checked: 0 },
|
||||
{ id: "ai_tts", is_checked: 0 },
|
||||
{ id: "aigc_bjh_status", is_checked: 1 },
|
||||
{ id: "aigc_bjh_status", is_checked: 0 },
|
||||
{ id: "reward", is_checked: 0 },
|
||||
]),
|
||||
}),
|
||||
@@ -188,7 +207,8 @@ async function publishBaijiahao(context: AdapterContext): Promise<PlatformPublis
|
||||
throw new Error(response.errmsg || "baijiahao_publish_failed");
|
||||
}
|
||||
|
||||
const manageUrl = `https://baijiahao.baidu.com/builder/rc/edit?type=news&article_id=${articleId}`;
|
||||
const manageUrl = baijiahaoManageUrl(articleId);
|
||||
const articleUrl = baijiahaoPublicUrl(articleId);
|
||||
if (context.article.publish_type === "draft") {
|
||||
return manageUrlResult(true, "pending_review", articleId, manageUrl, "百家号草稿保存成功。");
|
||||
}
|
||||
@@ -198,8 +218,8 @@ async function publishBaijiahao(context: AdapterContext): Promise<PlatformPublis
|
||||
"success",
|
||||
articleId,
|
||||
manageUrl,
|
||||
"百家号发布成功,公开链接待补查。",
|
||||
null,
|
||||
"百家号发布成功。",
|
||||
articleUrl,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,9 +111,16 @@ export function markdownToHtml(markdown: string): string {
|
||||
}) as string;
|
||||
}
|
||||
|
||||
export function normalizeArticleHtml(article: PublisherPublishArticleRequest): string {
|
||||
export interface NormalizeArticleHtmlOptions {
|
||||
prepareMarkdown?: (markdown: string) => 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(/<section\b/gi, "<div").replace(/<\/section>/gi, "</div>");
|
||||
next = next.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, "");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: "简书发布成功。",
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
const BAIJIAHAO_TABLE_STYLE = [
|
||||
"width: 100%",
|
||||
"border-collapse: collapse",
|
||||
"border-spacing: 0",
|
||||
"margin: 12px 0",
|
||||
"font-size: 15px",
|
||||
"line-height: 1.6",
|
||||
"color: #1f2937",
|
||||
].join("; ");
|
||||
|
||||
const BAIJIAHAO_TABLE_CELL_STYLE = [
|
||||
"border: 1px solid #d9d9d9",
|
||||
"padding: 8px 10px",
|
||||
"vertical-align: middle",
|
||||
"word-break: break-word",
|
||||
"min-width: 64px",
|
||||
].join("; ");
|
||||
|
||||
const BAIJIAHAO_TABLE_HEADER_CELL_STYLE = [
|
||||
BAIJIAHAO_TABLE_CELL_STYLE,
|
||||
"background-color: #f5f7fa",
|
||||
"font-weight: 600",
|
||||
].join("; ");
|
||||
|
||||
const BAIJIAHAO_FAUX_TABLE_STYLE = [
|
||||
"width: 100%",
|
||||
"margin: 12px 0",
|
||||
"border-top: 1px solid #d9d9d9",
|
||||
"border-left: 1px solid #d9d9d9",
|
||||
"overflow-x: auto",
|
||||
].join("; ");
|
||||
|
||||
const BAIJIAHAO_FAUX_TABLE_ROW_STYLE = [
|
||||
"display: flex",
|
||||
"align-items: stretch",
|
||||
"width: 100%",
|
||||
].join("; ");
|
||||
|
||||
const BAIJIAHAO_FAUX_TABLE_CELL_STYLE = [
|
||||
"flex: 1 1 0",
|
||||
"min-width: 96px",
|
||||
"box-sizing: border-box",
|
||||
"border-right: 1px solid #d9d9d9",
|
||||
"border-bottom: 1px solid #d9d9d9",
|
||||
"padding: 8px 10px",
|
||||
"vertical-align: middle",
|
||||
"word-break: break-word",
|
||||
"font-size: 15px",
|
||||
"line-height: 1.6",
|
||||
"color: #1f2937",
|
||||
"background: #ffffff",
|
||||
].join("; ");
|
||||
|
||||
const BAIJIAHAO_FAUX_TABLE_HEADER_CELL_STYLE = [
|
||||
BAIJIAHAO_FAUX_TABLE_CELL_STYLE,
|
||||
"background: #f5f7fa",
|
||||
"font-weight: 600",
|
||||
].join("; ");
|
||||
|
||||
const BAIJIAHAO_TABLE_ALLOWED_ATTRS = ["colspan", "rowspan", "align", "valign", "width"];
|
||||
|
||||
interface ParsedTableCell {
|
||||
content: string;
|
||||
header: boolean;
|
||||
colSpan: number;
|
||||
}
|
||||
|
||||
type ParsedTableRow = ParsedTableCell[];
|
||||
|
||||
function hasUnescapedPipe(value: string): boolean {
|
||||
let escaped = false;
|
||||
for (const char of value) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "|") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function splitMarkdownTableCells(value: string): string[] {
|
||||
const cells: string[] = [];
|
||||
let cell = "";
|
||||
let escaped = false;
|
||||
const trimmed = value.trim();
|
||||
const source = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed;
|
||||
const content = source.endsWith("|") && !source.endsWith("\\|") ? source.slice(0, -1) : source;
|
||||
|
||||
for (const char of content) {
|
||||
if (escaped) {
|
||||
cell += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
cell += char;
|
||||
continue;
|
||||
}
|
||||
if (char === "|") {
|
||||
cells.push(cell.trim());
|
||||
cell = "";
|
||||
continue;
|
||||
}
|
||||
cell += char;
|
||||
}
|
||||
|
||||
cells.push(cell.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
function isMarkdownTableRow(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
return trimmed !== "" && hasUnescapedPipe(trimmed) && splitMarkdownTableCells(trimmed).length >= 2;
|
||||
}
|
||||
|
||||
function isMarkdownTableSeparator(value: string): boolean {
|
||||
const cells = splitMarkdownTableCells(value).filter(Boolean);
|
||||
return cells.length >= 2 && cells.every((cell) => /^:?-{2,}:?$/.test(cell.trim()));
|
||||
}
|
||||
|
||||
function isMarkdownTableStart(lines: string[], index: number): boolean {
|
||||
return isMarkdownTableRow(lines[index] ?? "") && isMarkdownTableSeparator(lines[index + 1] ?? "");
|
||||
}
|
||||
|
||||
function isFenceBoundary(value: string): boolean {
|
||||
return /^\s*(```|~~~)/.test(value);
|
||||
}
|
||||
|
||||
export function prepareBaijiahaoMarkdown(markdown: string): string {
|
||||
const source = markdown.replace(/\r\n?/g, "\n").trim();
|
||||
if (!source) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines = source.split("\n");
|
||||
const output: string[] = [];
|
||||
let inCodeFence = false;
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? "";
|
||||
if (isFenceBoundary(line)) {
|
||||
inCodeFence = !inCodeFence;
|
||||
output.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inCodeFence && isMarkdownTableStart(lines, index)) {
|
||||
while (output.length > 0 && output[output.length - 1]?.trim() === "") {
|
||||
output.pop();
|
||||
}
|
||||
if (/^\s*#{1,6}\s*$/.test(output[output.length - 1] ?? "")) {
|
||||
output.pop();
|
||||
}
|
||||
if (output.length > 0 && output[output.length - 1]?.trim() !== "") {
|
||||
output.push("");
|
||||
}
|
||||
|
||||
output.push(lines[index] ?? "");
|
||||
output.push(lines[index + 1] ?? "");
|
||||
index += 2;
|
||||
|
||||
while (index < lines.length && isMarkdownTableRow(lines[index] ?? "")) {
|
||||
output.push(lines[index] ?? "");
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (index < lines.length && lines[index]?.trim() !== "") {
|
||||
output.push("");
|
||||
}
|
||||
index -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push(line);
|
||||
}
|
||||
|
||||
return output.join("\n").trim();
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function extractAttribute(sourceTag: string, attributeName: string): string | null {
|
||||
const matcher = new RegExp(`\\s${attributeName}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i");
|
||||
const match = matcher.exec(sourceTag);
|
||||
const value = match?.[1] ?? match?.[2] ?? match?.[3] ?? "";
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function allowedTableAttributes(sourceTag: string): string {
|
||||
return BAIJIAHAO_TABLE_ALLOWED_ATTRS
|
||||
.map((attributeName) => {
|
||||
const value = extractAttribute(sourceTag, attributeName);
|
||||
return value ? `${attributeName}="${escapeAttribute(value)}"` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function normalizeCellContent(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : "<br/>";
|
||||
}
|
||||
|
||||
function normalizeSpanAttribute(sourceTag: string, attributeName: "colspan" | "rowspan"): number {
|
||||
const value = extractAttribute(sourceTag, attributeName);
|
||||
const parsed = value ? Number.parseInt(value, 10) : 1;
|
||||
return Number.isFinite(parsed) && parsed > 1 ? Math.min(parsed, 12) : 1;
|
||||
}
|
||||
|
||||
function normalizeTableCell(sourceTag: string, content: string, header: boolean): string {
|
||||
const attrs = allowedTableAttributes(sourceTag);
|
||||
const attrPrefix = attrs ? `${attrs} ` : "";
|
||||
const style = header ? BAIJIAHAO_TABLE_HEADER_CELL_STYLE : BAIJIAHAO_TABLE_CELL_STYLE;
|
||||
const normalizedContent = normalizeCellContent(content);
|
||||
const cellContent = header ? `<strong>${normalizedContent}</strong>` : normalizedContent;
|
||||
return `<td ${attrPrefix}style="${style}">${cellContent}</td>`;
|
||||
}
|
||||
|
||||
function normalizeSingleTable(tableHtml: string): string {
|
||||
let body = tableHtml
|
||||
.replace(/^\s*<table\b[^>]*>/i, "")
|
||||
.replace(/<\/table>\s*$/i, "")
|
||||
.replace(/<caption\b[^>]*>[\s\S]*?<\/caption>/gi, "")
|
||||
.replace(/<colgroup\b[^>]*>[\s\S]*?<\/colgroup>/gi, "")
|
||||
.replace(/<col\b[^>]*\/?>/gi, "")
|
||||
.replace(/<\/?(?:thead|tbody|tfoot)\b[^>]*>/gi, "");
|
||||
|
||||
body = body.replace(/<th\b([^>]*)>([\s\S]*?)<\/th>/gi, (_match, attrs: string, content: string) =>
|
||||
normalizeTableCell(`<th${attrs}>`, content, true),
|
||||
);
|
||||
body = body.replace(/<td\b([^>]*)>([\s\S]*?)<\/td>/gi, (_match, attrs: string, content: string) =>
|
||||
normalizeTableCell(`<td${attrs}>`, content, false),
|
||||
);
|
||||
|
||||
let rowIndex = 0;
|
||||
body = body.replace(/<tr\b[^>]*>/gi, () => {
|
||||
rowIndex += 1;
|
||||
return rowIndex === 1 ? `<tr class="firstRow">` : "<tr>";
|
||||
});
|
||||
|
||||
return `<table data-sort="sortDisabled" border="1" cellpadding="0" cellspacing="0" style="${BAIJIAHAO_TABLE_STYLE}"><tbody>${body.trim()}</tbody></table>`;
|
||||
}
|
||||
|
||||
export function normalizeBaijiahaoTables(html: string): string {
|
||||
if (!/<table\b/i.test(html)) {
|
||||
return html;
|
||||
}
|
||||
return html.replace(/<table\b[^>]*>[\s\S]*?<\/table>/gi, (tableHtml) => normalizeSingleTable(tableHtml));
|
||||
}
|
||||
|
||||
function parseTableRows(tableHtml: string): ParsedTableRow[] {
|
||||
const rows: ParsedTableRow[] = [];
|
||||
for (const rowMatch of tableHtml.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
|
||||
const cells: ParsedTableRow = [];
|
||||
const rowContent = rowMatch[1] ?? "";
|
||||
for (const cellMatch of rowContent.matchAll(/<(th|td)\b([^>]*)>([\s\S]*?)<\/\1>/gi)) {
|
||||
const sourceTag = `<${cellMatch[1]}${cellMatch[2] ?? ""}>`;
|
||||
cells.push({
|
||||
content: normalizeCellContent(cellMatch[3] ?? ""),
|
||||
header: cellMatch[1]?.toLowerCase() === "th",
|
||||
colSpan: normalizeSpanAttribute(sourceTag, "colspan"),
|
||||
});
|
||||
}
|
||||
if (cells.length > 0) {
|
||||
rows.push(cells);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function renderFauxTableCell(cell: ParsedTableCell, rowIndex: number): string {
|
||||
const header = cell.header || rowIndex === 0;
|
||||
const baseStyle = header ? BAIJIAHAO_FAUX_TABLE_HEADER_CELL_STYLE : BAIJIAHAO_FAUX_TABLE_CELL_STYLE;
|
||||
const style = `${baseStyle}; flex: ${cell.colSpan} ${cell.colSpan} 0`;
|
||||
const content = header ? `<strong>${cell.content}</strong>` : cell.content;
|
||||
return `<div style="${style}">${content}</div>`;
|
||||
}
|
||||
|
||||
function renderSingleTableAsBlocks(tableHtml: string): string {
|
||||
const rows = parseTableRows(tableHtml);
|
||||
if (rows.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return `<div style="${BAIJIAHAO_FAUX_TABLE_STYLE}">${rows
|
||||
.map((row, rowIndex) => `<div style="${BAIJIAHAO_FAUX_TABLE_ROW_STYLE}">${row
|
||||
.map((cell) => renderFauxTableCell(cell, rowIndex))
|
||||
.join("")}</div>`)
|
||||
.join("")}</div>`;
|
||||
}
|
||||
|
||||
export function renderBaijiahaoTablesAsBlocks(html: string): string {
|
||||
if (!/<table\b/i.test(html)) {
|
||||
return html;
|
||||
}
|
||||
return html.replace(/<table\b[^>]*>[\s\S]*?<\/table>/gi, (tableHtml) => renderSingleTableAsBlocks(tableHtml));
|
||||
}
|
||||
|
||||
export function prepareBaijiahaoArticleHtml(html: string): string {
|
||||
// Baijiahao's preview/final article page does not consistently render native table tags,
|
||||
// so publish uses ordinary div blocks to preserve visible tabular content.
|
||||
return renderBaijiahaoTablesAsBlocks(html).trim();
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./baijiahao";
|
||||
export * from "./jianshu";
|
||||
export * from "./toutiao";
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
type JianshuCurrentUserResponse = {
|
||||
id?: number;
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
preferred_note_type?: "plain" | "markdown";
|
||||
};
|
||||
|
||||
type JianshuNotebookSummary = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type JianshuNoteCreateResponse = {
|
||||
id?: number;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
type JianshuTokenResponse = {
|
||||
token?: string;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
type JianshuQiniuResponse = {
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type JianshuPublicizeResponse = {
|
||||
id?: number;
|
||||
slug?: string;
|
||||
error?: Array<{ message?: string }>;
|
||||
};
|
||||
|
||||
export type JianshuNoteType = "plain" | "markdown";
|
||||
|
||||
export interface JianshuMediaSnapshot {
|
||||
platformUid: string | null;
|
||||
screenName: string | null;
|
||||
avatarUrl: string | null;
|
||||
preferredNoteType: JianshuNoteType;
|
||||
}
|
||||
|
||||
export interface JianshuPublishArticleInput {
|
||||
title: string;
|
||||
html: string;
|
||||
markdown: string | null;
|
||||
coverAssetUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface JianshuPublishTransport {
|
||||
fetchJson<T>(input: string, init?: RequestInit): Promise<T>;
|
||||
fetchImageBlob(sourceUrl: string): Promise<Blob | null>;
|
||||
reportProgress?(
|
||||
stage:
|
||||
| "media_info"
|
||||
| "fetch_notebook"
|
||||
| "create_note"
|
||||
| "upload_content_images"
|
||||
| "save_content"
|
||||
| "upload_cover"
|
||||
| "publish",
|
||||
): void;
|
||||
}
|
||||
|
||||
export type JianshuPublishResult =
|
||||
| {
|
||||
success: true;
|
||||
status: "success";
|
||||
articleId: string;
|
||||
mediaName: string;
|
||||
externalManageUrl: string;
|
||||
externalArticleUrl: string | null;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
status: "failed";
|
||||
code:
|
||||
| "jianshu_not_logged_in"
|
||||
| "article_content_empty"
|
||||
| "jianshu_no_notebook"
|
||||
| "jianshu_create_note_failed"
|
||||
| "jianshu_publish_failed";
|
||||
message: string;
|
||||
};
|
||||
|
||||
const JIANSHU_ORIGIN = "https://www.jianshu.com";
|
||||
const QINIU_UPLOAD_ENDPOINT = "https://upload.qiniup.com";
|
||||
|
||||
function randomFilenameStem(): string {
|
||||
const ts = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).slice(2, 10);
|
||||
return `${ts}-${rand}`;
|
||||
}
|
||||
|
||||
function jianshuApiHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
||||
return {
|
||||
Accept: "application/json",
|
||||
Referer: `${JIANSHU_ORIGIN}/`,
|
||||
Origin: JIANSHU_ORIGIN,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function jianshuJsonHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
||||
return jianshuApiHeaders({
|
||||
"Content-Type": "application/json",
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function parseJianshuErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
try {
|
||||
const parsed = JSON.parse(error.message) as {
|
||||
error?: Array<{ message?: string }>;
|
||||
message?: string;
|
||||
};
|
||||
const apiMessage = parsed?.error?.[0]?.message ?? parsed?.message;
|
||||
if (apiMessage) {
|
||||
return apiMessage;
|
||||
}
|
||||
} catch {
|
||||
// not JSON — fall through to raw message
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function fetchJianshuMediaSnapshot(
|
||||
fetchJson: <T>(input: string, init?: RequestInit) => Promise<T>,
|
||||
): Promise<JianshuMediaSnapshot | null> {
|
||||
const me = await fetchJson<JianshuCurrentUserResponse>(
|
||||
`${JIANSHU_ORIGIN}/author/current_user`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: jianshuApiHeaders(),
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
if (!me?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
platformUid: String(me.id),
|
||||
screenName: me.nickname ?? null,
|
||||
avatarUrl: me.avatar ?? null,
|
||||
preferredNoteType: me.preferred_note_type === "markdown" ? "markdown" : "plain",
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPrimaryNotebookId(
|
||||
fetchJson: <T>(input: string, init?: RequestInit) => Promise<T>,
|
||||
): Promise<number | null> {
|
||||
const notebooks = await fetchJson<JianshuNotebookSummary[]>(
|
||||
`${JIANSHU_ORIGIN}/author/notebooks`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: jianshuApiHeaders(),
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
const first = notebooks?.find((item) => typeof item?.id === "number");
|
||||
return first?.id ?? null;
|
||||
}
|
||||
|
||||
async function createNote(
|
||||
fetchJson: <T>(input: string, init?: RequestInit) => Promise<T>,
|
||||
notebookId: number,
|
||||
title: string,
|
||||
): Promise<JianshuNoteCreateResponse | null> {
|
||||
return await fetchJson<JianshuNoteCreateResponse>(
|
||||
`${JIANSHU_ORIGIN}/author/notes`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: jianshuJsonHeaders(),
|
||||
body: JSON.stringify({
|
||||
notebook_id: notebookId,
|
||||
title,
|
||||
at_bottom: true,
|
||||
}),
|
||||
},
|
||||
).catch(() => null);
|
||||
}
|
||||
|
||||
async function uploadImageToQiniu(
|
||||
transport: JianshuPublishTransport,
|
||||
sourceUrl: string,
|
||||
): Promise<string | null> {
|
||||
const blob = await transport.fetchImageBlob(sourceUrl);
|
||||
if (!blob || !blob.type.startsWith("image/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filename = `${randomFilenameStem()}.png`;
|
||||
const token = await transport.fetchJson<JianshuTokenResponse>(
|
||||
`${JIANSHU_ORIGIN}/upload_images/token.json?filename=${encodeURIComponent(filename)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: jianshuApiHeaders(),
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
if (!token?.token || !token.key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append("token", token.token);
|
||||
form.append("key", token.key);
|
||||
form.append("file", blob, filename);
|
||||
form.append("x:protocol", "https");
|
||||
|
||||
const uploaded = await transport.fetchJson<JianshuQiniuResponse>(
|
||||
QINIU_UPLOAD_ENDPOINT,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
body: form,
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
return uploaded?.url ?? null;
|
||||
}
|
||||
|
||||
function collectImageSources(content: string): string[] {
|
||||
const sources = new Set<string>();
|
||||
for (const match of content.matchAll(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi)) {
|
||||
const src = match[2]?.trim();
|
||||
if (src) {
|
||||
sources.add(src);
|
||||
}
|
||||
}
|
||||
for (const match of content.matchAll(/!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
|
||||
const src = match[1]?.trim();
|
||||
if (src) {
|
||||
sources.add(src);
|
||||
}
|
||||
}
|
||||
return [...sources];
|
||||
}
|
||||
|
||||
async function processContentImages(
|
||||
transport: JianshuPublishTransport,
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
const sources = collectImageSources(content);
|
||||
if (!sources.length) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const replacements = new Map<string, string>();
|
||||
for (const source of sources) {
|
||||
if (/jianshu\.io|sfx\.jianshu/i.test(source)) {
|
||||
continue;
|
||||
}
|
||||
const uploaded = await uploadImageToQiniu(transport, source);
|
||||
if (uploaded) {
|
||||
replacements.set(source, uploaded);
|
||||
}
|
||||
}
|
||||
|
||||
let next = content;
|
||||
for (const [from, to] of replacements.entries()) {
|
||||
next = next.split(from).join(to);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function attachCoverImage(
|
||||
transport: JianshuPublishTransport,
|
||||
noteId: number,
|
||||
coverAssetUrl: string,
|
||||
): Promise<void> {
|
||||
const coverUrl = await uploadImageToQiniu(transport, coverAssetUrl);
|
||||
if (!coverUrl) {
|
||||
return;
|
||||
}
|
||||
await transport.fetchJson<unknown>(
|
||||
`${JIANSHU_ORIGIN}/author/notes/${noteId}/update_cover_images`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: jianshuJsonHeaders({ "Content-Type": "application/json; charset=UTF-8" }),
|
||||
body: JSON.stringify({
|
||||
flow_type: 14,
|
||||
abbr: "",
|
||||
cover_image_urls: [coverUrl],
|
||||
}),
|
||||
},
|
||||
).catch(() => null);
|
||||
}
|
||||
|
||||
async function softDestroyNote(
|
||||
transport: JianshuPublishTransport,
|
||||
noteId: number,
|
||||
): Promise<void> {
|
||||
await transport.fetchJson<unknown>(
|
||||
`${JIANSHU_ORIGIN}/author/notes/${noteId}/soft_destroy`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: jianshuApiHeaders(),
|
||||
},
|
||||
).catch(() => null);
|
||||
}
|
||||
|
||||
function buildExternalManageUrl(notebookId: number, noteId: number): string {
|
||||
return `${JIANSHU_ORIGIN}/writer#/notebooks/${notebookId}/notes/${noteId}`;
|
||||
}
|
||||
|
||||
function buildExternalArticleUrl(slug: string | null | undefined): string | null {
|
||||
return slug ? `${JIANSHU_ORIGIN}/p/${slug}` : null;
|
||||
}
|
||||
|
||||
function sanitizeJianshuHtml(html: string): string {
|
||||
let next = html;
|
||||
|
||||
next = next.replace(/<(script|style|iframe|noscript|link|meta)\b[^>]*>[\s\S]*?<\/\1>/gi, "");
|
||||
next = next.replace(/<(script|style|iframe|noscript|link|meta)\b[^>]*\/?>/gi, "");
|
||||
|
||||
next = next.replace(/<section\b/gi, "<div").replace(/<\/section>/gi, "</div>");
|
||||
|
||||
next = next.replace(/<figure[^>]*>\s*(<img\b[^>]*>)\s*(?:<figcaption[^>]*>[\s\S]*?<\/figcaption>)?\s*<\/figure>/gi, "$1");
|
||||
|
||||
next = next.replace(/\son[a-z]+="[^"]*"/gi, "");
|
||||
next = next.replace(/\sstyle="[^"]*"/gi, "");
|
||||
next = next.replace(/\sclass="[^"]*"/gi, "");
|
||||
next = next.replace(/\sdata-[a-z-]+="[^"]*"/gi, "");
|
||||
|
||||
next = next.replace(/<table\b([^>]*)>([\s\S]*?)<\/table>/gi, (_match, attrs, inner) => {
|
||||
let body = inner;
|
||||
if (!/<thead\b/i.test(body) && !/<tbody\b/i.test(body)) {
|
||||
body = `<tbody>${body}</tbody>`;
|
||||
}
|
||||
return `<table${attrs}>${body}</table>`;
|
||||
});
|
||||
|
||||
return next.trim();
|
||||
}
|
||||
|
||||
function pickContent(
|
||||
input: JianshuPublishArticleInput,
|
||||
noteType: JianshuNoteType,
|
||||
): { content: string; usedMarkdown: boolean } {
|
||||
if (noteType === "markdown") {
|
||||
const md = input.markdown?.trim();
|
||||
if (md) {
|
||||
return { content: md, usedMarkdown: true };
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: sanitizeJianshuHtml(input.html.trim()),
|
||||
usedMarkdown: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function publishJianshuArticle(
|
||||
input: JianshuPublishArticleInput,
|
||||
transport: JianshuPublishTransport,
|
||||
): Promise<JianshuPublishResult> {
|
||||
transport.reportProgress?.("media_info");
|
||||
const media = await fetchJianshuMediaSnapshot(transport.fetchJson);
|
||||
if (!media?.platformUid) {
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "jianshu_not_logged_in",
|
||||
message: "未检测到简书登录态",
|
||||
};
|
||||
}
|
||||
|
||||
const { content } = pickContent(input, media.preferredNoteType);
|
||||
if (!content) {
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "article_content_empty",
|
||||
message: "html_content/markdown_content is empty",
|
||||
};
|
||||
}
|
||||
|
||||
transport.reportProgress?.("fetch_notebook");
|
||||
const notebookId = await fetchPrimaryNotebookId(transport.fetchJson);
|
||||
if (!notebookId) {
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "jianshu_no_notebook",
|
||||
message: "没有找到文集,请先在简书创建一个文集",
|
||||
};
|
||||
}
|
||||
|
||||
transport.reportProgress?.("create_note");
|
||||
const note = await createNote(transport.fetchJson, notebookId, input.title);
|
||||
if (!note?.id) {
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "jianshu_create_note_failed",
|
||||
message: "创建文章失败",
|
||||
};
|
||||
}
|
||||
|
||||
const noteId = note.id;
|
||||
let createdSlug = note.slug ?? null;
|
||||
let publishOk = false;
|
||||
|
||||
try {
|
||||
transport.reportProgress?.("upload_content_images");
|
||||
const processedContent = await processContentImages(transport, content);
|
||||
|
||||
transport.reportProgress?.("save_content");
|
||||
await transport.fetchJson<unknown>(`${JIANSHU_ORIGIN}/author/notes/${noteId}`, {
|
||||
method: "PUT",
|
||||
headers: jianshuJsonHeaders(),
|
||||
body: JSON.stringify({
|
||||
id: noteId,
|
||||
autosave_control: 1,
|
||||
title: input.title,
|
||||
content: processedContent,
|
||||
}),
|
||||
});
|
||||
|
||||
if (input.coverAssetUrl?.trim()) {
|
||||
transport.reportProgress?.("upload_cover");
|
||||
await attachCoverImage(transport, noteId, input.coverAssetUrl.trim());
|
||||
}
|
||||
|
||||
transport.reportProgress?.("publish");
|
||||
const publicize = await transport.fetchJson<JianshuPublicizeResponse>(
|
||||
`${JIANSHU_ORIGIN}/author/notes/${noteId}/publicize`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: jianshuJsonHeaders(),
|
||||
},
|
||||
).catch((error: unknown): JianshuPublicizeResponse => ({
|
||||
error: [{ message: parseJianshuErrorMessage(error, "jianshu_publish_failed") }],
|
||||
}));
|
||||
|
||||
if (publicize.error?.length) {
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "jianshu_publish_failed",
|
||||
message: publicize.error[0]?.message || "发布失败,请稍后重试",
|
||||
};
|
||||
}
|
||||
|
||||
publishOk = true;
|
||||
if (publicize.slug) {
|
||||
createdSlug = publicize.slug;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!publishOk) {
|
||||
await softDestroyNote(transport, noteId);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "jianshu_publish_failed",
|
||||
message: error instanceof Error ? error.message : "jianshu_publish_failed",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: "success",
|
||||
articleId: String(noteId),
|
||||
mediaName: media.screenName ?? "",
|
||||
externalManageUrl: buildExternalManageUrl(notebookId, noteId),
|
||||
externalArticleUrl: buildExternalArticleUrl(createdSlug),
|
||||
message: "简书发布成功。",
|
||||
};
|
||||
}
|
||||
@@ -128,6 +128,7 @@ func (s *MediaService) listArticlePublishRecords(ctx context.Context, tenantID,
|
||||
); err != nil {
|
||||
return nil, response.ErrInternal(50043, "publish_record_scan_failed", err.Error())
|
||||
}
|
||||
normalizePublishRecordForResponse(&item)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func normalizePublishRecordForResponse(item *PublishRecordResponse) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
item.ErrorMessage = normalizePublishRecordErrorMessage(item.PlatformID, item.ErrorMessage)
|
||||
if strings.TrimSpace(item.PlatformID) != "baijiahao" {
|
||||
return
|
||||
}
|
||||
|
||||
articleID := ""
|
||||
if item.ExternalArticleID != nil {
|
||||
articleID = strings.TrimSpace(*item.ExternalArticleID)
|
||||
}
|
||||
if articleID == "" {
|
||||
articleID = extractBaijiahaoArticleIDFromURL(item.ExternalArticleURL)
|
||||
}
|
||||
if articleID == "" {
|
||||
articleID = extractBaijiahaoArticleIDFromURL(item.ExternalManageURL)
|
||||
}
|
||||
if articleID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
publicURL := baijiahaoPublishedArticleURL(articleID)
|
||||
item.ExternalArticleURL = &publicURL
|
||||
}
|
||||
|
||||
func normalizePublishRecordErrorMessage(platform string, message *string) *string {
|
||||
if strings.TrimSpace(platform) != "baijiahao" || message == nil {
|
||||
return message
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(*message)
|
||||
if trimmed == "baijiahao_challenge_required" ||
|
||||
trimmed == "desktop_account_risk_control" ||
|
||||
trimmed == "desktop_account_challenge_required" ||
|
||||
strings.HasPrefix(trimmed, "baijiahao_challenge_required:") {
|
||||
normalized := baijiahaoRiskControlPrompt
|
||||
return &normalized
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
func baijiahaoPublishedArticleURL(articleID string) string {
|
||||
return fmt.Sprintf("https://baijiahao.baidu.com/s?id=%s", url.QueryEscape(strings.TrimSpace(articleID)))
|
||||
}
|
||||
|
||||
func extractBaijiahaoArticleIDFromURL(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if id := strings.TrimSpace(parsed.Query().Get("id")); id != "" {
|
||||
return id
|
||||
}
|
||||
if id := strings.TrimSpace(parsed.Query().Get("article_id")); id != "" {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func syncDesktopPublishTaskState(
|
||||
publishedAt,
|
||||
nullableJSON(task.Payload),
|
||||
nullableJSON(task.Result),
|
||||
desktopTaskErrorMessage(errorPayload),
|
||||
desktopTaskErrorMessage(errorPayload, task.Platform),
|
||||
publishRecordID,
|
||||
task.TenantID,
|
||||
); err != nil {
|
||||
@@ -411,7 +411,12 @@ func extractStringPointer(payload map[string]any, keys ...string) *string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func desktopTaskErrorMessage(payload map[string]any) *string {
|
||||
const baijiahaoRiskControlPrompt = "您触发平台风控,请手动发稿一篇并完成验证,方可继续使用"
|
||||
|
||||
func desktopTaskErrorMessage(payload map[string]any, platform string) *string {
|
||||
if message := desktopTaskPlatformErrorMessage(payload, platform); message != nil {
|
||||
return message
|
||||
}
|
||||
if message := extractStringPointer(payload, "message"); message != nil {
|
||||
return message
|
||||
}
|
||||
@@ -423,3 +428,26 @@ func desktopTaskErrorMessage(payload map[string]any) *string {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func desktopTaskPlatformErrorMessage(payload map[string]any, platform string) *string {
|
||||
if strings.TrimSpace(platform) != "baijiahao" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, key := range []string{"code", "message", "detail"} {
|
||||
value := extractStringPointer(payload, key)
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "baijiahao_challenge_required" ||
|
||||
trimmed == "desktop_account_risk_control" ||
|
||||
trimmed == "desktop_account_challenge_required" ||
|
||||
strings.HasPrefix(trimmed, "baijiahao_challenge_required:") {
|
||||
message := baijiahaoRiskControlPrompt
|
||||
return &message
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDesktopTaskErrorMessageMapsBaijiahaoRiskControl(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload map[string]any
|
||||
}{
|
||||
{
|
||||
name: "adapter challenge code",
|
||||
payload: map[string]any{
|
||||
"code": "baijiahao_challenge_required",
|
||||
"message": "baijiahao_challenge_required: 您所在网络环境异常,请完成验证",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "desktop account risk code",
|
||||
payload: map[string]any{
|
||||
"code": "desktop_account_risk_control",
|
||||
"message": "desktop account risk control triggered",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := desktopTaskErrorMessage(tt.payload, "baijiahao")
|
||||
if got == nil || *got != baijiahaoRiskControlPrompt {
|
||||
t.Fatalf("desktopTaskErrorMessage() = %v, want %q", got, baijiahaoRiskControlPrompt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopTaskErrorMessageKeepsGenericMessage(t *testing.T) {
|
||||
got := desktopTaskErrorMessage(map[string]any{
|
||||
"code": "baijiahao_upload_proxy_failed",
|
||||
"message": "百家号素材上传接口拒绝了封面图。",
|
||||
}, "baijiahao")
|
||||
if got == nil || *got != "百家号素材上传接口拒绝了封面图。" {
|
||||
t.Fatalf("desktopTaskErrorMessage() = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePublishRecordForResponseUsesBaijiahaoPublicURL(t *testing.T) {
|
||||
articleID := "1863525130677225913"
|
||||
editURL := "https://baijiahao.baidu.com/builder/rc/edit?type=news&article_id=1863525130677225913"
|
||||
item := &PublishRecordResponse{
|
||||
PlatformID: "baijiahao",
|
||||
ExternalArticleID: &articleID,
|
||||
ExternalArticleURL: &editURL,
|
||||
ExternalManageURL: &editURL,
|
||||
}
|
||||
|
||||
normalizePublishRecordForResponse(item)
|
||||
|
||||
const want = "https://baijiahao.baidu.com/s?id=1863525130677225913"
|
||||
if item.ExternalArticleURL == nil || *item.ExternalArticleURL != want {
|
||||
t.Fatalf("ExternalArticleURL = %v, want %q", item.ExternalArticleURL, want)
|
||||
}
|
||||
if item.ExternalManageURL == nil || *item.ExternalManageURL != editURL {
|
||||
t.Fatalf("ExternalManageURL = %v, want original edit URL", item.ExternalManageURL)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
||||
)
|
||||
@@ -43,6 +48,14 @@ func (h *AssetHandler) Serve(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if converted, contentType, ok := convertPublicAssetFormat(content, c.Query("format")); ok {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
_, _ = c.Writer.Write(converted)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := http.DetectContentType(content)
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
c.Header("Content-Type", contentType)
|
||||
@@ -50,6 +63,34 @@ func (h *AssetHandler) Serve(c *gin.Context) {
|
||||
_, _ = c.Writer.Write(content)
|
||||
}
|
||||
|
||||
func convertPublicAssetFormat(content []byte, format string) ([]byte, string, bool) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(format))
|
||||
if normalized == "" {
|
||||
return nil, "", false
|
||||
}
|
||||
if normalized != "png" && normalized != "jpg" && normalized != "jpeg" {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
decoded, _, err := image.Decode(bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
if normalized == "png" {
|
||||
if err := png.Encode(&output, decoded); err != nil {
|
||||
return nil, "", false
|
||||
}
|
||||
return output.Bytes(), "image/png", true
|
||||
}
|
||||
|
||||
if err := jpeg.Encode(&output, decoded, &jpeg.Options{Quality: 92}); err != nil {
|
||||
return nil, "", false
|
||||
}
|
||||
return output.Bytes(), "image/jpeg", true
|
||||
}
|
||||
|
||||
func (h *AssetHandler) signObjectKey(objectKey string) string {
|
||||
encodedKey := base64.RawURLEncoding.EncodeToString([]byte(objectKey))
|
||||
mac := hmac.New(sha256.New, []byte(h.secret))
|
||||
|
||||
+3
-1
@@ -21,4 +21,6 @@
|
||||
没有问题的:
|
||||
|
||||
头条号:toutiaohao.ts
|
||||
知乎: zhihu.ts
|
||||
知乎: zhihu.ts
|
||||
百家号:baijiao.ts (表格官方渲染成了图片,不可以的,我们需要把表格降级处理)
|
||||
简书:jianshu.ts (图片未测,文字,表格 ok)
|
||||
Reference in New Issue
Block a user