保姆級指南:一文擁有屬於你的 puppeteer 爬蟲應用
王志遠,微醫前端技術部。愛好吉他、健身、桌遊,最最關鍵,資深大廠員工(kfc 外賣小哥),trust me,好奇心使生命有趣起來!
背景
公司有日報,每天需要在公司的週報系統中填寫並提交,日報內容很簡單,業務層面把自己的提交記錄整理下,然後加點其他如架構、團隊等方面的產出就好;但每次都要【打開週報系統 - 登陸 - 複製粘貼 - 提交】,覺得很麻煩,之前瞭解過前端爬蟲神器 puppetter,遂決定深入學習一番。
但涉及到公司內部的週報系統,咱愛微醫,可不能幹這事兒。發現掘金是前端渲染項目,所以本文的案例採用掘金作爲實戰對象(小白鼠??),啥也不說了,就看小編能不能過了,如果各位看官看到本文,請給掘金的大度點個大大的贊!
本文目標
知識點
-
前端爬蟲知識入門:後端爬蟲依賴接口,如果是前端渲染頁面就無法爬取數據了,所以需要無頭瀏覽器實現前端爬蟲
-
提效工具的前置基礎知識:模擬人爲操作的提效工具,擴展思考範圍
實戰產出
爬取掘金首頁信息搭建了一個博客網站
-
登陸鑑權
-
數據入庫:爬取的數據會存入 mysql 數據庫,採用 navicat 遠程連接數據庫控制
-
訂閱更新:訂閱的標籤有更新文章時會推送至郵箱
相關資料
-
倉庫地址:https://gitee.com/zzmwzy/my-study-repos/tree/master/puppeteer-sty/crawl(求 star)
-
博客線上地址:http://82.157.62.28:8082/
當前實現效果
項目開始前置動作
依賴版本鎖定
{
"name": "crawl",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo "Error: no test specified" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^0.27.2",
"bluebird": "^3.7.2",
"body-parser": "^1.20.0",
"chalk": "^5.0.1",
"cheerio": "^1.0.0-rc.11",
"child_process": "^1.0.2",
"cron": "^2.0.0",
"ejs": "^3.1.8",
"express": "^4.18.1",
"express-session": "^1.17.3",
"iconv-lite": "^0.6.3",
"mysql": "^2.18.1",
"nodemailer": "^6.7.5",
"puppeteer": "^14.1.1",
"request": "^2.88.2",
"request-promise": "^4.2.6",
"urijs": "^1.19.11"
}
}
目錄結構
這個目錄結構可以在實戰時用作參考(別有壓力呀!)
.
├── 1. puppertee
│ ├── 1.js
│ ├── 2.js
│ ├── 3.js
│ ├── 4.js
│ ├── 5. 爬取京東.js
│ ├── baidu.png
│ └── items-0.png
├── 2. request
│ ├── 1.request-json.js
│ ├── 2.request-form.js
│ ├── 3.request-file.js
│ └── avatar.jpeg
├── 3. cheerio
│ ├── 1.cheerio.js
│ ├── 2.cheerio-selector.js
│ ├── 3.cheerio-attr.js
│ ├── 4.cheerio-props.js
│ └── 5.cheerio-find.js
├── 4. dependens
│ ├── 1. cron.js
│ ├── 2. error.js
│ ├── 3.debug.js
│ ├── 4. pm2.js
│ ├── 5. iconv-lite.js
│ ├── 6.mail.js
│ ├── 7.read.js
│ └── my-debug.js
├── bdyp.js
├── crawl-server
│ ├── app.js
│ ├── bin
│ │ └── www
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ │ ├── images
│ │ ├── javascripts
│ │ └── stylesheets
│ ├── routes
│ │ ├── index.js
│ │ └── users.js
│ └── uploads
│ ├── 50d33a30f74fd55ffc0f3c0aaea989b6
│ └── f24715d08bab6243f62bbe9f16a52d05
├── crawl.sql
├── db.js
├── mail.js
├── main.js
├── package-lock.json
├── package.json
├── read
│ ├── article-detail.js
│ ├── articles.js
│ ├── index.js
│ ├── tags.js
│ └── text.html
├── readme.md
├── utils
│ ├── domain-util.js
│ └── puppeteer-utils.js
├── web
│ ├── middleware
│ │ └── auth.js
│ ├── public
│ │ └── css
│ ├── router
│ │ └── bdyp.js
│ ├── server.js
│ ├── update
│ └── views
│ ├── detail.html
│ ├── footer.html
│ ├── header.html
│ ├── index.html
│ ├── login.html
│ └── subscribe.html
└── write
├── articles.js
├── index.js
└── tags.js
前面的髒活累活都整完啦,開搞開搞
第一步:熟悉爬蟲基礎概念
期待產出
-
傳統爬蟲怎麼工作的:利用 request 包實現傳統後端爬蟲爬取掘金標籤
-
前端爬蟲面向的問題:前端渲染導致傳統爬蟲無法抓取到數據
-
前端爬蟲怎麼工作的:
-
利用 puppetter 對百度官網進行截圖
-
利用 puppetter 爬取京東,模擬搜索實現爬取京東手機列表
這裏注意一定要先完成【開營計劃】中的項目前置,包括了依賴安裝,避免依賴版本導致的報錯
傳統爬蟲怎麼工作的:利用 request 包爬取掘金前端標籤下的首頁所有文章標題
目標
掘金前端標籤下的首頁所有文章標題並保存至【titles.txt】中
思路
-
獲取 html:使用 request 包請求頁面對應 url 從而獲取
-
獲取標題:對 html 字符串根據正則進行截取
實戰
我們先在倉庫根目錄下新建一個第一天實戰的目錄及文件1.puppertee/1.js
(在 git bash 中可以直接執行如下命令)
mkdir 1.puppertee && cd 1.puppertee && touch 1.js
然後實現如下內容即可
let request = require("request");
let url = "https://juejin.cn/tag/%E5%89%8D%E7%AB%AF";
let fs = require("fs");
let regexp = /class="title" data-v-\w+>(.+?)</a>/g;
request(url, (err, response, body) => {
let titles = [];
body.replace(regexp, (matched, title) => {
titles.push(title);
});
console.log(titles);
fs.writeFileSync("titles.txt", titles);
});
執行如下命令查看效果
node 1.js
實現效果如下
前端爬蟲面向的問題:前端渲染導致傳統爬蟲無法抓取到數據
這是我們提出一個需求,我們希望抓取掘金文章,你們會發現抓取不到東西,因爲掘金文章是前端渲染的頁面,後端接口請求時只能獲取到掛載點和未執行的 js 文件(這就不過多解釋了),那我們該怎麼辦?
前端爬蟲!我們使用 puppeteer 實現前端爬蟲
puppeteer
-
puppeteer 是 Chrome 團隊開發的一個 node 庫
-
可以通過 api 來控制瀏覽器的行爲,比如點擊,跳轉,刷新,在控制檯執行 js 腳本等等
-
通過這個工具可以用來寫爬蟲,自動簽到,網頁截圖,生成 pdf,自動化測試等
閒話不多說,實戰下就知道了
前端爬蟲怎麼工作的:利用 puppetter 對百度官網進行截圖
目標
利用 puppetter 打開百度官網,並對頁面進行截圖,存儲在項目根路徑
思路
-
頁面對象:puppeteer.launch 可以獲取一個瀏覽器實例,而此實例的 newPage 方法可以獲取一個頁面對象
-
打開百度:頁面對象存在 goto 方法,支持跳轉指定 url
-
頁面截圖:頁面對象存在 screenshot 方法,支持頁面截圖
實戰
我們先在倉庫根目錄下新建一個第一天實戰的目錄及文件1.puppertee/2.js
(在 git bash 中可以直接執行如下命令)
touch 2.js
然後實現如下內容即可
let puppeteer = require("puppeteer");
(async () => {
// 打開一個無界面的瀏覽器
const browser = await puppeteer.launch();
// 打開一個空白頁
let page = await browser.newPage();
// 在地址欄中輸入百度的地址
await page.goto("http://baidu.com");
// 把當前頁面進行截圖 保存在 baidu.png 文件中
await page.screenshot({
path: "baidu.png",
});
await browser.close(); //關閉瀏覽器
})();
執行如下命令查看效果
node 2.js
實現效果如下
前端爬蟲怎麼工作的:利用 puppetter 爬取京東,模擬搜索實現爬取京東手機列表
目標
利用 puppetter 爬取京東,模擬搜索實現爬取京東手機列表
思路
-
頁面對象:puppeteer.launch 可以獲取一個瀏覽器實例,而此實例的 newPage 方法可以獲取一個頁面對象
-
打開京東:頁面對象存在 goto 方法,支持跳轉指定 url
-
搜索手機關鍵詞:頁面對象存在 keyboard.type 方法,支持鍵盤事件
-
獲取手機標題列表:頁面對象存在 $$eval 方法,傳入選擇器,回傳對應的 DOM
實戰
我們先在倉庫根目錄下新建一個第一天實戰的目錄及文件1.puppertee/3.js
(在 git bash 中可以直接執行如下命令)
touch 3.js
然後實現如下內容即可
const puppeteer = require("puppeteer");
(async function () {
const browser = await puppeteer.launch({ headless: false }); //啓動瀏覽器
let page = await browser.newPage(); //創建一個 Page 實例
await page.setJavaScriptEnabled(true); //啓用 javascript
await page.goto("https://www.jd.com/");
const searchInput = await page.$("#key"); //獲取元素
await searchInput.focus(); //定位到搜索框
await page.keyboard.type("手機"); //輸入手機
const searchBtn = await page.$(".button");
await searchBtn.click();
await page.waitForSelector(".gl-item"); //等待元素加載之後,否則獲取不了異步加載的元素
const links = await page.$$eval(
".gl-item > .gl-i-wrap > .p-img > a",
(links) => {
return links.map((a) => {
return {
href: a.href.trim(),
title: a.title,
};
});
}
);
console.log(links);
})();
執行如下命令查看效果
node 3.js
實現效果如下
個人思考
我們已經可以利用 puppeteer 實現模擬用戶動作(寫入手機並觸發搜索),那如果我們要拿返回信息跳轉詳情並爬取詳情信息呢?請試試
第二步:抓去掘金數據
編寫網絡爬蟲抓取掘金數據(掘金標籤、文章、文章詳情),並存儲到 MySQL 數據庫中
- mysql 數據庫服務
-
數據入庫:mysql + bluebird
-
navicat 遠程連接數據庫
-
建表(如果是用筆者的服務器則可跳過)
- node 服務搭建(基於 express),提供觸發爬取接口
-
發起前端模擬瀏覽器請求獲取網頁內容:puppeteer
-
使用類似 jQuery 的語法來操作網頁提取需要的數據:cheerio
-
把數據保存到數據庫中以供查詢:mysql
實現效果
爬取 tag 信息
入庫數據
mysql 數據庫服務
要建表,需搭建 mysql 服務,可參考個人文章(保姆級指南:centos 安裝 mysql ):https://juejin.cn/post/7104346481787666446/
navicat 遠程連接數據庫
我們找個可視化工具來控制 mysql,這裏選用了 navicat,破解版分享網盤如下
- mac 版本
鏈接: https://pan.baidu.com/s/1RyTNoApa7MxkIDTtbQ2jkQ 密碼: jfjl
--來自百度網盤超級會員 V4 的分享
- win 版本
鏈接: https://pan.baidu.com/s/1WSxWm1eCqRnao9j8AGLQFQ 密碼: gdv1
--來自百度網盤超級會員 V4 的分享
一路 next 即可,安裝好後,連接剛剛搭建好的遠程 mysql,新建數據庫crawl-db
。
建表
使用 Navicat 導入數據庫
sql 數據
先下載對應的數據庫結構 sql 文件
鏈接: https://pan.baidu.com/s/12XVYjur54zz6eN9x-Ez9LA 密碼: mhjj
--來自百度網盤超級會員 V4 的分享
Navicat Premium 連接
打開 Navicat Premium,然後點擊右鍵選擇新建數據庫,名字跟我們要導入的數據庫的名字一樣
點擊確定後,我們就可以見到左邊出現剛剛我們建立好的數據庫了,然後右擊選擇 “運行 SQL 文件” 會彈出一個框,點擊 “...” 選擇文件所在的路徑,
點擊開始,文件就會導入成功!
node 服務搭建
-
提供 tag 接口:請求時爬取掘金標籤頁,獲取所有標籤信息併入庫 tags 表中
-
read:利用 puppetter 爬取標籤信息
-
write:寫入數據庫 tag 表中
-
提供 article 接口:請求時爬取指定標籤對應的掘金文章列表,獲取所有文章即對應詳情信息,併入庫 article、article-detail 和 article_tag 表中
-
查詢:根據標籤名查處理標籤對應頁面地址
-
read:利用 puppetter 爬取標籤頁信息
-
write:寫入數據庫 articles 表中,並將標籤和文章對應關係寫入中間表
article_tag
中
入口文件 server.js
我們先在倉庫根目錄下新建一個第二天實戰的目錄及文件web/server.js
(在 git bash 中可以直接執行如下命令)
mkdir web && cd web && touch server.js
然後實現如下內容即可
let express = require("express");
const { query } = require("../db");
let app = express();
let read = require("./utils/read");
let write = require("./utils/write");
app.listen(8082);
app.post("/tag", async function (req, res) {
// 獲取所有標籤
let tagUrl = "https://juejin.im/subscribe/all";
//讀取掘金的標籤列表
let tags = await read.tags(tagUrl);
// 把標籤寫到數據庫中
await write.tags(tags);
});
app.post("/article", async function (req, res) {
let { tagName } = req.query;
let tags = await query(`SELECT * FROM tags`);
tags = tags.filter((tag) => tag.name === tagName);
// 根據標籤獲取所有的文章
let allAricles = {};
// 標籤有很多,不同的標籤下面的文章可能會重複
for (tag of tags) {
let articles = await read.articles(tag.href, 1);
articles.forEach((article) => (allAricles[article.id] = article));
}
// {id:article}
await write.articles(Object.values(allAricles));
});
其中爬取數據和寫入數據至數據庫的實現放在utils
中,目錄結構如下
├── domain-util.js
├── puppeteer-utils.js
├── read
│ ├── article-detail.js
│ ├── articles.js
│ ├── index.js
│ ├── tags.js
└── write
├── articles.js
├── index.js
└── tags.js
創建好後依次實現即可;
domain-util.js
const URI = require("urijs");
/**
* @param {Mixin} url 地址或 uri
*/
const getHostName = function (uri) {
uri = new URI(uri);
return uri.hostname();
};
module.exports = {
getHostName,
};
puppeteer-utils.js
let puppeteer = require("puppeteer");
const cheerio = require("cheerio");
const domainUtil = require("./domain-util");
let browser;
async function getHTML(uri, isAutoScrollToBottom = true) {
let page = await openPage(uri);
/** 自動滾動至頁面底部,用於處理頁面觸底加載的情況
* @param {*} page page 對象
* @param {*} interval 間隔請求時間,儘可能趨近【被爬頁面觸底加載請求接口】的返回時間,但一定不要小於,不然就會出現爬取不完整的情況
*/
async function autoScrollToBottom(page, interval = 3000) {
// Expose a function 這個用於客戶端代碼 debugger 避免源碼映射失效的情況 //# sourceURL=__puppeteer_evaluation_script_
// 解決方案來源:https://stackoverflow.com/questions/65584989/debug-in-chromium-puppeteer-doesnt-populate-evaluate-script
// 這個 api 原意是偵聽頁面中觸發的自定義事件,可見文檔 https://www.qikegu.com/docs/4564
page.exposeFunction("nothing", () => null);
// 放在這裏的函數會在客戶端環境下執行 並且裏面的內容和外層是隔絕的,這意味着外面的依賴、方法都不能使用
await page.evaluate(async (...args) => {
await new Promise((resolve, reject) => {
let totalHeight = 0;
function exec() {
totalHeight = document.body.scrollHeight;
// 1. 滾動到底部
window.scrollBy(0, totalHeight);
// 2. 等待 10s 判斷頁面高度有無變化
setTimeout(() => {
// 1. 變化了,則重複行爲
if (document.body.scrollHeight > totalHeight) {
exec();
} else {
// 2. 沒變化,則結束行爲
resolve();
}
}, 3000);
}
exec();
});
});
}
if (isAutoScrollToBottom) {
await autoScrollToBottom(page);
}
// 獲取頁面完整 dom
let sum = await page.content();
const $ = cheerio.load(sum);
return $;
}
/** 打開一個無頭瀏覽器
*
* @param {*} opts
* @returns
*/
async function getPage(
opts = {
headless: false,
devtools: true,
}
) {
if (!browser) {
browser = await puppeteer.launch(opts);
}
// 打開一個空白頁
let page = await browser.newPage();
return page;
}
/** 打開一個無頭瀏覽器 並跳轉至指定地址
* @param {*} uri
* @returns page 對象
*/
async function openPage(
uri,
opts = {
headless: false,
devtools: true,
}
) {
let page = await getPage(opts);
//設置頁面打開時的頁面寬度高度
// await page.setViewport({
// width: 1920,
// height: 1080,
// });
// 在地址欄中輸入百度的地址
await page.goto(uri, {
waitUntil: "networkidle2",
});
return page;
}
/** 爲頁面對象添加 cookies
* @param {*} cookies
* @param {*} page
* @param {*} domain
*/
const addCookies = async (page, cookies, domain) => {
if (typeof cookies === "string") {
cookies = cookies.split(";").map((pair) => {
let name = pair.trim().slice(0, pair.trim().indexOf("="));
let value = pair.trim().slice(pair.trim().indexOf("=") + 1);
return { name, value, domain };
});
}
await Promise.all(
cookies.map((pair) => {
return page.setCookie(pair);
})
);
};
/**
*
* @param {*} url
* @param {*} cookies 自己的 cookies 支持數組和字符串形式
*/
async function login(url, cookies) {
let page = await getPage({
ignoreHTTPSErrors: true,
headless: false,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
// const ps = await browser.pages();
// await ps[0].close();
await addCookies(page, cookies, domainUtil.getHostName(url)); //雲盤域名
await page.setViewport({
//修改瀏覽器視窗大小
width: 1920,
height: 1080,
});
await page.goto(url, {
timeout: 600000,
waitUntil: "networkidle2",
});
return page;
}
async function click(page, select) {
await page.waitForSelector(select);
let node = await page.$(select);
node.click();
}
module.exports = {
getHTML,
openPage,
login,
click,
};
reead/index.js
let { tags } = require("./tags");
let { articles } = require("./articles");
module.exports = {
tags,
articles,
};
read/tags.js
const debug = require("debug")("juejin:task:read");
const puppeteerUtils = require("../puppeteer-utils");
function get(owner, props) {
if (owner) {
return owner[props];
} else {
return "";
}
}
exports.tags = async function (uri) {
debug("讀取文章標籤列表");
let $ = await puppeteerUtils.getHTML(uri);
let tags = [];
let domTags = $("li.item");
domTags.each((i, item) => {
let tag = $(item);
let image = tag.find("img.thumb").first();
let title = tag.find(".title").first();
let subscribe = tag.find(".subscribe").first();
let article = tag.find(".article").first();
let name = title.text().trim();
tags.push({
image: image.data("src") ? image.data("src").trim() : image.data("src"),
name,
url: `https://juejin.im/tag/${encodeURIComponent(title.text().trim())}`,
subscribe: get(Number(subscribe.text().match(/(\d+)/), [1])),
article: get(Number(article.text().match(/(\d+)/), [1])),
});
debug(`讀取文章標籤:${name}`);
});
return tags.filter((item) => item.name);
};
read/article-detail.js
const debug = require("debug")("juejin:task:read-detail");
const puppeteerUtils = require("../puppeteer-utils");
async function readArticle(id, uri) {
debug("讀取博文");
let $ = await puppeteerUtils.getHTML(uri, false);
let article = $(".main-container");
let title = article.find("h1").text().trim();
let content = article.find(".article-content").html();
// let tags = article.find(".tag-list-box>div.tag-list .tag-title");
// tags = tags.map((index, item) => {
// let href = $(item).attr("href");
// return href ? href.slice(4) : href;
// });
let tags;
// 獲取 yuan
let metas = article.find("meta");
for (let index = 0; index < metas.length; index++) {
const meta = metas[index];
if (meta.attribs && meta.attribs.itemprop === "keywords") {
tags = meta.attribs.content;
}
}
tags = tags.split(",");
debug(`讀取文章詳情:${title}`);
return {
id,
title,
content,
tags,
};
}
module.exports = {
readArticle,
};
read/article.js
const debug = require("debug")("juejin:task:read");
const puppeteerUtils = require("../puppeteer-utils");
const { readArticle } = require("./article-detail");
function removeEmoji(content) {
return (content || "").replace(
/(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|\ud83c[\ude32-\ude3a]|\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/g,
""
);
}
exports.articles = async function (uri, maxNum = 0) {
let $ = await puppeteerUtils.getHTML(uri, false);
let articleList = [];
let items = $(".item .title");
let articleNum = maxNum || items.length;
for (let i = 0; i < articleNum; i++) {
let article = $(items[i]);
let href = article.attr("href").trim();
let title = article.text().trim();
let id = href.match(//(\w+)$/)[1];
href = "https://juejin.im" + href;
let articleDetail = await readArticle(id, href);
articleList.push({
href,
title: removeEmoji(title),
id,
content: removeEmoji(articleDetail.content),
tags: articleDetail.tags,
});
debug(`讀取文章列表:${title}`);
}
return articleList;
};
write/index.js
let { tags } = require("./tags");
let { articles } = require("./articles");
module.exports = {
tags,
articles,
};
write/tags.js
const { query, end } = require("../../../db");
const debug = require("debug")("juejin:task:write");
exports.tags = async function (tagList) {
debug("保存文章標籤列表");
// 這裏在表設計中新增了一個索引,用於確定 tag 名稱唯一
for (tag of tagList) {
let oldTags = await query(`SELECT * FROM tags WHERE name=? LIMIT 1 `, [
tag.name,
]);
// oldTags = JSON.parse(JSON.stringify(oldTags));
if (Array.isArray(oldTags) && oldTags.length > 0) {
let oldTag = oldTags[0];
await query(`UPDATE tags SET name=?,image=?,url=? WHERE id=?`, [
tag.name,
tag.image,
tag.url,
oldTag.id,
]);
} else {
await query(`INSERT INTO tags(name,image,url) VALUES(?,?,?)`, [
tag.name,
tag.image,
tag.url,
]);
}
}
};
write/articles.js
const { query, end } = require("../../../db");
const debug = require("debug")("juejin:task:write");
const sendMail = require("../../../mail");
exports.articles = async function (articleList) {
debug("寫入博文列表");
for (article of articleList) {
let oldArticles = await query(
`SELECT * FROM articles WHERE id=? LIMIT 1 `,
article.id
);
if (Array.isArray(oldArticles) && oldArticles.length > 0) {
let oldArticle = oldArticles[0];
await query(`UPDATE articles SET title=?,content=?,href=? WHERE id=?`, [
article.title,
article.content,
article.href,
oldArticle.id,
]);
} else {
await query(
`INSERT INTO articles(id,title,href,content) VALUES(?,?,?,?)`,
[article.id, article.title, article.href, article.content]
);
}
// 先全部刪除
await query(`DELETE FROM article_tag WHERE article_id=? `, [article.id]);
const where = "('" + article.tags.join("','") + "')";
const sql = `SELECT id FROM tags WHERE name IN ${where}`;
let tagIds = await query(sql);
// 再全部插入
for (row of tagIds) {
await query(`INSERT INTO article_tag(article_id,tag_id) VALUES(?,?)`, [
article.id,
row.id,
]);
}
let tagIDsString = tagIds.map((item) => item.id).join(",");
// 在此,向所有訂閱了此標籤的用戶發送郵件
let emailSQL = `
SELECT DISTINCT users.email from user_tag INNER JOIN users ON user_tag.user_id = user_id WHERE tag_id IN (${tagIDsString})
`;
let emails = await query(emailSQL);
for (let index = 0; index < emails.length; index++) {
const emailInfo = emails[index];
sendMail(
emailInfo.email,
`
您訂閱的文章更新了
`,
`<a href="http:localhost:8080/detail/${article.id}">${article.title}</a>`
);
}
}
};
第三步:將爬取到的數據在一個 web 應用中展示
將爬取到的數據在一個 web 應用中展示;包含
node 服務功能擴展(基於 express)
-
支持靜態資源請求:express.static
-
支持 post 文件上傳請求:body-parser
-
支持登陸態:express-session
-
渲染引擎:ejs
node 服務搭建
-
路由搭建
-
頁面接口:首頁(/)、登陸(/login)、文章詳情頁(/detail/:id)、訂閱頁(/subscribe)
-
數據接口:提交登陸(/login)、提交訂閱(/subscribe)
-
頁面實現
-
鑑權中間件實現
路由搭建:入口文件 server.js
實現如下內容即可
let express = require("express");
let bodyParser = require("body-parser");
let session = require("express-session");
let { checkLogin } = require("./middleware/auth");
const path = require("path");
const { query } = require("../db");
const CronJob = require("cron").CronJob;
const debug = require("debug")("crawl:server");
const { spawn } = require("child_process");
let app = express();
app.use(express.static("web/public"));
app.use(
bodyParser.urlencoded({
extends: true,
})
);
app.use(bodyParser.json());
app.use(
session({
resave: true, // 每次都要重新保存 session
saveUninitialized: true, // 保存未初始化的 session
secret: "wzyan", // 指定密鑰
})
);
app.use(function (req, res, next) {
res.locals.user = req.session.user;
next();
});
app.set("view engine", "html");
app.set("views", path.resolve("web/views"));
app.engine("html", require("ejs").__express);
app.get("/", async function (req, res) {
let { tagId } = req.query;
let tags = await query(`SELECT * FROM tags`);
tagId = tagId || tags[0].id;
let articles = await query(
`SELECT a.* from articles a inner join article_tag t on a.id = t.article_id WHERE t.tag_id =? `,
[tagId]
);
res.render("index", {
tags,
articles,
});
});
app.get("/login", async function (req, res) {
res.render("login", { title: "登錄" });
});
app.post("/login", async function (req, res) {
let { email, password } = req.body;
let oldUsers = await query(`SELECT * FROM users WHERE email=?`, [email]);
let user;
if (Array.isArray(oldUsers) && oldUsers.length > 0) {
user = oldUsers[0];
} else {
let result = await query(`INSERT INTO users(email,password) VALUES(?,?)`, [
email,
password,
]);
user = {
id: result.insertId,
email,
password,
};
}
// 如果登陸成功,就把當前的用戶信息放在會話中,並重定向到首頁
req.session.user = user;
res.redirect("/");
});
app.get("/subscribe", checkLogin, async function (req, res) {
let tags = await query(`SELECT * FROM tags`);
let user = req.session.user; //{id,name}
let selectedTags = await query(
`SELECT tag_id from user_tag WHERE user_id = ?`,
[user.id]
);
let selectTagIds = selectedTags.map((item) => item["tag_id"]);
tags.forEach((item) => {
item.subscribe = selectTagIds.indexOf(item.id) != -1 ? true : false;
});
res.render("subscribe", { title: "請訂閱你感興趣的標籤", tags });
});
app.post("/subscribe", checkLogin, async function (req, res) {
console.log(req.body);
let { tags } = req.body; //[ '1', '2', '9' ] }
if (!tags) {
tags = [];
}
if (typeof tags === "string") {
tags = [tags];
}
function getNum(string) {
return string.replace(/[^0-9]/gi, "");
}
tags = tags.map((tag) => getNum(tag));
console.log(tags);
let user = req.session.user; //{id,name}
await query(`DELETE FROM user_tag WHERE user_id=?`, [user.id]);
for (let i = 0; i < tags.length; i++) {
await query(`INSERT INTO user_tag(user_id,tag_id) VALUES(?,?)`, [
user.id,
parseInt(tags[i]),
]);
}
res.redirect("/");
});
app.get("/detail/:id", async function (req, res) {
let id = req.params.id;
let articles = await query(`SELECT * FROM articles WHERE id=? `, [id]);
res.render("detail", { article: articles[0] });
});
app.listen(8082);
process.on("uncaughtException", function (err) {
console.error("uncaughtException: %s", err.stack);
});
服務已經搭建完成了,就差一步啦,實現前端頁面,衝!
頁面實現:頁面模板文件夾 views
頁面都放在 views 中,目錄結構如下
├── detail.html
├── header.html
├── index.html
├── login.html
└── subscribe.html
這裏我們需要如下頁面
-
index:首頁
-
login:登陸頁
-
header:頂部通用模塊
-
detail:文章詳情頁
-
subscribe:訂閱標籤頁
我們分別實現下,先實現首頁
mkdir views && cd views && touch index.html && touch login.html touch header.html touch detail.html touch subscribe.html
然後實現如下內容
<%- include ('header.html')%>
<div class="container">
<div class="row">
<div class="col-md-2">
<ul class="list-group">
<%tags.forEach(tag=>{%>
<li class="list-group-item text-center">
<a href="/?tagId=<%=tag.id%>">
<img style="width: 25px; height: 25px" src="<%=tag.image%>" />
<%=tag.name%>
</a>
</li>
<%})%>
</ul>
</div>
<div class="col-md-10">
<ul class="list-group">
<%articles.forEach(article=>{%>
<li class="list-group-item">
<a href="/detail/<%=article.id%>"> <%=article.title%> </a>
</li>
<%})%>
</ul>
</div>
</div>
</div>
login 登陸頁,實現如下內容
<%- include ('header.html')%>
<div class="row">
<div class="col-md-4 col-md-offset-4">
<form method="POST">
<input
type="email"
class="form-control"
placeholder="請輸入郵箱進行登錄"
/>
<input
type="password"
class="form-control"
placeholder="密碼"
/>
<button type="submit" class="btn btn-default">提交</button>
</form>
</div>
</div>
header 登陸頁,實現如下內容
<head>
<meta charset="UTF-8" />
<meta />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
<title>博客列表</title>
</head>
<body>
<nav class="navbar navbar-default">
<div class="container-fluid">
<!-- Brand and toggle get grouped for better mobile display -->
<div class="navbar-header">
<button
type="button"
class="navbar-toggle collapsed"
data-toggle="collapse"
data-target="#bs-example-navbar-collapse-1"
aria-expanded="false"
>
<span>Toggle navigation</span>
<span></span>
<span></span>
<span></span>
</button>
<a class="navbar-brand" href="#">博客列表</a>
</div>
<!-- Collect the nav links, forms, and other content for toggling -->
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<ul class="nav navbar-nav">
<li><a href="/">首頁</a></li>
<% if(user){ %>
<li><a href="/subscribe">訂閱</a></li>
<%} else {%>
<li><a href="/login">登陸</a></li>
<%}%>
</ul>
</div>
<!-- /.navbar-collapse -->
</div>
<!-- /.container-fluid -->
</nav>
</body>
detail 登陸頁,實現如下內容
<%- include ('header.html')%>
<div class="container">
<div class="row">
<div class="col-md-12">
<div class="panel">
<div class="panel-heading">
<h1 class="text-center"><%- article.title%></h1>
</div>
<div class="panel-body"><%- article.content%></div>
<div></div>
</div>
</div>
</div>
</div>
subscribe 登陸頁,實現如下內容
<%- include ('header.html')%>
<style>
.tag {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.tag img {
width: 50px;
margin-bottom: 20px;
}
</style>
<div class="row">
<form method="POST">
<input type="submit" class="btn btn-primary" />
<%
for(let i=0;i<tags.length;i++){
let tag = tags[i];
%>
<div class="col-md-3 tag">
<img src="<%=tag.image%>" />
<p>
<%=tag.name%>
</p>
<p>
<%=tag.subscribe%> 關注
<%=tag.article%> 文章
</p>
<div class="checkbox">
<label>
<input <%=tag.subscribe? "checked": ""%> type="checkbox"
<%=tag.id%>"> 關注
</label>
</div>
</div>
<%}
%>
</form>
</div>
login 登陸頁,實現如下內容
<%- include ('header.html')%>
<div class="row">
<div class="col-md-4 col-md-offset-4">
<form method="POST">
<input
type="email"
class="form-control"
placeholder="請輸入郵箱進行登錄"
/>
<input
type="password"
class="form-control"
placeholder="密碼"
/>
<button type="submit" class="btn btn-default">提交</button>
</form>
</div>
</div>
鑑權中間件實現:鑑權中間件
auth 鑑權中間件,在 web 目錄下執行如下命令
mkdir middleware && touch auth.html
然後實現如下內容
function checkLogin(req, res, next) {
if (req.session && req.session.user) {
next();
} else {
res.redirect("/login");
}
}
module.exports = {
checkLogin,
};
至此,我們就完成了用於數據展示的 node 服務搭建啦!
尾聲
少年們,心法已定,拿走不謝,嘗試動手自己實現下吧!希望可以幫到大家,爬蟲雖好,不要過度哦。
送上參考資料,助君一臂之力
參考資料
文檔
-
w3cschool - Puppeteer 手冊:https://www.w3cschool.cn/puppeteer/
-
Puppeteer 中文文檔:https://www.mofazhuan.com/puppeteer-doc-zh
-
奇客谷教程:https://www.qikegu.com/docs/4525
-
阿里雲社區 - Puppeteer APIv1.11 中文版:https://developer.aliyun.com/article/607102
-
F2E 中文文檔:https://learnku.com/docs/puppeteer/3.1.0/class-elementhandle/8558
-
追風個人博客 missyou:http://blogs.lovemiss.cn/blogs/node/puppeteer/page.html#page-setcontent
文章
-
記錄一下 Node 結合 Puppeteer 爬蟲經歷:https://www.jianshu.com/p/0808b8117fd7
-
結合項目來談談 Puppeteer:https://zhuanlan.zhihu.com/p/76237595
-
Puppeteer 性能優化與執行速度提升:https://blog.it2048.cn/article-puppeteer-speed-up/
前往微醫互聯網醫院在線診療平臺,快速問診,3 分鐘爲你找到三甲醫生。(https://wy.guahao.com/?channel=influence)
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/ddmfrm1XVsxt1DOHaE4gxQ