歷史文章:正文清洗與斷點(diǎn)續(xù)爬)
簡(jiǎn)介wechat_spider 是一套基于 Node.js 的微信爬蟲項(xiàng)目借助 AnyProxy 中間人代理批量抓取微信公眾號(hào)數(shù)據(jù)可獲取歷史文章鏈接、正文、閱讀量、點(diǎn)贊量、在看數(shù)和評(píng)論等字段適合需要做公眾號(hào)內(nèi)容歸檔、數(shù)據(jù)監(jiān)測(cè)或選題分析的個(gè)人開發(fā)者與數(shù)據(jù)采集學(xué)習(xí)者也提供 Docker 部署方式便于在個(gè)人電腦或服務(wù)器上運(yùn)行。壓縮包共 80 個(gè)文件約 1.13MB其中 JavaScript 文件近四十個(gè)、JSX 文件九個(gè)另有 JSON 配置、HTML 頁(yè)面、圖片和字體資源并包含 Docker、證書與說(shuō)明文件核心模塊覆蓋客戶端、服務(wù)端、數(shù)據(jù)模型、代理規(guī)則、導(dǎo)出工具與容器編排。已有 2630 人學(xué)習(xí)下載。讀者可從中梳理微信 HTTPS 請(qǐng)求解析、代理規(guī)則編寫、MongoDB 與 Redis 存儲(chǔ)、歷史文章批量采集及容器化部署的完整鏈路并獲得可直接參考運(yùn)行的代碼骨架、配置示例和后續(xù)擴(kuò)展思路。1. 從一篇 10w 的字段說(shuō)起wechat_spider 抓的到底是什么手里有一批公眾號(hào)清單想知道哪幾篇真的帶來(lái)了轉(zhuǎn)化光看標(biāo)題排不出優(yōu)先級(jí)。真要做選題庫(kù)、競(jìng)品跟蹤、內(nèi)容復(fù)盤需要的是每篇文章的正文、閱讀量、點(diǎn)贊、評(píng)論入口數(shù)、發(fā)布時(shí)間以及一個(gè)能把這些記錄串起來(lái)的歷史文章鏈接。wechat_spider 就是干這件事的 JavaScript 工程用 Node.js 驅(qū)動(dòng)無(wú)頭瀏覽器走一遍公眾號(hào)的歷史文章列表再逐篇打開詳情頁(yè)把正文 HTML 和互動(dòng)數(shù)據(jù)一起落到本地。它適合做內(nèi)容分析、選題挖掘、賬號(hào)數(shù)據(jù)歸檔的從業(yè)者也適合想拿真實(shí)中文語(yǔ)料做語(yǔ)料清洗和文本實(shí)驗(yàn)的人。不適合指望它當(dāng)通用搜索引擎爬蟲的人——它的邊界很硬只能拿到你有權(quán)限看到的那個(gè)賬號(hào)視角下的公開數(shù)據(jù)抓不到后臺(tái)的粉絲畫像和完整評(píng)論樹。2. 采集鏈路拆解歷史文章列表 → 正文 → 互動(dòng)數(shù)據(jù)的三級(jí)抓取這個(gè)工程的骨架其實(shí)就三級(jí)先拿到一個(gè)賬號(hào)的歷史文章分頁(yè)再?gòu)拿織l記錄里取詳情頁(yè)地址最后逐篇解析。三級(jí)之間靠的參數(shù)不一樣失敗原因也不一樣所以別把它當(dāng)成一個(gè)循環(huán)寫完得按層拆開。2.1 列表頁(yè)歷史文章的分頁(yè)參數(shù)與 msgList 結(jié)構(gòu)歷史文章列表走的是公眾號(hào)后臺(tái)自己用的那個(gè)分頁(yè)接口路徑大概是/mp/profile_ext帶上actiongetmsg。關(guān)鍵參數(shù)有這么幾個(gè)缺一個(gè)就返回空參數(shù)含義從哪來(lái)常見取值__biz賬號(hào)唯一標(biāo)識(shí)任意一篇該號(hào)文章的鏈接里base64 風(fēng)格串固定不變uin當(dāng)前登錄用戶標(biāo)識(shí)登錄后頁(yè)面 JS 變量base64 風(fēng)格串key本次會(huì)話密鑰登錄后頁(yè)面 JS 變量約 20 分鐘失效pass_ticket會(huì)話票據(jù)同上與 key 同期offset起始偏移自己維護(hù)首頁(yè) 0之后每次加 10count單次條數(shù)固定一般寫 10scene場(chǎng)景標(biāo)識(shí)固定124is_ok校驗(yàn)位固定1最容易被忽略的一點(diǎn)是返回體general_msg_list不是一個(gè) JSON 對(duì)象而是一段被轉(zhuǎn)義的 JSON 字符串得再JSON.parse一次才能拿到list數(shù)組。每條list項(xiàng)里comm_msg_info放的是id、datetime、typeapp_msg_ext_info放的是title、digest、content_url、cover、is_multi。is_multi 1表示這是一次群發(fā)里的多圖文后面的multi_app_msg_item_list里還有幾條子文章這些照樣是獨(dú)立文章漏了它們你的歷史文章總數(shù)會(huì)少一大截。翻頁(yè)的終止條件看can_msg_continue返回 0 就說(shuō)明到底了。別用msg_count判斷它在最后一頁(yè)經(jīng)常還是 10。// 解析歷史文章列表返回體展開多圖文 function parseHistoryPayload(raw) { const data typeof raw string ? JSON.parse(raw) : raw; if (data.ret ! 0) throw new Error(list ret${data.ret} ${data.errmsg}); // general_msg_list 是字符串必須二次解析 const general JSON.parse(data.general_msg_list); const out []; for (const item of general.list) { const info item.app_msg_ext_info; const common item.comm_msg_info; if (!info || !info.content_url) continue; // 純圖片/視頻消息沒(méi)有正文 const push (node, isSub) { out.push({ msgId: common.id, publishAt: common.datetime, // 秒級(jí)時(shí)間戳 title: node.title, digest: node.digest, // 摘要可用于去重校驗(yàn) url: node.content_url.replace(/amp;/g, ), // 鏈接里的 amp; 要還原 cover: node.cover, isSub, // 是否多圖文的子條目 }); }; push(info, false); if (info.is_multi Array.isArray(info.multi_app_msg_item_list)) { info.multi_app_msg_item_list.forEach(sub push(sub, true)); } } return { articles: out, hasMore: data.can_msg_continue 1 }; }這段代碼做了三件事校驗(yàn)ret、還原amp;、展開多圖文。content_url里帶amp;是常態(tài)不還原的話請(qǐng)求詳情頁(yè)會(huì)被參數(shù)截?cái)啾憩F(xiàn)就是正文頁(yè)一直跳回首頁(yè)。isSub字段留著有用后面排查「條數(shù)對(duì)不上」時(shí)能分清是群發(fā)次數(shù)還是文章篇數(shù)。2.2 詳情頁(yè)正文清洗與閱讀量/點(diǎn)贊字段定位詳情頁(yè)的 DOM 結(jié)構(gòu)每隔一段時(shí)間會(huì)變一次所以定位策略要分層優(yōu)先從頁(yè)面里的 JS 變量取數(shù)字取不到再退回 DOM 選擇器。數(shù)字類字段基本都在script里以賦值形式出現(xiàn)形如var read_num 1234;、var like_num 12;、var comment_id ...。正文容器是#js_content點(diǎn)贊和評(píng)論的展示位在#js_like_num、#js_cmt_num附近。用正則直接從 HTML 抓變量比等 DOM 渲染完再取文本快也少受懶加載影響。正文清洗的目標(biāo)是「能讀、能檢索、體積可控」。必須做的三件事把img上的>const cheerio require(cheerio); // 解析詳情頁(yè)數(shù)字字段 正文 function parseArticlePage(html) { const pick (name) { const m html.match(new RegExp(var\\s${name}\\s*\\s*[]?([^;]*))); return m ? m[1].trim() : ; }; const $ cheerio.load(html); const $body $(#js_content); if (!$body.length) return { ok: false, reason: no-content }; // 常見于被攔截或需驗(yàn)證 // 圖片懶加載地址回填 $body.find(img).each((_, el) { const $el $(el); const src $el.attr(data-src) || $el.attr(src) || ; $el.attr(src, src); $el.removeAttr(data-src); }); // 去樣式、去腳本、去嵌入塊 $body.find(*).removeAttr(style).removeAttr(class); $body.find(script, iframe, mp-common-videosnap, mpvoice).remove(); return { ok: true, readNum: Number(pick(read_num)) || 0, likeNum: Number(pick(like_num)) || 0, commentId: pick(comment_id), // 后續(xù)抓評(píng)論要用 title: $(#activity-name).text().trim(), author: $(#js_name).text().trim(), publishText: $(#publish_time).text().trim(), html: $body.html() || , text: $body.text().replace(/\s/g, ).trim(), }; }pick函數(shù)用的是寬松匹配因?yàn)樽兞柯暶饔械膸б?hào)有的不帶。返回ok: false的分支一定要顯式處理不能當(dāng)成空文章寫庫(kù)——#js_content不存在時(shí)九成是頁(yè)面被攔了或者會(huì)話過(guò)期重試一次往往就好了直接落庫(kù)會(huì)污染數(shù)據(jù)集。2.3 數(shù)據(jù)落庫(kù)字段設(shè)計(jì)與去重主鍵兩張表就夠一張文章表一張抓取日志表。文章表的主鍵別用自增 ID用msg_id idx或 URL 里的sn參數(shù)這樣重復(fù)跑不會(huì)產(chǎn)生臟數(shù)據(jù)。CREATE TABLE article ( sn TEXT PRIMARY KEY, -- URL 里的 sn天然去重 biz TEXT NOT NULL, msg_id TEXT, idx INTEGER DEFAULT 0, -- 群發(fā)內(nèi)第幾條 title TEXT, digest TEXT, url TEXT, publish_at INTEGER, -- 秒級(jí)時(shí)間戳 read_num INTEGER DEFAULT 0, like_num INTEGER DEFAULT 0, comment_id TEXT, content_html TEXT, content_text TEXT, fetched_at INTEGER, status TEXT DEFAULT ok -- ok / no-content / blocked ); CREATE INDEX idx_biz_time ON article(biz, publish_at DESC);閱讀量和點(diǎn)贊是隨時(shí)間增長(zhǎng)的所以要么每次抓都覆蓋更新、另開一張快照表存歷史曲線要么就只保留最近一次。做選題分析的話快照表更有價(jià)值隔一周再抓一遍能算出真實(shí)的增長(zhǎng)斜率比單點(diǎn)數(shù)字有用得多。快照表只存sn、read_num、like_num、snapshot_at四個(gè)字段加個(gè)聯(lián)合唯一鍵防止同一小時(shí)重復(fù)寫。3. 工程骨架Node.js Puppeteer 的項(xiàng)目搭建與登錄態(tài)注入列表頁(yè)和詳情頁(yè)都能用純 HTTP 請(qǐng)求打通但有兩個(gè)環(huán)節(jié)繞不開瀏覽器一是登錄拿key和pass_ticket二是部分賬號(hào)的詳情頁(yè)會(huì)做人機(jī)校驗(yàn)。所以工程里保留一個(gè) Puppeteer 實(shí)例專門處理這兩件事其余請(qǐng)求走axios或fetch速度快一個(gè)量級(jí)。3.1 目錄結(jié)構(gòu)與依賴wechat_spider/ src/ auth.js # 登錄、憑證提取與刷新 list.js # 歷史文章分頁(yè) detail.js # 詳情頁(yè)解析 store.js # 落庫(kù)與斷點(diǎn) index.js # 調(diào)度入口 data/ auth.json # 憑證緩存不進(jìn)版本庫(kù) spider.db package.json依賴只有四個(gè)puppeteer負(fù)責(zé)登錄和兜底渲染axios走接口cheerio解析 HTMLbetter-sqlite3落庫(kù)。p-limit可選用來(lái)控并發(fā)。npm init -y npm i puppeteer axios cheerio better-sqlite3 p-limit # 首次安裝會(huì)下載 Chromium網(wǎng)絡(luò)不好時(shí)用 PUPPETEER_SKIP_DOWNLOAD1 跳過(guò)掛本地瀏覽器puppeteer的版本跟著 Node 版本走Node 18 以上問(wèn)題不大。落庫(kù)用better-sqlite3而不是sqlite3因?yàn)樗峭?API抓取腳本里少一層回調(diào)嵌套代碼短一半。3.2 登錄態(tài)注入cookie 與 URL 參數(shù)兩套憑證憑證分兩塊缺一不可。cookie 決定「你是誰(shuí)」__biz / uin / key / pass_ticket決定「你能翻哪個(gè)號(hào)的歷史」。前者從瀏覽器里導(dǎo)出后者從登錄后的頁(yè)面 JS 里正則提取。// auth.js從已登錄頁(yè)面提取憑證 async function extractAuth(page) { const html await page.content(); const pick (name) { const m html.match(new RegExp(var\\s${name}\\s*\\s*[]?([^;]*))); return m ? m[1].trim() : ; }; const auth { key: pick(key), uin: pick(uin), passTicket: pick(pass_ticket), biz: pick(__biz) || new URL(page.url()).searchParams.get(__biz) || , savedAt: Date.now(), }; if (!auth.key || !auth.uin) throw new Error(憑證提取失敗頁(yè)面可能未登錄); return auth; }key的有效期很短二十分鐘左右就失效所以腳本里要有一個(gè)「憑證過(guò)期」的判定任何一次請(qǐng)求返回ret ! 0且errmsg里帶invalid或expired就重新走一遍登錄流程刷新auth.json。別寫死刷新間隔按返回碼觸發(fā)更省事。cookie 這塊我一般手動(dòng)導(dǎo)出一次存進(jìn)auth.json讓 Puppeteer 用page.setCookie()注入。這樣腳本不用每次輸賬號(hào)也方便在另一臺(tái)機(jī)器上跑同樣的數(shù)據(jù)。3.3 單篇抓取的完整函數(shù)與調(diào)用入口把前面幾塊拼起來(lái)單篇抓取就是一個(gè)「請(qǐng)求 → 判定 → 解析 → 落庫(kù)」的四步。// detail.js async function fetchOne(client, article, auth) { const url article.url; const sn new URL(url).searchParams.get(sn) || article.msgId; let html; try { const res await client.get(url, { timeout: 15000, headers: { User-Agent: UA, Referer: https://mp.weixin.qq.com/, // 缺 Referer 容易被判異常 }, }); html res.data; } catch (e) { return { sn, status: blocked, reason: e.message }; } const parsed parseArticlePage(html); if (!parsed.ok) return { sn, status: parsed.reason }; return { sn, status: ok, ...parsed, url, biz: auth.biz, msgId: article.msgId, publishAt: article.publishAt, fetchedAt: Math.floor(Date.now() / 1000), }; }sn從 URL 的查詢串里取取不到才退回msgId這是去重的第一道閘。Referer必須帶不然大量請(qǐng)求會(huì)被識(shí)別為直接訪問(wèn)。返回值里status有三種取值調(diào)度層按status決定重試還是跳過(guò)比用異常好維護(hù)。調(diào)用入口按「列表 → 逐篇 → 落庫(kù)」串注意列表翻頁(yè)和詳情抓取要用不同的節(jié)流策略前者慢、后者快。4. 節(jié)奏與重試翻頁(yè)間隔、并發(fā)數(shù)、失敗降級(jí)的參數(shù)怎么定抓取腳本寫得對(duì)不對(duì)跑一百篇看不出來(lái)跑到第兩千篇還沒(méi)被攔、數(shù)據(jù)還沒(méi)缺才算過(guò)關(guān)。節(jié)奏控制這塊沒(méi)有標(biāo)準(zhǔn)答案但有一套能照著調(diào)的參數(shù)基線。4.1 參數(shù)表間隔、并發(fā)、超時(shí)、重試次數(shù)參數(shù)建議值調(diào)整依據(jù)列表翻頁(yè)間隔35 秒低于 2 秒容易連續(xù)返回空頁(yè)詳情頁(yè)并發(fā)23單機(jī)超過(guò) 5 時(shí)失敗率明顯上升單請(qǐng)求超時(shí)15 秒詳情頁(yè)偶爾加載慢低于 8 秒誤判多失敗重試2 次間隔 5 秒、15 秒指數(shù)退避第三次直接標(biāo)記跳過(guò)憑證刷新閾值連續(xù) 3 次 invalid單次失敗可能是偶發(fā)單賬號(hào)翻頁(yè)上限無(wú)硬限按 can_msg_continue老號(hào)能翻到幾百頁(yè)這些數(shù)字不是拍腦袋是從失敗率反推的并發(fā)調(diào)到 5 以上時(shí)no-content的比例會(huì)從個(gè)位數(shù)跳到三成說(shuō)明已經(jīng)是限速而不是網(wǎng)絡(luò)抖動(dòng)。翻頁(yè)間隔低于 2 秒general_msg_list會(huì)開始返回空數(shù)組但ret還是 0這種「假裝成功」最坑所以列表頁(yè)每次拿到結(jié)果都要校驗(yàn)list.length 0為 0 就退避重試。還有一點(diǎn)別把列表和詳情混在一個(gè)并發(fā)池里。列表是「一次翻頁(yè)決定后面十條」的關(guān)鍵路徑慢一點(diǎn)無(wú)妨詳情是純 IO可以適度并發(fā)。分成兩個(gè)p-limit實(shí)例各自控速出問(wèn)題好定位。4.2 斷點(diǎn)續(xù)爬用 offset 已抓列表做冪等抓幾千篇的腳本一定會(huì)中斷所以斷點(diǎn)必須落盤不能只存內(nèi)存。斷點(diǎn)記錄三樣?xùn)|西當(dāng)前賬號(hào)的offset、已抓到的最新一頁(yè)的msg_id、以及本次任務(wù)的起始時(shí)間。// store.js斷點(diǎn)續(xù)爬 function loadCheckpoint(db, biz) { const row db.prepare( SELECT offset, last_msg_id FROM checkpoint WHERE biz ? ).get(biz); return row || { offset: 0, last_msg_id: null }; } function saveCheckpoint(db, biz, offset, lastMsgId) { db.prepare( INSERT INTO checkpoint (biz, offset, last_msg_id, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(biz) DO UPDATE SET offset excluded.offset, last_msg_id excluded.last_msg_id, updated_at excluded.updated_at ).run(biz, offset, lastMsgId, Date.now()); }last_msg_id的作用是兜底核對(duì)如果重啟后offset對(duì)應(yīng)的那頁(yè)第一條不是上次記錄的msg_id說(shuō)明期間有新發(fā)文列表整體后移了得回退一頁(yè)重新對(duì)。這個(gè)判斷能擋掉「漏抓三篇」這種最難受的情況——它不會(huì)報(bào)錯(cuò)只會(huì)讓你在月底對(duì)賬時(shí)發(fā)現(xiàn)總數(shù)少了一截。再疊加一層文章級(jí)的冪等落庫(kù)用INSERT OR IGNORE主鍵是sn。這樣即使重復(fù)翻頁(yè)、重復(fù)抓同一篇也不會(huì)產(chǎn)生重復(fù)行腳本可以放心大膽地重跑。5. 避坑與排查七個(gè)讓我返工的場(chǎng)景5.1 憑證與會(huì)話類問(wèn)題現(xiàn)象跑了兩百篇之后列表頁(yè)開始穩(wěn)定返回空數(shù)組ret仍然是 0。原因不是被封是key過(guò)期了接口對(duì)過(guò)期憑證的返回就是空列表而不是錯(cuò)誤碼。解決辦法是在列表層加一層「連續(xù)兩次空頁(yè)即觸發(fā)憑證刷新」的邏輯刷新后從原offset重試別從頭再來(lái)?,F(xiàn)象詳情頁(yè)全部返回no-content但用瀏覽器打開同一個(gè)鏈接完全正常。原因通常是 cookie 過(guò)期或者Referer缺失。先檢查請(qǐng)求頭再檢查auth.json的savedAt是不是超過(guò)一天。我的做法是把 cookie 有效期當(dāng)成本地狀態(tài)維護(hù)超過(guò) 12 小時(shí)就主動(dòng)重新導(dǎo)出一次不等到報(bào)錯(cuò)?,F(xiàn)象換了一臺(tái)機(jī)器跑同一份auth.json直接失效。原因是uin和key與登錄會(huì)話綁定換 IP 或者換設(shè)備容易觸發(fā)重新驗(yàn)證。解決辦法是憑證跟著機(jī)器走別跨機(jī)器復(fù)制腳本啟動(dòng)時(shí)先做一次輕量探測(cè)請(qǐng)求通過(guò)了再開始跑批。5.2 數(shù)據(jù)與字段類問(wèn)題現(xiàn)象抓到的閱讀量全是 0。原因是詳情頁(yè)變量名變了新版本把閱讀量挪到了別的位置正則沒(méi)匹配上。排查辦法是打開一篇詳情頁(yè)的 HTML 搜read_num看它還在不在。如果確實(shí)沒(méi)了就退回 DOM 取#js_read_area3的文本再不行就用帶渲染的 Puppeteer 兜底。數(shù)字字段一定要有「取不到就標(biāo)記為 unknown不要寫 0」的約定0 和未知在分析里完全是兩回事?,F(xiàn)象評(píng)論數(shù)有值但抓不到任何評(píng)論內(nèi)容。這是設(shè)計(jì)使然前臺(tái)展示的評(píng)論是作者精選后放出來(lái)的完整評(píng)論需要走單獨(dú)的評(píng)論接口參數(shù)里要帶頁(yè)面上的comment_id和actiongetcomment而且翻頁(yè)有自己的offset。想清這一點(diǎn)就不會(huì)白折騰——你拿到的是「精選評(píng)論」不是「全部評(píng)論」做情感分析時(shí)要在這個(gè)前提下設(shè)預(yù)期?,F(xiàn)象文章總數(shù)比對(duì)方后臺(tái)顯示的少。八成是漏了多圖文的子條目。is_multi 1的那次群發(fā)multi_app_msg_item_list里每條都是獨(dú)立文章只取主條目會(huì)穩(wěn)定少 20%40%。核對(duì)辦法很簡(jiǎn)單把抓到的條數(shù)按msg_id分組看每組的條數(shù)分布如果絕大多數(shù)是 1說(shuō)明子條目沒(méi)展開?,F(xiàn)象正文里的圖片全是空白或者裂圖。原因是懶加載的>-- 體檢一按賬號(hào)核對(duì)條數(shù)與時(shí)間空洞 SELECT biz, COUNT(*) AS total, MIN(publish_at) AS first_at, MAX(publish_at) AS last_at, SUM(CASE WHEN content_text IS NULL OR content_text THEN 1 ELSE 0 END) AS empty_body FROM article GROUP BY biz HAVING empty_body * 1.0 / COUNT(*) 0.01; -- 體檢四同一篇文章的閱讀量是否倒退 SELECT a.sn, a.read_num AS now_num, b.read_num AS prev_num FROM snapshot a JOIN snapshot b ON a.sn b.sn AND b.snapshot_at a.snapshot_at WHERE a.read_num b.read_num;這兩條 SQL 花不了幾秒鐘但能擋掉八成的「數(shù)據(jù)看起來(lái)沒(méi)問(wèn)題、分析結(jié)論卻不對(duì)」的情況。尤其是第二條抓取腳本加了并發(fā)之后快照寫串行的概率并不低而它不會(huì)報(bào)錯(cuò)只會(huì)讓你的增長(zhǎng)率算出來(lái)是負(fù)的。還有一個(gè)技巧值得單獨(dú)提給content_text建一個(gè)全文索引再配一張定時(shí)任務(wù)每天對(duì)同一批sn重抓一次閱讀量。一周之后你手上就不只是靜態(tài)數(shù)據(jù)而是一條條增長(zhǎng)曲線選題判斷的顆粒度會(huì)完全不一樣。我自己在這上面栽過(guò)一次——早期只存了最終值等想回頭算「發(fā)布后 48 小時(shí)的閱讀增速」時(shí)數(shù)據(jù)已經(jīng)補(bǔ)不回來(lái)了只能重新掛了兩個(gè)月的定時(shí)任務(wù)。從那以后我每次建采集工程第一件事就是把快照表和全量字段一起設(shè)計(jì)好寧可多存一列也不給未來(lái)的自己留遺憾。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取