bilibili 新番承包付费意愿调查

2014年10月1日 bilibili 正版新番承包上线,我就对 bilibili 这种自愿付费的方式感到好奇。而且经常听到「正版体验不佳」,「正版动画比起盗版,有何优势呢」的言论,那么中国用户在不插广告,不优先播放,不强制的情况下,到底愿意为「爱」付多少钱呢?

为什么 bilibili?

bilibili 早期,新番都是用户上传的,可以说是典型的「盗版网站」。那么这个拥有大量用户的「盗版网站」体验应该说不差吧。

随着 bilibili 开始购买版权,现在新番实际上是正版盗版共存的模式,而 bilibili 不插前置广告(当然现在有「约不约」了),不强制付费;从体验上看,特别相对其他国内视频网站,应该是最接近「盗版」的了。

付费人数

bilibili 新番承包人数可以非常方便的从番剧的介绍页面上获知:

sponsor count

但是由于动画的类型不同,热度不同,独播非独播用户(路人)成分不同,直接比较数字没有什么意义。需要首先找到一个基准来讨论播放和付费数之间的关系。

我抓取了新番承包上线以来新开播的 142 部新番(http://demo.pyspider.org/results?project=bgm_bilibili),去除话数小于10的 OAD,OVA 等,剩下 136 部。将总播放,追番人数,弹幕总数画为散点图:

追番人数,弹幕总数 / 总播放

由图可知,弹幕总数和总播放数相关性比追番人数相关性更大(参照 R^2)。独家和非独家新番在弹幕参与度上相差不大,但是非独家的追番率比独家新番少了一半。难道非独家新番用户大多是非 bilibili 注册用户吗?这说不通啊,明明应该是反过来,非会员不得不到 bilibili 上看才对啊。。

付费比例

在开始写这一节的时候,我本想应该挺简单的,承包人数要么和播放数正相关,要么和活跃(弹幕数)相关,要么就和追番人数相关。但是经过了3个小时,当我尝试了:

  • 总播放数
  • 平均每集播放数
  • 弹幕数
  • 收藏(追番)人数
  • 时间
  • 是否独家新番

画了20+张图之后发现,问题并没有这么简单。很难有一个什么方法能够预测出用户的付费意愿,有很多叫好不叫座,或者叫座不叫好,导致付费比例非常分散:

承包 / 总播放数

图中左边是独播新番,右边为非独播

这里面会发现一些有趣的地方:在独播和非独播中,都有一个承包比例非常高的点,分别是《电器街的漫画店》和《Fate/stay night [Unlimited Blade Works] 第一季》,他们都是2014年10月番,正好是新番承包刚上线时的作品,可能用户对承包模式的尝鲜,或者前期宣传上的增益。

将特异点排除之后,发现不管是否独播,他们的付费比例差别不大,但是非独播的方差大得多:

去除特异点后的 承包 / 总播放数

平均上来说,bilibili 的付费比例约为播放数的万分之 1.447,收藏人数的千分之5.373。但是这只能是整体估计,具体到单个番剧就没有意义了。

用户到底为了什么付费?

那么,具体到每一部番剧,用户到底因为什么因素愿意付费呢?

当我将付费比例前10与付费比例后10的放在一起比较,试图找出答案的时候,我真的失败了:

承包比例

在前10中有在我看来「这什么鬼」的,在后10中也有追过的,完全搞不懂拥有更高收费比例的番剧是为什么。当然,通过加入声优,导演,制作,类型 tag 等因素,或许可能找到原因,但这样少的数据,又很容易陷入过拟合的境地(如果有兴趣,可以下载数据分析看看)。

付费金额

人均承包金额

虽然在 bilibili 页面中有承包商排名,但是并不知道付费的金额,仅在你承包的时候,给出你当前的排名。为了了解承包商们在这样没有强制金额的「捐献」中愿意付多少钱,我从1元开始承包,然后查看我当前的排名来获得各个区段的人数:

承包总榜

为了消除连载中,独播,类型等影响,这里选择了连载中,非独播的的 Re:Zero 和已完结,独播,稍微腐女向的 K RETURN

付费区间人数

从图中可以看出:

  • 主要用户的付费金额在 5-13(不要吐槽为什么是13,手抖了!)
  • 不同类型的番剧,承包金额的分布几乎是完全一样的
  • TOP 付费用户的付费金额比较高,Re:Zero 我付到 500 元,依然有 3 个比我高的(于是我放弃了)
  • 同样付出 150 元,在 Re:Zero 中能排到 19,而在 K 中只能排到 37,还不论 K 的付费人数实际少于 Re:Zero(腐女还是有钱人多啊)

那么,假如我们不考虑前 3 位的土豪,人均承包金额约为 13.08 元。因为我们并不知道土豪能为我们拉升多少身价,那,即使我们现在假设排名前三的土豪均承包五千块,人均承包金额也不过18元。为了简化,我们取20块好了。因为选取了两部因素差异蛮大的动画,得知不同因素对承包金额的分布影响不大,这个人均承包金额是可以套用到不同番剧上的。

承包收入

那我们算一下,bilibili 通过新番承包,到底能赚多少钱呢?因为承包人数是公开的,乘以估计的人均20块的话,bilibili 承包收入收入排行:

承包收入排行

根据网上的传言,每集非独播新番版权价格大约是5万,独播更贵。那么好,我们统统算1万一集吧(对,就是这么任性)!那么也就 《Fate/stay night [Unlimited Blade Works] 第一季》 和 《电器街的漫画店》 实现了盈利。记得我们前面说过的付费比例异常吗?对,就是这两部「盈利」了的番剧。

从整体来看,bilibili 通过承包总收入为 288.7 万,平均每部番剧的承包收入是 21388 元,不打折的话,一集都买不起啊!

我想说什么

经常有人会用「正版体验不佳」作为盗版的理由,说得好像正版体验一样了就会付费了似的。bilibili 同时有提供正版和盗版内容,正版有比盗版体验差吗?难道正版看得人就少了吗?好,就算确实看正版的人少了,我们不看绝对值,那这寒酸的千分之5.373的付费比例是怎么回事?什么「正版体验不佳」啊,「要付钱当然体验不佳」啦。

另外一个常见的理由是「学生党,没有钱」,人均 20 块太贵出不起。请回过头看看追番人数,要是每个人出一块钱,那也要比现在这千分之5.373,人均20块的总收入高啊!一块钱都出不起吗?这可是一季动画,而不是一集让你出一块钱啊!看动画都是因为爱,而这份爱,连一块钱都不值吗?

爱

bilibili 不想通过广告那样半强制地收回那么一点点版权费,然而看起来这「爱」并不畅销。所以,我弱弱地提议,各位有爱的小伙伴,在看完一季动画后(是的,不喜欢可以不承包),从微信红包(是的,不用银行卡)中拿出那么一块钱(是的,最低承包不是5块,是可以改的),承包一下你喜欢的动画吧。。希望在「劣币驱逐良币」之前,良币不会先自己饿死吧。

demo.pyspider.org 部署经验

经常有人会问 pyspider 怎么进行分布式部署,这里以 demo.pyspider.org 的实际部署经验做一个例子。

因为 pyspider 支持分布式部署,为了验证也好,为了省钱多蹭 CPU 也好, demo.pyspider.org 通过 docker 部署在同一机房的 3 台 VPS 上,VPS 间有内网传输(实际通过 tinc)。

使用 docker 的原因是实际上 pyspider 能够运行任何 python 脚本,至少需要 docker 环境逃逸。

数据库 & 消息队列

demo.pyspider.org 的数据库为 PostgreSQL,理由是测试目的,磁盘占用和性能的折中。消息队列为 Redis,因为部署简单。

它们也是跑在 docker 中的:

1
2
docker run --name postgres -v /data/postgres/:/var/lib/postgresql/data -d -p $LOCAL_IP:5432:5432 -e POSTGRES_PASSWORD="" postgres
docker run --name redis -d -p $LOCAL_IP:6379:6379 redis

由于前面说过,机器间有内网,通过绑定内网 IP,没有做鉴权(反正 demo 会泄露)。

scheduler

由于 scheduler 只能运行一个,并且需要进行大量的数据库操作,它与上面的数据库和消息队列部署在一台单独的机器上。

1
2
3
4
5
6
docker run --name scheduler -d -p $LOCAL_IP:23333:23333 --restart=always binux/pyspider \
--taskdb "sqlalchemy+postgresql+taskdb://binux@10.21.0.7/taskdb" \
--resultdb "sqlalchemy+postgresql+resultdb://binux@10.21.0.7/resultdb" \
--projectdb "sqlalchemy+postgresql+projectdb://binux@10.21.0.7/projectdb" \
--message-queue "redis://10.21.0.7:6379/1" \
scheduler --inqueue-limit 5000 --delete-time 43200

其他组件

所有其他的组件(fetcher, processor, result_worker)在剩余的两台 VPS 上以相同的配置启动。他们都是通过 docker-compose 管理的

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
phantomjs:
image: 'binux/pyspider:latest'
command: phantomjs
cpu_shares: 512
environment:
- 'EXCLUDE_PORTS=5000,23333,24444'
expose:
- '25555'
mem_limit: 512m
restart: always
phantomjs-lb:
image: 'dockercloud/haproxy:latest'
links:
- phantomjs
restart: always
fetcher:
image: 'binux/pyspider:latest'
command: '--message-queue "redis://10.21.0.7:6379/1" --phantomjs-proxy "phantomjs:80" fetcher --xmlrpc'
cpu_shares: 512
environment:
- 'EXCLUDE_PORTS=5000,25555,23333'
links:
- 'phantomjs-lb:phantomjs'
mem_limit: 128m
restart: always
fetcher-lb:
image: 'dockercloud/haproxy:latest'
links:
- fetcher
restart: always
processor:
image: 'binux/pyspider:latest'
command: '--projectdb "sqlalchemy+postgresql+projectdb://binux@10.21.0.7/projectdb" --message-queue "redis://10.21.0.7:6379/1" processor'
cpu_shares: 512
mem_limit: 256m
restart: always
result-worker:
image: 'binux/pyspider:latest'
command: '--taskdb "sqlalchemy+postgresql+taskdb://binux@10.21.0.7/taskdb" --projectdb "sqlalchemy+postgresql+projectdb://binux@10.21.0.7/projectdb" --resultdb "sqlalchemy+postgresql+resultdb://binux@10.21.0.7/resultdb" --message-queue "redis://10.21.0.7:6379/1" result_worker'
cpu_shares: 512
mem_limit: 256m
restart: always
webui:
image: 'binux/pyspider:latest'
command: '--taskdb "sqlalchemy+postgresql+taskdb://binux@10.21.0.7/taskdb" --projectdb "sqlalchemy+postgresql+projectdb://binux@10.21.0.7/projectdb" --resultdb "sqlalchemy+postgresql+resultdb://binux@10.21.0.7/resultdb" --message-queue "redis://10.21.0.7:6379/1" webui --max-rate 0.2 --max-burst 3 --scheduler-rpc "http://o4.i.binux.me:23333/" --fetcher-rpc "http://fetcher/"'
cpu_shares: 512
environment:
- 'EXCLUDE_PORTS=24444,25555,23333'
links:
- 'fetcher-lb:fetcher'
mem_limit: 256m
restart: always
webui-lb:
image: 'dockercloud/haproxy:latest'
links:
- webui
restart: always
nginx:
image: 'nginx'
links:
- 'webui-lb:HAPROXY'
ports:
- '0.0.0.0:80:80'
volumes:
- /home/binux/nfs/profile/nginx/nginx.conf:/etc/nginx/nginx.conf
- /home/binux/nfs/profile/nginx/conf.d/:/etc/nginx/conf.d/
restart: always

然后通过 docker-compose scale phantomjs=2 processor=2 webui=4 指定启动两个 phantomjs 进程,两个 processor 进程,4个 webui 进程。

phantomjs

由于 phantomjs 有内存泄露问题,限制下内存就好了。EXCLUDE_PORTS 是为了下面的 haproxy 能够正确的均衡负载正确端口。

phantomjs-lb

通过 haproxy 自动负载均衡,只要将服务链接上去,就会将请求分发到不定多个 phantomjs 实例上,同时只暴露一个对外服务端口。

fetcher

链接 phantomjs-lb:phantomjs,注意这里的 --phantomjs-proxy "phantomjs:80"

由于 fetcher 是异步 http 请求,如果没有发生堵塞,单个 fetcher 一般就足够了。

fetcher-lb

同 phantomjs-lb

processor

processor 为最消耗 CPU 的组件,建议根据 CPU 的数量部署 +1/2 个。

result-worker

默认的 result-worker 只是在写数据库,除非发生堵塞,或者你重载了 result_worker,一个就够。

webui

首先,webui 为了安全性,限制了最大抓取速率 --max-rate 0.2 --max-burst 3

然后通过实际的 fetcher 进行抓取 --fetcher-rpc "http://fetcher/" 而不是 webui 自己发起请求,最大程度模拟环境(IP,库版本),因为以前遇到过调试的时候没问题,跑起来失败,然后在调试器复现又没法复现的问题。fetcher-rpc 可以不用,这样的会 webui 会自己直接发起请求。

因为 demo.pyspider.org 主要就是提供通过页面来尝试 pyspider, 这里的负载较大,而且实现上是同步的,任何脚本执行,抓取都是堵塞了,多一些 webui 会比较好。

webui-lb

同 phantpmjs-lb

nginx

这里做了一些前端缓存

其他

因为懒得管,每小时我会重启除了 scheduler 以外的其他组件(反正会重试)。

足兆叉虫的2015

我是从来不记日子的,这导致我也不知道有些事情是2015年发生的,还是2014年发生的,亦或只是我的臆想。即便如此,2015年也是变化的一年。

跳槽,工资没涨…… 到这里居然和2013年是一样的,但是当我在2015写下这篇日志的时候,国内已经2016。

说来惭愧,这一年除了一月写了几篇教程之后,不但 blog 落下了,开源也没有做多少。看着 pyspider 的 star 数蹭蹭涨到 5813,但是并没有太多精力去更新。希望在 2016 年能有时间把 slime 模式的坑填了吧。

其他的项目也就在年末的时候又重新玩了一把 WebRTC,基于的 WebTorrent 经过一年的开发,已经成熟了很多,feross 在 javascript 上从 tracker 到 BT 协议都实现了一遍,比起我那时山寨的好了非常多,虽然 hybrid 模式还有很多问题。。。对了 2015 年参与过 technical review 的 Learning WebRTC 也出版了,算是一次挺有趣的经历吧。

8月到英国之后,就是各种适应,加上新公司的蜜月期,一门心思放在了公司的项目上。在新公司才算是第一次接触到了机器学习,给我带来了很多新的思路,有种能成的感觉吧。

希望2016年能更有趣吧。

英国(二)

然后说一些「关于英国生活」类似的东西吧,想到什么写什么

衣食住行

  • 夏天不热,这个冬天不冷
  • 冬天是雨季,几乎一半时间在下雨,但是从来没有在上下班的时候下
  • 英国不负「难吃国」之名,只要是英文店名的地方,那真是难吃 + 贵
  • 中午的饭点是1-2点,晚餐饭点是8-9点
  • 一个很大问题是,看菜单很多时候不知道是什么东西,查字典也没用
  • 一盒 200g 的毛豆都要卖20-30RMB,而且还被他们视为高贵的健康食品
  • 有很多中国人开的中餐馆和日式便当,这是唯一能吃的东西(除了 KFC)
  • 超市的肉类品种和部位很不同,炒出来很老,我用了3个月才想出来怎么吃
  • 鸡翅鸡腿比鸡胸便宜,只要10RMB/斤
  • 肉比蔬菜便宜
  • 住非常贵,北京的7-8倍
  • 行非常贵,北京的5-10倍
  • 伦敦很小,和北京比起来

公司

  • 公司现在有40+个人,但是有15种国籍,22+种语言
  • 有4个华裔同事,但是任意两者之间只能用英语交流(除英语外会的语言分别是普通话,粤语,日语,马来语,德语)
  • 没有印度人
  • 一年25天年假,8天法定假日,没有年终奖
  • 不加班的主要原因是没有晚餐,肚子饿
  • 公司以外听不懂别人说什么 =_=

英国

半年没有更新 blog 了,其实我在憋(wan)个(W)大(O)招(W)。一年半以前,我说过「想学日语,想出国」,虽然这一年半也是没有干劲地随随便便过的,但是至少出国了。

目的地英国,工作,8月初。

要说经历,其实简单到爆。

  1. 投简历(依旧是只投一家)
  2. 视频面试(用我8年没用过的蹩脚英语)
  3. 考雅思(而且只要4分,我只做了套剑9就去了)
  4. 对方办 certificates of sponsorship(虽然每月有名额限制,按照打分排,但是以往的数据,只要满足条件,名额都够。)
  5. 办签证(不像旅游签证,不用行程单,不用资产证明,有 COS 就 ok)
  6. 买机票
  7. over

工资没涨,物价大涨,难吃,加上烂英语,瞬间感觉生活甚是艰辛。。。不过, there is always a way,就当作一次挑战吧。

就酱

pyspider 爬虫教程(三):使用 PhantomJS 渲染带 JS 的页面

英文原文:http://docs.pyspider.org/en/latest/tutorial/Render-with-PhantomJS/

在上两篇教程中,我们学习了怎么从 HTML 中提取信息,也学习了怎么处理一些请求复杂的页面。但是有一些页面,它实在太复杂了,无论是分析 API 请求的地址,还是渲染时进行了加密,让直接抓取请求非常麻烦。这时候就是 PhantomJS 大显身手的时候了。

在使用 PhantomJS 之前,你需要安装它(安装文档)。当你安装了之后,在运行 all 模式的 pyspider 时就会自动启用了。当然,你也可以在 demo.pyspider.org 上尝试。

使用 PhantomJS

当 pyspider 连上 PhantomJS 代理后,你就能通过在 self.crawl 中添加 fetch_type='js' 的参数,开启使用 PhantomJS 抓取。例如,在教程二中,我们尝试抓取的 http://movie.douban.com/explore 就可以通过 PhantomJS 直接抓取:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Handler(BaseHandler):
def on_start(self):
self.crawl('http://movie.douban.com/explore',
fetch_type='js', callback=self.phantomjs_parser)
def phantomjs_parser(self, response):
return [{
"title": "".join(
s for s in x('p').contents() if isinstance(s, basestring)
).strip(),
"rate": x('p strong').text(),
"url": x.attr.href,
} for x in response.doc('a.item').items()]
  • 我在这里使用了一些 PyQuery 的 API,你可以在 PyQuery complete API 获得完整的 API 手册。

在页面上执行自定义脚本

你会发现,在上面我们使用 PhantomJS 抓取的豆瓣热门电影只有 20 条。当你点击『加载更多』时,能获得更多的热门电影。为了获得更多的电影,我们可以使用 self.crawljs_script 参数,在页面上执行一段脚本,点击加载更多:

1
2
3
4
5
6
def on_start(self):
self.crawl('http://movie.douban.com/explore#more',
fetch_type='js', js_script="""
function() {
setTimeout("$('.more').click()", 1000);
}""", callback=self.phantomjs_parser)
  • 这个脚本默认在页面加载结束后执行,你可以通过 js_run_at 参数 修改这个行为
  • 由于是 AJAX 异步加载的,在页面加载完成时,第一页的电影可能还没有加载完,所以我们用 setTimeout 延迟 1 秒执行。
  • 你可以间隔一定时间,多次点击,这样可以加载更多页。
  • 由于相同 URL (实际是相同 taskid) 的任务会被去重,所以这里为 URL 加了一个 #more

上面两个例子,都可以在 http://demo.pyspider.org/debug/tutorial_douban_explore 中找到。

pyspider 爬虫教程(二):AJAX 和 HTTP

在上一篇教程中,我们使用 self.crawl API 抓取豆瓣电影的 HTML 内容,并使用 CSS 选择器解析了一些内容。不过,现在的网站通过使用 AJAX 等技术,在你与服务器交互的同时,不用重新加载整个页面。但是,这些交互手段,让抓取变得稍微难了一些:你会发现,这些网页在抓回来后,和浏览器中的并不相同。你需要的信息并不在返回 HTML 代码中。

在这一篇教程中,我们会讨论这些技术 和 抓取他们的方法。(英文版:AJAX-and-more-HTTP

AJAX

AJAX 是 Asynchronous JavaScript and XML(异步的 JavaScript 和 XML)的缩写。AJAX 通过使用原有的 web 标准组件,实现了在不重新加载整个页面的情况下,与服务器进行数据交互。例如在新浪微博中,你可以展开一条微博的评论,而不需要重新加载,或者打开一个新的页面。但是这些内容并不是一开始就在页面中的(这样页面就太大了),而是在你点击的时候被加载进来的。这就导致了你抓取这个页面的时候,并不能获得这些评论信息(因为你没有『展开』)。

AJAX 的一种常见用法是使用 AJAX 加载 JSON 数据,然后在浏览器端渲染。如果能直接抓取到 JSON 数据,会比 HTML 更容易解析。

当一个网站使用了 AJAX 的时候,除了用 pyspider 抓取到的页面和浏览器看到的不同以外。你在浏览器中打开这样的页面,或者点击『展开』的时候,常常会看到『加载中』或者类似的图标/动画。例如,当你尝试抓取:http://movie.douban.com/explore

douban explore

你会发现电影是『载入中…』

找到真实的请求

由于 AJAX 实际上也是通过 HTTP 传输数据的,所以我们可以通过 Chrome Developer Tools 找到真实的请求,直接发起真实请求的抓取就可以获得数据了。

  1. 打开一个新窗口
  2. Ctrl+Shift+I (在 Mac 上请按 Cmd+Opt+I) 打开开发者工具。
  3. 切换到网络( Netwotk 面板)
  4. 在窗口中打开 http://movie.douban.com/explore

在页面加载的过程中,你会在面板中看到所有的资源请求。

douban explore network panel

AJAX 一般是通过 XMLHttpRequest 对象接口发送请求的,XMLHttpRequest 一般被缩写为 XHR。点击网络面板上漏斗形的过滤按钮,过滤出 XHR 请求。挨个查看每个请求,通过访问路径和预览,找到包含信息的请求:http://movie.douban.com/j/search_subjects?type=movie&tag=%E7%83%AD%E9%97%A8&sort=recommend&page_limit=20&page_start=0

douban explore xhr preview

在豆瓣这个例子中,XHR 请求并不多,可以挨个查看来确认。但在 XHR 请求较多的时候,可能需要结合触发动作的时间,请求的路径等信息帮助在大量的请求中找到包含信息的关键请求。这需要抓取或者前端的相关经验。所以,有一个我一直在提的观点,学习抓取的最好方法是:学会写网站。

现在可以在新窗口中打开 http://movie.douban.com/j/search_subjects?type=movie&tag=%E7%83%AD%E9%97%A8&sort=recommend&page_limit=20&page_start=0,你会看到包含电影数据的 JSON 原始数据。推荐安装 JSONViewFirfox版)插件,这样可以看到更好看的 JSON 格式,展开折叠列等功能。然后,我们根据 JSON 数据,编写一个提取电影名和评分的脚本:

1
2
3
4
5
6
7
8
9
10
11
class Handler(BaseHandler):
def on_start(self):
self.crawl('http://movie.douban.com/j/search_subjects?type=movie&tag=%E7%83%AD%E9%97%A8&sort=recommend&page_limit=20&page_start=0',
callback=self.json_parser)
def json_parser(self, response):
return [{
"title": x['title'],
"rate": x['rate'],
"url": x['url']
} for x in response.json['subjects']]
  • 你可以使用 response.json 将结果转为一个 python 的 dict 对象

你可以在 http://demo.pyspider.org/debug/tutorial_douban_explore 获得完整的代码,并进行调试。脚本中还有一个使用 PhantomJS 渲染的提取版本,将会在下一篇教程中介绍。

HTTP

HTTP 是用来传输网页内容的协议。在前面的教程中,我们已经通过 self.crawl 接口提交了 URL 进行了抓取。这些抓取就是通过 HTTP 协议传输的。

在抓取过程中,你可能会遇到类似 403 Forbidden,或者需要登录的情况,这时候你就需要正确的 HTTP 参数进行抓取了。

一个典型的 HTTP 请求包如下,这个请求是发往 http://example.com/ 的:

1
2
3
4
5
6
7
8
9
10
11
GET / HTTP/1.1
Host: example.com
Connection: keep-alive
Cache-Control: max-age=0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.45 Safari/537.36
Referer: http://en.wikipedia.org/wiki/Example.com
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-CN,zh;q=0.8
If-None-Match: "359670651"
If-Modified-Since: Fri, 09 Aug 2013 23:54:35 GMT
  • 请求的第一行包含 method, path 和 HTTP 协议的版本信息
  • 余下的行被称为 header,是以 key: value 的形式呈现的
  • 如果是 POST 请求,在请求结尾可能还会有 body 内容

你可以通过前面用过的 Chrome Developer Tools 工具查看到这些信息:

request headers

在大多数时候,使用正确的 method, path, headersbody 总是能抓取到你需要的信息的。

HTTP Method

HTTP Method 告诉服务器对 URL 资源期望进行的操作。例如在打开一个 URL 的时候使用的是 GET 方式,而在提交数据的时候一般使用 POST。

TODO: need example here

HTTP Headers

HTTP Headers 是请求所带的一个参数列表,你可以在 这里 找到完整的常用 Headers 列表。一些常用的需要注意的有:

User-Agent

UA 是标识你使用的浏览器,或抓取程序的一段字符串。pyspider 使用的默认 UA 是 pyspider/VERSION (+http://pyspider.org/)。网站常用这个字符串来区分用户的操作系统和浏览器,以及判断对方是否是爬虫。所以在抓取的时候,常常会对 UA 进行伪装。

在 pyspider 中,你可以通过 self.crawl(URL, headers={'User-Agent': 'pyspider'}),或者是 crawl_config = {'headers': {'User-Agent': 'xxxx'}} 来指定脚本级别的 UA。详细请查看 API 文档

Referer

Referer 用于告诉服务器,你访问的上一个网页是什么。常常被用于防盗链,在抓取图片的时候可能会用到。

X-Requested-With

当使用 XHR 发送 AJAX 请求时会带上的 Header,常被用于判断是不是 AJAX 请求。例如在 北邮人论坛 中,你需要:

1
2
3
4
def on_start(self):
self.crawl('http://bbs.byr.cn/board/Python',
headers={'X-Requested-With': 'XMLHttpRequest'},
callback=self.index_page)

带有 headers={'X-Requested-With': 'XMLHttpRequest'} 才能抓取到内容。

虽然 Cookie 只是 HTTP Header 中的一个,但是因为非常重要,但是拿出来说一下。Cookie 被 HTTP 请求用来区分、追踪用户的身份,当你在一个网站登录的时候,就是通过写入 Cookie 字段来记录登录状态的。

当遇到需要登录的网站,你需要通过设置 Cookie 参数,来请求需要登录的内容。Cookie 可以通过开发者工具的请求面板,或者是资源面板中获得。在 pyspider 中,你也可以使用 response.cookies 获得返回的 cookie,并使用 self.crawl(URL, cookie={'key': 'value'}) 来设置请求的 Cookie 参数。

pyspider 爬虫教程(一):HTML 和 CSS 选择器

虽然以前写过 如何抓取WEB页面如何从 WEB 页面中提取信息。但是感觉还是需要一篇 step by step 的教程,不然没有一个总体的认识。不过,没想到这个教程居然会变成一篇译文,在这个爬虫教程系列文章中,会以实际的例子,由浅入深讨论爬取(抓取和解析)的一些关键问题。

在 教程一 中,我们将要爬取的网站是豆瓣电影:http://movie.douban.com/

你可以在: http://demo.pyspider.org/debug/tutorial_douban_movie 获得完整的代码,和进行测试。

开始之前

由于教程是基于 pyspider 的,你可以安装一个 pyspider(Quickstart,也可以直接使用 pyspider 的 demo 环境: http://demo.pyspider.org/

你还应该至少对万维网是什么有一个简单的认识:

  • 万维网是一个由许多互相链接的超文本页面(以下简称网页)组成的系统。
  • 网页使用网址(URL)定位,并链接彼此
  • 网页使用 HTTP 协议传输
  • 网页使用 HTML 描述外观和语义

所以,爬网页实际上就是:

  1. 找到包含我们需要的信息的网址(URL)列表
  2. 通过 HTTP 协议把页面下载回来
  3. 从页面的 HTML 中解析出需要的信息
  4. 找到更多这个的 URL,回到 2 继续

选取一个开始网址

既然我们要爬所有的电影,首先我们需要抓一个电影列表,一个好的列表应该:

  • 包含足够多的电影的 URL
  • 通过翻页,可以遍历到所有的电影
  • 一个按照更新时间排序的列表,可以更快抓到最新更新的电影

我们在 http://movie.douban.com/ 扫了一遍,发现并没有一个列表能包含所有电影,只能退而求其次,通过抓取分类下的所有的标签列表页,来遍历所有的电影: http://movie.douban.com/tag/

创建一个项目

在 pyspider 的 dashboard 的右下角,点击 “Create” 按钮

Creating a project

替换 on_start 函数的 self.crawl 的 URL:

1
2
3
@every(minutes=24 * 60)
def on_start(self):
self.crawl('http://movie.douban.com/tag/', callback=self.index_page)
  • self.crawl 告诉 pyspider 抓取指定页面,然后使用 callback 函数对结果进行解析。
  • @every) 修饰器,表示 on_start 每天会执行一次,这样就能抓到最新的电影了。

点击绿色的 run 执行,你会看到 follows 上面有一个红色的 1,切换到 follows 面板,点击绿色的播放按钮:

Run ont step

Tag 列表页

tag 列表页 中,我们需要提取出所有的 电影列表页 的 URL。你可能已经发现了,sample handler 已经提取了非常多大的 URL,所有,一种可行的提取列表页 URL 的方法就是用正则从中过滤出来:

1
2
3
4
5
6
7
8
import re
...
@config(age=10 * 24 * 60 * 60)
def index_page(self, response):
for each in response.doc('a[href^="http"]').items():
if re.match("http://movie.douban.com/tag/\w+", each.attr.href, re.U):
self.crawl(each.attr.href, callback=self.list_page)
  • 由于 电影列表页和 tag列表页长的并不一样,在这里新建了一个 callbackself.list_page
  • @config(age=10 * 24 * 60 * 60) 在这表示我们认为 10 天内页面有效,不会再次进行更新抓取

由于 pyspider 是纯 Python 环境,你可以使用 Python 强大的内置库,或者你熟悉的第三方库对页面进行解析。不过更推荐使用 CSS选择器。

电影列表页

再次点击 run 让我们进入一个电影列表页(list_page)。在这个页面中我们需要提取:

CSS选择器

CSS选择器,顾名思义,是 CSS 用来定位需要设置样式的元素 所使用的表达式。既然前端程序员都使用 CSS选择器 为页面上的不同元素设置样式,我们也可以通过它定位需要的元素。你可以在 CSS 选择器参考手册 这里学习更多的 CSS选择器 语法。

在 pyspider 中,内置了 response.docPyQuery 对象,让你可以使用类似 jQuery 的语法操作 DOM 元素。你可以在 PyQuery 的页面上找到完整的文档。

CSS Selector Helper

在 pyspider 中,还内置了一个 CSS Selector Helper,当你点击页面上的元素的时候,可以帮你生成它的 CSS选择器 表达式。你可以点击 Enable CSS selector helper 按钮,然后切换到 web 页面:

css selector helper

开启后,鼠标放在元素上,会被黄色高亮,点击后,所有拥有相同 CSS选择器 表达式的元素会被高亮。表达式会被插入到 python 代码当前光标位置。创建下面的代码,将光标停留在单引号中间:

1
2
def list_page(self, response):
for each in response.doc('').items():

点击一个电影的链接,CSS选择器 表达式将会插入到你的代码中,如此重复,插入翻页的链接:

1
2
3
4
5
6
def list_page(self, response):
for each in response.doc('HTML>BODY>DIV#wrapper>DIV#content>DIV.grid-16-8.clearfix>DIV.article>DIV>TABLE TR.item>TD>DIV.pl2>A').items():
self.crawl(each.attr.href, callback=self.detail_page)
# 翻页
for each in response.doc('HTML>BODY>DIV#wrapper>DIV#content>DIV.grid-16-8.clearfix>DIV.article>DIV.paginator>A').items():
self.crawl(each.attr.href, callback=self.list_page)
  • 翻页是一个到自己的 callback 回调

电影详情页

再次点击 run,follow 到详情页。使用 css selector helper 分别添加电影标题,打分和导演:

1
2
3
4
5
6
7
def detail_page(self, response):
return {
"url": response.url,
"title": response.doc('HTML>BODY>DIV#wrapper>DIV#content>H1>SPAN').text(),
"rating": response.doc('HTML>BODY>DIV#wrapper>DIV#content>DIV.grid-16-8.clearfix>DIV.article>DIV.indent.clearfix>DIV.subjectwrap.clearfix>DIV#interest_sectl>DIV.rating_wrap.clearbox>P.rating_self.clearfix>STRONG.ll.rating_num').text(),
"导演": [x.text() for x in response.doc('a[rel="v:directedBy"]').items()],
}

注意,你会发现 css selector helper 并不是总是能提取到合适的 CSS选择器 表达式。你可以在 Chrome Dev Tools 的帮助下,写一个合适的表达式:

Chrome Dev Tools

右键点击需要提取的元素,点击审查元素。你并不需要像自动生成的表达式那样写出所有的祖先节点,只要写出那些能区分你不需要的元素的关键节点的属性就可以了。不过这需要抓取和网页前端的经验。所以,学习抓取的最好方法就是学会这个页面/网站是怎么写的。

你也可以在 Chrome Dev Tools 的 Javascript Console 中,使用 $$(a[rel="v:directedBy"]) 测试 CSS Selector。

开始抓取

  1. 使用 run 单步调试你的代码,对于用一个 callback 最好使用多个页面类型进行测试。然后保存。
  2. 回到 Dashboard,找到你的项目
  3. status 修改为 DEBUGRUNNING
  4. run 按钮

pyspider index page

迁移 Python 3

使用 Python 3 的呼声一直很高,Python 3 解决了很多 2 中的坑,比如 unicode,在向他们解释为什么 print str 乱码,fp.write(str) 时报错,在什么时候需要 encode,更容易了。

但是由于一开始接触的就是 Python 2,熟悉的包都是 Python 2(我也不确定他们是否支持 Python 3)。公司机器上的 Python 2.7 就算是“最新”版本。于是一直没有升级。不过有一种说法,切换到 Python 3 的最好时机就是现在。-为了庆祝 star 过 3000-,由于见到两次要求支持 Python 3,用一个周末为 pyspider 加入了 Python 3 支持(怎么样,不难吧)。

主要参考:

开始之前

其实 Porting Python 2 Code to Python 3 这篇文章是一个非常好的索引,能让你对将要进行的工作有一个整体的把握,同时能提供细节的链接,能让你立即开始工作。而且这一节内容就来自此文的 The Short Explanation 一节。因为总结得很好,所以就不重复造轮子了。

首先,低版本的 Python 2 与 Python 3 之间的鸿沟太大了,特别是 Python 2.5(含) 以前的版本。要同时兼容他们的代价太大。而 Python 2.6 和 Python 2.7 已经带有部分 Python 3 的特性,这让迁移的代价大大降低了。同时,不建议支持 Python 3.3 以下的 3 字头版本,由于 Python 3 实际上已经 release 6 年了,这些 Python 3.x 版本也比较老了,很多特性还没有,或者包不支持。所以建议跳过他们。

其次,一定要有测试,保证测试足够的代码覆盖。Python 2 到 Python 3 从包改名到语法都有变化,几乎所有的代码都需要有修改。足够的代码覆盖,才能在这样大规模修改中,保证所有功能可用。而 pyspider 正是因为有 86% 的代码覆盖,我能这么快地完成代码迁移。

读一读 Python 2 和 Python 3 有什么不同。这个可以看看 What’s New in Python,特别是 What’s New In Python 3.0。当然也可以找一些中文的文章,这个方面应该还蛮多的。反正最主要的就是大量的包改名,以及 bytes, str, unicode 三者的变化。或者你可以先读一读 Cheat Sheet,虽然等下我们还需要它。

好,现在可以来看看你的包依赖是否支持 Python 3 了。并不是 pip 能安装的包就是支持 Python 3 的,可能装上了依旧不能工作。你可以用 Can I Use Python 3 检测包是否支持。不过我更推荐 PYTHON 3 WALL OF SUPERPOWERS (需要翻墙)。不过也不用担心,大部分包都是支持 Python 3 的,如果不支持,一般都会有替代,例如 pika 就可以被 ampq 替换,而 MySQL-python 能被 mysql-connector-python 替代。

第一步——查找替换

首先我们从大的方向入手,把一些改名了的包和函数处理一下。请打开 Cheat Sheet: Writing Python 2-3 compatible code 参照它们一条条进行。在能搜索的地方,使用搜索统一修改,不然挨个文件太慢,而且会忘记的。因为我用的是 six 作为多环境间的桥梁。所以需要同时参考 six的文档。你可能需要打开两个窗口,同时运行 Python 2 和 Python 3,确认语句在两个环境下都能执行。

在这一步,我做了以下处理:

  • 相对导入 - Imports relative to a package
  • urlparse / urllib 库改名 - six
  • thread 包改名,而且 get_ident 函数不再存在了。将 thread.get_ident() 改为 threading.current_thread().ident six
  • basestring 类型不再存在,用 six.string_types 代替 sheet
  • __metaclass__ 不再存在,用 six.add_metaclass 代替 sheet
  • UserDict.DictMixin 不再存在,用 collections.Mapping 或者 collections.MutableMapping 代替
  • / 现在是真的除法了,也就是说 int / int 会得到一个 float,使用 // 获得地板除效果(由于在 python 中,地板除用得少,实际上不改关系不大) sheet
  • StringIO 现在分为 io.BytesIOio.StringIO 视情况使用
  • print 现在是一个 function 了 sheet
  • unicode 关键字不再存在 使用 six.text_type 代替
  • __builtins__ 不存在了,six.moves.builtins sheet
  • reload 改为 six.reload_module
  • dict 的 keysitemsvalues 现在都是迭代器了,不返回列表,原来的 iteritems, itervalues 不再存在,使用 six.iterkeys 等函数代替。
  • raise exc_type, exc_value, tb 的形式不再支持,使用 six.reraise(exc_type, exc_value, tb) 代替。

其他的例如 try…catch,如果你在 Python 2 中就比较标准地使用 as,那么这时就不用修改了。

另外,如果你和我一样有 str(object) 来获得 object 的文字结果的习惯话,每次写 six.text_type(object) 太长了。可以写一些兼容性函数,然后在整个项目中使用。

注意到这里,我们并没有处理 bytes, string, unicode,请放下他们,我们在下一节处理这些问题。

第二步——处理 unicode

由于在 Python 3 中,所有的 'text' 都变成 unicode 了,所以你会觉得它会是一个大问题,是否需要给所有的 'text' 加上 u ,或者干脆所有文件都加上 from __future__ import unicode_literals

实际上,大部分时候不需要。

在 Python 2 中,我们很少有意识地区分 strunicode,对于大部分函数调用来说,给它 str 或者 unicode 都是一样的,因为他们共享大部分行为。但是在 Python 3 中,bytesstr(unicode) 却大不一样。例如当你 for c in bytes 时,得到的是一个 int 而不是一个 str

虽然不做任何修改,'text' 在 Python 2 中,是 str(bytes),而在 Python 3 中是 str(unicode)。但是提交给函数时,既然 Python 2 的函数同时支持 strunicode,所以没有任何问题。而且,在 Python 2 中,'text'+u'中文' 会自动升级为 unicode,所以,只需要注意在出现中文的地方使用 u'中文' 就好了(即使在 Python 2 中,这也是一个好的习惯)。而 b'bytes' 的场合非常少,更多的是使用 text.encode 进行转换。所以,对于习惯良好的 Python 2 代码来说,是几乎不需要修改的。

除了源代码之中的 unicode 问题,其他主要问题出现在输入输出上。但是,只要遵循:程序中流通的数据,只能是 unicode。数据进来之后必须转换成 unicode 即可。

最后

运行测试,哪报错改哪就好了。

pyspider介绍

虽然已经发过一篇架构设计,但是觉得还是有必要发一篇介绍。而且拖了那么久的第二里程碑的commit数已经超过第一个版本了。。

那么由我再次介绍一下 pyspider。

缘起

pyspider 来源于以前做的一个垂直搜索引擎使用的爬虫后端。我们需要从200个站点(由于站点失效,不是都同时啦,同时有100+在跑吧)采集数据,并要求在5分钟内将对方网站的更新更新到库中。

所以,灵活的抓取控制是必须的。同时,由于100个站点,每天都可能会有站点失效或者改版,所以需要能够监控模板失效,以及查看抓取状态。

为了达到5分钟更新,我们使用抓取最近更新页上面的最后更新时间,以此来判断页面是否需要再次抓取。

可见,这个项目对于爬虫的监控和调度要求是非常高的。

pyspider 的主要特性

  • python 脚本控制,可以用任何你喜欢的html解析包(内置 pyquery)
  • WEB 界面编写调试脚本,起停脚本,监控执行状态,查看活动历史,获取结果产出
  • 支持 MySQL, MongoDB, SQLite
  • 支持抓取 JavaScript 的页面
  • 组件可替换,支持单机/分布式部署,支持 Docker 部署
  • 强大的调度控制

由于功能太多,更多请参考脚本编写指南

感谢 +PhoenixNemo 提供的VPS,提供了一个 demo: demo.pyspider.org。无需安装即可体验。

demo

脚本样例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from libs.base_handler import *
class Handler(BaseHandler):
'''
this is a sample handler
'''
@every(minutes=24*60, seconds=0)
def on_start(self):
self.crawl('http://scrapy.org/', callback=self.index_page)
@config(age=10*24*60*60)
def index_page(self, response):
for each in response.doc('a[href^="http://"]').items():
self.crawl(each.attr.href, callback=self.detail_page)
def detail_page(self, response):
return {
"url": response.url,
"title": response.doc('title').text(),
}

例如这就是创建任务后默认生成的一个脚本示例。

  • 通过 on_start 回调函数,作为爬取的入口点,当点击主面板上的 run 的时候,就是调用这个函数,启动抓取。
  • self.crawl 告诉调度器,我们需要抓取 'http://scrapy.org/' 这个页面,然后使用 callback=self.index_page 这个回调函数进行解析。
  • 所有 return 的内容默认会被捕获到 resultdb 中,可以直接在 WEBUI 上看到。

更多特性和文档

签到 —— qiandao.today 介绍

qiandao.today 已经上线了一个半月,这篇blog一个半月以前就应该写了。直到我刷了14遍水晶塔没有ROLL到任何装备(不对,我最后通过贪婪ROLL到了!),打了两晚麻将,把把最小胡牌距离大于5(任意更换手牌达到胡牌的最小张数),房子里刷JJ怪之后。我觉得我必须做点什么。。。

好了,不扯蛋了。自动签到是我对于 “如何请求到数据” ,进行请求自动分析的一个尝试(实际是我 U2 因为45天没登录被封了)。通过浏览器捕获页面请求瀑布流,进行内容/请求分析,找出关键请求。所以,签到这个项目,我就是先从 HAR编辑器 开始做的。做的时候还玩了一下 angularjs然后其他部分都是随便写的

但是,对于签到来说,哪些请求是必要的,这个请求是怎么组装的(例如 token 参数怎么来),特征不明显。自动分析出来就能直接用的概率太低了,即使是人还得单步测试呢。于是 HAR编辑器 成为编辑和单步调试的辅助。自动分析变成了 “推荐相关请求”。

  • 用户部分系统尝试了一下 PBKDF2 进行密码加密。PBKDF2 的优势在于通过随机盐 加 可配置的多轮加密,加大了单个key的运算代价。
  • 模板执行部分通过提取页面信息,和 jinja2 引擎渲染,可以动态地改变请求的 url、header、data 各个部分。
  • 执行断言加上邮件系统,可以检测签到是否成功,在失败的时候给用户发送邮件提醒。

本来还想要做互助打码的验证码系统的,但是通过 雪月秋水cookie插件,其实大部分只有登录需要验证码,签到并不需要。关键是做这个东西不好玩,于是就算了。

运行了一个半月,目前有11个公开签到模板,400+个签到任务,每天进行300次签到。不过由于担心单IP登录帐号过多被封,只在v2ex做了一次广告,不敢大范围推广。。。


以下是面向普通用户的简介:

  • 云代签
  • 支持多个网站
  • 失败邮件提醒
  • 自制模板并分享(文档
  • https 传输安全保证
  • 一号一密用户数据加密
  • 开放源码,支持本地执行(提供本地lite版)

github: binux/qiandao
网站: https://qiandao.today