Files
geo/packages/publisher-platforms/src/jianshu.ts
T
root 162abdc97c
Backend CI / Backend (push) Has been cancelled
Frontend CI / Frontend (push) Failing after 1m39s
chore(frontend): introduce prettier + eslint and prune unused code
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports)
- Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier
- Add root scripts: format, format:check, lint, lint:fix
- Reformat 257 files across admin-web, ops-web, desktop-client, packages
- Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters
- Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex
- Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:39:09 +08:00

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