前言
笔者趁最近闲暇之余,写了一个 400 行的标准化爬虫程序,可以说是大大加快了爬虫的速度,只需要 3-30 秒就可以获取网页页面显示文字的标准路径,并完成路径下完整信息的爬取,将针对某一类网页构建标准化爬虫流程的速度提高 100 倍以上。作为 Python 入门者也可以很快上手这一套程序。
例如:设定网页爬取目标为 ‘https://sz.lianjia.com/ershoufang/rs红树湾/’,设定追踪文字 [‘200平大四房’,‘中信红树湾北区204平’],0.68 秒后程序返回结果:
Crawed contents:
[' 200平大四房 端头三面采光,看高尔夫和世界之窗内湖', ' 世纪村二期 4室2厅 1300万', ' 中信红树湾北区204平大四房 安静南北通装修保养好', ' 世纪村二期153平3+1房,25平入户花园', ' 红树西岸正南正北大三房 看海景', ' 高楼层,新装修,看海景园林景观,满五唯一,钥匙看房', ' 美庐锦园 3室2厅 930万', ' 红树西岸大三房 呓高尔夫看海景', ' 东南向无西晒 厨房带采光阳台 位置安静离滨海大道远', ' 西岸4房改精装大两房 看全景高尔夫 满五唯一 随时看房', ' 南北通 四房 客厅带阳台东南 看海景 厨房全明 少放盘', ' 百仕达红树西岸 2室1厅 1690万', ' 中信红树湾南区 4室2厅 1980万', ' 世纪村四期,日月府,大4房格局,183.02平米,精装修', ' 御景东方1栋三房 三个房间都朝南 客厅出阳台', ' 中信红树湾南区 4房加入户和中庭花园,可以做6房', ' 毛坯4房,看海+高尔夫,看房我有钥匙', ' 中信北区 高层 复试 看房方便', ' 地铁口物业,端头位三面采光,买两房', ' 百仕达红树西岸 3室2厅 2450万', ' 红树西岸南向202平大三房,看一线海景', ' 世纪村4+1房 客厅和主卧出阳台 南北通 诚心出售', ' 京基御景东方高层带连廊精装修推荐!', ' 带20平左右中庭花园 可做5房三套间 大户型物业', ' 中信红树湾 大平面 装修保养好 看海景 高尔夫景', ' 高楼层厅出阳台 东南向 南北通透四房 装修保养好', ' 世纪村二期精装三房 小区中间位置 安静舒适', ' 可观全景高尔夫的三居室,有钥匙,看房方便', ' 红树湾南区精装3房,安静开阔视野好。', ' 红树湾宜居三房 刚需入门户型 精装修端头位置']
尽管现在市面上已有非常多用于爬虫的软件,同样可实现以上一键操作。但作为使用者,我们无法了解和修改黑箱里的具体操作和规则。这也是笔者能带给您方便的地方。
介绍
完成一套程序基本可走完以下流程:
- Step 1: 爬取网页,以树的形式解析 HTML 结构
- Step 2: 根据指定文本,获取多条文本路径
- Step 3: 从多条文本路径中,通过算法求得最优标准路径
- Step 4: 遵循标准路径,爬取所有可获取信息
其中 Step 2 和 Step 4 应用了深度优先搜索,Step 3 应用了动态规划求解多维最长公共子序列的算法以及正则表达式。如果您需要跳过详细介绍直接获取完整代码,请直接跳到本文最后一节。
一、解析网页
第一步是定义节点类。HTML 本身以树的形式来承载数据,因此使用树的数据结构最为合适。
class Node(dict):
'''Node nodeect recording node information.'''
def __init__(self):
self.dict = dict()
def __getitem__(self,key):
'''Create a new child node as default.'''
if key not in self.dict.keys():
self.dict[key] = Node()
return self.dict[key]
def __str__(self):
'''Print child nodes.'''
return "Node with child nodes: %s \n(try to get the whole map by removing print function)"%', '.join(list(self.dict.keys()))
def keys(self):
'''Return the keys.'''
return self.dict.keys()
def add(self,key,value):
'''Add key and value within a node.'''
if key not in self.keys():
self.dict[key] = value
else:
self.dict[key] += value
每一个节点对象承载两类信息:子节点和文字。随后,定义爬虫框架类 MyCrawler:
HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.84 Safari/537.36"}
ATTEMPT_ENCODING = ('utf-8', 'gbk')
PRINT_INFO = True
PRINT_WARNING = True
class MyCrawler:
'''Class recording crawling details.'''
def __init__(self,headers=HEADERS,cookies=None,timeout=5,print_info=PRINT_INFO,print_warning=PRINT_WARNING):
self.url = None
self.html = None
self.headers = headers
self.cookies = cookies
self.timeout = timeout
self.print_info = print_info
self.print_warning = print_warning
接下来是解析网页的相关代码,直接补充在 Crawler 类下。缺了什么模块,读者直接在代码文件最上方 import 即可。
def parse(self,url):
'''Main process for crawling and parsing the whole html file.'''
self.url = url
response = requests.get(self.url, headers=self.headers, cookies=self.cookies, timeout=self.timeout)
for encoding in ATTEMPT_ENCODING: # attempt on different encoding type
try:
self.html = ' '*10 + response.content.decode(encoding) + ' ' # blank: prevent from index out of range
break
except:
continue
self.html = self.html.replace('\n',' ').replace('\r',' ')
self.top_node = Node()
special_tags = set(re.findall(r'<([^ ^>^!^/]+?)[ >]',self.html))
for tail in set(re.findall(r'</[^ ^<^>^!]+>',self.html)):
special_tags -= {
tail[2:-1]} # filter closed opening tags without corresponding ending tags
i,j,path = 0,0,[]
on_comments = on_script = on_single_quote = on_double_quote = in_tag = False
while i < len(self.html) - 1:
if on_comments:
# skip when scanning on comments
if (self.html[i-2:i+1] == '-->'):
on_comments = False
j = i
elif on_script:
# skip when scanning on javascript scripts
if (self.html[i-8:i+1] == '</script>'):
on_script = False
node = self.follow(path)
node.add('text',' '+self.html[j+1:i-8])
path = path[:-1]
j = i
elif (self.html[i] == "'") & (not on_double_quote) & in_tag:
on_single_quote = not on_single_quote
elif (self.html[i] == '"') & (not on_single_quote) & in_tag:
on_double_quote = not on_double_quote
elif (not on_single_quote) & (not on_double_quote):
if (self.html[i] == '<'):
if (len(path) > 0):
# record any content
node = self.follow(path)
node.add('text',' '+self.html[j+1:i])
if (self.html[i+1] != '/'):
if (self.html[i+1] != '!'):
# start recording opening tag: --> <div>
in_tag = True
j = i
else:
# skipping from comments: <!--... -->
if (self.html[i+1:i+4] == '!--'):
on_comments = True
else:
# start recording ending tag: --> </div>
in_tag = True
j = i
elif (self.html[i] == '>') & (self.html[j] == '<'):
in_tag = False
tag_name = re.findall(r'<([^ ^>]+?)[> ]',self.html[j:i+1])[0]
if (len(re.findall(r' content="',self.html[j:i+1])) > 0) or (tag_name in special_tags) or ((tag_name != 'script') & (self.html[i-1] == '/')):
# stop recording special node and start recording contents: ![在这里插入图片描述]() -->
node = self.follow(path)
node.add(self.html[j:i+1],'')
j = i
elif (self.html[j+1] != '/'):
# stop recording the opening tag and start recording contents: <div> -->
path.append(self.html[j:i+1])
if (tag_name == 'script'):
on_script = True
j = i
elif (self.html[j+1] == '/'):
# stop recording the ending tag: </div> -->
if re.findall(r'<([^ ]+?)[ >]',path[-1])[0] == tag_name[1:]:
path = path[:-1]
j = i
else:
try:
s = -1
while True:
if (re.findall(r'<([^ ]+?)[ >]',path[s])[0] == tag_name[1:]):
if self.print_warning: print("Warning: skipping excessive opening tags %s in order to match %s."%(', '.join(path[s+1:]),self.html[j:i+1]))
path = path[:s]
break
else:
s -= 1
except:
if self.print_warning: print("Warning: ignoring unmatched ending tag %s."%self.html[j:i+1])
j = i
i += 1
if len(path) > 0:
if self.print_warning: print("Warning: unexpected structure of HTML. Please carefully check the completeness of HTML file. If it doesn't work, please ask help from developer. \n(unclosed opening tags: %s)"%', '.join(path))
这样一来,网页解析便完成了,我们可以试试效果:
if __name__ == '__main__':
# basic setting
url = 'https://sz.lianjia.com/ershoufang/rs%E7%BA%A2%E6%A0%91%E6%B9%BE/'
# basic crawling for parsing the HTML file
crawler = MyCrawler()
crawler.parse(url)
print(crawler.html)
print(crawler.top_node.keys())
不出意外的话,您能看到原 HTML 文件,以及解析后的树第一个节点的子节点标签。如果出现了 Warning 开头的字段,说明在解析 HTML 的过程中出现问题,这些问题通常不是由笔者的程序本身造成的,而是网页设计者本身的缺陷,比如不合时宜地多出一个引号,或者多出一个毫无意义的标签。这些问题通常不会对程序造成影响(否则你也无法通过浏览器看到网页内容),但如果数量过大,例如大于 10 条,那么就要小心了(这样的情况笔者在测试过程中还未出现过)。针对以上问题,笔者在 MyCrawler 类下新添加了一个用于 debug 的类:
def debug_count(self):
'''To: Check the number of opening tag and ending tags using a same name.
----------------------
e.g. >>> crawler.debug_count('')
<div>: opening 103, ending 103.
...
Normally, each pair of two numbers should be equal, or there's somewhere wrong
in the HTML file.'''
opening_tags = set(re.findall(r'<([^ ^>^!^/]+?)[ >]',self.html))
ending_tags = set(re.findall(r'</([^ ^<^>^!]+?)>',self.html))
for tag in (opening_tags & ending_tags):
opening = len(re.findall(r'<%s[ >]'%tag,self.html))
ending = len(re.findall(r'</%s>'%tag,self.html))
print('<%s>: opening %d, ending %d.'%(tag,opening,ending))
使用规则很简单:
crawler.debug_count()
运行以上代码,程序会统计原 HTML 中节点出现的次数。具体的使用规则这里很难解释清楚,读者可使用后自行体会。
二、获取路径
接下来的代码同样用于 MyCrawler 类,实现给定文字,获取节点路径的功能。