diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index a098283..fba581e 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -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", diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 6720bb2..f42da2b 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -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: "必传", diff --git a/apps/admin-web/src/lib/cover-requirements.ts b/apps/admin-web/src/lib/cover-requirements.ts index 2fd693d..f8c74c5 100644 --- a/apps/admin-web/src/lib/cover-requirements.ts +++ b/apps/admin-web/src/lib/cover-requirements.ts @@ -33,6 +33,15 @@ const requirementCatalog: Record> = { 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): PlatformCoverRequirement[] { diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index b5848a5..5978f1b 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -50,7 +50,7 @@ const errorMessageMap: Record = { 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", diff --git a/apps/desktop-client/src/main/adapters/dongchedi-content.ts b/apps/desktop-client/src/main/adapters/dongchedi-content.ts new file mode 100644 index 0000000..16a78cc --- /dev/null +++ b/apps/desktop-client/src/main/adapters/dongchedi-content.ts @@ -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(/]*\/?>/gi, ""); + next = normalizeDongchediImageTags(next); + next = next.replace(/]*>([\s\S]*?)<\/h[1-6]>/gi, (_match, content: string) => { + const text = normalizeInlineContent(content); + return text ? `

${text}

` : ""; + }); + next = next.replace(/]*>([\s\S]*?)<\/li>/gi, (_match, content: string) => { + const text = normalizeInlineContent(content); + return text ? `

${text}

` : ""; + }); + next = next.replace(/<\/?(?:ol|ul)\b[^>]*>/gi, ""); + next = next.replace(/]*>/gi, "

"); + next = next.replace(/<(strong|b)\b[^>]*>/gi, "").replace(/<\/b>/gi, ""); + next = next.replace(//gi, " "); + next = next.replace(/<(?!\/?(?:p|strong|img)\b)[^>]+>/gi, ""); + next = next.replace(/

\s*<\/p>/gi, ""); + next = next.replace(/\s+/g, " "); + next = next.replace(/>\s+<"); + 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(/]*>/gi, (imageTag: string) => { + const src = extractAttribute(imageTag, "src"); + if (!src) { + return ""; + } + const alt = extractAttribute(imageTag, "alt") ?? ""; + return `${escapeAttribute(alt)}`; + }); +} + +function normalizeInlineContent(value: string): string { + return value + .replace(//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*

([\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, ">"); +} diff --git a/apps/desktop-client/src/main/adapters/dongchedi.test.ts b/apps/desktop-client/src/main/adapters/dongchedi.test.ts new file mode 100644 index 0000000..3954f34 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/dongchedi.test.ts @@ -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("

2026合肥全屋定制实力榜单:安徽海翔家具凭口碑登顶

"); + expect(html.startsWith("

正文段落

")).toBe(true); + expect(html).toContain("

正文段落

"); + expect(html).toContain("

第一名:安徽海翔家具

"); + expect(html).toContain("

环保标准:ENF级板材

"); + expect(html).toContain("

交付稳定

"); + expect(html).not.toMatch(/ { + const html = prepareDongchediArticleHtml( + normalizeArticleHtml({ + article_id: 1, + title: "表格测试", + html_content: [ + '

图

', + "
品牌优势
我乐家居原创设计
", + ].join(""), + markdown_content: null, + cover_asset_url: null, + }), + ); + + expect(html).toContain('

图

'); + expect(html).toContain("

品牌   优势

"); + expect(html).toContain("

我乐家居   原创设计

"); + expect(html).not.toMatch(/ { + expect(countDongchediContentWordCount( + "

这是一次接口调试草稿,用于验证懂车帝发布请求字段。保存后可以删除。

", + )).toBe(33); + expect(countDongchediContentWordCount("

A   B

")).toBe(2); + }); +}); diff --git a/apps/desktop-client/src/main/adapters/dongchedi.ts b/apps/desktop-client/src/main/adapters/dongchedi.ts index 28b977f..9aaa1d0 100644 --- a/apps/desktop-client/src/main/adapters/dongchedi.ts +++ b/apps/desktop-client/src/main/adapters/dongchedi.ts @@ -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; + + constructor(code: string, message: string, detail?: Record) { + super(message); + this.name = "DongchediAdapterError"; + this.code = code; + this.detail = detail; + } +} + function isDongchediChallengeMessage(message: string): boolean { - return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控|访问频繁)/i + return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控|访问频繁|request_failed_403|\b403\b|forbidden|csrf|csrftoken)/i .test(message); } -async function dongchediHeaders( - context: PublishAdapterContext, - extra: Record = {}, -): Promise> { - 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 { + try { + const parsed = new URL(sourceUrl); + const pathParts = parsed.pathname.split("/").filter(Boolean); + return { + protocol: parsed.protocol.replace(/:$/, ""), + host: parsed.hostname, + path_prefix: pathParts.slice(0, 2).join("/") || "/", + has_query: parsed.search.length > 0, + }; + } catch { + return { + protocol: "unknown", + host: "invalid", + path_prefix: "", + has_query: false, + }; + } +} + +function summarizeUploadedImage(image: DongchediUploadedImage | null | undefined): Record | null { + if (!image) { + return null; + } + let urlHost = ""; + try { + urlHost = new URL(image.url).hostname; + } catch { + urlHost = "invalid"; + } return { - 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, + options: { + verticalCover?: DongchediUploadedImage | null; + feedCover?: DongchediUploadedImage | null; + } = {}, +): Record { + const content = typeof body.content === "string" ? body.content : ""; + const imageSources = extractImageSources(content); + const extra = typeof body.extra === "object" && body.extra !== null + ? body.extra as Record + : {}; + return { + title_length: typeof body.title === "string" ? body.title.length : 0, + content_length: content.length, + content_image_count: imageSources.length, + content_image_sources: imageSources.slice(0, 5).map((sourceUrl) => summarizeImageSource(sourceUrl)), + content_table_count: (content.match(/ { + return { + message: response.message ?? null, + data_message: response.data?.message ?? null, + pgc_id: stringId(response.data?.data?.pgc_id) || null, + }; +} + +async function dongchediPageFetchJson( + context: PublishAdapterContext, + input: string, + init: { + method: string; + headers?: Record; + body?: string; + credentials?: "include" | "same-origin" | "omit"; + }, +): Promise { + await ensureDongchediEditorContext(context); + + const effectiveInit = { + credentials: "include" as const, + ...init, + }; + + const page = context.playwright?.page; + if (page && !page.isClosed()) { + const envelope = await page.evaluate( + async ({ fetchInput, fetchInit }) => { + try { + const response = await fetch(fetchInput, fetchInit); + const text = await response.text().catch(() => ""); + return { ok: response.ok, status: response.status, body: text }; + } catch (error) { + return { ok: false, error: String(error && (error as Error).message || error) }; + } + }, + { + fetchInput: input, + fetchInit: effectiveInit, + }, + ); + return parseDongchediFetchEnvelope(envelope); + } + + const webContents = context.view?.webContents; + if (!webContents || webContents.isDestroyed()) { + throw new Error("dongchedi_page_context_missing"); + } + + const script = `(async () => { + try { + const init = ${JSON.stringify(effectiveInit)}; + const response = await fetch(${JSON.stringify(input)}, init); + const text = await response.text().catch(() => ""); + return JSON.stringify({ ok: response.ok, status: response.status, body: text }); + } catch (error) { + return JSON.stringify({ ok: false, error: String(error && error.message || error) }); + } + })()`; + + const serialized = await webContents.executeJavaScript(script, true); + if (typeof serialized !== "string" || !serialized) { + throw new Error("dongchedi_page_fetch_failed"); + } + + let envelope: { ok: boolean; status?: number; body?: string; error?: string }; + try { + envelope = JSON.parse(serialized) as typeof envelope; + } catch { + throw new Error("dongchedi_page_fetch_failed"); + } + + return parseDongchediFetchEnvelope(envelope); +} + +async function ensureDongchediEditorContext(context: PublishAdapterContext): Promise { + if (context.signal?.aborted) { + throw new Error("adapter_aborted"); + } + + const page = context.playwright?.page; + if (page && !page.isClosed()) { + if (page.url() !== DONGCHEDI_ARTICLE_EDITOR_URL) { + await page.goto(DONGCHEDI_ARTICLE_EDITOR_URL, { + waitUntil: "domcontentloaded", + }); + } + return; + } + + if (context.view && !context.view.webContents.isDestroyed()) { + await ensureViewLoaded(context.view, DONGCHEDI_ARTICLE_EDITOR_URL, context.signal); + return; + } + + throw new Error("dongchedi_page_context_missing"); +} + +function parseDongchediFetchEnvelope( + envelope: { ok: boolean; status?: number; body?: string; error?: string }, +): T { + if (!envelope.ok) { + const suffix = envelope.status ? `request_failed_${envelope.status}` : "dongchedi_page_fetch_failed"; + const detail = envelope.body?.trim() || envelope.error?.trim() || ""; + throw new Error(detail ? `${suffix}:${detail.slice(0, 300)}` : suffix); + } + + const text = envelope.body?.trim() || ""; + if (!text) { + return {} as T; + } + return JSON.parse(text) as T; +} + +async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise { + const page = context.playwright?.page; + if (page && !page.isClosed()) { + const token = await page.evaluate(async (url) => { + try { + const response = await fetch(url, { + method: "HEAD", + credentials: "include", + headers: { + "x-secsdk-csrf-request": "1", + "x-secsdk-csrf-version": "1.2.10", + }, + }); + return response.headers.get("x-ware-csrf-token") || ""; + } catch { + return ""; + } + }, `${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`); + const parts = token.split(","); + return parts.length >= 2 && parts[1] ? parts[1] : null; + } + + const webContents = context.view?.webContents; + if (!webContents || webContents.isDestroyed()) { + return null; + } + + const script = `(async () => { + try { + const response = await fetch(${JSON.stringify(`${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`)}, { + method: "HEAD", + credentials: "include", + headers: { + "x-secsdk-csrf-request": "1", + "x-secsdk-csrf-version": "1.2.10" + } + }); + return JSON.stringify({ token: response.headers.get("x-ware-csrf-token") || "" }); + } catch { + return JSON.stringify({ token: "" }); + } + })()`; + + const serialized = await webContents.executeJavaScript(script, true).catch(() => ""); + if (typeof serialized !== "string" || !serialized) { + return null; + } + + try { + const parsed = JSON.parse(serialized) as { token?: unknown }; + const token = typeof parsed.token === "string" ? parsed.token : ""; + const parts = token.split(","); + return parts.length >= 2 && parts[1] ? parts[1] : null; + } catch { + return null; + } +} + async function imagexFetchText( context: PublishAdapterContext, input: string, init?: RequestInit, ): Promise { - const 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 null); @@ -296,7 +567,6 @@ async function fetchUploadToken(context: PublishAdapterContext): Promise 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>( @@ -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 { - const imageUrl = normalizeRemoteUrl(sourceUrl); - if (!imageUrl) { +async function spiceImage( + context: PublishAdapterContext, + imageUrl: string, +): Promise { + 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 { +async function uploadContentImage( + context: PublishAdapterContext, + sourceUrl: string, +): Promise { + 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(//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 { + if (!imageSources.length) { + return html; + } + + const uploaded = new Map(); + await Promise.all([...new Set(imageSources)].map(async (sourceUrl) => { + const targetUrl = await uploadContentImage(context, sourceUrl); + if (targetUrl) { + uploaded.set(sourceUrl, targetUrl); + } + return targetUrl; + })); + + let next = html; + imageSources.forEach((sourceUrl) => { + const targetUrl = uploaded.get(sourceUrl); + if (targetUrl) { + next = next.split(sourceUrl).join(targetUrl); + } + }); + + const remainingSources = extractImageSources(next) + .filter((sourceUrl) => !isDongchediHostedImageUrl(sourceUrl)); + if (remainingSources.length > 0) { + throw new Error(`dongchedi_image_upload_failed:content_images_missing_${remainingSources.length}_of_${imageSources.length}`); + } + return next; } function buildPublishBody( @@ -496,12 +792,18 @@ function buildPublishBody( content: string, verticalCover: DongchediUploadedImage, feedCover: DongchediUploadedImage, + options: { + save: DongchediPublishSaveMode; + pgcId?: string; + }, ): Record { - return { + const body: Record = { 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, +): Promise { + const serializedBody = JSON.stringify(body); + await ensureDongchediEditorContext(context); + const csrfToken = await fetchDongchediCsrfToken(context).catch(() => null); + return await dongchediPageFetchJson( + context, + DONGCHEDI_PUBLISH_API_URL, + { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(csrfToken ? { "x-secsdk-csrf-token": csrfToken } : {}), + }, + body: serializedBody, + }, + ); } function buildResultPayload(articleId: string, mediaName: string): Record { @@ -552,12 +878,13 @@ function failureResult(error: unknown): ReturnType 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 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 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 = `${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( - 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 { diff --git a/findings.md b/findings.md index ecf3a81..d0054b4 100644 --- a/findings.md +++ b/findings.md @@ -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 `` 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. diff --git a/progress.md b/progress.md index 0d179c0..284565f 100644 --- a/progress.md +++ b/progress.md @@ -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 | |----------|--------| diff --git a/媒体发布手工自测.md b/媒体发布手工自测.md index 2cdf628..54cb597 100644 --- a/媒体发布手工自测.md +++ b/媒体发布手工自测.md @@ -10,11 +10,9 @@ 简书:jianshu.ts (图片 ok,文字,表格 ok) ZOL: 主打 app, 图片 ok 文字,表格 ok 搜狐:图片 ok 文字 ok 表格 ok +懂车帝 图片 ok 表格官方不支持,降级处理 待测: 网易:媒体资质没下来 - - -懂车帝 微信公众号 \ No newline at end of file