猫之城物理钓鱼挂(二):图像采集以及画面分类

上一篇中,我们实现了屏幕触控的物理输出,但是钓鱼这个小游戏还是需要根据画面反馈来做动作的。我一开始的想法是用一个摄像头拍摄平板的画面然后进行图像处理。尝试了一会发现,就摄像头这 720P 的分辨率,光是梯形校正准确率都不高,可能做个图像分类还行,但是要分辨画面中的信息对我来说还是有些困难。然后睡前一阵查找,发现了软件实现的 AirPlay。

图像采集

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

OBS Capture Setting

Note:虽然最新的 OBS 自带了 Virtual Cam,但是似乎在 Windows 上和 OpenCV 有兼容性问题,捕获的画面是黑的,依旧需要使用插件解决。

画面分类

现在采集到了平板上的游戏画面,下一步就是给画面进行分类,来获得游戏所处的界面。这么做主要有这些原因:

  1. 很多游戏操作是有网络交互的,当点击按钮之后,会有不定长的延迟进入下一个界面,在下一步操作前进行画面分类识别能更鲁棒
  2. 在有的界面中,存在需要进一步识别的交互内容,例如钓鱼小游戏的浮标。先识别界面类型,能更有效和准确地决定是否需要进行这些信息提取。
  3. 获取当前状态可以使 bot 更灵活,脚本可以从任意状态启动。这一部分会再 第四篇 blog 中讲到。

在这个项目中,我直接抄了 tensorflowImage Classification Tutorial。对于这种标准的 UI 界面,随便什么模型效果应该都不差:classifier_training.ipynb

做图片分类的第一步是采集训练样本,你会注意到 screen_capture.pyobs_capture.py__main__ 部分都有 cv.imwrite 以及对应的按键绑定的代码。我首先会在开启图像采集的过程中,游玩游戏,手动进行需要自动化的整个流程,手动或者每 1 秒地频率采集一些原始图像。然后对应每一个分类新建一个文件夹,例如在《猫之城》中,我有 fish_idle, fish_ring, fish_drag, fish_reward 和 not_supported 这样一些分类。然后将采集到的图片拖到对应分类的文件夹中,我对于分类和图片的选取是这样的:

  • 分类之间的图像需要有较大的不同。例如,点击之后显示的确认对话框就没必要单独建立一个分类。
  • 每个分类选取至少 10-20 张 不同 的图片,尽量涵盖这个类别可能出现的所有变种,例如《猫之城》中 fish_idle 会出现不同的场景导致背景不一样。
  • 单个类别不应该有远多于别的类别的样本,最多和最少之间不超过 10 倍这样。
  • not_supported 可以用来放一些脚本用不到的 UI 截图来增加类别之间的差异性,以及在进入没有分类的页面的时候不会错误激活脚本。

然后就是套代码了,图片分类并不需要很高的图片分辨率,这里我随便选了一个 220x300 来保持图片宽高比,套示例模型就能达到 99% 的准确度了。因为是 UI 界面,也不存在裁切变换,之后实际测试结果也非常好。最后将分类列表和模型保存下来就可以啦。为了保存单个文件,并且减少体积,使用的是 TensorFlow Lite 模型,predict 的代码在 classifier.py。唯一需要注意的是使用的时候需要自己 resize 到 220x300,并且 OpenCV 图片的颜色是 BGR 而 tensorflow 是 RGB 的,需要要进行转换。其他就没什么了,总共有效代码也就 15 行,踩着巨人的肩膀,使用成熟的库之后还是挺简单的。

Image Classification Demo

Note: 图片中的 fish_ring = 099% 就是图片分类的结果和 score,而其他的图片识别内容和辅助线就在下一篇 blog 中讲解啦。

猫之城物理钓鱼挂(一):物理模拟触屏点击

真的有2年半没有写 blog 了。我是那种不愿意在事情尘埃落定之前,把它写下来的类型。在这两年半里,H1B 抽到了,也跳槽了。收入上去之后,也更愿意花钱解决问题,而不是自己做点什么,有好几次想要提笔,又感觉没什么好写的。以后会改善吗?我觉得不会,虽然我依旧会去尝试各种新的东西,但是觉得有必要写下来的变少了,现在我更多的是追求安稳的生活吧。

言归正传,这次带来的是一个手游《猫之城》的物理挂。4年以前,我做过一个《少前》的脚本,使用的是一个机器内的 App 来采集图像然后驱动控制的,然后就被封号了:P 。于是这一次就打算使用物理的方法,在机器外部实现所有的信息处理和控制。比如下面这一段视频,就是这个物理挂识别游戏内的钓鱼小游戏,然后通过电极模拟触控实现的:

在这一系列 blog 中,我会分为

  • 模拟物理点击
  • 图像采集以及画面分类
  • 游戏图像细节信息提取
  • 控制与 bot 状态机

等篇章讲解这个 bot 的实现,同时源代码已经上传到了 github: cat-planet-bot注意:使用外挂是违反游戏用户协议的行为。由于代码中使用了大量 hard coded 图像坐标,我并不认为你能直接使用它。这份代码仅在 blog 中作为引用,讲解学习。

我也是第一次做硬件,电子电路以及图像处理开发(所以使用的最简单的 Arduino),我只会很简单地介绍这方面的知识,如果有什么错误,或者更好的方案,欢迎在评论中指出。

电容屏的物理触控

简单的说,现在的平板手机的触摸屏都是电容屏,它是通过测量手指靠近屏幕导致的电容变化来获取点击位置的,所以只要你能造成屏幕上某个区域的电容变化,就能模拟出点击。不过,不管怎么说,不同屏幕实现方式和灵敏度还是有区别的,最靠谱简单的方式还是实践。毕竟只要某个方案在你自己的机器上有效就可以了嘛。这里我直接使用了一个网页,在 iPad 上测试了一些可行和不可行的例子:

不可行的:

  • 塑料的笔(和其他绝缘材料)
  • 没有接地的硬币(比如你手拿着硬币就可以认为是有效接地)
  • 没有接地的铝箔卷(在形状合适的情况下,不接地的铝箔卷可以形成点击,类似多芯导线形成的毛刷)
  • 接地了的硬币边缘
  • 单芯导线,回形针或者金属餐具(和屏幕接触面积只有一个点,无论是否接地)

可行的:

  • 电容笔或者触控笔(无论是否接地)
  • 接地并躺平的硬币
  • 接地了的铝箔卷
  • 不接地的多芯导线形成的毛刷
  • 电池正负极(类似上一条)

总结起来这里有两个关键:

  • 接触面积要足够大(面积要和手指类似)
  • 是否接地不是关键,但接地可以改变触控状态(这一点很重要,它是我们能够通过电路控制的关键)

于是,我这里选择的是:电击按摩贴。首先,这东西导电,而且可以随意裁剪大小,并且自带粘性,可以粘在屏幕上。通过控制是否接地(初次粘贴时屏幕会感应为持续按住状态,需要关闭再打开屏幕reset,原因见下文)可以控制触摸状态。最最最重要的是,这东西在美国很容易买到,并且我家里有:D 。如果你在国内,可以买到成品连点器,或者直接买吸盘造型的导电橡胶,价格实惠,卖家甚至已经给你接好了导线。

touch contact

电脑控制触控

在上一步我们知道可以通过电极的接地与否,模拟触摸的按下和抬起,这时候就需要一个 PC 到这个接地电路的控制器,这里一般是一个与 PC 通信的 MUC 中导出的 GPIO 接口。我这里使用的是 Arduino,一个非常成熟的入门级开发板和配套程序。不过你也可以用例如 树莓派,STM32,ESP32 等等平台,它们的开发板可能更便宜,而且有的还能做到无线控制。

然后电路具体怎么实现呢?简单查询,网上有说使用继电器,伺服电机的,也有说可以使用 N-channel MOSFET(也有说用 P-channel 或者不能用的)。但是都有一个问题:没有实物,而且我也不知道需要买什么规格的啊。在美国,如果一次没有搞定,重复购买的话,光运费就会多花出很多钱了。于是,我把镜头看向了国内,刷到了 【单片机】Arduino光遇自动弹琴机器人2.0来了 这个视频,视频中 UP 主使用了光耦甚至给出了型号。那还说什么呢?照着来呗。

选择这个方案还有一些原因是,其他方案里的,伺服电机存在机械机构,安装麻烦;继电器往往是电磁继电器,开关时会发出噪音;而 MOSFET 的电路不完全隔离,可能会因为自身存在一些电容,而屏幕判断不准确。

在面包板上将一个 GPIO 端口,与一个发光二极管,光耦,限流电阻串联,然后接地就可以了;光耦的另一端分别接按摩贴做的电极和地:

circle connection

在 GPIO 高电平时,光耦开关闭合,会将电极和地连通,从而模拟出按下的状态。

光耦和 LED 类似,需要串联一个限流电阻。例如我使用的 PC817 的 Forward Voltage 是 1.2V,电流 20mA。而 Arduino 的 GPIO 输出是 5V 的,通过 计算 我们需要串联一个大约 190Ω 的电阻。(什么,你说我还串联了一个 LED ?管它呢,又不是不能用)

Arduino 控制程序

Arduino 非常简单地提供了一个 IDE,插上开发板的 USB 就可以开始编程了,你首先可以照着 Arduino 的标准教程 Blink 熟悉下环境,当程序上传到开发板之后就不需要 IDE 了。PC 将通过 USB 连接 Arduino UART 串口进行通信。Arduino 部分的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void setup() {
Serial.begin(9600);
for (int i = 8; i <= 13; i++) {
pinMode(i, OUTPUT);
}
for (int i = A0; i <= A5; i++) {
pinMode(i, OUTPUT);
}
Serial.println("OK");
}

void loop() {
while (Serial.available()) {
String command = Serial.readStringUntil('\n');
String op = command.substring(0, 3);
String rest = command.substring(3);
if (op == "LOW") {
digitalWrite(rest.toInt(), LOW);
} else if (op == "HIG") {
digitalWrite(rest.toInt(), HIGH);
}
}
}

是不是很简单呢?这里首先初始化了开发板的串口,和一些 GPIO 作为输出。然后我们定义了一个通信协议:HIG00 和 LOW00 来控制 GPIO 的高低电平。注意,这里开发板在初始化后会立即发送一个 OK 信息,这是因为 Arduino UNO 会在串口连接上时重启,这个 OK 信息可以让 PC 端知道开发板已经准备好了。

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_pressautorelease 并且记录了每个 pin 的按下状态。这些都只是为了方便,并不是必须的,串口的速率是完全足够直接发送每个指令的,并且我测试中也没有感觉到任何延迟。

更多?

只要在面包板上插上更多的光耦和 Arduino GPIO 连接,然后制作更多的电极,就能支持多点触控了。但是,很明显的,这个数量是有限制的。而且无法改变点击位置,也无法实现滑动。那么有解决方案吗?我这里有一些想法:

首先,可以增加电极的数量,以至于覆盖到整个屏幕,然后在屏幕上分区分块控制每个点的电容变化。这个想法在 这篇论文 中有描述,但是我并没有找到成品。

而另一个更可行的方案是通过机械控制触控笔,实现全屏覆盖。例如改装一个 3D 打印机,将喷头换成一只触控笔,通过 3D 打印机的精确 3 轴移动来模拟点击。这个方案的好处在于 3D 打印机是一个非常便宜的成品,省去了零散零件采购的成本,而且很多 3D 打印机的固件是开源的,可以很容易地通过 G-code 操作。

总结

到这里,我们就打通了 PC 到平板的物理触控了。使用这些东西,就可以开发一些固定的自动化脚本了,例如下面这个脚本每次执行,自动按了 99 次 +10 然后购买:

在使用中,有一些影响触控成功率的经验:

  1. 在安装电极后,需要关闭屏幕再打开。(刚安装上的贴片,无论是否接地都会被检测为按下,重启屏幕可以将这个状态重置为抬起,这样接地时改变的电容就会检测为按下了。)
  2. 将平板接上电源效果会更好。(这样可以给平板接地,类似你手持平板的状态。但是并不需要和 Arduino 接在一起。)
  3. 我在电极和地之间接了一个电阻,可见导线的长度并没有关系。不需要担心导线太长产生的电容。

家居自动化

从 Google Assistant, Amazon Alexa, Apple Homekit 到米家,智能家居自动化已经不是什么新鲜的概念了。对于我来说,入坑的契机也非常简单:我不想下床关灯。然后随着想要自动化的场景增加,智能设备(可编程设备)就越来越多。这篇文章就根据自动场景介绍一下我现在的一些方案(本文无任何 affiliate )。

Hub

首先,在配置场景之前,需要选择一个 Hub —— 作为自动化中心,连接传感器和操作控制器(例如灯,插座,IR 遥控器等)。你可以选择 Google Home,Alexa,Homekit 这样大厂的方案,不过这里我还是推荐 Home Assistant 这样的开源方案:

  • 更多的接入设备支持(你甚至可以同时接入 Alexa 和 Google Assistant 的设备)
  • 更自由的自动化配置(例如 Google Assistant 不支持延迟触发;你甚至可以写 shell 脚本)
  • 更好的隐私保护(Home Assistant 的设备支持大多来源于逆向设备 API,能不联网就不联网)

我在 Synology DS218+ 上以 docker 运行 Home Assistant。

不过无论你选择什么方案,在这之后购买传感器和控制器的时候都需要注意你的 Hub 是否支持设备接入。考虑到价格,我的设备主要是 TP-Link 的插座加上米家的传感器,我会在具体场景中详细列出。

自动化场景

Hey Google, Good Night

首先就是我入坑的第一个场景,在床上关上家中所有的灯。我用到的设备有:

由于美国的房子没有灯,对的,没·有·灯。默认的开关控制的插座不一定在我想要的位置。这时候就可以用一个 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 比摄像头的方案要稳定得多,误触也少。

Hey Google, True on Projector

由于经常搬家,我都是用投影代替电视的。毕竟同样的尺寸,投影机容易搬多了。然后我现在的投影机是内置音响的,所以我还有一个 soundbar。这个场景就是,当我打开投影的时候,同时打开音响,关闭客厅灯,然后 PC 的输出切换到投影上,再打开 Plex。这里面用到的是:

首先是将这几个设备接入 Home Assistant,参考 Xiaomi IR Remotemqtt 的文档就好了。

然后是控制投影的开关,当米家万能遥控器接入 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+BB4EHgMeAh4BHgReAz4A6TCAA

switch:
  - 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.projector

input_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_screen

input_boolean:
  pc_screen: {}

然后就可以通过 Automation 把它们串起来了。由于是 WebUI 就能配置的,我就不贴出来了。注意一点是在打开投影机到切换 PC 输出之间加一个延迟,等到投影 ready 再切换,切换后再加个延迟再启动 Plex 就能保证 Plex 在投影的窗口前台全屏显示了。

总结

其他的例如

  • Hey Google, Turn on XXX 等单独的开关
  • Good Night 的时候同时关电脑,关投影
  • Google Assistant 控制 Alexa 设备
  • 监控本月流量有没有超过 1T,在 80% 关掉 PT 上传
  • 通过路由器监控接入设备,判断人在家的时候关闭摄像头监控
  • 当阳台摄像头检测到移动,Google Home Mini 的喇叭鸣警笛。
  • 当按照某种特定的顺序打开灯的时候,自动打开门,以防止出门忘带手机(前提是你能让 Google Home Mini 听到在门外的你)

由于都是重用现有设备这里就不介绍了,这些都能通过 Home Assistant 接入后用 Automation 完成。

总之「智能家居」中的「智能」其实就是一个语音识别加上一个个预定的场景,很蠢,但是,真香。当习惯了叫一句 Hey Google 就能躺着沙发上开关各种设备之后,就再也回不去找各种遥控器了。比起一个「懂你」然后随时监听上传的设备,一个离线语音识别,加自定义的场景可能能更快地满足你对自动化的需要。

如果你有家居自动化的点子或者方案也可以留言交流,(´▽`ʃ♡ƪ)

Zerotier Nat 网关出口 和 iptables 调试

每当看到各类教程中的 iptables 指令,在格式参数组合之下可以实现从防火墙,封禁 IP 端口到 NAT 的各种操作,就如同魔法一般,看不明白,却又感到无比强大。想学,但又好像不得要领,稍微不慎可能就再也连不上了。最近配置 Zerotier 的 Nat 网关的时候,看着 教程 中的各种指令,抄过之后完全不通,花了3个晚上之后,逼着搞清楚了怎么 debug ( 打 log ) 后,终于配置成功 (虽然最终失败的原因和 iptables 无关)。

Zerotier

首先介绍下 Zerotier 和为什么要配置 Nat 网关。

Zerotier 是一个虚拟局域网软件,可以很简单地将无限量(社区服务器版100台)设备放入同一个虚拟局域网中。这样就能在任何网络环境中,访问家中的 NAS 或其他设备。反过来,如果将一台服务器加入这个局域网中,将它配置为一个 NAT 网关,只要你加入这个虚拟局域网,就可以通过它连接世界。

选择 Zerotier 的原因是,它足够简单,只要一个 16 位的 network ID 就能实现组网了,相比我之前用过的 tinc,不需要节点 IP,不需要挨个配置节点,并且支持的操作系统广泛。

当然,第一步是安装 Zerotier,然后注册一个帐号创建一个私人网络。复制 network ID 在本地加入,然后回到网站中通过许可 (Auth?) 就好了。

当你将本地计算机和一台服务器加入网络后,然后就是根据 这个教程 。运行下面4个命令就行了:

1
2
3
4
sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -t nat -A POSTROUTING -o eth0 -s 10.6.4.0/22 -j SNAT --to-source 45.32.69.220
sudo iptables -A FORWARD -i zt+ -s 10.6.4.0/22 -d 0.0.0.0/0 -j ACCEPT
sudo iptables -A FORWARD -i eth0 -s 0.0.0.0/0 -d 10.6.4.0/0 -j ACCEPT

当然了,很明显,这里的 eth0, 10.6.4.0/22, 45.32.69.220 是需要根据实际环境替换的,zt+ 是指的任何以 zt 开头的网络,zerotier 都是以这样的名字创建的,所以不用修改。这都可以通过 ip addr 或者 ifconfig 进行确认。比如在我的环境中,环境是这样的:

  • 网关外网 IP:123.45.67.89
  • zertier 网络: 192.168.11.0/24
  • 网关 zerotier 网络 IP: 192.168.11.1
  • 本机 IP:192.168.11.20

命令是这样的:

1
2
3
4
sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -t nat -A POSTROUTING -o venet0 -s 192.168.111.0/24 -j SNAT --to-source 123.45.67.89
sudo iptables -A FORWARD -i zt+ -s 192.168.111.0/24 -d 0.0.0.0/0 -j ACCEPT
sudo iptables -A FORWARD -i venet0 -s 0.0.0.0/0 -d 192.168.111.0/24 -j ACCEPT

当我设置完了这些,然后把这个服务器设置为默认 0.0.0.0/0 的网关之后,我断网了
如果这样有用的话,我岂不是就没机会学习 iptables 了。

iptables

首先当然是把默认路由改回来。然后,如果只是为了调试,是不需要设置默认路由的,或者说最好不要设置默认路由到这台机器上的,你可以通过

1
2
3
4
# macos
sudo route add -net 98.76.54.32 192.168.111.1
# linux
# sudo route add -net 98.76.64.32 gw 192.168.111.1

设置一条单独的路由,到另一台主机上,然后就可以单独监控调试这条链路的情况了。

LOG 和 TRACE

好了,既然现在网络不通,我最想知道的当然是哪断了。

本机 -> 网关

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
2
3
4
5
6
7
8
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
Mar 2 15:58:11 myserver kernel: [5378263.921611] TRACE: raw:PREROUTING:policy:3 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 SEQ=3874826404 ACK=0 WINDOW=65535 RES=0x00 CWR ECE SYN URGP=0 OPT (02040ACA2C84454E80103030501000004010800000020000)
Mar 2 15:58:11 myserver kernel: [5378263.921645] TRACE: mangle:PREROUTING:policy:1 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 SEQ=3874826404 ACK=0 WINDOW=65535 RES=0x00 CWR ECE SYN URGP=0 OPT (02040ACA2C84454E80103030501000004010800000020000)
Mar 2 15:58:11 myserver kernel: [5378263.921672] TRACE: nat:PREROUTING:policy:1 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 SEQ=3874826404 ACK=0 WINDOW=65535 RES=0x00 CWR ECE SYN URGP=0 OPT (02040ACA2C84454E80103030501000004010800000020000)
Mar 2 15:58:11 myserver kernel: [5378263.921698] TRACE: mangle:FORWARD:policy:1 IN=zthnhi321 OUT=venet0 SRC=192.168.111.20 DST=98.76.54.32 LEN=64 TOS=0x00 PREC=0x00 TTL=63 ID=32766 DF PROTO=TCP SPT=65087 DPT=80 SEQ=3874826404 ACK=0 WINDOW=65535 RES=0x00 CWR ECE SYN URGP=0 OPT (02040ACA2C84454E80103030501000004010800000020000)
Mar 2 15:58:11 myserver kernel: [5378263.921719] TRACE: filter:FORWARD:rule:1 IN=zthnhi321 OUT=venet0 SRC=192.168.111.20 DST=98.76.54.32 LEN=64 TOS=0x00 PREC=0x00 TTL=63 ID=32766 DF PROTO=TCP SPT=65087 DPT=80 SEQ=3874826404 ACK=0 WINDOW=65535 RES=0x00 CWR ECE SYN URGP=0 OPT (02040ACA2C84454E80103030501000004010800000020000)
Mar 2 15:58:11 myserver kernel: [5378263.921741] TRACE: mangle:POSTROUTING:policy:1 IN= OUT=venet0 SRC=192.168.111.20 DST=98.76.54.32 LEN=64 TOS=0x00 PREC=0x00 TTL=63 ID=32766 DF PROTO=TCP SPT=65087 DPT=80 SEQ=3874826404 ACK=0 WINDOW=65535 RES=0x00 CWR ECE SYN URGP=0 OPT (02040ACA2C84454E80103030501000004010800000020000)
Mar 2 15:58:11 myserver kernel: [5378263.921763] TRACE: nat:POSTROUTING:rule:1 IN= OUT=venet0 SRC=192.168.111.20 DST=98.76.54.32 LEN=64 TOS=0x00 PREC=0x00 TTL=63 ID=32766 DF PROTO=TCP SPT=65087 DPT=80 SEQ=3874826404 ACK=0 WINDOW=65535 RES=0x00 CWR ECE SYN URGP=0 OPT (02040ACA2C84454E80103030501000004010800000020000)

表明这个包分别经过了

  • raw:PREROUTING:policy:3
  • mangle:PREROUTING:policy:1
  • nat:PREROUTING:policy:1
  • mangle:FORWARD:policy:1
  • filter:FORWARD:rule:1
  • mangle:POSTROUTING:policy:1
  • nat:POSTROUTING:rule:1

你可以通过

1
iptables -t nat -nvL --line-numbers

查看对应的规则编号。在这里,可以看到 filter:FORWARD:rule:1nat:POSTROUTING:rule:1 被触发了。即

1
2
sudo iptables -A FORWARD -i zt+ -s 192.168.111.0/24 -d 0.0.0.0/0 -j ACCEPT
sudo iptables -t nat -A POSTROUTING -o venet0 -s 192.168.111.0/24 -j SNAT --to-source 123.45.67.89

另外如果你尝试过执行 iptables -t nat -A POSTROUTING -i zt+ -o venet0 ,会收到 Can't use -i with POSTROUTING 报错。从 TRACE 中可以看出 nat:POSTROUTING:rule:1IN= 是空的。所以在 POSTROUTING 表中是不能使用 -i 指定入包接口的。

网站 -> 网关 -> 本机

这次我们一步到位

1
2
iptables -t raw -A PREROUTING -p TCP -s 98.76.54.32 -j LOG
iptables -t raw -A PREROUTING -p TCP -s 98.76.54.32 -j TRACE

另外为了防止日志太多,这里可以把刚才添加的那条 TRACE 删掉:

1
iptables -t raw -D PREROUTING -p TCP -s 192.168.111.20 -j TRACE 

再次 curl 98.76.54.32 就能看到包返回了

1
2
3
4
5
6
7
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
Mar 3 14:04:55 myserver kernel: [5457820.771696] IN=venet0 OUT= MAC= SRC=98.76.54.32 DST=123.45.67.89 LEN=60 TOS=0x00 PREC=0x00 TTL=55 ID=0 DF PROTO=TCP SPT=80 DPT=57286 WINDOW=14480 RES=0x00 ECE ACK SYN URGP=0
Mar 3 14:04:55 myserver kernel: [5457820.771713] TRACE: raw:PREROUTING:policy:4 IN=venet0 OUT= MAC= SRC=98.76.54.32 DST=123.45.67.89 LEN=60 TOS=0x00 PREC=0x00 TTL=55 ID=0 DF PROTO=TCP SPT=80 DPT=57286 SEQ=1319922288 ACK=3993987234 WINDOW=14480 RES=0x00 ECE ACK SYN URGP=0 OPT (020401AA7250238401080A2F75F14B4048030307)
Mar 3 14:04:55 myserver kernel: [5457820.771729] TRACE: mangle:PREROUTING:policy:1 IN=venet0 OUT= MAC= SRC=98.76.54.32 DST=123.45.67.89 LEN=60 TOS=0x00 PREC=0x00 TTL=55 ID=0 DF PROTO=TCP SPT=80 DPT=57286 SEQ=1319922288 ACK=3993987234 WINDOW=14480 RES=0x00 ECE ACK SYN URGP=0 OPT (020401AA7250238401080A2F75F14B4048030307)
Mar 3 14:04:55 myserver kernel: [5457820.771743] TRACE: mangle:FORWARD:policy:1 IN=venet0 OUT=zthnhi321 SRC=98.76.54.32 DST=192.168.111.20 LEN=60 TOS=0x00 PREC=0x00 TTL=55 ID=0 DF PROTO=TCP SPT=80 DPT=57286 SEQ=1319922288 ACK=3993987234 WINDOW=14480 RES=0x00 ECE ACK SYN URGP=0 OPT (020401AA7250238401080A2F75F14B4048030307)
Mar 3 14:04:55 myserver kernel: [5457820.771755] TRACE: filter:FORWARD:rule:2 IN=venet0 OUT=zthnhi321 SRC=98.76.54.32 DST=192.168.111.20 LEN=60 TOS=0x00 PREC=0x00 TTL=55 ID=0 DF PROTO=TCP SPT=80 DPT=57286 SEQ=1319922288 ACK=3993987234 WINDOW=14480 RES=0x00 ECE ACK SYN URGP=0 OPT (020401AA7250238401080A2F75F14B4048030307)
Mar 3 14:04:55 myserver kernel: [5457820.771767] TRACE: mangle:POSTROUTING:policy:1 IN= OUT=zthnhi321 SRC=98.76.54.32 DST=192.168.111.20 LEN=60 TOS=0x00 PREC=0x00 TTL=55 ID=0 DF PROTO=TCP SPT=80 DPT=57286 SEQ=1319922288 ACK=3993987234 WINDOW=14480 RES=0x00 ECE ACK SYN URGP=0 OPT (020401AA7250238401080A2F75F14B4048030307)

同理,可以看到数据包在经过 mangle:PREROUTING:policy:1 之后,DST 被改写回了 192.168.111.20。于是一次成功的 NAT 就完成了。

最后,这里有一张图,显示了数据包都会经过什么表:

Netfilter-packet-flow

之前配置 NAT 网关不成功的原因是:zerotier 防火墙错误设置了如下规则,导致包没法发回本机。

1
2
3
drop
not chr ipauth
;

网关+

既然有网关了,我就想能不能再搞个智能回国网关。,只要我连上这个局域网,就能听网易云音乐了。我用的 vnet.one 用的是 anyconnect 连接,它有一个开源实现 openconnect,于是我这样

1
2
3
4
5
6
7
8
sudo apt-get install curl vpnc-scripts build-essential libssl-dev libxml2-dev liblz4-dev
wget ftp://ftp.infradead.org/pub/openconnect/openconnect-8.02.tar.gz
tar xzf openconnect-8.02.tar.gz
cd openconnect-8.02
./configure --without-gnutls --with-vpnc-script=/usr/share/vpnc-scripts/vpnc-script
make
sudo make install
sudo ldconfig /usr/local/lib

然后用 bestroutetb 生成个路由:

1
2
bestroutetb --route.net=US,GB  --route.vpn=CN -p json -o routes.json -f
jq '.[] | select(.gateway == "vpn") | .prefix + "/" + (.length | tostring)' routes.json -c -r > routes.cn

整个设置路由的脚本

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
#!/bin/bash
source /root/.bashrc

DIR=$(dirname $0)

# Routes that we want to be used by the VPN link
ROUTES=`cat $DIR/routes.cn`

# Helpers to create dotted-quad netmask strings.
MASKS[1]="128.0.0.0"
MASKS[2]="192.0.0.0"
MASKS[3]="224.0.0.0"
MASKS[4]="240.0.0.0"
MASKS[5]="248.0.0.0"
MASKS[6]="252.0.0.0"
MASKS[7]="254.0.0.0"
MASKS[8]="255.0.0.0"
MASKS[9]="255.128.0.0"
MASKS[10]="255.192.0.0"
MASKS[11]="255.224.0.0"
MASKS[12]="255.240.0.0"
MASKS[13]="255.248.0.0"
MASKS[14]="255.252.0.0"
MASKS[15]="255.254.0.0"
MASKS[16]="255.255.0.0"
MASKS[17]="255.255.128.0"
MASKS[18]="255.255.192.0"
MASKS[19]="255.255.224.0"
MASKS[20]="255.255.240.0"
MASKS[21]="255.255.248.0"
MASKS[22]="255.255.252.0"
MASKS[23]="255.255.254.0"
MASKS[24]="255.255.255.0"
MASKS[25]="255.255.255.128"
MASKS[26]="255.255.255.192"
MASKS[27]="255.255.255.224"
MASKS[28]="255.255.255.240"
MASKS[29]="255.255.255.248"
MASKS[30]="255.255.255.252"
MASKS[31]="255.255.255.254"

export CISCO_SPLIT_INC=0

# Create environment variables that vpnc-script uses to configure network
function addroute()
{
local ROUTE="$1"
export CISCO_SPLIT_INC_${CISCO_SPLIT_INC}_ADDR=${ROUTE%%/*}
export CISCO_SPLIT_INC_${CISCO_SPLIT_INC}_MASKLEN=${ROUTE##*/}
export CISCO_SPLIT_INC_${CISCO_SPLIT_INC}_MASK=${MASKS[${ROUTE##*/}]}
export CISCO_SPLIT_INC=$((${CISCO_SPLIT_INC}+1))
}

for r in $ROUTES; do
addroute $r
done

for l in $INTERNAL_IP4_DNS; do
if [ $reason = "connect" ]; then
iptables -t nat -A PREROUTING -i zt+ -p udp --dport 53 -j DNAT --to $l
elif [ $reason = "disconnect" ]; then
iptables -t nat -D PREROUTING -i zt+ -p udp --dport 53 -j DNAT --to $l
fi
break
done

exec /usr/share/vpnc-scripts/vpnc-script

整合一下

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。

少女前线拖尸脚本 和 生成它的可视化工具

最近在玩少女前线,这是一个手机游戏,over。不是,就真的没有什么好讲的嘛,了解的人早有耳闻,不了解的就只要知道这是个手机游戏就好了,嗯。

然后,我会好好地,正常地,氪金地去玩这个游戏吗?不可能的,玩游戏哪有破解它有意思呢。当年破解 Ingress 是因为它用的 HTTPS 通信的,算是本行。百万亚瑟王是因为别人已经逆向好了,我只是写了一些 bot。现在这么办,玩不了了吗?作为一个不会安卓,不会逆向,不会汇编的菜鸡,那我只好上按键精灵了啊。于是乎,我找到了这个: AnkuLua

AnkuLua 是一個專注在自動化的Android App
基本自動化動作有:

  • 抓取螢幕並找尋指定圖案
  • 對找圖結果採取使用者要的動作(例如點擊、抓放(drag and drop)、打字…等等)

最重要的是,它能运行 lua 脚本!虽然我是一个不会安卓,不会逆向,不会汇编的菜鸡,但是我会 lua 啊。

ankulua-vision

不过,在使用过程中发现,找寻指定图案,需要不断截图/裁剪,这样太麻烦了。于是我又用 electron 做了一个可视化的截图资源管理器 ankulua-vision,像这样的:

screenshot

基本思路就是,一般游戏是由众多 UI 界面组成的,点击某个按钮能跳转到某个界面上去。那么通过截图,标注识别区域,那么程序就能知道游戏现在所处的界面。通过标注按钮区域,那么只需要 goto('battle'),程序就能自动规划从当前界面到 battle 的可行路径,然后点啊点啊就完成需要的操作了。这样一方面不需要自己去裁剪图片了,另一方面通过框架代码,在运行过程中能够有更多的错误检查,自动应对可能出现的各种异常。

理论上,对于点啊点的游戏,是能实现无代码的。即使不能,对于复杂的动作,也可以通过 lua 拓展。

源码在这里:https://github.com/binux/ankulua-vision

你依旧需要在安卓手机或者模拟器中安装 ankulua,然后加载生成的 start.lua 脚本。默认自带了一个简单的循环逻辑,运行后可以直接图形化界面配置运行。当然你也可以通过 lua 脚本拓展,除了 ankulua 本身的 API 可用之外,你也可以使用 stateMachine 这套界面跳转逻辑 API,重用简化步骤。stateMachine 的 API 在 README 中有简略的文档说明。

setting screenshot

源码使用 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 打开这个项目目录,调整截屏或者按钮位置。

脚本实现的功能

  • 43e, 02, 52n 拖尸
  • 自动重启后勤
  • 自动强化或者分解人形
  • 自动修理

使用方法

  1. 根据 [填坑结束?][失了智]萌新向拖尸教学帖[更新8-1N相关] 一文准备好打手和阵型,一队练级队,二队补给队,52n 还需要 3 队狗粮队。
  2. 解压拷贝脚本到手机中,在 ankulua 中加载 start.lua。
  3. 在启动界面中选择你的两个打手(每轮结束后,两个打手会交换),选择拖尸任务,如果仅自动后勤,选择 null 就好了。

其中 52n 会在战斗中撤退 5, 8 号位 (见 NGA 文 “43e的说明” 展开部分),02 在选择 m4a1 时会撤退 1, 7 号位。

setting screenshot

然后开始吧!

WARNING: 任何使用脚本的行为都是官方禁止的,我不对上文所述任何内容以及其后果负责

over

2018 新的冒险

真的又是好久没有写 blog 了。

年纪大了,记忆力下降,没有学习新东西的动力,也没精力折腾新的技术,新的领域了。每天就是看看斗鱼,打打游戏就过去了,现在的理想就是早点退休,当条咸鱼就好了。

2017 年主要给公司开发了一套基于 electron (chromium) 的页面渲染后端,可以保证抓取时和用户浏览器中看到的保持一致。同时这个服务器端的浏览器,可以通过 websocket 连接用户浏览器,双向同步页面内容变化,录下用户操作,在抓取时进行重放。这些功能我真的很想做给 pyspider,但是确实不方便。眼见着 pyspider stars 过万,而我却渐渐没有精力去维护了。我的希望是以后从现在的公司离职之后能有2-6个月全职开发 pyspider,算是这几年项目荒废的补偿吧。

公司终于把伦敦办公室关闭了,我也随着搬到了美国(湾区)。随便写一点美国的感受吧:

  • 加州税真高,比英国还高,英国人家好歹有免费医保啊
  • 美国真的是物资极大的丰富,真的可以理解为什么很多中国人来了就想要留下来,小富即安
    • 地广人稀,使得超市都是 super 起步的,这样会让选择非常多,卖的量都是加大号的
    • 充足的停车场,汽车出行不用担心不方便停车
    • 汽车让生活半径极大扩大,湾区各种中餐半小时车程都能到达,而半小时车程也不过是正常通勤所花的时间
    • 各种服务比起英国齐全多了,而且周六日不休
    • apartment 社区大都自带 365 天 7 * 24 开放恒温游泳池,健身房等设施(即使大冬天根本没有人去用,水也是恒温并更新的)
  • 非实时记账,很多场合真的需要使用支票,需要通过账单付费;因为是后付费,需要 SSN 查询你的信用记录。真的很不方便。
  • 租房好贵,宽带好贵,手机卡好贵,小费好贵

总体来说,英国更接近国内的政府+生活模式,而美国是只要你花钱,什么都有,不花钱,滚蛋。反正 L1 签证也就 3 年,也不能跳槽,而且就美国这个 H1B 抽奖 + 绿卡排队,比起英国来简直就是地狱模式。趁着这几年,在美国多玩一玩吧。9酱。

Data Highlighter

又是好久没有写 blog 了。现在确实没有上学的时候愿意折腾了,能用钱解决的问题,就不自己动手了。但是,很久不写 blog 这事呢,其实就是因为懒 _ (:3」∠) _。

这里带来的是
如何从 WEB 页面中提取信息
一文中提到的 data highlighter。但是由于开源需要重写代码,而我并不打算使用它,这里只给出 demo 和算法思路。

简介

Data Highlighter 其实是一种生成提取规则的方式:

Data Highlighter 的标注方式是:给一系列相似的页面,让用户标出(高亮)每个属性在页面中的位置。通过多个页面的标注信息,寻找每个属性的特征。当然了,这个特征可以是 xpath,也可以是上下文,也有可能是机器学习的特征向量。
Data Hightlighter 通过高亮 多个页面中相同属性 进行规则学习,省去了人为设置规则时的学习成本。实践表明,在单一页面模板下,标记2个页面就足以生成规则了。效率远大于手工设置规则。Google Data Highlighter 甚至对文字进行了切分,能在 英语 / 汉语普通话 / 粤语 xpath 相同的情况下,分别选出三种语言。是我目前见过的成熟度最高、通用性最好、最简便的数据抽取方式。

那我们通过例子介绍一下使用方式。首先打开 demo。这里列出了5个豆瓣电影的 sample 页面,点击 go 加载页面。将鼠标放在页面中,就会发现文字被高亮了,点击拖拽鼠标选择需要提取的文字,在弹出的菜单中选择属性名。


然后分别点击 gen_tpltest_all 就能看到生成的模板,以及提取效果了。

![extraction sample](/assets/image/Screenshot 2016-12-04 14.44.18.png)

算法解析

点击 gen_tpl 就可以看到生成的模板了,tpl 字段的 key 为抽取的变量的名字,value 描述了一个 状态机

先看一个简单的例子,以下就是对 name 字段的模板,它描述了一个 s0 -> e0 的状态机。

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
{
"need_more_sample": true,
"tips": {},
"tpl": {
"name": {
"states": {
"s0": {
"tag": "start",
"transitions": [
"e0"
],
"condition": {
"xpath": "/html/body/div/div/h1/span/textnode",
"features": {
"exclude": [],
"include": [
"ancestor::*[1][name()=\"span\" and @property='v:itemreviewed']"
]
}
}
},
"e0": {
"tag": "end",
"transitions": [],
"condition": {
"xpath": "/html/body/div/div/h1/span/textnode",
"features": {
"exclude": [
"following::*[position()=1 and name()=\"textnode\"]"
],
"include": [
"ancestor::*[1]/*[last()-0] = ancestor-or-self::*[1]"
]
}
}
}
},
"entrance_state": [
"s0"
],
"is_list": false,
"data_type": "TEXT"
}
}
}

直接跳到 tpl.name 部分,它有4个字段,is_listdata_type 描述了字段的类型,它们在字段定义的时候就已经指定了,没什么好说的。statesentrance_state 为状态机的描述部分。

entrance_state 表示状态机的入口为 s0

states 中描述了两个状态 s0e0s0.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
2
3
4
5
6
7
8
"features": {
"exclude": [
"preceding::*[position()=2 and name()=\"textnode\"]"
],
"include": [
"preceding::*[position()=2 and name()=\"a\"]"
]
}

如果每次都选择 2016 的标签,就会生成

1
2
3
4
5
6
"features": {
"exclude": [],
"include": [
"ancestor::*[1][contains(., '2016')]",
"contains(., '2016')"
]

通过特征集合的运算,算法能够通过样本,猜测出用户选择的意图。而这样的特征集合,可以不断地添加,以满足不同页面的需要。

需要特别说明的是,特征并不需要像 demo 中使用某种特定的选择器(xpath),由于模板执行时,可以再次为候选元素生成特征集合,对特征集合进行比较。实际上,你可以在特征集合中放入任何字符串,例如「第5元素」,「前一个字符为 answer,且值为 42」都是可以的。

状态机

不同于往常的选取一个元素(例如 pyspider 中的选择器),data highlighter 提供了

  1. 元素内文字选取
  2. 跨元素选取

的功能,这使得正常的「元素选择器」不再好使,取而代之的是一种定位开始和结束的规则。描述为状态机即:s0 -> e0

而 data highlighter 另一种需要支持的功能为列表选取:

就不能仅仅通过 s0 -> e0 这样开头结尾的模式进行描述了。它需要准确描述出整个列表的开头,结尾,分隔符等信息,需要通过一个类似

1
2
s0 -> e0 -> s1 -> e1 -> s2 -> e2
|------|

的状态机,s0 为整个列表的开头,s1 -> e1 为中间循环的组,e2 为 整个列表的结束。

而实际中,由于某些状态可以被合并,你可能会看到类似

1
2
3
s0 -> e0 -> s2 -> e1
|
s1

e0 和 e1 被合并了,即第一个元素的结束条件和中间元素的结束没有不同

的状态机

状态机的生成

虽然状态机看起来非常复杂,但是用程序处理起来却不难。首先为每一个样本(包括列表选取)生成一条 s0 -> e0 -> s1 -> e1 -> s2 -> e2 -> s3 -> e3 ... 的长链,然后尝试合并状态,然后将多个样本的链用同一规则合并。而不能合并的状态,就做个分支转移即可。

而状态能否合并,取决于它们有没有共同特征,就是这么简单。

总结

Data Highlighter 的算法设计,实际上是对元素特征选取的一种建模。通过设计合适的数据结构,使得多样本能够反映到模板中去。

这个算法是两年前设计的,现在看起来实际上问题蛮多的,例如:

  • 无法使用组合特征,即要求元素同时满足满足多个条件
  • 没有设计合理的泛化机制
  • 模板不可读

等,所以,我并不打算使用这个算法。

只不过,最近些年,看到很多数据提取的公司,特别是国内的数据提取平台,还在停留在非常初级的 css selector 或者 xpath 点选生成。希望这篇文章能抛砖引玉,提供一些新的思路,为数据抽取提供更易用有效的工具。

完。

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 部。将总播放,追番人数,弹幕总数画为散点图:

![追番人数,弹幕总数 / 总播放](/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)

从图中可以看出:

  • 主要用户的付费金额在 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 承包收入收入排行:

![承包收入排行](/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块,是可以改的),承包一下你喜欢的动画吧。。希望在「劣币驱逐良币」之前,良币不会先自己饿死吧。

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://[email protected]/taskdb" \
--resultdb "sqlalchemy+postgresql+resultdb://[email protected]/resultdb" \
--projectdb "sqlalchemy+postgresql+projectdb://[email protected]/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://[email protected]/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://[email protected]/taskdb" --projectdb "sqlalchemy+postgresql+projectdb://[email protected]/projectdb" --resultdb "sqlalchemy+postgresql+resultdb://[email protected]/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://[email protected]/taskdb" --projectdb "sqlalchemy+postgresql+projectdb://[email protected]/projectdb" --resultdb "sqlalchemy+postgresql+resultdb://[email protected]/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天法定假日,没有年终奖
  • 不加班的主要原因是没有晚餐,肚子饿
  • 公司以外听不懂别人说什么 =_=