AirPlay 是 Apple 的屏幕镜像和投影协议,这个协议已经被逆向并且有软件实现了,例如 LetsView, AirServer。通过 AirPlay + 接收软件,我们可以将 iPad 画面镜像投影到电脑上,分辨率更高,而且没有色差,处理起来就更简单了。如果是安卓的平板,也可以通过 Chromecast 或者 Miracast 协议投影,原理是一样的。
当图像投影到 PC 上之后,就可以通过截取 PC 屏幕窗口的的方式获取到平板画面内容。第一种方案是使用 MSS,一个跨平台的 Python 截图包,它能以大约 20 FPS 的速度捕获图片。使用也很简单,首先需要获取投影软件的窗口位置和大小,然后按帧截图发送给 OpenCV 处理。实现的代码在这里:screen_capture.py。由于 MSS 并没有提供获取窗口大小的方法,它的区域捕获仅仅依靠的是屏幕坐标。所以获取窗口还是需要我们自己实现的,而这部分不是跨平台的,也没办法获知窗口移动。再加上 MSS 仅仅是截图,当窗口在后台时就失效了,使用起来并不方便。
而更好的方法是通过 OBS + 虚拟摄像头 + OpenCV,对的,就是平时游戏主播使用的直播软件。简单来说就是使用 OBS 捕获投影软件窗口,再通过虚拟摄像头输出给 OpenCV。OBS 软件本身是跨平台的(但是在不同平台可能会有些不同),FPS 要多少有多少,而且窗口可以被遮挡(窗口不能最小化到后台,但是可以放在另一个 Virtual Desktop),窗口移动什么的也完全没有问题,专业的确实就是专业。OBS 设置部分很简单,只要增加一个 Source,然后再根据需要调整输出分辨率就好了。Python 部分的源代码在:obs_capture.py。
Note:虽然最新的 OBS 自带了 Virtual Cam,但是似乎在 Windows 上和 OpenCV 有兼容性问题,捕获的画面是黑的,依旧需要使用插件解决。
现在采集到了平板上的游戏画面,下一步就是给画面进行分类,来获得游戏所处的界面。这么做主要有这些原因:
在这个项目中,我直接抄了 tensorflow 的 Image Classification Tutorial。对于这种标准的 UI 界面,随便什么模型效果应该都不差:classifier_training.ipynb。
做图片分类的第一步是采集训练样本,你会注意到 screen_capture.py 和 obs_capture.py 的 __main__
部分都有 cv.imwrite
以及对应的按键绑定的代码。我首先会在开启图像采集的过程中,游玩游戏,手动进行需要自动化的整个流程,手动或者每 1 秒地频率采集一些原始图像。然后对应每一个分类新建一个文件夹,例如在《猫之城》中,我有 fish_idle, fish_ring, fish_drag, fish_reward 和 not_supported 这样一些分类。然后将采集到的图片拖到对应分类的文件夹中,我对于分类和图片的选取是这样的:
然后就是套代码了,图片分类并不需要很高的图片分辨率,这里我随便选了一个 220x300 来保持图片宽高比,套示例模型就能达到 99% 的准确度了。因为是 UI 界面,也不存在裁切变换,之后实际测试结果也非常好。最后将分类列表和模型保存下来就可以啦。为了保存单个文件,并且减少体积,使用的是 TensorFlow Lite 模型,predict 的代码在 classifier.py。唯一需要注意的是使用的时候需要自己 resize 到 220x300,并且 OpenCV 图片的颜色是 BGR 而 tensorflow 是 RGB 的,需要要进行转换。其他就没什么了,总共有效代码也就 15 行,踩着巨人的肩膀,使用成熟的库之后还是挺简单的。
Note: 图片中的 fish_ring = 099%
就是图片分类的结果和 score,而其他的图片识别内容和辅助线就在下一篇 blog 中讲解啦。
言归正传,这次带来的是一个手游《猫之城》的物理挂。4年以前,我做过一个《少前》的脚本,使用的是一个机器内的 App 来采集图像然后驱动控制的,然后就被封号了:P 。于是这一次就打算使用物理的方法,在机器外部实现所有的信息处理和控制。比如下面这一段视频,就是这个物理挂识别游戏内的钓鱼小游戏,然后通过电极模拟触控实现的:
在这一系列 blog 中,我会分为
等篇章讲解这个 bot 的实现,同时源代码已经上传到了 github: cat-planet-bot。注意:使用外挂是违反游戏用户协议的行为。由于代码中使用了大量 hard coded 图像坐标,我并不认为你能直接使用它。这份代码仅在 blog 中作为引用,讲解学习。
我也是第一次做硬件,电子电路以及图像处理开发(所以使用的最简单的 Arduino),我只会很简单地介绍这方面的知识,如果有什么错误,或者更好的方案,欢迎在评论中指出。
简单的说,现在的平板手机的触摸屏都是电容屏,它是通过测量手指靠近屏幕导致的电容变化来获取点击位置的,所以只要你能造成屏幕上某个区域的电容变化,就能模拟出点击。不过,不管怎么说,不同屏幕实现方式和灵敏度还是有区别的,最靠谱简单的方式还是实践。毕竟只要某个方案在你自己的机器上有效就可以了嘛。这里我直接使用了一个网页,在 iPad 上测试了一些可行和不可行的例子:
不可行的:
可行的:
总结起来这里有两个关键:
于是,我这里选择的是:电击按摩贴。首先,这东西导电,而且可以随意裁剪大小,并且自带粘性,可以粘在屏幕上。通过控制是否接地(初次粘贴时屏幕会感应为持续按住状态,需要关闭再打开屏幕reset,原因见下文)可以控制触摸状态。最最最重要的是,这东西在美国很容易买到,并且我家里有:D 。如果你在国内,可以买到成品连点器,或者直接买吸盘造型的导电橡胶,价格实惠,卖家甚至已经给你接好了导线。
在上一步我们知道可以通过电极的接地与否,模拟触摸的按下和抬起,这时候就需要一个 PC 到这个接地电路的控制器,这里一般是一个与 PC 通信的 MUC 中导出的 GPIO 接口。我这里使用的是 Arduino,一个非常成熟的入门级开发板和配套程序。不过你也可以用例如 树莓派,STM32,ESP32 等等平台,它们的开发板可能更便宜,而且有的还能做到无线控制。
然后电路具体怎么实现呢?简单查询,网上有说使用继电器,伺服电机的,也有说可以使用 N-channel MOSFET(也有说用 P-channel 或者不能用的)。但是都有一个问题:没有实物,而且我也不知道需要买什么规格的啊。在美国,如果一次没有搞定,重复购买的话,光运费就会多花出很多钱了。于是,我把镜头看向了国内,刷到了 【单片机】Arduino光遇自动弹琴机器人2.0来了 这个视频,视频中 UP 主使用了光耦甚至给出了型号。那还说什么呢?照着来呗。
选择这个方案还有一些原因是,其他方案里的,伺服电机存在机械机构,安装麻烦;继电器往往是电磁继电器,开关时会发出噪音;而 MOSFET 的电路不完全隔离,可能会因为自身存在一些电容,而屏幕判断不准确。
在面包板上将一个 GPIO 端口,与一个发光二极管,光耦,限流电阻串联,然后接地就可以了;光耦的另一端分别接按摩贴做的电极和地:
在 GPIO 高电平时,光耦开关闭合,会将电极和地连通,从而模拟出按下的状态。
光耦和 LED 类似,需要串联一个限流电阻。例如我使用的 PC817 的 Forward Voltage 是 1.2V,电流 20mA。而 Arduino 的 GPIO 输出是 5V 的,通过 计算 我们需要串联一个大约 190Ω 的电阻。(什么,你说我还串联了一个 LED ?管它呢,又不是不能用)
Arduino 非常简单地提供了一个 IDE,插上开发板的 USB 就可以开始编程了,你首先可以照着 Arduino 的标准教程 Blink 熟悉下环境,当程序上传到开发板之后就不需要 IDE 了。PC 将通过 USB 连接 Arduino UART 串口进行通信。Arduino 部分的代码如下:
1 | void setup() { |
是不是很简单呢?这里首先初始化了开发板的串口,和一些 GPIO 作为输出。然后我们定义了一个通信协议:HIG00 和 LOW00 来控制 GPIO 的高低电平。注意,这里开发板在初始化后会立即发送一个 OK 信息,这是因为 Arduino UNO 会在串口连接上时重启,这个 OK 信息可以让 PC 端知道开发板已经准备好了。
PC 这边可以用 pySerial 这个包,当开发板连接上 PC 或者 Mac 之后,会显示为 COM* 或者 /dev/tty* 设备。pySerial 也有 serial.tools.list_ports.comports()
API 可以列出所有的串口设备,我们可以通过一些条件找到 Arduino:
https://github.com/binux/cat-planet-bot/blob/main/arduino.py
代码中我还实现了一些 helper function 例如 throttle_press
和 autorelease
并且记录了每个 pin 的按下状态。这些都只是为了方便,并不是必须的,串口的速率是完全足够直接发送每个指令的,并且我测试中也没有感觉到任何延迟。
只要在面包板上插上更多的光耦和 Arduino GPIO 连接,然后制作更多的电极,就能支持多点触控了。但是,很明显的,这个数量是有限制的。而且无法改变点击位置,也无法实现滑动。那么有解决方案吗?我这里有一些想法:
首先,可以增加电极的数量,以至于覆盖到整个屏幕,然后在屏幕上分区分块控制每个点的电容变化。这个想法在 这篇论文 中有描述,但是我并没有找到成品。
而另一个更可行的方案是通过机械控制触控笔,实现全屏覆盖。例如改装一个 3D 打印机,将喷头换成一只触控笔,通过 3D 打印机的精确 3 轴移动来模拟点击。这个方案的好处在于 3D 打印机是一个非常便宜的成品,省去了零散零件采购的成本,而且很多 3D 打印机的固件是开源的,可以很容易地通过 G-code 操作。
到这里,我们就打通了 PC 到平板的物理触控了。使用这些东西,就可以开发一些固定的自动化脚本了,例如下面这个脚本每次执行,自动按了 99 次 +10 然后购买:
在使用中,有一些影响触控成功率的经验:
首先,在配置场景之前,需要选择一个 Hub —— 作为自动化中心,连接传感器和操作控制器(例如灯,插座,IR 遥控器等)。你可以选择 Google Home,Alexa,Homekit 这样大厂的方案,不过这里我还是推荐 Home Assistant 这样的开源方案:
我在 Synology DS218+ 上以 docker 运行 Home Assistant。
不过无论你选择什么方案,在这之后购买传感器和控制器的时候都需要注意你的 Hub 是否支持设备接入。考虑到价格,我的设备主要是 TP-Link 的插座加上米家的传感器,我会在具体场景中详细列出。
首先就是我入坑的第一个场景,在床上关上家中所有的灯。我用到的设备有:
由于美国的房子没有灯,对的,没·有·灯。默认的开关控制的插座不一定在我想要的位置。这时候就可以用一个 Smart Plug 接一个落地灯。而对于其他自带的例如浴室厨房灯,就通过替换 Smart Switch 控制。
设置方面也很简单,直接在 Google Home 的 Routines 中关掉所有的开关就好了。
这也是很常见的使用场景,红外感应人进入就开灯,然后延迟关灯,用到的设备有:
首先跟着文档将米家多功能网关接入 Home Assistant,然后就可以添加 Automation 了:
- id: '1561354113814' alias: Turn On Bathroom trigger: - entity_id: binary_sensor.xiaomi_motion_sensor platform: state to: 'on' condition: [] action: - data: entity_id: switch.bathroom_light service: switch.turn_on- id: '1560102516271' alias: Turn Off Bathroom trigger: - entity_id: switch.bathroom_light for: 00:10:00 platform: state to: 'on' - entity_id: binary_sensor.xiaomi_motion_sensor for: 00:10:00 platform: state to: 'off' condition: - condition: template value_template: '{{ is_state("switch.bathroom_light", "on") and as_timestamp(now()) - as_timestamp(states.switch.bathroom_light.last_changed) > 600 }}' - condition: template value_template: '{{ is_state("binary_sensor.xiaomi_motion_sensor", "off") and as_timestamp(now()) - as_timestamp(states.binary_sensor.xiaomi_motion_sensor.last_changed) > 600 }}' action: - alias: '' data: entity_id: switch.bathroom_light service: switch.turn_off
这可以有两个方案,一个是用摄像头检测到人就开灯,或者用 Smart Lock 的开锁事件。
Wyze Cam 就是小方智能摄像机 的国外版本,你可以用开源的固件。如果直接用它自带的。接入 Home Assistant 需要通过 ifttt。August Lock 就能直接支持了。
设置自动化和上面类似,condition 里面可以设置只在下班时间或者太阳落山后时才开灯。这里就贴配置了。总的来说 Smart Lock 比摄像头的方案要稳定得多,误触也少。
由于经常搬家,我都是用投影代替电视的。毕竟同样的尺寸,投影机容易搬多了。然后我现在的投影机是内置音响的,所以我还有一个 soundbar。这个场景就是,当我打开投影的时候,同时打开音响,关闭客厅灯,然后 PC 的输出切换到投影上,再打开 Plex。这里面用到的是:
首先是将这几个设备接入 Home Assistant,参考 Xiaomi IR Remote 和 mqtt 的文档就好了。
然后是控制投影的开关,当米家万能遥控器接入 Home Assistant 后,可以通过 xiaomi_miio.remote_learn_command
指令学习投影遥控的开关机代码,然后在 Home Assistant 中建立一个虚拟开关:
remote: - platform: xiaomi_miio host: 192.168.1.104 token: dxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxb commands: project_on: command: - raw:nMwmkwlk0mkxmEsms4mEsmM2m0wlk2AMKYzYBYgCDmoDLTUA85gAOUyAXkB+wOfAhkBDwEPAQYGSAXOCE8IQwQVCNsAcghfAI8AjwPImM2mYDPg7tMIA project_off: command: - raw:nMwmMwlk0mk1mEsms3mEsmM2AEIAjJqAywA/gD+Bz4DPgIeAh4CHgy+BB4EHgMeAh4BHgReAz4A6TCAAswitch: - platform: template switches: projector: value_template: "{{ states('input_boolean.projector') }}" turn_on: - service: remote.send_command data: command: - project_on entity_id: remote.xiaomi_miio_192_168_1_104 - service: input_boolean.turn_on entity_id: input_boolean.projector turn_off: - service: remote.send_command data: command: - project_off entity_id: remote.xiaomi_miio_192_168_1_104 - service: input_boolean.turn_off entity_id: input_boolean.projectorinput_boolean: projector: {}
音响也是一样,依葫芦画瓢就好了。
然后是 PC 这边,这里用了一个一个开源程序 Win10As 然后通过 mqtt 协议和 Home Assistant 连接。
设置三个指令:
name | cmdtext | cmdparameters |
---|---|---|
exec/plex | D:\plex.bat | 1 |
display/pc | D:\DisplaySwitch.exe | /internal |
display/projector | D:\DisplaySwitch.exe | /external |
其中 plex.bat:start "" /B "C:\Program Files\Plex\Plex Media Player\PlexMediaPlayer.exe" --tv --fullscreen
DisplaySwitch.exe 位于 C:\Windows\System32\DisplaySwitch.exe
不知道为什么从 Win10As 中无法访问这个程序,不过把它拷贝出来也是一样用的。
然后可以在 Home Assistant 中加一个 pc_screen 的 switch:
switch: - platform: template pc_screen: value_template: "{{ states('input_boolean.pc_screen') }}" turn_on: - service: mqtt.publish data: topic: GAMEBOX/display/pc - service: input_boolean.turn_on entity_id: input_boolean.pc_screen turn_off: - service: mqtt.publish data: topic: GAMEBOX/display/projector - service: input_boolean.turn_on entity_id: input_boolean.pc_screeninput_boolean: pc_screen: {}
然后就可以通过 Automation 把它们串起来了。由于是 WebUI 就能配置的,我就不贴出来了。注意一点是在打开投影机到切换 PC 输出之间加一个延迟,等到投影 ready 再切换,切换后再加个延迟再启动 Plex 就能保证 Plex 在投影的窗口前台全屏显示了。
其他的例如
由于都是重用现有设备这里就不介绍了,这些都能通过 Home Assistant 接入后用 Automation 完成。
总之「智能家居」中的「智能」其实就是一个语音识别加上一个个预定的场景,很蠢,但是,真香。当习惯了叫一句 Hey Google 就能躺着沙发上开关各种设备之后,就再也回不去找各种遥控器了。比起一个「懂你」然后随时监听上传的设备,一个离线语音识别,加自定义的场景可能能更快地满足你对自动化的需要。
如果你有家居自动化的点子或者方案也可以留言交流,(´▽`ʃ♡ƪ)
]]>首先介绍下 Zerotier 和为什么要配置 Nat 网关。
Zerotier 是一个虚拟局域网软件,可以很简单地将无限量(社区服务器版100台)设备放入同一个虚拟局域网中。这样就能在任何网络环境中,访问家中的 NAS 或其他设备。反过来,如果将一台服务器加入这个局域网中,将它配置为一个 NAT 网关,只要你加入这个虚拟局域网,就可以通过它连接世界。
选择 Zerotier 的原因是,它足够简单,只要一个 16 位的 network ID 就能实现组网了,相比我之前用过的 tinc,不需要节点 IP,不需要挨个配置节点,并且支持的操作系统广泛。
当然,第一步是安装 Zerotier,然后注册一个帐号,创建一个私人网络。复制 network ID 在本地加入,然后回到网站中通过许可 (Auth?
) 就好了。
当你将本地计算机和一台服务器加入网络后,然后就是根据 这个教程 。运行下面4个命令就行了:
1 | sudo sysctl -w net.ipv4.ip_forward=1 |
当然了,很明显,这里的 eth0
, 10.6.4.0/22
, 45.32.69.220
是需要根据实际环境替换的,zt+
是指的任何以 zt
开头的网络,zerotier 都是以这样的名字创建的,所以不用修改。这都可以通过 ip addr
或者 ifconfig
进行确认。比如在我的环境中,环境是这样的:
命令是这样的:
1 | sudo sysctl -w net.ipv4.ip_forward=1 |
当我设置完了这些,然后把这个服务器设置为默认 0.0.0.0/0 的网关之后,我断网了如果这样有用的话,我岂不是就没机会学习 iptables 了。
首先当然是把默认路由改回来。然后,如果只是为了调试,是不需要设置默认路由的,或者说最好不要设置默认路由到这台机器上的,你可以通过
1 | # macos |
设置一条单独的路由,到另一台主机上,然后就可以单独监控调试这条链路的情况了。
好了,既然现在网络不通,我最想知道的当然是哪断了。
1 | iptables -t raw -A PREROUTING -p TCP -s 192.168.111.20 -j LOG |
这里就给所有从 本机 发往 网关 的 TCP 数据包打了 LOG,然后 tail -f /var/log/messages
(或者 tail -f /var/log/kern.log
) 追踪日志。
现在你可以从本地往测试服务器发个请求: curl 98.76.54.32
,如果在日志中看到
1 | Mar 2 15:53:14 myserver kernel: [5377966.960574] IN=zthnhi321 OUT= MAC=88:55:bb:99:88:88:88:66:11:dd:ff:bb:00:00 SRC=192.168.111.20 DST=98.76.54.32 LEN=64 TOS=0x00 PREC=0x00 TTL=64 ID=45807 DF PROTO=TCP SPT=64408 DPT=80 WINDOW=65535 RES=0x00 SYN URGP=0 |
那么就可以确认网关确实收到这个包了
然后可以用 TRACE 追踪这个包是否触发了 NAT。
在一些环境中,你可能需要开启 TRACE 内核支持,参考 How to Enable IPtables TRACE Target on Debian Squeeze (6)
1 | iptables -t raw -A PREROUTING -p TCP -s 192.168.111.20 -j TRACE |
你会看到
1 | Mar 2 15:58:11 myserver kernel: [5378263.921579] IN=zthnhi321 OUT= MAC=88:55:bb:99:88:88:88:66:11:dd:ff:bb:00:00 SRC=192.168.111.20 DST=98.76.54.32 LEN=64 TOS=0x00 PREC=0x00 TTL=64 ID=32766 DF PROTO=TCP SPT=65087 DPT=80 WINDOW=65535 RES=0x00 CWR ECE SYN URGP=0 |
表明这个包分别经过了
你可以通过
1 | iptables -t nat -nvL --line-numbers |
查看对应的规则编号。在这里,可以看到 filter:FORWARD:rule:1
和 nat:POSTROUTING:rule:1
被触发了。即
1 | sudo iptables -A FORWARD -i zt+ -s 192.168.111.0/24 -d 0.0.0.0/0 -j ACCEPT |
另外如果你尝试过执行
iptables -t nat -A POSTROUTING -i zt+ -o venet0
,会收到Can't use -i with POSTROUTING
报错。从 TRACE 中可以看出nat:POSTROUTING:rule:1
中IN=
是空的。所以在POSTROUTING
表中是不能使用-i
指定入包接口的。
这次我们一步到位
1 | iptables -t raw -A PREROUTING -p TCP -s 98.76.54.32 -j LOG |
另外为了防止日志太多,这里可以把刚才添加的那条 TRACE 删掉:
1 | iptables -t raw -D PREROUTING -p TCP -s 192.168.111.20 -j TRACE |
再次 curl 98.76.54.32
就能看到包返回了
1 | Mar 3 14:04:55 myserver kernel: [5457820.771146] IN=zthnhi321 OUT= MAC=88:55:bb:99:88:88:88:66:11:dd:ff:bb:00:00 SRC=192.168.111.20 DST=98.76.54.32 LEN=64 TOS=0x00 PREC=0x00 TTL=64 ID=64965 DF PROTO=TCP SPT=57286 DPT=80 WINDOW=65535 RES=0x00 CWR ECE SYN URGP=0 |
同理,可以看到数据包在经过 mangle:PREROUTING:policy:1
之后,DST 被改写回了 192.168.111.20
。于是一次成功的 NAT 就完成了。
最后,这里有一张图,显示了数据包都会经过什么表:
之前配置 NAT 网关不成功的原因是:zerotier 防火墙错误设置了如下规则,导致包没法发回本机。
1 | drop |
既然有网关了,我就想能不能再搞个智能回国网关。,只要我连上这个局域网,就能听网易云音乐了。我用的 vnet.one 用的是 anyconnect 连接,它有一个开源实现 openconnect,于是我这样:
1 | sudo apt-get install curl vpnc-scripts build-essential libssl-dev libxml2-dev liblz4-dev |
然后用 bestroutetb 生成个路由:
1 | bestroutetb --route.net=US,GB --route.vpn=CN -p json -o routes.json -f |
整个设置路由的脚本
1 |
|
整合一下
1 | echo "password" | openconnect address.example.org -u username --passwd-on-stdin --non-inter --script /root/openconnect/script.sh |
完成。只不过,如果要看 bilibili 还需要设置 DNS 到国内,或者设置到网关上(上面的脚本配置了 DNS 转发)。而且 zerotier 只是为了虚拟局域网设计的,不如 anyconnect 能自动设置网关,路由,DNS方便。不过挺好玩的,还学了不少东西,over。
]]>然后,我会好好地,正常地,氪金地去玩这个游戏吗?不可能的,玩游戏哪有破解它有意思呢。当年破解 Ingress 是因为它用的 HTTPS 通信的,算是本行。百万亚瑟王是因为别人已经逆向好了,我只是写了一些 bot。现在这么办,玩不了了吗?作为一个不会安卓,不会逆向,不会汇编的菜鸡,那我只好上按键精灵了啊。于是乎,我找到了这个: AnkuLua
AnkuLua 是一個專注在自動化的Android App
基本自動化動作有:
- 抓取螢幕並找尋指定圖案
- 對找圖結果採取使用者要的動作(例如點擊、抓放(drag and drop)、打字…等等)
最重要的是,它能运行 lua 脚本!虽然我是一个不会安卓,不会逆向,不会汇编的菜鸡,但是我会 lua 啊。
不过,在使用过程中发现,找寻指定图案,需要不断截图/裁剪,这样太麻烦了。于是我又用 electron 做了一个可视化的截图资源管理器 ankulua-vision,像这样的:
基本思路就是,一般游戏是由众多 UI 界面组成的,点击某个按钮能跳转到某个界面上去。那么通过截图,标注识别区域,那么程序就能知道游戏现在所处的界面。通过标注按钮区域,那么只需要 goto('battle')
,程序就能自动规划从当前界面到 battle 的可行路径,然后点啊点啊就完成需要的操作了。这样一方面不需要自己去裁剪图片了,另一方面通过框架代码,在运行过程中能够有更多的错误检查,自动应对可能出现的各种异常。
理论上,对于点啊点的游戏,是能实现无代码的。即使不能,对于复杂的动作,也可以通过 lua 拓展。
源码在这里:https://github.com/binux/ankulua-vision
你依旧需要在安卓手机或者模拟器中安装 ankulua,然后加载生成的 start.lua 脚本。默认自带了一个简单的循环逻辑,运行后可以直接图形化界面配置运行。当然你也可以通过 lua 脚本拓展,除了 ankulua 本身的 API 可用之外,你也可以使用 stateMachine
这套界面跳转逻辑 API,重用简化步骤。stateMachine
的 API 在 README 中有简略的文档说明。
源码使用 GPLv3 或 MIT 许可证,取决于第一个有效 PR(例如 fix typo 不算),如果第一个 PR 之前有商业化需求或者 PR 作者要求,则 MIT。
WARNING: 任何使用脚本的行为都是官方禁止的,我不对下文所述任何内容以及其后果负责
于是,这里就是 少女前线的拖尸脚本:
https://github.com/binux/binux_github_com/releases/download/gf/shojo.zip
同时它也是一个 ankulua-vision 的项目,你可以通过 ankulua-vision 打开这个项目目录,调整截屏或者按钮位置。
其中 52n 会在战斗中撤退 5, 8 号位 (见 NGA 文 “43e的说明” 展开部分),02 在选择 m4a1 时会撤退 1, 7 号位。
然后开始吧!
WARNING: 任何使用脚本的行为都是官方禁止的,我不对上文所述任何内容以及其后果负责
over
]]>年纪大了,记忆力下降,没有学习新东西的动力,也没精力折腾新的技术,新的领域了。每天就是看看斗鱼,打打游戏就过去了,现在的理想就是早点退休,当条咸鱼就好了。
2017 年主要给公司开发了一套基于 electron (chromium) 的页面渲染后端,可以保证抓取时和用户浏览器中看到的保持一致。同时这个服务器端的浏览器,可以通过 websocket 连接用户浏览器,双向同步页面内容变化,录下用户操作,在抓取时进行重放。这些功能我真的很想做给 pyspider,但是确实不方便。眼见着 pyspider stars 过万,而我却渐渐没有精力去维护了。我的希望是以后从现在的公司离职之后能有2-6个月全职开发 pyspider,算是这几年项目荒废的补偿吧。
公司终于把伦敦办公室关闭了,我也随着搬到了美国(湾区)。随便写一点美国的感受吧:
总体来说,英国更接近国内的政府+生活模式,而美国是只要你花钱,什么都有,不花钱,滚蛋。反正 L1 签证也就 3 年,也不能跳槽,而且就美国这个 H1B 抽奖 + 绿卡排队,比起英国来简直就是地狱模式。趁着这几年,在美国多玩一玩吧。9酱。
]]>这里带来的是
如何从 WEB 页面中提取信息 一文中提到的 data highlighter。但是由于开源需要重写代码,而我并不打算使用它,这里只给出 demo 和算法思路。
Data Highlighter 其实是一种生成提取规则的方式:
Data Highlighter 的标注方式是:给一系列相似的页面,让用户标出(高亮)每个属性在页面中的位置。通过多个页面的标注信息,寻找每个属性的特征。当然了,这个特征可以是 xpath,也可以是上下文,也有可能是机器学习的特征向量。
Data Hightlighter 通过高亮 多个页面中相同属性 进行规则学习,省去了人为设置规则时的学习成本。实践表明,在单一页面模板下,标记2个页面就足以生成规则了。效率远大于手工设置规则。Google Data Highlighter 甚至对文字进行了切分,能在 英语 / 汉语普通话 / 粤语 xpath 相同的情况下,分别选出三种语言。是我目前见过的成熟度最高、通用性最好、最简便的数据抽取方式。
那我们通过例子介绍一下使用方式。首先打开 demo。这里列出了5个豆瓣电影的 sample 页面,点击 go 加载页面。将鼠标放在页面中,就会发现文字被高亮了,点击拖拽鼠标选择需要提取的文字,在弹出的菜单中选择属性名。
然后分别点击 gen_tpl
和 test_all
就能看到生成的模板,以及提取效果了。
![extraction sample](/assets/image/Screenshot 2016-12-04 14.44.18.png)
点击 gen_tpl
就可以看到生成的模板了,tpl
字段的 key 为抽取的变量的名字,value 描述了一个 状态机。
先看一个简单的例子,以下就是对 name
字段的模板,它描述了一个 s0 -> e0
的状态机。
1 | { |
直接跳到
tpl.name
部分,它有4个字段,is_list
和data_type
描述了字段的类型,它们在字段定义的时候就已经指定了,没什么好说的。states
和entrance_state
为状态机的描述部分。
entrance_state
表示状态机的入口为s0
。
states
中描述了两个状态s0
和e0
。s0.tag == start
表示这是一个开始状态,即标示字段提取的开头,e0.tag == end
为结束状态,即字段的结尾。s0.transitions == [e0]
表示从s0
能够转移到e0
,而由于e0.tag == end
已经结束了,所以就没有转移状态了。
在执行时,先序遍历 DOM 树,根据condition
的条件进行状态转移。
s0.condition
表示进入开始条件为:xpath/html/body/div/div/h1/span/textnode
并且满足ancestor::*[1][name()=\"span\" and @property='v:itemreviewed']
(父元素的 name 为 span,property 属性为 “v:itemreviewed”) 这个特征。
而进入结束条件为
e0.condition
: xpath/html/body/div/div/h1/span/textnode
并且满足ancestor::*[1]/*[last()-0] = ancestor-or-self::*[1]
(最后一个元素),并排除满足following::*[position()=1 and name()=\"textnode\"]
(右兄弟为 textnode,实际与 include 互斥)的元素。
简单地说,这个状态机描述了
属性 property='v:itemviewed' 的 span 的所有 textnode 孩子
这样一条规则。
而多状态的执行也是类似的,只不过它可能存在状态分支,或者在多个状态间循环。不过只要根据状态转移条件状态进行转移,再根据
tag
所标识的开始结束进行提取即可。
为什么要使用状态机在后面的小结讲解,我们暂且将整个状态机理解为「描述字段提取的开头和结尾」,每个状态就描述了开头结尾的特征。先来看看状态是如何描述「字段提取的开头和结尾」的。
算法的基本思路是寻找多个样本间相同的特征,并使得特征排除其他相似元素。
每一个元素可以根据 id, class 属性,文字内容,位置,前 n 个元素的特征,祖先元素特征生成一组特征集合。对多个样本的特征取交,对需要排除的元素取差。
例如如果每次都选择第二个「豆瓣成员常用的标签」,就会生成
1 | "features": { |
如果每次都选择 2016 的标签,就会生成
1 | "features": { |
通过特征集合的运算,算法能够通过样本,猜测出用户选择的意图。而这样的特征集合,可以不断地添加,以满足不同页面的需要。
需要特别说明的是,特征并不需要像 demo 中使用某种特定的选择器(xpath),由于模板执行时,可以再次为候选元素生成特征集合,对特征集合进行比较。实际上,你可以在特征集合中放入任何字符串,例如「第5元素」,「前一个字符为 answer,且值为 42」都是可以的。
不同于往常的选取一个元素(例如 pyspider 中的选择器),data highlighter 提供了
的功能,这使得正常的「元素选择器」不再好使,取而代之的是一种定位开始和结束的规则。描述为状态机即:s0 -> e0
。
而 data highlighter 另一种需要支持的功能为列表选取:
就不能仅仅通过 s0 -> e0
这样开头结尾的模式进行描述了。它需要准确描述出整个列表的开头,结尾,分隔符等信息,需要通过一个类似
1 | s0 -> e0 -> s1 -> e1 -> s2 -> e2 |
的状态机,s0
为整个列表的开头,s1 -> e1
为中间循环的组,e2
为 整个列表的结束。
而实际中,由于某些状态可以被合并,你可能会看到类似
1 | s0 -> e0 -> s2 -> e1 |
e0 和 e1 被合并了,即第一个元素的结束条件和中间元素的结束没有不同
的状态机
虽然状态机看起来非常复杂,但是用程序处理起来却不难。首先为每一个样本(包括列表选取)生成一条 s0 -> e0 -> s1 -> e1 -> s2 -> e2 -> s3 -> e3 ...
的长链,然后尝试合并状态,然后将多个样本的链用同一规则合并。而不能合并的状态,就做个分支转移即可。
而状态能否合并,取决于它们有没有共同特征,就是这么简单。
Data Highlighter 的算法设计,实际上是对元素特征选取的一种建模。通过设计合适的数据结构,使得多样本能够反映到模板中去。
这个算法是两年前设计的,现在看起来实际上问题蛮多的,例如:
等,所以,我并不打算使用这个算法。
只不过,最近些年,看到很多数据提取的公司,特别是国内的数据提取平台,还在停留在非常初级的 css selector 或者 xpath 点选生成。希望这篇文章能抛砖引玉,提供一些新的思路,为数据抽取提供更易用有效的工具。
完。
]]>为什么 bilibili?
bilibili 早期,新番都是用户上传的,可以说是典型的「盗版网站」。那么这个拥有大量用户的「盗版网站」体验应该说不差吧。
随着 bilibili 开始购买版权,现在新番实际上是正版盗版共存的模式,而 bilibili 不插前置广告(当然现在有「约不约」了),不强制付费;从体验上看,特别相对其他国内视频网站,应该是最接近「盗版」的了。
bilibili 新番承包人数可以非常方便的从番剧的介绍页面上获知:
但是由于动画的类型不同,热度不同,独播非独播用户(路人)成分不同,直接比较数字没有什么意义。需要首先找到一个基准来讨论播放和付费数之间的关系。
我抓取了新番承包上线以来新开播的 142 部新番(http://demo.pyspider.org/results?project=bgm_bilibili),去除话数小于10的 OAD,OVA 等,剩下 136 部。将总播放,追番人数,弹幕总数画为散点图:
![追番人数,弹幕总数 / 总播放](/assets/image/Screenshot 2016-05-15 15.29.25.png)
由图可知,弹幕总数和总播放数相关性比追番人数相关性更大(参照 R^2)。独家和非独家新番在弹幕参与度上相差不大,但是非独家的追番率比独家新番少了一半。难道非独家新番用户大多是非 bilibili 注册用户吗?这说不通啊,明明应该是反过来,非会员不得不到 bilibili 上看才对啊。。
在开始写这一节的时候,我本想应该挺简单的,承包人数要么和播放数正相关,要么和活跃(弹幕数)相关,要么就和追番人数相关。但是经过了3个小时,当我尝试了:
画了20+张图之后发现,问题并没有这么简单。很难有一个什么方法能够预测出用户的付费意愿,有很多叫好不叫座,或者叫座不叫好,导致付费比例非常分散:
![承包 / 总播放数](/assets/image/Screenshot 2016-05-15 17.55.25.png)
图中左边是独播新番,右边为非独播
这里面会发现一些有趣的地方:在独播和非独播中,都有一个承包比例非常高的点,分别是《电器街的漫画店》和《Fate/stay night [Unlimited Blade Works] 第一季》,他们都是2014年10月番,正好是新番承包刚上线时的作品,可能用户对承包模式的尝鲜,或者前期宣传上的增益。
将特异点排除之后,发现不管是否独播,他们的付费比例差别不大,但是非独播的方差大得多:
![去除特异点后的 承包 / 总播放数](/assets/image/Screenshot 2016-05-15 18.00.02.png)
平均上来说,bilibili 的付费比例约为播放数的万分之 1.447,收藏人数的千分之5.373。但是这只能是整体估计,具体到单个番剧就没有意义了。
那么,具体到每一部番剧,用户到底因为什么因素愿意付费呢?
当我将付费比例前10与付费比例后10的放在一起比较,试图找出答案的时候,我真的失败了:
![承包比例](/assets/image/Screenshot 2016-05-15 21.11.35.png)
在前10中有在我看来「这什么鬼」的,在后10中也有追过的,完全搞不懂拥有更高收费比例的番剧是为什么。当然,通过加入声优,导演,制作,类型 tag 等因素,或许可能找到原因,但这样少的数据,又很容易陷入过拟合的境地(如果有兴趣,可以下载数据分析看看)。
虽然在 bilibili 页面中有承包商排名,但是并不知道付费的金额,仅在你承包的时候,给出你当前的排名。为了了解承包商们在这样没有强制金额的「捐献」中愿意付多少钱,我从1元开始承包,然后查看我当前的排名来获得各个区段的人数:
![承包总榜](/assets/image/Screenshot 2016-05-15 21.16.24.png)
为了消除连载中,独播,类型等影响,这里选择了连载中,非独播的的 Re:Zero 和已完结,独播,稍微腐女向的 K RETURN。
![付费区间人数](/assets/image/Screenshot 2016-05-15 18.32.14.png)
从图中可以看出:
那么,假如我们不考虑前 3 位的土豪,人均承包金额约为 13.08 元。因为我们并不知道土豪能为我们拉升多少身价,那,即使我们现在假设排名前三的土豪均承包五千块,人均承包金额也不过18元。为了简化,我们取20块好了。因为选取了两部因素差异蛮大的动画,得知不同因素对承包金额的分布影响不大,这个人均承包金额是可以套用到不同番剧上的。
那我们算一下,bilibili 通过新番承包,到底能赚多少钱呢?因为承包人数是公开的,乘以估计的人均20块的话,bilibili 承包收入收入排行:
![承包收入排行](/assets/image/Screenshot 2016-05-15 18.55.03.png)
根据网上的传言,每集非独播新番版权价格大约是5万,独播更贵。那么好,我们统统算1万一集吧(对,就是这么任性)!那么也就 《Fate/stay night [Unlimited Blade Works] 第一季》 和 《电器街的漫画店》 实现了盈利。记得我们前面说过的付费比例异常吗?对,就是这两部「盈利」了的番剧。
从整体来看,bilibili 通过承包总收入为 288.7 万,平均每部番剧的承包收入是 21388 元,不打折的话,一集都买不起啊!
经常有人会用「正版体验不佳」作为盗版的理由,说得好像正版体验一样了就会付费了似的。bilibili 同时有提供正版和盗版内容,正版有比盗版体验差吗?难道正版看得人就少了吗?好,就算确实看正版的人少了,我们不看绝对值,那这寒酸的千分之5.373的付费比例是怎么回事?什么「正版体验不佳」啊,「要付钱当然体验不佳」啦。
另外一个常见的理由是「学生党,没有钱」,人均 20 块太贵出不起。请回过头看看追番人数,要是每个人出一块钱,那也要比现在这千分之5.373,人均20块的总收入高啊!一块钱都出不起吗?这可是一季动画,而不是一集让你出一块钱啊!看动画都是因为爱,而这份爱,连一块钱都不值吗?
bilibili 不想通过广告那样半强制地收回那么一点点版权费,然而看起来这「爱」并不畅销。所以,我弱弱地提议,各位有爱的小伙伴,在看完一季动画后(是的,不喜欢可以不承包),从微信红包(是的,不用银行卡)中拿出那么一块钱(是的,最低承包不是5块,是可以改的),承包一下你喜欢的动画吧。。希望在「劣币驱逐良币」之前,良币不会先自己饿死吧。
]]>因为 pyspider 支持分布式部署,为了验证也好,为了省钱多蹭 CPU 也好, demo.pyspider.org 通过 docker 部署在同一机房的 3 台 VPS 上,VPS 间有内网传输(实际通过 tinc)。
使用 docker 的原因是实际上 pyspider 能够运行任何 python 脚本,至少需要 docker 环境逃逸。
demo.pyspider.org 的**数据库为 PostgreSQL,理由是测试目的,磁盘占用和性能的折中。消息队列为 Redis**,因为部署简单。
它们也是跑在 docker 中的:
1 | docker run --name postgres -v /data/postgres/:/var/lib/postgresql/data -d -p $LOCAL_IP:5432:5432 -e POSTGRES_PASSWORD="" postgres |
由于前面说过,机器间有内网,通过绑定内网 IP,没有做鉴权(反正 demo 会泄露)。
由于 scheduler 只能运行一个,并且需要进行大量的数据库操作,它与上面的数据库和消息队列部署在一台单独的机器上。
1 | docker run --name scheduler -d -p $LOCAL_IP:23333:23333 --restart=always binux/pyspider \ |
所有其他的组件(fetcher, processor, result_worker)在剩余的两台 VPS 上以相同的配置启动。他们都是通过 docker-compose 管理的
1 | phantomjs: |
然后通过 docker-compose scale phantomjs=2 processor=2 webui=4
指定启动两个 phantomjs 进程,两个 processor 进程,4个 webui 进程。
由于 phantomjs 有内存泄露问题,限制下内存就好了。EXCLUDE_PORTS
是为了下面的 haproxy 能够正确的均衡负载正确端口。
通过 haproxy 自动负载均衡,只要将服务链接上去,就会将请求分发到不定多个 phantomjs 实例上,同时只暴露一个对外服务端口。
链接 phantomjs-lb:phantomjs
,注意这里的 --phantomjs-proxy "phantomjs:80"
由于 fetcher 是异步 http 请求,如果没有发生堵塞,单个 fetcher 一般就足够了。
同 phantomjs-lb
processor 为最消耗 CPU 的组件,建议根据 CPU 的数量部署 +1/2 个。
默认的 result-worker 只是在写数据库,除非发生堵塞,或者你重载了 result_worker,一个就够。
首先,webui 为了安全性,限制了最大抓取速率 --max-rate 0.2 --max-burst 3
。
然后通过实际的 fetcher 进行抓取 --fetcher-rpc "http://fetcher/"
而不是 webui 自己发起请求,最大程度模拟环境(IP,库版本),因为以前遇到过调试的时候没问题,跑起来失败,然后在调试器复现又没法复现的问题。fetcher-rpc 可以不用,这样的会 webui 会自己直接发起请求。
因为 demo.pyspider.org 主要就是提供通过页面来尝试 pyspider, 这里的负载较大,而且实现上是同步的,任何脚本执行,抓取都是堵塞了,多一些 webui 会比较好。
同 phantpmjs-lb
这里做了一些前端缓存
因为懒得管,每小时我会重启除了 scheduler 以外的其他组件(反正会重试)。
]]>跳槽,工资没涨…… 到这里居然和2013年是一样的,但是当我在2015写下这篇日志的时候,国内已经2016。
说来惭愧,这一年除了一月写了几篇教程之后,不但 blog 落下了,开源也没有做多少。看着 pyspider 的 star 数蹭蹭涨到 5813,但是并没有太多精力去更新。希望在 2016 年能有时间把 slime 模式的坑填了吧。
其他的项目也就在年末的时候又重新玩了一把 WebRTC,基于的 WebTorrent 经过一年的开发,已经成熟了很多,feross 在 javascript 上从 tracker 到 BT 协议都实现了一遍,比起我那时山寨的好了非常多,虽然 hybrid 模式还有很多问题。。。对了 2015 年参与过 technical review 的 Learning WebRTC 也出版了,算是一次挺有趣的经历吧。
8月到英国之后,就是各种适应,加上新公司的蜜月期,一门心思放在了公司的项目上。在新公司才算是第一次接触到了机器学习,给我带来了很多新的思路,有种能成的感觉吧。
希望2016年能更有趣吧。
然后说一些「关于英国生活」类似的东西吧,想到什么写什么
目的地英国,工作,8月初。
要说经历,其实简单到爆。
工资没涨,物价大涨,难吃,加上烂英语,瞬间感觉生活甚是艰辛。。。不过, there is always a way,就当作一次挑战吧。
就酱
]]>在上两篇教程中,我们学习了怎么从 HTML 中提取信息,也学习了怎么处理一些请求复杂的页面。但是有一些页面,它实在太复杂了,无论是分析 API 请求的地址,还是渲染时进行了加密,让直接抓取请求非常麻烦。这时候就是 PhantomJS 大显身手的时候了。
在使用 PhantomJS 之前,你需要安装它(安装文档)。当你安装了之后,在运行 all
模式的 pyspider 时就会自动启用了。当然,你也可以在 demo.pyspider.org 上尝试。
当 pyspider 连上 PhantomJS 代理后,你就能通过在 self.crawl
中添加 fetch_type='js'
的参数,开启使用 PhantomJS 抓取。例如,在教程二中,我们尝试抓取的 http://movie.douban.com/explore 就可以通过 PhantomJS 直接抓取:
1 | class Handler(BaseHandler): |
- 我在这里使用了一些 PyQuery 的 API,你可以在 PyQuery complete API 获得完整的 API 手册。
你会发现,在上面我们使用 PhantomJS 抓取的豆瓣热门电影只有 20 条。当你点击『加载更多』时,能获得更多的热门电影。为了获得更多的电影,我们可以使用 self.crawl
的 js_script
参数,在页面上执行一段脚本,点击加载更多:
1 | def on_start(self): |
- 这个脚本默认在页面加载结束后执行,你可以通过
js_run_at
参数 修改这个行为- 由于是 AJAX 异步加载的,在页面加载完成时,第一页的电影可能还没有加载完,所以我们用
setTimeout
延迟 1 秒执行。- 你可以间隔一定时间,多次点击,这样可以加载更多页。
- 由于相同 URL (实际是相同 taskid) 的任务会被去重,所以这里为 URL 加了一个
#more
上面两个例子,都可以在 http://demo.pyspider.org/debug/tutorial_douban_explore 中找到。
]]>self.crawl
API 抓取豆瓣电影的 HTML 内容,并使用 CSS 选择器解析了一些内容。不过,现在的网站通过使用 AJAX 等技术,在你与服务器交互的同时,不用重新加载整个页面。但是,这些交互手段,让抓取变得稍微难了一些:你会发现,这些网页在抓回来后,和浏览器中的并不相同。你需要的信息并不在返回 HTML 代码中。在这一篇教程中,我们会讨论这些技术 和 抓取他们的方法。(英文版:AJAX-and-more-HTTP)
AJAX 是 Asynchronous JavaScript and XML(异步的 JavaScript 和 XML)的缩写。AJAX 通过使用原有的 web 标准组件,实现了在不重新加载整个页面的情况下,与服务器进行数据交互。例如在新浪微博中,你可以展开一条微博的评论,而不需要重新加载,或者打开一个新的页面。但是这些内容并不是一开始就在页面中的(这样页面就太大了),而是在你点击的时候被加载进来的。这就导致了你抓取这个页面的时候,并不能获得这些评论信息(因为你没有『展开』)。
AJAX 的一种常见用法是使用 AJAX 加载 JSON 数据,然后在浏览器端渲染。如果能直接抓取到 JSON 数据,会比 HTML 更容易解析。
当一个网站使用了 AJAX 的时候,除了用 pyspider 抓取到的页面和浏览器看到的不同以外。你在浏览器中打开这样的页面,或者点击『展开』的时候,常常会看到『加载中』或者类似的图标/动画。例如,当你尝试抓取:http://movie.douban.com/explore
你会发现电影是『载入中…』
由于 AJAX 实际上也是通过 HTTP 传输数据的,所以我们可以通过 Chrome Developer Tools 找到真实的请求,直接发起真实请求的抓取就可以获得数据了。
Ctrl
+Shift
+I
(在 Mac 上请按 Cmd
+Opt
+I
) 打开开发者工具。在页面加载的过程中,你会在面板中看到所有的资源请求。
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
在豆瓣这个例子中,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 原始数据。推荐安装 JSONView(Firfox版)插件,这样可以看到更好看的 JSON 格式,展开折叠列等功能。然后,我们根据 JSON 数据,编写一个提取电影名和评分的脚本:
1 | class Handler(BaseHandler): |
- 你可以使用
response.json
将结果转为一个 python 的dict
对象
你可以在 http://demo.pyspider.org/debug/tutorial_douban_explore 获得完整的代码,并进行调试。脚本中还有一个使用 PhantomJS 渲染的提取版本,将会在下一篇教程中介绍。
HTTP 是用来传输网页内容的协议。在前面的教程中,我们已经通过 self.crawl
接口提交了 URL 进行了抓取。这些抓取就是通过 HTTP 协议传输的。
在抓取过程中,你可能会遇到类似 403 Forbidden
,或者需要登录的情况,这时候你就需要正确的 HTTP 参数进行抓取了。
一个典型的 HTTP 请求包如下,这个请求是发往 http://example.com/ 的:
1 | GET / HTTP/1.1 |
- 请求的第一行包含
method
,path
和 HTTP 协议的版本信息- 余下的行被称为 header,是以
key: value
的形式呈现的- 如果是 POST 请求,在请求结尾可能还会有
body
内容
你可以通过前面用过的 Chrome Developer Tools 工具查看到这些信息:
在大多数时候,使用正确的 method
, path
, headers
和 body
总是能抓取到你需要的信息的。
HTTP Method 告诉服务器对 URL 资源期望进行的操作。例如在打开一个 URL 的时候使用的是 GET 方式,而在提交数据的时候一般使用 POST。
TODO: need example here
HTTP Headers 是请求所带的一个参数列表,你可以在 这里 找到完整的常用 Headers 列表。一些常用的需要注意的有:
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 用于告诉服务器,你访问的上一个网页是什么。常常被用于防盗链,在抓取图片的时候可能会用到。
当使用 XHR 发送 AJAX 请求时会带上的 Header,常被用于判断是不是 AJAX 请求。例如在 北邮人论坛 中,你需要:
1 | def on_start(self): |
带有 headers={'X-Requested-With': 'XMLHttpRequest'}
才能抓取到内容。
虽然 Cookie
只是 HTTP Header 中的一个,但是因为非常重要,但是拿出来说一下。Cookie
被 HTTP 请求用来区分、追踪用户的身份,当你在一个网站登录的时候,就是通过写入 Cookie
字段来记录登录状态的。
当遇到需要登录的网站,你需要通过设置 Cookie 参数,来请求需要登录的内容。Cookie 可以通过开发者工具的请求面板,或者是资源面板中获得。在 pyspider 中,你也可以使用 response.cookies
获得返回的 cookie,并使用 self.crawl(URL, cookie={'key': 'value'})
来设置请求的 Cookie 参数。
在 教程一 中,我们将要爬取的网站是豆瓣电影:http://movie.douban.com/
你可以在: http://demo.pyspider.org/debug/tutorial_douban_movie 获得完整的代码,和进行测试。
由于教程是基于 pyspider 的,你可以安装一个 pyspider(Quickstart,也可以直接使用 pyspider 的 demo 环境: http://demo.pyspider.org/。
你还应该至少对万维网是什么有一个简单的认识:
所以,爬网页实际上就是:
既然我们要爬所有的电影,首先我们需要抓一个电影列表,一个好的列表应该:
我们在 http://movie.douban.com/ 扫了一遍,发现并没有一个列表能包含所有电影,只能退而求其次,通过抓取分类下的所有的标签列表页,来遍历所有的电影: http://movie.douban.com/tag/
在 pyspider 的 dashboard 的右下角,点击 “Create” 按钮
替换 on_start
函数的 self.crawl
的 URL:
1 |
|
self.crawl
告诉 pyspider 抓取指定页面,然后使用callback
函数对结果进行解析。@every
修饰器,表示on_start
每天会执行一次,这样就能抓到最新的电影了。
点击绿色的 run
执行,你会看到 follows
上面有一个红色的 1,切换到 follows
面板,点击绿色的播放按钮:
在 tag 列表页 中,我们需要提取出所有的 电影列表页 的 URL。你可能已经发现了,sample handler 已经提取了非常多大的 URL,所有,一种可行的提取列表页 URL 的方法就是用正则从中过滤出来:
1 | import re |
- 由于 电影列表页和 tag列表页长的并不一样,在这里新建了一个
callback
为self.list_page
@config(age=10 * 24 * 60 * 60)
在这表示我们认为 10 天内页面有效,不会再次进行更新抓取
由于 pyspider 是纯 Python 环境,你可以使用 Python 强大的内置库,或者你熟悉的第三方库对页面进行解析。不过更推荐使用 CSS选择器。
再次点击 run
让我们进入一个电影列表页(list_page
)。在这个页面中我们需要提取:
CSS选择器,顾名思义,是 CSS 用来定位需要设置样式的元素 所使用的表达式。既然前端程序员都使用 CSS选择器 为页面上的不同元素设置样式,我们也可以通过它定位需要的元素。你可以在 CSS 选择器参考手册 这里学习更多的 CSS选择器 语法。
在 pyspider 中,内置了 response.doc
的 PyQuery 对象,让你可以使用类似 jQuery 的语法操作 DOM 元素。你可以在 PyQuery 的页面上找到完整的文档。
在 pyspider 中,还内置了一个 CSS Selector Helper
,当你点击页面上的元素的时候,可以帮你生成它的 CSS选择器 表达式。你可以点击 Enable CSS selector helper
按钮,然后切换到 web
页面:
开启后,鼠标放在元素上,会被黄色高亮,点击后,所有拥有相同 CSS选择器 表达式的元素会被高亮。表达式会被插入到 python 代码当前光标位置。创建下面的代码,将光标停留在单引号中间:
1 | def list_page(self, response): |
点击一个电影的链接,CSS选择器 表达式将会插入到你的代码中,如此重复,插入翻页的链接:
1 | def list_page(self, response): |
- 翻页是一个到自己的
callback
回调
再次点击 run
,follow 到详情页。使用 css selector helper
分别添加电影标题,打分和导演:
1 | def detail_page(self, response): |
注意,你会发现 css selector helper
并不是总是能提取到合适的 CSS选择器 表达式。你可以在 Chrome Dev Tools 的帮助下,写一个合适的表达式:
右键点击需要提取的元素,点击审查元素。你并不需要像自动生成的表达式那样写出所有的祖先节点,只要写出那些能区分你不需要的元素的关键节点的属性就可以了。不过这需要抓取和网页前端的经验。所以,学习抓取的最好方法就是学会这个页面/网站是怎么写的。
你也可以在 Chrome Dev Tools 的 Javascript Console 中,使用 $$(a[rel="v:directedBy"])
测试 CSS Selector。
run
单步调试你的代码,对于用一个 callback
最好使用多个页面类型进行测试。然后保存。status
修改为 DEBUG
或 RUNNING
run
按钮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,确认语句在两个环境下都能执行。
在这一步,我做了以下处理:
get_ident
函数不再存在了。将 thread.get_ident()
改为 threading.current_thread().ident
sixbasestring
类型不再存在,用 six.string_types
代替 sheet__metaclass__
不再存在,用 six.add_metaclass
代替 sheetUserDict.DictMixin
不再存在,用 collections.Mapping
或者 collections.MutableMapping
代替/
现在是真的除法了,也就是说 int / int 会得到一个 float,使用 //
获得地板除效果(由于在 python 中,地板除用得少,实际上不改关系不大) sheetStringIO
现在分为 io.BytesIO
和 io.StringIO
视情况使用unicode
关键字不再存在 使用 six.text_type
代替__builtins__
不存在了,six.moves.builtins
sheetreload
改为 six.reload_module
keys
, items
, values
现在都是迭代器了,不返回列表,原来的 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
,请放下他们,我们在下一节处理这些问题。
由于在 Python 3 中,所有的 'text'
都变成 unicode 了,所以你会觉得它会是一个大问题,是否需要给所有的 'text'
加上 u
,或者干脆所有文件都加上 from __future__ import unicode_literals
?
实际上,大部分时候不需要。
在 Python 2 中,我们很少有意识地区分 str
和 unicode
,对于大部分函数调用来说,给它 str
或者 unicode
都是一样的,因为他们共享大部分行为。但是在 Python 3 中,bytes
和 str
(unicode
) 却大不一样。例如当你 for c in bytes
时,得到的是一个 int
而不是一个 str
。
虽然不做任何修改,'text'
在 Python 2 中,是 str
(bytes
),而在 Python 3 中是 str
(unicode
)。但是提交给函数时,既然 Python 2 的函数同时支持 str
和 unicode
,所以没有任何问题。而且,在 Python 2 中,'text'+u'中文'
会自动升级为 unicode
,所以,只需要注意在出现中文的地方使用 u'中文'
就好了(即使在 Python 2 中,这也是一个好的习惯)。而 b'bytes'
的场合非常少,更多的是使用 text.encode
进行转换。所以,对于习惯良好的 Python 2 代码来说,是几乎不需要修改的。
除了源代码之中的 unicode 问题,其他主要问题出现在输入输出上。但是,只要遵循:程序中流通的数据,只能是 unicode。数据进来之后必须转换成 unicode 即可。
运行测试,哪报错改哪就好了。
]]>那么由我再次介绍一下 pyspider。
pyspider 来源于以前做的一个垂直搜索引擎使用的爬虫后端。我们需要从200个站点(由于站点失效,不是都同时啦,同时有100+在跑吧)采集数据,并要求在5分钟内将对方网站的更新更新到库中。
所以,灵活的抓取控制是必须的。同时,由于100个站点,每天都可能会有站点失效或者改版,所以需要能够监控模板失效,以及查看抓取状态。
为了达到5分钟更新,我们使用抓取最近更新页上面的最后更新时间,以此来判断页面是否需要再次抓取。
可见,这个项目对于爬虫的监控和调度要求是非常高的。
由于功能太多,更多请参考脚本编写指南
感谢 +PhoenixNemo 提供的VPS,提供了一个 demo: demo.pyspider.org。无需安装即可体验。
1 | from libs.base_handler import * |
例如这就是创建任务后默认生成的一个脚本示例。
on_start
回调函数,作为爬取的入口点,当点击主面板上的 run
的时候,就是调用这个函数,启动抓取。self.crawl
告诉调度器,我们需要抓取 'http://scrapy.org/'
这个页面,然后使用 callback=self.index_page
这个回调函数进行解析。return
的内容默认会被捕获到 resultdb
中,可以直接在 WEBUI 上看到。好了,不扯蛋了。自动签到是我对于 “如何请求到数据” ,进行请求自动分析的一个尝试(实际是我 U2 因为45天没登录被封了)。通过浏览器捕获页面请求瀑布流,进行内容/请求分析,找出关键请求。所以,签到这个项目,我就是先从 HAR编辑器 开始做的。做的时候还玩了一下 angularjs。然后其他部分都是随便写的
但是,对于签到来说,哪些请求是必要的,这个请求是怎么组装的(例如 token 参数怎么来),特征不明显。自动分析出来就能直接用的概率太低了,即使是人还得单步测试呢。于是 HAR编辑器 成为编辑和单步调试的辅助。自动分析变成了 “推荐相关请求”。
本来还想要做互助打码的验证码系统的,但是通过 雪月秋水 的 cookie插件,其实大部分只有登录需要验证码,签到并不需要。关键是做这个东西不好玩,于是就算了。
运行了一个半月,目前有11个公开签到模板,400+个签到任务,每天进行300次签到。不过由于担心单IP登录帐号过多被封,只在v2ex做了一次广告,不敢大范围推广。。。
以下是面向普通用户的简介:
github: binux/qiandao
网站: https://qiandao.today
关于 pyspider 项目,这几天我也在慢慢填上这延期了3个月的坑,至少完成第二里程碑吧。但缺少实际应用的环境,很多东西是否工作得很好,我也不是很有把握。如果有的话,还是希望支持1-2个实际的抓取项目吧。
而 “如何获得页面/数据” 这个问题依旧是我持续关注中,想要去解决的问题。但是,既然某厂的后续解决方案是将所有抓取页面过 webkit 渲染(虽然很多时候渲染不出 或 需要点击动作,代价往往大于直接抓 API),不会有很多精力投入,待我慢慢想想。。
##四种解析模式
示例: https://www.kimonolabs.com/
通过手动、自动、半自动方式,设定需要抽取元素的 xpath、 css选择器 或 正则表达式 进行定位提取的方法(这里需要指出的是,html 并不是正则的,正则表达式可能在部分简单提取时有效,但 不要用正则表达式进行页面提取)。其根本思想是提供一种定位元素的规则进行页面抽取。
这个方法被用得最多,好处是有效,嗯。缺陷在于用户需要会 xpath / css选择器 / 正则语法,虽然有一些工具(例如上面的kimono、chrome的调试工具、pyspider里面的脚本)辅助生成规则,但可能通用性不足 或 区分度不够,选取到不需要的内容。这在大批量抽取时需要大量的高级人力去配置,即使是熟练工也需要5-10分钟配置一个页面(6-8个属性),需要耗费大量精力。
这种抽取方式的一种变形是:将 key 和 value 同时在页面中标出,通过 key 和 value 总是穿插出现的这一假设,省去单独为每个属性设置规则的人力,极大增快标注效率。
例如:http://movie.douban.com/subject/7054604/ 这个页面中的
导演: 迈克尔·贝
编剧: 伊伦·克鲁格
主演: 马克·沃尔伯格…
类型: 动作 / 科幻 / 冒险
制片国家/地区: 美国 / 中国大陆
语言: 英语 / 汉语普通话 / 粤语
上映日期: 2014-06-27(美国/中国大陆)
片长: 166分钟
又名: 变形金刚:歼灭世纪(港) / 变形金刚4:灭绝时代 / 变形金刚4 / 变4 / Transformers 4
IMDb链接: tt2109248
导演/编剧/类型等 属性名 往往拥有相同的 xpath,而值的 xpath 也是独立的几种。他们一定是 key: value 的形式组织的,通过用 key 分割 value 的方式能轻松将所有信息提取出来。
示例: http://googlewebmastercentral.blogspot.com/2012/12/introducing-data-highlighter-for-event.html
Data Highlighter 的标注方式是:给一系列相似的页面,让用户标出(高亮)每个属性在页面中的位置。通过多个页面的标注信息,寻找每个属性的特征。当然了,这个特征可以是 xpath,也可以是上下文,也有可能是机器学习的特征向量。
Data Hightlighter 通过高亮 多个页面中相同属性 进行规则学习,省去了人为设置规则时的学习成本。实践表明,在单一页面模板下,标记2个页面就足以生成规则了。效率远大于手工设置规则。Google Data Highlighter 甚至对文字进行了切分,能在 英语 / 汉语普通话 / 粤语
xpath 相同的情况下,分别选出三种语言。是我目前见过的成熟度最高、通用性最好、最简便的数据抽取方式。
示例: http://microformats.org/ 以及各大网站
页面属性标记,通过在页面数据元素上增加属性标识,通过开放的标准格式,为数据提取提供便利,例如这是豆瓣的评论数据:
1 | < p class="rating_self clearfix" typeof="v:Rating"> |
typeof="v:Rating"
表明这里是 rating 数据,v:best
表明 rating 的最大值。通过开放的 data format 标准,只按照标准抽取,就能得到包含的结构化数据。但是,需要站长的支持,在页面中加入标记才可以。
从广义上讲,主图识别,页面发布时间这样的属性,其实也可以是通过对页面内容进行分析获得的。这与 micro-data 一样,通过元素足够强的特征,对元素的含义进行理解分析。
页面模板(wrapper)抽取是基于这样一个假设:结构化页面都是 通过模板 将数据库中的数据 映射成页面的。通过页面分析,得到页面模板,通过模板提取出实际的结构化数据。
例如,我使用过的方法,将多个相似页面放在一起比对,寻找等位节点(具有相同结构或表示相同数据类型的元素),将 DOM树 合并。通过比较不同页面上的同类节点,能够获知页面中哪部分是变化的,哪部分是不变的。变化的部分为数据,不变部分为模板。最后形成如上图所示的模板,页面变化部分被涂黑。这个方法类似于将多张纸叠在一起,透过光去看,就会发现变化的文字部分会比其他部分更黑。
当然了,这个方法也有缺陷,例如:页面一,页面二 的标题部分,一个是蓝色,一个是绿色,虽然在人类视觉上它们相差不大,但从页面结构上绿色多了一层 <font>
,作为算法很难理解,这样的样式表示他们是否有相同的含义,是否有区别。同理左侧推荐的蓝绿相间,即使作为人也很难理解它们有什么区别。
##两个核心问题
总结起来,以上四种解析模式都在尝试解决以下两个问题:
当你打开一个页面,你怎么知道一个页面在传递什么信息?你怎么知道一个元素是文章的标题?怎么知道一个元素是作者?作为人类,我们可能会看到一个元素的位置是否在页面中间,元素的字体大小、颜色,元素前面是不是有一个 “作者:”,元素内容是否长得像一个人名/时间,上下文中这个元素都在讲什么,这篇文章是什么领域,等等。人类可能会有非常多的 经验知识 ,当看到一个页面的时候能够解读出页面上的信息。
在 “xpath / css选择器 / 正则表达式” 的解析模式中,这个工作正是人肉去完成的,人去解读这个页面,找到信息所在元素。而在 “data highlighter” 的解析模式中,也需要人在多个页面中进行标注,告诉机器每个属性所在。
但是作为计算机,是否能做到这一点?micro-data 通过开放的格式约定,通过 property
这一个特殊的属性标记告诉计算机一个元素说的是什么。而模板挖掘通过:xpath,元素class,id属性,上下文等特征去挖掘元素的含义。
但是,页面样式结构,在人类在没有足够的知识情况下,也有可能会无法解读,例如我们的爷爷奶奶可能就看不懂网页上说的是什么。同时,正如语言是有二义性的一样,页面结构也会如此,这给计算机去理解,页面说的是什么,带来了巨大的困难。
因为,大批量数据抽取是计算机的活,这需要 准确 地告诉计算机,你想要抽取的元素是哪一个。在 “xpath / css选择器 / 正则表达式” 的解析模式中,xpath、css选择器、正则表达式正是对这一信息的描述。选取一个正确的表达式,即涵盖不同页面,又和其他属性有所区分,是一件需要经验和技巧的工作。而 “data highlighter” 将这个工作交给了计算机。“模板生成和套用” 过程中也由计算机分析出了规则。
而对于 “micro-data” 来说,这个问题有些特殊。通过开放的标准格式,程序已经能够了解每个元素在说什么了,那么定位就不再有意义。但是反过来,这又何尝不是一种定位。
结构化解析实质是计算机对一个页面的理解,无论这种理解是人去创建规则、做出某种约定 还是 机器学习。上面列举的四种解析方式,“xpath / css选择器 / 正则表达式” 和 “data highlighter” 回答了这个元素和其他的有什么区别。 “micro-data” 利用了一个元素在说什么。而 “模板生成与提取” 同时涉及元素说什么,它在哪。
那么作为结构化解析的究级形态是怎样?我们可以假想一个人,他打开一个页面就能知道上面说的是什么,有什么样的信息,这是人类对于:通过网页获取知识的一种能力,一种方式。计算机也是一样,结构化抽取 就是 计算机从网页中获取知识的过程。“这个元素和其他的元素有什么区别” 终究只是在无法达到:计算机理解 一个页面在说什么 的辅助手段。理解 “一个元素在说什么” 乃至 “一个页面在说什么” 我认为是才是其究级形态,而结构化数据也不过是计算机,对于浩瀚互联网信息理解的一种表达罢了。
]]>pyspider是我一年多之前做的一个爬虫架构的开源化实现。主要的功能需求是:
而这也是绝大多数python爬虫的需求 —— 定向抓取,结构化化解析。但是面对结构迥异的各种网站,单一的抓取模式并不一定能满足,灵活的抓取控制是必须的。为了达到这个目的,单纯的配置文件往往不够灵活,于是,通过脚本去控制抓取是我最后的选择。
而去重调度,队列,抓取,异常处理,监控等功能作为框架,提供给抓取脚本,并保证灵活性。最后加上web的编辑调试环境,以及web任务监控,即成为了这套框架。
pyspider的设计基础是:以python脚本驱动的抓取环模型爬虫
这与后来在某厂看到的spider系统整体架构上区别不大
webui
scheduler
fetcher
processor
pyspider的架构主要分为 scheduler(调度器), fetcher(抓取器), processor(脚本执行):
各个组件间使用消息队列连接,除了scheduler是单点的,fetcher 和 processor 都是可以多实例分布式部署的。 scheduler 负责整体的调度控制
任务由 scheduler 发起调度,fetcher 抓取网页内容, processor 执行预先编写的python脚本,输出结果或产生新的提链任务(发往 scheduler),形成闭环。
每个脚本可以灵活使用各种python库对页面进行解析,使用框架API控制下一步抓取动作,通过设置回调控制解析动作。
注:output部分设计尚未决定,因为希望输出也可以很灵活地进行。现在是在脚本中有一个on_result
的回调,在里面可以自行实现结果输出。
翻自动墙路由基本除去apnic的国内ipv4白名单走国内方案,就剩下autoddvpn的封禁ip列表了(透明代理不考虑)。国内ip白名单的问题是,如果要玩外服DOTA,还得手动加上各种游戏的服务器IP,而autoddvpn万年不更新,很多时候根本命中不了。
于是,有了下面这个根据DNS查询记录添加封禁IP记录的方法:
通过脚本
logread -f
可以替换为 tail -f 日志文件
dev pptp-vpn
可以替换为建立VPN的链接的名字
不过,缺陷是。。第一次访问时需要过1分钟左右才能生效。。
另外,这个是福利: lifandb.html 来自 github/youxiachai/lifandb (请用chrome打开,如果安装了adblock-plus请先禁用)
]]>