diff --git a/apps/desktop-client/src/main/adapters/toutiao-platform.test.ts b/apps/desktop-client/src/main/adapters/toutiao-platform.test.ts new file mode 100644 index 0000000..a8c4cb1 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/toutiao-platform.test.ts @@ -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 & { 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: '

', + 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: '

', + 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) + }) +}) diff --git a/apps/desktop-client/src/main/adapters/toutiao.test.ts b/apps/desktop-client/src/main/adapters/toutiao.test.ts new file mode 100644 index 0000000..1abc7ec --- /dev/null +++ b/apps/desktop-client/src/main/adapters/toutiao.test.ts @@ -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: + '

', + 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') + }) +}) diff --git a/apps/desktop-client/src/main/adapters/toutiao.ts b/apps/desktop-client/src/main/adapters/toutiao.ts index 73ecd79..025522d 100644 --- a/apps/desktop-client/src/main/adapters/toutiao.ts +++ b/apps/desktop-client/src/main/adapters/toutiao.ts @@ -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 { + 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}`) diff --git a/packages/publisher-platforms/src/toutiao.ts b/packages/publisher-platforms/src/toutiao.ts index 2af63a2..3e84847 100644 --- a/packages/publisher-platforms/src/toutiao.ts +++ b/packages/publisher-platforms/src/toutiao.ts @@ -60,7 +60,7 @@ export interface ToutiaoPublishTransport { uploadHtmlImages( html: string, uploader: (sourceUrl: string) => Promise, - ): Promise<{ html: string }> + ): Promise<{ html: string; uploaded?: Map }> 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() + 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 { @@ -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() + 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()