“台部落”对简书部分文章的未授权转载行为,想必大家已经有所耳闻了吧。
有简友查了一些关于爬虫的资料,里面提到”通过网络爬虫批量获取数据的成本较低“,这段话我部分认同。
既然”台部落“爬我们的文章,这次我们就以”台部落”为例,带大家了解一下网络爬虫的基本开发流程。
测试连通性
先给出台部落的网址:https://www.twblogs.net
如果大家直接访问的话应该会报错,因为这个网站使用了一个叫做 Cloudflare 的 CDN,通过设置禁止了所有大陆地区用户的访问。
CDN,全称 Content Delivery Networks,内容分发网络,是一种加速网络资源访问的技术。
假设简书的图片存放在上海的一个机房中,如果你在西藏,访问这张图片的速度就会比在上海慢一些,因为图片需要从更远的地方传输过来,这一方面是电信号的传输速度上限导致的,另一方面是因为数据在传输中会经过更多次处理,每次处理都要消耗一定时间。
所以,简书会将图片上传到 CDN 网络中,这样当西藏的用户访问时,就可以就近从西藏的服务器获取数据,加快了访问速度。当然,CDN 还有其它作用,比如降低数据传输成本、在一定程度上抵御网络攻击等。
直接访问网页都看不到数据,用爬虫当然也是采集不到的,所以我们需要通过一些特殊手段,将自己的 IP 更改成非大陆区域。出于监管原因,我不能描述具体方式,技术人自然会懂,普通用户不需要过多了解。
总之,经过一些操作后,我们可以正常访问台部落网站了。
爬虫程序一般使用 Python 开发。首先,我们需要确保程序可以正常访问到台部落网站。编写如下代码:
import requests
proxies = {
"http": "socks5://127.0.0.1:10808",
"https": "socks5://127.0.0.1:10808"
}
response = requests.get("https://www.twblogs.net", proxies=proxies)
print(response.text)
在这段代码中,我们导入了一个叫做 requests 的库,可以简单理解成别人写好的代码,然后设置本地代理,并通过代理访问台部落网站。
访问结果(称为“请求”)存储在 reponse 变量中,最后,获取请求到的文本内容(网页代码),通过 print 函数将其打印出来。
运行代码,输出如下:
现在我们已经成功通过程序访问了台部落。
分析网络请求
我们把目光放回到网页上:
这次我们要爬取的内容是每篇文章的标题、作者和发布时间。
对信息流内容的爬取,数据加载方式往往是突破口。
这个网站触发新内容加载的途径是下滑,也就是说,一定存在这样一个逻辑,在页面下拉到一定位置时触发新数据的加载。
而要加载内容,就不可避免地要发送网络请求。
我们按下 F12,打开浏览器的开发者工具,切换到网络选项卡,然后下滑页面触发刷新:
界面中显示出了非常多的网络请求,简单查看一下,大多数都是资源请求,比如网页中的图标和一些代码文件。
点击上图红圈中的“Fetch/XHR”按钮,筛选出所有异步网络请求,这是我们获取数据的关键:
注意这几条请求的网址,改变的只有最后的一部分,前面的“path=index&postoffset=”是没有变化的(这里的第一条请求来自一个浏览器扩展,可以忽略)。
点开第一条请求:
现在我们看到了数据加载背后请求的网址,我们一般将这种通过异步方式调用,返回数据的网址称为 API(应用程序编程接口,简称“接口”)。
接口地址下面是请求方式,这里是 GET,HTTP 请求中最常用的一种,我们平时访问某个网站,其实就是对网站发起 GET 请求。
接下来切换到“Payload”(负载)选项卡:
这些是请求所携带的参数,这个网站没有什么反爬措施,两个参数都不难理解。有些网站会在发送请求时携带一个验证用的参数(Token),而这个参数是会根据一定规则变化的,这时我们就需要对网页的源代码进行分析,找出 Token 的生成逻辑,这里不做展开。
“响应”选项卡,很明显是一段 HTML 代码。我们平时看到的网页,就是通过这种语言编写而成的。
另外,HTML 不是编程语言,而是一种“标记语言”,只靠它一个也不能做出美观的网页,还需要有 CSS 为网页“穿上衣服”,也就是调整格式,以及 JavaScript 实现网站的交互逻辑,比如点击标题时进入对应的文章页面。
使用程序发送网络请求
有了这些信息,我们就可以通过编写程序请求这个接口了。代码如下:
import requests
proxies = {
"http": "socks5://127.0.0.1:10808",
"https": "socks5://127.0.0.1:10808"
}
response = requests.get("https://www.twblogs.net/api/list/?path=index&postoffset=620602826c6f797dd5fadd7a",
proxies=proxies)
print(response.text)
程序输出如下:
很明显又是一段 HTML,我们略微修改代码,将数据保存到一个 HTML 文件中:
with open("twblogs_mainpage.html", "w") as f:
f.write(response.text)
解析网页内容
用浏览器打开这个 HTML 文件:
这就是接口返回的结果,只这样看是看不出什么的,我们还是要回到源代码上,切换到“元素”选项卡:
开发者工具的左上角有一个方框和指针的按钮,点击一下,它会变成蓝色,然后点击网页上要采集的元素,开发者工具中的代码会自动展开,并显示出与这个元素对应的代码:
这里需要补充一点知识,HTML 是由一个个“标签”嵌套而成的,例如上图中的“html”、“body”、“div”、“section”。
每个标签都由尖括号包裹起来,不同种类的标签嵌套在一起,就构成了网页的骨架。
不难发现,我们的目标在body > section > h4 > a
中,具体来说,是这个 a 标签的内容。
这里要引入一种叫做 xPath 的表达式,它可以帮助我们快速地在 HTML 中定位内容。
上面的嵌套关系,用 xPath 表示是这样的:
//section[@class="list-item"]/div/h4/a/text()
双斜杠的意思是查找网页中所有符合条件的标签,单斜杠则代表嵌套关系,中括号内可以指定这个标签的属性。
所以,这段 xPath 的含义是这样的:在整个 HTML 文件中查找 class 属性为 list-item 的 section 标签,定位到它下面的 div 标签,然后是 h4 标签,再里面是 a 标签,获取这个标签的内容。
我们可以通过一个叫做 xPath Helper 的浏览器插件来验证这段 xPath 的正确性:
result 中显示出了每篇文章的标题,接下来就是用程序实现解析流程了,我们需要用到一个叫做 lxml 的库,它可以帮助我们通过 xPath 表达式提取网页内容:
import requests
from lxml import etree
proxies = {
"http": "socks5://127.0.0.1:10808",
"https": "socks5://127.0.0.1:10808"
}
response = requests.get("https://www.twblogs.net/api/list/?path=index&postoffset=620602826c6f797dd5fadd7a",
proxies=proxies)
html_obj = etree.HTML(response.text)
print(html_obj.xpath("//section[@class='list-item']/div/h4/a/text()"))
程序以一个 Python 列表的方式返回了页面中所有的文章标题。
接下来我们再写一些 xPath 表达式:
# 文章链接
//section[@class="list-item"]/div/h4/a/@href
# 作者昵称
//section[@class="list-item"]/div/div/a/span/text()
# 作者链接
//section[@class="list-item"]/div/div/a/@href
# 发布时间
//section[@class="list-item"]/div/div/span/text()
然后修改我们的代码,使程序能获取到这些数据:
from datetime import datetime
from pprint import pprint
import requests
from lxml import etree
proxies = {
"http": "socks5://127.0.0.1:10808",
"https": "socks5://127.0.0.1:10808"
}
response = requests.get("https://www.twblogs.net/api/list/?path=index&postoffset=620602826c6f797dd5fadd7a",
proxies=proxies)
html_obj = etree.HTML(response.text)
titles = html_obj.xpath("//section[@class='list-item']/div/h4/a/text()")
article_links = (f"https://www.twblogs.net{x}" for x in html_obj.xpath("//section[@class='list-item']/div/h4/a/@href"))
author_nicknames = html_obj.xpath("//section[@class='list-item']/div/div/a/span/text()")
author_links = (f"https://www.twblogs.net{x}" for x in html_obj.xpath("//section[@class='list-item']/div/div/a/@href"))
publish_times = (datetime.fromisoformat(x) for x in html_obj.xpath("//section[@class='list-item']/div/div/span/text()"))
result = []
for title, article_link, author_nickname, author_link, publish_time in zip(
titles, article_links, author_nicknames, author_links, publish_times):
result.append({
"title": title,
"article_link": article_link,
"author_nickname": author_nickname,
"author_link": author_link,
"publish_time": publish_time
})
pprint(result)
在一番神奇的操作之后,程序以列表中嵌套字典的方式返回了我们要爬取的所有数据。
分页处理
但这只是一页,怎么获取其它页的内容呢?
还记得我们在前文看到的请求参数么?
offset,意思是“偏移”,看看前面爬取到的数据,这个字段像什么?
对,文章链接。每一个新请求所携带的 offset 参数值,正是上一个请求最后一组数据中,文章链接的最后一部分。沿用简书的叫法,我们将这一段称为 slug。
而 path 参数是不变的,这样问题就简单了,只需要存储每次请求获得的 slug,然后在下一次请求中作为参数传入即可。
简单修改一下代码,爬它十页:
from datetime import datetime
from pprint import pprint
import requests
from lxml import etree
proxies = {
"http": "socks5://127.0.0.1:10808",
"https": "socks5://127.0.0.1:10808"
}
slug = None
result = []
for i in range(10):
params = {"path": "index", "postoffset": slug}
response = requests.get("https://www.twblogs.net/api/list/?path=index&postoffset=620602826c6f797dd5fadd7a",
proxies=proxies)
html_obj = etree.HTML(response.text)
titles = html_obj.xpath("//section[@class='list-item']/div/h4/a/text()")
article_links = (f"https://www.twblogs.net{x}" for x in html_obj.xpath("//section[@class='list-item']/div/h4/a/@href"))
author_nicknames = html_obj.xpath("//section[@class='list-item']/div/div/a/span/text()")
author_links = (f"https://www.twblogs.net{x}" for x in html_obj.xpath("//section[@class='list-item']/div/div/a/@href"))
publish_times = (datetime.fromisoformat(x) for x in html_obj.xpath("//section[@class='list-item']/div/div/span/text()"))
for title, article_link, author_nickname, author_link, publish_time in zip(
titles, article_links, author_nicknames, author_links, publish_times):
result.append({
"title": title,
"article_link": article_link,
"author_nickname": author_nickname,
"author_link": author_link,
"publish_time": publish_time
})
slug = result[-1]["article_link"].split("/")[-1]
print(f"爬取第 {i + 1} 页成功!")
print(f"数据条数:{len(result)}")
输出结果:
一百五十条数据,只用了十秒钟。
数据存储
result 列表在程序运行完毕之后就会消失,所以我们需要把数据进行存储。
一般情况下,我们使用数据库进行数据的存储,但考虑到代码复杂度和理解难度,我们这次使用 CSV 格式存储数据,可以理解为 Excel 表格。
只需要在最前面加上这行代码:
import pandas as pd
然后在最后加上这几行:
df = pd.DataFrame(result)
df.to_csv("data.csv", encoding="utf-8")
print("数据保存成功!")
程序运行完毕后,目录下会多出一个 data.csv 文件,我们打开它:
完美。
回头看看代码量,只有 44 行。
结语
十秒钟一百五十条数据,乍一看很快,但对于整个台部落的数据量来说,效率还是不尽如人意。
但我们编写的这种,是单线程同步爬虫。
在真正的爬虫程序中,可以同时发起数十个网络请求,可以让程序在发送一个请求后不在原地死等结果,而是去处理其它请求。
真实的数据库可以让多个程序同时写入数据,存储的数据量可达千万。
通过分布式技术,我们可以在多台服务器上部署爬虫程序,让结果统一存储到一个数据库内。
运用成熟的爬虫框架,我们可以更轻松地完成开发,并通过网页界面监控每个采集任务的状态。
生产数据的成本很高,但采集数据的成本很低。在技术防御之外,总需要有法律法规,对数据的使用方式进行限制。
利用无版权的商业数据获利,是不正当的商业竞争行为,做出这种行为的团体和公司,应当受到相应的处罚。
未经授权转载他人内容,属于侵犯著作权的行为。著作权的主体是公民,无论是否成年,你独立创作的内容,其著作权都属于你,你有权决定是否允许他人转载。
技术是否无罪,我不想做一个明确的判定,但我们共同期待着,期待着在创作领域,能有一束温暖的阳光穿过乌云,给予每个人应得的权利。