fix: improve toutiao image uploads

This commit is contained in:
2026-06-18 20:47:41 +08:00
parent 31c4dd9358
commit 4fa5adc25a
6 changed files with 77 additions and 16 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@geo/desktop-client", "name": "@geo/desktop-client",
"version": "0.1.1", "version": "0.1.2",
"private": true, "private": true,
"description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。", "description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。",
"author": { "author": {
@@ -33,6 +33,14 @@ describe('media image helpers', () => {
]) ])
}) })
it('can prefer jpeg conversion for platforms that reject large png uploads', () => {
expect(buildAssetURLCandidates('/api/public/assets/12', { preferredFormat: 'jpg' })).toEqual([
'http://127.0.0.1:8080/api/public/assets/12?format=jpg',
'http://127.0.0.1:8080/api/desktop/content/assets/12?format=jpg',
'http://127.0.0.1:8080/api/public/assets/12',
])
})
it('centers crop rectangles at the requested ratio', () => { it('centers crop rectangles at the requested ratio', () => {
const rect = getCenteredCropRect(1600, 1000, 4 / 3) const rect = getCenteredCropRect(1600, 1000, 4 / 3)
@@ -19,9 +19,23 @@ export interface CropRect {
height: number height: number
} }
const passthroughImageTypes = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/gif']) export type AssetImageFormat = 'png' | 'jpg' | 'jpeg'
function buildDesktopAssetFallbackURL(publicAssetUrl: URL): string | null { export interface FetchImageAssetBlobOptions extends AdapterFetchOptions {
preferredFormat?: AssetImageFormat
passthroughTypes?: string[]
}
const defaultPassthroughImageTypes = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/gif'])
function normalizeAssetImageFormat(format: AssetImageFormat | undefined): AssetImageFormat {
return format === 'jpg' || format === 'jpeg' ? 'jpg' : 'png'
}
function buildDesktopAssetFallbackURL(
publicAssetUrl: URL,
preferredFormat: AssetImageFormat,
): string | null {
if (!publicAssetUrl.pathname.startsWith('/api/public/assets/')) { if (!publicAssetUrl.pathname.startsWith('/api/public/assets/')) {
return null return null
} }
@@ -35,11 +49,14 @@ function buildDesktopAssetFallbackURL(publicAssetUrl: URL): string | null {
publicAssetUrl.searchParams.forEach((value, key) => { publicAssetUrl.searchParams.forEach((value, key) => {
fallbackURL.searchParams.set(key, value) fallbackURL.searchParams.set(key, value)
}) })
fallbackURL.searchParams.set('format', 'png') fallbackURL.searchParams.set('format', preferredFormat)
return fallbackURL.toString() return fallbackURL.toString()
} }
export function buildAssetURLCandidates(sourceUrl: string): string[] { export function buildAssetURLCandidates(
sourceUrl: string,
options: { preferredFormat?: AssetImageFormat } = {},
): string[] {
const trimmed = sourceUrl.trim() const trimmed = sourceUrl.trim()
if (!trimmed) { if (!trimmed) {
return [] return []
@@ -57,16 +74,17 @@ export function buildAssetURLCandidates(sourceUrl: string): string[] {
} }
try { try {
const preferredFormat = normalizeAssetImageFormat(options.preferredFormat)
const parsed = new URL(trimmed, resolveDesktopApiURL('/')) const parsed = new URL(trimmed, resolveDesktopApiURL('/'))
if (parsed.pathname.startsWith('/api/')) { if (parsed.pathname.startsWith('/api/')) {
const apiAssetUrl = new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`)) const apiAssetUrl = new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`))
if (apiAssetUrl.pathname.startsWith('/api/public/assets/')) { if (apiAssetUrl.pathname.startsWith('/api/public/assets/')) {
apiAssetUrl.searchParams.set('format', 'png') apiAssetUrl.searchParams.set('format', preferredFormat)
} }
pushCandidate(apiAssetUrl.toString()) pushCandidate(apiAssetUrl.toString())
const desktopAssetFallbackURL = buildDesktopAssetFallbackURL(apiAssetUrl) const desktopAssetFallbackURL = buildDesktopAssetFallbackURL(apiAssetUrl, preferredFormat)
if (desktopAssetFallbackURL) { if (desktopAssetFallbackURL) {
pushCandidate(desktopAssetFallbackURL) pushCandidate(desktopAssetFallbackURL)
} }
@@ -100,9 +118,17 @@ function fileNameForImageType(sourceType: string): string {
export async function fetchImageAssetBlob( export async function fetchImageAssetBlob(
sourceUrl: string, sourceUrl: string,
options: AdapterFetchOptions = {}, options: FetchImageAssetBlobOptions = {},
): Promise<ImageAssetBlob | null> { ): Promise<ImageAssetBlob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) { const passthroughImageTypes = new Set(
(options.passthroughTypes ?? [...defaultPassthroughImageTypes]).map((type) =>
type.toLowerCase(),
),
)
for (const candidate of buildAssetURLCandidates(sourceUrl, {
preferredFormat: options.preferredFormat,
})) {
const response = await ( const response = await (
shouldUseDesktopAssetFetch(candidate) shouldUseDesktopAssetFetch(candidate)
? fetchDesktopApiURL(candidate, { ? fetchDesktopApiURL(candidate, {
@@ -62,11 +62,19 @@ describe('toutiao adapter', () => {
expect(coverBlob?.type).toBe('image/png') expect(coverBlob?.type).toBe('image/png')
expect(fetchImageAssetBlob).toHaveBeenCalledWith( expect(fetchImageAssetBlob).toHaveBeenCalledWith(
'/api/public/assets/body-token', '/api/public/assets/body-token',
expect.anything(), expect.objectContaining({
preferredFormat: 'jpg',
passthroughTypes: ['image/jpeg', 'image/jpg', 'image/png'],
timeoutMs: 30_000,
}),
) )
expect(fetchImageAssetBlob).toHaveBeenCalledWith( expect(fetchImageAssetBlob).toHaveBeenCalledWith(
'/api/public/assets/cover-token', '/api/public/assets/cover-token',
expect.anything(), expect.objectContaining({
preferredFormat: 'jpg',
passthroughTypes: ['image/jpeg', 'image/jpg', 'image/png'],
timeoutMs: 30_000,
}),
) )
}) })
}) })
@@ -9,7 +9,14 @@ async function fetchToutiaoImageBlob(
sourceUrl: string, sourceUrl: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<Blob | null> { ): Promise<Blob | null> {
return (await fetchImageAssetBlob(sourceUrl, { signal }))?.blob ?? null return (
(await fetchImageAssetBlob(sourceUrl, {
signal,
preferredFormat: 'jpg',
passthroughTypes: ['image/jpeg', 'image/jpg', 'image/png'],
timeoutMs: 30_000,
}))?.blob ?? null
)
} }
function buildResultPayload( function buildResultPayload(
@@ -47,7 +54,11 @@ export const toutiaoAdapter: PublishAdapter = {
sessionFetchJson(context.session, input, init, { signal: context.signal }), sessionFetchJson(context.session, input, init, { signal: context.signal }),
fetchImageBlob: (sourceUrl) => fetchToutiaoImageBlob(sourceUrl, context.signal), fetchImageBlob: (sourceUrl) => fetchToutiaoImageBlob(sourceUrl, context.signal),
uploadHtmlImages: (sourceHtml, uploader) => uploadHtmlImages: (sourceHtml, uploader) =>
uploadHtmlImages(sourceHtml, uploader, { signal: context.signal }), uploadHtmlImages(sourceHtml, uploader, {
signal: context.signal,
imageTimeoutMs: 30_000,
timeoutMs: 120_000,
}),
async reportProgress(stage) { async reportProgress(stage) {
await context.reportProgress(`toutiao.${stage}`) await context.reportProgress(`toutiao.${stage}`)
}, },
+11 -3
View File
@@ -138,7 +138,7 @@ async function uploadCover(
if (publishType === 'publish') { if (publishType === 'publish') {
const form = new FormData() const form = new FormData()
form.append('upfile', blob, 'cover.png') form.append('upfile', blob, imageFileName(blob, 'cover'))
const uploaded = await transport const uploaded = await transport
.fetchJson<ToutiaoUploadPictureResponse>( .fetchJson<ToutiaoUploadPictureResponse>(
'https://mp.toutiao.com/mp/agw/article_material/photo/upload_picture', 'https://mp.toutiao.com/mp/agw/article_material/photo/upload_picture',
@@ -164,7 +164,7 @@ async function uploadCover(
} }
const firstForm = new FormData() const firstForm = new FormData()
firstForm.append('image', blob, 'cover.png') firstForm.append('image', blob, imageFileName(blob, 'cover'))
const first = await transport const first = await transport
.fetchJson<ToutiaoSpiceImageResponse>( .fetchJson<ToutiaoSpiceImageResponse>(
'https://mp.toutiao.com/spice/image?device_platform=web', 'https://mp.toutiao.com/spice/image?device_platform=web',
@@ -220,7 +220,7 @@ async function uploadContentImage(
} }
const form = new FormData() const form = new FormData()
form.append('image', blob, 'image.png') form.append('image', blob, imageFileName(blob, 'image'))
const uploaded = await transport const uploaded = await transport
.fetchJson<ToutiaoSpiceImageResponse>( .fetchJson<ToutiaoSpiceImageResponse>(
'https://mp.toutiao.com/spice/image?device_platform=web', 'https://mp.toutiao.com/spice/image?device_platform=web',
@@ -234,6 +234,14 @@ async function uploadContentImage(
return uploaded?.data?.image_url ?? 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( export async function publishToutiaoArticle(
input: ToutiaoPublishArticleInput, input: ToutiaoPublishArticleInput,
transport: ToutiaoPublishTransport, transport: ToutiaoPublishTransport,