Files
geo/apps/desktop-client/src/main/adapters/smzdm.ts
T
root 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
fix(publish): prevent stuck publish queue and duplicate posting
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>
2026-05-30 19:52:17 +08:00

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)
}
},
}