python爬虫学习 - 查看显卡价格
这是一个简单的爬虫项目,用于从中关村网站上爬取显卡报价数据,后续可以考虑爬取相关的参数信息让数据更立体。数据的保存使用的是json,以python为主要开发语言。这个项目纯粹玩玩儿,不会使用什么现有的框架,只要能动就好。
python版本:3.6.8
pip版本:18.1(不想用21版本的,装一个库弹一个错误简直要命)
我的主要目的是要每日爬取NVIDIA显卡的均价和极值(看看那帮挖矿的什么时候死,写个装机单1w的成本里能有6k是花在卡上也是绝了),所以我需要对几个主要的显卡型号数据进行累加,保存到json当中,再通过其他方式输出出来。参照runoob所示的爬虫架构,整个爬虫的业务流程大致如下:
- 构建待爬取页面列表list(即链接列表,一个链接指向一个查询页面)
- 根据keyword遍历list下载网页并处理网页内容:
- 使用正则表达式匹配目标段落,去除冗余元素
- 从段落中提取价格(可通过修改表达式将价格和相关的参数表一并down下来)
- 数据处理,我需要求均值所以将数据全部累加了起来
- 遍历完一个keyword后,将数据写入json
- 遍历完成后,保存到json文件中
一、 确认要爬取的页面链接
本次爬取的是中关村在线的搜索页面信息:https://detail.zol.com.cn/index.php?c=SearchList&keyword=1660&page=1
从链接格式中可以看出,目标网站是个php页面且带有查询参数,参数keyword对应的是查询字段,参数page对应的是分页页码,故爬取数据的时候可以通过设置这两个参数信息来调整爬取的方向。
利用f-string(格式化字符串常量,formatted string literals)构建链接列表,通过遍历列表抓取页面:
# 待查询显卡型号
keywords = [ "1060", "1660", "1070", "1080", "2060", "2070", "2080", "3060", "3060ti", "3070", "3070ti", "3080" ]
results = [] # 保存爬虫结果的json列表
for keyword in keywords:
# 根据keyword构建链接列表
urls = [ f"https://detail.zol.com.cn/index.php?c=SearchList&subcateId=6&keyword={keyword}&page={i}" for i in range(1, 5) ]
for url in urls:
result = traverse(urls, keyword) # 遍历当前链接列表
results.append(result.to_json()) # 保存爬虫结果
traverse是一个用于遍历链接list的一个方法,后面会提到具体的实现。遍历的结果在命名后保存到results中作为一次遍历的结果。这里之所以会有两个循环是因为每个型号我都需要爬取前四页的价格信息,所以第一层循环遍历的是显卡型号列表,第二层才是开始遍历链接列表,注意区分。
上述内容在架构中发挥的是URL管理器的职能,管理所有待爬取的页面,并对已经访问过的内容进行标记以避免重复爬取。
其实在这个的基础上我们可以再进一步,扩展这一功能,将链接和参数列表作为input导入到一个指定的URL管理器类或者function中,由程序自动拼装并生成链接表,这些工作可以由f-string或者是正则来完成。
二、下载页面
我们需要定义一个方法用于下载页面信息,为下一步分析页面做好准备。理想情况是,主程序会给出一个url参数调用此方法,方法以字符串的形式返回一个纯文本html,程序可以直接对下载结果进行字符匹配。因此我们需要导入requests库,用get方法发起http请求并获取页面信息,页面的header信息和cookie可以通过F12获取。:
def downloadHtml(url):
# 设置要传入的网页cookie,直接F12复制过来就好
cookie = {
"v": "1649838500",
"vn": "1S",
"Hm_lvt_ae5edc2bc4fc71370807f6187f0a2dd0": "1649766000",
"visited_serachKw": "3060",
"Adshow": "2",
"Hm_lpvt_ae5edc2bc4fc71370807f6187f0a2dd0": "1665768000",
"questionnaire_pv": "1649766000",
"visited_subcateId": "3 | 13",
"visited_subcateProId": "3-0 | 13-0"
}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Safari/537.36"
}
res = requests.get(url=url, headers=headers, timeout=5, cookies=cookie)
res.encoding = "GB2312"
return res.text
这里重点解释下为什么要cookie,这其实是一种反制反爬虫策略中的一个常见方案。
有些网站为了减轻爬虫频繁访问给服务器带来的压力会设置反爬虫策略(比如京东),对于一些缺少cookie或者不是从本站发起的访问请求,网站只会返回一个空白页面。具体的禁止访问协议可以通过查看网站的robots.txt文档来查看,比如中关村的就是 https://detail.zol.com.cn/robots.txt。
所以要绕开反爬虫策略,我们就必须构建一个cookie和header,在发起get请求时作为参数传入方法中,就可以正常的获取页面了。反制反爬虫的策略有很多,有兴趣的可以具体研究,这里不再赘述。
header:页面请求头,点击F12后在network一栏找到对应的链接,点击进去后再最下方可以找到header信息,一般是通用的,可以直接复制案例里的那行。如果使用的是requests.post方法,则一定要传入header。
cookie:临时缓存,点击F12后在Applications一栏里面可以找到。
三、定义查询结果类
这一步并非必须,只是从个人习惯上出发,我喜欢将查询结果合并成一个具体的实例化类对象而非一个集合,通过类对象我可以进一步构建对象列表并定义一些方法来辅助操作。尽管Python本质上鼓励的是面向函数开发而非面向对象开发,我仍旧可以通过定义类的形式来实现我的目的:
# ResultItem.py
# 查询结果类,保存被查询对象的名称、均价、统计数量、最高价与最低价信息
class ResultItem:
def __init__(self, name="", sum=0, counts=0, max_price=0, min_price=99999):
"""
构造函数
Args:
name: 查询对象名称
sum: 统计金额总和
counts: 统计次数总和
avg: 查询对象的均价
max_price: 查询对象的最高价
min_price: 查询对象的最低价
"""
self.name = name
self.sum = sum
self.counts = counts
self.max_price = max_price
self.min_price = min_price
self.avg = 0
def updateByValue(self, sum, counts, value):
"""
通过单个价格更新调用对象的最高价或最低价
"""
self.sum = sum
self.counts = counts
self.set_price(value)
def update(self, sum, counts, max_price, min_price):
"""
更新调用对象的属性值
"""
self.sum = sum
self.counts = counts
self.set_max_price(max_price)
self.set_min_price(min_price)
self.avg = self.sum / self.counts
def set_price(self, value):
"""
比较输入值与调用对象的最高价/最低价之间的大小并在发现大于/小于调用对象的
最高价/最低价时进行数值交换
"""
self.set_max_price(value)
self.set_min_price(value)
def set_max_price(self, value):
"""
设置最高价,当输入值小于调用对象的最高价时,调用对象的最高价值不会被改变
Args:
value: 输入值
"""
self.max_price = self.max_price if self.max_price > value else value
def set_min_price(self, value):
"""
设置最低价,当输入值大于调用对象的最低价时,调用对象的最低价值不会被改变
Args:
value: 输入值
"""
self.min_price = self.min_price if self.min_price < value else value
def to_string(self):
return f"[name: {self.name}, avg: {self.avg}, counts: {self.counts}, max_price: {self.max_price}, min_price: {self.min_price}]"
def to_json(self):
return {
"name": self.name,
"sum": self.sum,
"avg": self.avg,
"counts": self.counts,
"max_price": self.max_price,
"min_price": self.min_price
}
我再次重申这点不是必须的,单纯学习爬虫的话不需要特地定义查询结果类,这只是一个个人习惯。但我鼓励大家将参数或查询结果以类对象的形式传入/传出(尤其在多个方法返回同一格式的返回值集合时),类的操作性和语言含义要比单纯的set或map更加丰富,并且可以根据业务需求自定义规则,熟悉SSM框架的同志应该深有体会。
四、遍历链接列表并爬取数据
在前面我们定义了一个traverse方法用于遍历urls,现在就是实现它的时候了。这里我传入了两个参数,一个是urls,也就是链接列表,另一个是keyword,用来标记这个列表查询的是哪一种显卡型号:
def traverse(urls, keyword):
item = ResultItem(keyword)
for url in urls:
print("正在抓取:", url)
# 下载页面信息并提取数据
page_result = analysis(downloadHtml(url))
# 更新遍历结果的数据
item.update(
item.sum + page_result.sum,
item.counts + page_result.counts,
page_result.max_price,
page_result.min_price
)
return item
这部分的思路很简单:遍历列表 => 下载html => 提取数据 => 保存数据,提取的部分我单独抽出来写了个analysis方法,直接传入downloadHtml的结果,方法会返回一个查询结果类对象page_result:
def analysis(html):
# import re
text_pattern = re.compile('<b class="price-type">.*?</b>')
num_pattern = re.compile('[1-9][0-9]{0,}')
item = ResultItem()
price_box = text_pattern.findall(html)
for price_type in price_box:
price = num_pattern.findall(price_type)
if(len(price) > 0):
value = int(price[0]) # 按这个匹配模式出来的数字只可能有一个,直接调用即可
item.updateByValue(item.sum + value, item.counts + 1, value)
return item
通过观察页面我们可以发现,所有的价格都是包裹在'<b class="price-type">.*?</b>'
里面的,所以我们可以很容易的通过正则表达式将它们全部提取出来,再逐一遍历找出其中的数字并累加到查询结果item中。
到了这一步,最关键的就是要能用正则准确的找到我们想要的部分,所以一定要多次模拟测试,可以自己从页面上拷贝整个html到runoob的在线页面上测试正则表达式的准确性与合理性,这会影响到数据的真实性与合理性。
五、保存查询结果
最后一步是保存,我已经有了一个results以json列表的形式保存了所有的查询结果,现在只要写入json文件就好,简单点儿做的话可以直接导入json库用现成的方法。加上save_json方法后,主文件现在的内容是这样的:
import requests
import re
import json
import time
from ResultItem import ResultItem
def main():
# 不保留session,防止因为大量访问导致“Max retries exceeded with url”一类的问题出现
requests.session().keep_alive = False
start = time.time()
keywords = [
"1060", "1660", "1070", "1080", "2060", "2070", "2080", "3060", "3060ti", "3070", "3070ti", "3080"
]
results = []
# 页面链接列表
for keyword in keywords:
# 构建链接列表
urls = [
f"https://detail.zol.com.cn/index.php?c=SearchList&subcateId=6&keyword={keyword}&page={i}"
for i in range(1, 5)
]
result = traverse(urls, keyword)
results.append(result.to_json())
save_json(results)
end = time.time()
print(f"运行时长:{end - start}")
read()
def traverse(urls, keyword):
item = ResultItem(keyword)
for url in urls:
print("正在抓取:", url)
# 导入关键词和页面html
page_result = analysis(downloadHtml(url))
item.update(
item.sum + page_result.sum,
item.counts + page_result.counts,
page_result.max_price,
page_result.min_price
)
return item
def analysis(html):
text_pattern = re.compile('<b class="price-type">.*?</b>')
num_pattern = re.compile('[1-9][0-9]{0,}')
item = ResultItem()
price_box = text_pattern.findall(html)
for price_type in price_box:
price = num_pattern.findall(price_type)
if(len(price) > 0):
value = int(price[0]) # 按这个匹配模式出来的数字只可能有一个,直接调用即可
item.updateByValue(item.sum + value, item.counts + 1, value)
return item
def save_json(results):
with open("record.json", "w") as f:
json.dump(results, f)
print("已将结果保存至json")
return
def downloadHtml(url):
# 设置要传入的网页cookie
cookie = {
"v": "1649838500",
"vn": "1S",
"Hm_lvt_ae5edc2bc4fc71370807f6187f0a2dd0": "1649766000",
"visited_serachKw": "3060",
"Adshow": "2",
"Hm_lpvt_ae5edc2bc4fc71370807f6187f0a2dd0": "1665768000",
"questionnaire_pv": "1649766000",
"visited_subcateId": "3 | 13",
"visited_subcateProId": "3-0 | 13-0"
}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Safari/537.36"
}
res = requests.get(url=url, headers=headers, timeout=5, cookies=cookie)
res.encoding = "GB2312"
return res.text
def read():
"""
简单的读取方法,把json数据读取并打印出来看看效果
"""
with open("record.json", "r") as f:
list = json.load(f)
for index in list:
print(
f"型号:{index['name']}\t均价:{index['avg']:.2f}\t最高价:{index['max_price']}\t最低价:{index['min_price']}"
)
return
if __name__ == '__main__':
main()
如此,我们就将数据全部爬了下来并保存到了我们自己的数据文件中,使用json有个好处就在于我们可以非常简单直观的看到数据的情况。当然这次只保存了一次的数据,如果要做到每日追踪可以考虑些bat文件每天定时运行,或者自己手动跑,两种方案都行。
附录
这里记录了整个过程中参考的网站和资料,感谢dalao们的分享与贡献。
[1] 10行代码集2000张美女图,Python爬虫120例,再上征途
[2] Python typeerror: list indices must be integers or slices, not str Solution
[3] python读写json文件
[4] Python爬虫实战入门四:使用Cookie模拟登录——获取电子书下载链接
[5] python 关于Max retries exceeded with url 的错误
[6] Requests: 让 HTTP 服务人类
[7] Python使用format与f-string数字格式化