在这个信息爆炸的时代,网络上充斥着大量的旅游信息,而其中关于景区的介绍和评论更是琳琅满目。然而,对于想要获取特定景区信息并了解其真实评价的人来说,筛选和获取准确、有用的数据可能是一项极具挑战性的任务。为了解决这一难题,利用网络爬虫技术成为了一个高效的途径。
在这篇笔记中,我们将介绍一个针对去哪儿网(qunar.com)景区信息和评论的网络爬虫。通过 Python 的 Scrapy 框架,结合模糊匹配技术,我们将展示如何从该网站获取特定景区的相关信息,并抓取其评论内容。本文将逐步解析代码,探讨实现爬虫的思路和关键步骤,以帮助读者更好地理解和应用网络爬虫技术。
本文已对部分关键URL进行处理,本文内容仅供参考,请勿用以任何商业、违法行径
本文使用scrapy_redis实现分布式爬虫,若想了解scrapy基础,或遇到反爬较为严重的网站,想使用Selenium技术请参考:网络爬虫 - 冷月半明的专栏 - 掘金 (juejin.cn)
整体思路是利用 Scrapy 框架发送请求获取页面信息,通过 CSS 选择器解析页面内容,使用模糊匹配技术对景区名称进行相似度匹配,提取匹配度较高的景区信息和评论内容,并最终以 JSON 格式存储数据。
Scrapy-Redis 是 Scrapy 框架的一个扩展,用于实现分布式爬取。它基于 Redis 数据库实现了 Scrapy 的调度器、去重集和队列,使得多个爬虫节点可以共享相同的信息,并能够高效地协作。
以下是一些关于 Scrapy-Redis 的要点:
使用 Scrapy-Redis 可以实现一个分布式的、高效的爬虫系统,使得多个爬虫节点协同工作,提升了爬取效率和稳定性。
import urllib
import scrapy
from fuzzywuzzy import fuzz
from scrapy import Request
import pandas as pd
from ..items import QvnaItem
from scrapy_redis.spiders import RedisSpider
import json
urllib
、scrapy
、fuzzywuzzy
、pandas
等。这些模块用于处理网络请求、数据解析、相似度匹配、数据存储等。class QvnaSpider(RedisSpider):
name = "qvna"
allowed_domains =["piao.qunar.com"]
redis_key = 'db:start_urls'
class QvnaSpider(RedisSpider):
:定义了一个名为 QvnaSpider
的 Python 类,并指定它继承自 RedisSpider
。这意味着 QvnaSpider
类将继承 RedisSpider
类的所有属性和方法,可以重写或扩展父类的功能。name = "qvna"
:设置了该爬虫的名称为 "qvna"
。Scrapy 中每个爬虫的唯一标识是其名称。allowed_domains = ["piao.qunar.com"]
:指定了爬取的目标域名。在爬取过程中,Scrapy 将只会爬取这些域名下的页面,其他域名的页面将被忽略。redis_key = 'db:start_urls'
:指定了 Redis 中存储初始 URL 的键值名称为 'db:start_urls'
。这个键值对应着爬虫启动时从 Redis 中读取初始 URL 的地方。在分布式爬取时,起始 URL 会存储在 Redis 的特定键中,供多个爬虫节点共享使用。def start_requests(self) :
# 读取景区文件
# 用pandas读取.xlsx文件
df = pd.read_excel("D:\code\Scrapy\scrapy_tour\A级景区(按省份).xlsx")
scenic_namelist = df['景区名称']
dflen = len(scenic_namelist) # 执行多少行
for i in range(0,dflen):
key = scenic_namelist[i]
newurl = '************' + key + '®ion=&from=mpl_search_suggest'
# print(newurl)
re=Request(url=newurl,headers=header,meta={"use_selenium":False},callback=self.parse)
re.meta["data"] = {"oldtitle":key}
re.meta["data"]["id"]=i
yield re
这段代码是一个 Scrapy 爬虫的起始方法 start_requests
。它从一个名为"A级景区(按省份).xlsx"的 Excel 文件中读取景区名称,然后构建相应的 URL。接着生成针对这些 URL 的请求,并指定回调函数 parse
来处理响应。每个请求都包含了一个 data
字典,其中包含了景区名称和索引。通过循环处理每个景区名称,生成对应的请求,最终开始爬取。
def parse(self, response):
def get_similarity(oldtitle, newtitle):
# 模糊匹配
# 两个字符串之间的相似度(得分越高表示相似度越高)
similarity_score = fuzz.ratio(oldtitle, newtitle)
# 部分字符串匹配的得分
partial_score = fuzz.partial_ratio(oldtitle, newtitle)
# 排序匹配得分(处理单词顺序不同的情况)
token_sort_score = fuzz.token_sort_ratio(oldtitle, newtitle)
ans = [oldtitle, newtitle, similarity_score, partial_score, token_sort_score,
similarity_score + partial_score + token_sort_score]
return ans
sight_elements = response.css('.search_result .sight_item[data-sight-name]')
Similarity_score = []
for element in sight_elements:
title = element.attrib['data-sight-name']
Similarity_score.append(get_similarity(response.meta.get("data")["oldtitle"], title))
max_score = None
max_index = None
if Similarity_score != []:
for index, score in enumerate(Similarity_score):
if max_score == None or max_score[-1] < score[-1]:
max_score = score
max_index = index
if max_score != None and max_score[2] >= 50 and max_score[3] >= 50 and max_score[4] >= 50:
# print('max', max_score)
# print(sight_elements[max_index].attrib['data-sight-name'])
newurl = "https://piao.qunar.com" + sight_elements[max_index].css(
'.sight_item_detail .sight_item_about .sight_item_caption a::attr(href)').get()
price = sight_elements[max_index].css(
'.sight_item_detail .sight_item_pop .sight_item_price em::text').get()
re = Request(url=newurl, headers=header, meta={"use_selenium": False}, callback=self.parse_second)
response.meta["data"]["title"]=sight_elements[max_index].attrib['data-sight-name']
re.meta["data"]=response.meta["data"]
re.meta["data"]["price"]=price
yield re
这段代码是爬虫中的一个解析方法 parse
。它主要执行以下操作:
fuzz
库中的不同方法来计算传入的景区名称和页面中景区名称的相似度。主要有 fuzz.ratio
、fuzz.partial_ratio
和 fuzz.token_sort_ratio
。Similarity_score
中。该方法的主要作用是在爬取的页面中查找与目标景区名称相似的景区,然后提取相关信息并构建新的请求进一步获取更详细的数据。
一些优化的相关思考:
如果想要保证数据的准确性,那么一个高效且准确的匹配算法是必要的。
在上述代码中相似度计算只是对于 目标景点的名称和从去哪引擎过滤过一遍的相似景区名称 的比较,之所以选择这种方式是因为经过测试 去哪搜索引擎 的准确率较高,经过匹配后基本上能保证获取较为准确的数据,然而,有些旅游景点的搜索引擎准确率并没有那么高(比如飞猪),此时我们可以配合经纬度进行匹配。在很多景区列表的item里经纬度信息都会作为标签属性存在,而不会直接渲染,我们可以通过css选择器或XPath去获取。然后进行计算。
大致思路如下:
使用了两种不同的相似度度量方式:名称相似度和地理位置相似度,并通过加权计算得到最终的综合相似度。
地理位置相似度计算(Geographic Similarity):
计算两个景区之间的地理位置相似度。这是通过计算两个景区之间的地理距离,并将其归一化为 0 到 1 之间的值来实现的。通常会使用球面距离公式(如 Haversine 公式)来计算地理距离。
加权相似度计算(Weighted Similarity):
最后,对名称相似度和地理位置相似度进行加权求和,得到最终的综合相似度。这里通过权衡考虑了两个相似度度量的重要性。
calculate_distance
函数:
计算两个经纬度点之间的距离通常会使用球面距离公式,例如 Haversine 公式。下面是一个简化的示例,它使用 Haversine 公式来计算地球表面上两个点之间的球面距离:
示例代码如下:
from math import radians, sin, cos, sqrt, atan2
def calculate_distance(location1, location2):
lat1, lon1 = radians(location1[0]), radians(location1[1])
lat2, lon2 = radians(location2[0]), radians(location2[1])
# Haversine formula
dlon = lon2 - lon1
dlat = lat2 - lat1
a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2
c = 2 * atan2(sqrt(a), sqrt(1 - a))
radius_earth = 6371 # Earth radius in kilometers
distance = radius_earth * c
return distance
这段代码会返回两个经纬度点之间的球面距离,单位为公里。
# 计算经纬度相似度
distance = calculate_distance(location1, location2)
geographic_similarity = 1 - distance / (max_distance * 1.0)
# 将距离转化为相似度,最大距离设为max_distance
print(f"The geographic similarity between {location1} and {location2} is {geographic_similarity}.")
# 计算加权相似度
weighted_similarity = 0.3 * name_similarity + 0.7 * geographic_similarity
# 这里的0.3和0.7是权重,可以根据需要调整
然后根据名称相似度和经纬度得分去进行计算,可根据参数的具体情况去判断权重的选值。
上述思考未进行实践,有兴趣的朋友可以自己试试。
def parse_second(self, response):
# 进入景点详情页,解析景点id,发送第一个请求,获取评论数量以进一步行动
print(response.meta["data"])
sightid = response.css("#mp-tickets-new").attrib['data-sightid']
# print("景点id",sightid)
newurl= "*************?sightId="+sightid+"&index=1&page=1&pageSize=10&tagType=0"
re = Request(url=newurl, headers=header, meta={"use_selenium": False}, callback=self.parse_third)
re.meta["data"] = response.meta["data"]
re.meta["data"]["sightid"] = sightid
re.meta["result_flag"] = 1
re.meta["data"]["all_results"]=[]
yield re
这段代码实现了在进入景点详情页后,解析景点ID,发送第一个请求以获取评论数量,并进行下一步操作。
sightid = response.css("#mp-tickets-new").attrib['data-sightid']
re
,并使用之前解析的景点ID,配置相关的元数据信息。meta
中包括了爬虫需要的一些信息,如使用 Selenium、结果标志、评论列表等。yield re
将新的请求对象 re
返回,以便进一步进行评论数据的爬取和处理。这段代码的目的是获取景点的评论信息,准备好第一个请求,并将相关的元数据信息添加到请求中,以便在接下来的请求中使用这些信息进行进一步处理和爬取。
def parse_third(self, response):
if response.status != 200:
scrapy.Spider.logger.error(f"异常状态码,可能被识别")
return None
# 将获取到的数据添加进数组
# 大于五十条评论,只存储前五十条
try:
if response.json()["data"].get("commentCount") is not None and response.json()["data"].get("commentCount") >= 50:
response.meta["data"]["all_results"].append(response.json()["data"]["commentList"])
numlist = range(2, 6)
elif response.json()["data"].get("commentCount") <= 10 and response.json()["data"].get("commentCount") != 0:
response.meta["data"]["all_results"].append(response.json()["data"]["commentList"])
numlist = None
elif response.json()["data"].get("commentCount") == None or response.json()["data"].get("commentCount") == 0 :
return None
else:
numlist = range(2, int(response.json()["data"]["commentCount"] / 10) + 1)
if response.meta["result_flag"] == 1 and numlist != None:
# 第一页请求,且不仅有一页评论,循环发送请求获取剩下的评论
for i in numlist:
newurl = "?sightId=" + \
response.meta["data"]["sightid"] + "&index=" + str(i) + "&page=" + str(
i) + "&pageSize=10&tagType=0"
re = Request(url=newurl, headers=header, meta={"use_selenium": False}, callback=self.parse_third)
re.meta["data"] = response.meta["data"]
if i != numlist[-1]:
re.meta["result_flag"] = i
else:
re.meta["result_flag"] = -1
yield re
elif (response.meta["result_flag"] == 1 and numlist == None) or response.meta["result_flag"] == -1:
# 仅有一页评论,结束爬虫
if response.meta["data"]["all_results"] == []:
return None
json_str = json.dumps(response.meta["data"]["all_results"], ensure_ascii=False)
qvna_item = QvnaItem()
qvna_item['Price'] = response.meta['data']['price']
qvna_item['Title'] = response.meta['data']['oldtitle']
qvna_item['Id'] = response.meta['data']['id']
qvna_item['Commentlist'] = json_str
# 打印转换后的 JSON 字符串
# print( qvna_item )
print("进入管道")
response.meta["data"]["all_results"].clear()
yield qvna_item
except Exception as e:
scrapy.Spider.logger.error(f"Error: qvna爬虫报错,{e}")
return None
上述这段代码的功能是解析第二个请求的响应,处理景点评论信息,存储获取的评论数据并组装成 QvnaItem 对象返回。
首先检查响应的状态码是否为200,若不是则记录错误并返回None
。
尝试解析评论信息并存储到数组all_results
中。
None
。如果是第一页请求且有多页评论,则循环发送请求获取剩下的评论。
re
,添加了相应的元数据信息,包括result_flag
来表示当前处理的页码。如果只有一页评论或是最后一页评论的请求,则结束爬取并返回 QvnaItem 对象。
None
。all_results
列表,并返回 QvnaItem 对象。如果在处理过程中出现异常,记录错误并返回None
。
class QvnaItem(scrapy.Item):
Commentlist = scrapy.Field()
Price = scrapy.Field()
Title = scrapy.Field()
Id = scrapy.Field()
定义了一个 Scrapy Item 类 QvnaItem
,它是用于存储爬取到的数据的容器。
在这个 Item 类中,定义了以下字段:
Commentlist
: 用于存储景点评论的信息,通常是一个 JSON 字符串。Price
: 用于存储景点的价格信息。Title
: 存储景点的标题或名称。Id
: 存储景点的唯一标识符或 ID。QvnaItem
对象,并将数据存储在这些字段中,最终被传递到 Item Pipeline 进行后续处理。class MySQLPipeline:
def __init__(self, mysql_host, mysql_port, mysql_database, mysql_user, mysql_password):
self.mysql_host = mysql_host
self.mysql_port = mysql_port
self.mysql_database = mysql_database
self.mysql_user = mysql_user
self.mysql_password = mysql_password
self.conn = None
self.cursor = None
self.data = []
@classmethod
def from_crawler(cls, crawler):
return cls(
mysql_host=crawler.settings.get('MYSQL_HOST'),
mysql_port=crawler.settings.get('MYSQL_PORT'),
mysql_database=crawler.settings.get('MYSQL_DATABASE'),
mysql_user=crawler.settings.get('MYSQL_USER'),
mysql_password=crawler.settings.get('MYSQL_PASSWORD'),
)
def open_connection(self):
# 手动开启数据库连接
# print(self.mysql_port)
# print(self.mysql_user)
# print(self.mysql_password)
# print(self.mysql_host)
# print(self.mysql_database)
self.conn = mysql.connector.connect(
host=self.mysql_host,
port=self.mysql_port,
database=self.mysql_database,
user=self.mysql_user,
password=self.mysql_password,
)
self.cursor = self.conn.cursor()
def open_spider(self, spider):
self.conn = mysql.connector.connect(
host=self.mysql_host,
port=self.mysql_port,
database=self.mysql_database,
user=self.mysql_user,
password=self.mysql_password,
)
self.cursor = self.conn.cursor()
def close_spider(self, spider):
self.conn.close()
if self.data:
sql = "INSERT INTO xiecheng_data (id,title, commentlist,averagescore,opentime,number) VALUES (%s,%s, %s,%s, %s,%s)"
self.write_data(sql=sql)
def process_item(self, item, spider):
if not self.conn or not self.cursor:
# 如果连接或游标未初始化,则手动开启数据库连接
self.open_connection()
if isinstance(item, XiechengItem):
# 在这里执行将item数据存入MySQL的操作
# 例如,假设你有一个名为 "your_table" 的表,且item中包含字段 "field1" 和 "field2"
print("执行成功")
# print(item)
sql = "INSERT INTO xiecheng_data (id,title, commentlist,averagescore,opentime,number) VALUES (%s,%s, %s,%s, %s,%s)"
values = (
item['Id'], item['Title'], item['Commentlist'], item['AverageScore'], item['OpenTime'], item['Number'])
self.cursor.execute(sql, values)
self.conn.commit()
elif isinstance(item, QvnaItem):
sql = "INSERT INTO qvna_data (id,Title, Commentlist,Price) VALUES (%s,%s,%s,%s)"
values = (
item['Id'], item['Title'], item['Commentlist'], item['Price'])
self.data.append(values)
print(len(self.data))
if len(self.data) == 5:
self.write_data(sql)
elif isinstance(item, ZhihuItem):
sql = "INSERT INTO zhihu_data (Id,Title, Commentlist) VALUES (%s,%s,%s)"
values = (
item['Id'], item['Title'], item['Commentlist'])
self.data.append(values)
print(len(self.data))
if len(self.data) == 10:
self.write_data(sql)
return item
def write_data(self, sql):
self.cursor.executemany(sql, self.data)
self.conn.commit()
self.data.clear()
print("提交完成")
这段代码是一个自定义的 Scrapy Pipeline,名为 MySQLPipeline
,它用于将爬取到的数据存储到 MySQL 数据库中。
__init__
方法用于初始化连接 MySQL 数据库所需的参数,并创建一个空列表 self.data
用于暂存待写入数据库的数据。from_crawler
类方法是一个工厂方法,用于从 Scrapy 的配置中获取数据库连接所需的参数。open_connection
方法用于手动开启数据库连接。open_spider
方法在爬虫启动时调用,用于开启数据库连接。close_spider
方法在爬虫关闭时调用,用于关闭数据库连接,并将暂存的数据写入数据库。process_item
方法用于处理爬取到的数据。根据不同的 Item 类型,将数据存入相应的数据表中。如果是 XiechengItem
,直接将数据插入到名为 xiecheng_data
的表中;如果是 QvnaItem
或 ZhihuItem
,则将数据暂存到 self.data
列表中,当列表中的数据达到一定量(5 条或 10 条)时,再进行批量写入数据库操作。