fix desktop smzdm csrf publishing

This commit is contained in:
2026-06-25 00:18:24 +08:00
parent 590c219a64
commit 2ec266201c
4 changed files with 288 additions and 107 deletions
@@ -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)
+234 -105
View File
@@ -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<string, string> {
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<void> {
if (signal?.aborted) {
throw new Error('adapter_aborted')
@@ -178,16 +202,152 @@ async function smzdmCookieHeader(context: PublishAdapterContext): Promise<string
return await sessionCookieHeader(context.session, 'smzdm.com').catch(() => '')
}
async function smzdmCookieCsrfToken(context: PublishAdapterContext): Promise<string> {
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<string> {
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<HTMLMetaElement>(selector)?.content)
}
const inputNames = ['_csrf', 'csrf', 'csrf_token', 'csrfToken', 'x-csrf-token']
for (const name of inputNames) {
push(document.querySelector<HTMLInputElement>(`input[name="${name}"]`)?.value)
}
const globals = window as unknown as Window &
Record<string, unknown> & {
csrfToken?: string
csrf_token?: string
_csrf?: string
CSRF_TOKEN?: string
SMZDM_CSRF_TOKEN?: string
PAGE_CONFIG?: Record<string, unknown>
__INITIAL_STATE__?: Record<string, unknown>
}
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<string> {
return (await smzdmCookieCsrfToken(context)) || (await smzdmPageCsrfToken(context))
}
async function fetchSmzdmEditorToken(context: PublishAdapterContext): Promise<string> {
const response = await sessionFetchJson<SmzdmEditorTokenResponse>(
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<string, string> = {},
csrfToken?: string,
): Promise<Record<string, string>> {
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<string> {
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<SmzdmImageBlob | null> {
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<SmzdmUploadedImage | null> {
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<SmzdmImageUploadResponse>(
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<SmzdmCropResponse>(
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<SmzdmSubmitResponse>(
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}`)
}
@@ -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')
@@ -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<string, PlatformAdapter>(
[
...aiPlatformCatalog.map((platform) => platform.id),
@@ -402,8 +426,10 @@ const adapterRegistry = new Map<string, PlatformAdapter>(
? weixinGzhAdapter
: platform === 'zol'
? zolAdapter
: platform === 'dongchedi'
? dongchediAdapter
: platform === 'dongchedi'
? dongchediAdapter
: platform === 'smzdm'
? smzdmAdapter
: aiPlatformCatalog.some((item) => item.id === platform)
? aiAdapter
: genericAdapter,