Files
geo/apps/desktop-client/src/main/adapters/sohuhao.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

390 lines
10 KiB
TypeScript

import { randomBytes } from 'node:crypto'
import type { JsonValue } from '@geo/shared-types'
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
extractImageSources,
normalizeArticleHtml,
sessionCookieValue,
sessionFetchJson,
uploadHtmlImages,
} from './common'
import { fetchImageAssetBlob } from './media-image'
type SohuRegisterInfoResponse = {
data?: {
account?: {
id?: string | number
nickName?: string
avatar?: string
}
}
}
type SohuAccountListResponse = {
code?: number
data?: {
data?: Array<{
accounts?: SohuRawAccount[]
}>
}
}
type SohuRawAccount = {
id?: string | number
nickName?: string
avatar?: string
}
type SohuSubmitResponse = {
code?: number
success?: boolean
data?: string | number
msg?: string
message?: string
}
type SohuUploadResponse = {
url?: string
msg?: string
message?: string
}
type SohuAccount = {
id: string
name: string
}
type SohuPublishType = 'publish' | 'draft'
const SOHU_ORIGIN = 'https://mp.sohu.com'
const SOHU_MANAGE_URL = `${SOHU_ORIGIN}/mpfe/v4/contentManagement/first/page?newsType=1`
const SOHU_PUBLISH_URL = `${SOHU_ORIGIN}/mpbp/bp/news/v4/news/publish/v2`
const SOHU_DRAFT_URL = `${SOHU_ORIGIN}/mpbp/bp/news/v4/news/draft/v2`
function stringId(value: unknown): string {
if (typeof value === 'number') {
return Number.isFinite(value) ? String(value) : ''
}
if (typeof value !== 'string') {
return ''
}
return value.trim()
}
function resolvePublishType(payload: Record<string, unknown>): SohuPublishType {
return payload.publish_type === 'draft' || payload.publishType === 'draft' ? 'draft' : 'publish'
}
function isSohuSuccess(response: SohuSubmitResponse | null | undefined): boolean {
return Boolean(response?.data) && (response?.success === true || response?.code === 2_000_000)
}
function sohuResponseMessage(
response: { msg?: string; message?: string; code?: number } | null | undefined,
): string {
return response?.msg || response?.message || `sohuhao_error_${response?.code ?? 'unknown'}`
}
function isSohuChallengeMessage(message: string): boolean {
return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控)/i.test(
message,
)
}
function generateDeviceId(): string {
return randomBytes(16).toString('hex')
}
async function cookieHeaderForUrl(
context: PublishAdapterContext,
url = SOHU_ORIGIN,
): Promise<string> {
const cookies = await context.session.cookies.get({ url }).catch(() => [])
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')
}
async function sohuSpCm(context: PublishAdapterContext): Promise<string> {
const cookieValue =
(await sessionCookieValue(context.session, 'sohu.com', 'mp-cv').catch(() => '')) ||
(await sessionCookieValue(context.session, 'mp.sohu.com', 'mp-cv').catch(() => ''))
return cookieValue || `100-${Date.now()}-${generateDeviceId()}`
}
async function sohuHeaders(
context: PublishAdapterContext,
extra: Record<string, string> = {},
): Promise<Record<string, string>> {
const cookie = await cookieHeaderForUrl(context)
return {
accept: 'application/json, text/plain, */*',
origin: SOHU_ORIGIN,
referer: `${SOHU_ORIGIN}/`,
'x-requested-with': 'XMLHttpRequest',
...(cookie ? { cookie } : {}),
...extra,
}
}
function accountFromRaw(account: SohuRawAccount | null | undefined): SohuAccount | null {
const id = stringId(account?.id)
const name = account?.nickName?.trim() || ''
return id && name ? { id, name } : null
}
async function fetchAccount(context: PublishAdapterContext): Promise<SohuAccount | null> {
const registerInfo = await sessionFetchJson<SohuRegisterInfoResponse>(
context.session,
`${SOHU_ORIGIN}/mpbp/bp/account/register-info`,
{
credentials: 'include',
headers: await sohuHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
const account = accountFromRaw(registerInfo?.data?.account)
if (account) {
return account
}
const accountListURL = new URL(`${SOHU_ORIGIN}/mpbp/bp/account/list`)
accountListURL.searchParams.set('_', String(Date.now()))
const list = await sessionFetchJson<SohuAccountListResponse>(
context.session,
accountListURL.toString(),
{
credentials: 'include',
headers: await sohuHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
for (const group of list?.data?.data ?? []) {
for (const raw of group.accounts ?? []) {
const listed = accountFromRaw(raw)
if (listed) {
return listed
}
}
}
return null
}
async function uploadImage(
context: PublishAdapterContext,
accountId: string,
sourceUrl: string,
): Promise<string | null> {
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!image) {
return null
}
const form = new FormData()
form.append('file', image.blob, image.fileName)
form.append('accountId', accountId)
const url = new URL(`${SOHU_ORIGIN}/commons/front/outerUpload/image/file`)
url.searchParams.set('accountId', accountId)
const response = await sessionFetchJson<SohuUploadResponse>(
context.session,
url.toString(),
{
method: 'POST',
credentials: 'include',
headers: await sohuHeaders(context, {
'sp-cm': await sohuSpCm(context),
}),
body: form,
},
{ signal: context.signal },
).catch(() => null)
return response?.url?.trim() || null
}
async function submitArticle(
context: PublishAdapterContext,
account: SohuAccount,
html: string,
publishType: SohuPublishType,
): Promise<string> {
const coverUrl = context.article.cover_asset_url
? await uploadImage(context, account.id, context.article.cover_asset_url).catch(() => null)
: ''
const url = new URL(publishType === 'draft' ? SOHU_DRAFT_URL : SOHU_PUBLISH_URL)
url.searchParams.set('accountId', account.id)
const body = {
title: context.article.title?.trim() || '未命名文章',
content: html,
brief: '',
channelId: 30,
categoryId: -1,
cover: coverUrl || '',
accountId: Number(account.id),
// keep infoResource: 0 不要动
infoResource: 0,
}
const response = await sessionFetchJson<SohuSubmitResponse>(
context.session,
url.toString(),
{
method: 'POST',
credentials: 'include',
headers: await sohuHeaders(context, {
'content-type': 'application/json',
'dv-id': generateDeviceId(),
'sp-cm': await sohuSpCm(context),
}),
body: JSON.stringify(body),
},
{ signal: context.signal },
).catch(
(error): SohuSubmitResponse => ({
code: -1,
msg: error instanceof Error ? error.message : 'sohuhao_submit_failed',
}),
)
if (!isSohuSuccess(response)) {
const message = sohuResponseMessage(response)
if (isSohuChallengeMessage(message)) {
throw new Error(`sohuhao_challenge_required:${message}`)
}
throw new Error(`sohuhao_submit_failed:${message}`)
}
return stringId(response.data)
}
function buildResultPayload(
articleId: string,
accountId: string,
mediaName: string,
publishType: SohuPublishType,
): Record<string, JsonValue> {
const editUrl = `${SOHU_ORIGIN}/mpfe/v4/contentManagement/news/addarticle?spm=smmp.articlelist.0.0&contentStatus=2&id=${encodeURIComponent(articleId)}`
const articleUrl =
publishType === 'publish'
? `https://www.sohu.com/a/${encodeURIComponent(articleId)}_${encodeURIComponent(accountId)}`
: null
return {
platform: 'sohuhao',
media_name: mediaName,
external_article_id: articleId,
external_manage_url: editUrl || SOHU_MANAGE_URL,
external_article_url: articleUrl,
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 === 'sohuhao_not_logged_in') {
return {
status: 'failed',
summary: '搜狐号登录态失效,无法执行发布。',
error: {
code: 'sohuhao_not_logged_in',
message,
},
}
}
if (message === 'article_content_empty') {
return {
status: 'failed',
summary: '文章内容为空,无法推送到搜狐号。',
error: {
code: 'article_content_empty',
message,
},
}
}
if (message.startsWith('sohuhao_challenge_required') || isSohuChallengeMessage(message)) {
return {
status: 'failed',
summary: '搜狐号触发平台验证,请在搜狐号后台完成验证后重试。',
error: {
code: 'sohuhao_challenge_required',
message,
},
}
}
if (message.startsWith('sohuhao_submit_failed')) {
return {
status: 'failed',
summary: '搜狐号提交失败,请稍后重试或打开搜狐号后台确认内容状态。',
error: {
code: 'sohuhao_submit_failed',
message,
},
}
}
return {
status: 'failed',
summary: '搜狐号发布失败。',
error: {
code: 'sohuhao_publish_failed',
message,
},
}
}
export const sohuhaoAdapter: PublishAdapter = {
platform: 'sohuhao',
executionMode: 'session',
async publish(context, payload) {
try {
context.reportProgress('sohuhao.detect_login')
const account = await fetchAccount(context)
if (!account) {
throw new Error('sohuhao_not_logged_in')
}
context.reportProgress('sohuhao.normalize_html')
let html = normalizeArticleHtml(context.article)
if (!html) {
throw new Error('article_content_empty')
}
const coverAssetUrl = context.article.cover_asset_url?.trim() || ''
if (coverAssetUrl && extractImageSources(html).length === 0) {
html = `<img src="${coverAssetUrl}" />${html}`
}
context.reportProgress('sohuhao.upload_content_images')
const processed = await uploadHtmlImages(
html,
async (sourceUrl) => uploadImage(context, account.id, sourceUrl),
{ signal: context.signal },
)
const publishType = resolvePublishType(payload)
context.reportProgress('sohuhao.submit')
const articleId = await submitArticle(context, account, processed.html, publishType)
return {
status: 'succeeded',
summary: publishType === 'draft' ? '搜狐号草稿保存成功。' : '搜狐号发布成功。',
payload: buildResultPayload(articleId, account.id, account.name, publishType),
}
} catch (error) {
return failureResult(error)
}
},
}