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:
@@ -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(/&/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);
|
||||
|
||||
Reference in New Issue
Block a user