利用Python來爬取“吃雞”數據,為什麽別人能吃雞?
背景
最近老板愛上了吃雞(手遊:全軍出擊),經常拉著我們開黑,只能放棄午休的時間,陪老板在沙漠裏奔波。 上周在在微信遊戲頻道看戰績的時候突發奇想,是不是可以通過這個方式抓取到很多戰鬥數據,然後分析看看有什麽規律。
秀一波戰績,開黑情況下我們團隊吃雞率非常高,近100場吃雞次數51次
簡單評估了一下,覺得可行,咱就開始。
Step 1 分析數據接口
第一步當然是把這些戰績數據采集下來,首先我們需要了解頁面背後的故事。去看看頁面是如何獲取戰鬥數據的。
使用Charles抓包
抓包實現
在Mac下推薦使用工具Charles來從協議層抓取手機上的流量,原理就是在Mac上開啟一個代理服務器,然後將手機的網絡代理設置為Mac,這樣手機上的所有流量都會經過我們的代理*服務器了。 大致流程如下:
https加密流量的處理
在實際操作的時候發現微信所有的流量都走了HTTPS,導致我們的抓到的都是加密數據,對我們沒有任何參考意義。 經過研究,可以通過在手機和電腦都安裝Charles根證書的方式來實現對Https流量的分析,具體操作可以參考:
charles mac下https抓包和iphone https抓包
解決Charles無法正常抓包iOS 11中的Https請求
安裝證書後,我們的流量大致是這樣子的
經過上述的配置,我們已經可以讀取到https的請求和響應數據了,如下圖所示。
windows下用findler可以實現相同的功能
其實這就是一個非常典型的中間人場景
數據接口
接下來就根據這些數據來找出我們需要的接口了,經過分析,主要涉及三個接口
獲取用戶信息接口
獲取用戶戰績列表接口
獲取用戶指定戰績詳細信息接口
下面我們一個一個看
- 獲取用戶信息接口
request
API/cgi-bin/gamewap/getpubgmdatacenterindex方法GET參數openid、pass_ticketcookiekey pass_ticket、uin、pgv_pvid、sd_cookie_crttime、sd_userid
response
{ "user_info": { "openid": "oODfo0pjBQkcNuR4XLTQ321xFVws", "head_img_url":"http://wx.qlogo.cn/mmhead/Q3auHgzwzM5hSWxxxxxUQPwW9ibxxxx9DlxLTsKWk97oWpDI0rg/96", "nick_name": "望", "role_name": "xxxx", "zone_area_id": 0, "plat_id": 1 }, "battle_info": { "total_1": 75, "total_10": 336, "total_game": 745, "total_kill": 1669 }, "battle_list": [{ "map_id": 1, "room_id": "6575389198189071197", "team_id": 57, "dt_event_time": 1530953799, "rank_in_ds": 3, "times_kill": 1, "label": "前五", "team_type": 1, "award_gold": 677, "mode": 0 }], "appitem": { "AppID": "wx13051697527efc45", "IconURL":"https://mmocgame.qpic.cn/wechatgame/mEMdfrX5RU0dZFfNEdCsMJpfsof1HE0TP3cfZiboX0ZPxqh5aZnHjxPFXUGgsXmibe/0", "Name": "絕地求生 全軍出擊", "BriefName": "絕地求生 全軍出擊", "Desc": "官方正版絕地求生手遊", "Brief": "槍戰 | 808.2M", "WebURL": "https://game.weixin.qq.com/cgi-bin/h5/static/detail_v2/index.html?wechat_pkgid=detail_v2&appid=wx13051697527efc45&show_bubble=0", "DownloadInfo": { "DownloadURL": "https://itunes.apple.com/cn/app/id1304987143", "DownloadFlag": 5 }, "Status": 0, "AppInfoFlag": 45, "Label": [], "AppStorePopUpDialogConfig": { "Duration": 1500, "Interval": 172800, "ServerTimestamp": 1531066098 }, "HasEnabledChatGroup": false, "AppType": 0, "game_tag_list": ["絕地求生", "正版還原", "好友開黑", "百人對戰", "超大地圖"], "recommend_reason": "正版絕地求生,荒野射擊", "size_desc": "808.2M" }, "is_guest": true, "is_blocked": false, "errcode": 0, "errmsg": "ok"}
openid是用戶的惟一標識。
- 獲取用戶戰績列表接口
request
API/cgi-bin/gamewap/getpubgmbattlelist方法GET參數openid、pass_ticket、plat_id、after_time、limitcookiekey pass_ticket、uin、pgv_pvid、sd_cookie_crttime、sd_userid
response
{"errcode": 0,"errmsg": "ok","next_after_time": 1528120556,"battle_list": [{ "map_id": 1, "room_id": "6575389198111172597", "team_id": 57, "dt_event_time": 1530953799, "rank_in_ds": 3, "times_kill": 1, "label": "前五", "team_type": 1, "award_gold": 677, "mode": 0}, { "map_id": 1, "room_id": "6575336498940384115", "team_id": 11, "dt_event_time": 1530941404, "rank_in_ds": 5, "times_kill": 2, "label": "前五", "team_type": 1, "award_gold": 632, "mode": 0}],"has_next": true}
分析
這個接口用after_time來進行分頁,遍歷獲取時可以根據接口響應的has_next和next_after_time來判斷是否還有下一頁的數據。
列表裏面的room_id是每一場battle的惟一標識。
- 獲取用戶戰績詳情接口
request
API/cgi-bin/gamewap/getpubgmbattledetail方法GET參數openid、pass_ticket、room_idcookiekey pass_ticket、uin、pgv_pvid、sd_cookie_crttime、sd_userid
request
{"errcode": 0,"errmsg": "ok","base_info": { "nick_name": "柚茶", "head_img_url": "http://wx.qlogo.cn/mmhead/xxxx/96", "dt_event_time": 1528648165, "team_type": 4, "rank": 1, "player_count": 100, "role_sex": 1, "label": "大吉大利", "openid": "oODfo0s1w5lWjmxxxxxgQkcCljXQ"},"battle_info": { "award_gold": 622, "times_kill": 6, "times_head_shot": 0, "damage": 537, "times_assist": 3, "survival_duration": 1629, "times_save": 0, "times_reborn": 0, "vehicle_kill": 1, "forward_distance": 10140, "driving_distance": 5934, "dead_poison_circle_no": 6, "top_kill_distance": 223, "top_kill_distance_weapon_use": 2924130819, "be_kill_user": { "nick_name": "小旭", "head_img_url":"http://wx.qlogo.cn/mmhead/ibLButGMnqJNFsUtStNEV8tzlH1QpwPiaF9kxxxxx66G3ibjic6Ng2Rcg/96", "weapon_use": 20101000001, "openid": "oODfo0qrPLExxxxc0QKjFPnPxyI" }, "label": "大吉大利"},"team_info": { "user_list": [{ "nick_name": "ooo", "times_kill": 6, "assist_count": 3, "survival_duration": 1638, "award_gold": 632, "head_img_url":"http://wx.qlogo.cn/mmhead/Q3auHgzwzM4k4RXdyxavNxxxxUjcX6Tl47MNNV1dZDliazRKRg", "openid": "oODfo0xxxxf1bRAXE-q-lEezK0k" }, { "nick_name": "我吃炒肉", "times_kill": 2, "assist_count": 2, "survival_duration": 1502, "award_gold": 583, "head_img_url":"http://wx.qlogo.cn/mmhead/sTJptKvBQLKd5SAAjOF0VrwiapUxxxxFffxoDUcrVjYbDf9pNENQ", "openid": "oODfo0gIyDxxxxZpUrSrpapZSDT0" }]},"is_guest": true,"is_blocked": false}
分析
這個接口響應了戰鬥的詳細信息,包括殺敵數、爆頭數、救人數、跑動距離等等,足夠我們分析了。
這個接口還響應了是被誰殺死的以及組團成員的openid,利用這個特性我們這可無限深度的發散爬取更多用戶的數據。
至於cookie中的息pass_ticket等信息肯定是用於權限認證的,在上述的幾次請求中這些信息都沒有變化,所以我們不需要深研其是怎麽算出來的,只需要抓包提取到默認信息後填到代碼裏面就可以用了。
Step 2 爬取數據
接口已經確定下來了,接下來就是去抓取足夠量的數據了。
使用requests請求接口獲取數據
url = ‘https://game.weixin.qq.com/cgi-bin/gamewap/getpubgmdatacenterindex?openid=%s&plat_id=0&uin=&key=&pass_ticket=%s‘ % (openid, settings.pass_ticket) r = requests.get(url=url, cookies=settings.def_cookies, headers=settings.def_headers, timeout=(5.0,5.0)) tmp = r.json() wfile = os.path.join(settings.Res_UserInfo_Dir, ‘%s.txt‘ % (rediskeys.user(openid))) with codecs.open(wfile, ‘w‘, ‘utf-8‘) as wf: wf.write(simplejson.dumps(tmp, indent=2, sort_keys=True, ensure_ascii=False))
參照這種方式我們可以很快把另外兩個接口寫好。
使用redis來標記已經爬取過的信息
在上述接口中我們可能從用戶A的入口進去找到用戶B的openid,然後從用戶B的入口進去又找到用戶A的openid,為了避免重復采集,所以我們需要記錄下哪些信息是我們采集過的。 核心代碼片斷:
rediskeys.user_battle_list 根據openid獲取存在redis中的key值def user_battlelist(openid): return ‘ubl%s‘ % (openid)# 在提取battle list之前,首先判斷這用用戶的數據是否已經提取過了if settings.DataRedis.get(rediskeys.user_battle_list(openid)): return True# 在提取battle list之後,需要在redis中記錄用戶信息settings.DataRedis.set(rediskeys.user_battle_list(openid), 1)
使用celery來管理隊列
celery是一個非常好用的分布式隊列管理工具,我這次只打算在我自己的電腦上運行,所以並沒有用到分布式的功能。 我們創建三個task和三個queue
task_queues = ( Queue(‘queue_get_battle_info‘, exchange=Exchange(‘priority‘, type=‘direct‘), routing_key=‘gbi‘), Queue(‘queue_get_battle_list‘, exchange=Exchange(‘priority‘, type=‘direct‘), routing_key=‘gbl‘), Queue(‘queue_get_user_info‘, exchange=Exchange(‘priority‘, type=‘direct‘), routing_key=‘gui‘),) task_routes = ([ (‘get_battle_info‘, {‘queue‘: ‘queue_get_battle_info‘}), (‘get_battle_list‘, {‘queue‘: ‘queue_get_battle_list‘}), (‘get_user_info‘, {‘queue‘: ‘queue_get_user_info‘}),],)
然後在task中控制API請求和Redis數據實現完整的任務邏輯,如:
@app.task(name=‘get_battle_list‘)def get_battle_list(openid, plat_id=None, after_time=0, update_time=None): # 判斷是否已經取過用戶戰績列表信息 if settings.DataRedis.get(rediskeys.user_battle_list(openid)): return True if not plat_id: try: # 提取用戶信息 us = handles.get_user_info_handles(openid) plat_id=us[‘plat_id‘] except Exception as e: print ‘can not get user plat_id‘, openid, traceback.format_exc() return False # 提取戰績列表 battle_list = handles.get_battle_list_handle(openid, plat_id, after_time=0, update_time=None) # 為每一場戰鬥創建異步獲取詳情任務 for room_id in battle_list: if not settings.DataRedis.get(rediskeys.user_battle(openid, room_id)): get_battle_info.delay(openid, plat_id, room_id) return True
開始抓取
因為我們是發散是爬蟲,所以需要給代碼一個用戶的入口,所以需要手動創建一個用戶的采集任務
from tasks.all import get_battle_list my_openid = ‘oODfo0oIErZI2xxx9xPlVyQbRPgY‘my_platid = ‘0‘ get_battle_list.delay(my_openid, my_platid, after_time=0, update_time=None)
有入口之後我們就用celery來啟動worker去開始爬蟲
啟動獲取用戶詳情workercelery -A tasks.all worker -c 5 --queue=queue_get_user_info --loglevel=info -n get_user_info@%h # 啟動獲取戰績列表workercelery -A tasks.all worker -c 5 --queue=queue_get_battle_list --loglevel=info -n get_battle_list@%h # 啟動獲取戰績詳情workercelery -A tasks.all worker -c 30 --queue=queue_get_battle_info --loglevel=info -n get_battle_info@%h
這樣我們的爬蟲就可以愉快的跑起來了。再通過celery-flower來查看執行情況。
celery flower -A tasks.all --broker=redis://:
REDIS_HOST:$REDIS_PORT/10
通過flower,我們可以看到運行的效率還是非常不錯的。
在執行過程中會發現get_battle_list跑太快,導致get_battle_info即使開了30個並發都還會積壓很多,所以需要適時的去停一下這些worker。 在我們抓到20萬條信息之後就可以停下來了。
Step 3 數據分析
分析方案
20萬場戰鬥的數據已經抓取好了,全部分成json文件存在我本地磁盤上,接下來就做一些簡單的分析。 python在數據分析領域也非常強大,有很多非常優秀的庫,如pandas和NumPy,可惜我都沒有學過,而且對於一個高考數學只考了70幾分的人來說,數據分析實在是難,所以就自己寫了一個非常簡單的程序來做一些淺度分析。 需要進行深度分析,又不想自己爬蟲的大牛可以聯系我打包這些數據。
coding=utf-8import osimport jsonimport datetimeimport math from conf import settings class UserTeamTypeData: def init(self, team_type, player_count): self.team_type = team_type self.player_count = player_count self.label = {} self.dead_poison_circle_no = {} self.count = 0 self.damage = 0 self.survival_duration = 0 # 生存時間 self.driving_distance = 0 self.forward_distance = 0 self.times_assist = 0 # 助攻 self.times_head_shot = 0 self.times_kill = 0 self.times_reborn = 0 # 被救次數 self.times_save = 0 # 救人次數self.top_kill_distance = [] self.top_kill_distance_weapon_use = {} self.vehicle_kill = 0 # 車輛殺死 self.award_gold = 0 self.times_reborn_by_role_sex = {0: 0, 1: 0} # 0 男 1 女 self.times_save_by_role_sex = {0: 0, 1: 0} # 0 男 1 女 def update_dead_poison_circle_no(self, dead_poison_circle_no): if dead_poison_circle_no in self.dead_poison_circle_no: self.dead_poison_circle_no[dead_poison_circle_no] += 1 else: self.dead_poison_circle_no[dead_poison_circle_no] = 1 def update_times_reborn_and_save_by_role_sex(self, role, times_reborn, times_save): if role not in self.times_reborn_by_role_sex: return self.times_reborn_by_role_sex[role] += times_reborn self.times_save_by_role_sex[role] += times_save def update_top_kill_distance_weapon_use(self, weaponid): if weaponid not inself.top_kill_distance_weapon_use: self.top_kill_distance_weapon_use[weaponid] = 1 else: self.top_kill_distance_weapon_use[weaponid] += 1 class UserBattleData: def init(self, openid): self.openid = openid self.team_type_res = {} self.label = {} self.hour_counter = {} self.weekday_counter = {} self.usetime = 0 self.day_record = set() self.battle_counter = 0 def get_avg_use_time_per_day(self): # print "get_avg_use_time_per_day:", self.openid, self.usetime, len(self.day_record), self.usetime / len(self.day_record) return self.usetime / len(self.day_record) def update_label(self, lable): if lable in self.label: self.label[lable] += 1 else: self.label[lable] = 1 def get_team_type_data(self, team_type, player_count): player_count = int(math.ceil(float(player_count) / 10)) team_typekey = ‘%d%d‘ % (team_type, player_count) if team_type_key not in self.team_type_res: userteamtypedata = UserTeamTypeData(team_type, player_count) self.team_type_res[team_type_key] = userteamtypedata else: userteamtypedata = self.team_type_res[team_type_key] return userteamtypedata def update_user_time_property(self, dt_event_time): dt_event_time = datetime.datetime.fromtimestamp(dt_event_time) hour = dt_event_time.hour if hour in self.hour_counter: self.hour_counter[hour] += 1 else: self.hour_counter[hour] = 1 weekday = dt_event_time.weekday() if weekday inself.weekday_counter: self.weekday_counter[weekday] += 1 else: self.weekday_counter[weekday] = 1 self.day_record.add(dt_event_time.date()) def update_battle_info_by_room(self, roomid): # print ‘ load ‘, self.openid, roomid file = os.path.join(settings.Res_UserBattleInfo_Dir, self.openid, ‘%s.txt‘ % roomid)with open(file, ‘r‘) as rf: battledata = json.load(rf) self.battle_counter += 1 base_info = battledata[‘base_info‘] self.update_user_time_property(base_info[‘dt_event_time‘]) battle_info = battledata[‘battle_info‘] userteamtypedata = self.get_team_type_data(base_info[‘team_type‘], base_info[‘player_count‘]) userteamtypedata.count += 1 userteamtypedata.award_gold += battle_info[‘award_gold‘] userteamtypedata.damage += battle_info[‘damage‘] userteamtypedata.update_dead_poison_circle_no(battle_info[‘dead_poison_circle_no‘]) userteamtypedata.driving_distance += battle_info[‘driving_distance‘] userteamtypedata.forward_distance += battle_info[‘forward_distance‘] self.update_label(battle_info[‘label‘]) userteamtypedata.survival_duration += battle_info[‘survival_duration‘] self.usetime += battle_info[‘survival_duration‘]/60 userteamtypedata.times_assist += battle_info[‘times_assist‘] userteamtypedata.times_head_shot += battle_info[‘times_head_shot‘] userteamtypedata.times_kill += battle_info[‘times_kill‘] userteamtypedata.times_reborn += battle_info[‘times_reborn‘] userteamtypedata.times_save += battle_info[‘times_save‘] userteamtypedata.damage += battle_info[‘damage‘] userteamtypedata.top_kill_distance.append(battle_info[‘top_kill_distance‘]) userteamtypedata.update_times_reborn_and_save_by_role_sex(base_info[‘role_sex‘],battle_info[‘times_reborn‘], battle_info[‘times_save‘]) def get_user_battleinfo_rooms(self): user_dir = os.path.join(settings.Res_UserBattleInfo_Dir, self.openid) r = [room for room in os.listdir(user_dir)] r = [rr.replace(‘.txt‘, ‘‘) for rr in r] return r class AllUserCounter:def init(self): self.hour_counter = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0, 13: 0, 14: 0,15: 0, 16: 0, 17: 0, 18: 0, 19: 0, 20: 0, 21: 0, 22: 0, 23: 0} self.weekday_counter = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0} self.times_reborn_by_role_sex = {0: 0, 1: 0} # 0 男 1 女 self.times_save_by_role_sex = {0: 0, 1: 0} # 0 男 1 女 self.user_count = 0 self.battle_count = 0 self.every_user_use_time_per_day = [] self.top_kill_distance = 0 def avg_use_time(self): return sum(self.every_user_use_time_per_day) / len(self.every_user_use_time_per_day) def add_user_data(self, userbattledata): self.every_user_use_time_per_day.append(userbattledata.get_avg_use_time_per_day()) self.battle_count += userbattledata.battle_counter self.user_count += 1 for k in userbattledata.hour_counter: if k in self.hour_counter: self.hour_counter[k] += userbattledata.hour_counter[k] else: self.hour_counter[k] = userbattledata.hour_counter[k] for weekday in userbattledata.weekday_counter: if weekday in self.weekday_counter: self.weekday_counter[weekday] += userbattledata.weekday_counter[weekday] else: self.weekday_counter[weekday] = userbattledata.weekday_counter[weekday] for userteamtype in userbattledata.team_type_res: userteamtypedata = userbattledata.team_type_res[userteamtype] for k in userteamtypedata.times_reborn_by_role_sex: self.times_reborn_by_role_sex[k] += userteamtypedata.times_reborn_by_role_sex[k] for k in userteamtypedata.times_save_by_role_sex: self.times_save_by_role_sex[k] += userteamtypedata.times_save_by_role_sex[k] if userteamtypedata.top_kill_distance > self.top_kill_distance: self.top_kill_distance = userteamtypedata.top_kill_distancedef str(self): res = [] res.append(‘總用戶數 %d‘ % self.user_count) res.append(‘總戰鬥數 %d‘ % self.battle_count) res.append(‘平均日耗時 %d‘ % self.avg_use_time()) res.append(‘最遠擊殺 %d‘ % max(self.top_kill_distance)) res.append(‘男性角色 被救%d次 救人%d次‘ % (self.times_reborn_by_role_sex[0],self.times_save_by_role_sex[0])) res.append(‘女性角色 被救%d次 救人%d次‘ % (self.times_reborn_by_role_sex[1],self.times_save_by_role_sex[1])) res.append(‘小時分布‘) for hour in range(0, 24): # res.append(‘ %d: %d‘ % (hour, self.hour_counter[hour])) res.append(‘ %d: %d %.2f%%‘ % (hour, self.hour_counter[hour],self.hour_counter[hour]/float(self.battle_count)100)) res.append(‘星期分布‘) # res.append(self.weekday_counter.str()) for weekday in range(0, 7): res.append(‘ %d: %d %.2f%%‘ % (weekday+1, self.weekday_counter[weekday],(self.weekday_counter[weekday]/float(self.battle_count)100))) return ‘ ‘.join(res)def get_user_battleinfo_rooms(openid): user_dir = os.path.join(settings.Res_UserBattleInfo_Dir, openid) # files = os.listdir(user_dir) r = [room for room in os.listdir(user_dir)] r = [rr.replace(‘.txt‘, ‘‘) for rr in r] return r if name == ‘main‘: alluserconter = AllUserCounter() folders = os.listdir(settings.Res_UserBattleInfo_Dir) i = 0 for folder in folders: i+=1 print i, ‘/‘ , len(folders), folder userbattledata = UserBattleData(folder) for room in userbattledata.get_user_battleinfo_rooms(): userbattledata.update_battle_info_by_room(room) alluserconter.add_user_data(userbattledata) print " " * 3 print "---------------------------------------" print alluserconter
分析結果
- 平均用戶日在線時長2小時
從分布圖上看大部分用戶都在1小時以上,最猛的幾個人超過8小時。
註:我這裏統計的是每一局的存活時間,實際在線時長會比我這個更長。
-
女性角色被救次數高於男性
終於知道為什麽有那麽多人妖了,原來在遊戲裏面可以占便宜啊。 -
女性角色救人次數高於男性
給了大家一個帶妹上分的好理由。 -
周五大家最忙
估計周五大家都要忙著交差和寫周報了。 -
晚上22點是遊戲高峰
淩晨還有那麽多人玩,你們不睡覺嗎? - 最遠擊殺距離639米*
我看了一下98K、SKS和AWP的有效射程,大致都在800米以內,所以這個值可信度還是可以的。 反過來看抖音上的那些超遠距離擊*殺應該都是擺拍的。
- 能拿到「救死扶傷」稱號才是最高榮耀
從分布情況可以看出來,救死扶傷比十殺還要難。
能拿到救死扶傷稱號的大部分都是女性角色,再一次證明玩遊戲要帶妹。 回歸到這個遊戲的本質,那就是生存遊戲,沒什麽比活下來更重要的了。
結尾
這次爬蟲主要是利用了微信遊戲頻道可以查看陌生人數據的場景才能提取到這麽多數據。我們可以通過同樣的手段來分析王者榮耀和其它遊戲的數據,有興趣的同學可以嘗試一下。
好啦,以上就是我的分享啦,如果你跟我一樣都喜歡python,想成為一名優秀的程序員,也在學習python的道路上奔跑,歡迎你加入python學習群:839383765 群內每天都會分享最新業內資料,分享python免費課程,共同交流學習,讓學習變(編)成(程)一種習慣!
利用Python來爬取“吃雞”數據,為什麽別人能吃雞?