自從發(fā)布了 「原神」細(xì)節(jié)向初體驗(yàn) 這篇文章之后橘忱,粉絲朋友們紛紛感嘆“原來你也(開始)玩原神”。
不過剛開始是因?yàn)榈燃?jí)不夠规惰,后來是找不到人,再后來是在做主線泉蝌,目前我還沒和別人聯(lián)機(jī)過歇万。
入坑一個(gè)多月一來,身邊的朋友不停的向我安利米游社這個(gè) App勋陪,終于贪磺,我下載下來看了看。
不得不說诅愚,如果是重度玩家的話寒锚,這個(gè) App 確實(shí)能提升游戲體驗(yàn),不過作為資深觀景玩家违孝,大地圖是不可能用的刹前。
于是我盯上了首頁的信息流,想著身為技術(shù)人雌桑,不爬點(diǎn)數(shù)據(jù)下來有點(diǎn)對(duì)不起米哈游的 Slogan“技術(shù)宅拯救世界”喇喉,于是,開干筹燕。
一開始就選錯(cuò)了方向
米游社的內(nèi)容主要集中在在手機(jī)端轧飞,而在 App 的動(dòng)態(tài)數(shù)據(jù)獲取方面,各大廠商的實(shí)現(xiàn)方案都大差不差撒踪,無非是請(qǐng)求接口过咬、獲取數(shù)據(jù)、展示界面制妄。
于是我打開了 HttpCanary(一個(gè)安卓端網(wǎng)絡(luò)抓包工具)掸绞,一波操作之后,打開米游社 App耕捞,下滑加載內(nèi)容衔掸。
然后跳出了網(wǎng)絡(luò)連接失敗的提示。
JRT 技術(shù)驗(yàn)證時(shí)俺抽,我對(duì)簡書 App 抓包就遇到過類似的問題敞映,很明顯,這是 SSL 中間人攻擊防護(hù)磷斧。
簡單來說振愿,App 會(huì)對(duì)服務(wù)端的 SSL 證書進(jìn)行校驗(yàn)捷犹,如果不匹配,說明在這條連接中間冕末,有節(jié)點(diǎn)在篡改數(shù)據(jù)萍歉。
這種情況并非無解,但需要對(duì)設(shè)備進(jìn)行 Root档桃,將抓包工具的證書添加到信任列表枪孩,或者對(duì) App 進(jìn)行反編譯。前者費(fèi)時(shí)費(fèi)力藻肄,后者技術(shù)難度高且有法律風(fēng)險(xiǎn)蔑舞,看來這條路行不通。
在我上網(wǎng)搜索相關(guān)資料的時(shí)候仅炊,無意間發(fā)現(xiàn)米游社有網(wǎng)頁端斗幼,而且我要的信息流數(shù)據(jù)在網(wǎng)頁端同樣有展示。
技術(shù)難度一下子就下來了抚垄,只需要分析網(wǎng)絡(luò)請(qǐng)求蜕窿,然后針對(duì)性提取數(shù)據(jù)即可。
網(wǎng)絡(luò)請(qǐng)求分析
打開米游社網(wǎng)頁端(這里我們要爬的是原神區(qū)):https://bbs.mihoyo.com/ys/
F12 調(diào)出開發(fā)者工具呆馁,然后...
進(jìn)入了調(diào)試模式桐经,數(shù)據(jù)根本沒加載出來。
這是一種很常見的反爬措施浙滤,原理大概是這樣:開發(fā)者工具打開的時(shí)候阴挣,遇到 JS 代碼中的調(diào)試器(Debugger)語句就會(huì)暫停,否則跳過這段代碼繼續(xù)執(zhí)行纺腊,只需要通過某種方式不斷嘗試打開調(diào)試器(比如死循環(huán))畔咧,就可以讓我們打開開發(fā)者工具時(shí)無法正常獲取數(shù)據(jù)。
解決方法也很簡單揖膜,只需要點(diǎn)擊這個(gè)按鈕:
這個(gè)按鈕會(huì)禁用掉斷點(diǎn)調(diào)試功能誓沸,開啟之后刷新網(wǎng)頁,就可以正常獲取到數(shù)據(jù)了壹粟。
切換到網(wǎng)絡(luò)選項(xiàng)卡拜隧,篩選異步請(qǐng)求(Fetch/XHR):
向下滾動(dòng)頁面,加載新內(nèi)容趁仙,觀察請(qǐng)求面板的變化:
很明顯洪添,紅圈中的兩個(gè)請(qǐng)求是加載時(shí)發(fā)起的。
查看請(qǐng)求參數(shù)雀费,不難發(fā)現(xiàn)第二個(gè)請(qǐng)求的作用是根據(jù)帖子 ID 獲取互動(dòng)數(shù)據(jù)干奢,我們暫且放在一邊,主要關(guān)注第一個(gè)請(qǐng)求:
最近發(fā)現(xiàn)了一個(gè)很好用的網(wǎng)絡(luò)請(qǐng)求工具:Hoppscotch盏袄,我們將請(qǐng)求信息復(fù)制進(jìn)去律胀,點(diǎn)擊發(fā)送宋光。
Bingo,響應(yīng)數(shù)據(jù)出來了炭菌。看到這里逛漫,我不得不感嘆一句黑低,在游戲上米哈游算是同賽道頂尖,但在數(shù)據(jù)安全這方面酌毡,未免有些太過草率了克握。
響應(yīng)數(shù)據(jù)結(jié)構(gòu)分析
折疊具體數(shù)據(jù),只查看結(jié)構(gòu)部分:
我們來逐個(gè)分析枷踏。
retcode菩暗,猜一波是 return code(返回代碼)的縮寫,很明顯是狀態(tài)碼旭蠕,0 一般代表正常停团,類似 HTTP 狀態(tài)碼中的 200。
message掏熬,消息佑稠,是對(duì)狀態(tài)碼的描述,這里是 OK旗芬,印證了我們的猜測(cè)舌胶,一切正常。
data 里面就是我們要的數(shù)據(jù)了疮丛。
carousels幔嫂,翻譯一下是“旋轉(zhuǎn)木馬”,這個(gè)網(wǎng)頁中什么東西是旋轉(zhuǎn)的誊薄?答案是輪播圖履恩。
cover,遮罩暇屋,值是一個(gè)圖片地址似袁,訪問一下試試:
猜對(duì)了,正是首頁輪播圖咐刨。
recommended_posts昙衅,對(duì)應(yīng)帖子數(shù)據(jù):
recommended_topics,對(duì)應(yīng)推薦話題數(shù)據(jù)定鸟,位于網(wǎng)頁的右側(cè)邊欄:
fixed_posts而涉,可能是置頂帖子數(shù)據(jù),空的联予,暫且不去理會(huì)啼县。
selection_post_list材原,里面的帖子格式與 recommended_posts 不同,且沒有規(guī)律季眷,為簡化數(shù)據(jù)獲取流程余蟹,可以忽略。
我們需要的是帖子數(shù)據(jù)子刮,也就是 recommended_posts 中的內(nèi)容威酒。
帖子數(shù)據(jù)結(jié)構(gòu)分析
隨便選一條帖子數(shù)據(jù),與網(wǎng)頁上展示的內(nèi)容進(jìn)行比對(duì):
可以看到挺峡,一條帖子的數(shù)據(jù)分為十幾個(gè)部分葵孤,我們將對(duì)此一一說明。
post(帖子數(shù)據(jù))
game_id:游戲 ID橱赠,2 代表原神
post_id:帖子 ID尤仍,唯一標(biāo)識(shí)
f_forum_id:論壇 ID,唯一標(biāo)識(shí)
uid:用戶 ID狭姨,唯一標(biāo)識(shí)
subject:標(biāo)題
content:簡介
cover:題頭圖鏈接
view_type:可能和訪問方式有關(guān)宰啦,這里恒為 1
created_at:創(chuàng)建時(shí)間,UNIX 時(shí)間戳格式送挑,這里的時(shí)間為 2022 年 3 月 12 日 20:21:32
images:圖片數(shù)據(jù)绑莺,內(nèi)部是圖片鏈接
-
post_status:帖子狀態(tài):
is_top:是否被置頂
is_good:是否被加精
is_official:是否為官方帖子
topic_ids:所屬的話題 ID
view_status:可能和帖子可見性狀態(tài)有關(guān)(正常、限流惕耕、被屏蔽等)
max_floor:評(píng)論層數(shù)
is_original:是否為原創(chuàng)
republish_authorization:可能與轉(zhuǎn)載授權(quán)類型有關(guān)
reply_time:最后一次評(píng)論時(shí)間
is_deleted:是否被刪除
is_interactive:是否允許互動(dòng)
score:可能和評(píng)分有關(guān)
forum(論壇數(shù)據(jù))
id:論壇 ID纺裁,唯一標(biāo)識(shí),與 post 中的 f_forum_id 相同
name:論壇名稱
topics(話題數(shù)據(jù))
id:話題 ID司澎,唯一標(biāo)識(shí)
name:話題名稱
cover:話題題頭圖
content_type:可能和話題的類型有關(guān)
user(用戶數(shù)據(jù))
uid:用戶 ID欺缘,唯一標(biāo)識(shí),與 post 中的 uid 相同
nickname:用戶昵稱
introduce:個(gè)人簡介
avatar:可能和用戶頭像有關(guān)(米游社不能自行上傳頭像挤安,只能使用游戲中的角色圖片作為頭像谚殊,可選數(shù)量有限)
gender:性別
-
certification:認(rèn)證稱號(hào)
type:認(rèn)證稱號(hào)種類
label:認(rèn)證稱號(hào)名稱
-
level_exp:等級(jí)與經(jīng)驗(yàn)
level:等級(jí)
exp:經(jīng)驗(yàn)
avatar_url:頭像鏈接
pendant:頭像掛鏈接
stat(互動(dòng)數(shù)據(jù))
這幾項(xiàng)數(shù)據(jù)不知道為什么均為 0,我們會(huì)在后面用另一個(gè)接口補(bǔ)全這幾項(xiàng)數(shù)據(jù)蛤铜。
reply_num:評(píng)論量
view_num:閱讀量
like_num:點(diǎn)贊量
bookmark_num:收藏量
cover(題頭圖數(shù)據(jù))
url:題頭圖鏈接
height:圖片高度
width: 圖片寬度
format:圖片格式
size:圖片大心坌酢(字節(jié))
-
crop:裁剪數(shù)據(jù)
x:裁剪開始的橫向坐標(biāo)
y:裁剪開始的縱向坐標(biāo)
w:裁剪寬度
h:裁剪高度
url:加入裁剪參數(shù)的題頭圖鏈接(實(shí)際上是阿里云對(duì)象存儲(chǔ)的圖片處理功能)
is_user_set_cover:是否由用戶設(shè)置題頭圖
image_id:圖片 ID,唯一標(biāo)識(shí)
entity_type:實(shí)體類型围肥,含義未知
entity_id:實(shí)體 ID剿干,含義未知
image_list(圖片數(shù)據(jù))
各項(xiàng)數(shù)據(jù)含義與 cover 相同,不再重復(fù)說明穆刻。
其它數(shù)據(jù)
self_operation:自營置尔,含義未知
is_official_master:是否為官方管理員
is_user_master:是否為非官方管理員
-
help_sys:幫助系統(tǒng),含義未知
- top_up:含義未知
vote_count:票數(shù)氢伟,可能和論壇活動(dòng)有關(guān)
last_modify_time:最后一次更新時(shí)間(不正常數(shù)據(jù)榜轿,不應(yīng)為 0)
recommend_type:推薦類型
collection:專題數(shù)據(jù)
構(gòu)建數(shù)據(jù)庫表結(jié)構(gòu)
我們使用 Python 的 ORM 庫 Peewee 與 SQLite 數(shù)據(jù)庫進(jìn)行交互幽歼,簡化數(shù)據(jù)保存流程。
數(shù)據(jù)庫定義相關(guān)代碼存放在 db_config.py
文件中谬盐。
為了降低數(shù)據(jù)分析難度甸私,我們將采集的內(nèi)容分為幾個(gè)部分,分別建立不同的表來存儲(chǔ):
- 帖子數(shù)據(jù)
- 論壇數(shù)據(jù)
- 用戶數(shù)據(jù)
- 話題數(shù)據(jù)
- 圖片數(shù)據(jù)
- 頭像數(shù)據(jù)
- 認(rèn)證稱號(hào)數(shù)據(jù)
使用外鍵連接這些表设褐,這在 SQL 中是一個(gè)稍顯復(fù)雜的操作颠蕴,但 Peewee 幫我們抽象了這個(gè)操作,我們只需指定字段名稱助析、引用的表和這個(gè)字段在引用表中的名稱即可。
從 peewee 庫中導(dǎo)入我們使用的字段椅您,并初始化一個(gè)名為 data.db
的 SQLite 數(shù)據(jù)庫外冀。
from peewee import (BooleanField, CharField, DateTimeField, IntegerField, Model, SqliteDatabase)
db = SqliteDatabase("data.db")
首先是帖子數(shù)據(jù):
class Post(Model):
id = IntegerField(primary_key=True)
title = CharField()
summary = CharField()
content = CharField()
created_time = DateTimeField()
is_topped = BooleanField()
is_best = BooleanField()
is_official = BooleanField()
is_original = BooleanField()
is_deleted = BooleanField()
is_interactive = BooleanField()
visible_status = IntegerField()
comments_count = IntegerField()
republish_authorization = IntegerField()
last_comment_time = DateTimeField()
score = IntegerField()
views_count = IntegerField()
likes_count = IntegerField()
comments_count = IntegerField()
bookmarks_count = IntegerField()
class Meta:
database = db
table_name = "posts"
論壇數(shù)據(jù):
class Forum(Model):
post = ForeignKeyField(Post, backref="forum")
id = IntegerField()
name = CharField()
class Meta:
database = db
table_name = "forums"
用戶數(shù)據(jù):
class User(Model):
post = ForeignKeyField(Post, backref="user")
id = IntegerField()
name = CharField()
gender = IntegerField()
introduction = CharField()
pendant_url = CharField()
level = IntegerField()
exp = IntegerField()
class Meta:
database = db
table_name = "users"
話題數(shù)據(jù):
class Topic(Model):
post = ForeignKeyField(Post, backref="topics")
id = IntegerField(primary_key=True)
name = CharField()
cover_url = CharField()
content_type = IntegerField()
class Meta:
database = db
table_name = "topics"
圖片數(shù)據(jù):
class Image(Model):
post = ForeignKeyField(Post, backref="images")
id = IntegerField(primary_key=True)
url = CharField()
width = IntegerField()
height = IntegerField()
format = CharField()
size = IntegerField()
class Meta:
database = db
table_name = "images"
頭像數(shù)據(jù):
class Avatar(Model):
user = ForeignKeyField(User, backref="avatar")
id = IntegerField(primary_key=True)
url = CharField()
class Meta:
database = db
table_name = "avatars"
認(rèn)證數(shù)據(jù):
class Certification(Model):
user = ForeignKeyField(User, backref="certification")
id = IntegerField()
name = CharField()
class Meta:
database = db
table_name = "certifications"
編寫數(shù)據(jù)庫初始化函數(shù)并運(yùn)行:
def InitDB():
db.connect()
db.create_tables([Post, Forum, User, Topic, Image, Avatar, Certification])
InitDB()
程序運(yùn)行后,目錄中會(huì)多出一個(gè) data.db
文件掀泳,使用數(shù)據(jù)庫管理工具打開雪隧,表結(jié)構(gòu)正如我們所愿。
解析數(shù)據(jù)
新建 data_parse.py
文件员舵,在其中編寫我們的數(shù)據(jù)處理邏輯脑沿,以解析帖子數(shù)據(jù)為例:
def ParsePostData(json_data: Dict) -> Dict:
return {
"id": int(json_data["post"]["post_id"]),
"title": json_data["post"]["subject"],
"summary": json_data["post"]["content"],
"content": json_data["post"]["full_content"],
"created_time": datetime.fromtimestamp(json_data["post"]["created_at"]),
"is_topped": json_data["post"]["post_status"]["is_top"],
"is_best": json_data["post"]["post_status"]["is_good"],
"is_official": json_data["post"]["post_status"]["is_official"],
"is_original": json_data["post"]["is_original"],
"is_deleted": bool(json_data["post"]["is_deleted"]),
"is_interactive": json_data["post"]["is_interactive"],
"visible_status": json_data["post"]["view_status"],
"comments_count": json_data["post"]["max_floor"],
"republish_authorization": json_data["post"]["republish_authorization"],
"last_comment_time": datetime.fromisoformat(json_data["post"]["reply_time"]),
"score": json_data["post"]["score"],
"views_count": json_data["stat"]["view_num"],
"likes_count": json_data["stat"]["like_num"],
"comments_count": json_data["stat"]["reply_num"],
"bookmarks_count": json_data["stat"]["bookmark_num"]
}
這個(gè)函數(shù)接收數(shù)據(jù)字典,并以字典形式返回提取后的數(shù)據(jù)马僻。
類似的庄拇,我們可以編寫出論壇、用戶韭邓、話題措近、圖片、用戶頭像女淑、用戶認(rèn)證這幾類數(shù)據(jù)的解析函數(shù)瞭郑。
使用一個(gè)函數(shù)對(duì)完整的數(shù)據(jù)字典進(jìn)行解析:
def ParseData(json_data: Dict) -> Dict:
return {
"post_data": ParsePostData(json_data),
"forum_data": ParseForumData(json_data),
"user_data": ParseUserData(json_data),
"topics_data": ParseTopicsData(json_data),
"images_data": ParseImagesData(json_data),
"user_avatar_data": ParseUserAvatarData(json_data),
"user_certification_data": ParseUserCertificationData(json_data)
}
由于發(fā)起一次網(wǎng)絡(luò)請(qǐng)求時(shí),會(huì)獲取到一組數(shù)據(jù)鸭你,為了簡化主邏輯屈张,我們編寫一個(gè)解析數(shù)據(jù)列表的函數(shù):
def ParseDataList(data_list: List[Dict]) -> List[Dict]:
return [ParseData(data) for data in data_list]
到現(xiàn)在,我們的程序已經(jīng)可以實(shí)現(xiàn)數(shù)據(jù)的獲取與解析了袱巨。
數(shù)據(jù)預(yù)處理
由于信息流接口獲取到的數(shù)據(jù)存在一些問題阁谆,比如互動(dòng)數(shù)據(jù)異常,缺少帖子完整內(nèi)容等瓣窄,我們需要對(duì)請(qǐng)求到的數(shù)據(jù)進(jìn)行一些處理笛厦,替換掉錯(cuò)誤的數(shù)據(jù),加入我們希望獲取的數(shù)據(jù)俺夕。
替換互動(dòng)數(shù)據(jù)
首先裳凸,在 data_fetch.py
中增加一個(gè)數(shù)據(jù)獲取函數(shù):
def GetInteractiveData(post_ids: List[str]) -> Dict[int, Dict[str, int]]:
url = "https://bbs-api.mihoyo.com/post/wapi/getDynamicData"
params = {
"gids": 2,
"post_ids": ",".join(post_ids)
}
response = httpx_get(url, params=params)
response.raise_for_status()
data = response.text
try:
data = ujson_loads(data)
except ValueError:
raise ValueError("解析互動(dòng)數(shù)據(jù) Json 時(shí)出現(xiàn)異常")
if data["retcode"] != 0:
raise ValueError(f"獲取互動(dòng)數(shù)據(jù)時(shí)出現(xiàn)異常贱鄙,錯(cuò)誤碼:{data['retcode']},錯(cuò)誤信息:{data['message']}")
result = {}
for item in data["data"]["list"]:
result[item["post_id"]] = item["stat"]
return result
這個(gè)接口支持一次獲取一組動(dòng)態(tài)數(shù)據(jù)姨谷,只需要將對(duì)應(yīng)帖子的 post_id
以英文逗號(hào)分隔的格式作為接口的 post_ids
參數(shù)即可逗宁。
為了確保函數(shù)封裝良好,我們將函數(shù)的參數(shù)指定為由字符串形式的 post_id
組成的列表梦湘。(因?yàn)樾畔⒘鹘涌诜祷氐?post_id
是字符串類型)
接下來瞎颗,在 data_process.py
文件中編寫一個(gè)函數(shù),將傳入的 data
列表中的所有互動(dòng)數(shù)據(jù)替換成對(duì)應(yīng)的正確數(shù)據(jù):
def ReplaceInteractiveData(data: List[Dict]) -> List[Dict]:
post_ids = (item["post"]["post_id"] for item in data)
interactive_data = GetInteractiveData(post_ids)
result = []
for item in data:
item["stat"] = interactive_data[item["post"]["post_id"]]
result.append(item)
return result
加入帖子完整內(nèi)容數(shù)據(jù)
同樣的捌议,在 data_fetch.py
中定義一個(gè)函數(shù):
def GetPostContent(post_id: int) -> str:
url = "https://bbs-api.mihoyo.com/post/wapi/getPostFull"
params = {
"gids": 2,
"post_id": post_id
}
headers = {
"Referer": f"https://bbs.mihoyo.com/ys/article/{post_id}"
}
response = httpx_get(url, params=params, headers=headers)
response.raise_for_status()
data = response.text
try:
data = ujson_loads(data)
except ValueError:
raise ValueError("解析帖子全文 Json 時(shí)出現(xiàn)異常")
if data["retcode"] != 0:
raise ValueError(f"獲取帖子全文時(shí)出現(xiàn)異常哼拔,錯(cuò)誤碼:{data['retcode']},錯(cuò)誤信息:{data['message']}")
return data["data"]["post"]["post"]["content"]
這里我們遇到了一些問題瓣颅,調(diào)試這個(gè)接口的時(shí)候會(huì)出現(xiàn) 403 錯(cuò)誤倦逐,我們將常見反爬措施會(huì)校驗(yàn)的內(nèi)容(Cookie / UA / Referer 等)全部復(fù)制到請(qǐng)求工具中,再次請(qǐng)求發(fā)現(xiàn)數(shù)據(jù)正常返回宫补。
之后我們逐一刪除這些內(nèi)容檬姥,最后發(fā)現(xiàn),添加 Referer 即可規(guī)避這一反爬措施粉怕。
同樣的健民,編寫一個(gè)函數(shù)對(duì)原先的 data
列表進(jìn)行替換:
def AddFullContentData(data: List[Dict]) -> List[Dict]:
post_ids = (item["post"]["post_id"] for item in data)
contents_list = Parallel(n_jobs=10)(delayed(GetPostContent)(post_id) for post_id in post_ids)
result = []
for index, item in enumerate(data):
item["post"]["full_content"] = contents_list[index]
result.append(item)
return result
由于這個(gè)接口一次只能獲取一條數(shù)據(jù),我們需要使用多線程來提高程序的運(yùn)行效率贫贝。
過早的優(yōu)化是萬惡之源秉犹,如果不能確定這個(gè)函數(shù)會(huì)拖慢程序的運(yùn)行速度,可以先使用單線程請(qǐng)求平酿,后期通過性能分析找到瓶頸凤优,再針對(duì)性優(yōu)化。
這里使用的是 Python 的內(nèi)置庫 joblib
蜈彼,上述代碼將使用 10 個(gè)線程對(duì) GetPostContent
函數(shù)發(fā)起調(diào)用筑辨,并將結(jié)果以正確的順序存入 contents_list
中。
之后我們通過 for 循環(huán)幸逆,將每條帖子對(duì)應(yīng)的 content
插入到其數(shù)據(jù)的 post
項(xiàng)中棍辕。
數(shù)據(jù)存儲(chǔ)
由于我們?cè)跀?shù)據(jù)解析過程中,就將解析結(jié)果字典的鍵與數(shù)據(jù)庫的字段進(jìn)行了一一對(duì)應(yīng)还绘,所以我們可以直接使用字典解包楚昭,將鍵值對(duì)變?yōu)閰?shù),傳入 Peewee 的 create 函數(shù)中拍顷,從而快速實(shí)現(xiàn)數(shù)據(jù)的存儲(chǔ)抚太。
示例代碼如下:
def SavePostData(post_data: Dict) -> Post:
return Post.create(**post_data)
但在對(duì)論壇數(shù)據(jù)進(jìn)行保存時(shí),我們遇到了一個(gè)問題:將要被保存的數(shù)據(jù)可能已經(jīng)存在于數(shù)據(jù)庫中,這樣會(huì)因?yàn)橹麈I重復(fù)而產(chǎn)生異常尿贫。
因此电媳,我們需要對(duì)主鍵重復(fù)產(chǎn)生的 IntegrityError
進(jìn)行捕獲,并將其忽略:
def SaveForumData(forum_data: Dict, post_obj: Post) -> None:
try:
Forum.create(post=post_obj, **forum_data)
except IntegrityError:
pass
類似的庆亡,我們可以編寫出其它類型數(shù)據(jù)的存儲(chǔ)函數(shù)匾乓。
最后,用一個(gè)函數(shù)將它們聚合起來:
def SaveData(data: Dict):
with db.atomic(): # 開啟事務(wù)
post_obj = SavePostData(data["post_data"])
if data["forum_data"]: # 如果板塊數(shù)據(jù)不為空
SaveForumData(data["forum_data"], post_obj)
user_obj = SaveUserData(data["user_data"], post_obj)
SaveTopicsData(data["topics_data"], post_obj)
SaveImagesData(data["images_data"], post_obj)
SaveAvatarData(data["user_avatar_data"], user_obj)
SaveCertificationData(data["user_certification_data"], user_obj)
這里我們使用了數(shù)據(jù)庫的事務(wù)功能又谋,在事務(wù)中的數(shù)據(jù)庫操作拼缝,只可能全部成功或者全部失敗。
這樣做由兩個(gè)原因彰亥,其一咧七,可以防止程序出錯(cuò)時(shí)對(duì)數(shù)據(jù)庫造成污染;其二任斋,通過減少提交操作(Commit)猪叙,可以提高數(shù)據(jù)存儲(chǔ)的性能。
和 data_parse.py
中一樣仁卷,我們可以對(duì)一組數(shù)據(jù)進(jìn)行保存:
def SaveDataList(data_list: List[Dict]):
for data in data_list:
try:
SaveData(data)
except IntegrityError: # 主鍵重復(fù)
print(f"帖子 {data['post_data']['id']} 出現(xiàn)重復(fù),已自動(dòng)跳過")
這個(gè)函數(shù)考慮到了數(shù)據(jù)在獲取過程中產(chǎn)生變動(dòng)犬第,從而導(dǎo)致主鍵重復(fù)時(shí)的處理锦积。
主邏輯
導(dǎo)入庫部分省略。
我們先來定義一些常量:
TOTAL_DATA_COUNT = 30000
DATA_COUNT_PER_PAGE = 30
SLEEP_TIME = 0
ERROR_SLEEP_TIME = 20
DATA_SAVE_INTERVAL = 20
這樣做可以幫助我們快速修改運(yùn)行參數(shù)歉嗓,而不需要對(duì)使用到這些參數(shù)的每一個(gè)位置進(jìn)行改動(dòng)丰介。
如果可調(diào)整的參數(shù)超過 10 個(gè),或者這個(gè)爬蟲需要定期運(yùn)行鉴分,最好使用配置文件(比如 YAML)進(jìn)行管理哮幢。
由于數(shù)據(jù)保存需要消耗較長時(shí)間,我們將其抽離成獨(dú)立的一個(gè)線程志珍,因此橙垢,需要定義一個(gè)列表,用來存放待保存的數(shù)據(jù):
data_to_save_list: List[Dict] = []
data_to_save_list_lock = Lock()
接下來是數(shù)據(jù)保存線程:
def DataSaveJob():
while True:
if data_to_save_list:
with data_to_save_list_lock:
start_time = time()
SaveDataList(data_to_save_list)
print(f"已成功保存 {len(data_to_save_list)} 條數(shù)據(jù)伦糯,耗時(shí) {round(time() - start_time, 2)} 秒")
data_to_save_list.clear()
sleep(DATA_SAVE_INTERVAL)
由于數(shù)據(jù)的獲取柜某、保存到待保存列表的清空需要一定時(shí)間,在此期間敛纲,如果有新數(shù)據(jù)加入列表喂击,就會(huì)導(dǎo)致未保存數(shù)據(jù)的丟失,因此淤翔,我們需要在數(shù)據(jù)保存期間對(duì)數(shù)據(jù)進(jìn)行加鎖翰绊。
接下來,我們對(duì)需要采集的頁數(shù)進(jìn)行計(jì)算,并對(duì)必要的數(shù)據(jù)進(jìn)行校驗(yàn)监嗜,最后初始化數(shù)據(jù)庫谐檀。
然后啟動(dòng)數(shù)據(jù)保存線程:
data_save_thread = Thread(target=DataSaveJob, daemon=True) # 設(shè)置為守護(hù)線程,避免影響主線程退出
data_save_thread.start()
print("數(shù)據(jù)獲取線程啟動(dòng)成功...")
核心采集邏輯如下:
data = GetMainData(page, DATA_COUNT_PER_PAGE)
data = ReplaceInteractiveData(data)
data = AddFullContentData(data)
data = ParseDataList(data)
這里省略了關(guān)于出錯(cuò)重試的邏輯秤茅。
然后將數(shù)據(jù)加入待保存列表:
with data_to_save_list_lock:
data_to_save_list.extend(data)
在數(shù)據(jù)全部采集完畢后稚补,我們需要等待保存線程將它們?nèi)看嫒霐?shù)據(jù)庫:
print("等待數(shù)據(jù)存儲(chǔ)完成...")
while True:
if not data_to_save_list:
with data_to_save_list_lock: # 獲取到鎖則證明全部數(shù)據(jù)已保存完畢
print("數(shù)據(jù)存儲(chǔ)完成...")
exit()
else:
sleep(1)
運(yùn)行程序,輸出如下:
總數(shù)據(jù)量:30000 單頁數(shù)據(jù)個(gè)數(shù):30 總頁數(shù):1000
等待時(shí)間:0s 錯(cuò)誤重試間隔:20s 數(shù)據(jù)保存間隔:20s
初始化數(shù)據(jù)庫成功...
數(shù)據(jù)獲取線程啟動(dòng)成功...
開始采集數(shù)據(jù)...
開始采集第 1 頁
第 1 頁采集成功框喳,耗時(shí) 4.39 秒
開始采集第 2 頁
第 2 頁采集成功课幕,耗時(shí) 2.67 秒
開始采集第 3 頁
第 3 頁采集成功,耗時(shí) 2.57 秒
開始采集第 4 頁
第 4 頁采集成功五垮,耗時(shí) 2.42 秒
開始采集第 5 頁
第 5 頁采集成功乍惊,耗時(shí) 2.48 秒
開始采集第 6 頁
第 6 頁采集成功,耗時(shí) 2.07 秒
開始采集第 7 頁
第 7 頁采集成功放仗,耗時(shí) 2.16 秒
開始采集第 8 頁
已成功保存 210 條數(shù)據(jù)润绎,耗時(shí) 3.91 秒
第 8 頁采集成功,耗時(shí) 5.24 秒
(以下省略)
采集結(jié)果
本以為數(shù)據(jù)量至少在十萬量級(jí)诞挨,沒想到爬到五百多頁就開始連續(xù)報(bào)錯(cuò)莉撇,手動(dòng)請(qǐng)求接口發(fā)現(xiàn)已經(jīng)沒有新的數(shù)據(jù)返回,排除掉反爬原因后惶傻,可以確定是數(shù)據(jù)已經(jīng)被爬取完成棍郎。
我采集數(shù)據(jù)的時(shí)間是 2022 年 3 月 27 日,data.db
文件的大小為 71.6MB银室,總數(shù)據(jù)量 14997 條涂佃。
后記
本文中,我們對(duì)米游社的動(dòng)態(tài)加載請(qǐng)求進(jìn)行了分析蜈敢,設(shè)計(jì)了保存這些數(shù)據(jù)的數(shù)據(jù)庫結(jié)構(gòu)辜荠,通過自動(dòng)化請(qǐng)求接口實(shí)現(xiàn)了數(shù)據(jù)的獲取、解析與保存抓狭。
感興趣的小伙伴可以自行探索以下內(nèi)容:
- 將本文中的數(shù)據(jù)獲取相關(guān)代碼改寫為異步形式伯病,提升網(wǎng)絡(luò)請(qǐng)求性能
- 優(yōu)化數(shù)據(jù)庫操作性能
- 使用裝飾器實(shí)現(xiàn)數(shù)據(jù)預(yù)處理
- 使用
addict
庫改寫數(shù)據(jù)解析邏輯,增強(qiáng)可讀性 - 使用
rich
庫輸出不同顏色的終端信息
文中的程序會(huì)在本系列完結(jié)后開源辐宾,屆時(shí)倉庫地址將更新在此處狱从。
在接下來的文章中,我們將對(duì)這些數(shù)據(jù)進(jìn)行進(jìn)一步的分析叠纹。