Skip to content

Instantly share code, notes, and snippets.

@kaichen
Created June 5, 2026 04:17
Show Gist options
  • Select an option

  • Save kaichen/ff821cc339391b93e73afd8cfecce1d9 to your computer and use it in GitHub Desktop.

Select an option

Save kaichen/ff821cc339391b93e73afd8cfecce1d9 to your computer and use it in GitHub Desktop.
/**
* 微信公众号「发表记录」全量抓取脚本
*
* 思路:
* 后端区分 navigation 请求和 fetch 请求,fetch 拿到 SPA 骨架,只有真正
* navigate 才拿得到含数据的 HTML。所以用隐藏 iframe 模拟 navigation,
* 等渲染完从 contentDocument 拿数据,iframe 立即销毁。
* 主页面的 console / JS 上下文全程存活。
*
* 用法:
* 1. 登录 mp.weixin.qq.com,打开「发表记录」页(任意一页)
* 2. F12 → Console → 粘贴本脚本 → 回车
* 3. 等待结束,自动下载 publish_history_*.json
*
* 跑完会在 console 底部输出 [诊断] 区块,如有页面失败 / 解析报错 / 未知状态,
* 直接把诊断区块复制发给 Claude 修复。
*
* 关于 contentStart.js 的 NotFoundError:
* 这是浏览器扩展(油猴/翻译/拦截类)注入到 iframe 的 content script
* 在 iframe 销毁时处理不当,与本脚本无关,不影响数据抓取,可忽略。
*
* 自定义:
* dumpPublishHistory({ concurrency: 5, delayMs: 300, saveFormat: 'both' })
*/
(async function dumpPublishHistory(opts = {}) {
const {
count = 10,
concurrency = 5,
delayMs = 300,
iframeTimeout = 20000,
minStablePolls = 3,
pollInterval = 200,
retryPerPage = 1, // 单页失败重试次数
startPage = 1,
endPage = null,
saveFormat = 'json',
} = opts;
const KNOWN_STATUSES = new Set([
'已发表', '已群发', // 正常(新/旧版)
'无法查看', '已删除', '违规', // 不可见
'审核中', '审核失败', // 审核态
'发表失败', // 失败
]);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const text = (el) => el ? el.textContent.replace(/\s+/g, ' ').trim() : null;
const toNum = (s) => {
if (s == null) return null;
const t = String(s).replace(/[,¥¥\s]/g, '').trim();
if (t === '') return null;
const n = Number(t);
return isNaN(n) ? null : n; // 非数字直接 null,不要污染数据
};
// ---------- 提取 block 级共享元数据 ----------
function extractSharedMeta(block) {
const meta = {};
meta.time = text(block.querySelector('.weui-desktop-mass__time'));
if (meta.time) {
const m = meta.time.match(/(\d{4})年(\d{1,2})月(\d{1,2})日/);
meta.send_date = m ? `${m[1]}-${m[2].padStart(2,'0')}-${m[3].padStart(2,'0')}` : null;
} else meta.send_date = null;
const st = text(block.querySelector('.weui-desktop-mass__status_text'));
meta.status = st ? st.replace(/[▼▲↓↑]/g, '').trim() : null;
meta.status_detail = text(block.querySelector('.sended_status_desc'));
const notifyWording = text(block.querySelector('.sended_status_wording'));
meta.notify_text = notifyWording || meta.status_detail;
const sd = meta.status_detail || '';
// 新版"已通知N人 失败N人" / 旧版"发送成功N人 发送失败N人"
const mn1 = sd.match(/(?:已通知|发送成功)\s*(\d+)/);
const mn2 = sd.match(/(?:发送)?失败\s*(\d+)/);
meta.notified_count = mn1 ? parseInt(mn1[1]) : null;
meta.notify_failed = mn2 ? parseInt(mn2[1]) : null;
if (notifyWording && notifyWording.includes('已开启通知')) meta.notify_mode = 'broadcast';
else if (sd.includes('未开启群发')) meta.notify_mode = 'silent';
else if (meta.status === '已群发' || meta.notified_count > 0) meta.notify_mode = 'broadcast';
else meta.notify_mode = null;
meta.is_broadcast = meta.notify_mode === 'broadcast';
return meta;
}
// ---------- 解析单篇文章(scope 通常是 .publish_hover_content) ----------
function parseArticle(scope, shared, articleIdx) {
const item = { ...shared, article_idx: articleIdx, is_headline: articleIdx === 1 };
// 标题 / URL
const titleA = scope.querySelector('a.weui-desktop-mass-appmsg__title');
const titleSpan = scope.querySelector('.weui-desktop-mass-appmsg__title span');
item.title = text(titleSpan) || text(titleA);
item.url = titleA ? titleA.getAttribute('href') : null;
// 缩略图
const thumb = scope.querySelector('.weui-desktop-mass-appmsg__thumb');
if (thumb) {
const m = (thumb.getAttribute('style') || '').match(/url\(["']?(.+?)["']?\)/);
item.thumb = m ? m[1] : null;
} else item.thumb = null;
// 摘要 / 搜索描述片段
item.digest = text(scope.querySelector('.weui-desktop-mass-appmsg__digest'));
item.desc = text(scope.querySelector('.weui-desktop-mass-appmsg__desc'));
// 标签 + 文章类型
const tags = Array.from(scope.querySelectorAll('.weui-desktop-key-tag'))
.map(t => text(t)).filter(Boolean);
item.tags = tags;
item.is_original = tags.includes('原创');
item.is_repost = tags.includes('转载');
item.is_modified = tags.includes('已修改');
item.article_type = item.is_original ? '原创'
: item.is_repost ? '转载'
: '正常';
// 指标(兼容 __disable 版)
const getMetric = (cls) => {
const el = scope.querySelector(`.${cls} .weui-desktop-mass-media__data__inner`)
|| scope.querySelector(`.${cls}__disable .weui-desktop-mass-media__data__inner`);
return el ? toNum(text(el)) : null;
};
item.view = getMetric('appmsg-view');
item.like = getMetric('appmsg-like');
item.share = getMetric('appmsg-share');
item.recommend = getMetric('appmsg-haokan');
item.comment = getMetric('appmsg-comment');
item.underline = getMetric('appmsg-underline');
item.reward = getMetric('appmsg-reward');
item.forward = getMetric('appmsg-forward');
// ID(兼容 appmsg_id / appmsgid / id 三种命名,以及 item_idx / idx)
const linkWithId = scope.querySelector(
'.appmsg-underline, .appmsg-underline__disable, .appmsg-reward, .appmsg-forward'
);
if (linkWithId) {
const href = linkWithId.getAttribute('href') || '';
const mId = href.match(/[?&](?:appmsg_?id|id)=(\d+)/);
const mIdx = href.match(/[?&](?:item_)?idx=(\d+)/);
const mT = href.match(/[?&]send_time=(\d+)/);
item.appmsg_id = mId ? mId[1] : null;
item.item_idx = mIdx ? mIdx[1] : null;
item.send_time = mT ? parseInt(mT[1]) : null;
item.send_time_iso = mT ? new Date(parseInt(mT[1]) * 1000).toISOString() : null;
} else {
item.appmsg_id = null; item.item_idx = null;
item.send_time = null; item.send_time_iso = null;
}
// 删除信息
const tipsContent = text(scope.querySelector('.weui-desktop-mass-appmsg__tips_content'));
if (tipsContent) {
item.delete_info = tipsContent;
const mt = tipsContent.match(/删除时间\s*(.*?)(?=\s+操作人|$)/);
const mo = tipsContent.match(/操作人\s*(\S+)/);
item.delete_time = mt ? mt[1].trim() : null;
item.delete_operator = mo ? mo[1].trim() : null;
} else {
item.delete_info = null; item.delete_time = null; item.delete_operator = null;
}
// 视觉删除态
const mediaContainer = scope.querySelector('.weui-desktop-mass-media');
item.is_deleted_in_ui = !!(mediaContainer
&& mediaContainer.classList.contains('weui-desktop-mass-media_del'));
// is_abnormal: 仅在脚本无法完整 cover 时为 true
// 已知特殊状态(已删除/违规下架/发表失败等)只要识别到了就不算 abnormal
const hasId = item.url || item.appmsg_id;
const isKnownStatus = item.status && KNOWN_STATUSES.has(item.status);
item.is_abnormal = !item.title || !hasId || !isKnownStatus;
return item;
}
// ---------- 解析一个 block(可能含多篇文章) ----------
function parseBlock(block) {
const shared = extractSharedMeta(block);
const articles = block.querySelectorAll('.publish_hover_content');
if (!articles.length) {
// 兜底:整个 block 当一篇
return [parseArticle(block, shared, 1)];
}
return Array.from(articles).map((art, i) => parseArticle(art, shared, i + 1));
}
// ---------- URL 构造 ----------
const curUrl = new URL(location.href);
const token = curUrl.searchParams.get('token');
const lang = curUrl.searchParams.get('lang') || 'zh_CN';
if (!token) { console.error('当前 URL 拿不到 token,请确认在发表记录页'); return; }
const buildUrl = (page) =>
`https://mp.weixin.qq.com/cgi-bin/appmsgpublish?sub=list&begin=${(page-1)*count}&count=${count}&token=${token}&lang=${lang}`;
// ---------- iframe 加载并抓数据 ----------
async function loadPageViaIframe(pageNum) {
return new Promise((resolve, reject) => {
const iframe = document.createElement('iframe');
iframe.style.cssText = 'position:fixed;left:-99999px;top:0;width:1280px;height:800px;border:0;visibility:hidden;pointer-events:none';
iframe.setAttribute('aria-hidden', 'true');
// sandbox 不含 allow-modals,禁用 alert/confirm/prompt 等弹窗
// 否则页面内 alert 会阻塞主线程,所有 worker 一起卡死
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts');
let cleaned = false;
let pollTimer = null;
let lastCount = -1;
let stableCount = 0;
let polls = 0;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
if (pollTimer) clearInterval(pollTimer);
clearTimeout(timeoutTimer);
try { iframe.src = 'about:blank'; } catch {}
try { iframe.remove(); } catch {}
};
const timeoutTimer = setTimeout(() => {
cleanup();
reject(new Error(`page ${pageNum} 整体超时`));
}, iframeTimeout);
iframe.addEventListener('load', () => {
// 兜底:覆盖 contentWindow 上的弹窗 API,以防 sandbox 在某些情况下没生效
try {
const cw = iframe.contentWindow;
if (cw) {
cw.alert = function(){};
cw.confirm = function(){ return true; };
cw.prompt = function(){ return null; };
}
} catch (e) {}
pollTimer = setInterval(() => {
polls++;
let blocks = null;
try {
const doc = iframe.contentDocument;
if (doc) {
const root = doc.querySelector('.publish_record_history');
if (root) blocks = root.querySelectorAll('.weui-desktop-block');
}
} catch (e) {
cleanup(); reject(e); return;
}
const n = blocks ? blocks.length : 0;
if (n > 0) {
if (n === lastCount) {
stableCount++;
if (stableCount >= minStablePolls) {
const items = [];
for (const b of blocks) {
try {
items.push(...parseBlock(b));
} catch (e) {
items.push({
_parse_error: e.message,
_html_snippet: (b.outerHTML || '').slice(0, 800),
page: pageNum
});
}
}
cleanup();
resolve(items);
return;
}
} else {
lastCount = n;
stableCount = 0;
}
}
if (polls * pollInterval > iframeTimeout - 1000) {
cleanup();
reject(new Error(`page ${pageNum} 渲染等不到数据(超时)`));
}
}, pollInterval);
});
iframe.addEventListener('error', (e) => {
cleanup();
reject(new Error(`iframe error: ${e.message || 'unknown'}`));
});
iframe.src = buildUrl(pageNum);
document.body.appendChild(iframe);
});
}
// 带重试的封装
async function loadPageWithRetry(pageNum, workerId) {
let lastErr = null;
for (let attempt = 0; attempt <= retryPerPage; attempt++) {
try {
return await loadPageViaIframe(pageNum);
} catch (e) {
lastErr = e;
if (attempt < retryPerPage) {
console.warn(` ⟳ ${pageNum} [w${workerId}] 第 ${attempt+1} 次失败,重试: ${e.message}`);
await sleep(1000);
}
}
}
throw lastErr;
}
// ---------- 总页数 ----------
function getTotalPagesFromHostPage() {
const nums = Array.from(document.querySelectorAll('.weui-desktop-pagination__num'))
.filter(n => !n.classList.contains('weui-desktop-pagination__ellipsis'));
let maxNum = 1;
nums.forEach(n => { const v = parseInt(text(n)); if (!isNaN(v) && v > maxNum) maxNum = v; });
if (maxNum > 1) return maxNum;
const buttons = document.querySelectorAll('button, span, div');
for (const b of buttons) {
const t = b.textContent || '';
const m = t.match(/全部\s*(\d+)/);
if (m) return Math.ceil(parseInt(m[1]) / count);
}
return null;
}
// ---------- 主流程 ----------
console.log('%c[发表记录]', 'color:#0066cc;font-weight:bold');
let totalPages = endPage || getTotalPagesFromHostPage();
if (!totalPages) {
console.log(' 主页面拿不到总页数,iframe 探测中...');
try {
const firstItems = await loadPageWithRetry(startPage, 0);
console.log(` 第一页 ${firstItems.length} 条`);
totalPages = 1;
} catch (e) {
console.error(' 连第一页都加载失败:', e.message);
return;
}
}
console.log(` 总页数 ${totalPages}, 并发 ${concurrency}`);
const allPages = [];
for (let p = startPage; p <= totalPages; p++) allPages.push(p);
const results = [];
const errors = [];
let cursor = 0;
let completed = 0;
async function worker(workerId) {
while (cursor < allPages.length) {
const p = allPages[cursor++];
try {
const items = await loadPageWithRetry(p, workerId);
items.forEach(i => i.page = p);
results.push(...items);
completed++;
const abn = items.filter(i => i.is_abnormal).length;
console.log(` ✓ ${p}/${totalPages} [w${workerId}] — ${items.length}条 (异常 ${abn}) — 已完成 ${completed}/${allPages.length} 累计 ${results.length}`);
} catch (e) {
completed++;
console.error(` ✗ ${p}/${totalPages} [w${workerId}] (重试后仍失败):`, e.message);
errors.push({ page: p, error: e.message });
}
await sleep(delayMs);
}
}
const t0 = Date.now();
await Promise.all(Array.from({ length: concurrency }, (_, i) => worker(i + 1)));
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
results.sort((a, b) => {
if (a.page !== b.page) return a.page - b.page;
if (a.article_idx !== b.article_idx) return (a.article_idx || 0) - (b.article_idx || 0);
return (b.send_time || 0) - (a.send_time || 0);
});
// ---------- send_date 回填 ----------
// Pass 1: 从 send_time_iso 派生 (来自 underline/reward URL 的 unix timestamp,可靠)
// Pass 2: 上下文 + delete_time 推断 (主要处理已删除/违规下架文章)
const parseDelDate = s => {
if (!s) return null;
const m = s.match(/(\d{4})年(\d{2})月(\d{2})日/);
return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
};
let bf1 = 0, bfDel = 0, bfNeighbor = 0, bfFail = 0;
// Pass 1
for (const x of results) {
if (!x.send_date && x.send_time_iso) {
x.send_date = x.send_time_iso.slice(0, 10);
x.send_date_inferred = 'send_time';
bf1++;
}
}
// Pass 2 (数组已按时间倒序: page asc 对应最新在前)
for (let i = 0; i < results.length; i++) {
const x = results[i];
if (x.send_date) continue;
let newer = null, older = null;
for (let j = i - 1; j >= 0; j--) if (results[j].send_date) { newer = results[j].send_date; break; }
for (let j = i + 1; j < results.length; j++) if (results[j].send_date) { older = results[j].send_date; break; }
const delDate = parseDelDate(x.delete_time);
if (delDate && (older === null || delDate >= older) && (newer === null || delDate <= newer)) {
x.send_date = delDate;
x.send_date_inferred = 'delete_time';
bfDel++;
} else if (older) {
x.send_date = older;
x.send_date_inferred = 'older_neighbor';
bfNeighbor++;
} else if (newer) {
x.send_date = newer;
x.send_date_inferred = 'newer_neighbor';
bfNeighbor++;
} else {
bfFail++;
}
}
if (bf1 + bfDel + bfNeighbor + bfFail > 0) {
console.log(` send_date 回填: send_time=${bf1}, delete_time=${bfDel}, 邻居=${bfNeighbor}, 仍缺=${bfFail}`);
}
const totalAbn = results.filter(i => i.is_abnormal).length;
const byType = results.reduce((acc, r) => {
const k = r.article_type || '_未知';
acc[k] = (acc[k] || 0) + 1; return acc;
}, {});
console.log(`%c[完成] ${results.length} 篇,异常态 ${totalAbn} 篇,失败页 ${errors.length},耗时 ${elapsed}s`,
'color:#00aa00;font-weight:bold');
console.log(` 类型分布:`, byType);
const ids = results.map(r => r.appmsg_id).filter(Boolean);
if (ids.length !== new Set(ids).size) {
// 注意:同一 appmsg_id 的头条+次条共用 ID,所以重复可能是正常的,看 (appmsg_id, item_idx) 才唯一
const pairs = results.map(r => r.appmsg_id && `${r.appmsg_id}_${r.item_idx || r.article_idx}`).filter(Boolean);
if (pairs.length !== new Set(pairs).size) {
console.warn(`%c⚠ (appmsg_id, idx) 仍重复: ${pairs.length} → ${new Set(pairs).size}`,
'color:orange;font-weight:bold');
}
}
// ---------- 诊断报告 ----------
const parseErrors = results.filter(r => r._parse_error);
const unknownStatuses = [...new Set(
results.filter(r => r.status && !KNOWN_STATUSES.has(r.status)).map(r => r.status)
)];
const hasDiag = errors.length || parseErrors.length || unknownStatuses.length;
if (hasDiag) {
console.group(`%c[诊断] 复制以下内容发给 Claude 修复 ↓`, 'color:#cc6600;font-weight:bold;font-size:13px');
if (errors.length) {
console.group(`页面级失败 ${errors.length} 个`);
errors.forEach(e => console.log(`page ${e.page}: ${e.error}`));
console.groupEnd();
}
if (parseErrors.length) {
console.group(`解析失败 ${parseErrors.length} 条`);
parseErrors.forEach((e, i) => {
console.log(`── #${i+1} page=${e.page} ── ${e._parse_error}`);
console.log(e._html_snippet);
});
console.groupEnd();
}
if (unknownStatuses.length) {
console.group(`未知状态文本 ${unknownStatuses.length} 种(可能需要扩展 KNOWN_STATUSES)`);
unknownStatuses.forEach(s => {
const sample = results.find(r => r.status === s);
console.log(`"${s}" — 样本: ${sample.title || '(无标题)'} (page ${sample.page})`);
});
console.groupEnd();
}
console.groupEnd();
} else {
console.log('%c[诊断] 无异常 ✓', 'color:#00aa00');
}
window.__publish_dump = results;
window.__publish_errors = errors;
window.__publish_diag = { parseErrors, errors, unknownStatuses };
console.log('结果: window.__publish_dump 失败页: window.__publish_errors 诊断: window.__publish_diag');
if (errors.length) {
console.log('补抓失败页: dumpPublishHistory({ startPage: N, endPage: N })');
}
// ---------- 下载 ----------
const stamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
function downloadBlob(content, filename, mime) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
function toCSV(rows) {
if (!rows.length) return '';
const cols = Array.from(new Set(rows.flatMap(r => Object.keys(r))));
const esc = (v) => {
if (v == null) return '';
const s = (typeof v === 'string') ? v
: (Array.isArray(v) ? v.join('|') : JSON.stringify(v));
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
return [cols.join(','), ...rows.map(r => cols.map(c => esc(r[c])).join(','))].join('\n');
}
if (saveFormat === 'json' || saveFormat === 'both')
downloadBlob(JSON.stringify(results, null, 2),
`publish_history_${stamp}.json`, 'application/json');
if (saveFormat === 'csv' || saveFormat === 'both')
downloadBlob('\ufeff' + toCSV(results),
`publish_history_${stamp}.csv`, 'text/csv;charset=utf-8');
return results;
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment