米游社數(shù)據(jù)分析實(shí)戰(zhàn) |(一)數(shù)據(jù)的獲取淤堵、解析與存儲(chǔ)

自從發(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)一步的分析叠纹。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末季研,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子誉察,更是在濱河造成了極大的恐慌与涡,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,290評(píng)論 6 491
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異驼卖,居然都是意外死亡氨肌,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,107評(píng)論 2 385
  • 文/潘曉璐 我一進(jìn)店門酌畜,熙熙樓的掌柜王于貴愁眉苦臉地迎上來怎囚,“玉大人,你說我怎么就攤上這事桥胞】沂兀” “怎么了?”我有些...
    開封第一講書人閱讀 156,872評(píng)論 0 347
  • 文/不壞的土叔 我叫張陵贩虾,是天一觀的道長催烘。 經(jīng)常有香客問我,道長缎罢,這世上最難降的妖魔是什么伊群? 我笑而不...
    開封第一講書人閱讀 56,415評(píng)論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮策精,結(jié)果婚禮上舰始,老公的妹妹穿的比我還像新娘。我一直安慰自己咽袜,他們只是感情好蔽午,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,453評(píng)論 6 385
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著酬蹋,像睡著了一般。 火紅的嫁衣襯著肌膚如雪抽莱。 梳的紋絲不亂的頭發(fā)上范抓,一...
    開封第一講書人閱讀 49,784評(píng)論 1 290
  • 那天,我揣著相機(jī)與錄音食铐,去河邊找鬼匕垫。 笑死,一個(gè)胖子當(dāng)著我的面吹牛虐呻,可吹牛的內(nèi)容都是我干的象泵。 我是一名探鬼主播,決...
    沈念sama閱讀 38,927評(píng)論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼斟叼,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼偶惠!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起朗涩,我...
    開封第一講書人閱讀 37,691評(píng)論 0 266
  • 序言:老撾萬榮一對(duì)情侶失蹤忽孽,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體兄一,經(jīng)...
    沈念sama閱讀 44,137評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡厘线,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,472評(píng)論 2 326
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了出革。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片造壮。...
    茶點(diǎn)故事閱讀 38,622評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖骂束,靈堂內(nèi)的尸體忽然破棺而出耳璧,到底是詐尸還是另有隱情,我是刑警寧澤栖雾,帶...
    沈念sama閱讀 34,289評(píng)論 4 329
  • 正文 年R本政府宣布楞抡,位于F島的核電站,受9級(jí)特大地震影響析藕,放射性物質(zhì)發(fā)生泄漏召廷。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,887評(píng)論 3 312
  • 文/蒙蒙 一账胧、第九天 我趴在偏房一處隱蔽的房頂上張望竞慢。 院中可真熱鬧,春花似錦治泥、人聲如沸筹煮。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,741評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽败潦。三九已至,卻和暖如春准脂,著一層夾襖步出監(jiān)牢的瞬間劫扒,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,977評(píng)論 1 265
  • 我被黑心中介騙來泰國打工狸膏, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留沟饥,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 46,316評(píng)論 2 360
  • 正文 我出身青樓湾戳,卻偏偏與公主長得像贤旷,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子砾脑,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,490評(píng)論 2 348

推薦閱讀更多精彩內(nèi)容