Boto3 的基本用法

最近总是用到 S3, 有必要在这里记录一下 Boto3 的简单用法了。Boto3 是整个 AWS 的 SDK, 而不只是包括 S3. 还可以用来访问 SQS, EC2 等等。

Boto3 中有两种 API, 低级和高级。其中低级 API 是和 AWS 的 HTTP 接口一一对应的,通过 boto3.client(“xxx”) 暴露。高级接口是面向对象的,更加易于使用,通过 boto3.resource(“xxx”) 暴露,美中不足是不一定覆盖了所有的 API.

坑爹的 AWS 文档中,经常混用 resource 和 client 两套接口,也没有任何提示。在没写这篇文章之前,我的脑子里都是乱的,总觉得 S3(Simple Storage Service) 的这个狗屁接口哪里配得上 Simple 这个词,再看了 resource 高级接口后,发现确实是还可以。

如果没有特殊需求的话,建议使用高级 API. 本文一下就记录一些 boto3.resource(“s3”) 的例子。

import boto3

s3 = boto3.resource("s3")

# 创建一个 bucket
bucket = s3.create_bucket(Bucket="my-bucket")

# 获得所有的 bucket, boto 会自动处理 API 的翻页等信息。
for bucket in s3.buckets.all():
    print(bucket.name)

# 过滤 bucket, 同样返回一个 bucket_iterator
s3.buckets.fitler()

# 生成一个 Bucket 资源对象
bucket = s3.Bucket("my-bucket")
bucket.name  # bucket 的名字
bucket.delete()  # 删除 bucket

# 删除一些对象
bucket.delete_objects(
    Delete={
        'Objects': [
            {
                'Key': 'string',
                'VersionId': 'string'
            },
        ],
        'Quiet': True|False
    },
)
# 返回结果
{
    'Deleted': [
        {
            'Key': 'string',
            'VersionId': 'string',
            'DeleteMarker': True|False,
            'DeleteMarkerVersionId': 'string'
        },
    ],
    'RequestCharged': 'requester',
    'Errors': [
        {
            'Key': 'string',
            'VersionId': 'string',
            'Code': 'string',
            'Message': 'string'
        },
    ]
}

# 下载文件
bucket.download_file(Key, Filename, ExtraArgs=None, Callback=None, Config=None)

# 下载到文件对象,可能会自动开启多线程下载
with open('filename', 'wb') as data:
    bucket.download_fileobj('mykey', data)

# 上传文件
object = bucket.put_object(Body=b"data"|file, ContentMD5="", Key="xxx")

# 这个方法会自动开启多线程上传
with open('filename', 'rb') as f:
    bucket.upload_fileobj(f, 'mykey')

# 列出所有对象
bucket.objects.all()

# 过滤并返回对象
objects = bucket.objects.filter(
    Delimiter='string',
    EncodingType='url',
    Marker='string',
    MaxKeys=123,
    Prefix='string',
    RequestPayer='requester',
    ExpectedBucketOwner='string'
)

# 创建一个对象
obj = bucket.Object("xxx")
# 或者
obj = s3.Object("my-bucket", "key")

obj.bucket_name
obj.key

# 删除对象
obj.delete()
# 下载对象
obj.download_file(path)
# 自动多线程下载
with open('filename', 'wb') as data:
    obj.download_fileobj(data)
# 获取文件内容
rsp = obj.get()
rsp["Body"]  # 文件内容
obj.put(Body=b"xxx"|file, ContentMD5="")

# 上传文件
obj.upload_file(filename)
# 自动多线程上传
obj.upload_fileobj(fileobj)

如果想进一步了解,建议直接点开参考文献 2, 阅读下 resouce 相关接口的文档,其他的 client 接口完全可以不看。

参考

  1. https://stackoverflow.com/questions/42809096/difference-in-boto3-between-resource-client-and-session
  2. https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html#service-resource

Playwright: 比 Puppeteer 更好用的浏览器自动化工具

在 Playwright 之前,我一般会使用 Selenium 或者 Puppeteer 来进行浏览器自动化操作。然而,Selenium 经常会有一些奇怪的 bug, Puppeteer 则是没有官方 Python 版,非官方版本也只有 async 版本,并且也是有一些奇怪的 bug. 另外,众所周知,Python 的 Async API 并不是那么好使。

Playwright 是微软出品的浏览器自动化工具,代码质量应该是有足够保证的。而且它还官方支持同步版的 Python API, 同时支持三大浏览器,所以赶紧切换过来了。

特别注意 Playwright 的拼写,别把中间的 “w” 丢了。

安装

pip install playwright==1.8.0a1  # 很奇怪,必须指定版本,不指定会安装到一个古老的版本
python -m playwright install  # 安装浏览器,此处国内网络可能会有问题(你懂的),请自行解决

基本使用

Playwright 支持 Firefox/Chrome/WebKit(Safari). 其中 webkit 最轻量了,所以没有什么特殊需求最好使用 webkit, 不要使用 chromium.

from playwright.sync_api import sync_playwright as playwright

with playwright() as pw:
    webkit = pw.webkit.launch(headless=False)
    context = webkit.new_context()  # 需要创建一个 context
    page = context.new_page()  # 创建一个新的页面
    page.goto("https://www.apple.com")
    print(page.content())
    webkit.close()

Playwright 官方推荐使用 with 语句来访问,不过如果你不喜欢的话,也可以用 pw.start() 和 pw.stop().

新概念:Context

和 Puppeteer 不同的是,Playwright 新增了 context 的概念,每个 context 就像是一个独立的匿名模式会话,非常轻量,但是又完全隔离。比如说,可以在两个 context 中登录两个不同的账号,也可以在两个 context 中使用不同的代理。

通过 context 还可以设置 viewport, user_agent 等。

context = browser.new_context(
  user_agent='My user agent'
)
context = browser.new_context(
  viewport={ 'width': 1280, 'height': 1024 }
)
context = browser.new_context(
    http_credentials={"username": "bill", "password": "pa55w0rd"}
)

# new_context 其他几个比较有用的选项:
ignore_https_errors=False
proxy={"server": "http://example.com:3128", "bypass": ".example.com", "username": "", "password": ""}
extra_http_headers={"X-Header": ""}

context 中有一个很有用的函数context.add_init_script, 可以让我们设定在调用 context.new_page 的时候在页面中执行的脚本。

# hook 新建页面中的 Math.random 函数,总是返回 42
context.add_init_script(script="Math.random = () => 42;")
# 或者写在一个文件里
context.add_init_script(path="preload.js")

还可以使用 context.expose_bindingcontext.expose_function 来把 Python 函数暴露到页面中,不过个人感觉还是使用 addinitscript 暴露 JS 函数方便一些。

和 Puppeteer 一样,Playwright 的核心概念依然是 page, 核心 API 几乎都是 page 对象的方法。可以通过 context 来创建 page.

页面基本操作

按照官网文档,调用 page.goto(url) 后页面加载过程:

  1. 设定 url
  2. 通过网络加载解析页面
  3. 触发 page.on(“domcontentloaded”) 事件
  4. 执行页面的 js 脚本,加载静态资源
  5. 触发 page.on(“laod”) 事件
  6. 页面执行动态加载的脚本
  7. 当 500ms 都没有新的网络请求的时候,触发 networkidle 事件

page.goto(url) 会跳转到一个新的链接。默认情况下 Playwright 会等待到 load 状态。如果我们不关心加载的 CSS 图片等信息,可以改为等待到 domcontentloaded 状态,如果页面是 ajax 加载,那么我们需要等待到 networkidle 状态。如果 networkidle 也不合适的话,可以采用 page.waitforselector 等待某个元素出现。不过对于 click 等操作会自动等待。

page.goto(url, referer="", timeout=30, wait_until="domcontentloaded|load|networkidle")

Playwright 会自动等待元素处于可操作的稳定状态。当然也可以用 page.wait_for_* 函数来手工等待:

page.wait_for_event("event", event_predict, timeout)
page.wait_for_function(js_function)
page.wait_for_load_state(state="domcontentloaded|load|networkidle", timeout)
page.wait_for_selector(selector, timeout)
page.wait_for_timeout(timeout)  # 不推荐使用

对页面的操作方法主要有:

# selector 指的是 CSS 等表达式
page.click(selector)
page.fill(selector, value)  # 在 input 中填充值

# 例子
page.click("#search")

获取页面中的数据的主要方法有:

page.url  # url
page.title()  # title
page.content()  # 获取页面全文
page.inner_text(selector)  # element.inner_text()
page.inner_html(selector)
page.text_content(selector)
page.get_attribute(selector, attr)

# eval_on_selector 用于获取 DOM 中的值
page.eval_on_selector(selector, js_expression)
# 比如:
search_value = page.eval_on_selector("#search", "el => el.value")

# evaluate 用于获取页面中 JS 中的数据,比如说可以读取 window 中的值
result = page.evaluate("([x, y]) => Promise.resolve(x * y)", [7, 8])
print(result) # prints "56"

选择器表达式

在上面的代码中,我们使用了 CSS 表达式(比如#button)来选取元素。实际上,Playwright 还支持 XPath 和自己定义的两种简单表达式,并且是自动识别的。

# 通过文本选择元素,这是 Playwright 自定义的一种表达式
page.click("text=login")

# 直接通过 id 选择
page.click("id=login")

# 通过 CSS 选择元素
page.click("#search")
# 除了常用的 CSS 表达式外,Playwright 还支持了几个新的伪类
# :has 表示包含某个元素的元素
page.click("article:has(div.prome)")
# :is 用来对自身做断言
page.click("button:is(:text('sign in'), :text('log in'))")
# :text 表示包含某个文本的元素
page.click("button:text('Sign in')")  # 包含
page.click("button:text-is('Sign is')")  # 严格匹配
page.click("button:text-matches('\w+')")  # 正则
# 还可以根据方位匹配
page.click("button:right-of(#search)")  # 右边
page.click("button:left-of(#search)")  # 左边
page.click("button:above(#search)")  # 上边
page.click("button:below(#search)")  # 下边
page.click("button:near(#search)")  # 50px 之内的元素

# 通过 XPath 选择
page.click("//button[@id='search'])")
# 所有 // 或者 .. 开头的表达式都会默认为 XPath 表达式

对于 CSS 表达式,还可以添加前缀css=来显式指定,比如说 css=.login 就相当于 .login.

除了上面介绍的四种表达式以外,Playwright 还支持使用 >> 组合表达式,也就是混合使用四种表达式。

page.click('css=nav >> text=Login')

复用 Cookies 等认证信息

在 Puppeteer 中,复用 Cookies 也是一个老大难问题了。这个是 Playwright 特别方便的一点,他可以直接导出 Cookies 和 LocalStorage, 然后在新的 Context 中使用。

# 保存状态
import json
storage = context.storage_state()
with open("state.json", "w") as f:
    f.write(json.dumps(storage))

# 加载状态
with open("state.json") as f:
    storage_state = json.loads(f.read())
context = browser.new_context(storage_state=storage_state)

监听事件

通过 page.on(event, fn) 可以来注册对应事件的处理函数:

def log_request(intercepted_request):
    print("a request was made:", intercepted_request.url)
page.on("request", log_request)
# sometime later...
page.remove_listener("request", log_request)

其中比较重要的就是 request 和 response 两个事件

拦截更改网络请求

可以通过 page.on(“request”) 和 page.on(“response”) 来监听请求和响应事件。

from playwright.sync_api import sync_playwright as playwright

def run(pw):
    browser = pw.webkit.launch()
    page = browser.new_page()
    # Subscribe to "request" and "response" events.
    page.on("request", lambda request: print(">>", request.method, request.url))
    page.on("response", lambda response: print("<<", response.status, response.url))
    page.goto("https://example.com")
    browser.close()

with playwright() as pw:
    run(pw)

其中 request 和 response 的属性和方法,可以查阅文档:https://playwright.dev/python/docs/api/class-request

通过 context.route, 还可以伪造修改拦截请求等。比如说,拦截所有的图片请求以减少带宽占用:

context = browser.new_context()
page = context.new_page()
# route 的参数默认是通配符,也可以传递编译好的正则表达式对象
context.route("**/*.{png,jpg,jpeg}", lambda route: route.abort())
context.route(re.compile(r"(\.png$)|(\.jpg$)"), lambda route: route.abort())
page.goto("https://example.com")
browser.close()

其中 route 对象的相关属性和方法,可以查阅文档:https://playwright.dev/python/docs/api/class-route

灵活设置代理

Playwright 还可以很方便地设置代理。Puppeteer 在打开浏览器之后就无法在更改代理了,对于爬虫类应用非常不友好,而 Playwright 可以通过 Context 设置代理,这样就非常轻量,不用为了切换代理而重启浏览器。

context = browser.new_context(
    proxy={"server": "http://example.com:3128", "bypass": ".example.com", "username": "", "password": ""}
)

杀手级功能:录制操作直接生成代码

Playwright 的命令行还内置了一个有趣的功能:可以通过录制你的点击操作,直接生成 Python 代码。

python -m playwright codegen http://example.com/

Playwright 还有很多命令行功能,比如生成截图等等,可以通过 python -m playwright -h 查看。

其他

除此之外,Playwright 还支持处理页面弹出的窗口,模拟键盘,模拟鼠标拖动(用于滑动验证码),下载文件等等各种功能,请查看官方文档吧,这里不赘述了。对于写爬虫来说,Playwright 的几个特性可以说是秒杀 Puppeteer/Pyppeteer:

  1. 官方同步版本的 API
  2. 方便导入导出 Cookies
  3. 轻量级设置和切换代理
  4. 支持丰富的选择表达式

快点用起来吧!

参考

  1. https://playwright.dev/python/docs/core-concepts

放弃 Next.js, 拥抱 react-router

Next.js 是一个好库,设计上很优雅,实现上也没有什么大的问题。然而,考虑再三我还是决定暂时移除 next.js 了。不是我不想要服务端渲染,而是整个 JS 的生态圈大部分的库都没有考虑服务端渲染,这就导致我在学习和使用的过程中时不时要自己考虑如何处理服务端渲染的情形。本身我就是个初学者,连教程都看不太懂,再考虑服务端渲染,就一个头两个大了。另外一个原因就是组里另一个项目使用了 react-router, 没必要两个都搞了。这里姑且记录下移除 next.js, 添加 react-router 的过程,以便以后参考。

删除 nextjs

yarn remove next

更改 package.json scripts 部分的脚本:

"scripts": {
    "start": "react-scripts start",
    "dev": "react-scripts dev",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },

还好之前 create-react-app 创建的 index.js 和 App.js 还没删掉,直接就能用了。

样式

nextjs 中规定了只能用 module css 或者 scoped css, 而在 react 中没有硬性的规定。如果使用原生 CSS 的话自然最简单,但是容易名字冲突。鉴于另一个项目使用了 sass, 这里也用 sass 以统一下开发体验。

页面

暂时先保留 pages, components, layouts 三个文件夹,但是需要使用 react-router 路由。

yarn add react-router react-router-dom

在 index.js 中使用 router, 去掉 <App/>

Link

更改所有的 Link. 从 import Link from 'next/link' 改成 import {Link} from 'react-router-dom', 其中需要把 href 改为 to.

更改所有的 useRouter 的跳转,需要使用 useHistory.

获取数据

页面里的 getServerSideProps 显然是不能用了,需要改用 redux 的 thunk 来获取数据,所以需要以下几步:

  1. 设置对后端 API 的代理,在 package.json 中添加 "proxy": "http://localhost:4000", 即可
  2. 引入 redux, 设计 store 等
  3. 调整请求接口到 redux 中

一般来说,我们把相应的 getServerSideProps 函数的逻辑转移到对应的 Page 组件的 useEffect(fn, []) 钩子中就可以了。

代理的问题

在 next.js 中,需要在两个地方指定代理,一个是后端在 server 预加载数据的时候,需要指定上游 API 的地址,另一方面,在浏览器中发送 ajax 请求的时候需要设定代理访问上游 API, 否则会有跨域的问题。

使用 react-router 之后问题就简单了,所有数据都是从前端加载的,所以只需要指定代理的地址就好了。但是也要考虑到几种不同的环境:

  1. 开发阶段的配置
  2. 部署阶段的配置
  3. 如果有多个后端如何处理
  4. 用户鉴权放在哪里

综合考虑后,采用以下几点:

  1. 用户鉴权放在前端的 server, 也就是 express 中,这样就避免了上游 API 再添加复杂的逻辑,但是用户列表可以放在后端中。
  2. 开发环境和部署环境统一使用 express 代理多个后端,这样在开发环境也能保证和生产环境一样的效果,方便 debug.

参考

  1. https://create-react-app.dev/docs/proxying-api-requests-in-development/#configuring-the-proxy-manually
  2. https://stackoverflow.com/questions/50260684/bundle-react-express-app-for-production
  3. https://dev.to/nburgess/creating-a-react-app-with-react-router-and-an-express-backend-33l3

编写一个爬虫的思路,当遇到反爬时如何处理

本站/公众号/专栏不误正业好久了,今天终于写一篇爬虫的文章,然而并没有案例,也没有代码。

写了这么多年爬虫了,经常还是会撞上反爬机制。虽然大多数时候都能解决,但是毕竟反爬机制多种多样,有时候遇到一个许久不见的反爬机制,也会感到手生,一时想不上来应对方法,而浪费不少时间。最近写了不少爬虫,接下来一段时间又不写了,趁着手还比较熟,记录一下备忘,方便大家也方便自己。

之前写过一篇常用的反爬虫封禁手段概览, 但是主要是从反爬的角度来的,这篇主要从写爬虫的角度来说说。

开章明义,当遇到反爬机制时,想要做到把数据爬下来,无非四个方法:

  1. 加代理
  2. 降速度
  3. 破解接口
  4. 多注册几个账户

好多文章为了显示自己高大上,吹些什么高并发呀,分布式,机器学习破解验证码的幺蛾子,都是扯淡。与其扯这些东西,不如老老实实把数据爬下来才是王道,如果非要扯上一些 fancy 的东西,那把监控做好比啥都重要

补充说明一下,本文探讨的是数据收集型的小型爬虫,也就是你要对少数站点在较短时间内收集大量信息。而非搜索引擎型全网爬虫,即对大量站点在较长时间内收集综合信息。(全网当然要上高并发了)

为什么说爬虫不要扯高并发?

我们知道计算机程序按瓶颈不同大概分为两类,CPU 密集型和 IO 密集型。CPU 密集型就是偏重计算的任务,比如说编解码啥的;IO 密集型就是偏重于网络的任务,比如说下载或者 web 服务器。那么爬虫是哪种呢?你估计要回答 IO 密集型,恭喜你答对了。但是这不是我想说的重点,重点是爬虫不光是 IO 密集型的任务,实际上我想把它称作 IP 密集型任务。

什么是 IP 密集型任务呢?按照上面的定义我们知道,也就是说,对爬虫来说,最瓶颈的地方其实是你持有的 IP 的数量!作为一个合格的爬虫编写者,你肯定已经擅长伪造各种 HTTP headers, 破解 JS 的加密参数,但是唯独一个 — 来源 IP — 你是无法伪造的。好多看起来很难搞的事情,如果对方站点的小霸王服务器撑得住,只要加上足够的 IP 就很简单啦,不用绞尽脑汁去想各种策略了。

为什么不要用现成的框架?

上面说了,所谓的”高并发”对爬虫没有任何卵用,那么像是 Scrapy 这种采用了协程以便提高并发的框架我就不是很懂了。以前我专门写过一篇为什么不要用 Scrapy 的文章,所以这里就不再展开细说了。

另外如果你爬虫写多了肯定有自己的一套东西了,这时候你可能会有自己的一个小框架,这是可以的。但是我还是想提两点:

  1. 千万不要做成从模板生成新的爬虫项目的功能。假如你改了模板里的一个 bug 怎么办?以前生成的爬虫还挨个修改吗?
  2. 框架尽量简单,把可以复用的功能提取成单独的 utility 函数或者库。难免有需要改框架或者不适用框架的时候,这时候依然可以复用单独的模块。

拿到抓取任务时的思路

言归正传,我们开始说当拿到一个站点需要爬取时该如何处理。

数据量较小的爬取

首先开始 easy 模式。如果你要抓的网站结构比较简单,而你要的数据也比较少。那么你首先要考虑的是不要编写爬虫. 在浏览器控制台里写个 js 表达式 console.log 一下说不定就把数据导出来了。

如果你要的数据稍微多一点时,这时候点开一个页面然后复制数据出来可能就比较复杂了。这时候可以考虑写个小脚本,别直接 while True 写个死循环就了事儿,每爬一个页面至少 time.sleep(1) 是对对方网站最起码的尊重。当然你的老板可能要数据比较急,但是多少也要悠着点。

浏览器动态加载怎么办?

初学者在这里可能遇到第一个坑:动态网页。这时候可能是个好事儿,也可能是个坏事儿。如果是动态网页,数据自然是 ajax 加载的,如果 ajax 请求没有参数验证的话,那么就简单了,只是从解析 html 变成了解析 json 而已。

另一种情况是接口是需要参数验证的,这时候又分两种处理方式:

  1. 如果只是爬一下数据,直接上浏览器,爬完了事儿。
  2. 如果嫌浏览器资源占用太多,那么往往就会需要破解接口,这种情况下需要一定的 JS 逆向能力。

有的网站对浏览器甚至还做了一些限制,他会检测你的浏览器是正常的用户浏览器还是用程序控制的自动化浏览器。不过这都不是问题,无非就是伪造一下 webdriver 对象和 navigator 对象罢了。这个我也写过一篇具体文章讲如何伪造。

当然这时候也可能遇到情况比较简单的特殊情况,那就是对方的某个更新接口是固定的,而且加密参数里面没有时间戳,那么直接重复请求这个接口就行了。一般来说这种情况算是”瞎猫撞见死耗子”, 多数情况下,接口的签名都校验了搜索的参数和时间戳,也就是你变换关键词,或者想重放请求的话是行不通的,这时候就老老实实破解吧。

一般破解 JS 其实也都不难,常用的信息摘要,或者加密方法也没多少。不过先别接着破解,花上五分钟搜索一下有没有别人已经破解过了,可能就省了你半天到几天的功夫,何乐而不为呢?

实在要开始破解的话,在 JS 的控制台中全局搜索 (Opt+Cmd+F) 一下 AES, MD5 之类的关键词,可能就有收获。另一方面在 ajax 请求上加上断点,逐步找到加密的函数。

找到加密函数之后,如果简单一点的,直接写在一个函数里的,可以抽取出来直接调用 node 执行算出参数,或者你比较勤快用 Python 重写一下都可以。然而比较棘手的是有些函数是和 window 对象或者 DOM 绑定在一起的,这时候也可以考虑把整个 JS 文件保存下来,补全需要的接口。

最常见的 IP 封禁

正如我们前面说的,作为一个爬虫老手,伪造和破解简单的加密都是基本功了,比较蛋疼的还是封禁 IP, 这你下什么苦功夫也没法解决的,是一个资本问题。

当我们爬取的速率比较快的时候,就可能被对方拉黑 IP, 这时候有可能是临时性拉黑,有可能是持续性拉黑,有可能是永久性拉黑。

永久性拉黑比较狠,也没啥办法,直接换 IP 吧。需要区分的是临时性的拉黑和持续性拉黑。如果是临时性拉黑,也就是你的请求超过阈值了就会请求失败,但是过段时间自己又恢复了,那么你程序逻辑也不用改,反正就一直请求呗,总有数据的。如果是持续性拉黑就比较坑了,也就是说如果你被拉黑了还不停止请求,那么就一直出不了小黑屋,必须停下来 sleep 几秒。这时候你的程序的逻辑就需要适应这种机制。如果是单独的脚本还好,对于一些标准化的系统就需要考虑这些机制。

当我们需要换 IP 的时候,肯定不能手工去记得过几分钟换一下子 IP 了,那也太烦人了,一般是需要一个 IP 池子的。

代理 IP 按照质量和来源又分为几类:

  1. 比较垃圾的公用 IP
  2. 比较稳定的机房 IP
  3. 民用网段 IP

网上有一些站点会提供一些免费的代理 IP, 估计他们都是扫来的。这些 IP 可能都有无数的程序在扫描,使用他们,所以可以说是公用的 IP 了。通过收集验证这些 IP, 可以构造一个代理池子。如果实在很穷,或者抓取量不是很大,可以用这种 IP. 虽然这些 IP 特别慢,失败率特别高,总比用自己的一个出口 IP 要好一些。

比较稳定的机房 IP. 这种一般就需要花钱买了,稍微想多抓点数据,一个月 100 那是起步。对付大多数的抓取已经足够了。

对于有一些变态的站点,他们甚至会验证来源 IP 的用途。比如说一看你 IP 来自阿里云机房,啥也不说直接拉黑。这时候就需要所谓的”民用 IP”了。这种有专门的厂商和 App 合作来提供民用网络出口,也可以自己买 ADSL 机器自动拨号搭建,反正成本都是非常非常高了,一个月至少 1000 起步了。

带上账户或者验证码

IP 毕竟算是匿名的。对于一些数据更敏感的网站来说,他们可能要求你登录后才能访问。如果数据不多,那么直接用自己账户跑一下就完了。如果每个账户的访问额度有限,或者要爬的数据特别多,那可能需要注册不少账户,这时候只要不是付费账户,那么其实都好说(除了微信). 你可以选择:

  • 买一些账号,比如说微博账号也就一块半一个而已。
  • 自己注册一些,网上有免费邮箱,也有手机短信接码平台。

这时候不要急着去写个脚本自动化注册啥的,可能你并不需要那么多的账户。

比需要账户稍微弱一点的限制是验证码,图文验证码现在都不是问题了,直接打码平台或者训练个模型都很简单。复杂一点的点按,图片选字等就当饭后甜点吧,弄得出来弄不出来全看运气了。在这里我想说的一点是,请分辨一下你要爬的网站是每次请求必须验证码,还是在封禁 IP 之前出验证码。如果不是每次请求都出验证码,直接加大代理池吧,没必要抠这些东西,真的,时间才是最宝贵的。

不过这里需要特别注意的一点是:一定要考虑清楚其中的法律风险,需要账户访问已经说明这不是公开数据了,可能会触发对方的商业利益或者触犯用户的隐私,一定三思而后爬。

事情没那么简单

如果一个网站只采用一种手段,那么我们分析起问题来就简单了。然而遗憾的是,基本没这种好事儿。比如说一个网站可能即检测了浏览器的 webdriver, 而且还要封 IP, 这时候你就得用浏览器再加上代理,有时候给浏览器设置代理这件事情还挺复杂。还有可能你用账户 Cookies 爬起来飞快,但是竟然还会封 IP, 哼哧哼哧加上了代理池,又发现账户不能换登录 IP, 简直想骂人。

还有一些神仙网站,比如说淘宝或者裁判文书网,可能本文说的都完全没有任何价值,不过好在我大概不会碰这些网站了。

选哪些接口爬

其实我发现一般常见爬虫任务无非几种:

  1. 找到网站的一个列表,把里面数据全都爬下来。
  2. 自己弄些 id 或者关键词,通过查询或者搜索接口把数据全都爬下来。
  3. 刷网站的一些更新或者推荐接口,以期不断抓取。

首选的肯定是无状态的接口,搜索接口在大多说网站还是可以直接就拿来用的。如果有需要登录的,也有不需要登录的接口,那想都不用想,肯定爬不需要登录的接口。哪怕登录了,好多还是会封 IP, 何必呢?有些网站的详情或者翻页可能就需要登录了,实在没办法也只能走这些接口。这时候一定要做好登录出口和普通爬虫的代理池隔离。

要判断清楚爬虫任务是爬全量数据还是增量数据。一般来说爬全量数据的需求都有点扯,一定要和需求方 argue 一下,可能对方根本就没想清楚,觉得先把数据存下来然后慢慢再想怎么用,对于这种傻 X 需求一定要顶回去,别急着跪舔或者炫技。如果一定要爬全量,那也可以慢慢来,不用非着急忙慌的把对方网站都搞挂了,这并不是啥值得炫耀的,而是应该被鄙视。而且一般网站也不会把全量数据让你都能看到,比如可能只能翻 500 页,这时候只能通过细分查询条件来尽可能多地获得一些数据。增量的话一般就好说一点,就像上面说的,定时刷一下更新或者推荐接口就好了。

要爬 App 吗?一般来说现在的网站还是有足够的数据的,除非是一些只有 App 而没有网站的站点,那就没办法了。App 的破解和 JS 其实思路一样,但是可能好多 App 加了壳,或者把加密算法写到了 C 里面,相比 JS 来说,完全不是一个数量级了。对于 App 的破解我基本一窍不通,也就是靠写一些网页爬虫混口饭吃。

你应该庆幸的一点是,你需要写的只是一个爬虫,而不是发帖的机器人,一般网站对于这种制造垃圾数据的防范机制肯定比爬虫要复杂很多。

既然谈到破解 App 了,那么再多说一点。有不少同学觉得从爬虫出发,往深了发展就是逆向,那可就大错特错了,有两点:

  1. 搞逆向工资很低啊!甚至比爬虫还低,不信自己去招聘 App 上看看。你从一个工资高的搞到一个工资低的,这不是给自己找不自在吗?为什么不往钱更多的后端发展呢?哪怕洗洗数据,玩儿玩儿 Spark/Flink 都比搞爬虫强啊,更别说逆向了。好多同学以爬虫工程师自居,实际上这个身份认知就谬之大矣,爬虫就是一个小小的工具而已,用不上作为一种职位。如果基础差,多学点计算机理论知识,后端工程师才称得上是一个职位。
  2. 相比”拿来”来说,”创造”是一件更美好的事情。爬虫当然是拿来别人的东西,逆向就更狠了,别人都明说了不给的东西,还要抢过来,这样其实是不好的,更别说其中的法律风险了。自己去做一个 App 或者一个网站,一个其他产品,让真实的用户来使用,这样的感觉更好一点。而且也好公开吹牛逼啊,你做了一个 App 有 10 万日活可以公开吹逼,但是你把人家网站底裤扒光了,总不好意思大张旗鼓吧?爬虫毕竟是灰色地带的事情,锦衣夜行真的很不爽。

这里再次特别强调一下:破解别人的 App 可能是非法行为,需要负法律责任。

最后,总结一下

所以总结下来,我觉得遇到一个网站的需要考虑的思路是:

  1. 预估下需要爬的数据和时间节点,算出来每秒需要爬多少数据。别上来就设计个啥架构,八成根本用不上。
  2. 如果需要的速率比较小,那么直接 time.sleep(5) 慢慢跑着,也就是尽量不要触发封禁。
  3. 尽量找到一个公开的,不需要登录就能访问的接口或者页面,直接上代理池,别想那么多别的。
  4. 能从一个接口拿到的数据,不要再去多请求其他的接口,尽量减少访问量。
  5. 能很快破解的 JS 也可以破解一下,比较复杂的直接上浏览器,浏览器就直接做好伪装,省得出问题。
  6. 需要登录认证的一定要考虑 Cookie 异地失效的问题,最好使用单独的高质量 IP. 做一套路由机制,保证每个 Cookie 都从同一个 IP 出去。

总之,一次解决一个问题,不要同时触发两个反爬问题,容易按下葫芦起了瓢。

就是这些吧,本文核心观点 — 最简单粗暴的还是加大电量(误加 IP 池,如果一个不够,那就两个。加钱能解决的问题都不是问题。好多同学可能觉得你这叫哪门子爬虫啊,分布式系统也没有,最好玩的逆向你说去网上抄别人的答案,哪还有毛意思啊!然而,很遗憾,这才是现实世界,对于业务来说,爬虫最重要的是你拿到有用的数据,而不是写代码写牛逼了,有这时间回家陪陪家人不好么~

参考文献

本文没有任何参考文献,纯意识流瞎写。文中引用了之前写的几篇文章,懒得贴了,感兴趣自己在网站或者公众号找吧。

PS: 监控很重要,爬虫最怕跑着跑着对面改版了或者加反爬了,有了监控才好及时发现问题。关于监控,强烈推荐 Prometheus, 可以参考我以前的文章。

Nextjs 中遇到的一些坑

nextjs 的 Link 无法自定义 escape

nextjs 中的 Link 的 href 对象如果传的是字典,直接调用的是 nodejs 的 URL 库,不能自定义 escape, 比如说空格会被强制格式化成加好,而不是 %20. 而且好像它使用的这个 API 在 11.0 已经 deprecated 了,所以需要啥 url 的话,还是自己格式化吧~

不支持 loading spinner

Nextjs 不支持在页面跳转的时候触发 Loading Spinner, 也就是转动的小圆圈,所以需要自己实现一下,可以用 nprogress

在 _app.js 中:

import Router from 'next/router';
import NProgress from 'nprogress'; //nprogress module
import 'nprogress/nprogress.css'; //styles of nprogress

//Binding events. 
Router.events.on('routeChangeStart', () => NProgress.start());
Router.events.on('routeChangeComplete', () => NProgress.done());
Router.events.on('routeChangeError', () => NProgress.done());

function MyApp({ Component, pageProps }) {
    return <Component {...pageProps} />
}
export default MyApp;

代理后端 API 服务器

在 next.config.js 中配置重定向:

module.exports = {
  async rewrites() {
    return [
      {
        source: '/api/proxy/:path*',
        destination: `${process.env.NEXT_PUBLIC_API_URL}/:path*`,
      },
    ]
  },
}

参考

  1. https://levelup.gitconnected.com/improve-ux-of-your-next-js-app-in-3-minutes-with-page-loading-indicator-3a422113304d
  2. https://github.com/vercel/next.js/discussions/14057
  3. https://nextjs.org/docs/api-reference/next.config.js/rewrites

React Hooks

使用 useState hook

useState 可以用来管理一个组件比较简单的一两个状态,如果状态多了不适合使用 useState 管理,可以使用 useReducer.

import React, {useState} from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

export default Counter;

使用 useEffect hook

useEffect hook 用来实现一些副作用,一般可以用作页面首次加载时读取数据。

import React, {useState, useEffect} from 'react';

function App() {
  const [isOn, setIsOn] = useState(false);

  useEffect(() => {
    let interval;
    if (isOn) {
      interval = setInterval(() => console.log('tick'), 1000);
    }
    return () => clearInterval(interval);
  }, [isOn]);
  ...
}

export default App;

在 useEffect 中返回的函数会被用来做垃圾清理。另外需要注意的是,初始化的时候总会触发一次 useEffect.

默认情况下,每次 state 有改变的时候,都会调用 useEffect 函数。如果需要更改触发的时机,那么需要使用 useEffect 的第二个参数来指定监听的事件或者说状态。当第二个参数只使用一个空数组 [] 的时候就只会在组件加载的时候调用。数组中有哪些变量,表示在这些变量变化的时候调用。

使用 useContext hook

Context 用来向所有包含的元素广播状态,或者说事件,而不需要通过组件树层层传递。

  1. 首先需要通过 React.createContext 全局定义一个 Context,
  2. 然后在最外层使用 <MyContext.Provider /> 来包裹需要接受这个 context 的所有组件。
  3. 在需要使用状态的元素中调用 const ctx = useContext(MyContext), 然后使用 ctx 访问 Context 中的值
const themes = {
  light: { foreground: "#000000", background: "#eeeeee" },
  dark: { foreground: "#ffffff", background: "#222222" }
};

const ThemeContext = React.createContext(themes.light);

function App() {
  return (
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar(props) {
  return (
    <div> <ThemedButton /> </div>
  );
}

function ThemedButton() {
  const theme = useContext(ThemeContext);
  return (
    <button style={{ background: theme.background, color: theme.foreground }}>
      I am styled by theme context!
    </button>
  );
}

使用 useReducer hook

useReducer 和 useState 的用途基本是一样的,但是当需要的状态比较复杂的时候,最好使用 useReducer. 有了 useReducer 钩子,基本上可以不使用 redux 了。

const [state, dispatch] = useReducer(reducer, initialArg, init);

useReducer 通常放在管理一组状态的根元素这个层级,比如说一个页面。dispatch 函数触发事件,reducer 函数用来处理事件,更新 state.

需要注意的是,在 useReducer 的 reducer 函数中,和 redux 不同的是,不需要 state=initialState 这个参数。默认参数在调用 useReducer 的时候已经给出了。

const initialState = {count: 0};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

useReducer + useContext = Redux

可以组合使用 useReducer 和 useContext 来实现 Redux 的功能。对于某个组件自身的数据,我们只使用 useReducer 就可以很好地管理了。然而对于多个组件都需要使用的全局数据,需要是用 useContext 来广播给所有需要该数据的组件。

useRef 钩子

https://stackoverflow.com/questions/56455887/react-usestate-or-useref

自定义钩子

通过灵活组合 useState, 和 useEffect, 我们完全可以创建自己的钩子。

import React from 'react';

function useOffline() {
  const [isOffline, setIsOffline] = React.useState(false);

  function onOffline() {
    setIsOffline(true);
  }

  function onOnline() {
    setIsOffline(false);
  }

  React.useEffect(() => {
    window.addEventListener('offline', onOffline);
    window.addEventListener('online', onOnline);

    return () => {
      window.removeEventListener('offline', onOffline);
      window.removeEventListener('online', onOnline);
    };
  }, []);

  return isOffline;
}

function App() {
  const isOffline = useOffline();

  if (isOffline) {
    return <div>Sorry, you are offline ...</div>;
  }

  return <div>You are online!</div>;
}

export default App;

参考

  1. https://www.robinwieruch.de/react-hooks
  2. https://www.robinwieruch.de/react-hooks-fetch-data
  3. https://medium.com/@nazrhan.mohcine/react-hooks-work-with-usestate-and-usereducer-effectively-471646cdf925
  4. https://swizec.com/blog/usereducer-usecontext-for-easy-global-state-without-libraries
  5. 5.

Nextjs 教程

当我们要写一个稍微复杂的 React 应用的时候,就需要路由功能了,比较流行的路由是 react router. 这是一个很好的库,但是当我们已经用到路由的时候,下一步就该考虑如何做服务端渲染了,所以直接上 next.js 吧。

鉴于我已经使用 create-react-app 创建了 react 应用,需要手工安装一下 next.js

yarn add next

把 package.json 中的 scripts 替换掉

"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start"
}

核心概念

next 的核心概念是页面,没啥可解释的吧。按照约定,放在 /pages 文件夹中的每一个组件都是一个页面,比较恶心的是每个组件需要使用 export default 导出。

当然,pages/index.js 对应的自然是首页了。

function HomePage() {
  return <div>Welcome to Next.js!</div>
}

export default HomePage

然后就可以看到首页啦!啊啊啊

next.js 中,完全按照文件的物理路径来确定路由,比如如果你需要 post/1 这种路径,直接定义 pages/post/[id].js, 也是够直接了。

读取 URL 参数

需要使用 router 来手动读取

import {useRouter} from 'next/router'

function MyPage() {
  const router = useRouter();
  const {keyword} = router.query;
}

获取数据

在 nextjs 中,鼓励的方式是在服务端编译或者渲染的时候获取数据,而不是由客户端渲染数据。这里我们先不看 SSG 了,看现在最需要的 SSR.

在一个页面中,export 一个 async 函数 getServerSideProps 就可以实现获取服务端的数据。

export async function getServerSideProps(context) {
  return {
    props: {}, // will be passed to the page component as props
  }
}

context 中比较重要的几个属性:

  • params 路径中的参数,比如 {id: xxx}
  • req/res 请求响应
  • query query_string

在这个函数中,应该直接读取数据库或者外部 API.

除此之外,另一种方式自然是传统的在客户端获取数据了,可以使用 useSWR 库。

样式

next.js 中默认不让导入全局的 CSS, 所以你必须在 pages/_app.js 中导入全局的 css.

import '../styles.css'

// This default export is required in a new `pages/_app.js` file.
export default function MyApp({ Component, pageProps }) {
  return <Component {...pageProps} />
}

对于每一个组件,把它们的样式文件放到 [name].module.css 中就好啦。然后需要导入

import styles from 'Button.module.css';

export default function Button() {
  return <div className={styles.button}>Button</div>
}

另一种方式是使用 styled-jsx, 也就是把 CSS-in-JS 的方式,我个人还是喜欢这种方式一些。但是这种不好在 VSCode 中直接显示调色板。

<style jsx>{`
  h1 {
    color: red;
  }
`}</style>

静态文件

也很简单,直接放到 /public 目录,然后就能在根路径访问了。

路由

使用 [xxx] 放在路径中作为参数就好了。

nextjs 中的链接是这样的:

<Link href="/blog/[slug]" as={`/blog/${post.slug}`}>
    <a>{post.title}</a>
</Link>

外部接口

错误页面

next.js 可以自定义 404 和 500 错误页面

参考

  1. https://haodong.io/render-client-side-only-component-in-next-js
  2. https://github.com/vercel/next.js/blob/canary/examples/progressive-render/pages/index.js

《Prometheus 监控实战》笔记

第一章

与安全性一样,监控也应该是应用程序的核心功能。

如果应用程序在你没有注意到的情况下发生了故障,那么及时进行了监控,你也需要考虑下正在监控的内容是否合
理。

应该监控业务事务的内容或速率,而不是监控它运行的 Web 服务器的运行时间。

静态阈值几乎总是错误的,比如说只监控 CPU 超过 80% 的情况

监控的两种机制:

  • 探针,当我们对要监控的资源没有控制权的时候
  • 内省

显然,应该优先采用内省来监控。但是如果应用程序由第三方提供,并且你没有深入了解其内部操作的时候。从外
部查看应用程序以了解某些网络、安全性或可用性问题通常也很有帮助。

指标是一个组件属性的度量,对这个指标持续跟踪,观察的集合称为时间序列。

单一指标和聚合指标的组合可以提供最佳的健康视图:前者可深入到某个特定问题,而后者可以查看更高阶的状态。

使用平均值描述事件序列是非常危险的。有个笑话是:一位统计学家跳进平均深度只有 25 厘米的湖中,然后差点被
淹死,因为湖中有深达十米的大洞,虽然大部分水面深度只有 10cm

平局值的坏处就在于高峰和低谷可能被平均值所掩盖。百分位数才是识别异常值的理想选择。

监控方法论

Gregg 的 USE 指标:

  • Utilization(使用率). 资源忙于工作的平均时间,通常用随时间变化的百分比表示。
  • Saturatioin(饱和度). 资源排队工作的指标,无法处理额外的工作。通常用队列长度表示。
  • Error(错误). 错误事件的计数

Google 的四个黄金指标

  • 延迟:服务请求所花费的时间,需要区分成功和失败请求。因为失败请求可能延迟非常低,但是结果是错的
  • 流量:QPS 或者 TPS
  • 错误:错误的速率。包括空响应和超时等隐式错误
  • 饱和度:受限的资源,和上面类似

Weaveworks 的 RED 指标

  • Rate(流量)
  • Error(错误率)
  • Duration(延迟)

实际上已经被包含在 Google 的四个指标中了

警报和通知

  • 哪些问题需要通知
  • 谁需要被告知
  • 如何告知他们
  • 多久告知他们一次
  • 何时停止通知或升级到其他人

通知的信息应该包含以下几方面:

  • 清晰准确,可操作。应该让人能够看懂并知道如何操作
  • 为通知添加上下文,应该包含其他组件的相关信息
  • 只发送有意义的通知,不要因为有了通知系统就一定要使用

可视化

可视化系统最终要的在于:突出重点而不仅是提升视觉效果。

第二章

大多数监控查询和警报都是从最近(通常是一天内)的数据产生的。

Prometheus 的高可用架构建议:

  • 使用两个或多个相同配置的 Prometheus 服务器收集指标
  • 所有生成的警报发送到一个 Alertmanger 的集群,由 altermanager 进行消重

第三章

强烈建议不要单独配置每个服务的指标抓取间隔,这样能够确保你的所有时间序列具有相同的粒度。

即使向量 (Instant Vector): 一组包含每个时间序列的单个样本的时间序列集合

第四章

cAdvisor 作为容器运行,可以用来监控 Docker

如何设定标签体系?

  1. 使用拓扑标签,比如说 datacenter, job, instance 等
  2. 使用模式标签,比如 url_path, error_code

在这里书中有一处错误,书中说可以使用 user 作为标签,实际上绝对不要用 user 作为标签,这会让时间序列的 rank 直接爆炸

TODO avg(rate()) 是啥意思

predict_linear 这个函数非常有用,可以用来回答:”考虑到现在磁盘使用情况,以及他的增长速率,我们会在什么时候耗尽磁盘空间?

对于向量匹配运算,在多数情况下,一对一匹配就足够了。

第六章

最常见的错误是发送过多的警报。

应该针对症状而不是原因发出警报,又人类来判断造成问题的具体原因。

Alertmanger 的 route 块配置警报如何处理。receivers 配置警报的目的地。在规则中 expr 配置触发警报的规
则,而 for 指定在触发警报之前,测试表达式必须为 true 的时长,annotations 用于指定展示更多信息的标签。

警报一共有三个状态:

  • Inactive
  • Pending, 已经为 true, 但是 for 的时间还没满足
  • Firing, 处于触发状态

如果不指定 for 子句,那么警报会直接由 Inactive 转为 Firing.

在 annotation 中还可以使用模板,其中有一些变量,和 humanize 等等函数。

routes 是树形的,在 Yaml 配置中直接嵌套。

routes:
- match:
    severity: critical
  receiver: pager
  routes:
    - match:
        servrity: application
      receiver: support_team

Alert 的 silence 也很重要,可以通过

  • web 控制台
  • amtool
  • unsee 等第三方控制台

第七章

Prometheus 认为实现集群所投入的成本要高于数据本身的价值,所以 Prometheus 不用集群,直接两个配置走起。

第八章

为应用程序添加监控,从以下入口和出口做起:

  1. 测量请求和响应的数量和时间
  2. 测量对外部服务和 API 的调用次数和时间,比如对于数据库等的调用
  3. 测量作业调度,执行和其他周期性事件
  4. 测量重要业务和功能性事件的数量和时间

应用程序的指标又分为两大类

  1. 技术性指标,用于衡量应用程序的性能和状态,比如吞吐量、功能和状态等等
  2. 业务性指标,用于衡量应用程序的价值,比如电商网站的销售量

业务指标通常是应用程序指标的更进一步,他们通常与技术指标同义。一个技术性指标可能是交易服务的延迟,
而业务性指标可能是每个交易的价值

另外,对于长期业务指标来说,不要用监控系统了,一般来说还是用基于事件的统计系统。

第九章

mtail 用于从日志中抽取并发送时间序列

第十章

探针监控也称为黑盒监控

第十一章

当目标端点没有可以抓取的端点,比如批处理作业,可以使用 push gateway

Push Gateway 开箱即用,没有什么配置。

Web Cron 市场调研

因为实现了 sche 的语法,感觉完爆 cron,所以调研了一下 web cron,不过似乎市场不大。这个算是 unboundlling AWS 的一种,但是功能太简单了,直接用 AWS 也不复杂。而且竞争对手太多了。。

Heroku 真的是个很有意思的服务,解决了很多痛点,但是项目还是挺大的。不过值得思考的是,为什么 Heroku 做起来了,而 Google App Engine 凉了?是因为 GAE 超前时代太多了吗?

Sche.io 还没有被注册,可惜 99 刀太贵了,不然先注册一把。

  1. Indie Hacker 上有人有类似的想法。https://www.indiehackers.com/forum/cloud-based-cron-job-should-i-built-it-c46f58e66f
  2. Hacker News 上的评论大多是正面的,但是也不知道会不会付费。https://news.ycombinator.com/item?id=17346616
  3. 直接用 aws lambda 就行了。。https://gist.github.com/milesw/83332215df29fa25239712cd1ba273d9
  4. 这个 Cronhub 号称挣钱了。但是没看出哪里有意思或者可复制来。https://www.indiehackers.com/@tigran/cronhub-2nd-month-report-9e474add23

提到的一些痛点:

  1. 如何做好授权?
  2. 不一定所有服务都暴露了外部 http 接口
  3. 对于长时间执行的任务怎么办
  4. 能否直接 ssh 上去执行任务呢?
  5. 能否提供 API 让用户通过代码上传任务

如果真要做的话,应该把监控、日志统计等都做了。做到比 AWS、Azure、Heroku 的调度功能更加方便。

比较有意思的思考

网站的监控是一个挺大的领域

  1. 包括网站的功能监控,比如 Pingdom,HyperPing,PagerDuty,DataDog 等等
  2. 网站的用户监控,Google Analytics 和一系列的工具

关于 Heroku vs App Engine

这篇文章说的不错。Google 的第一个问题在于没有把用户放在心里,而是在 Demo 甚至 Show off 自己的 Infra。比如说:

  • 在 GAE 上用户需要大幅度修改自己的代码才能运行
  • 没有 SQL,只能用 Google 自己的 BigTable
  • 不支持最最最流行的 PHP

等等。。

而在 Heroku 上,用户可以随意使用已经很熟悉的 Postgres/Mongo/Redis/MySQL 等等数据库,也就是说迁移到 Heroku 几乎不费任何代价,而且还不用关心负载平衡、监控等运维细节。

另一方面,Heroku 最开始支持的是 Ruby on Rails,而这个社区在当时是非常活跃,而且乐于传教的。

最后一点,Google App Engine 甚至比 Heroku 贵不少。。说好的大厂不差钱呢?

sche – 一种人类能够看懂的 cron 语法

在 Linux 系统上,我们一般使用 cron 来设置定时任务,然而 cron 的语法还是有些佶屈聱牙的,几乎每次要修改的时候都需要查一下文档才知道什么意思,以至于有 crontab.guru 这种网站专门来解释 cron 的语法。

想象一下,能不能有一种让人一眼就能看懂的语法来表达周期性的调度操作呢?比如说这样:

every 10 minutes         , curl apple.com
every hour               , echo 'time to take some coffee'
every day at 10:30       , eat
every 5 to 10 minutes    , firefox http://news.ycombinator.com
every monday             , say 'Good week'
every wednesday at 13:15 , rm -rf /
every minute at :17      , ping apple.com
every 90 minutes         , echo 'time to stand up'

这样的配置文件是不是很容易懂呢?如果要写成 crontab 的格式大概是这样的:

*/10 * * * *    curl apple.com
0 * * * *       echo 'time to take some coffee'
30 10 * * *     eat
*/7 * * * *     firefox http://news.ycombinator.com  # 实际上是不对的,因为 cron 没法随机
0 0 * * MON     say 'Good week'
15 13 * * WED   rm -rf /
# every minute at :17  无法实现,因为 cron 中没有秒
0 0-21/3 * * *  echo 'time to stand up'  # 需要两条命令来完成每隔 90 分钟的操作
30 1-22/3 * * * echo 'time to stand up'

可以很明显看出,cron 的语法可读性还是差一些的,关键是维护起来更是像读天书一样。幸运的是,我在周末刚刚做了一个小工具,虽然还比较粗糙,但是也已经可以解析上面这种可读性比较好的语法。下面简单介绍一下如何使用:

介绍 sche

sche 是一个 Python 程序,所以可以使用 pip 直接安装:

pip install sche

安装之后,就会得到一个 sche 命令,帮助文件如下:

-> % sche -h
usage: sche [-h] [-f FILE] [-t]

A simple command like `cron`, but more human friendly.

The default configuration file is /etc/schetab, syntax goes like:

    # (optional) set time zone first
    timezone = +0800

    # line starts with # is a comment
    every 60 minutes, echo "wubba lubba dub dub"

    # backup database every day at midnight
    every day at 00:00, mysqldump -u backup

    # redirect logs so you can see them
    every minute, do_some_magic >> /some/output/file 2>&1

optional arguments:
  -h, --help            show this help message and exit
  -f FILE, --file FILE  configuration file to use
  -t, --test            test configuration and exit

我们只需要把需要执行的命令放到 /etc/schetab 文件下就好了,这里显然是在致敬 /etc/crontab。比如说:

-> % cat /etc/schetab
timzone = +0800
every 5 seconds, echo "wubba lubba dub dub"
every 10 seconds, date

-> % sche
wubba lubba dub dub
Tue Sep  1 22:15:01 CST 2020
wubba lubba dub dub
wubba lubba dub dub
Tue Sep  1 22:15:11 CST 2020
wubba lubba dub dub
wubba lubba dub dub
Tue Sep  1 22:15:21 CST 2020
wubba lubba dub dub

如何让 sche 像 cron 一样作为一个守护进程呢?秉承 Unix 一个命令只做一件事的哲学,sche 本身显然是不提供这个功能的,可以使用 systemd 实现,几行配置写个 unit 文件就搞定了。

sche 的来源

sche 是 schedule — 另一个 Python 库的一个 fork, schedule 支持这样的 Python 语句:

schedule.every(10).minutes.do(job)
schedule.every().hour.do(job)
schedule.every().day.at("10:30").do(job)
schedule.every().monday.do(job)
schedule.every().wednesday.at("13:15").do(job)
schedule.every().minute.at(":17").do(job)

然而我的需求是把时间配置独立出来,能够像 cron 一样存到一个文本文件里,而不是写成 Python 代码,于是提了一个 PR,增加了 when 这个方法来解析表达式。同时我还强烈需求时区支持,然而原版的 schedule 也不支持。所以就创建了一个 fork.

sche.when("every wednesday at 13:15").do(job)
sche.timezone("+0800").every().day.at("00:00").do(job)

最后,原生的 cron 命令实际上(至少我)已经极少用了,然而 crontab 的语法流传还是非常广的,在所有需要定时任务的地方,几乎都能看到 cron 的身影,比如说 Kubernetes job 等等,如果能够使用一种让正常人能随时看懂的语法,感觉还是很有意义的。

参考

  1. https://schedule.readthedocs.io/en/stable/
  2. https://crontab.guru/
  3. https://stackoverflow.com/q/247626/1061155