import { createHash, createHmac } from 'node:crypto' import type { JsonValue } from '@geo/shared-types' import { DONGCHEDI_PLATFORM_EXCEPTION_MESSAGE } from '../../shared/publisher-errors' import type { PublishAdapter, PublishAdapterContext } from './base' import { ensureViewLoaded, extractImageSources, normalizeArticleHtml, sessionFetchJson, } from './common' import { countDongchediContentWordCount, prepareDongchediArticleHtml } from './dongchedi-content' import { cropImageAssetBlob, fetchImageAssetBlob, type ImageAssetBlob } from './media-image' type DongchediAccountInfoResponse = { data?: { user_id_str?: string name?: string avatar_url?: string } } type DongchediUploadAuthResponse = { data?: { token?: DongchediImageXToken } } type DongchediImageXToken = { AccessKeyId?: string SecretAccessKey?: string SessionToken?: string CurrentTime?: string } type DongchediImageXApplyResponse = { Result?: { UploadAddress?: { StoreInfos?: Array<{ StoreUri?: string Auth?: string }> UploadHosts?: string[] SessionKey?: string } } } type DongchediImageURLResponse = { data?: { img_url_map?: Record< string, { main_url?: string } > } } type DongchediSpiceImageResponse = { data?: { image_url?: string } } type DongchediPublishResponse = { message?: string data?: { message?: string data?: { pgc_id?: string | number } } } type DongchediAccount = { uid: string name: string } type DongchediUploadedImage = { uri: string url: string width: number height: number } type DongchediPublishSaveMode = 0 | 1 const DONGCHEDI_ORIGIN = 'https://mp.dcdapp.com' const DONGCHEDI_IMAGE_X_ORIGIN = 'https://imagex.bytedanceapi.com' const DONGCHEDI_SERVICE_ID = 'f042mdwyw7' const DONGCHEDI_ARTICLE_EDITOR_URL = `${DONGCHEDI_ORIGIN}/profile_v2/publish/article` const DONGCHEDI_MANAGE_URL = `${DONGCHEDI_ORIGIN}/profile_v2/manage/content/article` const DONGCHEDI_PUBLISH_API_URL = `${DONGCHEDI_ORIGIN}/motor/content_publish/publish_mp_article/v1` const DONGCHEDI_ARTICLE_AD_TYPE_NONE = 2 function stringId(value: unknown): string { if (typeof value === 'number') { return Number.isFinite(value) ? String(value) : '' } if (typeof value !== 'string') { return '' } return value.trim() } function dongchediResponseMessage(response: DongchediPublishResponse | null | undefined): string { return response?.data?.message || response?.message || 'dongchedi_publish_failed' } class DongchediAdapterError extends Error { readonly code: string readonly detail?: Record constructor(code: string, message: string, detail?: Record) { super(message) this.name = 'DongchediAdapterError' this.code = code this.detail = detail } } function isDongchediChallengeMessage(message: string): boolean { return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控|访问频繁|request_failed_403|\b403\b|forbidden|csrf|csrftoken)/i.test( message, ) } function isDongchediPlatformExceptionMessage(message: string): boolean { return /\bserver[\s_-]*exception\b/i.test(message) } function redactDiagnosticText(value: string, maxLength = 220): string { return value .replace( /(?:https?:\/\/|\/api\/public\/assets\/|\/api\/desktop\/content\/assets\/)[^\s"'<>)]*/gi, '[url]', ) .replace(/\s+/g, ' ') .trim() .slice(0, maxLength) } function countHtmlTag(content: string, tagName: string): number { return (content.match(new RegExp(`<${tagName}\\b`, 'gi')) ?? []).length } function summarizeImageSource(sourceUrl: string): Record { try { const parsed = new URL(sourceUrl) const pathParts = parsed.pathname.split('/').filter(Boolean) return { protocol: parsed.protocol.replace(/:$/, ''), host: parsed.hostname, path_prefix: pathParts.slice(0, 2).join('/') || '/', has_query: parsed.search.length > 0, } } catch { return { protocol: 'unknown', host: 'invalid', path_prefix: '', has_query: false, } } } function summarizeUploadedImage( image: DongchediUploadedImage | null | undefined, ): Record | null { if (!image) { return null } let urlHost = '' try { urlHost = new URL(image.url).hostname } catch { urlHost = 'invalid' } return { uri_prefix: image.uri.slice(0, 24), url_host: urlHost, width: image.width, height: image.height, } } function isDongchediHostedImageUrl(sourceUrl: string): boolean { try { const parsed = new URL(sourceUrl) return ( parsed.protocol === 'https:' && /(?:^|\.)dcdapp\.com$|(?:^|\.)byteimg\.com$|(?:^|\.)snssdk\.com$/.test(parsed.hostname) && parsed.pathname.includes(DONGCHEDI_SERVICE_ID) ) } catch { return false } } function summarizePublishBody( body: Record, options: { verticalCover?: DongchediUploadedImage | null feedCover?: DongchediUploadedImage | null } = {}, ): Record { const content = typeof body.content === 'string' ? body.content : '' const imageSources = extractImageSources(content) const extra = typeof body.extra === 'object' && body.extra !== null ? (body.extra as Record) : {} return { title_length: typeof body.title === 'string' ? body.title.length : 0, content_length: content.length, content_image_count: imageSources.length, content_image_sources: imageSources .slice(0, 5) .map((sourceUrl) => summarizeImageSource(sourceUrl)), content_table_count: (content.match(/ { return { message: response.message ?? null, data_message: response.data?.message ?? null, pgc_id: stringId(response.data?.data?.pgc_id) || null, } } async function dongchediPageFetchJson( context: PublishAdapterContext, input: string, init: { method: string headers?: Record body?: string credentials?: 'include' | 'same-origin' | 'omit' }, ): Promise { await ensureDongchediEditorContext(context) const effectiveInit = { credentials: 'include' as const, ...init, } const page = context.playwright?.page if (page && !page.isClosed()) { const envelope = await page.evaluate( async ({ fetchInput, fetchInit }) => { try { const response = await fetch(fetchInput, fetchInit) const text = await response.text().catch(() => '') return { ok: response.ok, status: response.status, body: text } } catch (error) { return { ok: false, error: String((error && (error as Error).message) || error) } } }, { fetchInput: input, fetchInit: effectiveInit, }, ) return parseDongchediFetchEnvelope(envelope) } const webContents = context.view?.webContents if (!webContents || webContents.isDestroyed()) { throw new Error('dongchedi_page_context_missing') } const script = `(async () => { try { const init = ${JSON.stringify(effectiveInit)}; const response = await fetch(${JSON.stringify(input)}, init); const text = await response.text().catch(() => ""); return JSON.stringify({ ok: response.ok, status: response.status, body: text }); } catch (error) { return JSON.stringify({ ok: false, error: String(error && error.message || error) }); } })()` const serialized = await webContents.executeJavaScript(script, true) if (typeof serialized !== 'string' || !serialized) { throw new Error('dongchedi_page_fetch_failed') } let envelope: { ok: boolean; status?: number; body?: string; error?: string } try { envelope = JSON.parse(serialized) as typeof envelope } catch { throw new Error('dongchedi_page_fetch_failed') } return parseDongchediFetchEnvelope(envelope) } async function ensureDongchediEditorContext(context: PublishAdapterContext): Promise { if (context.signal?.aborted) { throw new Error('adapter_aborted') } const page = context.playwright?.page if (page && !page.isClosed()) { if (page.url() !== DONGCHEDI_ARTICLE_EDITOR_URL) { await page.goto(DONGCHEDI_ARTICLE_EDITOR_URL, { waitUntil: 'domcontentloaded', }) } return } if (context.view && !context.view.webContents.isDestroyed()) { await ensureViewLoaded(context.view, DONGCHEDI_ARTICLE_EDITOR_URL, context.signal) return } throw new Error('dongchedi_page_context_missing') } function parseDongchediFetchEnvelope(envelope: { ok: boolean status?: number body?: string error?: string }): T { if (!envelope.ok) { const suffix = envelope.status ? `request_failed_${envelope.status}` : 'dongchedi_page_fetch_failed' const detail = envelope.body?.trim() || envelope.error?.trim() || '' throw new Error(detail ? `${suffix}:${detail.slice(0, 300)}` : suffix) } const text = envelope.body?.trim() || '' if (!text) { return {} as T } return JSON.parse(text) as T } async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise { const page = context.playwright?.page if (page && !page.isClosed()) { const token = await page.evaluate(async (url) => { try { const response = await fetch(url, { method: 'HEAD', credentials: 'include', headers: { 'x-secsdk-csrf-request': '1', 'x-secsdk-csrf-version': '1.2.10', }, }) return response.headers.get('x-ware-csrf-token') || '' } catch { return '' } }, `${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`) const parts = token.split(',') return parts.length >= 2 && parts[1] ? parts[1] : null } const webContents = context.view?.webContents if (!webContents || webContents.isDestroyed()) { return null } const script = `(async () => { try { const response = await fetch(${JSON.stringify(`${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`)}, { method: "HEAD", credentials: "include", headers: { "x-secsdk-csrf-request": "1", "x-secsdk-csrf-version": "1.2.10" } }); return JSON.stringify({ token: response.headers.get("x-ware-csrf-token") || "" }); } catch { return JSON.stringify({ token: "" }); } })()` const serialized = await webContents.executeJavaScript(script, true).catch(() => '') if (typeof serialized !== 'string' || !serialized) { return null } try { const parsed = JSON.parse(serialized) as { token?: unknown } const token = typeof parsed.token === 'string' ? parsed.token : '' const parts = token.split(',') return parts.length >= 2 && parts[1] ? parts[1] : null } catch { return null } } async function imagexFetchText( context: PublishAdapterContext, input: string, init?: RequestInit, ): Promise { const response = await context.session.fetch(input, init) const text = await response.text() if (!response.ok) { throw new Error(text || `request_failed_${response.status}`) } return text } async function imagexFetchJson( context: PublishAdapterContext, input: string, init?: RequestInit, ): Promise { const text = await imagexFetchText(context, input, init) if (!text) { return {} as T } return JSON.parse(text) as T } async function fetchAccount(context: PublishAdapterContext): Promise { const url = new URL(`${DONGCHEDI_ORIGIN}/passport/account/info/v2/`) url.searchParams.set('aid', '2302') url.searchParams.set('account_sdk_source', 'web') const response = await sessionFetchJson( context.session, url.toString(), { credentials: 'include', }, ).catch(() => null) const user = response?.data return user?.user_id_str && user.name ? { uid: user.user_id_str, name: user.name } : null } function sha256Hex(value: string): string { return createHash('sha256').update(value).digest('hex') } function hmacSha256(key: Buffer | string, value: string): Buffer { return createHmac('sha256', key).update(value).digest() } function formatAmzDate(date: Date): string { return date.toISOString().replace(/[:-]|\.\d{3}/g, '') } function formatDateStamp(date: Date): string { return date.toISOString().slice(0, 10).replace(/-/g, '') } function signAWS4(input: { method: string url: string accessKeyId: string secretAccessKey: string securityToken?: string region?: string service?: string headers?: Record body?: string date?: Date }): Record { const region = input.region ?? 'cn-north-1' const service = input.service ?? 'imagex' const body = input.body ?? '' const url = new URL(input.url) const now = input.date ?? new Date() const amzDate = formatAmzDate(now) const dateStamp = formatDateStamp(now) const query = [...url.searchParams.entries()] .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&') const signedHeaders: Record = { 'x-amz-date': amzDate, ...(input.securityToken ? { 'x-amz-security-token': input.securityToken } : {}), ...(input.headers ?? {}), } const signedHeaderNames = Object.keys(signedHeaders) .map((key) => key.toLowerCase()) .sort() .join(';') const canonicalHeaders = Object.entries(signedHeaders) .map(([key, value]) => `${key.toLowerCase()}:${value.trim()}`) .sort() .join('\n') + '\n' const canonicalRequest = [ input.method.toUpperCase(), url.pathname || '/', query, canonicalHeaders, signedHeaderNames, sha256Hex(body), ].join('\n') const credentialScope = `${dateStamp}/${region}/${service}/aws4_request` const stringToSign = [ 'AWS4-HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest), ].join('\n') const kDate = hmacSha256(`AWS4${input.secretAccessKey}`, dateStamp) const kRegion = hmacSha256(kDate, region) const kService = hmacSha256(kRegion, service) const kSigning = hmacSha256(kService, 'aws4_request') const signature = createHmac('sha256', kSigning).update(stringToSign).digest('hex') return { authorization: `AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaderNames}, Signature=${signature}`, 'x-amz-date': amzDate, ...(input.securityToken ? { 'x-amz-security-token': input.securityToken } : {}), } } function crc32(data: Uint8Array): string { let crc = 0xffffffff const table = crc32Table() for (const byte of data) { crc = (crc >>> 8) ^ table[(crc ^ byte) & 0xff] } return ((crc ^ 0xffffffff) >>> 0).toString(16).padStart(8, '0') } let cachedCrc32Table: Uint32Array | null = null function crc32Table(): Uint32Array { if (cachedCrc32Table) { return cachedCrc32Table } const table = new Uint32Array(256) for (let index = 0; index < 256; index += 1) { let value = index for (let bit = 0; bit < 8; bit += 1) { value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1 } table[index] = value } cachedCrc32Table = table return table } async function fetchUploadToken( context: PublishAdapterContext, ): Promise> { const response = await sessionFetchJson( context.session, `${DONGCHEDI_ORIGIN}/motor/car_page/v6/img/get_upload_auth`, { credentials: 'include', }, ).catch(() => null) const token = response?.data?.token if (!token?.AccessKeyId || !token.SecretAccessKey || !token.SessionToken) { throw new Error('dongchedi_image_token_failed') } return { AccessKeyId: token.AccessKeyId, SecretAccessKey: token.SecretAccessKey, SessionToken: token.SessionToken, CurrentTime: token.CurrentTime || new Date().toISOString(), } } async function prepareImage( source: ImageAssetBlob, ratio?: number, ): Promise { if (!ratio) { return source } return await cropImageAssetBlob(source, ratio) } async function uploadImage( context: PublishAdapterContext, sourceUrl: string, ratio?: number, ): Promise { const source = await fetchImageAssetBlob(sourceUrl) if (!source) { throw new Error(ratio ? 'dongchedi_cover_fetch_failed' : 'dongchedi_image_fetch_failed') } const image = await prepareImage(source, ratio) if (!image) { throw new Error(ratio ? 'dongchedi_cover_fetch_failed' : 'dongchedi_image_fetch_failed') } const token = await fetchUploadToken(context) const nonce = Math.random().toString(36).slice(2) const applyUrl = new URL(`${DONGCHEDI_IMAGE_X_ORIGIN}/`) applyUrl.searchParams.set('Action', 'ApplyImageUpload') applyUrl.searchParams.set('Version', '2018-08-01') applyUrl.searchParams.set('ServiceId', DONGCHEDI_SERVICE_ID) applyUrl.searchParams.set('s', nonce) const signedApplyHeaders = signAWS4({ method: 'GET', url: applyUrl.toString(), accessKeyId: token.AccessKeyId, secretAccessKey: token.SecretAccessKey, securityToken: token.SessionToken, date: new Date(token.CurrentTime), }) const applyResponse = await imagexFetchJson( context, applyUrl.toString(), { headers: signedApplyHeaders, }, ).catch((error) => { throw new Error( error instanceof Error ? `dongchedi_image_apply_upload_failed:${error.message}` : 'dongchedi_image_apply_upload_failed', ) }) const uploadAddress = applyResponse.Result?.UploadAddress const storeInfo = uploadAddress?.StoreInfos?.[0] const uploadHost = uploadAddress?.UploadHosts?.[0] const storeUri = storeInfo?.StoreUri?.trim() || '' if (!storeUri || !storeInfo?.Auth || !uploadHost || !uploadAddress?.SessionKey) { throw new Error('dongchedi_image_apply_upload_failed') } const buffer = await image.blob.arrayBuffer() const putHeaders = new Headers({ 'content-disposition': 'attachment; filename="undefined"', 'content-type': 'application/octet-stream', 'content-crc32': crc32(new Uint8Array(buffer)), authorization: storeInfo.Auth, }) const putResponse = await context.session.fetch(`https://${uploadHost}/${storeUri}`, { method: 'PUT', headers: putHeaders, body: image.blob, }) if (!putResponse.ok) { const text = await putResponse.text().catch(() => '') throw new Error(text || `dongchedi_image_tos_upload_failed_${putResponse.status}`) } const commitUrl = new URL(DONGCHEDI_IMAGE_X_ORIGIN) commitUrl.searchParams.set('Action', 'CommitImageUpload') commitUrl.searchParams.set('Version', '2018-08-01') commitUrl.searchParams.set('SessionKey', uploadAddress.SessionKey) commitUrl.searchParams.set('ServiceId', DONGCHEDI_SERVICE_ID) const commitSignatureDate = new Date(token.CurrentTime) const commitHeaderDate = new Date() const signedCommitHeaders = signAWS4({ method: 'POST', url: commitUrl.toString(), accessKeyId: token.AccessKeyId, secretAccessKey: token.SecretAccessKey, securityToken: token.SessionToken, date: commitSignatureDate, }) await imagexFetchJson>(context, commitUrl.toString(), { method: 'POST', headers: { authorization: signedCommitHeaders.authorization, 'x-amz-date': formatAmzDate(commitHeaderDate), 'x-amz-security-token': token.SessionToken, }, }).catch((error) => { throw new Error( error instanceof Error ? `dongchedi_image_commit_upload_failed:${error.message}` : 'dongchedi_image_commit_upload_failed', ) }) const imageUrlResponse = await sessionFetchJson( context.session, `${DONGCHEDI_ORIGIN}/motor/car_page/v6/img/get_url`, { method: 'POST', credentials: 'include', body: `img_uris=${storeUri}&img_url_type=2&img_param=noop&img_format=image`, }, ).catch(() => null) const imageUrl = imageUrlResponse?.data?.img_url_map?.[storeUri]?.main_url?.trim() || '' if (!imageUrl) { throw new Error('dongchedi_image_url_failed') } return { uri: storeUri, url: imageUrl, width: image.width, height: image.height, } } async function spiceImage( context: PublishAdapterContext, imageUrl: string, ): Promise { if (!imageUrl.trim()) { return null } const response = await sessionFetchJson( context.session, `${DONGCHEDI_ORIGIN}/spice/image?sk=dcd`, { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', }, body: new URLSearchParams({ imageUrl, }), }, ).catch(() => null) return response?.data ?? null } async function uploadContentImage( context: PublishAdapterContext, sourceUrl: string, ): Promise { if (isDongchediHostedImageUrl(sourceUrl)) { return sourceUrl } const spiced = await spiceImage(context, sourceUrl) if (spiced?.image_url) { return spiced.image_url } const uploaded = await uploadImage(context, sourceUrl).catch(() => null) return uploaded?.url ?? null } async function processDongchediContentImages( context: PublishAdapterContext, html: string, imageSources: string[], ): Promise { if (!imageSources.length) { return html } const uploaded = new Map() await Promise.all( [...new Set(imageSources)].map(async (sourceUrl) => { const targetUrl = await uploadContentImage(context, sourceUrl) if (targetUrl) { uploaded.set(sourceUrl, targetUrl) } return targetUrl }), ) let next = html imageSources.forEach((sourceUrl) => { const targetUrl = uploaded.get(sourceUrl) if (targetUrl) { next = next.split(sourceUrl).join(targetUrl) } }) const remainingSources = extractImageSources(next).filter( (sourceUrl) => !isDongchediHostedImageUrl(sourceUrl), ) if (remainingSources.length > 0) { throw new Error( `dongchedi_image_upload_failed:content_images_missing_${remainingSources.length}_of_${imageSources.length}`, ) } return next } function buildPublishBody( title: string, content: string, verticalCover: DongchediUploadedImage, feedCover: DongchediUploadedImage, options: { save: DongchediPublishSaveMode pgcId?: string }, ): Record { const body: Record = { extra: { timer_status: 0, timer_time: '', article_ad_type: DONGCHEDI_ARTICLE_AD_TYPE_NONE, content_word_cnt: countDongchediContentWordCount(content), title_id: '', vertical_cover_image: JSON.stringify({ uri: verticalCover.uri, height: verticalCover.height, width: verticalCover.width, is_ai_cover: false, }), pgc_feed_covers: [ { url: feedCover.url, uri: feedCover.uri, thumb_width: feedCover.width, thumb_height: feedCover.height, }, ], }, save: options.save, title, content, source: 20, } if (options.pgcId) { body.pgc_id = options.pgcId } return body } async function submitDongchediArticle( context: PublishAdapterContext, body: Record, ): Promise { const serializedBody = JSON.stringify(body) await ensureDongchediEditorContext(context) const csrfToken = await fetchDongchediCsrfToken(context).catch(() => null) return await dongchediPageFetchJson( context, DONGCHEDI_PUBLISH_API_URL, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', ...(csrfToken ? { 'x-secsdk-csrf-token': csrfToken } : {}), }, body: serializedBody, }, ) } function buildResultPayload(articleId: string, mediaName: string): Record { return { platform: 'dongchedi', media_name: mediaName, external_article_id: articleId, external_manage_url: DONGCHEDI_MANAGE_URL, external_article_url: `https://www.dongchedi.com/article/${encodeURIComponent(articleId)}`, publish_type: 'publish', } } function failureResult( error: unknown, ): ReturnType extends Promise ? T : never { const message = error instanceof Error ? error.message : String(error) if (message === 'dongchedi_not_logged_in') { return { status: 'failed', summary: '懂车帝登录态失效,无法执行发布。', error: { code: 'dongchedi_not_logged_in', message, }, } } if (message === 'publish_cover_required') { const userMessage = '懂车帝发布必须上传封面图。' return { status: 'failed', summary: userMessage, error: { code: 'publish_cover_required', message: userMessage, }, } } if (message === 'article_content_empty') { return { status: 'failed', summary: '文章内容为空,无法推送到懂车帝。', error: { code: 'article_content_empty', message, }, } } if ( message === 'dongchedi_cover_fetch_failed' || message.startsWith('dongchedi_cover_fetch_failed:') ) { return { status: 'failed', summary: '懂车帝封面文件读取失败,请重新上传封面图后重试。', error: { code: 'dongchedi_cover_fetch_failed', message, }, } } if ( message === 'dongchedi_image_fetch_failed' || message.startsWith('dongchedi_image_fetch_failed:') ) { return { status: 'failed', summary: '懂车帝正文图片读取失败,请检查文章图片后重试。', error: { code: 'dongchedi_image_fetch_failed', message, }, } } if (message.startsWith('dongchedi_cover_upload_failed')) { return { status: 'failed', summary: '懂车帝封面上传失败,请稍后重试或更换封面图。', error: { code: 'dongchedi_cover_upload_failed', message, }, } } if ( message.startsWith('dongchedi_image_token_failed') || message.startsWith('dongchedi_image_apply_upload_failed') || message.startsWith('dongchedi_image_tos_upload_failed') || message.startsWith('dongchedi_image_commit_upload_failed') || message.startsWith('dongchedi_image_url_failed') || message.startsWith('dongchedi_image_upload_failed') ) { const isContentImageFailure = message.startsWith( 'dongchedi_image_upload_failed:content_images_missing', ) return { status: 'failed', summary: isContentImageFailure ? '懂车帝正文图片上传失败,请检查文章图片后重试。' : '懂车帝图片上传失败,请稍后重试或更换图片。', error: { code: 'dongchedi_image_upload_failed', message: isContentImageFailure ? '懂车帝正文图片上传失败,存在未完成上传替换的图片。' : message, ...(isContentImageFailure ? { detail: message } : {}), }, } } if (message.startsWith('dongchedi_challenge_required') || isDongchediChallengeMessage(message)) { return { status: 'failed', summary: '懂车帝触发平台验证,请在懂车帝后台完成验证后重试。', error: { code: 'dongchedi_challenge_required', message, }, } } if ( (error instanceof DongchediAdapterError && error.code === 'dongchedi_platform_exception') || isDongchediPlatformExceptionMessage(message) ) { return { status: 'failed', summary: DONGCHEDI_PLATFORM_EXCEPTION_MESSAGE, error: { code: 'dongchedi_platform_exception', message: DONGCHEDI_PLATFORM_EXCEPTION_MESSAGE, detail: error instanceof DongchediAdapterError && error.detail ? error.detail : { platform_message: message, }, }, } } return { status: 'failed', summary: '懂车帝发布失败。', error: { code: error instanceof DongchediAdapterError ? error.code : 'dongchedi_publish_failed', message, ...(error instanceof DongchediAdapterError && error.detail ? { detail: error.detail } : {}), }, } } export const dongchediAdapter: PublishAdapter = { platform: 'dongchedi', executionMode: 'playwright', async publish(context) { try { context.reportProgress('dongchedi.detect_login') const account = await fetchAccount(context) if (!account) { throw new Error('dongchedi_not_logged_in') } const coverAssetUrl = context.article.cover_asset_url?.trim() || '' if (!coverAssetUrl) { throw new Error('publish_cover_required') } const title = context.article.title?.trim() || '未命名文章' const html = prepareDongchediArticleHtml(normalizeArticleHtml(context.article), title) if (!html) { throw new Error('article_content_empty') } context.reportProgress('dongchedi.upload_cover') const verticalCover = await uploadImage(context, coverAssetUrl, 3 / 4) const feedCover = await uploadImage(context, coverAssetUrl, 4 / 3) if (!verticalCover || !feedCover) { throw new Error('dongchedi_cover_upload_failed') } context.reportProgress('dongchedi.upload_content_images') const contentImageSources = extractImageSources(html) const content = await processDongchediContentImages(context, html, contentImageSources) context.reportProgress('dongchedi.save_draft') const draftBody = buildPublishBody(title, content, verticalCover, feedCover, { save: 0, }) const draftResponse = await submitDongchediArticle(context, draftBody).catch( (error): DongchediPublishResponse => ({ message: error instanceof Error ? error.message : 'dongchedi_publish_failed', }), ) const draftArticleId = stringId(draftResponse.data?.data?.pgc_id) if (!draftArticleId) { const message = dongchediResponseMessage(draftResponse) if (isDongchediChallengeMessage(message)) { throw new Error(`dongchedi_challenge_required:${message}`) } const detail = { response: summarizePublishResponse(draftResponse), payload: summarizePublishBody(draftBody, { verticalCover, feedCover, }), } console.warn('[dongchedi] draft rejected', { taskId: context.taskId, ...detail, }) if (isDongchediPlatformExceptionMessage(message)) { throw new DongchediAdapterError('dongchedi_platform_exception', message, detail) } throw new DongchediAdapterError( 'dongchedi_publish_failed', message || 'dongchedi_draft_failed', detail, ) } context.reportProgress('dongchedi.submit') const publishBody = buildPublishBody(title, content, verticalCover, feedCover, { save: 1, pgcId: draftArticleId, }) const response = await submitDongchediArticle(context, publishBody).catch( (error): DongchediPublishResponse => ({ message: error instanceof Error ? error.message : 'dongchedi_publish_failed', }), ) const articleId = stringId(response.data?.data?.pgc_id) if (!articleId) { const message = dongchediResponseMessage(response) if (isDongchediChallengeMessage(message)) { throw new Error(`dongchedi_challenge_required:${message}`) } const detail = { response: summarizePublishResponse(response), payload: summarizePublishBody(publishBody, { verticalCover, feedCover, }), } console.warn('[dongchedi] publish rejected', { taskId: context.taskId, ...detail, }) if (isDongchediPlatformExceptionMessage(message)) { throw new DongchediAdapterError('dongchedi_platform_exception', message, detail) } throw new DongchediAdapterError( 'dongchedi_publish_failed', message || 'dongchedi_publish_failed', detail, ) } return { status: 'succeeded', payload: buildResultPayload(articleId, account.name), summary: '懂车帝发布成功。', } } catch (error) { return failureResult(error) } }, }