Files
geo/packages/publisher-platforms/src/jianshu.ts
T
root dbce8515e7 feat(publish): render jianshu html images as markdown
Jianshu's HTML editor strips uploaded <img> tags during save, so previously
uploaded images vanished from the rendered article. After image upload,
convert each <img> (and surrounding <p> wrappers) to a markdown image block
in both the desktop and extension publish paths so the references survive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:02:50 +08:00

515 lines
14 KiB
TypeScript

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;
}
function extractHtmlAttribute(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 escapeMarkdownAlt(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/\]/g, "\\]");
}
function markdownImageFromHtmlTag(imgTag: string): string {
const src = extractHtmlAttribute(imgTag, "src");
if (!src) {
return imgTag;
}
const alt = extractHtmlAttribute(imgTag, "alt") ?? "";
return `![${escapeMarkdownAlt(alt)}](${src})`;
}
export function renderJianshuHtmlImagesAsMarkdown(content: string): string {
if (!/<img\b/i.test(content)) {
return content;
}
let next = content.replace(
/<p\b[^>]*>\s*(<img\b[^>]*\/?>)\s*<\/p>/gi,
(_match, imgTag: string) => `\n\n${markdownImageFromHtmlTag(imgTag)}\n\n`,
);
next = next.replace(
/<img\b[^>]*\/?>/gi,
(imgTag) => `\n\n${markdownImageFromHtmlTag(imgTag)}\n\n`,
);
return next.replace(/\n{3,}/g, "\n\n").trim();
}
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 renderJianshuHtmlImagesAsMarkdown(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: "简书发布成功。",
};
}