From 2e5643ddfd4bf20fd30147c32378e88552c7540d Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 29 Apr 2026 17:12:49 +0800 Subject: [PATCH] feat(desktop/weixin-gzh): mass-send drafts and resolve public article URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drafts previously stopped at the草稿箱 step, leaving the operator to publish manually. Chain a /cgi-bin/masssend call, then poll the appmsgpublish list (by title and by latest) to extract the mp.weixin.qq.com/s public link and a publish_id. Surface dedicated failures for publish and link-missing states, and switch the result payload to publish_type="publish" with the appmsgpublish manage URL. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/adapters/weixin-gzh.ts | 426 +++++++++++++++++- 1 file changed, 413 insertions(+), 13 deletions(-) diff --git a/apps/desktop-client/src/main/adapters/weixin-gzh.ts b/apps/desktop-client/src/main/adapters/weixin-gzh.ts index 4711431..f53223b 100644 --- a/apps/desktop-client/src/main/adapters/weixin-gzh.ts +++ b/apps/desktop-client/src/main/adapters/weixin-gzh.ts @@ -16,6 +16,7 @@ import type { PublishAdapter, PublishAdapterContext } from "./base"; type WeixinMeta = { token: string; userName: string; + fakeId: string | null; nickName: string; ticket: string; svrTime: number; @@ -47,6 +48,15 @@ type WeixinCropResponse = { type WeixinOperateResponse = { appMsgId?: string | number; appmsgid?: string | number; + app_msg_id?: string | number; + msgid?: string | number; + msg_id?: string | number; + sent_msg_id?: string | number; + publish_id?: string | number; + url?: string; + link?: string; + article_url?: string; + content_url?: string; ret?: number; base_resp?: { ret?: number; @@ -63,6 +73,8 @@ type WeixinCover = { const WEIXIN_ORIGIN = "https://mp.weixin.qq.com"; const WEIXIN_HOME_URL = `${WEIXIN_ORIGIN}/`; +const WEIXIN_PUBLISH_POLL_ATTEMPTS = 8; +const WEIXIN_PUBLISH_POLL_INTERVAL_MS = 2_500; function isWeixinChallengeMessage(message: string): boolean { return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required)/i @@ -92,6 +104,11 @@ function parseMeta(html: string): WeixinMeta | null { const token = html.match(/data:\s*\{[\s\S]*?t:\s*["']([^"']+)["']/)?.[1] ?? ""; const ticket = html.match(/ticket:\s*["']([^"']+)["']/)?.[1] ?? ""; const userName = html.match(/user_name:\s*["']([^"']+)["']/)?.[1] ?? ""; + const fakeId = + html.match(/fakeid:\s*["']([^"']+)["']/)?.[1] + ?? html.match(/fake_id:\s*["']([^"']+)["']/)?.[1] + ?? html.match(/__biz=([^"&]+)/)?.[1] + ?? null; const nickName = html.match(/nick_name:\s*["']([^"']+)["']/)?.[1] ?? ""; const time = html.match(/time:\s*["'](\d+)["']/)?.[1] ?? ""; @@ -103,6 +120,7 @@ function parseMeta(html: string): WeixinMeta | null { token, ticket, userName, + fakeId, nickName, svrTime: time ? Number(time) : Date.now() / 1000, }; @@ -350,8 +368,12 @@ async function uploadCover( return response ? coverFromCropResponse(cdnUrl, body, response) : null; } -function formatWeixinError(response: WeixinOperateResponse | null | undefined): string { +function formatWeixinError( + response: WeixinOperateResponse | null | undefined, + fallback = "微信公众号操作失败", +): string { const ret = response?.ret ?? response?.base_resp?.ret; + const errMessage = response?.base_resp?.err_msg?.trim(); const map: Record = { [-8]: "请输入验证码", [-6]: "请输入验证码", @@ -374,11 +396,14 @@ function formatWeixinError(response: WeixinOperateResponse | null | undefined): 220001: "素材管理中的存储数量已达上限", 220002: "图片库已达到存储上限", }; - return typeof ret === "number" ? map[ret] || `同步到草稿箱失败 (错误码: ${ret})` : "同步到草稿箱失败"; + if (typeof ret === "number") { + return map[ret] || errMessage || `${fallback} (错误码: ${ret})`; + } + return errMessage || fallback; } function normalizeWeixinArticleId(response: WeixinOperateResponse | null | undefined): string { - const value = response?.appMsgId ?? response?.appmsgid; + const value = response?.appMsgId ?? response?.appmsgid ?? response?.app_msg_id; if (typeof value === "number") { return Number.isFinite(value) ? String(value) : ""; } @@ -388,6 +413,22 @@ function normalizeWeixinArticleId(response: WeixinOperateResponse | null | undef return ""; } +function normalizeWeixinResponseId(value: unknown): string { + if (typeof value === "number") { + return Number.isFinite(value) ? String(value) : ""; + } + if (typeof value === "string") { + return value.trim(); + } + return ""; +} + +function isWeixinSuccessResponse(response: WeixinOperateResponse | null | undefined): boolean { + const ret = response?.ret ?? response?.base_resp?.ret; + const message = response?.base_resp?.err_msg?.trim().toLowerCase(); + return ret === 0 || message === "ok" || message === "success"; +} + function buildOperateForm( context: PublishAdapterContext, meta: WeixinMeta, @@ -453,19 +494,322 @@ function buildOperateForm( return form; } -function buildResultPayload(articleId: string, mediaName: string, token: string): Record { - const draftUrl = - `${WEIXIN_ORIGIN}/cgi-bin/appmsg?t=media/appmsg_edit&action=edit&type=77&appmsgid=${encodeURIComponent(articleId)}&token=${encodeURIComponent(token)}&lang=zh_CN`; +function buildDraftManageUrl(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`; +} + +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( + articleId: string, + mediaName: string, + token: string, + externalArticleUrl: string | null, + publishId: string | null, +): Record { return { platform: "weixin_gzh", media_name: mediaName, external_article_id: articleId, - external_manage_url: draftUrl, - external_article_url: null, - publish_type: "draft", + external_manage_url: buildPublishManageUrl(token), + external_article_url: externalArticleUrl, + publish_type: "publish", + publish_id: publishId, }; } +function normalizeWeixinPublicUrl(value: string): string | null { + const cleaned = value + .replace(/\\u0026/g, "&") + .replace(/&/g, "&") + .trim(); + if (!cleaned) { + return null; + } + try { + const parsed = new URL(cleaned.startsWith("//") ? `https:${cleaned}` : cleaned); + if (parsed.hostname !== "mp.weixin.qq.com") { + return null; + } + if (parsed.pathname !== "/s" && !parsed.pathname.startsWith("/s/")) { + return null; + } + return parsed.toString(); + } catch { + return null; + } +} + +function parseNestedJson(value: string): unknown | null { + const trimmed = value.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { + return null; + } + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +function stringField(record: Record, keys: string[]): string { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + } + return ""; +} + +function normalizeComparableText(value: string): string { + return value.replace(/\s+/g, "").trim(); +} + +function findWeixinPublishedUrl( + payload: unknown, + articleId: string, + title: string, + allowLatestFallback: boolean, +): string | null { + const seen = new Set(); + const fallbackUrls: string[] = []; + const normalizedTitle = normalizeComparableText(title); + + const visit = (value: unknown): string | null => { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "string") { + const direct = normalizeWeixinPublicUrl(value); + if (direct) { + fallbackUrls.push(direct); + return null; + } + const parsed = parseNestedJson(value); + return parsed ? visit(parsed) : null; + } + if (typeof value !== "object") { + return null; + } + if (seen.has(value)) { + return null; + } + seen.add(value); + + if (Array.isArray(value)) { + for (const item of value) { + const matched = visit(item); + if (matched) { + return matched; + } + } + return null; + } + + const record = value as Record; + const url = stringField(record, [ + "url", + "link", + "content_url", + "contentUrl", + "article_url", + "articleUrl", + "source_url", + "sourceUrl", + ]); + const publicUrl = url ? normalizeWeixinPublicUrl(url) : null; + const id = stringField(record, [ + "appmsgid", + "appMsgId", + "app_msg_id", + "article_id", + "articleId", + "msgid", + "msg_id", + "mid", + ]); + const itemTitle = stringField(record, ["title", "arttitle", "article_title", "articleTitle"]); + const idMatches = Boolean(articleId && id === articleId); + const titleMatches = Boolean( + normalizedTitle + && itemTitle + && normalizeComparableText(itemTitle) === normalizedTitle, + ); + + if (publicUrl) { + fallbackUrls.push(publicUrl); + if (idMatches || titleMatches) { + return publicUrl; + } + } + + for (const item of Object.values(record)) { + const matched = visit(item); + if (matched) { + return matched; + } + } + return null; + }; + + const matched = visit(payload); + if (matched) { + return matched; + } + return allowLatestFallback ? fallbackUrls[0] ?? null : null; +} + +async function waitForWeixinPublishPoll(signal: AbortSignal): Promise { + if (signal.aborted) { + throw new Error("adapter_aborted"); + } + await new Promise((resolve, reject) => { + let timeout: ReturnType; + const abort = () => { + clearTimeout(timeout); + reject(new Error("adapter_aborted")); + }; + timeout = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(); + }, WEIXIN_PUBLISH_POLL_INTERVAL_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 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"); + + 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 => ({ + ret: -1, + base_resp: { + ret: -1, + err_msg: error instanceof Error ? error.message : "weixin_gzh_publish_failed", + }, + })); +} + +function buildPublishedListUrl( + meta: WeixinMeta, + title: string, + options: { searchField: string; query: string; fakeId: string | null }, +): string { + const url = new URL(`${WEIXIN_ORIGIN}/cgi-bin/appmsgpublish`); + url.searchParams.set("sub", "list"); + url.searchParams.set("search_field", options.searchField); + url.searchParams.set("begin", "0"); + url.searchParams.set("count", "5"); + url.searchParams.set("query", options.query); + url.searchParams.set("type", "101_1"); + url.searchParams.set("free_publish_type", "1"); + url.searchParams.set("sub_action", "list_ex"); + url.searchParams.set("token", meta.token); + url.searchParams.set("lang", "zh_CN"); + url.searchParams.set("f", "json"); + url.searchParams.set("ajax", "1"); + url.searchParams.set("random", String(Math.random())); + if (options.fakeId) { + url.searchParams.set("fakeid", options.fakeId); + } + if (title && !options.query) { + url.searchParams.set("title", title); + } + return url.toString(); +} + +async function fetchPublishedArticleUrl( + context: PublishAdapterContext, + meta: WeixinMeta, + articleId: string, + title: string, +): Promise { + const attempts = [ + { searchField: "7", query: title }, + { searchField: "null", query: title }, + { searchField: "null", query: "" }, + ]; + const fakeIdCandidates = [...new Set([meta.fakeId, meta.userName, null].filter((value) => value !== ""))]; + + for (let index = 0; index < WEIXIN_PUBLISH_POLL_ATTEMPTS; index += 1) { + for (const attempt of attempts) { + for (const fakeId of fakeIdCandidates) { + const response = await sessionFetchJson( + context.session, + buildPublishedListUrl(meta, title, { ...attempt, fakeId }), + { + credentials: "include", + signal: context.signal, + headers: await weixinHeaders(context, { + referer: buildPublishManageUrl(meta.token), + }), + }, + ).catch(() => null); + const url = findWeixinPublishedUrl( + response, + articleId, + title, + attempt.query === title || index >= 2, + ); + if (url) { + return url; + } + } + } + await waitForWeixinPublishPoll(context.signal); + } + + return null; +} + function failureResult(error: unknown): ReturnType extends Promise ? T : never { const message = error instanceof Error ? error.message : String(error); @@ -513,9 +857,31 @@ function failureResult(error: unknown): ReturnType ex }; } + if (message.startsWith("weixin_gzh_publish_failed")) { + return { + status: "failed", + summary: "微信公众号发布失败,请打开公众号后台确认草稿状态后重试。", + error: { + code: "weixin_gzh_publish_failed", + message, + }, + }; + } + + if (message.startsWith("weixin_gzh_public_url_missing")) { + return { + status: "failed", + summary: "微信公众号已提交发布,但未能获取外部文章链接,请在公众号后台发表记录确认。", + error: { + code: "weixin_gzh_public_url_missing", + message, + }, + }; + } + return { status: "failed", - summary: "微信公众号草稿保存失败。", + summary: "微信公众号发布失败。", error: { code: "weixin_gzh_publish_failed", message, @@ -572,17 +938,51 @@ export const weixinGzhAdapter: PublishAdapter = { const articleId = normalizeWeixinArticleId(response); if (!articleId) { - const message = formatWeixinError(response); + const message = formatWeixinError(response, "同步到草稿箱失败"); if (isWeixinChallengeMessage(message)) { throw new Error(`weixin_gzh_challenge_required:${message}`); } throw new Error(`weixin_gzh_save_failed:${message}`); } + 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}`); + } + throw new Error(`weixin_gzh_publish_failed:${message}`); + } + + context.reportProgress("weixin_gzh.resolve_public_url"); + const directArticleUrl = findWeixinPublishedUrl( + publishResponse, + articleId, + context.article.title?.trim() || "未命名文章", + true, + ); + const externalArticleUrl = directArticleUrl + ?? await fetchPublishedArticleUrl( + context, + meta, + articleId, + context.article.title?.trim() || "未命名文章", + ); + 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; + return { status: "succeeded", - summary: "微信公众号草稿保存成功,请在公众号后台确认后发布。", - payload: buildResultPayload(articleId, meta.nickName, meta.token), + summary: "微信公众号发布成功。", + payload: buildResultPayload(articleId, meta.nickName, meta.token, externalArticleUrl, publishId), }; } catch (error) { return failureResult(error);