1. 程式人生 > >比Selenium快100倍的方法爬東方財富網財務報表

比Selenium快100倍的方法爬東方財富網財務報表

之前,我們用Selenium成功爬取了東方財富網的財務報表資料,但是速度非常慢,爬取70頁需要好幾十分鐘。為了加快速度,本文分析網頁JavaScript請求,找到資料介面然後快速爬取財務報表資料。

1. JavaScript請求分析

 

接下來,我們深入分析。首先,點選報表底部的下一頁,然後觀察左側Name列,看會彈出什麼新的請求來:

 

可以看到,當不斷點選下一頁時,會相應彈出以get?type開頭的請求。點選右邊Headers選項卡,可以看到請求的URL,網址非常長,先不管它,後續我們會分析各項引數。接著,點選右側的Preview和Response,可以看到裡面有很多整齊的資料,嘗試猜測這可能是財務報表中的資料,經過和表格進行對比,發現這正是我們所需的資料,太好了。

 

然後將URL複製到新連結中開啟看看,可以看到表格中的資料完美地顯示出來了。竟然不用新增Headers、UA去請求就能獲取到,看來東方財富網很大方啊。

 

到這裡,爬取思路已經很清晰了。首先,用Request請求該URL,將獲取到的資料進行正則匹配,將資料轉變為json格式,然後寫入本地檔案,最後再加一個分頁迴圈爬取就OK了。這比之前的Selenium要簡單很多,而且速度應該會快很多倍。下面我們就先來嘗試爬一頁資料看看。

2. 爬取單頁

2.1. 抓取分析

這裡仍然以2018年中報的利潤表為例,抓取該網頁的第一頁表格資料,網頁url為:http://data.eastmoney.com/bbsj/201806/lrb.html

表格第一頁的js請求的url為:http://dcfm.eastmoney.com/em_mutisvcexpandinterface/api/js/get?type=CWBB_LRB&token=70f12f2f4f091e459a279469fe49eca5&st=noticedate&sr=-1&p=2&ps=50&js=var%20spmVUpAF={pages:(tp),data:%20(x)}&filter=(reportdate=^2018-06-30^)&rt=51312886}&filter=(reportdate=^2018-06-30^)&rt=51312886)

下面,我們通過分析該url,來抓取表格內容。

 1import requests
 2def get_table():
 3    params = {
 4        'type': 'CWBB_LRB',  # 表格型別,LRB為利潤表縮寫,必須
 5        'token': '70f12f2f4f091e459a279469fe49eca5',  # 訪問令牌,必須
 6        'st': 'noticedate',  # 公告日期
 7        'sr': -1,  # 保持-1不用改動即可
 8        'p': 1,  # 表格頁數
 9        'ps': 50,  # 每頁顯示多少條資訊
10        'js': 'var LFtlXDqn={pages:(tp),data: (x)}',  # js函式,必須
11        'filter': '(reportdate=^2018-06-30^)',  # 篩選條件
12        # 'rt': 51294261  可不用
13    }
14    url = 'http://dcfm.eastmoney.com/em_mutisvcexpandinterface/api/js/get?'
15    response = requests.get(url, params=params).text
16    print(response)
17get_table()

這裡我們定義了一個get_table()方法,來輸出抓取的第一頁表格內容。params為url請求中所包含的引數。

這裡對重要引數進行簡單說明:type為7個表格的型別說明,將type拆成兩部分:'CWBB_' 和'LRB',資產負債表等後3個表是以'CWBB_' 開頭,業績報表至預約披露時間表等前4個表是以'YJBB20_'開頭的;'LRB'為利潤表的首字母縮寫,同理業績報表則為'YJBB'。所以,如果要爬取不同的表格,就需要更改type引數。'filter'為表格篩選引數,這裡篩選出年中報的資料。不同的表格篩選條件會不一樣,所以當type型別更改的時候,也要相應修改filter型別。

params引數設定好之後,將url和params引數一起傳進requests.get()方法中,這樣就構造好了請求連線。幾行程式碼就可以成功獲取網頁第一頁的表格資料了:

 

可以看到,表格資訊儲存在LFtlXDqn變數中,pages表示表格有72頁。data為表格資料,是一個由多個字典構成的列表,每個字典是表格的一行資料。我們可以通過正則表示式分別提取出pages和data資料。

2.2. 正則表示式提取表格

1# 確定頁數
2import re 
3pat = re.compile('var.*?{pages:(\d+),data:.*?')
4page_all = re.search(pat, response)
5print(page_all.group(1))
6結果:
772

這裡用\d+匹配頁數中的數值,然後用re.search()方法提取出來。group(1)表示輸出第一個結果,這裡就是()中的頁數。

 1# 提取出list,可以使用json.dumps和json.loads
 2import json
 3pattern = re.compile('var.*?data: (.*)}', re.S)
 4items = re.search(pattern, response)
 5data = items.group(1)
 6print(data)
 7print(type(data))
 8結果如下:
 9[{'scode': '600478', 'hycode': '016040', 'companycode': '10001305', 'sname': '科力遠', 'publishname': '材料行業'...
10'sjltz': 10.466665, 'kcfjcxsyjlr': 46691230.93, 'sjlktz': 10.4666649042, 'eutime': '2018/9/6 20:18:42', 'yyzc': 14238766.31}]
11<class 'str'>
12

這裡在匹配表格資料用了(.*)表示貪婪匹配,因為data中有很多個字典,每個字典都是以'}'結尾,所以我們利用貪婪匹配到最後一個'}',這樣才能獲取data所有資料。多數情況下,我們可能會用到(.*?),這表示非貪婪匹配,意味著之多匹配一個'}',這樣的話,我們只能匹配到第一行資料,顯然是不對的。

2.3. json.loads()輸出表格

這裡提取出來的list是str字元型的,我們需要轉換為list列表型別。為什麼要轉換為list型別呢,因為無法用操作list的方法去操作str,比如list切片。轉換為list後,我們可以對list進行切片,比如data[0]可以獲取第一個{}中的資料,也就是表格第一行,這樣方便後續構造迴圈從而逐行輸出表格資料。這裡採用json.loads()方法將str轉換為list。

1data = json.loads(data)
2# print(data) 和上面的一樣
3print(type(data))
4print(data[0])
5結果如下:
6<class 'list'>
7{'scode': '600478', 'hycode': '016040', 'companycode': '10001305', 'sname': '科力遠', 'publishname': '材料行業', 'reporttimetypecode': '002', 'combinetypecode': '001', 'dataajusttype': '2', 'mkt': 'shzb', 'noticedate': '2018-10-13T00:00:00', 'reportdate': '2018-06-30T00:00:00', 'parentnetprofit': -46515200.15, 'totaloperatereve': 683459458.22, 'totaloperateexp': 824933386.17, 'totaloperateexp_tb': -0.0597570689015973, 'operateexp': 601335611.67, 'operateexp_tb': -0.105421872593886, 'saleexp': 27004422.05, 'manageexp': 141680603.83, 'financeexp': 33258589.95, 'operateprofit': -94535963.65, 'sumprofit': -92632216.61, 'incometax': -8809471.54, 'operatereve': '-', 'intnreve': '-', 'intnreve_tb': '-', 'commnreve': '-', 'commnreve_tb': '-', 'operatetax': 7777267.21, 'operatemanageexp': '-', 'commreve_commexp': '-', 'intreve_intexp': '-', 'premiumearned': '-', 'premiumearned_tb': '-', 'investincome': '-', 'surrenderpremium': '-', 'indemnityexp': '-', 'tystz': -0.092852, 'yltz': 0.178351, 'sjltz': 0.399524, 'kcfjcxsyjlr': -58082725.17, 'sjlktz': 0.2475682609, 'eutime': '2018/10/12 21:01:36', 'yyzc': 601335611.67}

接下來我們就將表格內容輸入到csv檔案中。

1# 寫入csv檔案
2import csv
3for d in data:
4    with open('eastmoney.csv', 'a', encoding='utf_8_sig', newline='') as f:
5        w = csv.writer(f)
6        w.writerow(d.values())

通過for迴圈,依次取出表格中的每一行字典資料{},然後用with…open的方法寫入'eastmoney.csv'檔案中。

tips:'a'表示可重複寫入;encoding='utf_8_sig' 能保持csv檔案的漢字不會亂碼;newline為空能避免每行資料中產生空行。

這樣,第一頁50行的表格資料就成功輸出到csv檔案中去了:

 

這裡,我們還可以在輸出表格之前新增上表頭:

1# 新增列標題
2def write_header(data):
3    with open('eastmoney.csv', 'a', encoding='utf_8_sig', newline='') as f:
4        headers = list(data[0].keys())
5        print(headers)  
6        print(len(headers)) # 輸出list長度,也就是有多少列
7        writer = csv.writer(f)
8        writer.writerow(headers)

這裡,data[0]表示list的一個字典中的資料,data[0].keys()表示獲取字典中的key鍵值,也就是列標題。外面再加一個list序列化(結果如下),然後將該list輸出到'eastmoney.csv'中作為表格的列標題即可。

1['scode', 'hycode', 'companycode', 'sname', 'publishname', 'reporttimetypecode', 'combinetypecode', 'dataajusttype', 'mkt', 'noticedate', 'reportdate', 'parentnetprofit', 'totaloperatereve', 'totaloperateexp', 'totaloperateexp_tb', 'operateexp', 'operateexp_tb', 'saleexp', 'manageexp', 'financeexp', 'operateprofit', 'sumprofit', 'incometax', 'operatereve', 'intnreve', 'intnreve_tb', 'commnreve', 'commnreve_tb', 'operatetax', 'operatemanageexp', 'commreve_commexp', 'intreve_intexp', 'premiumearned', 'premiumearned_tb', 'investincome', 'surrenderpremium', 'indemnityexp', 'tystz', 'yltz', 'sjltz', 'kcfjcxsyjlr', 'sjlktz', 'eutime', 'yyzc']
244 # 一共有44個欄位,也就是說表格有44列。

 

以上,就完成了單頁表格的爬取和下載到本地的過程。

3. 多頁表格爬取

將上述程式碼整理為相應的函式,再新增for迴圈,僅50行程式碼就可以爬取72頁的利潤報表資料:

 1import requests
 2import re
 3import json
 4import csv
 5import time
 6def get_table(page):
 7    params = {
 8        'type': 'CWBB_LRB',  # 表格型別,LRB為利潤表縮寫,必須
 9        'token': '70f12f2f4f091e459a279469fe49eca5',  # 訪問令牌,必須
10        'st': 'noticedate',  # 公告日期
11        'sr': -1,  # 保持-1不用改動即可
12        'p': page,  # 表格頁數
13        'ps': 50,  # 每頁顯示多少條資訊
14        'js': 'var LFtlXDqn={pages:(tp),data: (x)}',  # js函式,必須
15        'filter': '(reportdate=^2018-06-30^)',  # 篩選條件,如果不選則預設下載全部時期的資料
16        # 'rt': 51294261  可不用
17    }
18    url = 'http://dcfm.eastmoney.com/em_mutisvcexpandinterface/api/js/get?'
19    response = requests.get(url, params=params).text
20  # 確定頁數
21    pat = re.compile('var.*?{pages:(\d+),data:.*?')
22    page_all = re.search(pat, response)  # 總頁數
23    pattern = re.compile('var.*?data: (.*)}', re.S)
24    items = re.search(pattern, response)
25    data = items.group(1)
26    data = json.loads(data)
27    print('\n正在下載第 %s 頁表格' % page)
28    return page_all,data
29def write_header(data):
30    with open('eastmoney.csv', 'a', encoding='utf_8_sig', newline='') as f:
31        headers = list(data[0].keys())
32        writer = csv.writer(f)
33        writer.writerow(headers)
34def write_table(data):
35    for d in data:
36        with open('eastmoney.csv', 'a', encoding='utf_8_sig', newline='') as f:
37            w = csv.writer(f)
38            w.writerow(d.values())
39
40def main(page):
41    data = get_table(page)
42    write_table(data)
43
44if __name__ == '__main__':
45    start_time = time.time()  # 下載開始時間
46    # 寫入表頭
47    write_header(get_table(1))
48    page_all = get_table(1)[0]
49    page_all = int(page_all.group(1))
50    for page in range(1, page_all):
51        main(page)
52    end_time = time.time() - start_time  # 結束時間
53    print('下載用時: {:.1f} s' .format(end_time))

整個下載只用了20多秒,而之前用selenium花了幾十分鐘,這效率提升了足有100倍!

 

這裡,如果我們想下載全部時期(從2007年-2018年)利潤報表資料,也很簡單。只要將type中的filter引數註釋掉,意味著也就是不篩選日期,那麼就可以下載全部時期的資料。這裡當我們取消註釋filter列,將會發現總頁數page_all會從2018年中報的72頁增加到2528頁,全部下載完成後,表格有超過12萬行的資料。基於這些資料,可以嘗試從中進行一些有價值的資料分析。

 

4. 通用程式碼構造

以上程式碼實現了2018年中報利潤報表的爬取,但如果不想侷限於該報表,還想爬取其他報表或者其他任意時期的資料,那麼就需要手動地去修改程式碼中相應的欄位,很不方便。所以上面的程式碼可以說是簡短但不夠強大。

為了能夠靈活實現爬取任意類別和任意時期的報表資料,需要對程式碼再進行一些加工,就可以構造出通用強大的爬蟲程式了。

  1"""
  2e.g: http://data.eastmoney.com/bbsj/201806/lrb.html
  3"""
  4import requests
  5import re
  6from multiprocessing import Pool
  7import json
  8import csv
  9import pandas as pd
 10import os
 11import time
 12
 13# 設定檔案儲存在D盤eastmoney資料夾下
 14file_path = 'D:\\eastmoney'
 15if not os.path.exists(file_path):
 16    os.mkdir(file_path)
 17os.chdir(file_path)
 18
 19# 1 設定表格爬取時期、類別
 20def set_table():
 21    print('*' * 80)
 22    print('\t\t\t\t東方財富網報表下載')
 23    print('作者:高階農民工  2018.10.10')
 24    print('--------------')
 25    year = int(float(input('請輸入要查詢的年份(四位數2007-2018):\n')))
 26    # int表示取整,裡面加float是因為輸入的是str,直接int會報錯,float則不會
 27    # https://stackoverflow.com/questions/1841565/valueerror-invalid-literal-for-int-with-base-10
 28    while (year < 2007 or year > 2018):
 29        year = int(float(input('年份數值輸入錯誤,請重新輸入:\n')))
 30
 31    quarter = int(float(input('請輸入小寫數字季度(1:1季報,2-年中報,3:3季報,4-年報):\n')))
 32    while (quarter < 1 or quarter > 4):
 33        quarter = int(float(input('季度數值輸入錯誤,請重新輸入:\n')))
 34
 35    # 轉換為所需的quarter 兩種方法,2表示兩位數,0表示不滿2位用0補充,
 36    # http://www.runoob.com/python/att-string-format.html
 37    quarter = '{:02d}'.format(quarter * 3)
 38    # quarter = '%02d' %(int(month)*3)
 39
 40    # 確定季度所對應的最後一天是30還是31號
 41    if (quarter == '06') or (quarter == '09'):
 42        day = 30
 43    else:
 44        day = 31
 45    date = '{}-{}-{}' .format(year, quarter, day)
 46    # print('date:', date)  # 測試日期 ok
 47
 48    # 2 設定財務報表種類
 49    tables = int(
 50        input('請輸入查詢的報表種類對應的數字(1-業績報表;2-業績快報表:3-業績預告表;4-預約披露時間表;5-資產負債表;6-利潤表;7-現金流量表): \n'))
 51
 52    dict_tables = {1: '業績報表', 2: '業績快報表', 3: '業績預告表',
 53                   4: '預約披露時間表', 5: '資產負債表', 6: '利潤表', 7: '現金流量表'}
 54
 55    dict = {1: 'YJBB', 2: 'YJKB', 3: 'YJYG',
 56            4: 'YYPL', 5: 'ZCFZB', 6: 'LRB', 7: 'XJLLB'}
 57    category = dict[tables]
 58
 59    # js請求引數裡的type,第1-4個表的字首是'YJBB20_',後3個表是'CWBB_'
 60    # 設定set_table()中的type、st、sr、filter引數
 61    if tables == 1:
 62        category_type = 'YJBB20_'
 63        st = 'latestnoticedate'
 64        sr = -1
 65        filter =  "(securitytypecode in ('058001001','058001002'))(reportdate=^%s^)" %(date)
 66    elif tables == 2:
 67        category_type = 'YJBB20_'
 68        st = 'ldate'
 69        sr = -1
 70        filter = "(securitytypecode in ('058001001','058001002'))(rdate=^%s^)" %(date)
 71    elif tables == 3:
 72        category_type = 'YJBB20_'
 73        st = 'ndate'
 74        sr = -1
 75        filter=" (IsLatest='T')(enddate=^2018-06-30^)"
 76    elif tables == 4:
 77        category_type = 'YJBB20_'
 78        st = 'frdate'
 79        sr = 1
 80        filter =  "(securitytypecode ='058001001')(reportdate=^%s^)" %(date)
 81    else:
 82        category_type = 'CWBB_'
 83        st = 'noticedate'
 84        sr = -1
 85        filter = '(reportdate=^%s^)' % (date)
 86
 87    category_type = category_type + category
 88    # print(category_type)
 89    # 設定set_table()中的filter引數
 90
 91    yield{
 92    'date':date,
 93    'category':dict_tables[tables],
 94    'category_type':category_type,
 95    'st':st,
 96    'sr':sr,
 97    'filter':filter
 98    }
 99
100# 2 設定表格爬取起始頁數
101def page_choose(page_all):
102    # 選擇爬取頁數範圍
103    start_page = int(input('請輸入下載起始頁數:\n'))
104    nums = input('請輸入要下載的頁數,(若需下載全部則按回車):\n')
105    print('*' * 80)
106
107    # 判斷輸入的是數值還是回車空格
108    if nums.isdigit():
109        end_page = start_page + int(nums)
110    elif nums == '':
111        end_page = int(page_all.group(1))
112    else:
113        print('頁數輸入錯誤')
114
115    # 返回所需的起始頁數,供後續程式呼叫
116    yield{
117        'start_page': start_page,
118        'end_page': end_page
119    }
120
121# 3 表格正式爬取
122def get_table(date, category_type,st,sr,filter,page):
123    # 引數設定
124    params = {
125        # 'type': 'CWBB_LRB',
126        'type': category_type,  # 表格型別
127        'token': '70f12f2f4f091e459a279469fe49eca5',
128        'st': st,
129        'sr': sr,
130        'p': page,
131        'ps': 50,  # 每頁顯示多少條資訊
132        'js': 'var LFtlXDqn={pages:(tp),data: (x)}',
133        'filter': filter,
134        # 'rt': 51294261  可不用
135    }
136    url = 'http://dcfm.eastmoney.com/em_mutisvcexpandinterface/api/js/get?'
137    response = requests.get(url, params=params).text
138    # 確定頁數
139    pat = re.compile('var.*?{pages:(\d+),data:.*?')
140    page_all = re.search(pat, response)
141    # print(page_all.group(1))  # ok
142    # 提取出list,可以使用json.dumps和json.loads
143    pattern = re.compile('var.*?data: (.*)}', re.S)
144    items = re.search(pattern, response)
145    # 等價於
146    # items = re.findall(pattern,response)
147    # print(items[0])
148    data = items.group(1)
149    data = json.loads(data)
150    return page_all, data,page
151
152# 4 寫入表頭
153# 方法1 藉助csv包,最常用
154def write_header(data,category):
155    with open('{}.csv' .format(category), 'a', encoding='utf_8_sig', newline='') as f:
156        headers = list(data[0].keys())
157        # print(headers)  # 測試 ok
158        writer = csv.writer(f)
159        writer.writerow(headers)
160# 5 寫入表格
161def write_table(data,page,category):
162    print('\n正在下載第 %s 頁表格' % page)
163    # 寫入檔案方法1
164    for d in data:
165        with open('{}.csv' .format(category), 'a', encoding='utf_8_sig', newline='') as f:
166            w = csv.writer(f)
167            w.writerow(d.values())
168
169def main(date, category_type,st,sr,filter,page):
170    func = get_table(date, category_type,st,sr,filter,page)
171    data = func[1]
172    page = func[2]
173    write_table(data,page,category)
174if __name__ == '__main__':
175    # 獲取總頁數,確定起始爬取頁數
176    for i in set_table():
177        date = i.get('date')
178        category = i.get('category')
179        category_type = i.get('category_type')
180        st = i.get('st')
181        sr = i.get('sr')
182        filter = i.get('filter')
183    constant = get_table(date,category_type,st,sr,filter, 1)
184    page_all = constant[0]
185
186    for i in page_choose(page_all):
187        start_page = i.get('start_page')
188        end_page = i.get('end_page')
189
190    # 先寫入表頭
191    write_header(constant[1],category)
192    start_time = time.time()  # 下載開始時間
193    # 爬取表格主程式
194    for page in range(start_page, end_page):
195        main(date,category_type,st,sr,filter, page)
196    end_time = time.time() - start_time  # 結束時間
197    print('下載完成')
198    print('下載用時: {:.1f} s' .format(end_time))

以爬取2018年中業績報表為例,感受一下比selenium快得多的爬取效果:

 

利用上面的程式,我們可以下載任意時期和任意報表的資料。這裡,我下載完成了2018年中報所有7個報表的資料。

 

文中程式碼和素材資源可以在下面的連結中獲取:https://github.com/makcyun/eastmoney_spider

 

本文完。