type ToutiaoMediaInfoResponse = { data?: { user?: { id_str?: string screen_name?: string https_avatar_url?: string } media?: { has_third_party_ad_permission?: boolean has_toutiao_ad_permission?: boolean } } code?: number message?: string } type ToutiaoUploadPictureResponse = { url?: string web_uri?: string origin_web_uri?: string rigin_web_uri?: string width?: number height?: number } type ToutiaoSpiceImageResponse = { data?: { image_url?: string image_uri?: string image_width?: number image_height?: number } } type ToutiaoPublishResponse = { code?: number message?: string data?: { pgc_id?: string | number } } export interface ToutiaoMediaSnapshot { platformUid: string | null screenName: string | null avatarUrl: string | null hasAdPermission: boolean } export interface ToutiaoPublishArticleInput { title: string html: string coverAssetUrl?: string | null publishType: 'publish' | 'draft' } export interface ToutiaoPublishTransport { fetchJson(input: string, init?: RequestInit): Promise fetchImageBlob(sourceUrl: string): Promise uploadHtmlImages( html: string, uploader: (sourceUrl: string) => Promise, ): Promise<{ html: string; uploaded?: Map }> reportProgress?( stage: 'media_info' | 'upload_content_images' | 'upload_cover' | 'submit', ): void | Promise } export type ToutiaoPublishResult = | { success: true status: 'success' | 'pending_review' articleId: string mediaName: string externalManageUrl: string externalArticleUrl: string | null message: string } | { success: false status: 'failed' code: | 'toutiaohao_not_logged_in' | 'article_content_empty' | 'toutiaohao_image_upload_failed' | 'toutiaohao_publish_failed' message: string } function extractHtmlImageSources(html: string): string[] { const sources = new Set() for (const match of html.matchAll(/]*src=(['"])(.*?)\1[^>]*>/gi)) { const source = match[2]?.trim() if (source) { sources.add(source) } } return [...sources] } export async function fetchToutiaoMediaSnapshot( fetchJson: (input: string, init?: RequestInit) => Promise, ): Promise { const response = await fetchJson( 'https://mp.toutiao.com/mp/agw/media/get_media_info', { headers: { accept: 'application/json, text/plain, */*', }, }, ).catch(() => null) const user = response?.data?.user if (!user?.id_str || !user.screen_name) { return null } return { platformUid: user.id_str, screenName: user.screen_name, avatarUrl: user.https_avatar_url ?? null, hasAdPermission: Boolean( response?.data?.media?.has_third_party_ad_permission || response?.data?.media?.has_toutiao_ad_permission, ), } } async function uploadCover( transport: ToutiaoPublishTransport, sourceUrl: string, publishType: 'publish' | 'draft', ) { const blob = await transport.fetchImageBlob(sourceUrl) if (!blob) { return null } if (publishType === 'publish') { const form = new FormData() form.append('upfile', blob, imageFileName(blob, 'cover')) const uploaded = await transport .fetchJson( 'https://mp.toutiao.com/mp/agw/article_material/photo/upload_picture', { method: 'POST', body: form, }, ) .catch(() => null) if (!uploaded?.url || !uploaded.web_uri) { return null } return { id: 0, url: uploaded.url, uri: uploaded.web_uri, origin_uri: uploaded.origin_web_uri ?? uploaded.rigin_web_uri ?? '', thumb_width: uploaded.width ?? 0, thumb_height: uploaded.height ?? 0, } } const firstForm = new FormData() firstForm.append('image', blob, imageFileName(blob, 'cover')) const first = await transport .fetchJson( 'https://mp.toutiao.com/spice/image?device_platform=web', { method: 'POST', body: firstForm, }, ) .catch(() => null) const imageUrl = first?.data?.image_url if (!imageUrl) { return null } const secondForm = new FormData() secondForm.append('imageUrl', imageUrl) const final = await transport .fetchJson( 'https://mp.toutiao.com/spice/image?device_platform=web&need_cover_url=1', { method: 'POST', body: secondForm, }, ) .catch(() => null) if (!final?.data?.image_url || !first.data?.image_uri) { return null } return { id: '', url: final.data.image_url, uri: first.data.image_uri, ic_uri: '', thumb_width: first.data.image_width ?? 0, thumb_height: first.data.image_height ?? 0, extra: { from_content_uri: '', from_content: '0', }, } } async function uploadContentImage( transport: ToutiaoPublishTransport, sourceUrl: string, ): Promise { const blob = await transport.fetchImageBlob(sourceUrl) if (!blob) { return null } const form = new FormData() form.append('image', blob, imageFileName(blob, 'image')) const uploaded = await transport .fetchJson( 'https://mp.toutiao.com/spice/image?device_platform=web', { method: 'POST', body: form, }, ) .catch(() => null) return uploaded?.data?.image_url ?? null } function imageFileName(blob: Blob, baseName: string): string { const type = blob.type.toLowerCase() if (type === 'image/jpeg' || type === 'image/jpg') { return `${baseName}.jpg` } return `${baseName}.png` } export async function publishToutiaoArticle( input: ToutiaoPublishArticleInput, transport: ToutiaoPublishTransport, ): Promise { await transport.reportProgress?.('media_info') const media = await fetchToutiaoMediaSnapshot(transport.fetchJson) if (!media?.platformUid || !media.screenName) { return { success: false, status: 'failed', code: 'toutiaohao_not_logged_in', message: '未检测到头条号登录态', } } const normalizedHtml = input.html.trim() if (!normalizedHtml) { return { success: false, status: 'failed', code: 'article_content_empty', message: 'html_content is empty', } } await transport.reportProgress?.('upload_content_images') const processed = await transport.uploadHtmlImages(normalizedHtml, async (sourceUrl) => uploadContentImage(transport, sourceUrl), ) const contentImageSources = extractHtmlImageSources(normalizedHtml) const uploadedSources = processed.uploaded ?? new Map() const missingUpload = contentImageSources.find( (source) => !uploadedSources.has(source) && processed.html.includes(source), ) if (missingUpload) { return { success: false, status: 'failed', code: 'toutiaohao_image_upload_failed', message: `toutiaohao_image_upload_failed: ${missingUpload}`, } } await transport.reportProgress?.('upload_cover') const cover = input.coverAssetUrl?.trim() ? await uploadCover(transport, input.coverAssetUrl.trim(), input.publishType) : null const body = new URLSearchParams({ content: processed.html, title: input.title, mp_editor_stat: JSON.stringify({ code_block: 1 }), is_refute_rumor: '0', save: input.publishType === 'publish' ? '1' : '0', timer_status: '0', draft_form_data: JSON.stringify({ coverType: 1 }), article_ad_type: media.hasAdPermission ? '3' : '2', pgc_feed_covers: JSON.stringify(cover ? [cover] : []), is_fans_article: '0', govern_forward: '0', praise: '0', disable_praise: '0', tree_plan_article: '0', activity_tag: '0', trends_writing_tag: '0', claim_exclusive: '0', info_source: JSON.stringify({ source_type: 5, source_author_uid: '', time_format: '', position: {}, }), }) await transport.reportProgress?.('submit') const response: ToutiaoPublishResponse = await transport .fetchJson( 'https://mp.toutiao.com/mp/agw/article/publish?source=mp&type=article&aid=1231', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body, }, ) .catch((error) => ({ code: -1, message: error instanceof Error ? error.message : 'toutiaohao_publish_failed', })) const articleId = response.data?.pgc_id != null ? String(response.data.pgc_id) : '' if (response.code !== 0 || !articleId) { return { success: false, status: 'failed', code: 'toutiaohao_publish_failed', message: response.message || 'toutiaohao_publish_failed', } } if (input.publishType === 'draft') { return { success: true, status: 'pending_review', articleId, mediaName: media.screenName, externalManageUrl: 'https://mp.toutiao.com/profile_v4/graphic/publish', externalArticleUrl: null, message: '头条号草稿保存成功。', } } return { success: true, status: 'success', articleId, mediaName: media.screenName, externalManageUrl: 'https://mp.toutiao.com/profile_v4/index', externalArticleUrl: `https://www.toutiao.com/article/${articleId}/`, message: '头条号发布成功。', } }