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