写个爬虫爬好莱坞八卦新闻

这篇文章提到的源码可以在我的github^0上面找到, 项目名称是whosdatedwho_crawler.

这篇东西是什么鬼


以下是废话.
所有东西的存在都是有原因的. 就算你是在发呆, 也有“我什么事情都不想干”这个原因. 而之所以会有这篇文章, 全都是因为我太懒了.
学校有一门奇怪的课程叫思政课社会实践, 说白了就是社会调查. 很多人都可以周期性的感知到这门课的存在, 因为每隔一段时间在你的qq聊天列表的某个群里就会蹦出来一个“社会调查问卷, 帮忙填一下, 谢谢!!!”, 而且后面一定会跟着一个红包. 但是我是懒的, 而且我也很穷. 不可能会去设计这么一份问卷然后一个一个群用红包去拜托别人. 但是作业还是要做的. 且我的良心不允许我作假. 于是, 我就希望能找到这样一个调查课题, 它所需的信息可以很方便的获取到. 而这个时候, 刚好某老实巴交的男演员被人绿了, 而且他一被绿全中国都知道了, 这让我得到了一个结论:

名人的婚恋八卦是互联网上最不缺乏的资源.

于是, 我想起了一个神奇的网站www.whosdatedwho.com^1, 这上面收录了大量(大概翻了一下, 发现深不见底, 一直到19世纪60年代的记录都有)好莱坞明星(其实也不限好莱坞, 比如秦凯跟何姿的engagement上面也有记录)的恋爱, 分手, 订婚, 结婚和生小孩的信息, 而且过半数的条目里面都会有关于这对couple的各种信息对比, 包括括国籍, 身高, 职业, 年龄等, 可以说是丰富的八卦来源. 于是, 我便开始着手编写爬虫, 把上面的数据爬下来. 有了这玩意, 就可以研究诸如”身高差对好莱坞明星恋情长度的关系”, “年龄差对好莱坞明星恋情长度的关系”之类的问题了.
这篇文章记录了寻找whosdatedwho.com的记录查询接口, 分析网页结构, 数据表设计以及构建爬虫的过程.
废话到此结束.

寻找whosdatedwho.com的记录查询接口


我们爬一个网站的时候, 并不是乱爬的, 理想的情况是:这个网站有一个可翻页的列表, 翻页可以通过改变url中的page之类的参数来控制, 每一页有固定数目的通向详情页面的链接. 这样的话, 我们的爬虫就只需要一页一页地把详情页面的链接抠出来, 然后再跟着这些链接进入到详情页面去爬取需要的信息.
在whosdatedwho的首页中间可以看到一个大大的”Latest Events”, 旁边跟着一个小小的”See More”. 直觉告诉我们这个see more里面会有我们想要得东西. 进去之后发现到了http://www.whosdatedwho.com/timeline, 而这里的确有一个列表, 但是是下拉加载更多的那种. 这个下拉加载更多的玩意儿对于爬虫来说并没有什么用处, 除非我在爬虫里面加上js解释器, 然后再向页面发送模拟的下拉信息, 不过这样太洒了, 我不打算这样做. 我们知道, 凡是这种东西肯定是用ajax或者websocket做出来的, 所以我们只要分析一下源码找到相应的数据获取接口就行了. 逛了了一圈发现这个无限延长的列表在`#ff-7c01f37bfcc5c8dc3e81f5359fd5b212 > div.ff > div.ff-latest-list, 于是我在这个节点上加了一个on subtree modifications断点, 然后下拉, 发现代码停在这样一个地方:

1
2
3
4
5
6
7
append: function() {
return this.domManip(arguments, function(a) {
if (1 === this.nodeType || 11 === this.nodeType || 9 === this.nodeType) {
var b = m(this, a);
=> b.appendChild(a)
}
})

调用栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(anonymous function) (js?f=/dev/zurb/…formatted:2589)
domManip (js?f=/dev/zurb/…formatted:2681)
append (js?f=/dev/zurb/…formatted:2586)
_.fn.(anonymous function) (js?f=/dev/zurb/…formatted:2702)
opts.loading.start (js?f=/static/jq…js,sly.js…:272)
infscr_retrieve (js?f=/static/jq…js,sly.js…:305)
infscr_scroll (js?f=/static/jq…js,sly.js…:308)
(anonymous function) (js?f=/static/jq…js,sly.js…:268)
dispatch (js?f=/dev/zurb/…formatted:2251)
q.handle (js?f=/dev/zurb/…formatted:2143)
trigger (js?f=/dev/zurb/…formatted:2225)
(anonymous function) (js?f=/dev/zurb/…formatted:2487)
each (js?f=/dev/zurb/…:formatted:622)
each (js?f=/dev/zurb/…:formatted:521)
trigger (js?f=/dev/zurb/…formatted:2486)
(anonymous function) (js?f=/static/jq…js,sly.js…:312)

看了以下变量a的值, 发现这是一个img节点, 图像内容是一个圈圈, 看来是下拉的时候出现的那个表示“加载中”的圈圈. 这不是我们想要的, 于是我们让它继续执行. 很快, 代码又停在了同样的地方, 但是此时调用栈已经变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(anonymous function) (js?f=/dev/zurb/…formatted:2589)
domManip (js?f=/dev/zurb/…formatted:2681)
append (js?f=/dev/zurb/…formatted:2586)
_.fn.(anonymous function) (js?f=/dev/zurb/…formatted:2702)
(anonymous function) (js?f=/static/jq…js,sly.js…:639)
each (js?f=/dev/zurb/…:formatted:622)
each (js?f=/dev/zurb/…:formatted:521)
update (js?f=/static/jq…js,sly.js…:639)
(anonymous function) (js?f=/static/jq…5.js,sly.js…:1)
opts.callback (js?f=/static/jq…js,sly.js…:274)
infscr_loadcallback (js?f=/static/jq…js,sly.js…:294)
infscr_ajax_callback (js?f=/static/jq…js,sly.js…:303)
k (js?f=/dev/zurb/…formatted:1720)
fireWith (js?f=/dev/zurb/…formatted:1777)
c (js?f=/dev/zurb/…formatted:3482)
(anonymous function) (js?f=/dev/zurb/…formatted:3744)

ajax四个大字映入眼帘. 进到到infscr_ajax_callback, 发现代码停在这个地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
case 'html':
instance._debug('Using ' + (method.toUpperCase()) + ' via $.ajax() method');
$.ajax({
url: desturl,
dataType: opts.dataType,
complete: function infscr_ajax_callback(jqXHR, textStatus) {
condition = (typeof (jqXHR.isResolved) !== 'undefined') ? (jqXHR.isResolved()) : (textStatus === 'success' || textStatus === 'notmodified');
if (condition) {
=> instance._loadcallback(box, jqXHR.responseText, desturl)
} else {
instance._error('end')
}
}
});
break;

看来就是这里没跑了. 查看desturl的值, 发现是?page=2&_block=page.latestEvents. 也就是说, 这个page=2就是翻页的关键. 于是我把2改成3试了一下, 发现可以正常加载新的内容. (我也尝试过把&_block=page.latestEvents去掉, 不过发现去掉之后不能正常工作.)
于是, 们得到了想要的东西:

第$k$页记录的url为: http://www.whosdatedwho.com/timeline?page=$k$&_block=page.latestEvents

设计数据表


在确认了数据能够抓取之后, 在确定下一步抓取计划之前, 需要确定你要抓取些什么, 以及在何处抓取. 通过观察, 可以发现列表页面有的信息为:

  • 事件类型, 包括: Hookup, Breakup, Engagement, Marriage, Child, Divorce
  • 日期
  • 双方姓名
  • 进入到详情页面的链接

随便进入到一个详情页面, 发现里面能够提供的信息有:

  • 姓名
  • 年龄
  • 身高
  • 星座
  • 职业
  • 发色
  • 瞳色
  • 国籍
  • 匹配度
  • 是否事实
  • 持续时间

数据用传统的关系型数据库存储, 可以设计如下数据表:

whosdatedwho
+--------------+-------------+------+-----+---------+-------+
| Field        | Type        | Null | Key | Default | Extra |
+--------------+-------------+------+-----+---------+-------+
| name1        | varchar(64) | YES  |     | NULL    |       |
| name2        | varchar(64) | YES  |     | NULL    |       |
| gender1      | varchar(8)  | YES  |     | NULL    |       |
| gender2      | varchar(8)  | YES  |     | NULL    |       |
| height1      | smallint(6) | YES  |     | NULL    |       |
| height2      | smallint(6) | YES  |     | NULL    |       |
| age1         | tinyint(4)  | YES  |     | NULL    |       |
| age2         | tinyint(4)  | YES  |     | NULL    |       |
| zodiac1      | varchar(16) | YES  |     | NULL    |       |
| zodiac2      | varchar(16) | YES  |     | NULL    |       |
| occupation1  | varchar(64) | YES  |     | NULL    |       |
| occupation2  | varchar(64) | YES  |     | NULL    |       |
| haircolor1   | varchar(32) | YES  |     | NULL    |       |
| haircolor2   | varchar(32) | YES  |     | NULL    |       |
| eyecolor1    | varchar(32) | YES  |     | NULL    |       |
| eyecolor2    | varchar(32) | YES  |     | NULL    |       |
| nationality1 | varchar(64) | YES  |     | NULL    |       |
| nationality2 | varchar(64) | YES  |     | NULL    |       |
| event        | varchar(64) | YES  |     | NULL    |       |
| date         | date        | YES  |     | NULL    |       |
| duration     | tinyint(4)  | YES  |     | NULL    |       |
| score        | tinyint(4)  | YES  |     | NULL    |       |
+--------------+-------------+------+-----+---------+-------+

相应的SQL语句为:

1
2
3
4
5
6
7
8
9
10
11
create table whosdatedwho (name1 varchar(64), name2 varchar(64),
gender1 varchar(8), gender2 varchar(8),
height1 smallint, height2 smallint,
age1 tinyint, age2 tinyint,
zodiac1 varchar(16), zodiac2 varchar(16),
occupation1 varchar(64), occupation2 varchar(64),
haircolor1 varchar(32), haircolor2 varchar(32),
eyecolor1 varchar(32), eyecolor2 varchar(32),
nationality1 varchar(64), nationality2 varchar(64),
event varchar(64), date date,
duration tinyint, score tinyint);

构建抓取xpath


我们采用xpath来定位信息承载节点, 这个东西纯粹是个体力活. 以详情页面中的couple comparison部分为例, 在#ff-couple-comparison中, 可以看到name相关的部分是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
<div class="row collapse">
<div style="color:#c6c6c6;" class="small-12 columns text-center">Name</div>
</div>
<div class="row collapse">
<div style="padding-right:1rem;border-right:1px solid #d6d6d6;" class="small-6 columns text-right">
<h5></h5>
<h5> Cassie Ventura </h5>
</div>
<div style="padding-left:1rem;border-left:1px solid #d6d6d6;" class="small-6 columns text-left">
<h5></h5>
<h5> Sean Combs </h5>
</div>
</div>
...

其结构特点为: 由一个担任标题作用的#row collapse引导出接下来的几个包含信息的#row collapse, 信息藏在一对h5标签之后. 虽然每个页面有的信息不尽相同, 但是我们可以通过遍历这些#row collapse来判断出现了哪些信息. 比如上面出现了Name, 就可以知道下一个#row collapse肯定包含了名字信息, 所以我就可以在下一个#row collapse中提取相关信息, 然后再从下下个#row collapse开始继续往下遍历, 直至遍历完这一系列#row collapse.
需要注意的一点是, couple comparison中男女出现的位置是是不一定的, 有时男的在左, 有时女的在左, 有时男的既在左又在右, 所以需要进行判断, 判断的方法是读取height一栏中图片的alt属性(Male or Female).
具体的xpath可以参见源码.

构建爬虫


其实到了这一步基本上已经水到渠成了. 爬虫使用python+scrapy实现. 需要注意的一点是, 并不是每一个item都包含了所有的字段, 所以在向数据库插入数据时不能简单地用一个字符串模板来构建sql语句, 需要根据有的字段动态构建, 代码如下;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
field_list = []
value_list = []
for key in item:
field_list.append(key)
value = item.fields[key]["serializer"](item[key])
if isinstance(value, unicode):
value_list.append(cymysql.escape_string(value))
elif isinstance(value, int):
value_list.append(str(value))
self.cur.execute("insert into whosdatedwho(%s) values(%s)"%
(",".join(field_list,
",".join(value_list)))
...

还有就是要注意转义, 别自己把自己给注入了.

实际爬取


接下来只需要crapy crawl whosdatedwho就可以去找个阴凉的地方喝茶慢慢等了. 爬虫的速度还是很不错的, 睡了个午觉, 起来来玩了会儿手机, 就有了33330条记录, 从1995到2016, 对于这种过家家的作业而言, 这3万多条数据足够了.