- Notifications
You must be signed in to change notification settings - Fork49
译文:Puppeteer 与 Chrome Headless —— 从入门到爬虫
License
csbun/thal
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
和他 的Medium 英文原文
Puppeteer
是 Google Chrome 团队官方的无头/无界面(Headless)Chrome 工具。正因为这个官方声明,许多业内自动化测试库都已经停止维护,包括PhantomJS。Selenium IDE for Firefox 项目也因为缺乏维护者而终止。
译者注:关于 PhantomJS 和 Selenium IDE for Firefox 停止维护并没有找到相关的公告,但这两个项目的确已经都超过 2 年没有发布新版本了。但另一个今年 5 月才开启的项目Chromeless 目前在 Github 上已经超过 1w star,目前还非常活跃。
Chrome 作为浏览器市场的领头羊,Chrome Headless 必将成为 web 应用自动化测试 的行业标杆。所以我整合了这份如何利用Chrome Headless 做网页爬虫
的入门指南。
本文我们将使用Chrome Headless
,Puppeteer
,Node
和MongoDB
,爬取 GitHub,登录并提取和保存用户的邮箱。不用担心 GitHub 的频率限制,本文会基于 Chrome Headless 和 Node 给你相应的策略。同时,请时刻关注Puppeteer
的文档,因为该项目仍然处于开发中,API 并不是很稳定。
开始之前,我们需要安装以下工具。点击他们的官网然后安装吧。
译者注:Puppeteer 要求使用 Node v6.4.0,但因为文中大量使用
async/await
,需要 Node v7.6.0 或以上。
项目都是以创建文件夹开始。
$ mkdir thal$ cd thal
初始化 NPM,填入一些必要的信息。
$ npm init
安装Puppeteer
。由于Puppeteer
并不是稳定的版本而且每天都在更新,所以如果你想要最新的功能可以直接通过 GitHub 的仓库安装。
$ npm i --save puppeteer
Puppeteer 包含了自己的 chrome / chromium 用以确保可以无头地工作。因此每当你安装/更新 puppeteer 的时候,他都会下载指定的 chrome 版本。
我们将从页面截图开始,这是他们的文档中的代码。
constpuppeteer=require('puppeteer');asyncfunctionrun(){constbrowser=awaitpuppeteer.launch();constpage=awaitbrowser.newPage();awaitpage.goto('https://github.com');awaitpage.screenshot({path:'screenshots/github.png'});browser.close();}run();
如果您第一次使用Node
7 或 8,那你可能不太熟悉async
和await
关键字。简单地说,一个async
函数返回一个 Promise,当 Promise 完成时会返回你所定义的内容。当你需要像同步函数那样调用时,需要使用await
。
保存上面的代码为index.js
文件到项目目录里。并运行
$ node index.js
这样截图就会被保存到screenshots/
目录下。
译者注:如果不是 clone 本 repo 的童鞋可能会遇到
screenshots
目录不存在的异常,请先手动创建该目录,或使用mkdirp。
如果你在 GitHub 上搜索john,然后点击 Users 标签,你将看到一个带有姓名信息的用户列表。
有些用户设置了他们的邮箱是公开可见的,但有些用户没有。但你一个邮箱都看不到的原因是没有登录。下面,让我们利用Puppeteer 文档 来登录吧~
译者注:上图是登录后看到的效果
在项目根目录添加一个creds.js
文件。我强烈建议用一个没什么卵用的邮箱来注册一个新 GitHub 账号,不然 GitHub 可能会封掉你的常用账号。
module.exports={username:'<GITHUB_USERNAME>',password:'<GITHUB_PASSWORD>'};
同时添加一个.gitignore
文件,输入以下内容:
node_modules/creds.js
在调用 Puppeteer 的launch
方法的时候传入参数对象中带有headless: false
,即可启动其 GUI 界面,进行可视化调试。
constbrowser=awaitpuppeteer.launch({headless:false});
跳转到登录页
awaitpage.goto('https://github.com/login');
在浏览器中打开https://github.com/login。右击Username or email address 下方的输入框(译者注:并选择Inspect)。在开发者工具中,右击被高亮的代码选择Copy
>Copy selector
。
把复制出来的值放到以下常量中
constUSERNAME_SELECTOR='#login_field';// "#login_field" 就是被复制出来的值
重复上面的步骤,吧Password 输入框和Sign in 按钮的值也填好,将得到下面内容:
// dom element selectorsconstUSERNAME_SELECTOR='#login_field';constPASSWORD_SELECTOR='#password';constBUTTON_SELECTOR='#login > form > div.auth-form-body.mt-3 > input.btn.btn-primary.btn-block';
Puppeteer 提供了click
方法用来点击 DOM 元素和type
方法来输入内容。下面我们将填写验证信息并点击登录然后坐等跳转。
首先,需要引入creds.js
文件。
constCREDS=require('./creds');
然后
// puppeteer@0.11 以前是需要点击再输入// await page.click(USERNAME_SELECTOR);// await page.type(CREDS.username);// await page.click(PASSWORD_SELECTOR);// await page.type(CREDS.password);// puppeteer@0.12 以后 page.type 方法需要对某个 selector 进行输入awaitpage.type(USERNAME_SELECTOR,CREDS.username);awaitpage.type(PASSWORD_SELECTOR,CREDS.password);awaitpage.click(BUTTON_SELECTOR);awaitpage.waitForNavigation();
现在,我们已经登录啦!我们可以点击搜索框,填写并在结果页面点击用户标签。但有一个简单的方法:搜索请求通常是 GET 请求,所有内容都是通过 URL 发送的。所以,在搜索框内手动输入john
,然后点击用户标签并复制地址栏上的网址。这将是
constsearchUrl='https://github.com/search?q=john&type=Users&utf8=%E2%9C%93';
做一丢丢调整
constuserToSearch='john';constsearchUrl='https://github.com/search?q='+userToSearch+'&type=Users&utf8=%E2%9C%93';
让我们跳转到这个页面,看是不是真的搜索到了?
awaitpage.goto(searchUrl);awaitpage.waitFor(2*1000);
译者注:本小节没有直译,因为译者没有使用作者的方案
我们的目的是搜刮用户的username
和email
。我们可以在 Chrome 的开发者工具中看到,每个单独的用户信息都是在一个 class 为user-list-item
的<div>
内。
一种提取元素内容的方法是Page
orElementHandle
的evaluate
方法,因为它作用于浏览器运行的上下文环境内。当我们跳转到搜索结果页的时候,使用page.evaluate
方法可以将所有用户信息的 div 获取出来。
constUSER_LIST_INFO_SELECTOR='.user-list-item';constusers=awaitpage.evaluate((sel)=>{const$els=document.querySelectorAll(sel);// ...},USER_LIST_INFO_SELECTOR);
遍历上面的$els
,继续使用选择器提取出其中的信息。当然,这里的选择器相当于用户信息的 div 的,不是像之前那样直接复制出来的,稍微有一点 css 知识应该能很容易读懂。
constUSER_LIST_INFO_SELECTOR='.user-list-item';constUSER_LIST_USERNAME_SELECTOR='.user-list-info>a:nth-child(1)';constUSER_LIST_EMAIL_SELECTOR='.user-list-info>.user-list-meta .muted-link';constusers=awaitpage.evaluate((sInfo,sName,sEmail)=>{returnArray.prototype.slice.apply(document.querySelectorAll(sInfo)).map($userListItem=>{// 用户名constusername=$userListItem.querySelector(sName).innerText;// 邮箱const$email=$userListItem.querySelector(sEmail);constemail=$email ?$email.innerText :undefined;return{ username, email,};})// 不是所有用户都显示邮箱.filter(u=>!!u.email);},USER_LIST_INFO_SELECTOR,USER_LIST_USERNAME_SELECTOR,USER_LIST_EMAIL_SELECTOR);console.log(users);
现在,当你运行node index.js
,你讲看到 Chrome 跳出来自动执行上述操作后,在命令行打出username
与其相关的email
。
首先我们需要评估搜索结果最后一页的页码。在搜索结果页的顶部,你可以看到当我在翻译这篇文章时有70,134 users。
有趣的现象: 如果你对比上面的截图,你会发现这两天已经有 371 个新的john 同学加入 GitHub。
从开发者工具复制人数的选择器。我们将在run
外面写一个新的函数,用来获取页面数。
asyncfunctiongetNumPages(page){constNUM_USER_SELECTOR='#js-pjax-container .codesearch-results h3';letinner=awaitpage.evaluate((sel)=>{returndocument.querySelector(sel).innerHTML;},NUM_USER_SELECTOR);// 格式是: "69,803 users"inner=inner.replace(',','').replace(' users','');constnumUsers=parseInt(inner);console.log('numUsers: ',numUsers);/* * GitHub 每页显示 10 个结果 */constnumPages=Math.ceil(numUsers/10);returnnumPages;}
在搜索结果页底部,如果你把鼠标悬浮在页码按钮上面,可以看到是一个指向下一页的链接。如:第二页的链接是https://github.com/search?p=2&q=john&type=Users&utf8=%E2%9C%93
。注意到p=2
是一个 URL 的 query 参数,这将帮助我们跳转到指定的页面。
在添加了遍历页码的代码在上面爬取内容的方法之后,代码变成了这样:
constnumPages=awaitgetNumPages(page);console.log('Numpages: ',numPages);for(leth=1;h<=numPages;h++){// 跳转到指定页码awaitpage.goto(`${searchUrl}&p=${h}`);// 执行爬取constusers=awaitpage.evaluate((sInfo,sName,sEmail)=>{returnArray.prototype.slice.apply(document.querySelectorAll(sInfo)).map($userListItem=>{// 用户名constusername=$userListItem.querySelector(sName).innerText;// 邮箱const$email=$userListItem.querySelector(sEmail);constemail=$email ?$email.innerText :undefined;return{ username, email,};})// 不是所有用户都显示邮箱.filter(u=>!!u.email);},USER_LIST_INFO_SELECTOR,USER_LIST_USERNAME_SELECTOR,USER_LIST_EMAIL_SELECTOR);users.map(({username, email})=>{// TODO: 保存用户信息console.log(username,'->',email);});}
到这里Puppeteer
的部分已经结束了。下面我们将使用mongoose
存储上面的信息到MongoDB
。它是个ORM,确切说是个便于从数据库进行信息存储和检索的库。
$ npm i --save mongoose
MongoDB 是一个 Schema-less 的 NoSQL 数据库,但我们可以使用 Mongoose 使其遵循一些原则。首先我们需要创建一个Model
,他代表 MongoDB 中的Collection
。创建一个models
文件夹,然后在里面创建一个user.js
文件,并加入以下 collection 的构造函数代码。之后无论我们塞什么东西进User
,他都会遵循这个结构。
constmongoose=require('mongoose');constuserSchema=newmongoose.Schema({username:String,email:String,dateCrawled:Date});module.exports=mongoose.model('User',userSchema);
现在我们可以开始往数据库塞数据了。由于我们不希望数据库中存在重复的 email,所以我们只新增那些以前从没出现过的邮箱,否则我们只更新数据。为此,我们需要使用 mongoose 的Model.findOneAndUpdate
方法。
回到index.js
,引用所需的依赖:
constmongoose=require('mongoose');constUser=require('./models/user');
然后我们再创建一个新的方法,用于upsert (更新 update 或 新增 insert) 用户实例。
functionupsertUser(userObj){constDB_URL='mongodb://localhost/thal';if(mongoose.connection.readyState==0){mongoose.connect(DB_URL);}// if this email exists, update the entry, don't insert// 如果邮箱存在,就更新实例,不新增constconditions={email:userObj.email};constoptions={upsert:true,new:true,setDefaultsOnInsert:true};User.findOneAndUpdate(conditions,userObj,options,(err,result)=>{if(err){throwerr;}});}
启动 MongoDB 服务。用下面的代码替换掉之前的注释内容// TODO: 保存用户信息
。
upsertUser({username:username,email:email,dateCrawled:newDate()});
想要检查是否真的保存了这些用户,可以到 mongo 里面执行下列脚本:
$ mongo> use thal> db.users.find().pretty()
你会看到有多个用户在已经添加在里面,那你就成功了哦~
译者注:使用
db.users.find().pretty().length()
可以查看爬取了多少条
Chrome Headless 和 Puppeteer 开启了网页爬虫和自动化测试的新纪元,而且 Chrome Headless 还支持 WebGL!你可以把你的爬虫脚本发布到云端,然后就可以坐享其成。当然,发布到服务器之前请记得去掉headless: false
配置。
- 在爬取的时候,你可能会被 GitHub 的频率控制阻止。
- 另外,我发现你无法跳到 100 页之后。
译者注:我爬了 100 页并没有被阻止。从 101 页开始就变成了 404 页面,或许通过页面下方的页码进行遍历会更合理
广阔无垠的沙漠见证着穿越
这些巨大的沙滩的人们的斗争和牺牲。Thal 是巴基斯坦的一个跨越多个地区的沙漠,包括我的家乡 Bhakkar。与今天在互联网
上搜索数据的情况类似。这就是为什么我将这个 repo 命名为Thal
。如果你喜欢它,那请与他人分享。如果您有任何建议,请在这里发表评论或直接与原作者联络@e_mad_ehsan。他很乐意听到你的消息。
译者注:中文版也欢迎直接提issue 讨论 或 PR