2026-04-27 00:57:37 +08:00
|
|
|
import { createHash, createHmac } from "node:crypto";
|
|
|
|
|
|
|
|
|
|
import type { JsonValue } from "@geo/shared-types";
|
|
|
|
|
|
|
|
|
|
import {
|
2026-04-28 18:35:36 +08:00
|
|
|
ensureViewLoaded,
|
2026-04-27 00:57:37 +08:00
|
|
|
extractImageSources,
|
|
|
|
|
normalizeArticleHtml,
|
|
|
|
|
sessionFetchJson,
|
|
|
|
|
} from "./common";
|
|
|
|
|
import {
|
|
|
|
|
cropImageAssetBlob,
|
|
|
|
|
fetchImageAssetBlob,
|
|
|
|
|
type ImageAssetBlob,
|
|
|
|
|
} from "./media-image";
|
2026-04-28 18:35:36 +08:00
|
|
|
import { countDongchediContentWordCount, prepareDongchediArticleHtml } from "./dongchedi-content";
|
2026-04-27 00:57:37 +08:00
|
|
|
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<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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-28 18:35:36 +08:00
|
|
|
type DongchediPublishSaveMode = 0 | 1;
|
|
|
|
|
|
2026-04-27 00:57:37 +08:00
|
|
|
const DONGCHEDI_ORIGIN = "https://mp.dcdapp.com";
|
|
|
|
|
const DONGCHEDI_IMAGE_X_ORIGIN = "https://imagex.bytedanceapi.com";
|
|
|
|
|
const DONGCHEDI_SERVICE_ID = "f042mdwyw7";
|
2026-04-28 18:35:36 +08:00
|
|
|
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;
|
2026-04-27 00:57:37 +08:00
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 18:35:36 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 00:57:37 +08:00
|
|
|
function isDongchediChallengeMessage(message: string): boolean {
|
2026-04-28 18:35:36 +08:00
|
|
|
return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控|访问频繁|request_failed_403|\b403\b|forbidden|csrf|csrftoken)/i
|
2026-04-27 00:57:37 +08:00
|
|
|
.test(message);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 18:35:36 +08:00
|
|
|
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";
|
|
|
|
|
}
|
2026-04-27 00:57:37 +08:00
|
|
|
return {
|
2026-04-28 18:35:36 +08:00
|
|
|
uri_prefix: image.uri.slice(0, 24),
|
|
|
|
|
url_host: urlHost,
|
|
|
|
|
width: image.width,
|
|
|
|
|
height: image.height,
|
2026-04-27 00:57:37 +08:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 18:35:36 +08:00
|
|
|
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(/ /gi) ?? []).length,
|
|
|
|
|
amp: (content.match(/&/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>(
|
2026-04-27 21:34:15 +08:00
|
|
|
context: PublishAdapterContext,
|
|
|
|
|
input: string,
|
2026-04-28 18:35:36 +08:00
|
|
|
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;
|
2026-04-27 21:34:15 +08:00
|
|
|
}
|
2026-04-28 18:35:36 +08:00
|
|
|
|
|
|
|
|
if (context.view && !context.view.webContents.isDestroyed()) {
|
|
|
|
|
await ensureViewLoaded(context.view, DONGCHEDI_ARTICLE_EDITOR_URL, context.signal);
|
|
|
|
|
return;
|
2026-04-27 21:34:15 +08:00
|
|
|
}
|
2026-04-28 18:35:36 +08:00
|
|
|
|
|
|
|
|
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);
|
2026-04-27 21:34:15 +08:00
|
|
|
}
|
2026-04-28 18:35:36 +08:00
|
|
|
|
|
|
|
|
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);
|
2026-04-27 00:57:37 +08:00
|
|
|
const text = await response.text();
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(text || `request_failed_${response.status}`);
|
|
|
|
|
}
|
2026-04-27 21:34:15 +08:00
|
|
|
return text;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function imagexFetchJson<T>(
|
|
|
|
|
context: PublishAdapterContext,
|
|
|
|
|
input: string,
|
|
|
|
|
init?: RequestInit,
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
const text = await imagexFetchText(context, input, init);
|
2026-04-27 00:57:37 +08:00
|
|
|
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()]
|
2026-04-27 21:34:15 +08:00
|
|
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
2026-04-27 00:57:37 +08:00
|
|
|
.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),
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-27 21:34:15 +08:00
|
|
|
const applyResponse = await imagexFetchJson<DongchediImageXApplyResponse>(
|
|
|
|
|
context,
|
2026-04-27 00:57:37 +08:00
|
|
|
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();
|
2026-04-27 21:34:15 +08:00
|
|
|
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}`, {
|
2026-04-27 00:57:37 +08:00
|
|
|
method: "PUT",
|
2026-04-27 21:34:15 +08:00
|
|
|
headers: putHeaders,
|
2026-04-27 00:57:37 +08:00
|
|
|
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);
|
2026-04-28 18:35:36 +08:00
|
|
|
const commitSignatureDate = new Date(token.CurrentTime);
|
|
|
|
|
const commitHeaderDate = new Date();
|
2026-04-27 00:57:37 +08:00
|
|
|
const signedCommitHeaders = signAWS4({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: commitUrl.toString(),
|
|
|
|
|
accessKeyId: token.AccessKeyId,
|
|
|
|
|
secretAccessKey: token.SecretAccessKey,
|
|
|
|
|
securityToken: token.SessionToken,
|
2026-04-28 18:35:36 +08:00
|
|
|
date: commitSignatureDate,
|
2026-04-27 00:57:37 +08:00
|
|
|
});
|
|
|
|
|
|
2026-04-27 21:34:15 +08:00
|
|
|
await imagexFetchJson<Record<string, unknown>>(
|
|
|
|
|
context,
|
2026-04-27 00:57:37 +08:00
|
|
|
commitUrl.toString(),
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
2026-04-28 18:35:36 +08:00
|
|
|
headers: {
|
|
|
|
|
authorization: signedCommitHeaders.authorization,
|
|
|
|
|
"x-amz-date": formatAmzDate(commitHeaderDate),
|
|
|
|
|
"x-amz-security-token": token.SessionToken,
|
|
|
|
|
},
|
2026-04-27 00:57:37 +08:00
|
|
|
},
|
|
|
|
|
).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",
|
2026-04-28 18:35:36 +08:00
|
|
|
body: `img_uris=${storeUri}&img_url_type=2&img_param=noop&img_format=image`,
|
2026-04-27 00:57:37 +08:00
|
|
|
},
|
|
|
|
|
).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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 18:35:36 +08:00
|
|
|
async function spiceImage(
|
|
|
|
|
context: PublishAdapterContext,
|
|
|
|
|
imageUrl: string,
|
|
|
|
|
): Promise<DongchediSpiceImageResponse["data"] | null> {
|
|
|
|
|
if (!imageUrl.trim()) {
|
2026-04-27 00:57:37 +08:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await sessionFetchJson<DongchediSpiceImageResponse>(
|
|
|
|
|
context.session,
|
|
|
|
|
`${DONGCHEDI_ORIGIN}/spice/image?sk=dcd`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
credentials: "include",
|
2026-04-28 18:35:36 +08:00
|
|
|
headers: {
|
2026-04-27 00:57:37 +08:00
|
|
|
"content-type": "application/x-www-form-urlencoded;charset=utf-8",
|
2026-04-28 18:35:36 +08:00
|
|
|
},
|
2026-04-27 00:57:37 +08:00
|
|
|
body: new URLSearchParams({
|
|
|
|
|
imageUrl,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
).catch(() => null);
|
|
|
|
|
|
2026-04-28 18:35:36 +08:00
|
|
|
return response?.data ?? null;
|
2026-04-27 00:57:37 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-28 18:35:36 +08:00
|
|
|
async function uploadContentImage(
|
|
|
|
|
context: PublishAdapterContext,
|
|
|
|
|
sourceUrl: string,
|
|
|
|
|
): Promise<string | null> {
|
|
|
|
|
if (isDongchediHostedImageUrl(sourceUrl)) {
|
|
|
|
|
return sourceUrl;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 00:57:37 +08:00
|
|
|
const spiced = await spiceImage(context, sourceUrl);
|
2026-04-28 18:35:36 +08:00
|
|
|
if (spiced?.image_url) {
|
|
|
|
|
return spiced.image_url;
|
2026-04-27 00:57:37 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const uploaded = await uploadImage(context, sourceUrl).catch(() => null);
|
|
|
|
|
return uploaded?.url ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 18:35:36 +08:00
|
|
|
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;
|
2026-04-27 00:57:37 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildPublishBody(
|
|
|
|
|
title: string,
|
|
|
|
|
content: string,
|
|
|
|
|
verticalCover: DongchediUploadedImage,
|
|
|
|
|
feedCover: DongchediUploadedImage,
|
2026-04-28 18:35:36 +08:00
|
|
|
options: {
|
|
|
|
|
save: DongchediPublishSaveMode;
|
|
|
|
|
pgcId?: string;
|
|
|
|
|
},
|
2026-04-27 00:57:37 +08:00
|
|
|
): Record<string, unknown> {
|
2026-04-28 18:35:36 +08:00
|
|
|
const body: Record<string, unknown> = {
|
2026-04-27 00:57:37 +08:00
|
|
|
extra: {
|
|
|
|
|
timer_status: 0,
|
|
|
|
|
timer_time: "",
|
2026-04-28 18:35:36 +08:00
|
|
|
article_ad_type: DONGCHEDI_ARTICLE_AD_TYPE_NONE,
|
|
|
|
|
content_word_cnt: countDongchediContentWordCount(content),
|
|
|
|
|
title_id: "",
|
2026-04-27 00:57:37 +08:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
2026-04-28 18:35:36 +08:00
|
|
|
save: options.save,
|
2026-04-27 00:57:37 +08:00
|
|
|
title,
|
|
|
|
|
content,
|
|
|
|
|
source: 20,
|
|
|
|
|
};
|
2026-04-28 18:35:36 +08:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-04-27 00:57:37 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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") {
|
2026-04-28 18:35:36 +08:00
|
|
|
const userMessage = "懂车帝发布必须上传封面图。";
|
2026-04-27 00:57:37 +08:00
|
|
|
return {
|
|
|
|
|
status: "failed",
|
2026-04-28 18:35:36 +08:00
|
|
|
summary: userMessage,
|
2026-04-27 00:57:37 +08:00
|
|
|
error: {
|
|
|
|
|
code: "publish_cover_required",
|
2026-04-28 18:35:36 +08:00
|
|
|
message: userMessage,
|
2026-04-27 00:57:37 +08:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
2026-04-28 18:35:36 +08:00
|
|
|
|| message.startsWith("dongchedi_image_upload_failed")
|
2026-04-27 00:57:37 +08:00
|
|
|
) {
|
2026-04-28 18:35:36 +08:00
|
|
|
const isContentImageFailure = message.startsWith("dongchedi_image_upload_failed:content_images_missing");
|
2026-04-27 00:57:37 +08:00
|
|
|
return {
|
|
|
|
|
status: "failed",
|
2026-04-28 18:35:36 +08:00
|
|
|
summary: isContentImageFailure
|
|
|
|
|
? "懂车帝正文图片上传失败,请检查文章图片后重试。"
|
|
|
|
|
: "懂车帝图片上传失败,请稍后重试或更换图片。",
|
2026-04-27 00:57:37 +08:00
|
|
|
error: {
|
|
|
|
|
code: "dongchedi_image_upload_failed",
|
2026-04-28 18:35:36 +08:00
|
|
|
message: isContentImageFailure ? "懂车帝正文图片上传失败,存在未完成上传替换的图片。" : message,
|
|
|
|
|
...(isContentImageFailure ? { detail: message } : {}),
|
2026-04-27 00:57:37 +08:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (message.startsWith("dongchedi_challenge_required") || isDongchediChallengeMessage(message)) {
|
|
|
|
|
return {
|
|
|
|
|
status: "failed",
|
|
|
|
|
summary: "懂车帝触发平台验证,请在懂车帝后台完成验证后重试。",
|
|
|
|
|
error: {
|
|
|
|
|
code: "dongchedi_challenge_required",
|
|
|
|
|
message,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status: "failed",
|
|
|
|
|
summary: "懂车帝发布失败。",
|
|
|
|
|
error: {
|
2026-04-28 18:35:36 +08:00
|
|
|
code: error instanceof DongchediAdapterError ? error.code : "dongchedi_publish_failed",
|
2026-04-27 00:57:37 +08:00
|
|
|
message,
|
2026-04-28 18:35:36 +08:00
|
|
|
...(error instanceof DongchediAdapterError && error.detail ? { detail: error.detail } : {}),
|
2026-04-27 00:57:37 +08:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const dongchediAdapter: PublishAdapter = {
|
|
|
|
|
platform: "dongchedi",
|
2026-04-28 18:35:36 +08:00
|
|
|
executionMode: "playwright",
|
2026-04-27 00:57:37 +08:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 18:35:36 +08:00
|
|
|
const title = context.article.title?.trim() || "未命名文章";
|
|
|
|
|
const html = prepareDongchediArticleHtml(normalizeArticleHtml(context.article), title);
|
2026-04-27 00:57:37 +08:00
|
|
|
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");
|
2026-04-28 18:35:36 +08:00
|
|
|
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",
|
|
|
|
|
}));
|
2026-04-27 00:57:37 +08:00
|
|
|
|
2026-04-28 18:35:36 +08:00
|
|
|
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,
|
2026-04-27 00:57:37 +08:00
|
|
|
}),
|
2026-04-28 18:35:36 +08:00
|
|
|
};
|
|
|
|
|
console.warn("[dongchedi] draft rejected", {
|
|
|
|
|
taskId: context.taskId,
|
|
|
|
|
...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,
|
2026-04-27 00:57:37 +08:00
|
|
|
).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}`);
|
|
|
|
|
}
|
2026-04-28 18:35:36 +08:00
|
|
|
const detail = {
|
|
|
|
|
response: summarizePublishResponse(response),
|
|
|
|
|
payload: summarizePublishBody(publishBody, {
|
|
|
|
|
verticalCover,
|
|
|
|
|
feedCover,
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
console.warn("[dongchedi] publish rejected", {
|
|
|
|
|
taskId: context.taskId,
|
|
|
|
|
...detail,
|
|
|
|
|
});
|
|
|
|
|
throw new DongchediAdapterError("dongchedi_publish_failed", message || "dongchedi_publish_failed", detail);
|
2026-04-27 00:57:37 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status: "succeeded",
|
|
|
|
|
payload: buildResultPayload(articleId, account.name),
|
|
|
|
|
summary: "懂车帝发布成功。",
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return failureResult(error);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
};
|