diff --git a/apps/desktop-client/src/main/adapters/wangyihao-content.ts b/apps/desktop-client/src/main/adapters/wangyihao-content.ts new file mode 100644 index 0000000..48f6f7d --- /dev/null +++ b/apps/desktop-client/src/main/adapters/wangyihao-content.ts @@ -0,0 +1,15 @@ +import type { DesktopArticleContent } from "@geo/shared-types"; +import { + prepareBaijiahaoMarkdown, + renderTablesAsParagraphRows, +} from "../../../../../packages/publisher-platforms/src/baijiahao"; + +import { normalizeArticleHtml } from "./common"; + +export function prepareWangyihaoArticleHtml(article: DesktopArticleContent): string { + return renderTablesAsParagraphRows( + normalizeArticleHtml(article, { + prepareMarkdown: prepareBaijiahaoMarkdown, + }), + ).trim(); +} diff --git a/apps/desktop-client/src/main/adapters/wangyihao.test.ts b/apps/desktop-client/src/main/adapters/wangyihao.test.ts new file mode 100644 index 0000000..0750315 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/wangyihao.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { prepareWangyihaoArticleHtml } from "./wangyihao-content"; + +describe("wangyihao article content", () => { + it("downgrades markdown tables to paragraph rows before saving drafts", () => { + const html = prepareWangyihaoArticleHtml({ + article_id: 1, + title: "表格测试", + html_content: null, + markdown_content: [ + "| 品牌 | 品牌定位 | 核心优势 |", + "| --- | --- | --- |", + "| 我乐家居 | 主打原创设计的全国性定制品牌,定位偏向时尚轻奢风格 | 拥有多项外观设计专利,产品颜值辨识度高 |", + "", + "下一段中文说明", + ].join("\n"), + cover_asset_url: null, + }); + + expect(html).not.toContain("品牌   品牌定位   核心优势

"); + expect(html).toContain("我乐家居   主打原创设计"); + expect(html).toContain("

下一段中文说明

"); + }); +}); diff --git a/apps/desktop-client/src/main/adapters/wangyihao.ts b/apps/desktop-client/src/main/adapters/wangyihao.ts index c79e772..bb11f29 100644 --- a/apps/desktop-client/src/main/adapters/wangyihao.ts +++ b/apps/desktop-client/src/main/adapters/wangyihao.ts @@ -3,13 +3,18 @@ import type { Page } from "playwright-core"; import { extractImageSources, - normalizeArticleHtml, sessionCookieHeader, sessionFetchJson, uploadHtmlImages, } from "./common"; import { fetchImageAssetBlob } from "./media-image"; import type { PublishAdapter, PublishAdapterContext } from "./base"; +import { prepareWangyihaoArticleHtml } from "./wangyihao-content"; +import { + WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE, + WANGYIHAO_PUBLISH_CLICK_MESSAGE, + WANGYIHAO_TOKEN_MISSING_MESSAGE, +} from "../../shared/publisher-errors"; type WangyiInfoResponse = { data?: { @@ -31,21 +36,58 @@ type WangyiUploadResponse = { }; type WangyiDraftResponse = { - code?: number; + code?: number | string; message?: string; + msg?: string; + errMsg?: string; + errorMsg?: string; data?: string; }; +type WangyiPublishDetailResponse = { + code?: number | string; + data?: { + wemediaId?: string | number; + onlineState?: string | number; + hasRecommRight?: boolean | number; + allowedRepost?: boolean | number; + newProminentFlag?: string | number; + }; +}; + type WangyiAccount = { realUserId: string; wemediaId: string; name: string; }; +type WangyiPublishDetail = { + wemediaId: string; + onlineState: string; + hasRecommRight: boolean; + allowedRepost: boolean; + newProminentFlag: string; +}; + const WANGYI_ORIGIN = "https://mp.163.com"; const WANGYI_HOME_URL = `${WANGYI_ORIGIN}/subscribe_v4/index.html`; const WANGYI_MANAGE_URL = `${WANGYI_ORIGIN}/subscribe_v4/index.html#/article-manage`; +const WANGYI_CREATIVE_STATEMENT = "个人原创,仅供参考"; const WANGYI_PUBLISH_DELAY_MS = 2_000; +const WANGYI_TOKEN_WAIT_MS = 12_000; +const WANGYI_GUARDIAN_APP_ID = "YD00250021379271"; +const WANGYI_GUARDIAN_SCRIPT_URL = "https://static.ws.126.net/163/mp/main/static/YiDunProtector-Web-2.0.7.js"; +const WANGYI_TOKEN_ATTR = "data-geo-wangyihao-token"; +const WANGYI_TOKEN_STATUS_ATTR = "data-geo-wangyihao-token-status"; +const WANGYI_TOKEN_ERROR_ATTR = "data-geo-wangyihao-token-error"; +const WANGYI_RESPONSE_CODE_MESSAGES: Record = { + "100001": "参数为空", + "100002": "参数错误", + "100003": "数据不存在", + "100004": "参数超出范围", + "100005": "数据已存在", + "100006": "请选择上传文件", +}; function stringId(value: unknown): string { if (typeof value === "number") { @@ -61,8 +103,23 @@ function parseFormString(value: string): Record { return Object.fromEntries(new URLSearchParams(value)); } -function wangyiResponseMessage(response: { message?: string; code?: number } | null | undefined): string { - return response?.message || `wangyihao_error_${response?.code ?? "unknown"}`; +function wangyiResponseMessage( + response: { + message?: string; + msg?: string; + errMsg?: string; + errorMsg?: string; + code?: number | string; + data?: unknown; + } | null | undefined, +): string { + return response?.message + || response?.msg + || response?.errMsg + || response?.errorMsg + || (typeof response?.data === "string" && !response.data.includes("=") ? response.data : "") + || WANGYI_RESPONSE_CODE_MESSAGES[String(response?.code ?? "")] + || `wangyihao_error_${response?.code ?? "unknown"}`; } function isWangyiChallengeMessage(message: string): boolean { @@ -122,6 +179,45 @@ async function fetchAccount(context: PublishAdapterContext): Promise { + const fallback: WangyiPublishDetail = { + wemediaId: account.wemediaId, + onlineState: "", + hasRecommRight: false, + allowedRepost: false, + newProminentFlag: "", + }; + + const url = new URL(`${WANGYI_ORIGIN}/wemedia/article/postpage.do`); + url.searchParams.set("_", String(Date.now())); + url.searchParams.set("wemediaId", account.wemediaId); + url.searchParams.set("mediaId", account.wemediaId); + + const response = await sessionFetchJson( + context.session, + url.toString(), + { + credentials: "include", + headers: await wangyiHeaders(context), + }, + ).catch(() => null); + + if (String(response?.code) !== "1" || !response?.data) { + return fallback; + } + + return { + wemediaId: stringId(response.data.wemediaId) || account.wemediaId, + onlineState: stringId(response.data.onlineState), + hasRecommRight: response.data.hasRecommRight === true || response.data.hasRecommRight === 1, + allowedRepost: response.data.allowedRepost === true || response.data.allowedRepost === 1, + newProminentFlag: stringId(response.data.newProminentFlag), + }; +} + async function getPublishToken(context: PublishAdapterContext): Promise { const page = context.playwright?.page; if (!page) { @@ -131,19 +227,177 @@ async function getPublishToken(context: PublishAdapterContext): Promise await page.goto(WANGYI_HOME_URL, { waitUntil: "domcontentloaded", }); + + // 网易号的 neg 有时是顶层绑定,不一定挂在 window 上;这里保持和旧版插件一致的读取方式。 + const token = await readPublishTokenFromPage(page); + return token.trim(); +} + +async function readPublishTokenFromPage(page: Page): Promise { + await page.evaluate((attrs) => { + document.documentElement.removeAttribute(attrs.token); + document.documentElement.removeAttribute(attrs.status); + document.documentElement.removeAttribute(attrs.error); + }, { + token: WANGYI_TOKEN_ATTR, + status: WANGYI_TOKEN_STATUS_ATTR, + error: WANGYI_TOKEN_ERROR_ATTR, + }).catch(() => null); + + await page.addScriptTag({ + content: ` + (() => { + const tokenAttr = ${JSON.stringify(WANGYI_TOKEN_ATTR)}; + const statusAttr = ${JSON.stringify(WANGYI_TOKEN_STATUS_ATTR)}; + const errorAttr = ${JSON.stringify(WANGYI_TOKEN_ERROR_ATTR)}; + const guardianScriptUrl = ${JSON.stringify(WANGYI_GUARDIAN_SCRIPT_URL)}; + const guardianAppId = ${JSON.stringify(WANGYI_GUARDIAN_APP_ID)}; + const timeoutMs = ${WANGYI_TOKEN_WAIT_MS}; + const retryMs = 250; + const scriptLoadTimeoutMs = 6_000; + + const root = document.documentElement; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const setStatus = (status, error = "") => { + root.setAttribute(statusAttr, status); + if (error) { + root.setAttribute(errorAttr, String(error).slice(0, 240)); + } + }; + const readTopLevelNeg = () => { + try { + return typeof neg !== "undefined" ? neg : null; + } catch (_error) { + return null; + } + }; + const readGuardianFactory = () => { + try { + return typeof createNEGuardian === "function" ? createNEGuardian : null; + } catch (_error) { + return null; + } + }; + const readGuardian = () => { + const topLevelNeg = readTopLevelNeg(); + if (topLevelNeg && typeof topLevelNeg.getToken === "function") { + return topLevelNeg; + } + if (window.neg && typeof window.neg.getToken === "function") { + return window.neg; + } + if (window.__geoWangyihaoNeg && typeof window.__geoWangyihaoNeg.getToken === "function") { + return window.__geoWangyihaoNeg; + } + const createGuardian = readGuardianFactory(); + if (createGuardian) { + window.__geoWangyihaoNeg = createGuardian({ appId: guardianAppId, timeout: 6_000 }); + return window.__geoWangyihaoNeg; + } + return null; + }; + const loadGuardianScript = () => new Promise((resolve) => { + if (readGuardianFactory()) { + resolve(true); + return; + } + const script = document.createElement("script"); + let done = false; + const finish = (loaded) => { + if (done) { + return; + } + done = true; + resolve(loaded); + }; + script.src = guardianScriptUrl; + script.async = false; + script.onload = () => finish(true); + script.onerror = () => finish(false); + setTimeout(() => finish(false), scriptLoadTimeoutMs); + document.head.appendChild(script); + }); + const requestToken = async (client) => { + const result = await Promise.resolve(client.getToken()); + return typeof result?.token === "string" ? result.token.trim() : ""; + }; + + window.__geoWangyihaoTokenPromise = (async () => { + const deadline = Date.now() + timeoutMs; + setStatus("waiting"); + + if (document.readyState === "loading") { + await new Promise((resolve) => { + document.addEventListener("DOMContentLoaded", resolve, { once: true }); + }); + } + + if (!readGuardian()) { + setStatus("loading_guardian"); + await loadGuardianScript(); + } + + while (Date.now() <= deadline) { + const client = readGuardian(); + if (!client) { + setStatus("missing_client"); + await sleep(retryMs); + continue; + } + + try { + setStatus("requesting"); + const token = await requestToken(client); + if (token) { + root.setAttribute(tokenAttr, token); + setStatus("ready"); + return token; + } + setStatus("empty_token"); + } catch (error) { + setStatus("retrying", error && error.message ? error.message : error); + } + + await sleep(retryMs); + } + + setStatus("missing"); + return ""; + })().catch((error) => { + setStatus("failed", error && error.message ? error.message : error); + return ""; + }); + })(); + `, + }).catch(() => null); + await page.waitForFunction( - "() => Boolean(window.neg && typeof window.neg.getToken === 'function')", - undefined, - { timeout: 12_000 }, + ({ token, status }) => { + const root = document.documentElement; + const statusValue = root.getAttribute(status); + return Boolean(root.getAttribute(token)) || statusValue === "missing" || statusValue === "failed"; + }, + { + token: WANGYI_TOKEN_ATTR, + status: WANGYI_TOKEN_STATUS_ATTR, + }, + { timeout: WANGYI_TOKEN_WAIT_MS + 2_000 }, ).catch(() => null); - const token = await page.evaluate(async () => { - const neg = (window as unknown as { neg?: { getToken?: () => Promise<{ token?: string }> } }).neg; - const result = await neg?.getToken?.().catch(() => null); - return result?.token ?? ""; - }).catch(() => ""); + const token = await page.evaluate((attr) => document.documentElement.getAttribute(attr) ?? "", WANGYI_TOKEN_ATTR) + .catch(() => ""); - return token.trim(); + await page.evaluate((attrs) => { + document.documentElement.removeAttribute(attrs.token); + document.documentElement.removeAttribute(attrs.status); + document.documentElement.removeAttribute(attrs.error); + }, { + token: WANGYI_TOKEN_ATTR, + status: WANGYI_TOKEN_STATUS_ATTR, + error: WANGYI_TOKEN_ERROR_ATTR, + }).catch(() => null); + + return token; } async function uploadImage( @@ -186,54 +440,19 @@ async function uploadImage( async function saveDraft( context: PublishAdapterContext, account: WangyiAccount, + detail: WangyiPublishDetail, html: string, token: string, ): Promise { const url = new URL(`${WANGYI_ORIGIN}/wemedia/article/status/api/publishV2.do`); url.searchParams.set("_", String(Date.now())); - url.searchParams.set("wemediaId", account.wemediaId); - url.searchParams.set("realUserId", account.realUserId); - const body = new URLSearchParams({ - wemediaId: account.wemediaId, - operation: "saveDraft", - articleId: "-1", - title: context.article.title?.trim() || "未命名文章", - content: html, - essayClassify: "", - essayUrl: "", - essayTitle: "", - essayId: "", - activitySubjectId: "", - subjectId: "", - original: "0", - picUrl: "", - onlineState: "2", - cover: "auto", - scheduled: "0", - creativeStatement: "个人观点,仅供参考", - ursToken: token, - }); + const body = buildDraftBody(context, account, detail, html, token); + const response = + await saveDraftWithPage(context, url.toString(), body).catch(() => null) + ?? await saveDraftWithSession(context, url.toString(), body); - const response = await sessionFetchJson( - context.session, - url.toString(), - { - method: "POST", - credentials: "include", - headers: await wangyiHeaders(context, { - "content-type": "application/x-www-form-urlencoded", - "x-b3-sampled": "1", - "x-b3-spanid": "0", - }), - body, - }, - ).catch((error): WangyiDraftResponse => ({ - code: -1, - message: error instanceof Error ? error.message : "wangyihao_draft_save_failed", - })); - - if (response.code !== 1 || !response.data) { + if (String(response.code) !== "1" || !response.data) { const message = wangyiResponseMessage(response); if (isWangyiChallengeMessage(message)) { throw new Error(`wangyihao_challenge_required:${message}`); @@ -248,12 +467,303 @@ async function saveDraft( return draftId.trim(); } -async function clickFirstVisibleButton(page: Page, text: string): Promise { - const button = page.locator("button").filter({ hasText: text }).first(); - return await button.click({ timeout: 5_000 }).then( - () => true, - () => false, +function buildDraftBody( + context: PublishAdapterContext, + account: WangyiAccount, + detail: WangyiPublishDetail, + html: string, + token: string, +): URLSearchParams { + const body = new URLSearchParams(); + body.set("wemediaId", detail.wemediaId || account.wemediaId); + body.set("articleId", "-1"); + body.set("title", context.article.title?.trim() || "未命名文章"); + body.set("content", html); + body.set("cover", "auto"); + body.set("operation", "saveDraft"); + body.set("scheduled", "0"); + body.set("ursToken", token); + body.set("picUrl", ""); + body.set("original", "0"); + body.set("creativeStatement", WANGYI_CREATIVE_STATEMENT); + + if (detail.onlineState) { + body.set("onlineState", detail.onlineState); + } + if (detail.hasRecommRight) { + body.set("recommendState", "0"); + } + if (detail.allowedRepost) { + body.set("allowedRepost", "1"); + } + if (detail.newProminentFlag === "1") { + body.set("newProminentState", "0"); + } + + return body; +} + +async function saveDraftWithSession( + context: PublishAdapterContext, + url: string, + body: URLSearchParams, +): Promise { + return await sessionFetchJson( + context.session, + url, + { + method: "POST", + credentials: "include", + headers: await wangyiHeaders(context, { + "content-type": "application/x-www-form-urlencoded", + "x-b3-sampled": "1", + "x-b3-spanid": "0", + }), + body, + }, + ).catch((error): WangyiDraftResponse => ({ + code: -1, + message: error instanceof Error ? error.message : "wangyihao_draft_save_failed", + })); +} + +async function saveDraftWithPage( + context: PublishAdapterContext, + url: string, + body: URLSearchParams, +): Promise { + const page = context.playwright?.page; + if (!page) { + return null; + } + + const serialized = await page.evaluate( + async ({ requestUrl, requestBody }) => { + const response = await fetch(requestUrl, { + method: "POST", + credentials: "include", + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-b3-sampled": "1", + "x-b3-spanid": "0", + }, + body: requestBody, + }); + return await response.text(); + }, + { + requestUrl: url, + requestBody: body.toString(), + }, ); + + if (!serialized) { + return null; + } + return JSON.parse(serialized) as WangyiDraftResponse; +} + +type WangyiPublishNetworkResult = { + success: boolean; + code: string; + message: string; +}; + +function compactText(value: string): string { + return value.trim().replace(/\s+/g, ""); +} + +function isPublishRequestBody(postData: string): boolean { + if (!postData) { + return false; + } + const decoded = (() => { + try { + return decodeURIComponent(postData); + } catch { + return postData; + } + })(); + return /(?:^|&)operation=publish(?:&|$)/.test(decoded); +} + +async function clickFirstVisibleAction(page: Page, texts: string[], timeoutMs = 10_000): Promise { + const selectors = [ + ".ant-modal button", + ".ant-popover button", + "button", + "[role='button']", + ".ant-btn", + "a", + ]; + const targets = texts.map(compactText); + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + for (const selector of selectors) { + const locator = page.locator(selector); + const count = await locator.count().catch(() => 0); + for (let index = 0; index < count; index += 1) { + const candidate = locator.nth(index); + const text = await candidate.innerText({ timeout: 300 }) + .catch(async () => await candidate.textContent({ timeout: 300 }).catch(() => "")); + if (!targets.includes(compactText(text || ""))) { + continue; + } + const visible = await candidate.isVisible().catch(() => false); + const enabled = await candidate.isEnabled().catch(() => false); + if (!visible || !enabled) { + continue; + } + await candidate.scrollIntoViewIfNeeded({ timeout: 1_000 }).catch(() => undefined); + await candidate.click({ timeout: 5_000 }); + return true; + } + } + + const clicked = await page.evaluate((targetTexts) => { + const compact = (value: string) => value.trim().replace(/\s+/g, ""); + const isVisible = (element: Element) => { + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return false; + } + const style = window.getComputedStyle(element); + return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0"; + }; + const isDisabled = (element: Element) => { + const htmlElement = element as HTMLElement & { disabled?: boolean }; + return Boolean(htmlElement.disabled) + || element.getAttribute("aria-disabled") === "true" + || /\b(disabled|is-disabled|ant-btn-disabled)\b/i.test(element.className?.toString() || ""); + }; + + const candidates = Array.from( + document.querySelectorAll( + ".ant-modal button, .ant-popover button, button, [role='button'], .ant-btn, a", + ), + ); + for (const candidate of candidates) { + if (!targetTexts.includes(compact(candidate.innerText || candidate.textContent || ""))) { + continue; + } + const clickable = candidate.closest("button, [role='button'], a, .ant-btn") ?? candidate; + if (!isVisible(clickable) || isDisabled(clickable)) { + continue; + } + clickable.scrollIntoView({ block: "center", inline: "center" }); + clickable.click(); + return true; + } + return false; + }, targets).catch(() => false); + + if (clicked) { + return true; + } + + await page.waitForTimeout(150).catch(() => undefined); + } + + return false; +} + +async function waitForWangyiEditorReady(page: Page): Promise { + await page.waitForFunction( + () => { + if (document.readyState === "loading") { + return false; + } + const actions = Array.from(document.querySelectorAll("button, [role='button'], .ant-btn, a")); + return actions.some((element) => (element.textContent || "").trim().replace(/\s+/g, "") === "发布"); + }, + undefined, + { timeout: 20_000 }, + ).catch(() => undefined); +} + +async function waitForPublishNetworkResponse( + page: Page, + timeoutMs: number, +): Promise { + const response = await page.waitForResponse((candidate) => { + if (!candidate.url().includes("/wemedia/article/status/api/publishV2.do")) { + return false; + } + if (candidate.request().method().toUpperCase() !== "POST") { + return false; + } + return isPublishRequestBody(candidate.request().postData() || ""); + }, { timeout: timeoutMs }).catch(() => null); + + if (!response) { + return null; + } + + const text = await response.text().catch(() => ""); + let parsed: WangyiDraftResponse | null = null; + try { + parsed = text ? JSON.parse(text) as WangyiDraftResponse : null; + } catch { + parsed = { + code: response.status(), + message: text || `http_${response.status()}`, + }; + } + + return { + success: String(parsed?.code) === "1", + code: String(parsed?.code ?? response.status()), + message: wangyiResponseMessage(parsed), + }; +} + +async function readWangyiPageMessage(page: Page): Promise { + return await page.evaluate(() => { + const text = document.body?.innerText || ""; + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => /(发布|发表|提交|审核|成功|失败|错误|验证|登录|确认)/.test(line)) + .slice(-8) + .join(" / ") + .slice(0, 300); + }).catch(() => ""); +} + +async function waitForPublishCompletion( + page: Page, + oldUrl: string, + responsePromise: Promise, + timeoutMs: number, +): Promise { + const result = await Promise.race([ + responsePromise.then((response) => response ? { kind: "network" as const, response } : null), + page.waitForURL((url) => url.toString() !== oldUrl, { timeout: timeoutMs }) + .then(() => ({ kind: "navigation" as const })) + .catch(() => null), + page.waitForFunction( + () => /(发布成功|发表成功|提交成功|已提交审核|已进入审核|审核中)/.test(document.body?.innerText || ""), + undefined, + { timeout: timeoutMs }, + ) + .then(() => ({ kind: "page" as const })) + .catch(() => null), + page.waitForTimeout(timeoutMs).then(() => null), + ]); + + if (!result) { + return false; + } + + if (result.kind === "network") { + if (result.response.success) { + return true; + } + throw new Error(`wangyihao_publish_failed:${result.response.message || result.response.code}`); + } + + return true; } async function publishDraft(context: PublishAdapterContext, draftId: string): Promise { @@ -262,29 +772,33 @@ async function publishDraft(context: PublishAdapterContext, draftId: string): Pr throw new Error("wangyihao_playwright_missing"); } - const oldUrl = `https://mp.163.com/subscribe_v4/index.html#/article-publish/${encodeURIComponent(draftId)}?option=editDraft`; - await page.goto(oldUrl, { + const draftUrl = `https://mp.163.com/subscribe_v4/index.html#/article-publish/${encodeURIComponent(draftId)}?option=editDraft`; + await page.goto(draftUrl, { waitUntil: "domcontentloaded", }); await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => null); + await waitForWangyiEditorReady(page); - await clickFirstVisibleButton(page, "发布"); + const oldUrl = page.url(); + const publishResponsePromise = waitForPublishNetworkResponse(page, 22_000); + const firstClicked = await clickFirstVisibleAction(page, ["发布"], 12_000); + if (!firstClicked) { + const message = await readWangyiPageMessage(page); + throw new Error(`wangyihao_publish_click_failed${message ? `:${message}` : ""}`); + } await sleep(WANGYI_PUBLISH_DELAY_MS, context.signal); - const firstNavigated = await page.waitForURL((url) => url.toString() !== oldUrl, { timeout: 5_000 }).then( - () => true, - () => false, - ); - if (firstNavigated) { + if (await waitForPublishCompletion(page, oldUrl, publishResponsePromise, 2_500)) { return true; } - await clickFirstVisibleButton(page, "确认发布"); - const confirmed = await page.waitForURL((url) => url.toString() !== oldUrl, { timeout: 8_000 }).then( - () => true, - () => false, - ); - return confirmed || page.url() !== oldUrl; + const confirmed = await clickFirstVisibleAction(page, ["确认发布", "确定发布", "确认", "确定"], 8_000); + if (!confirmed) { + return await waitForPublishCompletion(page, oldUrl, publishResponsePromise, 4_000); + } + + return await waitForPublishCompletion(page, oldUrl, publishResponsePromise, 10_000) + || page.url() !== oldUrl; } function buildResultPayload(articleId: string, mediaName: string): Record { @@ -326,10 +840,10 @@ function failureResult(error: unknown): ReturnType ex if (message === "wangyihao_token_missing") { return { status: "failed", - summary: "网易号发布 token 获取失败,请重新打开网易号后台确认登录状态。", + summary: WANGYIHAO_TOKEN_MISSING_MESSAGE, error: { code: "wangyihao_token_missing", - message, + message: WANGYIHAO_TOKEN_MISSING_MESSAGE, }, }; } @@ -346,23 +860,26 @@ function failureResult(error: unknown): ReturnType ex } if (message.startsWith("wangyihao_draft_save_failed") || message === "wangyihao_draft_id_missing") { + const isParameterError = /(?:wangyihao_error_)?100002|参数错误/.test(message); return { status: "failed", - summary: "网易号草稿保存失败,请稍后重试或打开网易号后台确认内容状态。", + summary: isParameterError + ? WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE + : "网易号草稿保存失败,请稍后重试或打开网易号后台确认内容状态。", error: { code: "wangyihao_draft_save_failed", - message, + message: isParameterError ? WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE : message, }, }; } - if (message === "wangyihao_publish_click_failed") { + if (message.startsWith("wangyihao_publish_click_failed")) { return { status: "failed", - summary: "网易号草稿已保存,但页面自动点击发布失败,请到网易号后台手动发布。", + summary: WANGYIHAO_PUBLISH_CLICK_MESSAGE, error: { code: "wangyihao_publish_click_failed", - message, + message: WANGYIHAO_PUBLISH_CLICK_MESSAGE, }, }; } @@ -387,9 +904,10 @@ export const wangyihaoAdapter: PublishAdapter = { if (!account) { throw new Error("wangyihao_not_logged_in"); } + const detail = await fetchPublishDetail(context, account); context.reportProgress("wangyihao.normalize_html"); - let html = normalizeArticleHtml(context.article); + let html = prepareWangyihaoArticleHtml(context.article); if (!html) { throw new Error("article_content_empty"); } @@ -409,7 +927,7 @@ export const wangyihaoAdapter: PublishAdapter = { const processed = await uploadHtmlImages(html, async (sourceUrl) => uploadImage(context, account, sourceUrl)); context.reportProgress("wangyihao.save_draft"); - const draftId = await saveDraft(context, account, processed.html, token); + const draftId = await saveDraft(context, account, detail, processed.html, token); context.reportProgress("wangyihao.publish_draft"); const published = await publishDraft(context, draftId); diff --git a/apps/desktop-client/src/shared/publisher-errors.test.ts b/apps/desktop-client/src/shared/publisher-errors.test.ts index f8e8412..2114580 100644 --- a/apps/desktop-client/src/shared/publisher-errors.test.ts +++ b/apps/desktop-client/src/shared/publisher-errors.test.ts @@ -2,9 +2,13 @@ import { describe, expect, it } from "vitest"; import { QIEHAO_REAL_NAME_AUTH_MESSAGE, + WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE, + WANGYIHAO_PUBLISH_CLICK_MESSAGE, + WANGYIHAO_TOKEN_MISSING_MESSAGE, isQiehaoRealNameAuthError, normalizePublisherErrorMessage, normalizeQiehaoPublishErrorMessage, + normalizeWangyihaoPublishErrorMessage, } from "./publisher-errors"; describe("publisher error normalization", () => { @@ -19,6 +23,31 @@ describe("publisher error normalization", () => { expect(normalizeQiehaoPublishErrorMessage("publish rejected", -5022)).toBe(QIEHAO_REAL_NAME_AUTH_MESSAGE); }); + it("normalizes Wangyihao token missing failures", () => { + expect(normalizePublisherErrorMessage("wangyihao_token_missing")).toBe(WANGYIHAO_TOKEN_MISSING_MESSAGE); + expect(normalizeWangyihaoPublishErrorMessage("Error: wangyihao_token_missing")).toBe( + WANGYIHAO_TOKEN_MISSING_MESSAGE, + ); + }); + + it("normalizes Wangyihao draft parameter failures", () => { + expect(normalizePublisherErrorMessage("wangyihao_draft_save_failed:wangyihao_error_100002")).toBe( + WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE, + ); + expect(normalizeWangyihaoPublishErrorMessage("wangyihao_draft_save_failed:参数错误")).toBe( + WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE, + ); + }); + + it("normalizes Wangyihao publish click failures", () => { + expect(normalizePublisherErrorMessage("wangyihao_publish_click_failed")).toBe( + WANGYIHAO_PUBLISH_CLICK_MESSAGE, + ); + expect(normalizeWangyihaoPublishErrorMessage("wangyihao_publish_click_failed:未找到发布按钮")).toBe( + WANGYIHAO_PUBLISH_CLICK_MESSAGE, + ); + }); + it("leaves unknown publisher errors unchanged", () => { expect(normalizePublisherErrorMessage("qiehao_publish_failed")).toBe("qiehao_publish_failed"); }); diff --git a/apps/desktop-client/src/shared/publisher-errors.ts b/apps/desktop-client/src/shared/publisher-errors.ts index ff03c5d..ced0a2c 100644 --- a/apps/desktop-client/src/shared/publisher-errors.ts +++ b/apps/desktop-client/src/shared/publisher-errors.ts @@ -1,4 +1,9 @@ export const QIEHAO_REAL_NAME_AUTH_MESSAGE = "企鹅号账号未完成实名认证,请先到企鹅号后台完成实名认证后再重试。"; +export const WANGYIHAO_TOKEN_MISSING_MESSAGE = "网易号发布凭证获取失败,请重新打开网易号后台确认登录状态。"; +export const WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE = + "网易号草稿保存失败:参数错误。请检查标题、正文、封面或账号状态后重试。"; +export const WANGYIHAO_PUBLISH_CLICK_MESSAGE = + "网易号草稿已保存,但自动发布未完成。请打开网易号后台确认草稿状态后重试。"; function normalizeErrorText(value: string | null | undefined): string | null { const trimmed = value?.trim(); @@ -37,6 +42,28 @@ export function normalizeQiehaoPublishErrorMessage( return isQiehaoRealNameAuthError(normalized, code) ? QIEHAO_REAL_NAME_AUTH_MESSAGE : normalized; } -export function normalizePublisherErrorMessage(message: string | null | undefined): string | null { - return normalizeQiehaoPublishErrorMessage(message); +export function normalizeWangyihaoPublishErrorMessage(message: string | null | undefined): string | null { + const normalized = normalizeErrorText(message); + if (!normalized) { + return null; + } + + if (/\bwangyihao_token_missing\b/i.test(normalized)) { + return WANGYIHAO_TOKEN_MISSING_MESSAGE; + } + if ( + /\bwangyihao_error_100002\b/i.test(normalized) + || /\bwangyihao_draft_save_failed:.*参数错误/i.test(normalized) + ) { + return WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE; + } + if (/\bwangyihao_publish_click_failed\b/i.test(normalized)) { + return WANGYIHAO_PUBLISH_CLICK_MESSAGE; + } + return normalized; +} + +export function normalizePublisherErrorMessage(message: string | null | undefined): string | null { + const qiehaoMessage = normalizeQiehaoPublishErrorMessage(message); + return normalizeWangyihaoPublishErrorMessage(qiehaoMessage); } diff --git a/媒体发布手工自测.md b/媒体发布手工自测.md index ff8e408..544a5bf 100644 --- a/媒体发布手工自测.md +++ b/媒体发布手工自测.md @@ -11,9 +11,8 @@ ZOL: 主打 app, 图片 ok 文字,表格 ok 搜狐:图片 ok 文字 ok 表格 ok 懂车帝 图片 ok 表格官方不支持,降级处理 (CDP) + 微信公众号:图片 ok 文字 ok 表格 ok (CDP) https://www.liefutuan.com/21397.html - -待测: -网易:媒体资质没下来 +网易:图片 ok 表格官方不支持,降级处理 (CDP)