爬虫

网页更新与重抓策略

我们知道网页总是会更新的。在大规模的网络爬取中,一个很重要的问题是重抓策略,也就是在什么时候去重新访问同一个网页已获得更新。要获得这个问题的解,需要满足如下两个条件:

1. 尽可能地少访问,以减少自身和对方站点的资源占用
2. 尽可能快的更新,以便获得最新结果

这两个条件几乎是对立的,所以我们必须找到一种算法,并获得一个尽可能优的折衷。

可以使用泊松过程:https://stackoverflow.com/questions/10331738/strategy-for-how-to-crawl-index-frequently-updated-webpages

为什么不使用 scrapy?

最近面了几家公司,每当我提到头条的爬虫都是自己写的时候,对方一个下意识的问题就是
“为什么不使用开源的 scrapy?”。实际上我在头条的 lead 就是 scrapy 的 contributor,
而他自己也不用自己的框架,显然说明 scrapy 不适合大型项目,那么具体问题在哪儿呢?
今天终于有时间了,详细写写这个问题。

# 爬虫并不需要一个框架

Web 服务器是一个爬虫可以抽象出来的是各种组件。而 scrapy 太简陋了,比如说去重,
直接用的是内存中的一个集合。如果要依赖 scrapy 写一个大型的爬虫,几乎每个组件都要
自己实现,那有何必用 scrapy 呢?

# scrapy 不是完整的爬虫框架

一个完整的爬虫至少需要两部分,fetcher 和 frontier。其中 fetcher 用于下载网页,而
frontier 用于调度。scrapy 重点实现的是 fetcher 部分,也就是下载部分。

# scrapy 依赖 twisted

这一点导致 scrapy 深入后曲线非常地陡峭,要想了解一些内部的机理,必须对 twisted
比较明了。而 twisted 正如它的名字一样,是非常扭曲的一些概念,虽然性能非常好,
但是要理解起来是要花上不少时间的。

# scrapy 适合的领域

scrapy 主要是和一次性地从指定的站点爬取一些数据

最重要的并不是你使用不使用 Scrapy,而是你不能为每一站点去单独写一个爬虫的脚本。代码的灵
活度实在太大了,对于没有足够经验的工程师来说,写出来的脚本可能很难维护。重点是要把主循环
掌握在爬虫平台的手中,而不是让每一个脚本都各行其是。

# 参考

1. [scrapy 源码解读](http://kaito-kidd.com/2016/11/01/scrapy-code-analyze-architecture/)

知乎移动端接口分析

最近想注册一些知乎的机器人玩玩儿,比如给自己点赞之类的,通过抓包分析,获得了完整注册登录流程。

# 抓包

“`
1 POST https://api.zhihu.com/auth/digits
← 401 application/json 97b 246ms
2 GET https://api.zhihu.com/captcha
← 200 application/json 22b 233ms
3 PUT https://api.zhihu.com/captcha
← 202 application/json 5.46k 323ms
4 POST https://api.zhihu.com/captcha
← 201 application/json 16b 295ms
5 POST https://api.zhihu.com/sms/digits
← 201 application/json 16b 353ms
6 POST https://api.zhihu.com/validate/digits
← 201 application/json 16b 409ms
7 POST https://api.zhihu.com/validate/register_form
← 200 application/json 16b 279ms
8 POST https://api.zhihu.com/register
← 201 application/json 761b 529ms
“`

逐行分析一下每个包:

1. 这个请求发送了 `username: +86155xxxxxxxx` 请求,然后返回了 `缺少验证码票据`,应该是表示缺少验证码。
2. 应该不是请求验证码,而是请求是否需要验证码,返回了`”show_captcha”: false`,虽然表示的是不需要验证码,但是还是弹出了验证码,奇怪。
3. 注意这个请求是 PUT,POST 参数`height: 60, width: 240`。然后返回了验证码:`{“img_base64”: …}`, base64 解码后就是验证码
4. 这一步 POST 正确的 captcha 并通过验证,参数是:`input_text: nxa8`, 返回是:`{ “success”: true }`
5. 这一步请求发送短信验证码,POST 参数是:`phone_no: +86155xxxxxxxx`, 发挥是:`{ “success”: true }`
6. 提交验证码,POST 参数是: `phone_no: +86155xxxxxxxx, digits: xxxxxx`, 返回是:`{ “success”: true }`
7. 填写用户信息,POST 参数是:`phone_no: +86155xxxxxxxx, gender: 0, fullname: XXX`,返回是:`{ “success”: true }`
8. 上一步注册了用户,这一步是向知乎请求新的 access token。

请求 POST 参数:
“`
digits: 865405
fullname: Lucindai
phone_no: +8615568995304
register_type: phone_digits
“`

返回数据如下:
“`
{
“access_token”: “…”,
“cookie”: { },
“expires_in”: 2592000,
“lock_in”: 1800,
“old_id”: 155681178,
“refresh_token”: “…”,
“token_type”: “bearer”,
“uid”: “…”,
“unlock_ticket”: “…”,
“user_id”:…
}
“`

其中的 refresh token 和 access token 都是 OAuth2 中的参数,可以用于使用 OAuth2 访问知乎的 API。可以使用 zhihu_oauth 这个库来访问知乎。

知乎的 API 还需要在 header 中设定一些特殊参数,可以参考 zhihu_oauth 中的参数

再注册成功之后还应该设定密码,这样之后就可以使用密码登录了。

“`
PUT https://api.zhihu.com/account/password
new_password=xxxxxx
“`

如何破解被 JS 加密的数据

由于网页和JavaScript都是明文的,导致很多API接口都直接暴露在爬虫的眼里,所以好多
网站选择使用混淆后的 JavaScript 来加密接口。其中有分为两大类:

1. 通过 JavaScript 计算一个参数或者 Cookie 作为接口的签名验证
2. 返回的数据是加密的,需要使用 JavaScript 解密

不过总的来说,这两种加密的破解思路都是一样的。

1. 找到相关的网络请求。如果找不到,清空缓存,尝试触发
2. 打断点找到相关代码,可以是 ajax 断点或者 js 断点。或者直接看网络请求的
initiator
3. 逐层分析,找到加密函数
4. 使用 node 执行js代码获得数据

# 具体步骤

有空了再写。。

参考:

1. [中国天气质量网返回结果加密的破解](https://cuiqingcai.com/5024.html)
2. [破解 Google 翻译的token](https://zhuanlan.zhihu.com/p/32139007)
3. [JavaScript 生成 Cookie](https://github.com/jhao104/spider/tree/master/PyV8%E7%A0%B4%E8%A7%A3JS%E5%8A%A0%E5%AF%86Cookie)
4. [常见加密算法](http://liehu.tass.com.cn/archives/1016)

爬虫利器 Chrome Headless 和 Puppeteer 最佳实践

> 翻译自:https://docs.browserless.io/blog/2018/06/04/puppeteer-best-practices.html

browserless 已经运行了200万次的 chrome headless 请求,下面是他们总结出来的最佳实践:

# 一、不要使用无头浏览器

![](https://ws2.sinaimg.cn/large/006tNc79gy1fs056p3uvaj319w0fcacy.jpg)

无头 Chrome 占用的大量资源

无论如何,只要可以的话,不要运行无头浏览器。特别是千万别在你跑其他应用的服务器上跑。无头浏览器的行为难以预测,对资源占用非常多,就像是 Rick and Morty 里面的 Meseeks(美国动画片《瑞克和莫蒂》中,召唤出了过多的 Meseeks 导致出了大问题)。几乎所有你想通过浏览器用的事情(比如说运行 JavaScript)都可以使用简单的 Linux 工具来实现。`Cheerio` 和其他的库提供了优雅的 Node API 来实现 HTTP 请求和采集等需求。

比如,你可以像这样获取一个页面并抽取内容:

“`
import cheerio from ‘cheerio’;
import fetch from ‘node-fetch’;

async function getPrice(url) {
const res = await fetch(url);
const html = await res.test();
const $ = cheerio.load(html);
return $(‘buy-now.price’).text();
}

getPrice(‘https://my-cool-website.com/’);
“`

显然这肯定不能覆盖所有的方面,如果你正在读这篇文章的话,你可能需要一个无头浏览器,所以接着看吧。

# 二、不要在不需要的时候运行无头浏览器

我们遇到过好多客户尝试在不使用的时候也保持浏览器开着,这样他们就总能够直接连上浏览器。尽管这样能够有效地加快连接速度,但是最终会在几个小时内变糟。很大程度上是因为浏览器总会尝试缓存并且慢慢地吃掉内存。只要你不是在活跃地使用浏览器,就关掉它。

“`
import puppeteer from ‘puppeteer’;

async function run() {
const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.goto(‘https://www.example.com/’);

// More stuff …page.click() page.type()

browser.close(); // <- Always do this! } ``` 在 browserless,我们会给每个会话设置一个定时器,而且在WebSocket链接关闭的时候关闭浏览器。但是如果你使用自己独立的浏览器的话,记得一定要关闭浏览器,否则你很可能在半夜还要陷入恶心的调试中。 # 三、 `page.evaluate` 是你的好朋友 Puppeteer 有一些很酷的语法糖,比如可以保存 DOM 选择器等等东西到 Node 运行时中。尽管这很方便,但是当有脚本在变换 DOM 节点的时候很可能坑你一把。尽管看起来有一些 hacky,但是最好还是在浏览器中运行浏览器这边的工作。也就是说使用 `page.evaluate` 来操作。 比如,不要使用下面这种方法(使用了三个 async 动作): ``` const $anchor = await page.$('a.buy-now'); const link = await $anchor.getProperty('href'); await $anchor.click(); return link; ``` 这样做,使用了一个 async 动作: ``` await page.evaluate(() => {
const $anchor = document.querySelector(‘a.buy-now’);
const text = $anchor.href;
$anchor.click();
});
“`

另外的好处是这样做是可移植的:也就是说你可以在浏览器中运行这个代码来测试下是不是需要重写你的 node 代码。当然,能用调试器调试的时候还是用调试器来缩短开发时间。

最重要的规则就是数一下你使用的 await 的数量,如果超过 1 了,那么说明你最好把代码写在 page.evaluate 中。原因在于,所有的 async 函数都必须在 Node 和 浏览器直接传来传去,也就是需要不停地 json 序列化和反序列化。尽管这些解析成本也不是很高(有 WebSocket 支持),但是总还是要花费时间的。

# 四、并行化浏览器,而不是页面

上面我们已经说过尽量不要使用浏览器,而且只在需要的时候才打开浏览器,下面的这条最佳实践是——在一个浏览器中只使用一个会话。尽管通过页面来并行化可能会给你省下一些时间,如果一个页面崩溃了,可能会把整个浏览器都带翻车。而且,每个页面都不能保证是完全干净的(cookies 和存储可能会互相渗透)。

不要这样:

“`
import puppeteer from ‘puppeteer’;

// Launch one browser and capture the promise
const launch = puppeteer.launch();

const runJob = async (url) {
// Re-use the browser here
const browser = await launch;
const page = await browser.newPage();
await page.goto(url);
const title = await page.title();

browser.close();

return title;
};
“`

要这样:

“`
import puppeteer from ‘puppeteer’;

const runJob = async (url) {
// Launch a clean browser for every “job”
const browser = puppeteer.launch();
const page = await browser.newPage();
await page.goto(url);
const title = await page.title();

browser.close();

return title;
};
“`

每一个新的浏览器实例都会得到一个干净的 `–user-data-dir` (除非你手工设定)。也就是说会是一个完全新的会话。如果 Chrome 崩溃了,也不会把其他的会话一起干掉。

# 五、队列和限制并发

browserless 的一个核心功能是无缝限制并行和使用队列。也就是说消费程序可以直接使用 puppeteer.connect 而不需要自己实现一个队列。这避免了大量的问题,大部分是太多的 Chrome 实例杀掉了你的应用的可用资源。

最好也最简单的方法是使用 browserless 提供的镜像:

“`
# Pull in Puppeteer@1.4.0 support
$ docker pull browserless/chrome:release-puppeteer-1.4.0
$ docker run -e “MAX_CONCURRENT_SESSIONS=10” browserless/chrome:release-puppeteer-1.4.0
“`

上面限制了并发连接数到10,还可以使用`MAX_QUEUE_LENGTH`来配置队列的长度。总体来说,**每1GB内存可以并行运行10个请求**。CPU 有时候会占用过多,但是总的来说瓶颈还是在内存上。

# 六、不要忘记 `page.waitForNavigation`

如果点击了链接之后,需要使用 page.waitForNavigation 来等待页面加载。

下面这个不行
“`
await page.goto(‘https://example.com’);
await page.click(‘a’);
const title = await page.title();
console.log(title);
“`

这个可以
“`
await page.goto(‘https://example.com’);
page.click(‘a’);
await page.waitForNavigation();
const title = await page.title();
console.log(title);
“`

# 七、使用 docker 来管理 Chrome

Chrome 除了浏览之外,还会有好多的莫名其妙的线程,所以最好使用 docker 来管理

Python爬虫利器——lxml 和 xpath 表达式

最近要做下微信爬虫,之前写个小东西都是直接用正则提取数据就算了,如果需要更稳定的提取数据,还是使用 xpath 定位元素比较可靠。周末没事,从爬虫的角度研究了一下 python xml/html 相关的库。

Python 标准库中自带了 xml 模块,但是性能不够好,而且缺乏一些人性化的 API。相比之下,第三方库 lxml 是用 Cython 实现的,而且增加了很多实用的功能,可谓爬虫处理网页数据的一件利器。

严格来说,html 并不是 xml 的一种,不过 lxml 对于 xml 和 html 都有很好的支持,分别使用 `lxml.etree` 和 `lxml.html`两个模块。

# 解析

网页下载下来以后是个 bytes 的形式,需要构造 DOM 树:

“`

In [1]: html = ”’
…:

helloworld

…: ”’

In [2]: import lxml.html

In [3]: doc = lxml.html.fromstring(html)

In [4]: doc
Out[4]:

“`

# Element 结构

生成的树是一个设计很精妙的结构,可以把它当做一个对象访问当前节点自身的文本节点,可以把他当做一个数组,元素就是他的子节点,可以把它当做一个字典,从而遍历它的属性,下面演示了 lxml 的常见用法:

“`
In [5]: doc.text
Out[5]: ‘hello’

In [6]: doc.tag
Out[6]: ‘p’

In [7]: doc[0].tag
Out[7]: ‘span’

In [11]: for k, v in doc[0].items():
…: print(k, v)
…:
id world

In [12]: doc[0].get(‘id’)
Out[12]: ‘world’

In [13]: doc[0].attrib
Out[13]: {‘id’: ‘world’}
“`

# 遍历树的方法

doc 是一个树形结构,可以通过一些方法访问树中的其他节点:

“`
In [14]: doc.getroottree() # 返回树
Out[14]:

In [19]: doc.getroottree().getroot() # 返回根节点,这里是 lxml 自动生成的 html 节点
Out[19]:

In [20]: doc.getparent() # lxml 自动生成的 body 节点
Out[20]:

In [21]: doc.getprevious()

In [22]: doc.getnext()

In [23]: doc.text_content()
Out[23]: ‘helloworld’

In [25]: lxml.html.tostring(doc, pretty_print=True, encoding=’utf-8′)
Out[25]: b’

helloworld

\n’

“`
注意因为我们给的是一个 html 的片段(`

`),所以 lxml 自动生成了 html 和 body 等节点已构成完整的 html 文档。

如果需要显式地指定生成一个 html 片段文档还是完整文档,可以分别使用:lxml.html.fragment_fromstring 和 lxml.html.document_fromstring 两个方法。

lxml 还有其他一些方法,都列在下面了:

Element.tail

* Element.append(Element) 添加一个子元素
* Element.set(‘attr’, value) 设置属性
* Element.iter(tag_name) 遍历所有后系元素,可以使用 `*`
* ElementTree.getelementpath(Element)
* Element.getroottree() 返回对应的树
* ElementTree.getpath(Element) 返回一个元素的 xpath
* ElementTree.getroot() 返回根节点
* HtmlElement.drop_tree() 删除当前节点下的所有节点,但是保留text
* HtmlElement.drop_tag() 删除当前节点,但是保留它的子节点和text
* HtmlElement.classes 返回类
* HtmlElement.find_class(class_name) 按照 class 查找 tag
* HtmlElement.get_element_by_id(id, *default) 按照 id 查找元素
* HtmlElement.iterlinks() 遍历所有连接
* HtmlElement.make_links_absolute(base_url=None, resolve_base_href=True) 把所有连接变成绝对链接
* HtmlElement.resolve_base_href() 解析 base 标签
* HtmlElement.rewrite_links(link_repl_func) 替换所有的链接

# XPath

XPath 实在太强大了,在定位元素方面绝对是秒杀 CSS 选择器。在 lxml 中,节点和树分别具有xpath 函数。

lxml 中的 xpath 方法,对于 xpath 表达式应该返回元素,总是返回一个数组,即使只有一个元素

“`
In [24]: doc.xpath(‘//span/text()’)
Out[24]: [‘world’]
“`

lxml 中的 xpath 函数支持变量

“`
print(root.xpath(“$text”, text = “Hello World!”))
Hello World!
“`

xpath may return _ElementStringResult, which is not picklable, use xpath(smart_strings=False) to avoid this http://lxml.de/xpathxslt.html#xpath-return-values

lxml 还支持几个函数 `find/findall`,他们使用 ElementPath,是一种类似 xpath 的语言,感觉很是奇怪,lxml 的文档描述他是 xpath 的一个子集,暂时不看了。

# 常见问题

lxml 在遇到小于号的时候会出问题(按照标准,应该编码为 `<`),直接把后面的文档都丢了,但是浏览器兼容性比较好,不会有问题。

by default, the lxml parser is not very error-proof, the html5parser lib is behaves more like your web browser.

lxml.html.html5parser provides same interface with lxml.html

tricks and traps

读《The Anatomy of a large-scale hypertextual Web search engine》

Google 在1997年的论文[1], 到现在(2017)的话, 已经有二十年的历史了, 然而对于编写一个小的搜索引擎, 依然有好多具有指导意义的地方.

The Anatomy of a large-scale hypertextual Web search engine 这篇论文应该是一片总结性质的论文, 而且论文并没有多少的关于数据结构等的实现细节. 只是大体描绘了一下架构.

# Google的算法

首先, Google大量使用了在超文本也就是网页中存在的结构, 也就是锚文本和链接. 还有就是如何有效的处理在网页上, 所有人都可以任意发布任何文字的问题, Google在这片文章里给的解决方案是PageRank.

在20年前, 主要问题是, 网页已经开始快速增长, 然而当时的所有搜索引擎给出的结果只是搜索结果的数量也增长了, 却没能把最相关的结果放在首页. 因为人们并不会因为给出结果多而去多看几页, 所以这样的结果是不可取的. 在设计Google的过程中, Google还考虑了随着web规模的增长, 会对现有的体系造成的影响以及如何应对.

Google 还表达了对当时的搜索引擎都是商业化的, 因而一些诸如用户查询之类的结果无法共学术应用的情况表达了不满. (呵呵, Google这不是打自己的脸么)

对于 PageRank 算法, 提到了简单的公式:

![](https://ws1.sinaimg.cn/large/006tKfTcly1fqazehy4zdj30im02mmxd.jpg)

其中Tx表示的是指向A页面的所有页面, C表示的是一个页面上所有的外链. 对于这个公式的解释是这样的. 假设有一个随机的浏览者, 他不断的点击网页中的链接, 从不点后退, 直到他感到烦了, 然后在随机的拿一个网页开始点击. 其中d就表示了这个人会感到烦了的概率. 这样造成的结果就是如果一个网页有很多的的外链指向他的话, 他就有很大的机会获得比较高的PR, 或者如果一个很权威的站点指向的他的话, 也有很大机会获得比较高的PR.

对于锚文本, 大多数网站都是把他和所在的页联系起来, Google还把锚文本以及PR值和它指向的页面联系起来.

# Google的架构

其实这部分才是我最感兴趣的地方. 之所以今天会抽出时间来阅读这篇论文, 主要就是想写个小爬虫, 然后发现写来写去, 太不优雅了, 才想起翻出Google的论文读一读.

## Google整体架构

![](https://ws2.sinaimg.cn/large/006tKfTcly1fqazes7038j30gn0iitbl.jpg)

Google的架构非常的模块化, 基本上可以看到整个图, 就知道每个模块是负责做什么的. 大概分成了几个部分: 爬虫(下载器), indexer, barrel, sorter, 和(searcher)前端服务.

其中

1. 爬虫负责下载网页, 其中每一个url都会有一个唯一的docID.
2. indexer负责解析网页中的单词, 生成hit记录, 并产生前向索引. 然后抽出所有的链接.
3. URLResolver会把indexer生成的锚文本读取并放到锚文本和链接放到索引中, 然后生成一个docID -> docID 的映射数据库. 这个数据库用来计算PageRank.
4. sorter根据indexer生成的正向索引, 根据wordID建立反向索引. 为了节省内存, 这块是inplace做的. 并且产生了wordID的列表和偏移
5. searcher负责接收用户的请求, 然后使用DumpLexicon产生的lexicon和倒排和PageRank一起做出响应.

## 用到的数据结构

由于一个磁盘寻道就会花费 10ms 的时间(1997), 所以Google几乎所有的数据结构都是存在大文件中的. 他们实现了基于固定宽度ISAM, 按照docID排序的document索引, 索引中包含了当前文件状态, 指向repository的指针, 文件的校验和, 不同的统计信息等. 变长信息, 比如标题和url存在另一个文件中. (YN: SSD对这个问题有什么影响呢)

hitlist指的是某个单词在谋篇文档中出现的位置, 字体, 大小写等信息. Google手写了一个htilist的编码模式, 对于每个hit花费2byte

barrel中存放按照docID排序存放document

## 模块

### 爬虫

爬虫又分为了两个部分, URLServer 负责分发URL给Crawler. Crawler 是分布式的, 有多个实例, 负责下载网页. 每获得一个URL的时候, 都会生成一个docID. Google使用了一个URLServer 和 3个Crawler. 每一个Crawler大概会维持300个连接, 可以达到每秒钟爬取100个网页. 并且使用了异步IO来管理事件.

#### DNS

Google指出爬虫的一个瓶颈在于每个请求都需要去请求DNS. 所以他们在每一个Crawler上都设置了DNS 缓存.

YN: 对于HTTP 1.1来说, 默认连接都是keep-alive的, 对于URLServer分发连接应该应该同一个域名尽量分发到同一个crawler上, 这样可以尽量避免建立连接的开销.

indexer会把下载到的网页分解成hit记录,每一个hit记录了单词, 在文档中的位置, 和大概的字体大小和是否是大写等因素. indexer还会把所有的链接都抽取出来, 并存到一个anchor文件中. 这个文件保存了链接的指向和锚文本等元素.

## rank

Google并没有手工为每一个因素指定多少权重, 而是设计了一套反馈系统来帮助我们调节参数.

# 结果评估

Google认为他们的搜索能够产生最好的结果的原因是因为使用了PageRank. Google在9天内下载了2600万的网页, indexer的处理能力在 54qps, 其中

# 拓展

query cacheing, smart disk allocation, subindices

链接合适应该重新抓取, 何时应该抓取新连接

使用了NFS, 性能有问题

## YN:

如何判定为一个hub也 -> 识别列表
hub页的链接产出率 -> 根据一个列表页是否产生新连接来动态的调整hub页的抓取频率

[1] http://infolab.stanford.edu/~backrub/google.html

爬虫 IP 封禁与反封禁

反爬虫的核心在于区分开正常用户访问和恶意爬虫用户。来源 IP 是访问很重要的一个特征,我们可以从来源 IP 的角度来做出不少反爬虫策略。

* 是否是代理IP
* 是否是民用IP
* IP 地理信息

一般来说,大规模的爬虫我们都会放到服务器上去跑,搭建代理集群也会在服务器上,而正常用户的IP地址则来自家用IP范围内。这就给反爬虫的一方提供了便利,对于来自数据中心的请求可以直接限制访问甚至直接屏蔽掉,而对于家用的IP地址则宽容一些。

下面我们来看几个实例

# 直接爬取网站

一般正常用户的页面访问量很小,如果发现某个 IP 的访问量特别大,那么肯定是爬虫,直接封禁即可,或者每次都需要输入验证码访问。

IP 被封禁后一般不会被解封,或者需要很长时间,这时候只有两种思路,要么降低频率,更改自己的行为特征,避免被封,要么更换 IP。一般来说,不管怎样更改自己的行为,访问量还是很难降下来的,这时候只能换一个 IP 继续爬。

# 使用代理网站提供的代理IP

一些黑客会使用端口扫描器扫描互联网上的开放代理,然后免费或者付费提供给其他用户使用,比如下面这些网站:

![免费代理](https://ws2.sinaimg.cn/large/006tNbRwly1fu6vtfrvgvj30zy0pgn3y.jpg)

但是这些网站的代理中能直接使用的可能不到10%,而且失效时间很短。所以要使用这些代理 IP,需要首先爬取这些网站,然后随取随用。

# 利用 ADSL 服务器更换 IP

网上有一些小的厂商代理了各地运营商的服务,搭建了一些小的服务器,一般内存只有 512M,而硬盘只有 8G,但是好处是通过 ADSL 上网,因此可以随时更换 IP。比如笔者搭建的这个动态代理:

![ADSL](https://ws4.sinaimg.cn/large/006tNbRwly1fu6vpix9s0j30wq0aa77o.jpg)

每三十分钟更换一次 IP,而这些服务器也很便宜,在 100-200 每月,所以大可以搭建一个集群,这样基本上一个 IP 被封之前也基本被换掉了。

要封禁这种用户也很简单,可以看出虽然 IP 在更换,但是基本上还是在一个 B 段之内,一个 B 段也就6w个用户,直接封了就行了

# 利用数据中心提供的更换 IP 接口来

有些爬虫会利用阿里云或者AWS的弹性 IP 来爬数据,反爬虫的第一步可以把阿里云的 IP 都屏蔽掉,正常用户一般是不会用这些 IP 来访问的。

# 附录

阿里云的出口 IP 列表:

“`
deny 42.96.128.0/17;
deny 42.120.0.0/16;
deny 42.121.0.0/16;
deny 42.156.128.0/17;
deny 110.75.0.0/16;
deny 110.76.0.0/19;
deny 110.76.32.0/20;
deny 110.76.48.0/20;
deny 110.173.192.0/20;
deny 110.173.208.0/20;
deny 112.74.0.0/16;
deny 112.124.0.0/16;
deny 112.127.0.0/16;
deny 114.215.0.0/16;
deny 115.28.0.0/16;
deny 115.29.0.0/16;
deny 115.124.16.0/22;
deny 115.124.20.0/22;
deny 115.124.24.0/21;
deny 119.38.208.0/21;
deny 119.38.216.0/21;
deny 119.42.224.0/20;
deny 119.42.242.0/23;
deny 119.42.244.0/22;
deny 120.24.0.0/14;
deny 120.24.0.0/16;
deny 120.25.0.0/18;
deny 120.25.64.0/19;
deny 120.25.96.0/21;
deny 120.25.108.0/24;
deny 120.25.110.0/24;
deny 120.25.111.0/24;
deny 121.0.16.0/21;
deny 121.0.24.0/22;
deny 121.0.28.0/22;
deny 121.40.0.0/14;
deny 121.42.0.0/18;
deny 121.42.0.0/24;
deny 121.42.64.0/18;
deny 121.42.128.0/18;
deny 121.42.192.0/19;
deny 121.42.224.0/19;
deny 121.196.0.0/16;
deny 121.197.0.0/16;
deny 121.198.0.0/16;
deny 121.199.0.0/16;
deny 140.205.0.0/16;
deny 203.209.250.0/23;
deny 218.244.128.0/19;
deny 223.4.0.0/16;
deny 223.5.0.0/16;
deny 223.5.5.0/24;
deny 223.6.0.0/16;
deny 223.6.6.0/24;
deny 223.7.0.0/16;
101.200.0.0/15 
101.37.0.0/16 
101.37.0.0/17 
101.37.0.0/24 
101.37.128.0/17 
103.52.196.0/22 
103.52.196.0/23 
103.52.196.0/24 
103.52.198.0/23 
106.11.0.0/16 
106.11.0.0/17 
106.11.0.0/18 
106.11.1.0/24 
106.11.128.0/17 
106.11.32.0/22 
106.11.36.0/22 
106.11.48.0/21 
106.11.56.0/21 
106.11.64.0/19 
110.173.192.0/20 
110.173.196.0/24 
110.173.208.0/20 
110.75.0.0/16 
110.75.236.0/22 
110.75.239.0/24 
110.75.240.0/20 
110.75.242.0/24 
110.75.243.0/24 
110.75.244.0/22 
110.76.0.0/19 
110.76.21.0/24 
110.76.32.0/20 
110.76.48.0/20 
112.124.0.0/16 
112.125.0.0/16 
112.126.0.0/16 
112.127.0.0/16 
112.74.0.0/16 
112.74.0.0/17 
112.74.116.0/22 
112.74.120.0/22 
112.74.128.0/17 
112.74.32.0/19 
112.74.64.0/22 
112.74.68.0/22 
114.215.0.0/16 
114.55.0.0/16 
114.55.0.0/17 
114.55.128.0/17 
115.124.16.0/22 
115.124.20.0/22 
115.124.24.0/21 
115.28.0.0/16 
115.29.0.0/16 
118.190.0.0/16 
118.190.0.0/17 
118.190.0.0/24 
118.190.128.0/17 
118.31.0.0/16 
118.31.0.0/17 
118.31.0.0/24 
118.31.128.0/17 
119.38.208.0/21 
119.38.216.0/21 
119.38.219.0/24 
119.42.224.0/20 
119.42.242.0/23 
119.42.244.0/22 
119.42.248.0/21 
120.24.0.0/14 
120.24.0.0/15 
120.25.0.0/18 
120.25.104.0/22 
120.25.108.0/24 
120.25.110.0/24 
120.25.111.0/24 
120.25.112.0/23 
120.25.115.0/24 
120.25.136.0/22 
120.25.64.0/19 
120.25.96.0/21 
120.27.0.0/17 
120.27.128.0/17 
120.27.128.0/18 
120.27.192.0/18 
120.55.0.0/16 
120.76.0.0/15 
120.76.0.0/16 
120.77.0.0/16 
120.78.0.0/15 
121.0.16.0/21 
121.0.24.0/22 
121.0.28.0/22 
121.196.0.0/16 
121.197.0.0/16 
121.198.0.0/16 
121.199.0.0/16 
121.40.0.0/14 
121.42.0.0/18 
121.42.0.0/24 
121.42.128.0/18 
121.42.17.0/24 
121.42.192.0/19 
121.42.224.0/19 
121.42.64.0/18 
123.56.0.0/15 
123.56.0.0/16 
123.57.0.0/16 
139.129.0.0/16 
139.129.0.0/17 
139.129.128.0/17 
139.196.0.0/16 
139.196.0.0/17 
139.196.128.0/17 
139.224.0.0/16 
139.224.0.0/17 
139.224.128.0/17 
140.205.0.0/16 
140.205.128.0/18 
140.205.192.0/18 
140.205.32.0/19 
140.205.76.0/24 
182.92.0.0/16 
203.107.0.0/24 
203.107.1.0/24 
203.209.224.0/19 
218.244.128.0/19 
223.4.0.0/16 
223.5.0.0/16 
223.5.5.0/24 
223.6.0.0/16 
223.6.6.0/24 
223.7.0.0/16 
39.100.0.0/14 
39.104.0.0/14 
39.104.0.0/15 
39.104.0.0/24 
39.106.0.0/15 
39.108.0.0/16 
39.108.0.0/17 
39.108.0.0/24 
39.108.128.0/17 
39.96.0.0/13 
39.96.0.0/14 
39.96.0.0/24 
42.120.0.0/16 
42.121.0.0/16 
42.156.128.0/17 
42.96.128.0/17 
45.113.40.0/22 
45.113.40.0/23 
45.113.40.0/24 
45.113.42.0/23 
47.92.0.0/14 
47.92.0.0/15 
47.92.0.0/24 
47.94.0.0/15
“`

Go 语言和爬虫

# 爬虫的算法

## 广度遍历

如果把每一个页面看做一个节点,把每个链接看做一个有向边,那么网页之间就构成了一个有向图。爬虫的核心就是对这个图做一个广度优先的遍历:

“`
func breadthFirst(visit func(item, string) []string, worklist []string) {
seen := make(map[string]bool)
for len(worklist) > 0 {
items := worklist
worklist = nil
for _, items := range items {
if !seen[item] {
seen[item] = true
worklist = append(worklist, visit(item)…)
}
}
}
}
“`

## 终止条件

如果我们面对的是一个有限的图,那么用广度遍历一定可以停下来。但是对于互联网来说,甚至于对于某个网站来说,页面的数量都可能是无限的,或者说没必要爬遍所有页面。那么需要考虑以下几个限制条件:

– 边界限制,比如说限定下爬去的域名
– 深度,比如说限定下爬取的深度
– 并发,比如说开多少个goroutine?以及如何控制并发
– 如何终止,终止条件是什么,限制抓取的深度还是什么?
– 等待所有进程终止,当程序退出的时候,有没有 wait 子过程退出

# 抽取数据

## 使用 CSS 定位元素,而不是 XPath

之前还在用 Python 写爬虫的时候喜欢用 XPath,主要是选择路径比 CSS 表达式看起来更清晰,而且 Python 有一个强大的 lxml 库,对于 xpath 的操作非常便捷。不过也有些缺点,xpath 的坏处就是没有办法按照类选择,而只能按照 class 当做一个属性来选择。而现在的布局之类的好多都是按照类来的,所以可能还是使用 CSS 表达式比较好。举个例子:

“`

Hello World

“`

比如说网站采用了上面的标签来表示标题,其中的`col-sm-6`可能是用于页面布局的一个类,很有可能经常改变,所以我们想要按照 title 这个属性来定位这个元素,如果使用 xpath 的话,需要这样写:

“`
//div[contains(concat(‘ ‘, normalize-space(@class), ‘ ‘), ‘ title ‘)]
“`

而 CSS 天生就是为了布局而生的,所以要选择这个元素,直接这样就可以了:

“`
.title
“`

当然 CSS 也有一些不方便的时候,比如 XPath 使用 `//nav/span[2]` 就能表达清楚的逻辑,CSS 需要使用 `nav>span:nth-child(2)`。略显长,但是还好不像 XPath 表达类(class)的时候那么 trick。

另外,XPath 不光可以选择元素,还可以选择属性,比如 `//a/@href`,可以直接拿到`a` 的链接,而CSS则只能选择标签。

## goquery

在 Go 语言中,可以使用 goquery 来选取元素,他实现了类似于 jQuery 的语法。

goquery 提供了两个类型,Document 和 Selector,主要通过这两个对象的方法来选择元素。

“`
type Document struct {
*Selection
Url *url.URL
rootNode *html.Node // 文档的根节点
}
“`

Document 内嵌了 Selection,因此可以直接使用Selection 的方法。

“`
type Selection struct {
Nodes []*html.Node
document *Document
prevSel *Selection
}
“`

其中 Selection 的不少方法都是和 jQuery 中类似的,再次不再赘述,只列出来可能和抓取相关的一些函数。

1. 生成文档

1. `NewDocumentFromNode(root *html.Node) *Document`: 传入 *html.Node 对象,也就是根节点。
2. `NewDocument(url string) (*Document, error)`: 传入 URL,内部用 http.Get 获取网页。
3. `NewDocumentFromReader(r io.Reader) (*Document, error)`: 传入 io.Reader,内部从 reader 中读取内容并解析。
4. `NewDocumentFromResponse(res *http.Response) (*Document, error)`: 传入 HTTP 响应,内部拿到 res.Body(实现了 io.Reader) 后的处理方式类似 NewDocumentFromReader.

2. 查找节点

1. `Find()` 根据 CSS 查找节点

3. 循环遍历选择的节点

1. `Each(f func(int, *Selection)) *Selection`: 其中函数 f 的第一个参数是当前的下标,第二个参数是当前的节点
2. `EachWithBreak(f func(int, *Selection) bool) *Selection`: 和 Each 类似,增加了中途跳出循环的能力,当 f 返回 false 时结束迭代
3. `Map(f func(int, *Selection) string) (result []string)`: f 的参数与上面一样,返回一个 string 类型,最终返回 []string.

4. 获取节点的属性或者内容

1. `Attr()`: 获得某个属性的值
2. `Html()`: 获得当前节点的 html
3. `Length()`:
4. `Text()`:

(未完待续)

# ref

1. http://liyangliang.me/posts/2016/03/zhihu-go-insight-parsing-html-with-goquery/
2. http://blog.studygolang.com/2015/04/go-jquery-goquery/