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 { publishToutiaoArticle } from '../../../../../packages/publisher-platforms/src/toutiao'
|
||||||
|
|
||||||
import type { PublishAdapter } from './base'
|
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(
|
function buildResultPayload(
|
||||||
articleId: string,
|
articleId: string,
|
||||||
@@ -36,7 +41,7 @@ export const toutiaoAdapter: PublishAdapter = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
fetchJson: (input, init) => sessionFetchJson(context.session, input, init),
|
fetchJson: (input, init) => sessionFetchJson(context.session, input, init),
|
||||||
fetchImageBlob,
|
fetchImageBlob: fetchToutiaoImageBlob,
|
||||||
uploadHtmlImages,
|
uploadHtmlImages,
|
||||||
reportProgress(stage) {
|
reportProgress(stage) {
|
||||||
context.reportProgress(`toutiao.${stage}`)
|
context.reportProgress(`toutiao.${stage}`)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export interface ToutiaoPublishTransport {
|
|||||||
uploadHtmlImages(
|
uploadHtmlImages(
|
||||||
html: string,
|
html: string,
|
||||||
uploader: (sourceUrl: string) => Promise<string | null>,
|
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
|
reportProgress?(stage: 'media_info' | 'upload_content_images' | 'upload_cover' | 'submit'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,10 +77,25 @@ export type ToutiaoPublishResult =
|
|||||||
| {
|
| {
|
||||||
success: false
|
success: false
|
||||||
status: 'failed'
|
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
|
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(
|
export async function fetchToutiaoMediaSnapshot(
|
||||||
fetchJson: <T>(input: string, init?: RequestInit) => Promise<T>,
|
fetchJson: <T>(input: string, init?: RequestInit) => Promise<T>,
|
||||||
): Promise<ToutiaoMediaSnapshot | null> {
|
): Promise<ToutiaoMediaSnapshot | null> {
|
||||||
@@ -246,6 +261,19 @@ export async function publishToutiaoArticle(
|
|||||||
const processed = await transport.uploadHtmlImages(normalizedHtml, async (sourceUrl) =>
|
const processed = await transport.uploadHtmlImages(normalizedHtml, async (sourceUrl) =>
|
||||||
uploadContentImage(transport, 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')
|
transport.reportProgress?.('upload_cover')
|
||||||
const cover = input.coverAssetUrl?.trim()
|
const cover = input.coverAssetUrl?.trim()
|
||||||
|
|||||||
Reference in New Issue
Block a user