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>
390 lines
10 KiB
TypeScript
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)
|
|
}
|
|
},
|
|
}
|