Files
geo/apps/desktop-client/src/main/adapters/dongchedi.ts
T
root 6fa9a6c052 feat(dongchedi): translate platform "server exception" into actionable copy
Map dongchedi adapter failures (raw "server exception" or
dongchedi_platform_exception) to a Chinese prompt steering the user to
the dongchedi backend, so renderer/admin views and desktop-task error
payloads no longer surface the opaque platform message.
2026-05-11 12:26:38 +08:00

1166 lines
34 KiB
TypeScript

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<string, JsonValue>
constructor(code: string, message: string, detail?: Record<string, JsonValue>) {
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<string, JsonValue> {
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<string, JsonValue> | 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<string, unknown>,
options: {
verticalCover?: DongchediUploadedImage | null
feedCover?: DongchediUploadedImage | null
} = {},
): Record<string, JsonValue> {
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<string, unknown>)
: {}
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(/<table\b/gi) ?? []).length,
content_tag_counts: {
p: countHtmlTag(content, 'p'),
strong: countHtmlTag(content, 'strong'),
img: countHtmlTag(content, 'img'),
h: (content.match(/<h[1-6]\b/gi) ?? []).length,
a: countHtmlTag(content, 'a'),
span: countHtmlTag(content, 'span'),
div: countHtmlTag(content, 'div'),
ul: countHtmlTag(content, 'ul'),
ol: countHtmlTag(content, 'ol'),
li: countHtmlTag(content, 'li'),
table: countHtmlTag(content, 'table'),
},
content_entity_counts: {
nbsp: (content.match(/&nbsp;/gi) ?? []).length,
amp: (content.match(/&amp;/gi) ?? []).length,
},
content_has_local_asset_url:
/(?:localhost|127\.0\.0\.1|\/api\/public\/assets\/|\/api\/desktop\/content\/assets\/)/i.test(
content,
),
content_start: redactDiagnosticText(content),
content_end: redactDiagnosticText(content.slice(-500)),
vertical_cover: summarizeUploadedImage(options.verticalCover),
feed_cover: summarizeUploadedImage(options.feedCover),
article_ad_type: typeof extra.article_ad_type === 'number' ? extra.article_ad_type : null,
content_word_cnt: typeof extra.content_word_cnt === 'number' ? extra.content_word_cnt : null,
pgc_id: stringId(body.pgc_id) || null,
save: typeof body.save === 'number' ? body.save : null,
source: typeof body.source === 'number' ? body.source : null,
}
}
function summarizePublishResponse(response: DongchediPublishResponse): Record<string, JsonValue> {
return {
message: response.message ?? null,
data_message: response.data?.message ?? null,
pgc_id: stringId(response.data?.data?.pgc_id) || null,
}
}
async function dongchediPageFetchJson<T>(
context: PublishAdapterContext,
input: string,
init: {
method: string
headers?: Record<string, string>
body?: string
credentials?: 'include' | 'same-origin' | 'omit'
},
): Promise<T> {
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<T>(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<T>(envelope)
}
async function ensureDongchediEditorContext(context: PublishAdapterContext): Promise<void> {
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<T>(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<string | null> {
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<string> {
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<T>(
context: PublishAdapterContext,
input: string,
init?: RequestInit,
): Promise<T> {
const text = await imagexFetchText(context, input, init)
if (!text) {
return {} as T
}
return JSON.parse(text) as T
}
async function fetchAccount(context: PublishAdapterContext): Promise<DongchediAccount | null> {
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<DongchediAccountInfoResponse>(
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<string, string>
body?: string
date?: Date
}): Record<string, string> {
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<string, string> = {
'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<Required<DongchediImageXToken>> {
const response = await sessionFetchJson<DongchediUploadAuthResponse>(
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<ImageAssetBlob | null> {
if (!ratio) {
return source
}
return await cropImageAssetBlob(source, ratio)
}
async function uploadImage(
context: PublishAdapterContext,
sourceUrl: string,
ratio?: number,
): Promise<DongchediUploadedImage | null> {
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<DongchediImageXApplyResponse>(
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<Record<string, unknown>>(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<DongchediImageURLResponse>(
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<DongchediSpiceImageResponse['data'] | null> {
if (!imageUrl.trim()) {
return null
}
const response = await sessionFetchJson<DongchediSpiceImageResponse>(
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<string | null> {
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<string> {
if (!imageSources.length) {
return html
}
const uploaded = new Map<string, string>()
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<string, unknown> {
const body: Record<string, unknown> = {
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<string, unknown>,
): Promise<DongchediPublishResponse> {
const serializedBody = JSON.stringify(body)
await ensureDongchediEditorContext(context)
const csrfToken = await fetchDongchediCsrfToken(context).catch(() => null)
return await dongchediPageFetchJson<DongchediPublishResponse>(
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<string, JsonValue> {
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<PublishAdapter['publish']> extends Promise<infer T> ? 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)
}
},
}