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:
@@ -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}`)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user