貓眼票房大屏投影及常規版資料爬取

MirrorTan發表於2019-08-13

貓眼票房 是檢視國內影院上線電影票房的一個站點,包括 大屏投影常規版 兩個版面。本文嘗試對這兩個版面的資料進行爬取。

1 大屏投影

大屏投影是貓眼票房目前預設的首頁,其中票房資料近乎實時更新,該版面展示的資料中相較於常規版多了一項「場均人次」。

1.1 頁面分析

使用瀏覽器開啟 大屏投影 版後,通過 檢查 調出分析工具,選擇 Network 選項卡,頁面中會不斷更新 https://box.maoyan.com/promovie/api/box/se... 檔案 ~ 已實現更新票房資料。

大屏投影

也即,該 Ajax 相應的 json 資料即為我們所需的資料。同時,當我們跳轉到不同的日期,發現請求的 API 相應改變,如 2019/8/11 的票房資料請求 API 為 https://box.maoyan.com/promovie/api/box/se...

所以,我們可以模仿瀏覽器的 Ajax 請求來實現資料的獲取。

1.2 程式碼實現

import time
import datetime
import requests
from requests.exceptions import RequestException

def get_one_page(date):
    headers = {
        'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36'
    }
    url = 'https://box.maoyan.com/promovie/api/box/second.json?beginDate=' + date
    try:
        response = requests.get(url, headers=headers)
        if response.status_code == 200:
            return response.json()
    except RequestException as e:
        print("Requests {}, Error {}.".format(date, e.args))

def parse_one_page(json):
    if json:
        today = json.get('data').get('queryDate')
        items = json.get('data').get('list')
        for item in items:
            yield {
                'date': today,
                'movieName': item['movieName'],
                'releaseInfo': item['releaseInfo'],
                'sumBoxInfo': item['sumBoxInfo'],
                'splitSumBoxInfo': item['splitSumBoxInfo'],
                'boxInfo': item['boxInfo'],
                'boxRate': item['boxRate'],
                'showInfo': item['showInfo'],
                'showRate': item['showRate'],
                'avgShowView': item['avgShowView'],
                'avgSeatView': item['avgSeatView']
            }

def main():
    start_date = datetime.date.today()
    for i in range(0, 31):
        date = start_date - datetime.timedelta(days=i)
        json = get_one_page(date.strftime(r'%Y%m%d'))
        for item in parse_one_page(json):
            print(item)
        time.sleep(1)

if __name__ == '__main__':
    main()

藉助於 requests 庫,通過 get_one_page(date) 函式獲得指定日期的票房資料;通過 parse_one_page(json) 實現對 json 格式資料的解析;在 main() 函式中,藉助於 datetime 函式,一次請求過去一個月以來資料。

2 常規版

普通版的資料 30 分鐘更新一次,直接請求 https://piaofang.maoyan.com/?ver=normal 頁面時,所需資料已經存在,但返回的數字加密過 ~ 而且每次請求後獲得的資料中數字加密方式不一致。

2.1 頁面分析

通過 檢查 常規版 頁面,選擇 Network 選項卡,檢視 https://piaofang.maoyan.com/?ver=normal 對應的 Response 資料:

常規版頁面

我們發現,此頁面的數字進行過加密,對應的樣式為 class="cs",通過在該頁面中搜尋 cs 發現其對應的樣式為:

<style id="js-nuwa">
  @font-face{font-family:"cs";src:url(data:application/font-woff;charset=utf-8;base64,d09G...yxfp) format("woff");}
.cs{font-family:cs}
</style>

也即,字型通過 base64 進行加密。

2.2 字型加密

我們將加密後的字串 (d09G...yxfp) 使用 base64 解碼後,藉助於 fontTools 庫儲存為字型檔案:

import os
import re
import base64

import requests
from fontTools.ttLib import TTFont

def font_face(url, name):
    headers = {
        'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36'
    }
    response = requests.get(url, headers=headers)
    fonts = re.search(r'base64,(.*?)\)', response.text, re.S)
    fonts = base64.b64decode(fonts)
    with open(name+'.woff', 'wb') as fp:
        fp.write(fonts)
    TTFont(name+'.woff').saveXML(name+'.xml')

if __name__ == '__main__':
    url1 = 'https://piaofang.maoyan.com/?ver=normal&date=2019-07-29'
    url2 = 'https://piaofang.maoyan.com/?ver=normal&date=2019-07-28'
    font_face(url1, 'font1')
    font_face(url2, 'font2')

藉助於 百度字型編輯器 開啟儲存好的字型:

font1

font2

此時,我們即可找到加密後的字串與數字之間的對應關係。不過這裡有一點麻煩的地方在於不同時間載入加密方式不一樣,也即字串和數字對應的關係會發生變化。

不過,當我們將字型檔案使用 fontTools 庫儲存為 .xml 格式後發現:

GlyphOrder

各個數字對應的字串位於 <GlyphOrder>...</GlyphOrder> 中(id 與數值並沒有直接關係);如請求的 font1 中 8 對應於 uniE577,而 font2 中的 1 對應於 uniE5F9,但二者的字形 <glyf>... 中的內容完全一致:

glyf

因此,我們找到了所需的不變數,即數字與字型檔案中 <glyf> 的對應關係不會發生變化,而前端呈現的字串與數字之間的對應關係會發生變化。

因此,我們只要找到第一次的加密字串與數字之間的對應關係,利用 <glyf> 中的元素去匹配後續新檔案,即可實現加密字元還原。

2.3 程式碼實現

import re
import time
import datetime
import base64

import requests
from requests.exceptions import RequestException
from pyquery import PyQuery as pq
from fontTools.ttLib import TTFont

font = TTFont('font1.woff')
uni_list = font.getGlyphOrder()[2:]
first_match = {
    'uniE893': '0',
    'uniF690': '1',
    'uniF55C': '2',
    'uniF28F': '3',
    'uniF4B1': '4',
    'uniE623': '5',
    'uniF294': '6',
    'uniEEC4': '7',
    'uniE577': '8',
    'uniE77B': '9'
}

def get_one_page(date):
    headers = {
        'User-Agent': os.getenv('User_Agent')
    }
    url = 'https://piaofang.maoyan.com/?ver=normal&date=' + date
    try:
        response = requests.get(url, headers=headers)
        if response.status_code == 200:
            return response.text
        else:
            return None
    except RequestException as e:
        print("Requests {}, Error {}.".format(date, e.args))
        return None

def parse_font(html):
    fonts = re.findall(r'base64,(.*?)\)', html, re.S)[0]
    # fonts = re.search(r'base64,(.*?)\)', html, re.S)
    fonts = base64.b64decode(fonts)
    with open('tmp.woff', 'wb') as fp:
        fp.write(fonts)
    font1 = TTFont('tmp.woff')
    # obj_list1 = font1.getGlyphNames()[1:-1]
    uni_list1 = font1.getGlyphOrder()[2:]
    tmp_match = {}
    for uni1 in uni_list1:
        obj1 = font1['glyf'][uni1]  #獲取編碼 uni1 在 tmp.ttf 中對應的物件
        for uni in uni_list:
            obj = font['glyf'][uni]
            if obj==obj1:
                tmp_match[uni1] = first_match[uni]
    return tmp_match

def rebuild_number(number, tmp_match):
    '''還需要對數字進行改寫'''
    result = ''
    for num in number:
        s = str(hex(ord(num)))
        s = s.upper().replace('0X', 'uni')
        if s in tmp_match.keys():
            result += tmp_match[s]
        else:
            result += num
    return result

def parse_one_page(html):
    tmp_match = parse_font(html)
    doc = pq(html)
    today = doc('.today').text()[:10]
    movies = doc('#ticket_tbody ul').items()
    for movie in movies:
        result = {}
        result['date'] =  today
        result['movieName'] = movie.find('.c1 b').text()
        result['releaseInfo'] = movie.find('.c1 em').text().split()[0]
        result['sumBoxInfo'] = rebuild_number(movie.find('.c1 em i').text(), tmp_match)
        result['boxInfo'] =  rebuild_number(movie.find('.c2').text(), tmp_match)
        result['boxRate'] = rebuild_number(movie.find('.c3').text(), tmp_match)
        result['showRate'] = rebuild_number(movie.find('.c4').text(), tmp_match)
        result['avgSeatView'] = rebuild_number(movie.find('.c5').text(), tmp_match)
        yield result

def main():
    start_date = datetime.date.today()
    for i in range(0, 31):
        date = start_date - datetime.timedelta(days=i)
        html = get_one_page(date.isoformat())
        for result in parse_one_page(html):
            print(result)
        time.sleep(1)

if __name__ == '__main__':
    main()

使用 requests 庫,通過 get_one_page(date) 實現指定日期網頁請求;再利用 parse_one_page(html) 資料的提取,其中,首先呼叫 parse_font(html) 構建當前數字與加密字串的對應關係,再利用 rebuild_number(number, tmp_match) 實現加密字串的到數字的轉換過程。

3 參考資料

  1. 大齡碼農的Python之路, 丹楓無跡, Python爬蟲例項:爬取貓眼電影——破解字型反爬, 2019/8/13.
  2. 《Python3網路爬蟲開發實戰》, 崔慶才,2019/8/13.

相關文章