fix(toutiao): fail publish when body images fail to upload

Route content/cover images through the desktop media-image asset
pipeline and abort before submitting when any body image was not
replaced with a Toutiao-hosted URL, so we don't publish articles
that reference our own private asset endpoints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 20:24:28 +08:00
parent 8761e47f78
commit a9591143e8
4 changed files with 215 additions and 4 deletions
@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from 'vitest'
import {
publishToutiaoArticle,
type ToutiaoPublishTransport,
} from '../../../../../packages/publisher-platforms/src/toutiao'
function createTransport(
overrides: Partial<ToutiaoPublishTransport> = {},
): ToutiaoPublishTransport & { submitCalls: URLSearchParams[] } {
const submitCalls: URLSearchParams[] = []
const transport: ToutiaoPublishTransport & { submitCalls: URLSearchParams[] } = {
submitCalls,
async fetchJson(input, init) {
if (input.includes('/media/get_media_info')) {
return {
data: {
user: {
id_str: 'uid-1',
screen_name: '头条号测试账号',
},
media: {},
},
} as never
}
if (input.includes('/spice/image')) {
return {
data: {
image_url: 'https://p3-sign.toutiaoimg.com/tos-cn-i-body/image.png',
image_uri: 'tos-cn-i-body/image.png',
image_width: 640,
image_height: 360,
},
} as never
}
if (input.includes('/article/publish')) {
submitCalls.push(init?.body as URLSearchParams)
return {
code: 0,
data: {
pgc_id: 'pgc-1',
},
} as never
}
return {} as never
},
async fetchImageBlob() {
return new Blob(['image'], { type: 'image/png' })
},
async uploadHtmlImages(html, uploader) {
const source = '/api/public/assets/body-token'
const target = await uploader(source)
return {
html: target ? html.split(source).join(target) : html,
uploaded: target ? new Map([[source, target]]) : new Map(),
}
},
...overrides,
}
return transport
}
describe('publishToutiaoArticle', () => {
it('submits content with body images replaced by Toutiao image URLs', async () => {
const transport = createTransport()
const result = await publishToutiaoArticle(
{
title: '标题',
html: '<p><img src="/api/public/assets/body-token" alt="" /></p>',
publishType: 'publish',
},
transport,
)
expect(result.success).toBe(true)
expect(transport.submitCalls).toHaveLength(1)
expect(transport.submitCalls[0]?.get('content')).toContain('https://p3-sign.toutiaoimg.com')
expect(transport.submitCalls[0]?.get('content')).not.toContain('/api/public/assets/body-token')
})
it('fails before submit when any body image cannot be uploaded to Toutiao', async () => {
const transport = createTransport({
async uploadHtmlImages(html) {
return {
html,
uploaded: new Map(),
}
},
})
const fetchJson = vi.spyOn(transport, 'fetchJson')
const result = await publishToutiaoArticle(
{
title: '标题',
html: '<p><img src="/api/public/assets/body-token" alt="" /></p>',
publishType: 'publish',
},
transport,
)
expect(result).toMatchObject({
success: false,
status: 'failed',
code: 'toutiaohao_image_upload_failed',
})
expect(fetchJson.mock.calls.some(([input]) => input.includes('/article/publish'))).toBe(false)
})
})
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../../../../../packages/publisher-platforms/src/toutiao', () => ({
publishToutiaoArticle: vi.fn(async () => ({
success: true,
status: 'success',
articleId: 'pgc-1',
mediaName: '头条号测试账号',
externalManageUrl: 'https://mp.toutiao.com/profile_v4/index',
externalArticleUrl: 'https://www.toutiao.com/article/pgc-1/',
message: '头条号发布成功。',
})),
}))
vi.mock('./media-image', () => ({
fetchImageAssetBlob: vi.fn(async () => ({
blob: new Blob(['image'], { type: 'image/png' }),
width: 1,
height: 1,
fileName: 'image.png',
})),
}))
import { publishToutiaoArticle } from '../../../../../packages/publisher-platforms/src/toutiao'
import { toutiaoAdapter } from './toutiao'
import { fetchImageAssetBlob } from './media-image'
describe('toutiao adapter', () => {
it('fetches relative public assets through the desktop asset pipeline before platform upload', async () => {
const result = await toutiaoAdapter.publish(
{
taskId: 'task-1',
accountId: 'account-1',
session: {} as never,
view: null,
playwright: null,
signal: new AbortController().signal,
phase: 'initial',
reportProgress: vi.fn(),
article: {
article_id: 889,
title: '2026年室内门锁怎么选?五款高适配产品参考',
html_content: null,
markdown_content:
'<p class="article-editor-image article-editor-image--center" align="center"><img src="/api/public/assets/body-token" alt="" /></p>',
cover_asset_url: '/api/public/assets/cover-token',
},
},
{},
)
expect(result.status).toBe('succeeded')
expect(publishToutiaoArticle).toHaveBeenCalledOnce()
const transport = vi.mocked(publishToutiaoArticle).mock.calls[0]?.[1]
expect(transport).toBeTruthy()
const bodyBlob = await transport?.fetchImageBlob('/api/public/assets/body-token')
const coverBlob = await transport?.fetchImageBlob('/api/public/assets/cover-token')
expect(bodyBlob?.type).toBe('image/png')
expect(coverBlob?.type).toBe('image/png')
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/body-token')
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/cover-token')
})
})
@@ -2,7 +2,12 @@ import type { JsonValue } from '@geo/shared-types'
import { publishToutiaoArticle } from '../../../../../packages/publisher-platforms/src/toutiao'
import type { PublishAdapter } from './base'
import { fetchImageBlob, normalizeArticleHtml, sessionFetchJson, uploadHtmlImages } from './common'
import { normalizeArticleHtml, sessionFetchJson, uploadHtmlImages } from './common'
import { fetchImageAssetBlob } from './media-image'
async function fetchToutiaoImageBlob(sourceUrl: string): Promise<Blob | null> {
return (await fetchImageAssetBlob(sourceUrl))?.blob ?? null
}
function buildResultPayload(
articleId: string,
@@ -36,7 +41,7 @@ export const toutiaoAdapter: PublishAdapter = {
},
{
fetchJson: (input, init) => sessionFetchJson(context.session, input, init),
fetchImageBlob,
fetchImageBlob: fetchToutiaoImageBlob,
uploadHtmlImages,
reportProgress(stage) {
context.reportProgress(`toutiao.${stage}`)
+30 -2
View File
@@ -60,7 +60,7 @@ export interface ToutiaoPublishTransport {
uploadHtmlImages(
html: string,
uploader: (sourceUrl: string) => Promise<string | null>,
): Promise<{ html: string }>
): Promise<{ html: string; uploaded?: Map<string, string> }>
reportProgress?(stage: 'media_info' | 'upload_content_images' | 'upload_cover' | 'submit'): void
}
@@ -77,10 +77,25 @@ export type ToutiaoPublishResult =
| {
success: false
status: 'failed'
code: 'toutiaohao_not_logged_in' | 'article_content_empty' | 'toutiaohao_publish_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<string>()
for (const match of html.matchAll(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi)) {
const source = match[2]?.trim()
if (source) {
sources.add(source)
}
}
return [...sources]
}
export async function fetchToutiaoMediaSnapshot(
fetchJson: <T>(input: string, init?: RequestInit) => Promise<T>,
): Promise<ToutiaoMediaSnapshot | null> {
@@ -246,6 +261,19 @@ export async function publishToutiaoArticle(
const processed = await transport.uploadHtmlImages(normalizedHtml, async (sourceUrl) =>
uploadContentImage(transport, sourceUrl),
)
const contentImageSources = extractHtmlImageSources(normalizedHtml)
const uploadedSources = processed.uploaded ?? new Map<string, string>()
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}`,
}
}
transport.reportProgress?.('upload_cover')
const cover = input.coverAssetUrl?.trim()