使用python开发completeroms网站爬虫

使用Python开发爬虫是很快速简单灵活的。我在completeroms这个网站看到了很全的模拟器游戏下载,包括NES,GBA,GBC等等,偶尔下载几个ROM回味一下童年时代红白机的乐趣也不失为一件很有情调的事。然而该网站没有一个批量下载的功能,只能一个个自己下载,下载一个ROM得点开两个页面,即便手速再快,每次下载之前还得等待10秒。于是,对于想批量下载的羊毛党来说,得弄个爬虫才是真爱了。

开始

1.爬虫抓取逻辑

抓取的逻辑一般和人用浏览器访问大致是一样的,下载ROM我们首先会访问目录页。目录是有分页的,所以先要遍历所有目录页。针对于每一页,又会有若干个条目,所以又要遍历该页中的所有条目,对于每个条目,都有超链接跟踪到详情页,在详情页中便可找到下载链接。有了下载链接,呵呵,下就完了。

2.使用Requests访问HTTP

有了爬取的逻辑,接下来就要具体实现了,首先分析一下目录页网址的特点,如第NES游戏第3页:http://www.completeroms.com/roms/nintendo/3 只有最后一个数字会改变,前面的都一样,因此如果要爬1~64页,一个for循环就好了。然而现在我们怎么使用http请求拿到网页的内容呢?使用requests库吧:requests.get(url)一下就可以发送一个get请求了,于是就可以写代码了:

1
2
3
4
5
max_page = 65
for i in range(1, max_page):
url = 'http://www.completeroms.com/roms/nintendo/%d' % i
req = requests.get(url)
print req.text

3.使用BeautifulSoup解析DOM

使用requests可以拿到整个网页的html代码,但是我们需要从这些代码里提取出我们需要的信息,使用一些DOM解析库(如BeautifulSoup)来分析DOM结构即可。我们现在需要的是目录列表,这些列表是存在于table标签中的,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<tr>
<td><img src="//www.completeroms.com/assets/img/flags/usa.png" alt="United States of America"></td>
<td><a href="//www.completeroms.com/dl/nintendo/dragon-ball-z-4in1-/106706">Dragon Ball Z 4-in-1 <span class="usa" /></span></a></td>
<td class="hidden-xs"><a href="//www.completeroms.com/roms/nintendo">Nintendo</a></td>
<td class="hidden-xs">20,882</td>
<td class="hidden-xs">
<input type="text" class="rating" data-size="xs" disabled="true" data-show-caption="false" data-show-clear="false" value="4.7" current-value="4.7">
</td>
</tr>
<tr>
<td><img src="//www.completeroms.com/assets/img/flags/usa.png" alt="United States of America"></td>
<td><a href="//www.completeroms.com/dl/nintendo/prince-of-persia-u/4219">Prince Of Persia <span class="usa" /></span></a></td>
<td class="hidden-xs"><a href="//www.completeroms.com/roms/nintendo">Nintendo</a></td>
<td class="hidden-xs">20,791</td>
<td class="hidden-xs">
<input type="text" class="rating" data-size="xs" disabled="true" data-show-caption="false" data-show-clear="false" value="4.6" current-value="4.6">
</td>
</tr>

我想要过滤出来的,就是a标签href属性的内容,因为href链接就是对应ROM详情页的网址。用以下代码,就可以把这些href获取到:

1
2
3
4
5
6
7
8
9
bs = BeautifulSoup(req.text, features='html5lib')
tr_nodes = bs.find('table', class_='table').find_all('tr')
for tr_node in tr_nodes:
a_node = tr_node.find('a')
if a_node is None:
print 'warn: empty node!'
return
a_url = a_node.attrs['href']
print a_url

这样以后我拿到的一堆详情页的URL:

1
2
3
4
//www.completeroms.com/dl/nintendo/dragon-ball-z-4in1-/106706
//www.completeroms.com/dl/nintendo/prince-of-persia-u/4219
//www.completeroms.com/dl/nintendo/shinobi-u/4315
...

4.使用正则匹配提取字符串

本来的访问顺序是这样的,目录页->详情页->下载页,但根据观察发现不用访问详情页便可得知下载页的URL,这样少了一次网络请求,可以节省点网络流量哈。下载页的URL形如:http://www.completeroms.com/thankyou.php?id=4367 显而易见id参数对应的值就是上一步中得到的href的最后一个“/”后面的数字。所以我们下一步要做的,就是从//www.completeroms.com/dl/nintendo/shinobi-u/4315这样的一个网址中截取出4315这个id。这个id的长度是不固定的,特点是处于“/”的后面,拿到它的一种做法是用正则匹配,但也可以用find()如:

1
2
3
4
url = '//www.completeroms.com/dl/nintendo/shinobi-u/4315'
last_id = url.rfind('/') + 1
rom_id = url[last_id:]
download_url = 'http://www.completeroms.com/thankyou.php?id=%s' % rom_id

这样以后,下载页的url又到手了,如果我用浏览器打开这个网址,等待10秒以后,ROM文件就自动开始下载了。但爬虫肯定不会就此罢休,起码得让ROM自动下载。我们有了下载页的URL,还需要做下一步,需要在下载页中找到文件的URL,这样才能下载嘛。然而通过观察就能发现,文件的下载地址并不是直接包含在任何一个a标签链接里的,而是写在js里,js跑起来后倒计时十秒钟后自动开始下载的,所以使用BeautifulSoup这样的DOM搜索去做就不合适了,我的做法是直接全文用正则匹配了。因为文件链接地址在整个文档中非常显眼:

1
2
3
4
5
6
7
8
$(function(){
function start_download()
{
var url = "http://dl.completeroms.com/grab/Nintendo/Pirate/Dragon Ball Z 4-in-1 [p1][!].zip";
window.open( url,"_self");
};
window.setTimeout( start_download, 10000 );
});

我要的就是 var url = 后面的这个链接,使用下面的代码就把它从整个文档中过滤出来了:

1
2
3
4
5
ptn = re.compile(r'.*var url = "(?P<zip_url>.*)";.*')
m = ptn.search(req.text)
if hasattr(m, 'group'):
zip_url = m.group('zip_url')
print zip_url

5.文件下载

这时,迫不及待的在浏览器里输入刚刚从js里提取出来的这个文件下载地址,狂敲回车键,期待着文件开始下载。然而世事总难料,503错误出现了,半天的努力白费了。
既然用浏览器下载不了,使用爬虫直接下载,那不也是一样白费么。要发现其中的猫腻,就得搞清楚js中使用window.open( url,”_self”)来下载和直接用浏览器打开下载有啥区别。
最终发现,差异就在于他的请求头里多带了个参数 Referer:http://www.completeroms.com/thankyou.php?id=4367 知道了这个,我们自己也可以伪造个请求头,于是下载的代码出炉了:

1
2
3
4
5
6
7
8
9
10
11
12
headers = {
'Referer': download_url,
'Accept - Encoding': 'gzip, deflate',
'Accept-Language': 'en-US,en;q=0.5',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/56.0'
}
# 请求的时候设置请求头
r = requests.get(zip_url, headers=headers)
# 设置下载目录
with open(rom_path, "wb") as code:
code.write(r.content)

6.多线程下载

把上面的流程串起来,下载就可以源源不断跑起来了。网页上如果还有其他我们需要的信息,就解析对应的DOM获取就行了,比如我想把游戏说明里的缩略图也下载下来,依旧如法炮制就行。
但是,这时你会发现速度成问题了,这个网站nes的ROM有3000多个,顺次访问完3000个文件再下载完3000个文件,已经过去数十个小时了。我们希望看到的是多线程刷刷的下载速度,我们使用ThreadPool,就可以同时爬取多个下载页,或者同时下载多个文件了,使用很简单:

1
2
3
4
5
6
7
8
9
10
11
12
def start_fetch():
pool = ThreadPool(8)
pool_requests = makeRequests(get_game_in_pool, tr_nodes, None, None)
[pool.putRequest(req) for req in pool_requests]
pool.wait()
print 'finished!'

def get_game_in_pool(tr_node):
a_node = tr_node.find('a')
if a_node is None:
print 'warn: empty node!'
return

代码中定义了线程池的容量是8, get_game_in_pool便是异步执行的方法, tr_nodes是个数组,系统会取出数组中每个元素tr_node作参数传给异步方法。
打个比方,线程池执行就像排队上厕所,tr_nodes数组就是排在厕所外面的队列,而get_game_in_pool函数定义了每个人上厕所需要走的流程,tr_node就是队列中的每个人。一共8个坑位,厕所开门时,队列头部8个人涌入占坑,谁上完厕所就让队列里下一个人进来,厕所从一开门就阻塞在那里直到所有人完事,厕所关门,执行print ‘finished!’
把爬虫放到线程池用8个线程来跑,未免也太心黑了,不过这个网站应该做了反爬虫,这样的并发下载使用不到半分钟,网站就爆出请求太多的错误了。即便是顺次下载,使用一段时间后也会报错,没辙了么。最终不但没用成多线程,还得加上5s的延时才得以稳定运行。只能靠时间来改变一切了。
完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
import codecs
import os
import re
import time

import requests
from bs4 import BeautifulSoup
from threadpool import ThreadPool, makeRequests

DOWNLOAD_ROOT = '../rom-download/completeroms/'
SUPPORT_TYPES = {
'nintendo': 64,
'gameboy-color': 23,
'game-gear': 14,
}


class RomDownloader:
def __init__(self, types):
self.img_root = ''
self.pool_rom_list = []
self.download_types = types

def start_fetch(self):
for my_type in self.download_types:
if my_type not in SUPPORT_TYPES:
continue
max_page = SUPPORT_TYPES[my_type]
rom_list = []
count = 0
for i in range(0, max_page):
index_url = 'http://www.completeroms.com/roms/%s/%d' % (my_type, i + 1)
req = requests.get(index_url)
bs = BeautifulSoup(req.text, features='html5lib')
tr_nodes = bs.find('table', class_='table').find_all('tr')
for tr_node in tr_nodes:
a_node = tr_node.find('a')
if a_node is None:
continue
detail_url = a_node.attrs['href']
last_id = detail_url.rfind('/') + 1
rom_id = detail_url[last_id:]
if not rom_id:
continue
rom_info = {
'index': count,
'rom_id': rom_id,
}
rom_list.append(rom_info)
count += 1
print 'parse index ok[%s] total:[%d]' % (index_url, count)
self.parse_download_info_async(rom_list)
self.write_url_map_csv(my_type)
self.download_img_async(my_type)
self.download_roms_slowly(my_type)

def parse_download_info_async(self, rom_list):
print 'start parse download info, count[%d]' % len(rom_list)
self.pool_rom_list = []
pool = ThreadPool(64)
pool_requests = makeRequests(self.get_download_url_pool, rom_list,
self.get_download_url_done, self.get_download_url_error)
[pool.putRequest(req) for req in pool_requests]
pool.wait()
print 'parse download info, count[%d]' % len(self.pool_rom_list)

@staticmethod
def get_download_url_error(req_args, error_info):
print 'url error:[%s]' % req_args.args[0]['final_url']
print error_info

@staticmethod
def get_download_url_pool(rom_info):
rom_id = rom_info['rom_id']
final_url = 'http://www.completeroms.com/thankyou.php?id=%s' % rom_id
rom_info['final_url'] = final_url
headers = {
'Accept - Encoding': 'gzip, deflate',
'Accept-Language': 'en-US,en;q=0.5',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/56.0'
}
req = requests.get(final_url, headers=headers, timeout=60)
bs = BeautifulSoup(req.text, features='html5lib')
fname_node = bs.find('h3')
if fname_node:
file_name = fname_node.text.strip()
rom_info['file_name'] = file_name
img_node = bs.find('img', class_='rom-cover')
if img_node:
img_url = img_node.attrs['src']
rom_info['img_url'] = img_url
last_dot = img_url.rfind('.')
ext_name = img_url[last_dot:].lower()
last_dot = file_name.rfind('.')
simple_name = file_name[0:last_dot]
rom_info['simple_name'] = simple_name
img_name = simple_name + ext_name
rom_info['img_name'] = img_name
ptn = re.compile(r'.*var url = "(?P<zip_url>.*)";.*')
m = ptn.search(req.text)
if hasattr(m, 'group'):
zip_url = m.group('zip_url')
rom_info['file_url'] = zip_url
return rom_info

def write_url_map_csv(self, my_type):
csv_root = DOWNLOAD_ROOT + my_type + '/'
if not os.path.exists(csv_root):
os.makedirs(csv_root)
time_str = time.strftime('%Y-%m-%d-%H%M%S', time.localtime())
log_path = '%surl-map-%s.csv' % (csv_root, time_str)
csv_writer = codecs.open(log_path, 'w', 'utf-8')
csv_writer.write('Name,Rom Url,Image Url,Referer Url\n')
for rom_info in self.pool_rom_list:
if 'simple_name' not in rom_info:
print 'no simple name[%s]' % rom_info['file_name']
continue
csv_writer.write('"%s","%s","%s","%s"\n' % (rom_info['simple_name'] or '', rom_info['file_url'],
rom_info['img_url'], rom_info['final_url']))
csv_writer.close()
print 'write csv file ok[%s]' % my_type

def get_download_url_done(self, req_args, rom_info):
if rom_info is None:
print 'get download url error!'
index = rom_info['index']
file_name = rom_info['file_name']
print 'get download url ok[%3d.%s]' % (index, file_name)
self.pool_rom_list.append(rom_info)

def download_img_async(self, my_type):
self.img_root = DOWNLOAD_ROOT + my_type + '/images/'
if not os.path.exists(self.img_root):
os.makedirs(self.img_root)
print 'start download images, count[%d]' % len(self.pool_rom_list)
pool = ThreadPool(64)
pool_requests = makeRequests(self.download_img_in_pool, self.pool_rom_list, self.download_img_done,
self.download_img_error)
[pool.putRequest(req) for req in pool_requests]
pool.wait()
print 'download images ok, count[%d]' % len(self.pool_rom_list)

@staticmethod
def download_img_error(req_args, error_info):
args = req_args.args[0]
if 'img_url' in args:
print 'download image error:[%s]' % args['img_url']
print error_info

def download_img_in_pool(self, rom_info):
img_name = rom_info['img_name']
img_url = rom_info['img_url']
img_path = self.img_root + img_name
if os.path.exists(img_path):
return rom_info
r = requests.get(img_url, timeout=60)
with open(img_path, "wb") as code:
code.write(r.content)
return rom_info

@staticmethod
def download_img_done(req_args, rom_info):
index = rom_info['index']
img_name = rom_info['img_name']
print 'finish download image[%d][%s]' % (index, img_name)

def download_roms_slowly(self, my_type):
roms_root = DOWNLOAD_ROOT + my_type + '/roms/'
if not os.path.exists(roms_root):
os.makedirs(roms_root)
print 'start download roms, count[%d]' % len(self.pool_rom_list)
for rom_info in self.pool_rom_list:
file_name = rom_info['file_name']
rom_path = roms_root + file_name
if os.path.exists(rom_path):
print 'exists rom[%s]' % file_name
continue
final_url = rom_info['final_url']
file_url = rom_info['file_url']
headers = {
'Referer': final_url,
'Accept - Encoding': 'gzip, deflate',
'Accept-Language': 'en-US,en;q=0.5',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:56.0) Gecko/20100101 Firefox/56.0'
}
try:
r = requests.get(file_url, headers=headers, timeout=300)
with open(rom_path, "wb") as code:
code.write(r.content)
print 'download rom ok sleep 10s[%3d.%s]' % (rom_info['index'], file_name)
time.sleep(6)
except Exception, e:
print 'exception[%s][%s]' % (rom_info['file_name'], e)


if __name__ == '__main__':
print ('start fetch!!')
download_types = [
'gameboy-color',
'nintendo',
'game-gear',
]
downloader = RomDownloader(download_types)
downloader.start_fetch()

做的比较简单,如果需要稳定的话还要再加些空判断和异常处理,校验之类。我的目的只是几千个ROM一夜之间下载到手,其他的就不用管了。
完整项目:https://github.com/huzongyao/EmuRomCrawler

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器