import { createHash, createHmac } from "node:crypto"; import type { JsonValue } from "@geo/shared-types"; import { extractImageSources, normalizeArticleHtml, normalizeRemoteUrl, sessionCookieHeader, sessionFetchJson, uploadHtmlImages, } from "./common"; import { cropImageAssetBlob, fetchImageAssetBlob, type ImageAssetBlob, } from "./media-image"; import type { PublishAdapter, PublishAdapterContext } from "./base"; 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; }; }; 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; }; const DONGCHEDI_ORIGIN = "https://mp.dcdapp.com"; const DONGCHEDI_IMAGE_X_ORIGIN = "https://imagex.bytedanceapi.com"; const DONGCHEDI_SERVICE_ID = "f042mdwyw7"; const DONGCHEDI_MANAGE_URL = `${DONGCHEDI_ORIGIN}/profile_v2/content/manage/article`; 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"; } function isDongchediChallengeMessage(message: string): boolean { return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控|访问频繁)/i .test(message); } async function dongchediHeaders( context: PublishAdapterContext, extra: Record = {}, ): Promise> { const cookie = await sessionCookieHeader(context.session, "dongchedi.com").catch(() => ""); return { accept: "application/json, text/plain, */*", origin: DONGCHEDI_ORIGIN, referer: DONGCHEDI_ORIGIN, ...(cookie ? { cookie } : {}), ...extra, }; } async function mainFetchJson(input: string, init?: RequestInit): Promise { const response = await fetch(input, init); const text = await response.text(); if (!response.ok) { throw new Error(text || `request_failed_${response.status}`); } 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", headers: await dongchediHeaders(context), }, ).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.localeCompare(right)) .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", headers: await dongchediHeaders(context), }, ).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 mainFetchJson( 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 putResponse = await fetch(`https://${uploadHost}/${storeUri}`, { method: "PUT", headers: { "content-disposition": 'attachment; filename="undefined"', "content-type": "application/octet-stream", "content-crc32": crc32(new Uint8Array(buffer)), authorization: storeInfo.Auth, }, 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 signedCommitHeaders = signAWS4({ method: "POST", url: commitUrl.toString(), accessKeyId: token.AccessKeyId, secretAccessKey: token.SecretAccessKey, securityToken: token.SessionToken, }); await mainFetchJson>( commitUrl.toString(), { method: "POST", headers: signedCommitHeaders, }, ).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", headers: await dongchediHeaders(context, { "content-type": "application/x-www-form-urlencoded;charset=UTF-8", }), body: new URLSearchParams({ 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, sourceUrl: string): Promise { const imageUrl = normalizeRemoteUrl(sourceUrl); if (!imageUrl) { return null; } const response = await sessionFetchJson( context.session, `${DONGCHEDI_ORIGIN}/spice/image?sk=dcd`, { method: "POST", credentials: "include", headers: await dongchediHeaders(context, { "content-type": "application/x-www-form-urlencoded;charset=utf-8", }), body: new URLSearchParams({ imageUrl, }), }, ).catch(() => null); return response?.data?.image_url?.trim() || null; } async function uploadContentImage(context: PublishAdapterContext, sourceUrl: string): Promise { const spiced = await spiceImage(context, sourceUrl); if (spiced) { return spiced; } const uploaded = await uploadImage(context, sourceUrl).catch(() => null); return uploaded?.url ?? null; } function htmlTextCount(html: string): number { return html .replace(//gi, "\n") .replace(/<[^>]+>/g, "") .replace(/ /g, " ") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, "\"") .replace(/'/g, "'") .replace(/\s+/g, "") .length; } function buildPublishBody( title: string, content: string, verticalCover: DongchediUploadedImage, feedCover: DongchediUploadedImage, ): Record { return { extra: { article_ad_type: 3, timer_status: 0, timer_time: "", 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, }, ], content_word_cnt: htmlTextCount(content), title_id: "", }, save: 1, title, content, source: 20, }; } 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") { return { status: "failed", summary: "懂车帝发布必须上传封面图。", error: { code: "publish_cover_required", message, }, }; } 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") ) { return { status: "failed", summary: "懂车帝图片上传失败,请稍后重试或更换图片。", error: { code: "dongchedi_image_upload_failed", message, }, }; } if (message.startsWith("dongchedi_challenge_required") || isDongchediChallengeMessage(message)) { return { status: "failed", summary: "懂车帝触发平台验证,请在懂车帝后台完成验证后重试。", error: { code: "dongchedi_challenge_required", message, }, }; } return { status: "failed", summary: "懂车帝发布失败。", error: { code: "dongchedi_publish_failed", message, }, }; } export const dongchediAdapter: PublishAdapter = { platform: "dongchedi", executionMode: "session", 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"); } let html = normalizeArticleHtml(context.article); if (!html) { throw new Error("article_content_empty"); } if (extractImageSources(html).length === 0) { html = `${html}`; } 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 processed = await uploadHtmlImages(html, async (sourceUrl) => uploadContentImage(context, sourceUrl)); context.reportProgress("dongchedi.submit"); const title = context.article.title?.trim() || "未命名文章"; const response = await sessionFetchJson( context.session, `${DONGCHEDI_ORIGIN}/motor/content_publish/publish_mp_article/v1`, { method: "POST", credentials: "include", headers: await dongchediHeaders(context, { "content-type": "application/json", }), body: JSON.stringify(buildPublishBody(title, processed.html, verticalCover, feedCover)), }, ).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}`); } throw new Error(message || "dongchedi_publish_failed"); } return { status: "succeeded", payload: buildResultPayload(articleId, account.name), summary: "懂车帝发布成功。", }; } catch (error) { return failureResult(error); } }, };