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: '简书发布成功。', } }