Created
June 5, 2026 04:17
-
-
Save kaichen/ff821cc339391b93e73afd8cfecce1d9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 微信公众号「发表记录」全量抓取脚本 | |
| * | |
| * 思路: | |
| * 后端区分 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