比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
本文完。