diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index 534a066..a9e98ad 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -249,7 +249,8 @@ const TOUTIAO_LOGIN_URL = "https://mp.toutiao.com/auth/page/login"; const TOUTIAO_CONSOLE_HOME_URL = "https://mp.toutiao.com/profile_v4/index"; const TOUTIAO_WORKBENCH_URL_PATTERN = /^https:\/\/mp\.toutiao\.com\/profile_v4\//i; const WEIXIN_GZH_HOME_URL = "https://mp.weixin.qq.com/"; -const WEIXIN_GZH_CONSOLE_HOME_URL = "https://mp.weixin.qq.com/cgi-bin/home"; +const WEIXIN_GZH_LOGIN_URL = "https://mp.weixin.qq.com/cgi-bin/loginpage"; +const WEIXIN_GZH_DRAFT_LIST_URL = "https://mp.weixin.qq.com/cgi-bin/appmsg"; const BILIBILI_API_ORIGIN = "https://api.bilibili.com"; const ZOL_API_ORIGIN = "https://open-api.zol.com.cn"; const ZOL_REFERER = "https://post.zol.com.cn/"; @@ -444,7 +445,33 @@ function extractToutiaoAccount(response: ToutiaoMediaInfoResponse | null | undef } function extractWeixinGzhToken(html: string): string { - return html.match(/data:\s*\{[\s\S]*?t:\s*["']([^"']+)["']/)?.[1] ?? ""; + const patterns = [ + /data:\s*\{[\s\S]*?t:\s*["']([^"']+)["']/, + /(?:\?|&|&)token=(\d+)(?:&|&|$)/, + /["']?token["']?\s*[:=]\s*["']?(\d+)["']?/, + ]; + + for (const pattern of patterns) { + const token = html.match(pattern)?.[1]?.trim(); + if (token) { + return token; + } + } + + return ""; +} + +function buildWeixinGzhDraftListURL(token?: string): string { + const url = new URL(WEIXIN_GZH_DRAFT_LIST_URL); + url.searchParams.set("begin", "0"); + url.searchParams.set("count", "10"); + url.searchParams.set("type", "77"); + url.searchParams.set("action", "list_card"); + if (token) { + url.searchParams.set("token", token); + } + url.searchParams.set("lang", "zh_CN"); + return url.toString(); } function isWeixinGzhLoggedOutText(text: string): boolean { @@ -504,14 +531,10 @@ async function resolveWeixinGzhConsoleURL(session: Session): Promise { const token = extractWeixinGzhToken(html); if (!token) { - return WEIXIN_GZH_HOME_URL; + return WEIXIN_GZH_LOGIN_URL; } - const url = new URL(WEIXIN_GZH_CONSOLE_HOME_URL); - url.searchParams.set("t", "home/index"); - url.searchParams.set("lang", "zh_CN"); - url.searchParams.set("token", token); - return url.toString(); + return buildWeixinGzhDraftListURL(token); } async function resolvePublishAccountConsoleURL( @@ -3065,8 +3088,8 @@ const publishBindingDefinitions: Record { const cookies = await context.session.cookies.get({ url }).catch(() => []); return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; "); @@ -478,7 +539,7 @@ function buildOperateForm( share_imageinfo0: "{\"list\":[]}", dot0: "{}", categories_list0: "[]", - claim_source_type0: "1", + claim_source_type0: "4", is_user_no_claim_source0: "0", }); @@ -494,7 +555,11 @@ function buildOperateForm( return form; } -function buildDraftManageUrl(articleId: string, token: string): string { +function buildDraftManageUrl(token: string): string { + return `${WEIXIN_ORIGIN}/cgi-bin/appmsg?begin=0&count=10&type=77&action=list_card&token=${encodeURIComponent(token)}&lang=zh_CN`; +} + +function buildDraftEditUrl(articleId: string, token: string): string { return `${WEIXIN_ORIGIN}/cgi-bin/appmsg?t=media/appmsg_edit&action=edit&type=77&appmsgid=${encodeURIComponent(articleId)}&token=${encodeURIComponent(token)}&lang=zh_CN`; } @@ -502,11 +567,34 @@ function buildPublishManageUrl(token: string): string { return `${WEIXIN_ORIGIN}/cgi-bin/appmsgpublish?sub=list&begin=0&count=10&token=${encodeURIComponent(token)}&lang=zh_CN`; } -function buildResultPayload( +function buildDraftResultPayload( articleId: string, mediaName: string, token: string, - externalArticleUrl: string | null, + publishError: string | null = null, + fallbackReason = "publish_verification_required", +): Record { + return { + platform: "weixin_gzh", + media_name: mediaName, + external_article_id: articleId, + external_manage_url: buildDraftManageUrl(token), + external_article_url: null, + publish_type: "draft", + ...(publishError + ? { + fallback_reason: fallbackReason, + publish_error: publishError, + } + : {}), + }; +} + +function buildPublishedResultPayload( + articleId: string, + mediaName: string, + token: string, + externalArticleUrl: string, publishId: string | null, ): Record { return { @@ -567,21 +655,163 @@ function stringField(record: Record, keys: string[]): string { return ""; } +function booleanField(record: Record, keys: string[]): boolean | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") { + return value; + } + if (typeof value === "number" && Number.isFinite(value)) { + return value !== 0; + } + if (typeof value === "string" && value.trim()) { + if (/^(true|1)$/i.test(value.trim())) { + return true; + } + if (/^(false|0)$/i.test(value.trim())) { + return false; + } + } + } + return null; +} + function normalizeComparableText(value: string): string { return value.replace(/\s+/g, "").trim(); } +function parseJsonLike(value: unknown): unknown { + return typeof value === "string" ? parseNestedJson(value) ?? value : value; +} + +function findStringFieldDeep(value: unknown, keys: string[], seen = new Set()): string { + const parsed = parseJsonLike(value); + if (parsed === null || parsed === undefined || typeof parsed !== "object") { + return ""; + } + if (seen.has(parsed)) { + return ""; + } + seen.add(parsed); + + if (Array.isArray(parsed)) { + for (const item of parsed) { + const found = findStringFieldDeep(item, keys, seen); + if (found) { + return found; + } + } + return ""; + } + + const record = parsed as Record; + const direct = stringField(record, keys); + if (direct) { + return direct; + } + for (const item of Object.values(record)) { + const found = findStringFieldDeep(item, keys, seen); + if (found) { + return found; + } + } + return ""; +} + +function findWeixinPublishedUrlByDraftId( + payload: unknown, + articleId: string, + title: string, +): string | null { + if (!articleId) { + return null; + } + const normalizedTitle = normalizeComparableText(title); + const seen = new Set(); + + const visit = (value: unknown): string | null => { + const parsed = parseJsonLike(value); + if (parsed === null || parsed === undefined || typeof parsed !== "object") { + return null; + } + if (seen.has(parsed)) { + return null; + } + seen.add(parsed); + + if (Array.isArray(parsed)) { + for (const item of parsed) { + const matched = visit(item); + if (matched) { + return matched; + } + } + return null; + } + + const record = parsed as Record; + const draftId = findStringFieldDeep(record.publish_info ?? record, [ + "draft_msgid", + "draftMsgId", + "draft_appmsgid", + "draftAppMsgId", + ]); + if (draftId === articleId) { + const appmsgItems = parseJsonLike(record.appmsgex ?? record.appmsg_info ?? []); + if (Array.isArray(appmsgItems)) { + for (const item of appmsgItems) { + const itemRecord = parseJsonLike(item); + if (!itemRecord || typeof itemRecord !== "object" || Array.isArray(itemRecord)) { + continue; + } + const candidate = itemRecord as Record; + const publicUrl = normalizeWeixinPublicUrl(stringField(candidate, [ + "link", + "url", + "content_url", + "contentUrl", + "article_url", + "articleUrl", + ])); + if (!publicUrl || booleanField(candidate, ["is_deleted", "isDeleted", "deleted"]) === true) { + continue; + } + const itemTitle = stringField(candidate, ["title", "arttitle", "article_title", "articleTitle"]); + if (!normalizedTitle || !itemTitle || normalizeComparableText(itemTitle) === normalizedTitle) { + return publicUrl; + } + } + } + } + + for (const item of Object.values(record)) { + const matched = visit(item); + if (matched) { + return matched; + } + } + return null; + }; + + return visit(payload); +} + function findWeixinPublishedUrl( payload: unknown, articleId: string, title: string, allowLatestFallback: boolean, ): string | null { + const draftMatchedUrl = findWeixinPublishedUrlByDraftId(payload, articleId, title); + if (draftMatchedUrl) { + return draftMatchedUrl; + } + const seen = new Set(); const fallbackUrls: string[] = []; const normalizedTitle = normalizeComparableText(title); - const visit = (value: unknown): string | null => { + const visit = (value: unknown, inheritedIdMatches = false): string | null => { if (value === null || value === undefined) { return null; } @@ -592,7 +822,7 @@ function findWeixinPublishedUrl( return null; } const parsed = parseNestedJson(value); - return parsed ? visit(parsed) : null; + return parsed ? visit(parsed, inheritedIdMatches) : null; } if (typeof value !== "object") { return null; @@ -604,7 +834,7 @@ function findWeixinPublishedUrl( if (Array.isArray(value)) { for (const item of value) { - const matched = visit(item); + const matched = visit(item, inheritedIdMatches); if (matched) { return matched; } @@ -633,24 +863,29 @@ function findWeixinPublishedUrl( "msgid", "msg_id", "mid", + "draft_msgid", + "draftMsgId", + "draft_appmsgid", + "draftAppMsgId", ]); const itemTitle = stringField(record, ["title", "arttitle", "article_title", "articleTitle"]); - const idMatches = Boolean(articleId && id === articleId); + const idMatches = inheritedIdMatches || Boolean(articleId && id === articleId); const titleMatches = Boolean( normalizedTitle && itemTitle && normalizeComparableText(itemTitle) === normalizedTitle, ); + const isDeleted = booleanField(record, ["is_deleted", "isDeleted", "deleted"]) === true; - if (publicUrl) { + if (publicUrl && !isDeleted) { fallbackUrls.push(publicUrl); - if (idMatches || titleMatches) { + if (articleId ? idMatches : titleMatches) { return publicUrl; } } for (const item of Object.values(record)) { - const matched = visit(item); + const matched = visit(item, idMatches); if (matched) { return matched; } @@ -662,13 +897,17 @@ function findWeixinPublishedUrl( if (matched) { return matched; } - return allowLatestFallback ? fallbackUrls[0] ?? null : null; + return !articleId && allowLatestFallback ? fallbackUrls[0] ?? null : null; } -async function waitForWeixinPublishPoll(signal: AbortSignal): Promise { - if (signal.aborted) { +function throwIfWeixinAborted(signal?: AbortSignal): void { + if (signal?.aborted) { throw new Error("adapter_aborted"); } +} + +async function waitForWeixinDelay(signal: AbortSignal | undefined, ms: number): Promise { + throwIfWeixinAborted(signal); await new Promise((resolve, reject) => { let timeout: ReturnType; const abort = () => { @@ -676,68 +915,890 @@ async function waitForWeixinPublishPoll(signal: AbortSignal): Promise { reject(new Error("adapter_aborted")); }; timeout = setTimeout(() => { - signal.removeEventListener("abort", abort); + signal?.removeEventListener("abort", abort); resolve(); - }, WEIXIN_PUBLISH_POLL_INTERVAL_MS); - signal.addEventListener("abort", abort, { once: true }); + }, ms); + signal?.addEventListener("abort", abort, { once: true }); }); } -function buildMassSendForm(meta: WeixinMeta, articleId: string): URLSearchParams { - return new URLSearchParams({ - token: meta.token, - lang: "zh_CN", - f: "json", - ajax: "1", - random: String(Math.random()), - type: "10", - appmsgid: articleId, - groupid: "-1", - sex: "0", - country: "", - province: "", - city: "", - synctxweibo: "0", - synctxnews: "0", - imgcode: "", - operation_seq: String(Date.now()), - needcomment: "1", - }); +async function waitForWeixinPublishPoll(signal: AbortSignal): Promise { + await waitForWeixinDelay(signal, WEIXIN_PUBLISH_POLL_INTERVAL_MS); } -async function publishDraft( - context: PublishAdapterContext, - meta: WeixinMeta, - articleId: string, -): Promise { - const url = new URL(`${WEIXIN_ORIGIN}/cgi-bin/masssend`); - url.searchParams.set("action", "send"); - url.searchParams.set("t", "ajax-response"); - url.searchParams.set("token", meta.token); - url.searchParams.set("lang", "zh_CN"); +function parseWeixinOperateResponseText(text: string): WeixinOperateResponse { + const trimmed = text.trim().replace(/^\uFEFF/, ""); + if (!trimmed) { + return { + ret: -1, + base_resp: { + ret: -1, + err_msg: "微信公众号发表响应为空", + }, + }; + } - return await sessionFetchJson( - context.session, - url.toString(), - { - method: "POST", - credentials: "include", - signal: context.signal, - headers: await weixinHeaders(context, { - "content-type": "application/x-www-form-urlencoded", - referer: buildDraftManageUrl(articleId, meta.token), - }), - body: buildMassSendForm(meta, articleId), - }, - ).catch((error): WeixinOperateResponse => ({ + const candidates = [trimmed]; + const jsonStart = trimmed.indexOf("{"); + const jsonEnd = trimmed.lastIndexOf("}"); + if (jsonStart >= 0 && jsonEnd > jsonStart) { + candidates.push(trimmed.slice(jsonStart, jsonEnd + 1)); + } + + for (const candidate of candidates) { + try { + return JSON.parse(candidate) as WeixinOperateResponse; + } catch { + // Try the next candidate. + } + } + + return { ret: -1, base_resp: { ret: -1, - err_msg: error instanceof Error ? error.message : "weixin_gzh_publish_failed", + err_msg: trimmed.slice(0, 500), }, + }; +} + +function isWeixinPublishNetworkResponse(response: PlaywrightResponse): boolean { + if (response.request().method().toUpperCase() !== "POST") { + return false; + } + let url: URL; + try { + url = new URL(response.url()); + } catch { + return false; + } + if (url.origin !== WEIXIN_ORIGIN || !url.pathname.startsWith("/cgi-bin/")) { + return false; + } + + const action = url.searchParams.get("action") || ""; + const sub = url.searchParams.get("sub") || ""; + if (url.pathname.endsWith("/masssend") && action === "send") { + return true; + } + if (url.pathname.endsWith("/appmsgpublish")) { + return /(publish|send)/i.test(`${action} ${sub}`); + } + if (url.pathname.endsWith("/operate_appmsg")) { + return /(publish|send)/i.test(`${action} ${sub}`); + } + return false; +} + +async function waitForWeixinPublishNetworkResponse( + page: PlaywrightPage, + timeoutMs = WEIXIN_PUBLISH_RESPONSE_TIMEOUT_MS, +): Promise { + const response = await page.waitForResponse( + isWeixinPublishNetworkResponse, + { timeout: timeoutMs }, + ).catch(() => null); + if (!response) { + return null; + } + + const text = await response.text().catch((error) => { + return error instanceof Error ? error.message : "weixin_gzh_publish_response_read_failed"; + }); + return parseWeixinOperateResponseText(text); +} + +async function waitForWeixinEditorReady(page: PlaywrightPage): Promise { + await page.waitForLoadState("domcontentloaded", { timeout: WEIXIN_EDITOR_READY_TIMEOUT_MS }).catch(() => null); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => null); + await page.waitForFunction( + () => { + const text = document.body?.innerText || ""; + return /发表|发布|草稿|图文|登录/.test(text); + }, + undefined, + { timeout: WEIXIN_EDITOR_READY_TIMEOUT_MS }, + ).catch(() => null); +} + +async function readWeixinPageMessage( + page: PlaywrightPage, + options: { includeBodyFallback?: boolean } = {}, +): Promise { + const includeBodyFallback = options.includeBodyFallback ?? true; + return await page.evaluate((allowBodyFallback) => { + const isVisible = (element: Element): boolean => { + const target = element as HTMLElement; + const rect = target.getBoundingClientRect(); + const style = window.getComputedStyle(target); + return rect.width > 0 + && rect.height > 0 + && style.display !== "none" + && style.visibility !== "hidden" + && style.opacity !== "0"; + }; + const selectors = [ + ".weui-desktop-toast", + ".weui-desktop-dialog", + ".weui-desktop-dialog__wrp", + ".weui-desktop-tips", + ".weui-desktop-msg", + ".weui-desktop-popover", + ".weui-desktop-popover__inner", + ".weui-desktop-warn", + ".weui-desktop-error", + "[role='alert']", + "[role='dialog']", + ]; + const messages = selectors.flatMap((selector) => { + return Array.from(document.querySelectorAll(selector)) + .filter(isVisible) + .map((element) => (element as HTMLElement).innerText || element.textContent || "") + .map((text) => text.trim()) + .filter(Boolean); + }); + if (messages.length) { + return messages.join("\n").slice(0, 1000); + } + if (!allowBodyFallback) { + return ""; + } + return (document.body?.innerText || "").trim().slice(0, 1000); + }, includeBodyFallback).catch(() => ""); +} + +async function waitForWeixinPublishDialog( + page: PlaywrightPage, + signal: AbortSignal, +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS) { + throwIfWeixinAborted(signal); + const opened = await page.evaluate(() => { + const compact = (value: string | null | undefined) => (value || "").replace(/\s+/g, ""); + const isVisible = (element: Element): boolean => { + const target = element as HTMLElement; + const rect = target.getBoundingClientRect(); + const style = window.getComputedStyle(target); + return rect.width > 0 + && rect.height > 0 + && style.display !== "none" + && style.visibility !== "hidden" + && style.opacity !== "0"; + }; + const dialogs = Array.from(document.querySelectorAll( + "[role='dialog'], .weui-desktop-dialog, .weui-desktop-dialog__wrp, .weui-dialog", + )).filter(isVisible); + return dialogs.some((dialog) => { + const text = compact((dialog as HTMLElement).innerText || dialog.textContent || ""); + return text.includes("群发通知") + || (text.includes("发表") && (text.includes("取消") || text.includes("确认"))); + }); + }).catch(() => false); + if (opened) { + return true; + } + await waitForWeixinDelay(signal, WEIXIN_ACTION_RETRY_INTERVAL_MS); + } + return false; +} + +async function clickWeixinVisibleAction( + page: PlaywrightPage, + texts: string[], + options: { + scope?: "page" | "dialog"; + timeoutMs?: number; + signal?: AbortSignal; + } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 8_000; + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + throwIfWeixinAborted(options.signal); + const result = await page.evaluate( + ({ scope, targetTexts }): WeixinPageActionResult => { + const compact = (value: string | null | undefined) => (value || "").replace(/\s+/g, ""); + const wanted = targetTexts.map(compact).filter(Boolean); + const isVisible = (element: Element): boolean => { + const target = element as HTMLElement; + const rect = target.getBoundingClientRect(); + const style = window.getComputedStyle(target); + return rect.width > 0 + && rect.height > 0 + && style.display !== "none" + && style.visibility !== "hidden" + && style.opacity !== "0"; + }; + const isDisabled = (element: Element): boolean => { + const target = element as HTMLButtonElement; + const className = String((element as HTMLElement).className || ""); + return Boolean(target.disabled) + || element.getAttribute("aria-disabled") === "true" + || /\b(disabled|is-disabled|weui-desktop-btn_disabled)\b/i.test(className); + }; + const rootSelector = "[role='dialog'], .weui-desktop-dialog, .weui-desktop-dialog__wrp, .weui-dialog"; + const roots = scope === "dialog" + ? Array.from(document.querySelectorAll(rootSelector)).filter(isVisible) + : [document.body].filter(Boolean); + const effectiveRoots = roots.length ? roots : [document.body]; + const selector = [ + "button", + "a", + "[role='button']", + ".weui-desktop-btn", + ".weui-btn", + ".btn", + ".js_btn", + ".js_submit", + ".js_publish", + ].join(","); + + const scored: Array<{ element: HTMLElement; text: string; score: number }> = []; + const seen = new Set(); + for (const root of effectiveRoots) { + const candidates = Array.from(root.querySelectorAll(selector)); + for (const element of candidates) { + if (seen.has(element) || !isVisible(element) || isDisabled(element)) { + continue; + } + seen.add(element); + const text = compact((element as HTMLElement).innerText || element.textContent || ""); + if (!text) { + continue; + } + const exact = wanted.includes(text); + const contains = wanted.some((target) => text.includes(target)); + if (!exact && !contains) { + continue; + } + const className = String((element as HTMLElement).className || ""); + const rect = (element as HTMLElement).getBoundingClientRect(); + const primary = /(primary|confirm|success|submit|publish|weui-desktop-btn_primary)/i.test(className); + const score = (exact ? 1000 : 0) + (primary ? 100 : 0) + rect.top / 10_000; + scored.push({ element: element as HTMLElement, text, score }); + } + } + + scored.sort((left, right) => right.score - left.score); + const target = scored[0]; + if (!target) { + return { + clicked: false, + reason: "not_found", + }; + } + target.element.click(); + return { + clicked: true, + text: target.text, + }; + }, + { + scope: options.scope ?? "page", + targetTexts: texts, + }, + ).catch((error): WeixinPageActionResult => ({ + clicked: false, + reason: error instanceof Error ? error.message : "click_failed", + })); + + if (result.clicked) { + return result; + } + await waitForWeixinDelay(options.signal, WEIXIN_ACTION_RETRY_INTERVAL_MS); + } + return { + clicked: false, + reason: "not_found", + }; +} + +async function disableWeixinMassNotification(page: PlaywrightPage): Promise { + return await page.evaluate((): WeixinMassNotificationResult => { + const compact = (value: string | null | undefined) => (value || "").replace(/\s+/g, ""); + const isVisible = (element: Element): boolean => { + const target = element as HTMLElement; + if (target instanceof HTMLInputElement && target.type === "checkbox") { + return true; + } + const rect = target.getBoundingClientRect(); + const style = window.getComputedStyle(target); + return rect.width > 0 + && rect.height > 0 + && style.display !== "none" + && style.visibility !== "hidden" + && style.opacity !== "0"; + }; + const switchState = (element: Element): "on" | "off" | "unknown" => { + const input = element instanceof HTMLInputElement + ? element + : element.querySelector("input[type='checkbox']"); + if (input instanceof HTMLInputElement) { + return input.checked ? "on" : "off"; + } + const ariaChecked = element.getAttribute("aria-checked") || element.getAttribute("aria-pressed"); + if (ariaChecked === "true") { + return "on"; + } + if (ariaChecked === "false") { + return "off"; + } + const className = String((element as HTMLElement).className || ""); + if (/\b(off|disabled|close|closed|unchecked)\b/i.test(className)) { + return "off"; + } + if (/(_on|--on|\bon\b|checked|active|selected|opened|enabled)/i.test(className)) { + return "on"; + } + const text = compact((element as HTMLElement).innerText || element.textContent || ""); + if (/已关闭|关闭中|不开启/.test(text)) { + return "off"; + } + if (/已开启|开启中/.test(text)) { + return "on"; + } + return "unknown"; + }; + const textElements: HTMLElement[] = []; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode; + const text = compact(node.textContent || ""); + const parent = node.parentElement; + if (parent && text.includes("群发通知")) { + textElements.push(parent); + } + } + if (!textElements.length) { + return { + found: false, + changed: false, + reason: "label_missing", + }; + } + + const switchSelector = [ + "input[type='checkbox']", + "[role='switch']", + ".weui-desktop-switch", + ".weui-switch", + "[class*='switch']", + "[class*='Switch']", + ].join(","); + + for (const labelElement of textElements) { + let root: HTMLElement | null = labelElement; + for (let depth = 0; root && root !== document.body && depth < 8; depth += 1, root = root.parentElement) { + const switches = Array.from(root.querySelectorAll(switchSelector)) + .filter((candidate) => isVisible(candidate)); + for (const candidate of switches) { + const before = switchState(candidate); + if (before === "off") { + return { + found: true, + changed: false, + before, + }; + } + const input = candidate instanceof HTMLInputElement + ? candidate + : candidate.querySelector("input[type='checkbox']"); + const clickTarget = + input?.closest(".weui-desktop-switch, .weui-switch, label") + ?? candidate.querySelector(".weui-desktop-switch__box") + ?? candidate; + (clickTarget as HTMLElement).click(); + return { + found: true, + changed: true, + before, + }; + } + } + } + + return { + found: true, + changed: false, + reason: "switch_missing", + }; + }).catch((error): WeixinMassNotificationResult => ({ + found: false, + changed: false, + reason: error instanceof Error ? error.message : "switch_detect_failed", })); } +async function waitForWeixinMassNotificationOff( + page: PlaywrightPage, + signal: AbortSignal, + timeoutMs = 6_000, +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + throwIfWeixinAborted(signal); + const checked = await page.evaluate(() => { + const input = document.querySelector( + ".mass_send__notify input[type='checkbox'], .mass-send__notify input[type='checkbox']", + ) as HTMLInputElement | null; + return input?.checked ?? null; + }).catch(() => null); + if (checked === false) { + return true; + } + await waitForWeixinDelay(signal, WEIXIN_ACTION_RETRY_INTERVAL_MS); + } + return false; +} + +function throwWeixinPublishResponseFailure(response: WeixinOperateResponse | null): void { + if (!response || isWeixinSuccessResponse(response)) { + return; + } + const message = formatWeixinError(response, "微信公众号发布失败"); + if (isWeixinChallengeResponse(response) || isWeixinChallengeMessage(message)) { + throw new Error(`weixin_gzh_challenge_required:${message}`); + } + throw new Error(`weixin_gzh_publish_failed:${message}`); +} + +async function assertNoWeixinPublishFeedbackError(page: PlaywrightPage): Promise { + const message = await readWeixinPageMessage(page, { includeBodyFallback: false }); + if (isWeixinPublishAuthorizationMessage(message)) { + throw new Error(`weixin_gzh_publish_authorization_required:${message}`); + } + if (isWeixinChallengeMessage(message)) { + throw new Error(`weixin_gzh_challenge_required:${message}`); + } + if (/失败|错误|参数错误|服务错误/.test(message)) { + throw new Error(`weixin_gzh_publish_failed:${message}`); + } +} + +async function detectWeixinPublishAuthorization(page: PlaywrightPage): Promise { + const result = await page.evaluate(() => { + const isVisible = (element: Element): boolean => { + const target = element as HTMLElement; + const rect = target.getBoundingClientRect(); + const style = window.getComputedStyle(target); + return rect.width > 0 + && rect.height > 0 + && style.display !== "none" + && style.visibility !== "hidden" + && style.opacity !== "0"; + }; + const selectors = [ + "[role='dialog']", + ".weui-desktop-dialog", + ".weui-desktop-dialog__wrp", + ".weui-dialog", + ".new_mass_send_dialog", + ".weui-desktop-toast", + ".weui-desktop-msg", + ".weui-desktop-tips", + "[class*='qrcode']", + "[class*='qr_code']", + "[class*='qrcheck']", + "[id*='qrcode']", + "[id*='qr_code']", + ]; + const visibleBlocks = selectors.flatMap((selector) => { + return Array.from(document.querySelectorAll(selector)) + .filter(isVisible) + .map((element) => ({ + text: ((element as HTMLElement).innerText || element.textContent || "").trim(), + hasQrMedia: Array.from(element.querySelectorAll("img, canvas, iframe, svg")).some((media) => { + const value = [ + media.getAttribute("alt"), + media.getAttribute("title"), + media.getAttribute("src"), + media.getAttribute("class"), + media.getAttribute("id"), + ].filter(Boolean).join(" "); + const rect = (media as HTMLElement).getBoundingClientRect(); + return rect.width >= 80 + && rect.height >= 80 + && /(qr|qrcode|二维码|扫码|scan)/i.test(value); + }), + })); + }); + + for (const block of visibleBlocks) { + const text = block.text.replace(/\s+/g, " ").trim(); + if (/(微信验证|管理员|运营者|微信号|扫码|扫描|二维码|授权|发文保护|发表保护|qrcode|qr code|scan|verify)/i.test(text)) { + return text.slice(0, 1000); + } + if (block.hasQrMedia) { + return (text || "需要管理员或运营者微信扫码授权发布").slice(0, 1000); + } + } + return ""; + }).catch(() => ""); + return result.trim() || null; +} + +async function waitForWeixinPublishAuthorization( + page: PlaywrightPage, + signal: AbortSignal, + timeoutMs = 1_500, +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + throwIfWeixinAborted(signal); + const message = await detectWeixinPublishAuthorization(page); + if (message) { + return message; + } + await waitForWeixinDelay(signal, 150); + } + return null; +} + +async function waitForWeixinPublishResponseOrAuthorization( + page: PlaywrightPage, + signal: AbortSignal, + responsePromise: Promise, +): Promise { + const result = await Promise.race([ + responsePromise.then((response) => ({ + kind: "response", + response, + })), + waitForWeixinPublishAuthorization(page, signal, 3_000).then((message) => { + return message + ? { + kind: "authorization", + message, + } + : null; + }), + ]); + + if (result?.kind === "authorization") { + throw new Error(`weixin_gzh_publish_authorization_required:${result.message}`); + } + if (result?.kind === "response") { + const authorizationMessage = await waitForWeixinPublishAuthorization(page, signal, 2_000); + if (authorizationMessage) { + throw new Error(`weixin_gzh_publish_authorization_required:${authorizationMessage}`); + } + return result.response; + } + const response = await responsePromise; + const authorizationMessage = await waitForWeixinPublishAuthorization(page, signal, 2_000); + if (authorizationMessage) { + throw new Error(`weixin_gzh_publish_authorization_required:${authorizationMessage}`); + } + return response; +} + +async function completeWeixinPublishConfirmation( + context: PublishAdapterContext, + page: PlaywrightPage, + directResponsePromise: Promise, +): Promise { + const dialogOpened = await waitForWeixinPublishDialog(page, context.signal); + if (!dialogOpened) { + const response = await waitForWeixinPublishResponseOrAuthorization( + page, + context.signal, + directResponsePromise, + ); + throwWeixinPublishResponseFailure(response); + await assertNoWeixinPublishFeedbackError(page); + return response; + } + + context.reportProgress("weixin_gzh.disable_mass_notification"); + await waitForWeixinDelay(context.signal, 300); + const notification = await disableWeixinMassNotification(page); + if (notification.found && notification.reason === "switch_missing") { + const message = await readWeixinPageMessage(page); + throw new Error(`weixin_gzh_publish_failed:未找到群发通知开关,无法关闭后发表${message ? `:${message}` : ""}`); + } + if (notification.changed && !(await waitForWeixinMassNotificationOff(page, context.signal))) { + const message = await readWeixinPageMessage(page); + throw new Error(`weixin_gzh_publish_failed:群发通知开关未能关闭${message ? `:${message}` : ""}`); + } + await waitForWeixinDelay(context.signal, 300); + + const responsePromise = waitForWeixinPublishNetworkResponse(page); + const confirm = await clickWeixinVisibleAction(page, ["发表", "确认发表", "发布", "确认发布"], { + scope: "dialog", + timeoutMs: 8_000, + signal: context.signal, + }); + if (!confirm.clicked) { + const message = await readWeixinPageMessage(page); + throw new Error(`weixin_gzh_publish_failed:未找到微信公众号确认发表按钮${message ? `:${message}` : ""}`); + } + const authorizationAfterConfirm = await waitForWeixinPublishAuthorization(page, context.signal, 1_000); + if (authorizationAfterConfirm) { + throw new Error(`weixin_gzh_publish_authorization_required:${authorizationAfterConfirm}`); + } + + const continueConfirm = await clickWeixinVisibleAction(page, ["继续发表"], { + scope: "dialog", + timeoutMs: 8_000, + signal: context.signal, + }); + if (continueConfirm.clicked) { + context.reportProgress("weixin_gzh.continue_publish_without_notification"); + } + + const response = await waitForWeixinPublishResponseOrAuthorization( + page, + context.signal, + responsePromise, + ); + throwWeixinPublishResponseFailure(response); + await assertNoWeixinPublishFeedbackError(page); + return response; +} + +async function clickWeixinDraftCardPublish( + page: PlaywrightPage, + articleId: string, + title: string, + signal: AbortSignal, +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < 12_000) { + throwIfWeixinAborted(signal); + const result = await page.evaluate( + ({ targetArticleId, targetTitle }): WeixinPageActionResult => { + const compact = (value: string | null | undefined) => (value || "").replace(/\s+/g, ""); + const normalizedTitle = compact(targetTitle); + const actionSelector = [ + "button", + "a", + "[role='button']", + ".weui-desktop-btn", + ".weui-btn", + ".btn", + ".js_btn", + ".js_submit", + ".js_publish", + ].join(","); + const isVisible = (element: Element): boolean => { + const target = element as HTMLElement; + const rect = target.getBoundingClientRect(); + const style = window.getComputedStyle(target); + return rect.width > 0 + && rect.height > 0 + && style.display !== "none" + && style.visibility !== "hidden" + && style.opacity !== "0"; + }; + const isDisabled = (element: Element): boolean => { + const target = element as HTMLButtonElement; + const className = String((element as HTMLElement).className || ""); + return Boolean(target.disabled) + || element.getAttribute("aria-disabled") === "true" + || /\b(disabled|is-disabled|weui-desktop-btn_disabled)\b/i.test(className); + }; + const isPublishAction = (element: Element): boolean => { + const text = compact((element as HTMLElement).innerText || element.textContent || ""); + return text === "发表" || text === "发布" || text.endsWith("发表") || text.endsWith("发布"); + }; + const rootCandidates: HTMLElement[] = []; + const pushAncestors = (element: Element | null, depthLimit: number) => { + let root = element?.parentElement ?? null; + for (let depth = 0; root && root !== document.body && depth < depthLimit; depth += 1, root = root.parentElement) { + if (!rootCandidates.includes(root)) { + rootCandidates.push(root); + } + } + }; + + if (targetArticleId) { + for (const element of Array.from(document.querySelectorAll("a, [href], [data-appmsgid], [data-id], [data-msgid]"))) { + const value = [ + element.getAttribute("href"), + element.getAttribute("data-appmsgid"), + element.getAttribute("data-id"), + element.getAttribute("data-msgid"), + ].join(" "); + if (value.includes(targetArticleId)) { + pushAncestors(element, 8); + } + } + } + + if (normalizedTitle) { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode; + const text = compact(node.textContent || ""); + if (text && (text.includes(normalizedTitle) || normalizedTitle.includes(text))) { + pushAncestors(node.parentElement, 8); + } + } + } + + const scored: Array<{ element: HTMLElement; text: string; score: number }> = []; + for (const root of rootCandidates) { + const rootText = compact(root.innerText || root.textContent || ""); + const titleMatched = Boolean(normalizedTitle && (rootText.includes(normalizedTitle) || normalizedTitle.includes(rootText))); + const idMatched = Boolean(targetArticleId && root.innerHTML.includes(targetArticleId)); + const actions = Array.from(root.querySelectorAll(actionSelector)) + .filter((element) => isVisible(element) && !isDisabled(element) && isPublishAction(element)); + for (const action of actions) { + const rect = (action as HTMLElement).getBoundingClientRect(); + const rootRect = root.getBoundingClientRect(); + const text = compact((action as HTMLElement).innerText || action.textContent || ""); + const score = + (idMatched ? 2000 : 0) + + (titleMatched ? 1000 : 0) + + (text === "发表" || text === "发布" ? 100 : 0) + - (rootRect.width * rootRect.height) / 1_000_000 + - rect.top / 100_000; + scored.push({ + element: action as HTMLElement, + text, + score, + }); + } + } + + if (!scored.length) { + const actions = Array.from(document.querySelectorAll(actionSelector)) + .filter((element) => isVisible(element) && !isDisabled(element) && isPublishAction(element)); + for (const action of actions) { + const rect = (action as HTMLElement).getBoundingClientRect(); + const text = compact((action as HTMLElement).innerText || action.textContent || ""); + scored.push({ + element: action as HTMLElement, + text, + score: (text === "发表" || text === "发布" ? 100 : 0) - rect.top / 1000, + }); + } + } + + scored.sort((left, right) => right.score - left.score); + const target = scored[0]; + if (!target) { + return { + clicked: false, + reason: "not_found", + }; + } + target.element.click(); + return { + clicked: true, + text: target.text, + }; + }, + { + targetArticleId: articleId, + targetTitle: title, + }, + ).catch((error): WeixinPageActionResult => ({ + clicked: false, + reason: error instanceof Error ? error.message : "click_failed", + })); + + if (result.clicked) { + return result; + } + await waitForWeixinDelay(signal, WEIXIN_ACTION_RETRY_INTERVAL_MS); + } + return { + clicked: false, + reason: "not_found", + }; +} + +async function publishDraftFromDraftManagePage( + context: PublishAdapterContext, + meta: WeixinMeta, + articleId: string, + title: string, +): Promise { + const page = context.playwright?.page; + if (!page || page.isClosed()) { + throw new Error("weixin_gzh_playwright_page_missing"); + } + + await page.goto(buildDraftManageUrl(meta.token), { + waitUntil: "domcontentloaded", + }); + await waitForWeixinEditorReady(page); + + const initialMessage = await readWeixinPageMessage(page); + if (/loginpage|\/cgi-bin\/login/i.test(page.url()) || /登录超时|重新登录/.test(initialMessage)) { + throw new Error("weixin_gzh_not_logged_in"); + } + + const directResponsePromise = waitForWeixinPublishNetworkResponse( + page, + WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000, + ); + const popupPromise = page.waitForEvent("popup", { timeout: 8_000 }).catch(() => null); + const openDialog = await clickWeixinDraftCardPublish(page, articleId, title, context.signal); + if (!openDialog.clicked) { + const message = await readWeixinPageMessage(page); + throw new Error(`weixin_gzh_publish_failed:未找到草稿箱文章的发表按钮${message ? `:${message}` : ""}`); + } + + const popup = await popupPromise; + if (popup && !popup.isClosed()) { + popup.setDefaultTimeout(WEIXIN_EDITOR_READY_TIMEOUT_MS); + popup.setDefaultNavigationTimeout(WEIXIN_EDITOR_READY_TIMEOUT_MS); + await waitForWeixinEditorReady(popup); + const popupMessage = await readWeixinPageMessage(popup); + if (/loginpage|\/cgi-bin\/login/i.test(popup.url()) || /登录超时|重新登录/.test(popupMessage)) { + throw new Error("weixin_gzh_not_logged_in"); + } + const popupResponse = await completeWeixinPublishConfirmation( + context, + popup, + waitForWeixinPublishNetworkResponse(popup, WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000), + ); + await popup.close().catch(() => undefined); + return popupResponse; + } + + return await completeWeixinPublishConfirmation(context, page, directResponsePromise); +} + +async function publishDraftFromEditorPage( + context: PublishAdapterContext, + meta: WeixinMeta, + articleId: string, +): Promise { + const page = context.playwright?.page; + if (!page || page.isClosed()) { + throw new Error("weixin_gzh_playwright_page_missing"); + } + + await page.goto(buildDraftEditUrl(articleId, meta.token), { + waitUntil: "domcontentloaded", + }); + await waitForWeixinEditorReady(page); + + const initialMessage = await readWeixinPageMessage(page); + if (/loginpage|\/cgi-bin\/login/i.test(page.url()) || /登录超时|重新登录/.test(initialMessage)) { + throw new Error("weixin_gzh_not_logged_in"); + } + + const directResponsePromise = waitForWeixinPublishNetworkResponse( + page, + WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000, + ); + const openDialog = await clickWeixinVisibleAction(page, ["发表", "发布"], { + timeoutMs: 12_000, + signal: context.signal, + }); + if (!openDialog.clicked) { + const message = await readWeixinPageMessage(page); + throw new Error(`weixin_gzh_publish_failed:未找到微信公众号发表按钮${message ? `:${message}` : ""}`); + } + + return await completeWeixinPublishConfirmation(context, page, directResponsePromise); +} + function buildPublishedListUrl( meta: WeixinMeta, title: string, @@ -891,7 +1952,7 @@ function failureResult(error: unknown): ReturnType ex export const weixinGzhAdapter: PublishAdapter = { platform: "weixin_gzh", - executionMode: "session", + executionMode: "playwright", async publish(context) { try { context.reportProgress("weixin_gzh.detect_login"); @@ -946,20 +2007,71 @@ export const weixinGzhAdapter: PublishAdapter = { } context.reportProgress("weixin_gzh.publish"); - const publishResponse = await publishDraft(context, meta, articleId); - if (!isWeixinSuccessResponse(publishResponse)) { - const message = formatWeixinError(publishResponse, "微信公众号发布失败"); - if (isWeixinChallengeMessage(message)) { - throw new Error(`weixin_gzh_challenge_required:${message}`); + const articleTitle = context.article.title?.trim() || "未命名文章"; + let publishResponse: WeixinOperateResponse | null; + try { + publishResponse = await publishDraftFromDraftManagePage(context, meta, articleId, articleTitle); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isWeixinPublishAuthorizationRequiredError(message)) { + const authorizationMessage = normalizeWeixinPublishAuthorizationError(message); + return { + status: "succeeded", + summary: "微信公众号草稿已保存,发表需要在公众号后台完成微信验证/授权发布,请打开草稿箱处理。", + payload: buildDraftResultPayload( + articleId, + meta.nickName, + meta.token, + authorizationMessage, + "publish_authorization_required", + ), + }; + } + if (message.startsWith("weixin_gzh_challenge_required") || isWeixinChallengeMessage(message)) { + return { + status: "succeeded", + summary: "微信公众号草稿已保存,当前账号发表仍触发平台验证,请在草稿箱手动处理后发表。", + payload: buildDraftResultPayload(articleId, meta.nickName, meta.token, message), + }; + } + if (message.includes("未找到草稿箱文章的发表按钮")) { + try { + publishResponse = await publishDraftFromEditorPage(context, meta, articleId); + } catch (fallbackError) { + const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + if (isWeixinPublishAuthorizationRequiredError(fallbackMessage)) { + const authorizationMessage = normalizeWeixinPublishAuthorizationError(fallbackMessage); + return { + status: "succeeded", + summary: "微信公众号草稿已保存,发表需要在公众号后台完成微信验证/授权发布,请打开草稿箱处理。", + payload: buildDraftResultPayload( + articleId, + meta.nickName, + meta.token, + authorizationMessage, + "publish_authorization_required", + ), + }; + } + if (fallbackMessage.startsWith("weixin_gzh_challenge_required") || isWeixinChallengeMessage(fallbackMessage)) { + return { + status: "succeeded", + summary: "微信公众号草稿已保存,当前账号发表仍触发平台验证,请在草稿箱手动处理后发表。", + payload: buildDraftResultPayload(articleId, meta.nickName, meta.token, fallbackMessage), + }; + } + throw fallbackError; + } + } else { + throw error; } - throw new Error(`weixin_gzh_publish_failed:${message}`); } context.reportProgress("weixin_gzh.resolve_public_url"); const directArticleUrl = findWeixinPublishedUrl( publishResponse, articleId, - context.article.title?.trim() || "未命名文章", + articleTitle, true, ); const externalArticleUrl = directArticleUrl @@ -967,22 +2079,25 @@ export const weixinGzhAdapter: PublishAdapter = { context, meta, articleId, - context.article.title?.trim() || "未命名文章", + articleTitle, ); if (!externalArticleUrl) { throw new Error(`weixin_gzh_public_url_missing:${articleId}`); } - const publishId = - normalizeWeixinResponseId(publishResponse.publish_id) - || normalizeWeixinResponseId(publishResponse.sent_msg_id) - || normalizeWeixinResponseId(publishResponse.msg_id) - || normalizeWeixinResponseId(publishResponse.msgid) - || null; + const publishId = publishResponse + ? ( + normalizeWeixinResponseId(publishResponse.publish_id) + || normalizeWeixinResponseId(publishResponse.sent_msg_id) + || normalizeWeixinResponseId(publishResponse.msg_id) + || normalizeWeixinResponseId(publishResponse.msgid) + || null + ) + : null; return { status: "succeeded", summary: "微信公众号发布成功。", - payload: buildResultPayload(articleId, meta.nickName, meta.token, externalArticleUrl, publishId), + payload: buildPublishedResultPayload(articleId, meta.nickName, meta.token, externalArticleUrl, publishId), }; } catch (error) { return failureResult(error);