import { Buffer } from 'node:buffer' import type { JsonValue } from '@geo/shared-types' import { nativeImage } from 'electron' import { prepareBaijiahaoArticleHtml, prepareBaijiahaoMarkdown, } from '../../../../../packages/publisher-platforms/src/baijiahao' import { PUBLISH_IMAGE_FETCH_FAILED_MESSAGE } from '../../shared/publisher-errors' import { resolveDesktopApiURL } from '../transport/api-client' import type { PublishAdapter, PublishAdapterContext } from './base' import { normalizeArticleHtml, sessionFetchJson, sessionFetchText, uploadHtmlImages, } from './common' type BaijiahaoAppInfoResponse = { data?: { user?: { userid?: string | number name?: string uname?: string username?: string avatar?: string app_id?: string | number } } } type BaijiahaoPublishResponse = { errno?: number errmsg?: string ret?: { id?: string | number } } type BaijiahaoUploadResponse = { errno?: number errmsg?: string err_msg?: string ret?: { https_url?: string url?: string } } type BaijiahaoCutResponse = { data?: { new_url?: string } } type BaijiahaoUser = NonNullable['user']> type BaijiahaoImageBlob = { blob: Blob fileName: string } const BAIJIAHAO_SUPPORTED_IMAGE_TYPES = new Set([ 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', ]) const BAIJIAHAO_ORIGIN = 'https://baijiahao.baidu.com' const BAIJIAHAO_EDIT_URL = `${BAIJIAHAO_ORIGIN}/builder/rc/edit?type=news` const BAIJIAHAO_STEP_DELAY_MS = 1400 const BAIJIAHAO_RISK_CONTROL_PROMPT = '您触发平台风控,请手动发稿一篇并完成验证,方可继续使用' function normalizeUserId(value: unknown): string { if (value === null || value === undefined) { return '' } return String(value).trim() } function displayNameFromUser(user: BaijiahaoUser): string { return user.name?.trim() || user.uname?.trim() || user.username?.trim() || '' } function baijiahaoHeaders(headers?: HeadersInit): Headers { const next = new Headers(headers) if (!next.has('origin')) { next.set('origin', BAIJIAHAO_ORIGIN) } if (!next.has('referer')) { next.set('referer', BAIJIAHAO_EDIT_URL) } return next } function waitForHumanPace(signal?: AbortSignal): Promise { if (signal?.aborted) { return Promise.reject(new Error('adapter_aborted')) } return new Promise((resolve, reject) => { const cleanup = () => { signal?.removeEventListener('abort', onAbort) } const timeout = setTimeout(() => { cleanup() resolve() }, BAIJIAHAO_STEP_DELAY_MS) const onAbort = () => { clearTimeout(timeout) cleanup() reject(new Error('adapter_aborted')) } signal?.addEventListener('abort', onAbort, { once: true }) }) } function baijiahaoResponseMessage( response: | { errno?: number errmsg?: string err_msg?: string message?: string } | null | undefined, ): string { return ( response?.errmsg || response?.err_msg || response?.message || `errno_${response?.errno ?? 'unknown'}` ) } function isBaijiahaoChallengeMessage(message: string): boolean { return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required)/i.test( message, ) } async function fetchAppInfo(context: PublishAdapterContext): Promise { const response = await sessionFetchJson( context.session, 'https://baijiahao.baidu.com/builder/app/appinfo', { credentials: 'include', headers: baijiahaoHeaders({ accept: 'application/json, text/plain, */*', }), }, ).catch(() => null) return response?.data?.user ?? null } async function getPublishToken(context: PublishAdapterContext): Promise { const html = await sessionFetchText(context.session, BAIJIAHAO_EDIT_URL, { credentials: 'include', headers: baijiahaoHeaders({ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', }), }) return /window\.__BJH__INIT__AUTH__\s*=\s*(["'])(.*?)\1/.exec(html)?.[2] ?? '' } async function uploadImage( context: PublishAdapterContext, sourceUrl: string, appId: string, cropCover: boolean, ): Promise { const image = await fetchBaijiahaoImageBlob(sourceUrl) if (!image) { throw new Error(cropCover ? 'baijiahao_cover_fetch_failed' : 'baijiahao_image_fetch_failed') } const form = new FormData() form.append('media', image.blob, 'cover.png') form.append('org_file_name', 'cover.png') form.append('type', 'image') form.append('app_id', appId) form.append('is_waterlog', '0') form.append('save_material', '1') form.append('no_compress', '0') form.append('article_type', 'news') const uploaded = await sessionFetchJson( context.session, `${BAIJIAHAO_ORIGIN}/materialui/picture/uploadProxy`, { method: 'POST', credentials: 'include', headers: baijiahaoHeaders(), body: form, }, ).catch((error) => { throw new Error( error instanceof Error ? `baijiahao_upload_proxy_request_failed:${error.message}` : 'baijiahao_upload_proxy_request_failed', ) }) const uploadedUrl = uploaded?.ret?.https_url ?? uploaded?.ret?.url ?? null if (!uploadedUrl) { const message = baijiahaoResponseMessage(uploaded) if (isBaijiahaoChallengeMessage(message)) { throw new Error(`baijiahao_challenge_required:${message}`) } throw new Error(`baijiahao_upload_proxy_failed:${message}`) } if (!cropCover) { return uploadedUrl } const cut = await sessionFetchJson( context.session, `${BAIJIAHAO_ORIGIN}/materialui/picture/auto_cutting`, { method: 'POST', credentials: 'include', headers: baijiahaoHeaders({ 'content-type': 'application/x-www-form-urlencoded', }), body: new URLSearchParams({ org_url: uploadedUrl, type: 'news', cutting_type: 'cover_image', }), }, ).catch((error) => { throw new Error( error instanceof Error ? `baijiahao_cover_cut_failed:${error.message}` : 'baijiahao_cover_cut_failed', ) }) return cut?.data?.new_url ?? uploadedUrl } function buildImageURLCandidates(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 fetchBaijiahaoImageBlob(sourceUrl: string): Promise { for (const candidate of buildImageURLCandidates(sourceUrl)) { const response = await fetch(candidate).catch(() => null) if (!response?.ok) { continue } const sourceBlob = await response.blob().catch(() => null) if (!sourceBlob || !sourceBlob.type.startsWith('image/')) { continue } return normalizeBaijiahaoImageBlob(sourceBlob) } return null } async function normalizeBaijiahaoImageBlob(sourceBlob: Blob): Promise { const sourceType = sourceBlob.type.toLowerCase() if (BAIJIAHAO_SUPPORTED_IMAGE_TYPES.has(sourceType)) { return { blob: sourceBlob, fileName: sourceType === 'image/gif' ? 'image.gif' : sourceType === 'image/jpeg' || sourceType === 'image/jpg' ? 'image.jpg' : 'image.png', } } const buffer = Buffer.from(await sourceBlob.arrayBuffer()) const image = nativeImage.createFromBuffer(buffer) if (image.isEmpty()) { return null } const png = image.toPNG() return { blob: new Blob([new Uint8Array(png)], { type: 'image/png' }), fileName: 'image.png', } } async function processContentImages( context: PublishAdapterContext, html: string, appId: string, ): Promise { const processed = await uploadHtmlImages(html, async (sourceUrl) => { await waitForHumanPace(context.signal) return await uploadImage(context, sourceUrl, appId, false) }) return processed.html } function buildActivityList(): string { return JSON.stringify([ { id: 'ttv', is_checked: 0 }, { id: 'ai_tts', is_checked: 0 }, { id: 'aigc_bjh_status', is_checked: 0 }, { id: 'reward', is_checked: 0 }, ]) } function buildCoverImages(coverUrl: string): string { return JSON.stringify([ { src: coverUrl, cropData: {}, machine_chooseimg: 0, isLegal: 0, cover_source_tag: 'local', }, ]) } function baijiahaoManageUrl(articleId: string): string { return `${BAIJIAHAO_EDIT_URL}&article_id=${encodeURIComponent(articleId)}` } function baijiahaoPublicUrl(articleId: string): string { return `${BAIJIAHAO_ORIGIN}/s?id=${encodeURIComponent(articleId)}` } function buildResultPayload( articleId: string, mediaName: string, externalManageUrl: string, externalArticleUrl: string, ): Record { return { platform: 'baijiahao', media_name: mediaName, external_article_id: articleId, external_manage_url: externalManageUrl, external_article_url: externalArticleUrl, publish_type: 'publish', } } function failureResult( error: unknown, ): ReturnType extends Promise ? T : never { const message = error instanceof Error ? error.message : String(error) if (message === 'baijiahao_not_logged_in') { return { status: 'failed', summary: '百家号登录态失效,无法执行发布。', error: { code: 'baijiahao_not_logged_in', message, }, } } if (message === 'baijiahao_token_missing') { return { status: 'failed', summary: '百家号发布 Token 获取失败,请重新打开百家号后台确认登录态。', error: { code: 'baijiahao_token_missing', message, }, } } if (message === 'publish_cover_required') { return { status: 'failed', summary: '百家号发布必须上传封面图。', error: { code: 'publish_cover_required', message, }, } } if (message === 'baijiahao_cover_upload_failed') { return { status: 'failed', summary: '百家号封面上传失败,请更换封面图后重试。', error: { code: 'baijiahao_cover_upload_failed', message, }, } } if ( message === 'baijiahao_cover_fetch_failed' || message.startsWith('baijiahao_cover_fetch_failed:') ) { return { status: 'failed', summary: PUBLISH_IMAGE_FETCH_FAILED_MESSAGE, error: { code: 'baijiahao_cover_fetch_failed', message, }, } } if ( message.startsWith('baijiahao_upload_proxy_failed') || message.startsWith('baijiahao_upload_proxy_request_failed') ) { return { status: 'failed', summary: '百家号素材上传接口拒绝了封面图。', error: { code: 'baijiahao_upload_proxy_failed', message, }, } } if (message.startsWith('baijiahao_cover_cut_failed')) { return { status: 'failed', summary: '百家号封面裁剪接口处理失败。', error: { code: 'baijiahao_cover_cut_failed', message, }, } } if (message.startsWith('baijiahao_challenge_required')) { return { status: 'failed', summary: BAIJIAHAO_RISK_CONTROL_PROMPT, error: { code: 'baijiahao_challenge_required', message, }, } } if (message === 'article_content_empty') { return { status: 'failed', summary: '文章内容为空,无法推送到百家号。', error: { code: 'article_content_empty', message, }, } } return { status: 'failed', summary: '百家号发布失败。', error: { code: 'baijiahao_publish_failed', message, }, } } export const baijiahaoAdapter: PublishAdapter = { platform: 'baijiahao', executionMode: 'session', async publish(context) { try { context.reportProgress('baijiahao.detect_login') const user = await fetchAppInfo(context) const platformUid = normalizeUserId(user?.userid) const mediaName = user ? displayNameFromUser(user) : '' const appId = normalizeUserId(user?.app_id) if (!platformUid || !mediaName || !appId) { throw new Error('baijiahao_not_logged_in') } context.reportProgress('baijiahao.get_token') await waitForHumanPace(context.signal) const token = await getPublishToken(context) if (!token) { throw new Error('baijiahao_token_missing') } const coverAssetUrl = context.article.cover_asset_url?.trim() || '' if (!coverAssetUrl) { throw new Error('publish_cover_required') } const html = prepareBaijiahaoArticleHtml( normalizeArticleHtml(context.article, { prepareMarkdown: prepareBaijiahaoMarkdown, }), ) if (!html) { throw new Error('article_content_empty') } context.reportProgress('baijiahao.upload_content_images') await waitForHumanPace(context.signal) const content = await processContentImages(context, html, appId) context.reportProgress('baijiahao.upload_cover') await waitForHumanPace(context.signal) const coverUrl = await uploadImage(context, coverAssetUrl, appId, true) if (!coverUrl) { throw new Error('baijiahao_cover_upload_failed') } context.reportProgress('baijiahao.submit') await waitForHumanPace(context.signal) const response: BaijiahaoPublishResponse = await sessionFetchJson( context.session, `${BAIJIAHAO_ORIGIN}/pcui/article/publish`, { method: 'POST', credentials: 'include', headers: baijiahaoHeaders({ 'content-type': 'application/x-www-form-urlencoded', Token: token, }), body: new URLSearchParams({ type: 'news', title: context.article.title?.trim() || '未命名文章', content, abstract_from: '1', cover_layout: 'one', cover_images: buildCoverImages(coverUrl), activity_list: buildActivityList(), }), }, ).catch( (requestError): BaijiahaoPublishResponse => ({ errno: -1, errmsg: requestError instanceof Error ? requestError.message : 'baijiahao_publish_failed', }), ) const articleId = response.ret?.id != null ? String(response.ret.id) : '' if (response.errno !== 0 || !articleId) { const message = baijiahaoResponseMessage(response) if (isBaijiahaoChallengeMessage(message)) { throw new Error(`baijiahao_challenge_required:${message}`) } throw new Error(message || 'baijiahao_publish_failed') } const manageUrl = baijiahaoManageUrl(articleId) const articleUrl = baijiahaoPublicUrl(articleId) return { status: 'succeeded', payload: buildResultPayload(articleId, mediaName, manageUrl, articleUrl), summary: '百家号发布成功。', } } catch (error) { return failureResult(error) } }, }