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): 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 { const cookies = await context.session.cookies.get({ url }).catch(() => []) return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ') } async function sohuSpCm(context: PublishAdapterContext): Promise { 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 = {}, ): Promise> { 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 { const registerInfo = await sessionFetchJson( 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( 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 { 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( 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 { 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( 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 { 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 extends Promise ? 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 = `${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) } }, }