diff --git a/apps/desktop-client/src/main/adapters/smzdm.test.ts b/apps/desktop-client/src/main/adapters/smzdm.test.ts index fb1b9b2..f0f6c59 100644 --- a/apps/desktop-client/src/main/adapters/smzdm.test.ts +++ b/apps/desktop-client/src/main/adapters/smzdm.test.ts @@ -21,6 +21,8 @@ import { encodeSmzdmForm, extractSmzdmArticleIdFromHref, getSmzdmCoverCropRect, + isSmzdmCsrfMissingMessage, + smzdmCsrfHeaders, smzdmArticleUrl, } from './smzdm' @@ -54,6 +56,18 @@ describe('smzdm adapter helpers', () => { expect(buildSmzdmAwne('4242025233', 123)).toBe('m2At8ovjSz5kmnLNUFEW9g==') }) + it('builds SMZDM CSRF headers like the reference extension submit request', () => { + expect(smzdmCsrfHeaders('token-1')).toMatchObject({ + _csrf_token: 'token-1', + 'x-csrf-token': 'token-1', + }) + }) + + it('recognizes SMZDM missing csrf responses', () => { + expect(isSmzdmCsrfMissingMessage('CSRF token缺失')).toBe(true) + expect(isSmzdmCsrfMissingMessage('csrf token missing')).toBe(true) + }) + it('centers the cover crop at the required SMZDM ratio', () => { const crop = getSmzdmCoverCropRect(2000, 1000) diff --git a/apps/desktop-client/src/main/adapters/smzdm.ts b/apps/desktop-client/src/main/adapters/smzdm.ts index f396087..fc45f99 100644 --- a/apps/desktop-client/src/main/adapters/smzdm.ts +++ b/apps/desktop-client/src/main/adapters/smzdm.ts @@ -2,19 +2,18 @@ import { Buffer } from 'node:buffer' import { createCipheriv, createHash } from 'node:crypto' import type { JsonValue } from '@geo/shared-types' -import { nativeImage } from 'electron' import { PUBLISH_IMAGE_FETCH_FAILED_MESSAGE } from '../../shared/publisher-errors' -import { resolveDesktopApiURL } from '../transport/api-client' import type { PublishAdapter, PublishAdapterContext } from './base' import { - adapterFetch, extractImageSources, normalizeArticleHtml, raceWithAbort, sessionCookieHeader, + sessionCookieValue, sessionFetchJson, } from './common' +import { fetchImageAssetBlob } from './media-image' type SmzdmPublishType = 'publish' @@ -64,6 +63,14 @@ type SmzdmCropResponse = { }> } +type SmzdmEditorTokenResponse = { + error_code?: number | string + error_msg?: string + data?: { + token?: string + } +} + type SmzdmSubmitResponse = { error_code?: number | string error_msg?: string @@ -75,12 +82,6 @@ type SmzdmSubmitResponse = { } } -type SmzdmImageBlob = { - blob: Blob - width: number - height: number -} - type SmzdmUploadedImage = { id: string origin: string @@ -151,6 +152,29 @@ function isSmzdmChallengeMessage(message: string): boolean { ) } +export function isSmzdmCsrfMissingMessage(message: string): boolean { + return /csrf/i.test(message) && /(缺失|missing|required|invalid|校验|验证|403)/i.test(message) +} + +export function smzdmCsrfHeaders(token: string): Record { + return { + _csrf_token: token, + 'x-csrf-token': token, + 'x-xsrf-token': token, + 'csrf-token': token, + } +} + +function appendSmzdmCsrfFields(target: FormData | URLSearchParams, token: string): void { + if (!token) { + return + } + target.append('_csrf', token) + target.append('csrf_token', token) + target.append('csrfToken', token) + target.append('csrf', token) +} + async function waitForHumanPace(signal?: AbortSignal): Promise { if (signal?.aborted) { throw new Error('adapter_aborted') @@ -178,16 +202,152 @@ async function smzdmCookieHeader(context: PublishAdapterContext): Promise '') } +async function smzdmCookieCsrfToken(context: PublishAdapterContext): Promise { + const names = [ + 'csrf_token', + 'csrfToken', + '_csrf', + 'csrf', + 'XSRF-TOKEN', + 'xsrf-token', + 'csrftoken', + 'CSRF-TOKEN', + 'SMZDM_CSRF_TOKEN', + ] + + for (const name of names) { + const value = await sessionCookieValue(context.session, 'smzdm.com', name).catch(() => '') + const decoded = decodeCookieText(value).trim() + if (decoded) { + return decoded + } + } + + const cookies = await context.session.cookies.get({ domain: 'smzdm.com' }).catch(() => []) + const matched = cookies.find((cookie) => /(csrf|xsrf)/i.test(cookie.name) && cookie.value.trim()) + return matched ? decodeCookieText(matched.value).trim() : '' +} + +async function smzdmPageCsrfToken(context: PublishAdapterContext): Promise { + const page = context.playwright?.page + if (!page) { + return '' + } + + return await page + .evaluate(() => { + const candidates: string[] = [] + const push = (value: unknown) => { + if (typeof value !== 'string') { + return + } + const trimmed = value.trim() + if (trimmed && !candidates.includes(trimmed)) { + candidates.push(trimmed) + } + } + + const selectors = [ + 'meta[name="csrf-token"]', + 'meta[name="csrf_token"]', + 'meta[name="_csrf"]', + 'meta[name="csrf"]', + 'meta[name="x-csrf-token"]', + 'meta[name="xsrf-token"]', + ] + for (const selector of selectors) { + push(document.querySelector(selector)?.content) + } + + const inputNames = ['_csrf', 'csrf', 'csrf_token', 'csrfToken', 'x-csrf-token'] + for (const name of inputNames) { + push(document.querySelector(`input[name="${name}"]`)?.value) + } + + const globals = window as unknown as Window & + Record & { + csrfToken?: string + csrf_token?: string + _csrf?: string + CSRF_TOKEN?: string + SMZDM_CSRF_TOKEN?: string + PAGE_CONFIG?: Record + __INITIAL_STATE__?: Record + } + push(globals.csrfToken) + push(globals.csrf_token) + push(globals._csrf) + push(globals.CSRF_TOKEN) + push(globals.SMZDM_CSRF_TOKEN) + push(globals.PAGE_CONFIG?.csrfToken) + push(globals.PAGE_CONFIG?.csrf_token) + push(globals.__INITIAL_STATE__?.csrfToken) + push(globals.__INITIAL_STATE__?.csrf_token) + + const readStorage = (storage: Storage) => { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index) || '' + if (/(csrf|xsrf)/i.test(key)) { + push(storage.getItem(key)) + } + } + } + try { + readStorage(window.localStorage) + } catch { + // Ignore inaccessible storage in restricted platform pages. + } + try { + readStorage(window.sessionStorage) + } catch { + // Ignore inaccessible storage in restricted platform pages. + } + + for (const item of document.cookie.split(';')) { + const [rawName, ...rawValue] = item.split('=') + if (/(csrf|xsrf)/i.test(rawName || '')) { + push(decodeURIComponent(rawValue.join('='))) + } + } + + return candidates[0] || '' + }) + .catch(() => '') +} + +async function smzdmCsrfToken(context: PublishAdapterContext): Promise { + return (await smzdmCookieCsrfToken(context)) || (await smzdmPageCsrfToken(context)) +} + +async function fetchSmzdmEditorToken(context: PublishAdapterContext): Promise { + const response = await sessionFetchJson( + context.session, + `${SMZDM_ORIGIN}/api/editor/get_token`, + { + method: 'GET', + credentials: 'include', + headers: await smzdmHeaders(context, {}, ''), + }, + { signal: context.signal }, + ).catch(() => null) + + return stringId(response?.data?.token) || (await smzdmCsrfToken(context)) +} + async function smzdmHeaders( context: PublishAdapterContext, extra: Record = {}, + csrfToken?: string, ): Promise> { const cookie = await smzdmCookieHeader(context) + const token = csrfToken ?? (await smzdmCsrfToken(context)) return { accept: 'application/json, text/plain, */*', origin: SMZDM_ORIGIN, referer: SMZDM_TOU_GAO_URL, + 'x-requested-with': 'XMLHttpRequest', ...(cookie ? { cookie } : {}), + ...(token ? smzdmCsrfHeaders(token) : {}), ...extra, } } @@ -289,81 +449,6 @@ async function getArticleId(context: PublishAdapterContext): Promise { return articleId } -function buildAssetURLCandidates(sourceUrl: string): string[] { - const trimmed = sourceUrl.trim() - if (!trimmed) { - return [] - } - - if (/^(data|blob):/i.test(trimmed)) { - return [trimmed] - } - - const candidates: string[] = [] - const pushCandidate = (value: string) => { - if (!candidates.includes(value)) { - candidates.push(value) - } - } - - try { - const parsed = new URL(trimmed, resolveDesktopApiURL('/')) - - if (parsed.pathname.startsWith('/api/')) { - const apiAssetUrl = new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`)) - if (apiAssetUrl.pathname.startsWith('/api/public/assets/')) { - apiAssetUrl.searchParams.set('format', 'png') - } - pushCandidate(apiAssetUrl.toString()) - } - - pushCandidate(parsed.toString()) - } catch { - pushCandidate(trimmed) - } - - return candidates -} - -async function fetchSmzdmImageBlob( - sourceUrl: string, - signal?: AbortSignal, -): Promise { - for (const candidate of buildAssetURLCandidates(sourceUrl)) { - const response = await adapterFetch(candidate, {}, { signal, timeoutMs: 10_000 }).catch( - () => null, - ) - if (!response?.ok) { - continue - } - - const sourceBlob = await response.blob().catch(() => null) - if (!sourceBlob || !sourceBlob.type.startsWith('image/')) { - continue - } - - const buffer = Buffer.from(await sourceBlob.arrayBuffer()) - const image = nativeImage.createFromBuffer(buffer) - if (image.isEmpty()) { - continue - } - - const size = image.getSize() - const normalizedBlob = - sourceBlob.type.toLowerCase() === 'image/webp' - ? new Blob([new Uint8Array(image.toPNG())], { type: 'image/png' }) - : sourceBlob - - return { - blob: normalizedBlob, - width: size.width, - height: size.height, - } - } - - return null -} - export function getSmzdmCoverCropRect( width: number, height: number, @@ -396,16 +481,18 @@ async function uploadImage( articleId: string, cropCover: boolean, ): Promise { - const image = await fetchSmzdmImageBlob(sourceUrl, context.signal) + const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal }) if (!image) { throw new Error(cropCover ? 'smzdm_cover_fetch_failed' : 'smzdm_image_fetch_failed') } + const csrfToken = await smzdmCsrfToken(context) const form = new FormData() - form.append('imgFile', image.blob, 'image.png') + form.append('imgFile', image.blob, image.fileName) form.append('id', 'WU_FILE_0') form.append('type', image.blob.type || 'image/png') form.append('article_id', articleId) + appendSmzdmCsrfFields(form, csrfToken) const uploaded = await sessionFetchJson( context.session, @@ -413,7 +500,7 @@ async function uploadImage( { method: 'POST', credentials: 'include', - headers: await smzdmHeaders(context), + headers: await smzdmHeaders(context, {}, csrfToken), body: form, }, { signal: context.signal }, @@ -426,6 +513,9 @@ async function uploadImage( if (!isSuccessfulCode(uploaded.error_code) || !uploaded.data?.url) { const message = smzdmResponseMessage(uploaded) + if (isSmzdmCsrfMissingMessage(message)) { + throw new Error(`smzdm_csrf_missing:${message}`) + } if (isSmzdmChallengeMessage(message)) { throw new Error(`smzdm_challenge_required:${message}`) } @@ -436,19 +526,26 @@ async function uploadImage( const uploadedId = stringId(uploaded.data.id) if (uploadedId) { + const recordBody = new URLSearchParams({ + article_id: articleId, + ids: uploadedId, + }) + appendSmzdmCsrfFields(recordBody, csrfToken) + void sessionFetchJson( context.session, `${SMZDM_ORIGIN}/api/editor/image_add_time/record`, { method: 'POST', credentials: 'include', - headers: await smzdmHeaders(context, { - 'content-type': 'application/x-www-form-urlencoded', - }), - body: new URLSearchParams({ - article_id: articleId, - ids: uploadedId, - }), + headers: await smzdmHeaders( + context, + { + 'content-type': 'application/x-www-form-urlencoded', + }, + csrfToken, + ), + body: recordBody, }, { signal: context.signal }, ).catch(() => null) @@ -468,13 +565,21 @@ async function uploadImage( { method: 'POST', credentials: 'include', - headers: await smzdmHeaders(context, { - 'content-type': 'application/x-www-form-urlencoded', - }), - body: new URLSearchParams({ - article_id: articleId, - pic_url: uploaded.data.url, - }), + headers: await smzdmHeaders( + context, + { + 'content-type': 'application/x-www-form-urlencoded', + }, + csrfToken, + ), + body: (() => { + const body = new URLSearchParams({ + article_id: articleId, + pic_url: uploaded.data.url, + }) + appendSmzdmCsrfFields(body, csrfToken) + return body + })(), }, { signal: context.signal }, ).catch(() => null) @@ -504,6 +609,7 @@ async function uploadImage( cropForm.append('cut_pic_list[0][original_pic_width]', String(image.width)) cropForm.append('cut_pic_list[0][cutUrl]', original?.data?.original_url || '') cropForm.append('cut_pic_list[0][is_head]', '1') + appendSmzdmCsrfFields(cropForm, csrfToken) const cropped = await sessionFetchJson( context.session, @@ -511,7 +617,7 @@ async function uploadImage( { method: 'POST', credentials: 'include', - headers: await smzdmHeaders(context), + headers: await smzdmHeaders(context, {}, csrfToken), body: cropForm, }, { signal: context.signal }, @@ -796,6 +902,17 @@ function failureResult( } } + if (message.startsWith('smzdm_csrf_missing') || isSmzdmCsrfMissingMessage(message)) { + return { + status: 'failed', + summary: '什么值得买 CSRF token 缺失,请重新登录或重新绑定账号后再试。', + error: { + code: 'smzdm_csrf_missing', + message, + }, + } + } + if (message.startsWith('smzdm_cover_fetch_failed')) { return { status: 'failed', @@ -905,15 +1022,24 @@ export const smzdmAdapter: PublishAdapter = { await context.reportProgress('smzdm.submit') await waitForHumanPace(context.signal) + const csrfToken = await fetchSmzdmEditorToken(context) + if (!csrfToken) { + throw new Error('smzdm_csrf_missing:editor_token_missing') + } + const response = await sessionFetchJson( context.session, `${SMZDM_ORIGIN}/api/editor/article/submit`, { method: 'POST', credentials: 'include', - headers: await smzdmHeaders(context, { - 'content-type': 'application/x-www-form-urlencoded', - }), + headers: await smzdmHeaders( + context, + { + 'content-type': 'application/x-www-form-urlencoded', + }, + csrfToken, + ), body: encodeSmzdmForm( buildSubmitForm({ articleId, @@ -937,6 +1063,9 @@ export const smzdmAdapter: PublishAdapter = { const topicCode = response.data?.update_topic?.error_code if (!isSuccessfulCode(response.error_code) || !isSuccessfulCode(topicCode)) { const message = response.data?.update_topic?.error_msg || smzdmResponseMessage(response) + if (isSmzdmCsrfMissingMessage(message)) { + throw new Error(`smzdm_csrf_missing:${message}`) + } if (isSmzdmChallengeMessage(message)) { throw new Error(`smzdm_challenge_required:${message}`) } diff --git a/apps/desktop-client/src/main/platform-auth-adapters.test.ts b/apps/desktop-client/src/main/platform-auth-adapters.test.ts index 3c8a378..78be24e 100644 --- a/apps/desktop-client/src/main/platform-auth-adapters.test.ts +++ b/apps/desktop-client/src/main/platform-auth-adapters.test.ts @@ -94,6 +94,18 @@ describe('platform auth adapters', () => { ).toBe('auth_failure') }) + it('classifies SMZDM missing csrf as auth failure', () => { + const adapter = getPlatformAdapter('smzdm') + + expect( + adapter.classifyFailure({ + error: { + code: 'smzdm_csrf_missing', + }, + }), + ).toBe('auth_failure') + }) + it('classifies Juejin missing login as auth failure', () => { const adapter = getPlatformAdapter('juejin') diff --git a/apps/desktop-client/src/main/platform-auth-adapters.ts b/apps/desktop-client/src/main/platform-auth-adapters.ts index 9fdb6e1..394f51a 100644 --- a/apps/desktop-client/src/main/platform-auth-adapters.ts +++ b/apps/desktop-client/src/main/platform-auth-adapters.ts @@ -234,6 +234,20 @@ function classifyDongchediFailure(input: PlatformFailureInput): PlatformFailureC return classifyFailure(input) } +function classifySmzdmFailure(input: PlatformFailureInput): PlatformFailureClassification { + const code = typeof input.error?.code === 'string' ? input.error.code : '' + if (code === 'smzdm_not_logged_in' || code === 'smzdm_csrf_missing') { + return 'auth_failure' + } + if (code === 'smzdm_challenge_required') { + return 'challenge' + } + if (code.startsWith('smzdm_')) { + return 'ok' + } + return classifyFailure(input) +} + const aiAdapter: PlatformAdapter = { async probe(account, partition) { return await probeAIAccountSession(account, partition) @@ -364,6 +378,16 @@ const dongchediAdapter: PlatformAdapter = { classifyFailure: classifyDongchediFailure, } +const smzdmAdapter: PlatformAdapter = { + async probe(account, partition) { + return await probePublishAccountSession(account, partition) + }, + async silentRefresh(account, partition) { + return await silentRefreshPublishAccountSession(account, partition) + }, + classifyFailure: classifySmzdmFailure, +} + const adapterRegistry = new Map( [ ...aiPlatformCatalog.map((platform) => platform.id), @@ -402,8 +426,10 @@ const adapterRegistry = new Map( ? weixinGzhAdapter : platform === 'zol' ? zolAdapter - : platform === 'dongchedi' - ? dongchediAdapter + : platform === 'dongchedi' + ? dongchediAdapter + : platform === 'smzdm' + ? smzdmAdapter : aiPlatformCatalog.some((item) => item.id === platform) ? aiAdapter : genericAdapter,