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(input: string, init?: RequestInit): Promise; fetchImageBlob(sourceUrl: string): Promise; 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 = {}): Record { return { Accept: "application/json", Referer: `${JIANSHU_ORIGIN}/`, Origin: JIANSHU_ORIGIN, ...extra, }; } function jianshuJsonHeaders(extra: Record = {}): Record { 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 (!/]*>\s*(]*\/?>)\s*<\/p>/gi, (_match, imgTag: string) => `\n\n${markdownImageFromHtmlTag(imgTag)}\n\n`, ); next = next.replace( /]*\/?>/gi, (imgTag) => `\n\n${markdownImageFromHtmlTag(imgTag)}\n\n`, ); return next.replace(/\n{3,}/g, "\n\n").trim(); } export async function fetchJianshuMediaSnapshot( fetchJson: (input: string, init?: RequestInit) => Promise, ): Promise { const me = await fetchJson( `${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: (input: string, init?: RequestInit) => Promise, ): Promise { const notebooks = await fetchJson( `${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: (input: string, init?: RequestInit) => Promise, notebookId: number, title: string, ): Promise { return await fetchJson( `${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 { const blob = await transport.fetchImageBlob(sourceUrl); if (!blob || !blob.type.startsWith("image/")) { return null; } const filename = `${randomFilenameStem()}.png`; const token = await transport.fetchJson( `${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( 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(); for (const match of content.matchAll(/]*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 { const sources = collectImageSources(content); if (!sources.length) { return content; } const replacements = new Map(); 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 { const coverUrl = await uploadImageToQiniu(transport, coverAssetUrl); if (!coverUrl) { return; } await transport.fetchJson( `${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 { await transport.fetchJson( `${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(//gi, ""); next = next.replace(/]*>\s*(]*>)\s*(?:]*>[\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(/]*)>([\s\S]*?)<\/table>/gi, (_match, attrs, inner) => { let body = inner; if (!/${body}`; } return `${body}`; }); 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 { 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(`${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( `${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: "简书发布成功。", }; }