fa51a3455f
Desktop Client Build / Resolve Build Metadata (push) Successful in 41s
Backend CI / Backend (push) Failing after 7m14s
Desktop Client Build / Build Desktop Client (push) Successful in 23m46s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 56s
The desktop publish queue could stall for a long time and end users assumed the software was broken. Root cause: a hung adapter held its execution slot with no wall-clock timeout while auto-renewing its lease forever, so the server never reclaimed it and every queued task behind it stayed 等待发布. Auto-recovery also risked silently re-posting a non-idempotent article. Client (Electron): - per-task wall-clock deadline + abort; progress-gated lease renewal that stops and aborts a stalled task instead of renewing it forever - decouple the concurrency cap from CDP-induced CPU/memory pressure (admission gate instead of self-throttling collapse); 15s watchdog pump - all adapter network I/O now has fetch timeouts and honors context.signal; bounded image-upload concurrency with per-image timeout - surface live adapter progress, elapsed time, queue position and a working-vs-queued distinction in the publish view Server (tenant-api): - publish lease-recovery worker (every 3m) + supporting index: reclaim expired in_progress publish leases, requeue (<3 attempts) or terminal-fail - 3-minute lease TTL with client-presence-gated extension; max 3 attempts Idempotency (production-grade core): - durable publish_submit_started_at marker, set before the irreversible platform submit POST; recovery and abort route a maybe-submitted task to unknown (manual reconcile, kept in the dedup set) instead of re-posting - desktop UI requires explicit confirmation before retrying a possibly- already-published task Verified: go build/vet/test (incl. resolvePublishRecoveryOutcome), vue-tsc, vitest 141/141, gofmt all clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
958 lines
25 KiB
TypeScript
958 lines
25 KiB
TypeScript
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<void> {
|
|
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<string> {
|
|
return await sessionCookieHeader(context.session, 'smzdm.com').catch(() => '')
|
|
}
|
|
|
|
async function smzdmHeaders(
|
|
context: PublishAdapterContext,
|
|
extra: Record<string, string> = {},
|
|
): Promise<Record<string, string>> {
|
|
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<SmzdmAccount | null> {
|
|
const response = await sessionFetchJson<SmzdmCurrentUserResponse>(
|
|
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<string> {
|
|
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<HTMLAnchorElement>('a.release-new') ??
|
|
Array.from(document.querySelectorAll<HTMLAnchorElement>("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<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,
|
|
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<SmzdmUploadedImage | null> {
|
|
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<SmzdmImageUploadResponse>(
|
|
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<SmzdmOriginalImageResponse>(
|
|
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<SmzdmCropResponse>(
|
|
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<string, string>()
|
|
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<Record<string, string>> {
|
|
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<string, unknown>)) {
|
|
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<string, unknown> {
|
|
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<string, JsonValue> {
|
|
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<PublishAdapter['publish']> extends Promise<infer T> ? 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 = `<img src="${coverAssetUrl}" />${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<SmzdmSubmitResponse>(
|
|
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)
|
|
}
|
|
},
|
|
}
|