发布于 ,更新于 

笔记:Python 爬虫/数据分析简易教程

楔子

我相信点进这篇文章的你肯定听说过爬虫及其原理,简单来说,就是模拟浏览器,向网站发送请求,然后,解析网站返回的数据,从中提取需要的信息。

爬虫还有更加丰富的功能与技巧,我们今天不讲。就讲最基础的:读取 HTML 和 JSON,然后,提取信息。这个功能,足以应付大部分的爬虫需求。

既然选择 Python,那么我们也可以非常轻松地把爬虫和数据分析结合起来。例如,爬取某个网站的排行榜,把统计数据输出到 CSV 文件,同时用 matplotlib 绘制图表。

本文中的代码全部来自我的项目 mojimoon/bangumi-anime-ranking,顺便给我点个 star 吧。

希望对你有所帮助。

工具

包括后续分析数据的步骤在内,你很有可能需要用到下表中的第三方库:

库名 用途
requests 发送 HTTP 请求
beautifulsoup4 解析 HTML
regex 正则表达式
numpy 处理数值
pandas 处理表格
matplotlib 绘制图表
scipy 拟合曲线
fake-useragent 生成随机 UA

还有可能用到的内置库:

  • json:解析 JSON
  • csv:解析和生成 CSV
  • time:计时或者等待

你不知道有没有安装这些库,也不成问题。我们可以一行命令安装所有需要的库:

1
pip install requests beautifulsoup4 regex numpy pandas matplotlib scipy

分析网页

有必要说明,解析 HTML 和解析 JSON 是两个完全不同的工作,但是,本质都是一样的:一般,先手动获取一个文件,分析文件结构,找到需要的信息在文件中的位置,然后,写代码,把这个位置的信息提取出来。

作为例子,我们今天的项目是爬取 Bangumi 的动画排行榜,然后获取每部动画的详细数据,后续进行分析。爬取排行榜需要解析 HTML,获取详细数据用的是官方 API,所以需要解析 JSON。

解析 HTML

首先,我们手动获取排行榜第一页的 HTML:

1
wget https://bgm.tv/anime/browser?sort=rank -O rank.html

我们想要知道如何获得排行榜上每个条目的 ID,可以考虑通过某些 id 或者 href 属性来定位这些条目。先把注意力集中在排行榜主体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<ul id="browserItemList" class="browserFull">
<li id="item_326" class="item odd clearit">
<a href="/subject/326" class="subjectCover cover ll">
<span class="image"><img src="//lain.bgm.tv/pic/cover/c/a6/66/326_D8wjw.jpg" class="cover" /></span>
<span class="overlay"></span>
</a>
<div class="inner">
<h3><a href="/subject/326" class="l">攻壳机动队 S.A.C. 2nd GIG</a> <small class="grey">攻殻機動隊 S.A.C. 2nd GIG</small></h3>
<span class="rank"><small>Rank </small>1</span>
<p class="info tip">26话 / 2004年1月1日 / 神山健治 / 士郎正宗 </p><p class="rateInfo">
<span class="starstop-s"><span class="starlight stars9"></span></span> <small class="fade">9.2</small> <span class="tip_j">(6342人评分)</span>
</p>
</div>
</li>
<!-- 首页有 24 个条目,篇幅所限只保留了 1 个 -->
</ul>

因为网页源代码是动态生成的,格式上很混乱是正常现象。(此处的 HTML 代码是我整理过的,实际的源代码,每个标签都是一行,没有缩进。)

仍然不难发现,我们要找的信息在 #browserItemList 下的 li 中,liid 属性是 item_ 加上条目的 ID。

尽量使用id来定位元素,因为id是唯一的。要检查你的选择器是否正确,请用浏览器的开发者工具。

我们使用 BeautifulSoup 来解析 HTML:

1
2
for li in soup.select('#browserItemList > li'):
sid = li['id'][5:]

——不会用 BeautifulSoup?没关系,下文会介绍用法。这里只需要了解怎么解析 HTML。

解析 JSON

查阅 Bangumi 官方 API 文档,得知

  • API 服务器的地址是 https://api.bgm.tv/
  • 获取条目信息的 API 是 v0/subjects/{id},其中 {id} 是条目的 ID

因此,我们再次手动爬取第一个条目的数据:

1
wget https://api.bgm.tv/v0/subjects/326 -O subject.json

结果是一个 JSON 文件,关键信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"collection": {
"collect": 8810, "doing": 699, "dropped": 95, "on_hold": 378, "wish": 4011
},
"date": "2004-01-01",
"eps": 26,
"id": 326,
"name": "攻殻機動隊 S.A.C. 2nd GIG",
"name_cn": "攻壳机动队 S.A.C. 2nd GIG",
"rating": {
"count": {
"1": 31, "10": 2949, "2": 8, "3": 8, "4": 7,
"5": 16, "6": 61, "7": 200, "8": 927, "9": 2135
},
"rank": 1,
"score": 9.2,
"total": 6342
}
}

此处的 JSON 代码也是我整理过的,实际的源代码只有一行。

因此,我们可以通过 json 库来解析 JSON:

1
2
3
4
5
6
7
8
# 此处我预先下载了 JSON 在 data/sub/{id}.json
jfile = open('data\\sub\\%d.json' % sid, 'r', encoding='utf-8')
j = json.load(jfile)
jfile.close()
title = j['name_cn'] if j['name_cn'] else j['name']
s = [0] * 10
for i in range(10):
s[i] = j['rating']['count'][str(i + 1)]

获取这些信息后,我们可以用 pandas 进行整合,保存到 CSV 文件,再进行后续处理。

我们初步了解怎么爬取数据,那么接下来讲解具体的实现。

用法快速入门

虽然文档非常全面详尽,但也不是每个人都有耐性看完的。我个人认为,入门级别的爬虫,只需要知道一些基本用法即可:

import requests

1
2
3
4
from fake_useragent import UserAgent
r = requests.get(url, headers={'User-Agent': UserAgent().chrome}) # 随机UA
r.raise_for_status()
r.encoding = r.apparent_encoding

发送 HTTP 请求,url(str) 是请求的网址,headers(dict) 是请求头,User-Agent 是浏览器标识,有些网站会根据这个标识来判断你是不是爬虫,如果是爬虫,就不给你数据了。

from bs4 import BeautifulSoup

1
soup = BeautifulSoup(r.text, 'html.parser')

解析 HTML。r.text(str) 是 HTTP 响应的正文,html.parser 是解析器,这里使用的是 Python 内置的解析器。

1
for li in soup.select('#browserItemList > li'):

使用 CSS 选择器,选中 HTML 中的元素。然后,可以把这个元素当作字典来使用,例如,li['id'] 是这个元素的 id 属性,li.select('a')[0]['href'] 是这个元素的第一个 a 子元素的 href 属性。

import json

1
2
j = json.load(jfile)
jfile.close()

解析 JSON,jfile(file) 是一个文件对象,这一步后就可以关闭文件了。然后,可以把这个 JSON 当作字典来使用,例如,j['data']['list'][0]['name'] 是这个 JSON 的 data 字段的 list 字段的第一个元素的 name 字段。

import csv

1
2
3
writer = csv.writer(ofile)
writer.writerow(['sid', 'title', 's1', 's2', 's3', 's4', 's5', 's6', 's7',
's8', 's9', 's10', 'rank', 'vote', 'avg', 'std', 'user'])

生成 CSV,ofile(file) 是一个文件对象;然后 writer.writerow() 写入一行数据,参数是一个列表。

csv 本身的功能完全可以用 pandas 来代替。

import re

1
id = re.search(r'item_(\d+)', li['id']).group(1)

这里给出一个很浅显的例子:在 li['id'] 中匹配 item_ 后面的数字,然后,把这个数字提取出来。re.search() 返回一个 Match 对象,group(1) 是这个对象的第一个分组,也就是第一个括号(即 (\d+))匹配到的内容。

正则表达式太复杂了,而且很容易出错,我这儿推荐一个网站:regex101。这个网站可以帮助你调试正则表达式,外加很有用的『翻译成人话』功能。例如,<a href="(.+?)">(.+?)</a> 这个正则表达式,可以翻译成人话:『匹配 <a href=",然后,匹配任意字符,直到遇到第一个 ",然后,匹配 ">,然后,匹配任意字符,直到遇到第一个 </a>』。

regex101 的界面
regex101 的界面

import numpy as np

1
2
3
mean, std, hi, uq, med, lq, lo= \
np.mean(s), np.std(s), np.max(s), np.percentile(s, 75), \
np.median(s), np.percentile(s, 25), np.min(s)

应该是非常常用的一些统计量了,其中 s(list) 是一组数值。

numpy 非常神通广大,由于这几个库经常一起用,所以我不单独列出,下文会展示更多 numpy 的用法。

import matplotlib.pyplot as plt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
plt.hist(s, bins=250, density=True, alpha=.75, color='g') # 绘制频率密度直方图,数据分为 250 组
x = np.linspace(lo, hi, 1000)
plt.plot(x, norm.pdf(x, mean, std), color='r', linewidth=.5) # 用 1000 个点绘制正态分布密度曲线
plt.axvline(med, color='r', linestyle='dashed', linewidth=1) # 在中位数处画一条虚线
x_diff = (hi - lo) / 500 # 用于调整中位数标签的位置,水平方向上向右平移全图宽度的 1/500
mid_y = plt.ylim()[1] / 2 # 用于调整中位数标签的位置, 垂直方向上向上平移半个全图高度
plt.text(med + x_diff, mid_y, f"{med:.3f}", rotation=90, va='center', color='r') # 在中位数处添加标签,向右旋转 90 度(即竖着向上),垂直方向居中
plt.legend(['Normal Dist', 'Median'], loc='upper left') # 图例
plt.title("Distribution of %s" % name) # 图题
plt.xlabel(name) # 横坐标标签
plt.ylabel("Density") # 纵坐标标签
plt.xticks(range(1, 11)) # 横坐标刻度,显示为 1 到 10 的 10 个整数
plt.yscale('log') # 纵坐标使用对数刻度
plt.savefig(pre + fname) # 保存图片
plt.clf() # 清空画布

这里给出一个绘制频率密度直方图的例子,s(list) 是一组数值,name(str) 是这组数值的名称,pre(str) 是图片的路径,fname(str) 是图片的文件名。为了绘制出更好看的图,plt 提供了非常多的参数,如有必要还是得查文档。标签的水平和垂直方向的位置调整,我觉得是比较不错的技巧。

1
plt.bar(range(1, 11), s / n, width=.8, color='g', alpha=.75)

绘制条形图。和直方图不同,直方图给出的是不同的 x,而条形图给出的是不同的 y。因此我们将 range(1, 11) 作为 x,将 s / n 作为 y(其中,s 是一个 numpy 数组;这样写是因为条形图不支持自动转换为频率密度)。

from scipy.stats import norm, pearsonr

1
2
3
4
5
6
7
r, p = pearsonr(x, y)
ofile.write("Correlation between %s and %s,%.6f,\n" % (xname, yname, r))
plt.scatter(x, y, s=1, alpha=.5, color='g') # 绘制散点图
m, b = np.polyfit(x, y, 1) # 用一次多项式拟合,相当于线性回归
ofile.write("%s = a * %s + b,%.6f,%.6f\n" % (yname, xname, m, b))
plt.plot(x, m*np.float64(x) + b, color='b', linewidth=.5) # 由于此处的 x 是 pandas 的 Series,需要转换为 numpy 数组
plt.legend(["Data", "Linear Regression"]) # 注意散点图需要手动添加图例,直方图/条形图不需要

scipy.stats 中有很多种分布和拟合工具。

正态分布,上文已提到用 plt.plot(x, norm.pdf(x, mean, std), color='r', linewidth=.5) 绘制正态分布密度曲线,这相当于是传入一个 numpy 数组 x,直接计算对应的正态分布密度。另外的例子:_P3S = norm.cdf(3) * 100,这相当于是计算正态分布的累积分布函数,即 P(X <= 3) 的百分数。

pearsonr 是计算皮尔逊相关系数的方法。

import pandas as pd

1
2
3
4
5
df.loc[:,'bayes'] = (VOT_MIN * AVG_AVG + df['vote'] * df['avg']) / (VOT_MIN + df['vote']) # 计算贝叶斯平均
df = df.sort_values(by=['bayes'], ascending=False) # 按照贝叶斯平均降序排列
df.loc[:,'b_rank'] = np.arange(1,ENT+1) # 添加贝叶斯排名
_df = df.sort_values(by=['rank']).drop(['s1','s2','s3','s4','s5','s6','s7','s8','s9','s10'], axis=1) # 删除不需要的列
_df.to_csv('data/rank/rank.csv', index=False, float_format='%.4f') # 保存为 csv 文件

pandas 的最强大之处就是可以把『数组当作数处理』,尽管 df 这个 Dataframe 有很多行,但是 df['vote'] 之类的数组都可以像数字一样参与运算,会对每一行都进行相同的操作。

顺便一提,df.loc[:,'bayes'] 会返回一个 Series,而 df['bayes'] 会返回一个 numpy 数组。这两者的区别在于,Series 会保留原来的索引,而 numpy 数组不会。这里我们需要保留原来的索引,因为我们要把贝叶斯平均和原来的数据一起保存到 csv 文件中。

实例

为供读者参考,这里给出这个项目的实例。受篇幅所限,省略了部分功能的实现与文件操作,但是保留了核心代码。

爬取排行榜
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def get_html(page, ofile):
url = pre + str(page)
try:
r = requests.get(url, headers={'User-Agent': UserAgent().chrome})
r.raise_for_status()
r.encoding = r.apparent_encoding
soup = BeautifulSoup(r.text, 'html.parser')
entries = 0
for li in soup.select('#browserItemList > li'):
id = re.search(r'item_(\d+)', li['id']).group(1)
ofile.write(id + '\n')
entries += 1
return entries
except:
print('error on page %d' % page)
return -1

pages_block = 10 # 每次爬取 10 页
per_page = 24 # 每页 24 个条目

for i in range(1, 10000, pages_block):
ofile = open('data\\id\\%d.txt' % i, 'w')
entries = 0
flag = False
for j in range(pages_block):
res = get_html(i + j, ofile)
if res == -1:
res = 0
elif res == 0:
flag = True
break
entries += res
ofile.close()
print('block %d done, %d entries' % (i, entries))
# 检测到空白页,或者某页的条目数量没有达到上限(意味着没有下一页了),则停止爬取
if flag or ((pages_block - 1) * per_page < entries < pages_block * per_page):
break
获取条目信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def get_json(sid):
url = pre + str(sid)
try:
r = requests.get(url, headers={'User-Agent': UserAgent().chrome})
r.raise_for_status()
r.encoding = r.apparent_encoding
ofile = open('data\\sub\\%d.json' % sid, 'w', encoding='utf-8')
ofile.write(r.text)
ofile.close()
return True
except:
return False

def api_main():
# 通过读取 data/id/{page}.txt 文件,获取每个条目的 id。实现与上文类似,略

def csv_main():
writer = csv.writer(ofile)
writer.writerow(['sid', 'title', 's1', 's2', 's3', 's4', 's5', 's6', 's7',
's8', 's9', 's10', 'rank', 'vote', 'avg', 'std', 'user'])
for line in ifile:
sid = int(line)
jfile = open('data\\sub\\%d.json' % sid, 'r', encoding='utf-8')
j = json.load(jfile)
jfile.close()
title = j['name_cn'] if j['name_cn'] else j['name']
s = [0] * 10
for i in range(10):
s[i] = j['rating']['count'][str(i + 1)]
rank = j['rank']
vote = sum(s)
avg = sum([(i + 1) * s[i] for i in range(10)]) / vote
std = (sum([(i + 1 - avg) ** 2 * s[i] for i in range(10)]) / vote) ** 0.5
user = j['collection']['collect'] + j['collection']['doing'] + \
j['collection']['on_hold'] + j['collection']['dropped']

writer.writerow([sid, title] + s + [rank, vote, avg, std, user])
进行数据分析
  1. 上文 matplotlib 部分已经相当详细地介绍了如何绘制图表。
  2. 至于输出统计数据到 CSV,只需要会用 writer.writerow 方法就可以了。
  3. 上文 pandas 部分也已经介绍了如何使用贝叶斯平均进行排序。

此处不再赘述,若有需要可以参考仓库中的代码。

结语

最后一提,不要硬编码。这是一个非常严肃的问题,硬编码让你这次写起来方便,以后要改就麻烦了。Python 提供了非常强大的 try...except... 语法,提高代码的鲁棒性。另外,在判断异常的时候,也不能只考虑数据在多少页结束这个问题,还要纳入网络不稳定等因素,例如,requests 库提供了 r.raise_for_status() 方法,可以在请求失败的时候抛出异常。

好吧,这期简易教程就写到这里。

mojimoon/bangumi-anime-ranking 这个项目的统计结果可以在项目的 README.md 中找到,点进去看看吧。

都看到这里了就麻烦点个 Star 吧!