引言
团队最近经常需要分析一些网站数据,需要从多个数据网站去手动复制数据到 Excel 里面,这种重复劳动且没有意义的体力活应该交给机器去干,释放出人的劳动力去干更有意思的事,所以有了学习采集方法的这篇文章。开源的采集库有 python 的 scraper,java 的 selenium,ruby 的 watir,nodejs 的 puppeteer,golang 的 chromedp。基于快速上手入门就选择了 puppeteer,备选是 chromedp,因为日常是使用 golang 开发项目。
目录
- 环境搭建
- 网页截屏 demo
- terminal 运行 script 采集目标数据
- web 服务化运行 script 采集目标数据
- 总结
- 了解更多
1、环境搭建
# mac terminal 运行
# 安装homebrew,配置国内镜像的参考 https://mirrors.ustc.edu.cn/help/brew.git.html
$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# 等待 node 安装完成
$ brew install node
# 查看版本
$ node -v
$ npm -v
# 安装puppeteer 环境
$ npm i puppeteer
2、网页截屏 demo
// https://github.com/puppeteer/puppeteer/blob/main/examples/screenshot.js
"use strict";
const puppeteer = require("puppeteer");
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto("http://example.com");
await page.screenshot({ path: "example.png" });
await browser.close();
})();
mac 下面直接运行可能会提示浏览器版本问题,需要指定下载对应的版本才能运行起来。所以修改之后的代码
// 版本号查找链接 http://omahaproxy.appspot.com/
const puppeteer = require("puppeteer");
const browserFetcher = puppeteer.createBrowserFetcher();
// 下载指定版本的chrome浏览器,下载完成之后返回chrome浏览器对象
browserFetcher.download("809590").then(async (res) => {
// options 参数见
// https://zhaoqize.github.io/puppeteer-api-zh_CN/#?product=Puppeteer&version=v8.0.0&show=api-class-puppeteer
const options = {
executablePath: res.executablePath, // chrome执行路径
headless: true, // 浏览器无头模式,后台运行,false 会打卡浏览器
defaultViewport: {
width: 1800,
height: 768,
},
args: ["--start-maximized"],
};
puppeteer.launch(options).then(async (browser) => {
const page = await browser.newPage();
await page.goto("http://www.baidu.com");
await page.screenshot({ path: "baidu.png" });
await browser.close();
});
});
# 运行上面的脚本文件
$ node test.js
$ ls baidu.png
至此已经 puppeteer 入门了。
3、terminal 运行 script 采集目标数据
接下来就是开始针对团队需要分析的数据采集了,先熟悉 puppeteer api 文档,主要熟悉 Page、JSHandle、以及 ElementHandle 对象,下面的代码会经常用到这 3 个 api。
除了上面的 3 个常用对象之外,还要熟悉 chrome 开发者工具的 api,知道怎么去查找 dom 节点的路径。下图是直接打开 chrome 开发者工具,在 Elements 面板里面,选择要查找的 dom 节点上右击弹出菜单。
具体几个功能见 Console Utilities API reference。
下面就开始实战,要想拿到目标网站的数据,需要 2 个步骤,账号登陆和打开指定网页。登陆的代码是通过脚本登陆之后把 cookie 保存到本地文件里面,然后采集的脚本就可以直接载入 cookie 文件直接使用,这样避免了没有身份的问题。这个登陆脚本的弊端是无非解决无头模式下面的扫码登陆。
直接上代码,登陆代码脚本如下,
// login.js
const puppeteer = require("puppeteer");
const fs = require("fs").promises;
const fs2 = require("fs");
const puppeteerNode = puppeteer;
const browserFetcher = puppeteerNode.createBrowserFetcher();
browserFetcher.download("809590").then((res) => {
let options = {
executablePath: res.executablePath, //chrome执行路径
headless: false, //浏览器无头模式
defaultViewport: {
width: 1800,
height: 768,
},
args: ["--start-maximized"],
};
puppeteer.launch(options).then(async (browser) => {
let cookies = {};
const page = await browser.newPage();
if (fs2.existsSync("./cookies.json")) {
const cookiesString = await fs.readFile("./cookies.json");
let cookies = JSON.parse(cookiesString);
await page.setCookie(...cookies);
await page.goto("https://dy.mock.com/login?routerstr=workbench");
} else {
await page.goto("https://dy.mock.com/login?routerstr=workbench");
await page.waitForSelector("#app > div > div.bg_login > div > div > div.of_hidden > div");
const tabs = await page.$$("#app > div > div.bg_login > div > div > div.of_hidden > div");
await tabs[1].click().then(async (res) => {
await page.waitForSelector("#input-msg > div > input");
await page.waitForSelector("#app > div > div > div > div > div > div > form > div.form_item.pointer");
const inputs = await page.$$("#input-msg > div > input");
await inputs[0].type("账号");
await inputs[1].type("密码");
await page.click(
"#app > div > div > div > div > div > div > form > div.form_item.pointer"
);
////有头模式下面,需要等待时间,以便扫码登陆操作完成
// await page.waitForTimeout(5000);
cookies = await page.cookies();
await fs.writeFile("./cookies.json", JSON.stringify(cookies, null, 2));
});
}
browser.close();
});
});
$ node login.js
$ ls cookies.json
下面是抓取页面内容的脚本代码。
// grab.js
const puppeteer = require("puppeteer");
const fs = require("fs").promises;
const browserFetcher = puppeteer.createBrowserFetcher();
browserFetcher.download("809590").then((res) => {
puppeteer
.launch({
executablePath: res.executablePath, //chrome执行路径
headless: false, //浏览器无头模式
})
.then(async (browser) => {
const page = await browser.newPage();
await page.setViewport({ width: 1800, height: 768 });
// 缺少登陆验证,默认已经执行过上面的登陆脚本
const cookiesString = await fs.readFile("./cookies.json");
let cookies = JSON.parse(cookiesString);
await page.setCookie(...cookies);
await page.goto("https://dy.fake.com/kol_list/kol_list");
await page.waitForSelector("table");
await page.waitForSelector("#app > div > div.main_view > div > div:nth-child(1) > div:nth-child(1) > div > div > div > div > div");
const tabs = await page.$$("#app > div > div.main_view > div > div:nth-child(1) > div:nth-child(1) > div > div > div > div > div");
for (let [i, tab] of tabs.entries()) {
const tabName = await (await tab.getProperty("innerText")).jsonValue();
tab.click().then(async (res) => {
await page.waitForSelector("table");
const result = await page.$$eval("table", (tables) => {
let trs = tables[2].children[1].children;
let t = [];
let csv ="名称,粉丝总数,粉丝质量,中位点赞数,中位评论数,中位分享数,指数\n";
for (const tr of trs) {
let name = tr.children[2].innerText;
let fans = tr.children[3].innerText.replace(",", "");
let fansQ = tr.children[4].innerText.replace(",", "");
let likeAvg = tr.children[5].innerText.replace(",", "");
let commentAvg = tr.children[6].innerText.replace(",", "");
let shareAvg = tr.children[7].innerText.replace(",", "");
let index = tr.children[8].innerText.replace(",", "");
let tmp = [name,fans,fansQ,likeAvg,commentAvg,shareAvg,index];
t.push({name: name,fans: fans,q: fansQ,likeAvg: likeAvg,commentAvg: commentAvg,shareAvg: shareAvg,index: index});
csv += tmp.join(",") + "\n";
}
return [t, csv];
});
await fs.writeFile("./" + i + "-" + tabName + "-index.csv",result[1]);
});
await page.waitForTimeout(3000);
}
});
});
# 运行脚本
$ node grab.js
$ ls -la *.csv
可以看一下抓取到的csv文件内容
至此 terminal 爬取数据的部分就结束了。
4、web 服务运行脚本采集数据
web 服务运行脚本这部分代码很简单,就是把上面的 terminal 的代码包装一下,通过 web 服务 对外访问提供服务,这样可以通过浏览器直接打开网页进行操作,无需开 terminal 去运行一些命令。
web 服务这块,我直接选择了 eggjs 框架搭建业务逻辑代码,不用再去写一些 http 相关的代码。
代码如下
'use strict';
const Controller = require('egg').Controller;
const path = require('path');
const puppeteer = require('puppeteer');
const fs = require('fs').promises;
const fs2 = require('fs');
const archiver = require('archiver');
class HomeController extends Controller {
async index() {
const { ctx } = this;
await ctx.render('home/index.tpl');
}
async login() {
const { ctx } = this;
const v = await this.puppeteerLogin(ctx);
await ctx.render('home/login.tpl', { v });
}
async grab() {
const { ctx } = this;
const account = ctx.cookies.get('account');
if (account === '' || account === undefined) {
return ctx.redirect('/');
}
const puppeteerNode = puppeteer;
const browserFetcher = puppeteerNode.createBrowserFetcher();
let v = await browserFetcher.download('809590').then(res => {
const options = {
executablePath: res.executablePath, // chrome执行路径
headless: true, // 浏览器无头模式
defaultViewport: {
width: 1800,
height: 768,
},
args: [ '--start-maximized' ],
};
const v = puppeteer.launch(options)
.then(async browser => {
const page = await browser.newPage();
await page.setViewport({ width: 1800, height: 768 });
const sessionCookieDir = path.join(ctx.app.config.sessionDir, account);
const fileName = account + '-target.zip';
const publicZipFile = path.join(ctx.app.config.publicDir, fileName);
if (!fs2.existsSync(sessionCookieDir)) {
return 'pls wait seconds for login ';
}
const lockFile = sessionCookieDir + '/start.lock';
if (fs2.existsSync(lockFile)) {
console.log('pls wait seconds for done');
return 'pls wait seconds for done';
}
const zipFilePath = path.join(sessionCookieDir, fileName);
if (fs2.existsSync(publicZipFile)) {
console.log(publicZipFile);
return publicZipFile;
}
await fs.writeFile(sessionCookieDir + '/start.lock', 'lock');
const sessionDataDir = path.join(sessionCookieDir, 'data');
if (!fs2.existsSync(sessionDataDir)) {
fs2.mkdirSync(sessionDataDir, '0777', true);
}
const sessionCookiePath = path.join(sessionCookieDir, 'cookies.json');
const cookiesString = await fs.readFile(sessionCookiePath);
const cookies = JSON.parse(cookiesString);
await page.setCookie(...cookies);
await page.goto('https://dy.fake.com/kol_list/kol_list');
await page.waitForSelector('table');
await page.waitForSelector('#app > div > div.main_view > div > div:nth-child(1) > div:nth-child(1) > div > div > div > div > div');
const tabs = await page.$$('#app > div > div.main_view > div > div:nth-child(1) > div:nth-child(1) > div > div > div > div > div');
for (const [ i, tab ] of tabs.entries()) {
const tabName = await (await tab.getProperty('innerText')).jsonValue();
tab.click().then(async res => {
await page.waitForSelector('table');
const result = await page.$$eval('table', tables => {
const trs = tables[2].children[1].children;
const t = [];
let csv = '名称,粉丝总数,粉丝质量,中位点赞数,中位评论数,中位分享数,指数' + "\n";
for (const tr of trs) {
const name = tr.children[2].innerText;
const fans = tr.children[3].innerText.replace(',', '');
const fansQ = tr.children[4].innerText.replace(',', '');
const likeAvg = tr.children[5].innerText.replace(',', '');
const commentAvg = tr.children[6].innerText.replace(',', '');
const shareAvg = tr.children[7].innerText.replace(',', '');
const index = tr.children[8].innerText.replace(',', '');
const tmp = [ name, fans, fansQ, likeAvg, commentAvg, shareAvg, index ];
t.push({name,fans,fansQ,likeAvg,commentAvg,shareAvg,index});
csv += tmp.join(',') + "\n";
}
return [ t, csv ];
});
await fs.writeFile(sessionDataDir + '/' + i + '-' + tabName + '-cassindex.csv', result[1]);
});
await page.waitForTimeout(1000);
}
const output = fs2.createWriteStream(zipFilePath);
const archive = archiver('zip', {zlib: {level: 9}});
await output.on('close', function() {
console.log(archive.pointer() + ' total bytes');
console.log('archiver has been finalized and the output file descriptor has closed.');
fs.copyFile(zipFilePath, publicZipFile);
fs.rm(zipFilePath);
});
await output.on('end', function() {
console.log('Data has been drained');
});
await archive.on('error', function(err) {
throw err;
});
await archive.on('warning', function(err) {
if (err.code === 'ENOENT') {
console.log('warning---->' + err);
} else {
throw err;
}
});
await archive.pipe(output);
await archive.directory(sessionDataDir, false);
await archive.finalize();
await fs.rm(lockFile);
return publicZipFile;
});
return v;
});
if (v.indexOf('/public/') >= 0) {
v = v.replaceAll(ctx.app.config.webRootDir, '');
await ctx.render('home/download.tpl', { v });
} else {
ctx.body = v;
}
}
puppeteerLogin(ctx) {
const username = ctx.request.body.username;
const password = ctx.request.body.password;
const puppeteerNode = puppeteer;
const browserFetcher = puppeteerNode.createBrowserFetcher();
const v = browserFetcher.download('809590')
.then(res => {
const options = {
executablePath: res.executablePath, // chrome执行路径
headless: true, // 浏览器无头模式
defaultViewport: {
width: 1800,
height: 768,
},
args: [ '--start-maximized' ],
};
const v = puppeteer.launch(options)
.then(
async browser => {
let cookies = {};
const page = await browser.newPage();
const sessionCookieDir = path.join(ctx.app.config.sessionDir, username);
if (!fs2.existsSync(sessionCookieDir)) {
fs2.mkdirSync(sessionCookieDir, '0777', true);
}
const sessionCookiePath = path.join(sessionCookieDir, 'cookies.json');
if (fs2.existsSync(sessionCookiePath)) {
const cookiesString = await fs.readFile(sessionCookiePath);
const cookies = JSON.parse(cookiesString);
await page.setCookie(...cookies);
await page.goto('https://dy.fake.com/login?routerstr=workbench');
} else {
await page.goto('https://dy.fake.com/login?routerstr=workbench');
await page.waitForSelector('#app > div > div.bg_login > div > div > div.of_hidden > div');
const tabs = await page.$$('#app > div > div.bg_login > div > div > div.of_hidden > div');
await tabs[1].click()
.then(async res => {
await page.waitForSelector('#input-msg > div > input');
await page.waitForSelector('#app > div > div > div > div > div > div > form > div.form_item.pointer');
const inputs = await page.$$('#input-msg > div > input');
await inputs[0].type(username);
await inputs[1].type(password);
await page.click('#app > div > div > div > div > div > div > form > div.form_item.pointer');
await page.waitForTimeout(1500);
cookies = await page.cookies();
await fs.writeFile(sessionCookiePath, JSON.stringify(cookies, null, 2));
});
}
await browser.close();
ctx.cookies.set('account', username);
return 'success';
}
);
return v;
});
return v;
}
}
module.exports = HomeController;
web 服务部署参见 eggjs 官网文档,根据步骤部署完之后,启动服务可能会遇到一些问题,在 linux 服务器下面安装 puppeteer 涉及一些库依赖,这些错误根据具体提示,直接 Google 一下应该能解决。环境安装完之后运行代码还是依然会遇到代码问题,这是因为在 linux 下面上面的代码需要做出调整,把浏览器启动的参数里面改成
args: [ '--start-maximized', '--no-sandbox', '--disable-setuid-sandbox'],
关闭沙箱模式之后,再启动服务就可以正常运行了。
5、总结
在 puppeteer 入门过程中,遇到各种问题,节点查找,节点文本提取,循环遍历节点等等,这些通过不断的输出调试以及查找 api 和 google 搜索,至此把遇到的问题都给解决了,虽然都解决了问题,但是代码还是不够完善的,只能是跑起来的一个 demo,还需要持续优化代码的。
对于 nodejs 使用的不多,在遇到异步回调的会忘记等待返回或者没有执行 promoise 的 callback,导致写这块的代码比较慢,要查找 api 再来写代码。接下来要再系统的学一下nodejs的知识。
代码里涉及到的抓取网页链接地址被打码了,无法正常访问的。需要的同学可以通过了解更多联系获取。
项目里相关链接
- puppeteer
- puppeteer api: 中文API
- chrome devtools: Console Utilities API reference
- eggjs
- chromedp
- risk of spider
6、了解更多
原文链接:Puppeteer 入门