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)