戰(zhàn):抓取公眾號(hào)歷史文章與閱讀量評論)
簡介這是一份基于 Node.js 的微信爬蟲項(xiàng)目源碼面向需要批量采集公眾號(hào)數(shù)據(jù)的開發(fā)者與數(shù)據(jù)分析人員通過 AnyProxy 中間人代理機(jī)制解析微信 HTTPS 請求可獲取文章正文、閱讀量、點(diǎn)贊量、在看數(shù)及評論等核心指標(biāo)并支持抓取公眾號(hào)全部歷史文章鏈接。項(xiàng)目兼容 AnyProxy 4可運(yùn)行于個(gè)人電腦或服務(wù)器并提供 Docker 部署方案使用前需準(zhǔn)備 Node 8.8.1 以上、MongoDB 3.4.6 以上及 Redis 環(huán)境。壓縮包共 80 個(gè)文件約 1.13MB以 40 個(gè) js 與 9 個(gè) jsx 業(yè)務(wù)腳本為主體輔以 json 配置、html 模板、png/svg 圖標(biāo)、woff2/ttf 等字體資源以及 Dockerfile、docker-compose.yml、證書與說明文檔前后端與代理規(guī)則模塊劃分清晰。目前已有 2627 人學(xué)習(xí)下載適合想研究代理抓包、數(shù)據(jù)落庫與爬蟲工程化的讀者參考復(fù)用。1. 公眾號(hào)歷史文章抓取從閱讀量到評論一套 JavaScript 爬蟲能拿到什么做公眾號(hào)內(nèi)容分析的人多半遇到過這種局面想復(fù)盤某個(gè)號(hào)三年的選題曲線后臺(tái)只能一頁頁翻想統(tǒng)計(jì)閱讀量中位數(shù)手動(dòng)復(fù)制到表格里眼睛都花了。這個(gè)wechat_spider項(xiàng)目就是沖著這個(gè)痛點(diǎn)來的——用 JavaScript 寫的一套抓取工具目標(biāo)是把公眾號(hào)的歷史文章鏈接、正文內(nèi)容、閱讀量、點(diǎn)贊量、評論一并拉下來。它適合做內(nèi)容運(yùn)營分析、競品號(hào)監(jiān)測、選題庫搭建的從業(yè)者也適合想拿真實(shí)數(shù)據(jù)練手的數(shù)據(jù)方向同學(xué)。技術(shù)棧是 Node.js 加 JavaScript核心思路是模擬客戶端請求、解析返回結(jié)構(gòu)、分頁遍歷最后落成結(jié)構(gòu)化數(shù)據(jù)。下面按「它怎么跑起來、參數(shù)怎么調(diào)、哪里會(huì)翻車」的順序拆一遍。2. 環(huán)境搭建與請求鏈路Node.js 側(cè)要準(zhǔn)備什么2.1 運(yùn)行環(huán)境與依賴清單這套工具跑在 Node.js 上建議用 LTS 版本太老的版本對async/await和fetch支持不完整容易在并發(fā)請求那一步報(bào)奇怪的錯(cuò)。依賴方面常見做法是axios負(fù)責(zé) HTTP 請求、cheerio負(fù)責(zé) HTML 解析、fs-extra負(fù)責(zé)文件寫入、p-limit控制并發(fā)數(shù)。這幾個(gè)包在 npm 上都能直接裝不需要額外編譯工具鏈。先把項(xiàng)目目錄初始化好裝依賴# 初始化項(xiàng)目生成 package.json npm init -y # 安裝核心依賴 npm install axios cheerio fs-extra p-limit # 如果要做定時(shí)任務(wù)再加一個(gè) npm install node-cron裝完之后檢查node_modules里有沒有axios和cheerio這兩個(gè)目錄缺了說明網(wǎng)絡(luò)或鏡像源有問題換源重裝即可。這一步看著簡單但后面所有請求和解析都依賴它們裝不干凈后面全是玄學(xué)報(bào)錯(cuò)。2.2 請求鏈路從公眾號(hào)入口到文章列表抓取的核心鏈路分三段第一段是拿到目標(biāo)公眾號(hào)的文章列表入口第二段是翻頁遍歷歷史文章拿到每篇的鏈接第三段是逐篇請求詳情頁解析閱讀量、點(diǎn)贊和評論。第一段通常需要一個(gè)能定位到該公眾號(hào)的標(biāo)識(shí)參數(shù)這個(gè)參數(shù)在公眾號(hào)主頁的請求里能找到具體字段名各版本可能不同常見做法是從主頁 URL 或返回的 JSON 里提取。下面是一個(gè)請求封裝的骨架把公共頭、超時(shí)、重試都收在一起const axios require(axios); // 創(chuàng)建帶默認(rèn)配置的請求實(shí)例 const client axios.create({ timeout: 10000, // 單次請求超時(shí) 10 秒太短容易誤判失敗 headers: { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept: application/json, text/plain, */* } }); // 帶重試的請求函數(shù)失敗后隔 1 秒重試最多 3 次 async function requestWithRetry(url, params {}, retries 3) { for (let i 0; i retries; i) { try { const res await client.get(url, { params }); return res.data; } catch (err) { if (i retries - 1) throw err; await new Promise(r setTimeout(r, 1000)); } } } module.exports { requestWithRetry };這段代碼里timeout設(shè) 10 秒是個(gè)經(jīng)驗(yàn)值公眾號(hào)接口偶爾會(huì)慢設(shè) 3 秒會(huì)大量誤報(bào)失敗。重試間隔 1 秒是為了避開瞬時(shí)抖動(dòng)重試次數(shù) 3 次足夠再多說明目標(biāo)接口本身有問題硬重試沒意義。User-Agent必須帶不帶的話部分接口直接返回空數(shù)據(jù)這是最常見的翻車點(diǎn)之一。2.3 分頁遍歷與去重歷史文章列表是分頁返回的每頁條數(shù)有限需要循環(huán)請求直到?jīng)]有下一頁。這里要處理兩個(gè)問題一是翻頁游標(biāo)或偏移量怎么傳二是重復(fù)文章怎么去重。常見做法是用一個(gè)Set存已抓到的文章 ID每次解析后先查重再入庫。const seen new Set(); // 已抓取的文章 ID 集合 async function fetchArticleList(accountId, maxPages 50) { const all []; let offset 0; const pageSize 20; for (let page 0; page maxPages; page) { const data await requestWithRetry(/api/article/list, { account_id: accountId, offset: offset, count: pageSize }); const list data?.articles || []; if (list.length 0) break; // 沒有數(shù)據(jù)了提前結(jié)束 for (const item of list) { if (seen.has(item.id)) continue; // 去重 seen.add(item.id); all.push(item); } offset pageSize; await new Promise(r setTimeout(r, 500)); // 每頁之間歇一下 } return all; }maxPages是保護(hù)性上限防止接口異常時(shí)無限循環(huán)。每頁之間停 500 毫秒是降低請求頻率太快容易觸發(fā)限流。offset的遞增方式取決于接口設(shè)計(jì)有的用游標(biāo)cursor有的用頁碼page拿到實(shí)際返回后按字段調(diào)整。去重這步不能省翻頁過程中接口偶爾會(huì)返回重復(fù)項(xiàng)不去重最后統(tǒng)計(jì)閱讀量會(huì)偏高。3. 正文、閱讀量、點(diǎn)贊與評論的解析字段怎么取、結(jié)構(gòu)怎么落3.1 詳情頁解析正文與互動(dòng)數(shù)據(jù)拿到文章鏈接后逐篇請求詳情頁。正文一般在 HTML 的某個(gè)容器里用cheerio按選擇器提取閱讀量和點(diǎn)贊量有的在頁面內(nèi)嵌的 JS 變量里有的在單獨(dú)的接口返回中。常見做法是先用cheerio抓正文再用正則從頁面源碼里匹配閱讀量字段。const cheerio require(cheerio); function parseArticleDetail(html) { const $ cheerio.load(html); // 正文取內(nèi)容容器去掉腳本和樣式 const content $(#js_content).text().trim(); // 閱讀量、點(diǎn)贊量常藏在頁面腳本里用正則匹配 const readMatch html.match(/read_num[]?\s*[:]\s*[]?(\d)/); const likeMatch html.match(/like_num[]?\s*[:]\s*[]?(\d)/); return { content: content, readNum: readMatch ? parseInt(readMatch[1], 10) : 0, likeNum: likeMatch ? parseInt(likeMatch[1], 10) : 0 }; }#js_content是正文容器的常見 ID不同模板可能不同抓不到時(shí)先打印 HTML 看結(jié)構(gòu)再改選擇器。正則里的read_num和like_num是字段名實(shí)際字段名以頁面源碼為準(zhǔn)匹配不到就返回 0避免整條記錄失敗。正文用.text()會(huì)丟掉圖片和鏈接如果需要保留富文本改用.html()再單獨(dú)清洗。3.2 評論抓取分頁與嵌套結(jié)構(gòu)評論通常是單獨(dú)接口按文章 ID 分頁拉取每條評論可能帶子評論。抓的時(shí)候要處理兩層分頁主評論翻頁和子評論展開。常見做法是先拉主評論再對每條有子評論的項(xiàng)發(fā)二次請求。async function fetchComments(articleId, maxPages 20) { const comments []; let page 1; while (page maxPages) { const data await requestWithRetry(/api/comment/list, { article_id: articleId, page: page, limit: 20 }); const list data?.comments || []; if (list.length 0) break; for (const c of list) { comments.push({ user: c.nickname, text: c.content, like: c.like_num || 0, time: c.create_time }); } page; await new Promise(r setTimeout(r, 300)); } return comments; }maxPages同樣做上限保護(hù)。評論接口對頻率更敏感間隔設(shè) 300 毫秒以上比較穩(wěn)。子評論如果也要就在主評論循環(huán)里判斷c.reply_count 0再發(fā)一次請求但要注意嵌套層級別無限展開設(shè)個(gè)深度上限。3.3 數(shù)據(jù)落盤JSON 與 CSV 兩種格式抓完的數(shù)據(jù)建議同時(shí)落 JSON 和 CSV。JSON 保留完整結(jié)構(gòu)方便后續(xù)程序處理CSV 方便直接丟進(jìn)表格軟件看。用fs-extra寫入const fs require(fs-extra); async function saveData(articles, outputDir ./output) { await fs.ensureDir(outputDir); // 目錄不存在就創(chuàng)建 // 完整數(shù)據(jù)存 JSON await fs.writeJson(${outputDir}/articles.json, articles, { spaces: 2 }); // 摘要字段存 CSV const header title,readNum,likeNum,url\n; const rows articles.map(a ${a.title},${a.readNum},${a.likeNum},${a.url} ).join(\n); await fs.writeFile(${outputDir}/summary.csv, header rows, utf8); }ensureDir保證目錄存在避免首次運(yùn)行報(bào)錯(cuò)。CSV 里標(biāo)題和 URL 用雙引號(hào)包住防止內(nèi)容里的逗號(hào)破壞列結(jié)構(gòu)。JSON 用spaces: 2格式化方便人工查看。數(shù)據(jù)量大時(shí) CSV 拼接可能占內(nèi)存可以改成流式寫入但一般幾千篇以內(nèi)直接拼沒問題。4. 避坑與排查抓取過程中最容易翻車的五件事4.1 請求返回空數(shù)據(jù)或 403現(xiàn)象請求發(fā)出后返回空數(shù)組或 403 狀態(tài)碼。原因缺少必要的請求頭或請求頻率過高被臨時(shí)限制。解決補(bǔ)全User-Agent、Referer等頭降低并發(fā)數(shù)每頁之間加延時(shí)。如果持續(xù) 403說明該入口暫時(shí)不可用換時(shí)間段再試。4.2 閱讀量字段匹配不到現(xiàn)象正文能抓到但閱讀量和點(diǎn)贊量全是 0。原因字段名和正則不匹配或數(shù)據(jù)在異步加載的接口里而非頁面源碼。解決先打印完整 HTML 搜索read關(guān)鍵字定位真實(shí)字段名如果頁面源碼里沒有改用抓包工具找到對應(yīng)接口單獨(dú)請求。4.3 翻頁死循環(huán)或漏抓現(xiàn)象程序一直翻頁不結(jié)束或中間漏掉幾頁。原因翻頁游標(biāo)邏輯寫錯(cuò)或接口在某一頁返回異常導(dǎo)致break提前觸發(fā)。解決打印每頁返回的條數(shù)和游標(biāo)值確認(rèn)遞增邏輯對異常返回做判斷區(qū)分「真的沒有下一頁」和「請求失敗」。4.4 評論抓取被限流現(xiàn)象評論抓到前幾頁后開始返回空或報(bào)錯(cuò)。原因評論接口頻率限制比文章列表更嚴(yán)。解決把評論請求的間隔調(diào)到 500 毫秒以上并發(fā)降到 1必要時(shí)分批分時(shí)段抓。4.5 數(shù)據(jù)重復(fù)或統(tǒng)計(jì)偏差現(xiàn)象同一篇文章出現(xiàn)多次閱讀量統(tǒng)計(jì)偏高。原因翻頁過程中接口返回重復(fù)項(xiàng)去重沒做或做得不徹底。解決用文章 ID 做Set去重入庫前再查一次統(tǒng)計(jì)前先按 ID 去重再算中位數(shù)和均值。5. 進(jìn)階技巧把抓取結(jié)果變成可復(fù)用的選題庫抓到數(shù)據(jù)只是第一步真正省時(shí)間的是把結(jié)果整理成能直接用的選題庫。我一般會(huì)做三件事按閱讀量分檔、按時(shí)間看趨勢、按關(guān)鍵詞聚類。分檔用簡單的分位數(shù)就行不用上復(fù)雜模型function analyzeArticles(articles) { // 按閱讀量排序后取分位數(shù) const sorted [...articles].sort((a, b) a.readNum - b.readNum); const q (p) sorted[Math.floor(sorted.length * p)]?.readNum || 0; const low q(0.25); // 25 分位 const mid q(0.5); // 中位數(shù) const high q(0.75); // 75 分位 // 分檔統(tǒng)計(jì) const buckets { low: 0, mid: 0, high: 0 }; for (const a of articles) { if (a.readNum low) buckets.low; else if (a.readNum high) buckets.mid; else buckets.high; } return { low, mid, high, buckets }; }這段代碼把文章按閱讀量分成三檔q(0.25)是 25 分位值低于它的算低閱讀高于 75 分位的算高閱讀。分檔之后就能看出這個(gè)號(hào)的內(nèi)容基本盤在哪哪些選題屬于爆款區(qū)間。趨勢分析按月份分組算均值關(guān)鍵詞聚類用簡單的詞頻統(tǒng)計(jì)就夠不用上 NLP 模型。驗(yàn)證抓取結(jié)果是否可靠我習(xí)慣抽三篇手動(dòng)核對閱讀量和評論數(shù)對不上就回頭查解析邏輯。這個(gè)習(xí)慣是從一次翻車來的——當(dāng)時(shí)正則匹配到了頁面里另一個(gè)同名字段導(dǎo)致所有閱讀量都偏大抽檢才發(fā)現(xiàn)。從那以后我每次抓完都強(qiáng)制走一遍抽檢三篇對不上就全量重跑。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取