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