feat(desktop/weixin-gzh): mass-send drafts and resolve public article URL

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 17:12:49 +08:00
parent 2501f9db85
commit 2e5643ddfd
@@ -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<number, string> = {
[-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<string, JsonValue> {
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<string, JsonValue> {
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(/&amp;/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<string, unknown>, 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<unknown>();
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<string, unknown>;
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<void> {
if (signal.aborted) {
throw new Error("adapter_aborted");
}
await new Promise<void>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout>;
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<WeixinOperateResponse> {
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<WeixinOperateResponse>(
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<string | null> {
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<unknown>(
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<PublishAdapter["publish"]> extends Promise<infer T> ? T : never {
const message = error instanceof Error ? error.message : String(error);
@@ -513,9 +857,31 @@ function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> 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);