From 24a62fd52ce2d9ebffc1b90e34495474c4159fc2 Mon Sep 17 00:00:00 2001 From: liangxu Date: Thu, 25 Jun 2026 01:11:46 +0800 Subject: [PATCH] fix desktop smzdm webp image upload --- .../src/components/ArticlePublishStatus.vue | 12 +- .../src/main/adapters/media-image.test.ts | 17 ++- .../src/main/adapters/media-image.ts | 109 +++++++++++----- .../desktop-client/src/main/adapters/smzdm.ts | 123 +++++++++++++++++- 4 files changed, 218 insertions(+), 43 deletions(-) diff --git a/apps/admin-web/src/components/ArticlePublishStatus.vue b/apps/admin-web/src/components/ArticlePublishStatus.vue index b3ba628..3957f97 100644 --- a/apps/admin-web/src/components/ArticlePublishStatus.vue +++ b/apps/admin-web/src/components/ArticlePublishStatus.vue @@ -80,26 +80,16 @@ const platformMap = computed(() => { }) const platformEntries = computed(() => { - const seen = new Set() const entries: PublishPlatformEntry[] = [] for (const record of publishRecordsQuery.data.value ?? []) { const targetType = String(record.target_type ?? 'platform_account') - const key = - targetType === 'enterprise_site' - ? `${targetType}:${record.target_connection_id ?? record.id}` - : `${record.platform_id}:${record.platform_account_id}` - if (seen.has(key)) { - continue - } - seen.add(key) - const normalizedId = normalizePublishPlatformId(record.platform_id) const platform = platformMap.value.get(normalizedId) const fallback = enterprisePlatformFallback(normalizedId) entries.push({ - key, + key: `${targetType}:${record.id}`, name: record.platform_name || platform?.name || fallback.name, shortName: platform?.short_name || fallback.shortName, accent: platform?.accent_color || fallback.accent, diff --git a/apps/desktop-client/src/main/adapters/media-image.test.ts b/apps/desktop-client/src/main/adapters/media-image.test.ts index acbbe21..db34eab 100644 --- a/apps/desktop-client/src/main/adapters/media-image.test.ts +++ b/apps/desktop-client/src/main/adapters/media-image.test.ts @@ -22,7 +22,7 @@ vi.mock('../transport/api-client', () => ({ resolveDesktopApiURL: (path: string) => `http://127.0.0.1:8080${path}`, })) -import { buildAssetURLCandidates, getCenteredCropRect } from './media-image' +import { buildAssetURLCandidates, getCenteredCropRect, normalizeImageAssetBlob } from './media-image' describe('media image helpers', () => { it('resolves public asset URLs through public and authenticated desktop candidates', () => { @@ -41,6 +41,21 @@ describe('media image helpers', () => { ]) }) + it('normalizes protocol-relative image URLs to https like browser fetches on HTTPS pages', () => { + expect(buildAssetURLCandidates('//res.smzdm.com/cover.png')).toEqual([ + 'https://res.smzdm.com/cover.png', + ]) + }) + + it('converts webp blobs to png when webp is not a platform passthrough type', async () => { + const image = await normalizeImageAssetBlob(new Blob(['webp'], { type: 'image/webp' }), { + passthroughTypes: ['image/jpeg', 'image/jpg', 'image/png'], + }) + + expect(image?.blob.type).toBe('image/png') + expect(image?.fileName).toBe('image.png') + }) + it('centers crop rectangles at the requested ratio', () => { const rect = getCenteredCropRect(1600, 1000, 4 / 3) diff --git a/apps/desktop-client/src/main/adapters/media-image.ts b/apps/desktop-client/src/main/adapters/media-image.ts index 1117467..1c29ba9 100644 --- a/apps/desktop-client/src/main/adapters/media-image.ts +++ b/apps/desktop-client/src/main/adapters/media-image.ts @@ -3,6 +3,7 @@ import { Buffer } from 'node:buffer' import { nativeImage } from 'electron' import { fetchDesktopApiURL, resolveDesktopApiURL } from '../transport/api-client' +import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from '../user-agent' import { adapterFetch, mergeAbortSignals, timeoutSignal, type AdapterFetchOptions } from './common' export interface ImageAssetBlob { @@ -12,6 +13,11 @@ export interface ImageAssetBlob { fileName: string } +export interface RawImageAssetBlob { + blob: Blob + fileName: string +} + export interface CropRect { x: number y: number @@ -24,6 +30,7 @@ export type AssetImageFormat = 'png' | 'jpg' | 'jpeg' export interface FetchImageAssetBlobOptions extends AdapterFetchOptions { preferredFormat?: AssetImageFormat passthroughTypes?: string[] + headers?: HeadersInit } const defaultPassthroughImageTypes = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/gif']) @@ -66,6 +73,10 @@ export function buildAssetURLCandidates( return [trimmed] } + if (/^\/\//.test(trimmed)) { + return [`https:${trimmed}`] + } + const candidates: string[] = [] const pushCandidate = (value: string) => { if (!candidates.includes(value)) { @@ -113,30 +124,43 @@ function fileNameForImageType(sourceType: string): string { if (sourceType === 'image/jpeg' || sourceType === 'image/jpg') { return 'image.jpg' } + if (sourceType === 'image/webp') { + return 'image.webp' + } return 'image.png' } -export async function fetchImageAssetBlob( +function imageFetchHeaders(options: Pick): Headers { + const headers = new Headers(options.headers) + if (!headers.has('accept')) { + headers.set('accept', 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8') + } + if (!headers.has('accept-language')) { + headers.set('accept-language', STANDARD_ACCEPT_LANGUAGES) + } + if (!headers.has('user-agent')) { + headers.set('user-agent', STANDARD_USER_AGENT) + } + return headers +} + +export async function fetchRawImageAssetBlob( sourceUrl: string, options: FetchImageAssetBlobOptions = {}, -): Promise { - const passthroughImageTypes = new Set( - (options.passthroughTypes ?? [...defaultPassthroughImageTypes]).map((type) => - type.toLowerCase(), - ), - ) - +): Promise { for (const candidate of buildAssetURLCandidates(sourceUrl, { preferredFormat: options.preferredFormat, })) { + const headers = imageFetchHeaders(options) const response = await ( shouldUseDesktopAssetFetch(candidate) ? fetchDesktopApiURL(candidate, { signal: mergeAbortSignals([options.signal, timeoutSignal(options.timeoutMs ?? 10_000)]), + headers, }) : adapterFetch( candidate, - {}, + { headers }, { timeoutMs: options.timeoutMs ?? 10_000, signal: options.signal }, ) ).catch(() => null) @@ -149,33 +173,60 @@ export async function fetchImageAssetBlob( continue } - const image = nativeImage.createFromBuffer(Buffer.from(await sourceBlob.arrayBuffer())) - if (image.isEmpty()) { - continue - } - - const size = image.getSize() - const sourceType = sourceBlob.type.toLowerCase() - if (passthroughImageTypes.has(sourceType)) { - return { - blob: sourceBlob, - width: size.width, - height: size.height, - fileName: fileNameForImageType(sourceType), - } - } - return { - blob: new Blob([new Uint8Array(image.toPNG())], { type: 'image/png' }), - width: size.width, - height: size.height, - fileName: 'image.png', + blob: sourceBlob, + fileName: fileNameForImageType(sourceBlob.type.toLowerCase()), } } return null } +export async function normalizeImageAssetBlob( + sourceBlob: Blob, + options: Pick = {}, +): Promise { + if (!sourceBlob.type.startsWith('image/')) { + return null + } + + const image = nativeImage.createFromBuffer(Buffer.from(await sourceBlob.arrayBuffer())) + if (image.isEmpty()) { + return null + } + + const size = image.getSize() + const passthroughImageTypes = new Set( + (options.passthroughTypes ?? [...defaultPassthroughImageTypes]).map((type) => + type.toLowerCase(), + ), + ) + const sourceType = sourceBlob.type.toLowerCase() + if (passthroughImageTypes.has(sourceType)) { + return { + blob: sourceBlob, + width: size.width, + height: size.height, + fileName: fileNameForImageType(sourceType), + } + } + + return { + blob: new Blob([new Uint8Array(image.toPNG())], { type: 'image/png' }), + width: size.width, + height: size.height, + fileName: 'image.png', + } +} + +export async function fetchImageAssetBlob( + sourceUrl: string, + options: FetchImageAssetBlobOptions = {}, +): Promise { + const raw = await fetchRawImageAssetBlob(sourceUrl, options) + return raw ? await normalizeImageAssetBlob(raw.blob, options) : null +} + export function getCenteredCropRect(width: number, height: number, ratio: number): CropRect { let cropWidth = width let cropHeight = cropWidth / ratio diff --git a/apps/desktop-client/src/main/adapters/smzdm.ts b/apps/desktop-client/src/main/adapters/smzdm.ts index fc45f99..4b0887b 100644 --- a/apps/desktop-client/src/main/adapters/smzdm.ts +++ b/apps/desktop-client/src/main/adapters/smzdm.ts @@ -13,7 +13,12 @@ import { sessionCookieValue, sessionFetchJson, } from './common' -import { fetchImageAssetBlob } from './media-image' +import { + fetchRawImageAssetBlob, + normalizeImageAssetBlob, + type FetchImageAssetBlobOptions, + type ImageAssetBlob, +} from './media-image' type SmzdmPublishType = 'publish' @@ -475,13 +480,127 @@ export function getSmzdmCoverCropRect( } } +const smzdmImageFetchOptions: FetchImageAssetBlobOptions = { + preferredFormat: 'jpg', + passthroughTypes: ['image/jpeg', 'image/jpg', 'image/png'], + timeoutMs: 30_000, + headers: { + referer: SMZDM_TOU_GAO_URL, + }, +} + +function imageAssetFromDataUrl(dataUrl: string): ImageAssetBlob | null { + const match = /^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i.exec(dataUrl) + if (!match) { + return null + } + + const blob = new Blob([Buffer.from(match[2], 'base64')], { type: match[1].toLowerCase() }) + return { + blob, + width: 0, + height: 0, + fileName: match[1].toLowerCase() === 'image/jpeg' ? 'image.jpg' : 'image.png', + } +} + +async function convertImageInBrowser( + context: PublishAdapterContext, + sourceUrl: string, +): Promise { + const page = context.playwright?.page + if (!page) { + return null + } + + const result = await raceWithAbort( + page.evaluate(async (url) => { + const response = await fetch(url, { + credentials: 'include', + headers: { + accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + }, + }) + if (!response.ok) { + return null + } + + const blob = await response.blob() + if (!blob.type.startsWith('image/')) { + return null + } + + const bitmap = await createImageBitmap(blob) + try { + const canvas = document.createElement('canvas') + canvas.width = bitmap.width + canvas.height = bitmap.height + const context2d = canvas.getContext('2d') + if (!context2d) { + return null + } + context2d.drawImage(bitmap, 0, 0) + const dataUrl = canvas.toDataURL('image/png') + return { + dataUrl, + width: bitmap.width, + height: bitmap.height, + } + } finally { + bitmap.close() + } + }, sourceUrl), + { signal: context.signal, timeoutMs: 30_000 }, + ).catch(() => null) + + if (!result?.dataUrl) { + return null + } + + const image = imageAssetFromDataUrl(result.dataUrl) + if (!image) { + return null + } + + return { + ...image, + width: result.width, + height: result.height, + } +} + +async function fetchSmzdmImageAssetBlob( + context: PublishAdapterContext, + sourceUrl: string, +): Promise { + const raw = await fetchRawImageAssetBlob(sourceUrl, smzdmImageFetchOptions) + if (!raw) { + return await convertImageInBrowser(context, sourceUrl) + } + + const normalized = await normalizeImageAssetBlob(raw.blob, smzdmImageFetchOptions) + if (normalized) { + return normalized + } + + const rawType = raw.blob.type.toLowerCase() + if (rawType === 'image/webp' || raw.fileName.endsWith('.webp')) { + const dataUrl = `data:${raw.blob.type};base64,${Buffer.from(await raw.blob.arrayBuffer()).toString( + 'base64', + )}` + return await convertImageInBrowser(context, dataUrl) + } + + return null +} + async function uploadImage( context: PublishAdapterContext, sourceUrl: string, articleId: string, cropCover: boolean, ): Promise { - const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal }) + const image = await fetchSmzdmImageAssetBlob(context, sourceUrl) if (!image) { throw new Error(cropCover ? 'smzdm_cover_fetch_failed' : 'smzdm_image_fetch_failed') }