fix: improve toutiao image uploads
This commit is contained in:
@@ -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}`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user