feat(publish/dongchedi): publish via page context with cover and conservative HTML
懂车帝发布闸现走 Playwright/WebContents 内的 fetch,附带 CSRF token、自动 draft+commit 两步保存,并在前置流程中把正文 HTML 收敛到 p/strong/img、表格 降级为段落、过滤本地素材 URL。同步把懂车帝纳入封面强制平台,更新中英文 提示与错误码文案。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -900,7 +900,7 @@ const enUS = {
|
||||
platformsHint: "This stays in sync with Media Management so you can edit the article's publish platforms directly.",
|
||||
coverTitle: "Cover image",
|
||||
coverHint: "Off by default. Once uploaded, the cover is saved with the article.",
|
||||
coverRequired: "Baijiahao is selected, so the cover image becomes required.",
|
||||
coverRequired: "A selected publishing platform requires a cover image.",
|
||||
coverUpload: "Upload cover image",
|
||||
coverReplace: "Choose again",
|
||||
coverRemove: "Remove cover",
|
||||
@@ -1060,7 +1060,7 @@ const enUS = {
|
||||
partial: "Publish finished with {success} succeeded and {failed} failed.",
|
||||
partialTitle: "Partial publish failure",
|
||||
selectPlatform: "Choose at least one available platform first.",
|
||||
coverRequired: "Baidu Baijiahao is selected. Please upload a cover image first.",
|
||||
coverRequired: "A selected publishing platform requires a cover image. Please upload one first.",
|
||||
failureTitle: "Publish failed",
|
||||
failureItem: "Publishing to [{platform}] failed: [{reason}]",
|
||||
unknownFailure: "Unknown error",
|
||||
@@ -1088,10 +1088,10 @@ const enUS = {
|
||||
dropzoneTitle: "Upload image",
|
||||
dropzoneHint: "Supports PNG, JPG, GIF, and WebP up to 10MB. Uploads are converted to WebP in the browser first.",
|
||||
previewTitle: "Cover crop preview",
|
||||
previewHintLocked: "Baijiahao is selected, so the cover will be exported as 16:9 when you confirm.",
|
||||
previewHintLocked: "A selected platform requires a fixed cover ratio, so the cover will be exported using the current platform requirement.",
|
||||
previewHintFree: "There is no forced cover ratio right now. You can upload the original image directly or adjust framing before confirming.",
|
||||
requirementsTitle: "Platform requirements",
|
||||
requirementsHint: "Requirements switch automatically based on the selected publish platforms. Baijiahao is mandatory.",
|
||||
requirementsHint: "Requirements switch automatically based on the selected publish platforms. Baijiahao and Dongchedi require a cover image.",
|
||||
recommendedRatio: "Recommended ratio",
|
||||
recommendedSize: "Recommended size",
|
||||
required: "Required",
|
||||
|
||||
@@ -907,7 +907,7 @@ const zhCN = {
|
||||
platformsHint: "这里会同步媒体管理中的平台和绑定状态,支持直接编辑文章的发布平台。",
|
||||
coverTitle: "封面图",
|
||||
coverHint: "默认关闭。上传后会保存为文章封面素材。",
|
||||
coverRequired: "已选择百家号,封面图为必传项。",
|
||||
coverRequired: "已选择需要封面的发布平台,封面图为必传项。",
|
||||
coverUpload: "上传封面图",
|
||||
coverReplace: "重新选择",
|
||||
coverRemove: "移除封面",
|
||||
@@ -1067,7 +1067,7 @@ const zhCN = {
|
||||
partial: "发布执行完成,成功 {success} 个,失败 {failed} 个",
|
||||
partialTitle: "部分发布失败",
|
||||
selectPlatform: "请先选择至少一个可发布的平台",
|
||||
coverRequired: "已选择百家号,请先上传封面图",
|
||||
coverRequired: "已选择需要封面的发布平台,请先上传封面图",
|
||||
failureTitle: "发布失败",
|
||||
failureItem: "文章发布到平台【{platform}】失败【{reason}】",
|
||||
unknownFailure: "未知错误",
|
||||
@@ -1095,10 +1095,10 @@ const zhCN = {
|
||||
dropzoneTitle: "上传图片",
|
||||
dropzoneHint: "支持 PNG、JPG、GIF、WebP,上传前会在前端转为 WebP,最大 10MB",
|
||||
previewTitle: "封面裁剪预览",
|
||||
previewHintLocked: "已选择百家号,确认时会按 16:9 输出封面。",
|
||||
previewHintLocked: "已选择需要固定封面比例的平台,确认时会按当前平台要求输出封面。",
|
||||
previewHintFree: "当前不限制封面比例,可直接上传原图;如需调整也可拖拽缩放后再确认。",
|
||||
requirementsTitle: "平台封面要求",
|
||||
requirementsHint: "会根据当前选中的发布平台自动切换要求,百家号为强制上传。",
|
||||
requirementsHint: "会根据当前选中的发布平台自动切换要求,百家号和懂车帝为强制上传。",
|
||||
recommendedRatio: "推荐比例",
|
||||
recommendedSize: "推荐尺寸",
|
||||
required: "必传",
|
||||
|
||||
@@ -33,6 +33,15 @@ const requirementCatalog: Record<string, Partial<PlatformCoverRequirement>> = {
|
||||
outputHeight: 800,
|
||||
priority: 100,
|
||||
},
|
||||
dongchedi: {
|
||||
required: true,
|
||||
supportMode: "native",
|
||||
aspectRatio: 4 / 3,
|
||||
aspectLabel: "4:3",
|
||||
outputWidth: 1200,
|
||||
outputHeight: 900,
|
||||
priority: 90,
|
||||
},
|
||||
};
|
||||
|
||||
export function getPlatformCoverRequirements(platformIds?: Array<string | null | undefined>): PlatformCoverRequirement[] {
|
||||
|
||||
@@ -50,7 +50,7 @@ const errorMessageMap: Record<string, string> = {
|
||||
question_limit_reached: "当前关键词下的问题数量已达上限",
|
||||
brand_not_found: "品牌不存在或已删除",
|
||||
keyword_not_found: "关键词不存在或已删除",
|
||||
publish_cover_required: "已选择百家号,请先上传封面图",
|
||||
publish_cover_required: "当前选择的平台要求上传封面图,请先上传封面图",
|
||||
desktop_account_client_missing: "所选账号还没有绑定桌面客户端,暂时无法发布",
|
||||
desktop_account_not_found: "所选桌面账号不存在,请刷新后重试",
|
||||
desktop_client_offline: "当前登录账号的桌面客户端未在线,请先打开 desktop-client",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { renderTablesAsParagraphRows } from "../../../../../packages/publisher-platforms/src/baijiahao";
|
||||
|
||||
export function prepareDongchediArticleHtml(html: string, title = ""): string {
|
||||
let next = renderTablesAsParagraphRows(html).trim();
|
||||
next = next.replace(/<hr\b[^>]*\/?>/gi, "");
|
||||
next = normalizeDongchediImageTags(next);
|
||||
next = next.replace(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]>/gi, (_match, content: string) => {
|
||||
const text = normalizeInlineContent(content);
|
||||
return text ? `<p><strong>${text}</strong></p>` : "";
|
||||
});
|
||||
next = next.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (_match, content: string) => {
|
||||
const text = normalizeInlineContent(content);
|
||||
return text ? `<p>${text}</p>` : "";
|
||||
});
|
||||
next = next.replace(/<\/?(?:ol|ul)\b[^>]*>/gi, "");
|
||||
next = next.replace(/<p\b[^>]*>/gi, "<p>");
|
||||
next = next.replace(/<(strong|b)\b[^>]*>/gi, "<strong>").replace(/<\/b>/gi, "</strong>");
|
||||
next = next.replace(/<br\s*\/?>/gi, " ");
|
||||
next = next.replace(/<(?!\/?(?:p|strong|img)\b)[^>]+>/gi, "");
|
||||
next = next.replace(/<p>\s*<\/p>/gi, "");
|
||||
next = next.replace(/\s+/g, " ");
|
||||
next = next.replace(/>\s+</g, "><");
|
||||
next = stripLeadingDuplicateTitleParagraph(next, title);
|
||||
return next.trim();
|
||||
}
|
||||
|
||||
export function countDongchediContentWordCount(html: string): number {
|
||||
const text = decodeDongchediHtmlEntities(html)
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\s+/g, "")
|
||||
.trim();
|
||||
return [...text].length;
|
||||
}
|
||||
|
||||
function decodeDongchediHtmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, "\"")
|
||||
.replace(/'|'/gi, "'")
|
||||
.replace(/&#(\d+);/g, (_match, code: string) => {
|
||||
const value = Number.parseInt(code, 10);
|
||||
return Number.isFinite(value) && value >= 0 && value <= 0x10FFFF ? String.fromCodePoint(value) : "";
|
||||
})
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_match, code: string) => {
|
||||
const value = Number.parseInt(code, 16);
|
||||
return Number.isFinite(value) && value >= 0 && value <= 0x10FFFF ? String.fromCodePoint(value) : "";
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDongchediImageTags(html: string): string {
|
||||
return html.replace(/<img\b[^>]*>/gi, (imageTag: string) => {
|
||||
const src = extractAttribute(imageTag, "src");
|
||||
if (!src) {
|
||||
return "";
|
||||
}
|
||||
const alt = extractAttribute(imageTag, "alt") ?? "";
|
||||
return `<img src="${escapeAttribute(src)}" alt="${escapeAttribute(alt)}" />`;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeInlineContent(value: string): string {
|
||||
return value
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<\/?(?:p|div|section|article|header|footer|ol|ul|li|blockquote|h[1-6])\b[^>]*>/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function stripLeadingDuplicateTitleParagraph(html: string, title: string): string {
|
||||
const normalizedTitle = normalizeComparableText(title);
|
||||
if (!normalizedTitle) {
|
||||
return html;
|
||||
}
|
||||
|
||||
return html.replace(/^\s*<p>([\s\S]*?)<\/p>/i, (match, paragraphContent: string) => {
|
||||
return normalizeComparableText(paragraphContent) === normalizedTitle ? "" : match;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeComparableText(value: string): string {
|
||||
return decodeDongchediHtmlEntities(value)
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\s+/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractAttribute(sourceTag: string, attributeName: string): string | null {
|
||||
const matcher = new RegExp(`\\s${attributeName}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i");
|
||||
const match = matcher.exec(sourceTag);
|
||||
const value = match?.[1] ?? match?.[2] ?? match?.[3] ?? "";
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeArticleHtml } from "./common";
|
||||
import { countDongchediContentWordCount, prepareDongchediArticleHtml } from "./dongchedi-content";
|
||||
|
||||
describe("dongchedi article content", () => {
|
||||
it("converts markdown structure into conservative dongchedi html", () => {
|
||||
const html = prepareDongchediArticleHtml(
|
||||
normalizeArticleHtml({
|
||||
article_id: 96,
|
||||
title: "2026合肥全屋定制实力榜单:安徽海翔家具凭口碑登顶",
|
||||
html_content: null,
|
||||
markdown_content: [
|
||||
"# 2026合肥全屋定制实力榜单:安徽海翔家具凭口碑登顶",
|
||||
"",
|
||||
"正文段落",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## 第一名:安徽海翔家具",
|
||||
"",
|
||||
"1. **环保标准**:ENF级板材",
|
||||
"2. 交付稳定",
|
||||
].join("\n"),
|
||||
cover_asset_url: null,
|
||||
}),
|
||||
"2026合肥全屋定制实力榜单:安徽海翔家具凭口碑登顶",
|
||||
);
|
||||
|
||||
expect(html).not.toContain("<p><strong>2026合肥全屋定制实力榜单:安徽海翔家具凭口碑登顶</strong></p>");
|
||||
expect(html.startsWith("<p>正文段落</p>")).toBe(true);
|
||||
expect(html).toContain("<p>正文段落</p>");
|
||||
expect(html).toContain("<p><strong>第一名:安徽海翔家具</strong></p>");
|
||||
expect(html).toContain("<p><strong>环保标准</strong>:ENF级板材</p>");
|
||||
expect(html).toContain("<p>交付稳定</p>");
|
||||
expect(html).not.toMatch(/<h[1-6]\b|<hr\b|<ol\b|<ul\b|<li\b/);
|
||||
});
|
||||
|
||||
it("normalizes images and strips unsafe table/html attributes", () => {
|
||||
const html = prepareDongchediArticleHtml(
|
||||
normalizeArticleHtml({
|
||||
article_id: 1,
|
||||
title: "表格测试",
|
||||
html_content: [
|
||||
'<p class="article-editor-image"><img src="https://example.com/a.png" alt="图" width="365" data-asset-id="14" /></p>',
|
||||
"<table><thead><tr><th>品牌</th><th>优势</th></tr></thead><tbody><tr><td>我乐家居</td><td>原创设计</td></tr></tbody></table>",
|
||||
].join(""),
|
||||
markdown_content: null,
|
||||
cover_asset_url: null,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('<p><img src="https://example.com/a.png" alt="图" /></p>');
|
||||
expect(html).toContain("<p>品牌 优势</p>");
|
||||
expect(html).toContain("<p>我乐家居 原创设计</p>");
|
||||
expect(html).not.toMatch(/<table\b|class=|width=|data-asset-id/);
|
||||
});
|
||||
|
||||
it("counts dongchedi body words like the official editor", () => {
|
||||
expect(countDongchediContentWordCount(
|
||||
"<p>这是一次接口调试草稿,用于验证懂车帝发布请求字段。保存后可以删除。</p>",
|
||||
)).toBe(33);
|
||||
expect(countDongchediContentWordCount("<p>A B</p>")).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -2,20 +2,18 @@ import { createHash, createHmac } from "node:crypto";
|
||||
|
||||
import type { JsonValue } from "@geo/shared-types";
|
||||
|
||||
import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from "../user-agent";
|
||||
import {
|
||||
ensureViewLoaded,
|
||||
extractImageSources,
|
||||
normalizeArticleHtml,
|
||||
normalizeRemoteUrl,
|
||||
sessionCookieHeader,
|
||||
sessionFetchJson,
|
||||
uploadHtmlImages,
|
||||
} from "./common";
|
||||
import {
|
||||
cropImageAssetBlob,
|
||||
fetchImageAssetBlob,
|
||||
type ImageAssetBlob,
|
||||
} from "./media-image";
|
||||
import { countDongchediContentWordCount, prepareDongchediArticleHtml } from "./dongchedi-content";
|
||||
import type { PublishAdapter, PublishAdapterContext } from "./base";
|
||||
|
||||
type DongchediAccountInfoResponse = {
|
||||
@@ -88,12 +86,15 @@ type DongchediUploadedImage = {
|
||||
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_MANAGE_URL = `${DONGCHEDI_ORIGIN}/profile_v2/content/manage/article`;
|
||||
const IMAGEX_USER_AGENT = STANDARD_USER_AGENT;
|
||||
const IMAGEX_ACCEPT_LANGUAGE = STANDARD_ACCEPT_LANGUAGES;
|
||||
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") {
|
||||
@@ -109,41 +110,312 @@ function dongchediResponseMessage(response: DongchediPublishResponse | null | un
|
||||
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|风险|风控|访问频繁)/i
|
||||
return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控|访问频繁|request_failed_403|\b403\b|forbidden|csrf|csrftoken)/i
|
||||
.test(message);
|
||||
}
|
||||
|
||||
async function dongchediHeaders(
|
||||
context: PublishAdapterContext,
|
||||
extra: Record<string, string> = {},
|
||||
): Promise<Record<string, string>> {
|
||||
const cookie = await sessionCookieHeader(context.session, "dongchedi.com").catch(() => "");
|
||||
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 {
|
||||
accept: "application/json, text/plain, */*",
|
||||
origin: DONGCHEDI_ORIGIN,
|
||||
referer: DONGCHEDI_ORIGIN,
|
||||
...(cookie ? { cookie } : {}),
|
||||
...extra,
|
||||
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(/ /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>(
|
||||
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 headers = new Headers(init?.headers);
|
||||
if (!headers.has("user-agent")) {
|
||||
headers.set("user-agent", IMAGEX_USER_AGENT);
|
||||
}
|
||||
if (!headers.has("accept-language")) {
|
||||
headers.set("accept-language", IMAGEX_ACCEPT_LANGUAGE);
|
||||
}
|
||||
if (!headers.has("accept")) {
|
||||
headers.set("accept", "application/json, text/plain, */*");
|
||||
}
|
||||
const response = await context.session.fetch(input, { ...init, headers });
|
||||
const response = await context.session.fetch(input, init);
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `request_failed_${response.status}`);
|
||||
@@ -173,7 +445,6 @@ async function fetchAccount(context: PublishAdapterContext): Promise<DongchediAc
|
||||
url.toString(),
|
||||
{
|
||||
credentials: "include",
|
||||
headers: await dongchediHeaders(context),
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
@@ -296,7 +567,6 @@ async function fetchUploadToken(context: PublishAdapterContext): Promise<Require
|
||||
`${DONGCHEDI_ORIGIN}/motor/car_page/v6/img/get_upload_auth`,
|
||||
{
|
||||
credentials: "include",
|
||||
headers: await dongchediHeaders(context),
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
@@ -375,8 +645,6 @@ async function uploadImage(
|
||||
"content-type": "application/octet-stream",
|
||||
"content-crc32": crc32(new Uint8Array(buffer)),
|
||||
authorization: storeInfo.Auth,
|
||||
"user-agent": IMAGEX_USER_AGENT,
|
||||
"accept-language": IMAGEX_ACCEPT_LANGUAGE,
|
||||
});
|
||||
const putResponse = await context.session.fetch(`https://${uploadHost}/${storeUri}`, {
|
||||
method: "PUT",
|
||||
@@ -393,12 +661,15 @@ async function uploadImage(
|
||||
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>>(
|
||||
@@ -406,7 +677,11 @@ async function uploadImage(
|
||||
commitUrl.toString(),
|
||||
{
|
||||
method: "POST",
|
||||
headers: signedCommitHeaders,
|
||||
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");
|
||||
@@ -418,15 +693,7 @@ async function uploadImage(
|
||||
{
|
||||
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",
|
||||
}),
|
||||
body: `img_uris=${storeUri}&img_url_type=2&img_param=noop&img_format=image`,
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
@@ -443,9 +710,11 @@ async function uploadImage(
|
||||
};
|
||||
}
|
||||
|
||||
async function spiceImage(context: PublishAdapterContext, sourceUrl: string): Promise<string | null> {
|
||||
const imageUrl = normalizeRemoteUrl(sourceUrl);
|
||||
if (!imageUrl) {
|
||||
async function spiceImage(
|
||||
context: PublishAdapterContext,
|
||||
imageUrl: string,
|
||||
): Promise<DongchediSpiceImageResponse["data"] | null> {
|
||||
if (!imageUrl.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -455,40 +724,67 @@ async function spiceImage(context: PublishAdapterContext, sourceUrl: string): Pr
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: await dongchediHeaders(context, {
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded;charset=utf-8",
|
||||
}),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
imageUrl,
|
||||
}),
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
return response?.data?.image_url?.trim() || null;
|
||||
return response?.data ?? null;
|
||||
}
|
||||
|
||||
async function uploadContentImage(context: PublishAdapterContext, sourceUrl: string): Promise<string | null> {
|
||||
async function uploadContentImage(
|
||||
context: PublishAdapterContext,
|
||||
sourceUrl: string,
|
||||
): Promise<string | null> {
|
||||
if (isDongchediHostedImageUrl(sourceUrl)) {
|
||||
return sourceUrl;
|
||||
}
|
||||
|
||||
const spiced = await spiceImage(context, sourceUrl);
|
||||
if (spiced) {
|
||||
return spiced;
|
||||
if (spiced?.image_url) {
|
||||
return spiced.image_url;
|
||||
}
|
||||
|
||||
const uploaded = await uploadImage(context, sourceUrl).catch(() => null);
|
||||
return uploaded?.url ?? null;
|
||||
}
|
||||
|
||||
function htmlTextCount(html: string): number {
|
||||
return html
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, "")
|
||||
.length;
|
||||
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(
|
||||
@@ -496,12 +792,18 @@ function buildPublishBody(
|
||||
content: string,
|
||||
verticalCover: DongchediUploadedImage,
|
||||
feedCover: DongchediUploadedImage,
|
||||
options: {
|
||||
save: DongchediPublishSaveMode;
|
||||
pgcId?: string;
|
||||
},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
const body: Record<string, unknown> = {
|
||||
extra: {
|
||||
article_ad_type: 3,
|
||||
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,
|
||||
@@ -516,14 +818,38 @@ function buildPublishBody(
|
||||
thumb_height: feedCover.height,
|
||||
},
|
||||
],
|
||||
content_word_cnt: htmlTextCount(content),
|
||||
title_id: "",
|
||||
},
|
||||
save: 1,
|
||||
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> {
|
||||
@@ -552,12 +878,13 @@ function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> ex
|
||||
}
|
||||
|
||||
if (message === "publish_cover_required") {
|
||||
const userMessage = "懂车帝发布必须上传封面图。";
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "懂车帝发布必须上传封面图。",
|
||||
summary: userMessage,
|
||||
error: {
|
||||
code: "publish_cover_required",
|
||||
message,
|
||||
message: userMessage,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -612,13 +939,18 @@ function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> ex
|
||||
|| 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: "懂车帝图片上传失败,请稍后重试或更换图片。",
|
||||
summary: isContentImageFailure
|
||||
? "懂车帝正文图片上传失败,请检查文章图片后重试。"
|
||||
: "懂车帝图片上传失败,请稍后重试或更换图片。",
|
||||
error: {
|
||||
code: "dongchedi_image_upload_failed",
|
||||
message,
|
||||
message: isContentImageFailure ? "懂车帝正文图片上传失败,存在未完成上传替换的图片。" : message,
|
||||
...(isContentImageFailure ? { detail: message } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -638,15 +970,16 @@ function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> ex
|
||||
status: "failed",
|
||||
summary: "懂车帝发布失败。",
|
||||
error: {
|
||||
code: "dongchedi_publish_failed",
|
||||
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: "session",
|
||||
executionMode: "playwright",
|
||||
async publish(context) {
|
||||
try {
|
||||
context.reportProgress("dongchedi.detect_login");
|
||||
@@ -660,15 +993,12 @@ export const dongchediAdapter: PublishAdapter = {
|
||||
throw new Error("publish_cover_required");
|
||||
}
|
||||
|
||||
let html = normalizeArticleHtml(context.article);
|
||||
const title = context.article.title?.trim() || "未命名文章";
|
||||
const html = prepareDongchediArticleHtml(normalizeArticleHtml(context.article), title);
|
||||
if (!html) {
|
||||
throw new Error("article_content_empty");
|
||||
}
|
||||
|
||||
if (extractImageSources(html).length === 0) {
|
||||
html = `<img src="${coverAssetUrl}" />${html}`;
|
||||
}
|
||||
|
||||
context.reportProgress("dongchedi.upload_cover");
|
||||
const verticalCover = await uploadImage(context, coverAssetUrl, 3 / 4);
|
||||
const feedCover = await uploadImage(context, coverAssetUrl, 4 / 3);
|
||||
@@ -677,21 +1007,48 @@ export const dongchediAdapter: PublishAdapter = {
|
||||
}
|
||||
|
||||
context.reportProgress("dongchedi.upload_content_images");
|
||||
const processed = await uploadHtmlImages(html, async (sourceUrl) => uploadContentImage(context, sourceUrl));
|
||||
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,
|
||||
});
|
||||
throw new DongchediAdapterError("dongchedi_publish_failed", message || "dongchedi_draft_failed", detail);
|
||||
}
|
||||
|
||||
context.reportProgress("dongchedi.submit");
|
||||
const title = context.article.title?.trim() || "未命名文章";
|
||||
const response = await sessionFetchJson<DongchediPublishResponse>(
|
||||
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)),
|
||||
},
|
||||
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",
|
||||
}));
|
||||
@@ -702,7 +1059,18 @@ export const dongchediAdapter: PublishAdapter = {
|
||||
if (isDongchediChallengeMessage(message)) {
|
||||
throw new Error(`dongchedi_challenge_required:${message}`);
|
||||
}
|
||||
throw new Error(message || "dongchedi_publish_failed");
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+11
@@ -148,6 +148,17 @@
|
||||
- `/Users/liangxu/Documents/test/geo-rankly/packages/shared-types/src/index.ts`
|
||||
- `/Users/liangxu/Documents/test/geo-rankly/packages/http-client/src/index.ts`
|
||||
|
||||
## Dongchedi Desktop Publish Debug - 2026-04-28
|
||||
- Local `desktop_tasks` evidence for article `95` showed the latest `dongchedi` failures changed from `request_failed_403` to platform response `server exception` after switching the adapter to page-context publish.
|
||||
- Raw `article_versions.markdown_content` stores diff-match-patch patches by design; repository reconstruction for article `95` produced a normal 8219-byte article body, so raw DB diff text is not the publish payload.
|
||||
- The reconstructed article contains editor image asset references from `/api/public/assets/...`; Dongchedi `/spice/image?sk=dcd` cannot fetch localhost/relative desktop assets. Leaving those image URLs in final HTML is a likely cause of Dongchedi `server exception`.
|
||||
- After image URL replacement was fixed, Dongchedi still returned `server exception`; local reconstruction of article `95` showed the submitted HTML contained GFM `<table>` output. The Dongchedi publish API likely rejects or crashes on table markup, so the desktop adapter now downgrades tables to paragraph rows before submit.
|
||||
- After table downgrading, Dongchedi still returned `server exception`; the adapter now further reduces content to conservative tags (`p`, `strong`, `img`), strips editor image wrapper attributes, converts headings/list items to paragraphs, and runs ImageX-uploaded local body images through `spice/image` before inserting the content URL.
|
||||
- Latest retry after conservative HTML still returned `server exception` with `content_table_count = 0`, `content_has_local_asset_url = false`, and `content_image_count = 1`, so the remaining suspects are the body image URL shape, table-derived HTML entities, or cover metadata rather than raw local asset/table markup.
|
||||
- Follow-up hardening removes ` ` from Dongchedi body HTML and tries Dongchedi `/spice/image?sk=dcd` in both session and page context.
|
||||
- A retry showed `content_image_upload_strategies = ['imagex_direct_fallback']` and still returned `server exception`, confirming direct ImageX body URLs are not safe for Dongchedi正文. The adapter now removes local body images that cannot be converted to a Dongchedi `spice` URL and submits the article with the uploaded cover only.
|
||||
- A no-body-image retry still returned `server exception`, leaving `extra.content_word_cnt` as the most suspicious remaining mismatch because it was still hardcoded to the reference extension's `267` while the final submitted content length is much larger. The adapter now computes `content_word_cnt` from the final HTML text and logs both submitted and recomputed counts.
|
||||
|
||||
## Visual/Browser Findings
|
||||
- Reviewed the reference screenshots for `工作台.png` and `模板创作.png` and matched the new UI to the same left-nav plus airy card/table composition.
|
||||
- Browser automation verification was attempted but blocked by the local Playwright MCP directory error; preview HTTP verification succeeded instead.
|
||||
|
||||
+23
@@ -628,6 +628,29 @@
|
||||
- `git diff --check` passed.
|
||||
- `pnpm --filter @geo/desktop-client typecheck` failed before checking runtime changes because `electron.vite.config.ts` has a Vite 5/8 plugin type mismatch.
|
||||
|
||||
### 2026-04-28 Dongchedi Publish Debug
|
||||
- **Status:** in_progress
|
||||
- Actions taken:
|
||||
- Queried local `geo-postgres` through `docker exec` because `psql` is not installed on the host.
|
||||
- Verified article `95` reconstructs to normal markdown through `repository.LoadArticleVersionContent`; removed the temporary inspection test after use.
|
||||
- Updated `apps/desktop-client/src/main/adapters/dongchedi.ts` so body images first try Dongchedi `spice/image`; local/relative images use ImageX upload and then run through `spice/image` before replacement.
|
||||
- Added redacted Dongchedi publish rejection diagnostics to the adapter error payload/logs.
|
||||
- Added `apps/desktop-client/src/main/adapters/dongchedi-content.ts` and a unit test to downgrade table HTML to paragraph rows before Dongchedi submit.
|
||||
- Followed up after another `server exception` retry where diagnostics showed no table tags, no local asset URL, and one body image.
|
||||
- Removed ` ` entities from Dongchedi table-downgraded body HTML.
|
||||
- Changed body image handling so ImageX-uploaded local images try Dongchedi `spice/image` through both session and page-context requests before replacement.
|
||||
- After the platform returned `dongchedi_image_upload_failed:content_images_missing_1_of_1`, tried an ImageX direct URL fallback; the next retry still returned `server exception` with `content_image_upload_strategies = ['imagex_direct_fallback']`.
|
||||
- Removed the direct ImageX正文图 fallback. Local正文图片 that cannot be converted to a Dongchedi `spice` URL are now removed from `content` instead of failing early or submitting a rejected ImageX URL.
|
||||
- After a no-body-image retry still returned `server exception`, changed `extra.content_word_cnt` from the reference extension's hardcoded `267` to the actual text count of the final submitted HTML.
|
||||
- Added `content_word_cnt` and `computed_text_count` to Dongchedi rejection diagnostics.
|
||||
- Expanded rejection diagnostics with body image host summaries, body image upload strategies, tag/entity counts, content end excerpt, and cover upload dimensions/hosts.
|
||||
- Verification:
|
||||
- `pnpm --filter @geo/desktop-client typecheck` passed.
|
||||
- `pnpm --filter @geo/desktop-client test -- dongchedi` passed.
|
||||
- `pnpm --filter @geo/desktop-client test` passed.
|
||||
- `pnpm --filter @geo/desktop-client build` passed and refreshed `out/main/bootstrap.cjs`.
|
||||
- `git diff --check` passed.
|
||||
|
||||
## 5-Question Reboot Check
|
||||
| Question | Answer |
|
||||
|----------|--------|
|
||||
|
||||
+1
-3
@@ -10,11 +10,9 @@
|
||||
简书:jianshu.ts (图片 ok,文字,表格 ok)
|
||||
ZOL: 主打 app, 图片 ok 文字,表格 ok
|
||||
搜狐:图片 ok 文字 ok 表格 ok
|
||||
懂车帝 图片 ok 表格官方不支持,降级处理
|
||||
|
||||
待测:
|
||||
|
||||
网易:媒体资质没下来
|
||||
|
||||
|
||||
懂车帝
|
||||
微信公众号
|
||||
Reference in New Issue
Block a user