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' type SmzdmPublishType = 'publish' type SmzdmCurrentUserResponse = { smzdm_id?: string | number nickname?: string audit_nickname?: string avatar?: string data?: { smzdm_id?: string | number nickname?: string audit_nickname?: string avatar?: string } } type SmzdmAccount = { uid: string name: string } type SmzdmImageUploadResponse = { error_code?: number | string error_msg?: string msg?: string data?: { id?: string | number url?: string small_pic?: string } } type SmzdmOriginalImageResponse = { error_code?: number | string error_msg?: string data?: { original_url?: string } } type SmzdmCropResponse = { error_code?: number | string error_msg?: string data?: Array<{ pic_url?: string square_pic_url?: string }> } type SmzdmSubmitResponse = { error_code?: number | string error_msg?: string data?: { update_topic?: { error_code?: string | number error_msg?: string } } } type SmzdmImageBlob = { blob: Blob width: number height: number } type SmzdmUploadedImage = { id: string origin: string small: string } type SmzdmCropRect = { src_x: number src_y: number src_w: number src_h: number size_w: number size_h: number } const SMZDM_ORIGIN = 'https://post.smzdm.com' const SMZDM_ZHIYOU_ORIGIN = 'https://zhiyou.smzdm.com' const SMZDM_TOU_GAO_URL = `${SMZDM_ORIGIN}/tougao/` const SMZDM_STEP_DELAY_MS = 1200 const SMZDM_COVER_RATIO = 1484 / 628 const SMZDM_COVER_SIZE_W = 416 const SMZDM_COVER_SIZE_H = 178 function stringId(value: unknown): string { if (typeof value === 'number') { return Number.isFinite(value) ? String(value) : '' } if (typeof value !== 'string') { return '' } return value.trim() } function normalizeSmzdmUid(value: unknown): string { const uid = stringId(value) return uid && uid !== '0' ? uid : '' } function decodeCookieText(value: string): string { const source = value.replace(/\+/g, ' ') try { return decodeURIComponent(source) } catch { return source } } function smzdmResponseMessage( response: | { error_code?: string | number error_msg?: string msg?: string } | null | undefined, ): string { return response?.error_msg || response?.msg || `smzdm_error_${response?.error_code ?? 'unknown'}` } function isSuccessfulCode(value: unknown): boolean { return value === 0 || value === '0' } function isSmzdmChallengeMessage(message: string): boolean { return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风控|风险)/i.test( message, ) } async function waitForHumanPace(signal?: AbortSignal): Promise { if (signal?.aborted) { throw new Error('adapter_aborted') } return new Promise((resolve, reject) => { const cleanup = () => { signal?.removeEventListener('abort', onAbort) } const timeout = setTimeout(() => { cleanup() resolve() }, SMZDM_STEP_DELAY_MS) const onAbort = () => { clearTimeout(timeout) cleanup() reject(new Error('adapter_aborted')) } signal?.addEventListener('abort', onAbort, { once: true }) }) } async function smzdmCookieHeader(context: PublishAdapterContext): Promise { return await sessionCookieHeader(context.session, 'smzdm.com').catch(() => '') } async function smzdmHeaders( context: PublishAdapterContext, extra: Record = {}, ): Promise> { const cookie = await smzdmCookieHeader(context) return { accept: 'application/json, text/plain, */*', origin: SMZDM_ORIGIN, referer: SMZDM_TOU_GAO_URL, ...(cookie ? { cookie } : {}), ...extra, } } async function fetchCurrentAccount(context: PublishAdapterContext): Promise { const response = await sessionFetchJson( context.session, `${SMZDM_ZHIYOU_ORIGIN}/user/info/jsonp_get_current`, { credentials: 'include', headers: { accept: 'application/json, text/plain, */*', origin: SMZDM_ZHIYOU_ORIGIN, referer: `${SMZDM_ZHIYOU_ORIGIN}/user`, }, }, { signal: context.signal }, ).catch(() => null) const user = response?.data ?? response const uid = normalizeSmzdmUid(user?.smzdm_id) const name = user?.nickname?.trim() || user?.audit_nickname?.trim() || '' if (uid) { return { uid, name: name || `值友${uid}`, } } return null /* const cookieUid = normalizeSmzdmUid( await sessionCookieValue(context.session, 'smzdm.com', 'smzdm_id').catch(() => ''), ) const cookieUser = decodeCookieText( await sessionCookieValue(context.session, 'smzdm.com', 'user').catch(() => ''), ) const cookieName = cookieUser.trim() if (!cookieUid) { return null } return { uid: cookieUid, name: cookieName || `值友${cookieUid}`, } */ } export function extractSmzdmArticleIdFromHref(value: string | null | undefined): string { const href = value?.trim() || '' if (!href) { return '' } const pathname = (() => { try { return new URL(href, SMZDM_ORIGIN).pathname } catch { return href } })() return pathname.split('/').filter(Boolean).pop() ?? '' } async function getArticleId(context: PublishAdapterContext): Promise { const page = context.playwright?.page if (!page) { throw new Error('smzdm_playwright_page_missing') } await raceWithAbort( page.goto(SMZDM_TOU_GAO_URL, { waitUntil: 'domcontentloaded', timeout: 45_000, }), { signal: context.signal, timeoutMs: 45_000 }, ) await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => undefined) const href = await page .evaluate(() => { const link = document.querySelector('a.release-new') ?? Array.from(document.querySelectorAll("a[href*='/edit/']"))[0] ?? null return link?.getAttribute('href') || link?.href || '' }) .catch(() => '') const articleId = extractSmzdmArticleIdFromHref(href) if (!articleId) { const currentUrl = page.url() if (/\/user\/login/i.test(currentUrl)) { throw new Error('smzdm_not_logged_in') } throw new Error('smzdm_article_id_missing') } 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, ratio = SMZDM_COVER_RATIO, ): SmzdmCropRect { let srcW = width let srcH = srcW / ratio if (srcH > height) { srcH = height srcW = srcH * ratio } const srcX = (width - srcW) / 2 const srcY = (height - srcH) / 2 const scale = Math.min(SMZDM_COVER_SIZE_W / width, SMZDM_COVER_SIZE_H / height) return { src_x: srcX, src_y: srcY, src_w: srcW, src_h: srcH, size_w: srcW * scale, size_h: srcH * scale, } } async function uploadImage( context: PublishAdapterContext, sourceUrl: string, articleId: string, cropCover: boolean, ): Promise { const image = await fetchSmzdmImageBlob(sourceUrl, context.signal) if (!image) { throw new Error(cropCover ? 'smzdm_cover_fetch_failed' : 'smzdm_image_fetch_failed') } const form = new FormData() form.append('imgFile', image.blob, 'image.png') form.append('id', 'WU_FILE_0') form.append('type', image.blob.type || 'image/png') form.append('article_id', articleId) const uploaded = await sessionFetchJson( context.session, `${SMZDM_ORIGIN}/api/images/upload/local`, { method: 'POST', credentials: 'include', headers: await smzdmHeaders(context), body: form, }, { signal: context.signal }, ).catch( (error): SmzdmImageUploadResponse => ({ error_code: -1, error_msg: error instanceof Error ? error.message : 'smzdm_image_upload_failed', }), ) if (!isSuccessfulCode(uploaded.error_code) || !uploaded.data?.url) { const message = smzdmResponseMessage(uploaded) if (isSmzdmChallengeMessage(message)) { throw new Error(`smzdm_challenge_required:${message}`) } throw new Error( `${cropCover ? 'smzdm_cover_upload_failed' : 'smzdm_image_upload_failed'}:${message}`, ) } const uploadedId = stringId(uploaded.data.id) if (uploadedId) { 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, }), }, { signal: context.signal }, ).catch(() => null) } if (!cropCover) { return { id: uploadedId, origin: uploaded.data.url, small: uploaded.data.small_pic || uploaded.data.url, } } const original = await sessionFetchJson( context.session, `${SMZDM_ORIGIN}/api/image/original`, { 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, }), }, { signal: context.signal }, ).catch(() => null) const crop = getSmzdmCoverCropRect(image.width, image.height) const cropForm = new FormData() cropForm.append('cut_pic_list[0][src_x]', String(crop.src_x)) cropForm.append('cut_pic_list[0][src_y]', String(crop.src_y)) cropForm.append('cut_pic_list[0][src_w]', String(crop.src_w)) cropForm.append('cut_pic_list[0][src_h]', String(crop.src_h)) cropForm.append('cut_pic_list[0][article_id]', articleId) cropForm.append('cut_pic_list[0][size_w]', String(crop.size_w)) cropForm.append('cut_pic_list[0][size_h]', String(crop.size_h)) cropForm.append( 'cut_pic_list[0][cropperData]', JSON.stringify({ x: crop.src_x, y: crop.src_y, width: crop.src_w, height: crop.src_h, rotate: 0, scaleX: 1, scaleY: 1, }), ) cropForm.append('cut_pic_list[0][original_pic_height]', String(image.height)) 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') const cropped = await sessionFetchJson( context.session, `${SMZDM_ORIGIN}/api/image/crop`, { method: 'POST', credentials: 'include', headers: await smzdmHeaders(context), body: cropForm, }, { signal: context.signal }, ).catch( (error): SmzdmCropResponse => ({ error_code: -1, error_msg: error instanceof Error ? error.message : 'smzdm_cover_crop_failed', }), ) const cropResult = cropped.data?.[0] return { id: uploadedId, origin: cropResult?.pic_url || uploaded.data.url, small: cropResult?.square_pic_url || uploaded.data.small_pic || uploaded.data.url, } } function replaceAllLiteral(source: string, from: string, to: string): string { return source.split(from).join(to) } async function processContentImages( context: PublishAdapterContext, html: string, articleId: string, ): Promise<{ html: string; images: string[] }> { const sources = extractImageSources(html) if (!sources.length) { return { html, images: [] } } let next = html const uploadedImages: string[] = [] const uploaded = new Map() for (const source of sources) { await waitForHumanPace(context.signal) const image = await uploadImage(context, source, articleId, false) if (!image?.origin) { continue } uploaded.set(source, image.origin) uploadedImages.push(image.origin) } for (const [from, to] of uploaded.entries()) { next = replaceAllLiteral(next, from, to) } return { html: next, images: uploadedImages, } } export function buildSmzdmAwne(uid: string, textCount: number): string { const key = createHash('md5').update(`${uid}-${textCount}-smzdm.com`.trim()).digest('hex') const plaintext = Buffer.from(String(textCount), 'utf8').toString('base64') const cipher = createCipheriv('aes-256-ecb', Buffer.from(key, 'utf8'), null) cipher.setAutoPadding(true) return Buffer.concat([cipher.update(Buffer.from(plaintext, 'utf8')), cipher.final()]).toString( 'base64', ) } async function calculateEditorSignature( context: PublishAdapterContext, articleId: string, html: string, uid: string, ): Promise<{ awne: string; wne: string }> { const page = context.playwright?.page if (!page) { throw new Error('smzdm_playwright_page_missing') } await raceWithAbort( page.goto(`${SMZDM_ORIGIN}/edit/${encodeURIComponent(articleId)}`, { waitUntil: 'domcontentloaded', timeout: 45_000, }), { signal: context.signal, timeoutMs: 45_000 }, ) await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => undefined) const textCount = await raceWithAbort( page.evaluate((content) => { const editorNode = document.querySelector('.ProseMirror[contenteditable]') as | (HTMLElement & { editor?: { commands?: { setContent?: (value: string, emitUpdate?: boolean, options?: unknown) => unknown getTextCount?: () => unknown } } }) | null const editor = editorNode?.editor if (!editor?.commands?.setContent || !editor.commands.getTextCount) { return null } editor.commands.setContent(content, false, { parseOptions: { preserveWhitespace: 'full', }, }) return editor.commands.getTextCount() }, html), { signal: context.signal, timeoutMs: 20_000 }, ) .catch(() => null) const normalizedCount = typeof textCount === 'number' && Number.isFinite(textCount) ? Math.round(textCount) : null if (normalizedCount === null) { throw new Error('smzdm_editor_signature_failed') } return { awne: buildSmzdmAwne(uid, normalizedCount), wne: String(normalizedCount), } } function withoutProtocol(url: string): string { return url.replace(/^https?:/i, '') } function buildImageList(images: string[]): Array> { return images.map((url) => ({ height: '', image_tag: '', original_drawing: '0', pic_url: url, picture_id: '', width: '', cms_link: '', other_data: '', image_product_tag: '', })) } export function encodeSmzdmForm(value: unknown, key = ''): string { const parts: string[] = [] const append = (item: unknown, itemKey: string) => { if (item === null || item === undefined) { parts.push(`${encodeURIComponent(itemKey)}=`) return } if (Array.isArray(item)) { item.forEach((child, index) => append(child, `${itemKey}[${index}]`)) return } if (typeof item === 'object') { for (const [childKey, child] of Object.entries(item as Record)) { append(child, itemKey ? `${itemKey}[${childKey}]` : childKey) } return } parts.push(`${encodeURIComponent(itemKey)}=${encodeURIComponent(String(item))}`) } append(value, key) return parts.join('&') } function buildSubmitForm(input: { articleId: string title: string html: string awne: string wne: string cover: SmzdmUploadedImage | null images: string[] }): Record { return { article_id: input.articleId, submit_type: 'submit', title: input.title, series_title: '', focus_image: input.cover ? withoutProtocol(input.cover.origin) : '', series_order_id: '0', series_id: '0', anonymous: '0', first_publish: '0', remark: '', create_state_type: '3', ai_state_type: '2', square_pic_url: input.cover ? withoutProtocol(input.cover.small) : '', cover_image_rectangle: '', custom_topics: '', group_id: '', awne: input.awne, wne: input.wne, editorValue: input.html, ...(input.images.length ? { image_list: buildImageList(input.images) } : {}), } } function smzdmManageUrl(articleId: string): string { return `${SMZDM_ORIGIN}/edit/${encodeURIComponent(articleId)}` } export function smzdmArticleUrl(articleId: string): string { return `${SMZDM_ORIGIN}/p/${encodeURIComponent(articleId)}/` } function buildResultPayload( publishType: SmzdmPublishType, articleId: string, mediaName: string, externalManageUrl: string, ): Record { return { platform: 'smzdm', media_name: mediaName, external_article_id: articleId, external_manage_url: externalManageUrl, external_article_url: smzdmArticleUrl(articleId), publish_type: publishType, } } function failureResult( error: unknown, ): ReturnType extends Promise ? T : never { const message = error instanceof Error ? error.message : String(error) if (message === 'smzdm_not_logged_in') { return { status: 'failed', summary: '什么值得买登录态失效,无法执行发布。', error: { code: 'smzdm_not_logged_in', message, }, } } if (message === 'smzdm_playwright_page_missing') { return { status: 'failed', summary: '什么值得买发布需要隐藏浏览器页面参与签名计算,请稍后重试。', error: { code: 'smzdm_playwright_page_missing', message, }, } } if (message === 'smzdm_article_id_missing') { return { status: 'failed', summary: '什么值得买投稿入口未返回文章 ID,请打开投稿页确认账号状态后重试。', error: { code: 'smzdm_article_id_missing', message, }, } } if (message === 'article_content_empty') { return { status: 'failed', summary: '文章内容为空,无法推送到什么值得买。', error: { code: 'article_content_empty', message, }, } } if (message === 'smzdm_editor_signature_failed') { return { status: 'failed', summary: '什么值得买编辑器签名计算失败,请打开编辑器确认页面已正常加载后重试。', error: { code: 'smzdm_editor_signature_failed', message, }, } } if (message.startsWith('smzdm_cover_fetch_failed')) { return { status: 'failed', summary: PUBLISH_IMAGE_FETCH_FAILED_MESSAGE, error: { code: 'smzdm_cover_fetch_failed', message, }, } } if (message.startsWith('smzdm_image_fetch_failed')) { return { status: 'failed', summary: PUBLISH_IMAGE_FETCH_FAILED_MESSAGE, error: { code: 'smzdm_image_fetch_failed', message, }, } } if (message.startsWith('smzdm_cover_upload_failed')) { return { status: 'failed', summary: '什么值得买封面上传失败,请更换封面图后重试。', error: { code: 'smzdm_cover_upload_failed', message, }, } } if (message.startsWith('smzdm_image_upload_failed')) { return { status: 'failed', summary: '什么值得买正文图片上传失败,请稍后重试或更换图片。', error: { code: 'smzdm_image_upload_failed', message, }, } } if (message.startsWith('smzdm_challenge_required') || isSmzdmChallengeMessage(message)) { return { status: 'failed', summary: '什么值得买触发平台验证,请在什么值得买后台完成验证后重试。', error: { code: 'smzdm_challenge_required', message, }, } } return { status: 'failed', summary: '什么值得买发布失败。', error: { code: 'smzdm_publish_failed', message, }, } } export const smzdmAdapter: PublishAdapter = { platform: 'smzdm', executionMode: 'playwright', async publish(context) { try { context.reportProgress('smzdm.detect_login') const account = await fetchCurrentAccount(context) if (!account?.uid) { throw new Error('smzdm_not_logged_in') } context.reportProgress('smzdm.create_article_id') const articleId = await getArticleId(context) let html = normalizeArticleHtml(context.article) if (!html) { throw new Error('article_content_empty') } const coverAssetUrl = context.article.cover_asset_url?.trim() || '' let cover: SmzdmUploadedImage | null = null if (coverAssetUrl) { context.reportProgress('smzdm.upload_cover') await waitForHumanPace(context.signal) cover = await uploadImage(context, coverAssetUrl, articleId, true) } if (coverAssetUrl && extractImageSources(html).length === 0) { html = `${html}` } context.reportProgress('smzdm.upload_content_images') const processed = await processContentImages(context, html, articleId) context.reportProgress('smzdm.calculate_signature') const signature = await calculateEditorSignature( context, articleId, processed.html, account.uid, ) context.reportProgress('smzdm.submit') await waitForHumanPace(context.signal) 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', }), body: encodeSmzdmForm( buildSubmitForm({ articleId, title: context.article.title?.trim() || '未命名文章', html: processed.html, awne: signature.awne, wne: signature.wne, cover, images: processed.images, }), ), }, { signal: context.signal }, ).catch( (requestError): SmzdmSubmitResponse => ({ error_code: -1, error_msg: requestError instanceof Error ? requestError.message : 'smzdm_publish_failed', }), ) 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 (isSmzdmChallengeMessage(message)) { throw new Error(`smzdm_challenge_required:${message}`) } throw new Error(message || 'smzdm_publish_failed') } return { status: 'succeeded', payload: buildResultPayload('publish', articleId, account.name, smzdmManageUrl(articleId)), summary: '什么值得买发布成功。', } } catch (error) { return failureResult(error) } }, }