Author: 逸飞

React 中的表单

表单元素和所有其他元素的不同之处在于:表单元素是输入组件,它是有内部状态的,所有的其他元素都是输出元素。原生的 HTML 表单在 React 中本身也是能用的,但是一般情况下,我们可能会交给一个函数来处理提交表单这个事情,因为需要表单验证等等,这时候我们就可以用”受控组件”了。

什么是受控组件

受控组件就是数据不保存在 DOM 中,而是始终保存在 JS 中,随时通过 value/onChange 读取验证。非受控组件数据依然保存在 DOM 树中,只在 submit 时候读取。

function MyForm(props) {
  value, setValue = useState("");

  function handleChange(e) {
    setValue(e.target.value)
  }

  function handleSubmit(e) {
    console.log("A name was submitted: " + value);
    e.preventDefault();
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:
        <input type="text" value={value} onChange={onChange} />
      </label>
      <input type="submit" value="Submit" />
    </form>
  );
}

使用原生表单

完全可以使用原生组件,而不要直接使用受控组件。

function form2kv(form) {
  const fd = new FormData(form);
  const ret = {};
  for (let key of fd.keys()) {
    ret[key] = fd.get(key);
  }
  return ret;
}

function MyForm(props) {

  // 也可以直接通过读取 events.target 的值来获取
  function handleSubmit(event) {
    event.preventDefault();
    console.log(event.target.elements.username.value) // from elements property
    console.log(event.target.username.value)          // or directly
  }

  function handleSubmit(e) {
    e.preventDefault();
    const data = form2kv(e.target);
    axios.post("/api/myresource", data);
  }
  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="username">Enter username</label>
      <input type="text" id="username" name="username">
      <label htmlFor="password">Enter password</label>
      <input type="password" id="password" name="password">
      <label htmlFor="birthdate">Enter your birth date</label>
      <input type="date" id="birthdate" name="birthdate">
      <button type="submit">Send data!</button>
    </form>
  )
}

在上面的例子中,我们使用了浏览器自带的 Formdata 对象,通过传入 DOM 中的 Form 元素来读取其中的数据。另外需要注意的一点是,我们使用了 onSubmit 事件,而不是 Submit Button 的 onClick 时间,这样保留了浏览器的原生处理方式,也就是可以使用回车键提交。

处理数据

如果当我们需要变换数据格式时呢?

  • 用户输入的数据是 MM/DD/YYYY, 服务端要求是 YYYY-MM-DD
  • 用户名应该全部大写

这时候可以使用另一个小技巧,data-x 属性。

const inputParsers = {
  date(input) {
    const [month, day, year] = input.split('/');
    return `${year}-${month}-${day}`;
  },
  uppercase(input) {
    return input.toUpperCase();
  },
  number(input) {
    return parseFloat(input);
  },
};

function form2kv(form, parsers = {}) {
  const fd = new FormData(form);
  const ret = {};
  for (let name of data.keys()) {
    const input = form.elements[name];
    const parseName = input.dataset.parse;

    if (parseName) {
      const parser = inputParsers[parseName];
      const parsedValue = parser(data.get(name));
      ret[name] = parsedValue;
    } else {
      ret[name] = data.get(name);
    }
  }
  return ret;
}

function MyForm(props) {
  function handleSubmit(e) {
    e.preventDefault();
    const data = form2kv(e.target);
    fetch("api/myresource", method="POST", body=JSON.stringify(data));
  }
  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="username">Enter username</label>
      <input type="text" id="username" name="username" data-parse="uppercase">
      <label htmlFor="password">Enter password</label>
      <input type="password" id="password" name="password">
      <label htmlFor="birthdate">Enter your birth date</label>
      <input type="date" id="birthdate" name="birthdate" data-parse="date">
      <button type="submit">Send data!</button>
    </form>
  )
}

在上面的例子中,我们通过使用 data-x 属性指定了应该使用的处理函数。通过表单的 elements 属性,我们可以按照 input 的 name 访问元素。而节点的 data-x 属性可以在 dataset[x] 属性中访问。

验证表单

HTML 的原生 API 就支持了很多的验证属性,其中最最好用的就是 pattern 属性了,他可以指定一个正则来验证表单。而且我们也可以通过 type 属性来制定 email 等等特殊的 text 类型。HTML 支持的验证属性有:

  • required, 必填字段,不能为空
  • pattern, 通过一个正则来验证输入
  • minlength/maxlength, 输入文本的长度
  • min/max 数字的区间
  • type, email 等等类型

在 JS 中可以调用 form.checkValidity() 函数获得检查结果。还需要在 form 中添加 novalidate 属性,否则浏览器就会在校验失败的时候直接不触发 submit 事件了。

当表单验证不通过的时候,会获得一个 :invalid 的伪类。但是比较坑爹的是,当表单还没有填写的时候,验证就已经生效了,这样对用户来说非常不友好,不过也很好克服,我们只要设定一个类,比如说 display-errors, 只有当用户尝试提交表单之后再应用这个类就好啦~

displayErrors, setDisplayErrors = useState(false);

function handleSubmit(e) {
  if (!e.target.checkValidity()) {
    setDisplayError(true);
  }
}

return (
  <form
    onSubmit={handleSubmit}
    noValidate  // 即使校验失败也要触发
    className={displayErrors ? 'displayErrors' : ''}
  >
    {/* ... */}
  </form>
)
.displayErrors input:invalid {
  border-color: red;
}

select 组件

原生的 select 组件

<select>
  <option value="grapefruit">Grapefruit</option>
  <option value="lime">Lime</option>
  <option selected value="coconut">Coconut</option>
  <option value="mango">Mango</option>
</select>

在受控的组件中,可以在 select 组件中使用 value 属性指定选项。读取可以使用 e.target.value

<select value={state.value} onChange={handleChange}>
  <option value="grapefruit">Grapefruit</option>
  <option value="lime">Lime</option>
  <option value="coconut">Coconut</option>
  <option value="mango">Mango</option>
</select>

如果需要多选的话,使用 <select multiple={true} value={['B', 'C']}>

file 组件

file 组件是只读的,所以只能使用

textarea 组件

react-hook-form

对于更复杂的逻辑,推荐使用 react-hook-form

react-hook-form 的 v7 中使用的是未受控的组件,这样性能更好。最近一直在使用 tailwind UI,对于原生组件来说写样式更方便一点。

当使用 RHF 的时候,可以配合使用 Yup 来做表单验证。

使用 antd 表单

表单的基本元素是 <Form/>, Form 中嵌套 Form.Item, 其中再嵌套 Input/TextArea 等组件。

表单的数据都在 form 元素中,使用 const form = Form.useForm() 来获取,并通过 form={form} 传递给 Form 控件。

设置了 name 属性的 Form.Item 会被 form 接管,也就是:

  1. 不需要通过 onChange 来更新属性值。当然依然可以通过 onChange 监听
  2. 不能使用 value 和 defaultValue,只能使用 form 的 initialValues/setFieldsValue 设置

总体来说,antd 的表单还是封装过度了,非常难以定制化。

参考

  1. https://reactjs.org/docs/uncontrolled-components.html
  2. Controlled and uncontrolled form inputs in React don’t have to be complicated
  3. How to handle forms with just React
  4. https://reactjs.org/docs/forms.html
  5. https://jsfiddle.net/everdimension/5ry2wdaa/
  6. https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements
  7. https://www.cnblogs.com/wonyun/p/6023363.html
  8. https://stackoverflow.com/questions/23427384/get-form-data-in-reactjs
  9. https://github.com/jquense/yup

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

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

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

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

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

拿到抓取任务时的思路

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

数据量较小的爬取

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

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

确保自己的请求没有明显的爬虫特征

发送 http 请求时,Host, Connection, Accept, User-Agent, Referer, Accept-Encoding, Accept-Language 这七个头必须添加,因为正常的浏览器都会有这 7 个头。
 
其中:

  1. Host 一般各种库都已经填充了
  2. Connection 填 Keep-Alive, 正经浏览器不可能是 Close。
  3. Accept 一般填 text/html 或者 application/json.
  4. User-Agent 使用自己的爬虫或者伪造浏览器的 UA, 而且要即使变更,这个是最重要的。
  5. Referer 一般填当前 URL 即可,考虑按照真实访问顺序添加 referer,初始的 referer 可以使用 google,没人会拒绝搜索引擎流量的。
  6. Accept-Encoding 从 gzip 和 deflate 中选,好多网站会强行返回 gzip 的结果。
  7. Aceept-Language 根据情况选择,比如 zh-CN, en-US

把这些填充上,至少不会被最低级的反爬手段封禁了。稍微敏感点的网站,都会禁掉 curlpython-urllib 这种 User-Agent 的。

另一个比较重要的事情是 Cookie,有些网站比较傻,你直接不处理 Cookie 就每次都把你当新用户,不过这种网站比较少了。还有的网站需要在首页获得一个匿名 Cookie 然后一直携带这个匿名 Cookie 就好了。还有一些网站需要 Cookie 每次请求后更新,就像浏览器一样。这些都是比较简单的方法,一开始试试就知道是哪种了。对于需要登录后的 Cookie 的,那是登录反爬的范畴,后面详谈。

陷阱链接,有一些网站上会有一些隐藏的链接,通过 CSS 或者其他方式让正常用户看不到,而爬虫不管啊,拿到链接就是一个爬,以此来识别爬虫和正常用户,不过这也都是小众做法,很少遇到了。

浏览器动态加载怎么办?

初学者在这里可能遇到第一个坑:动态网页。这时候可能是个好事儿,也可能是个坏事儿。如果是动态网页,数据自然是 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 几秒。这时候你的程序的逻辑就需要适应这种机制。如果是单独的脚本还好,对于一些标准化的系统就需要考虑这些机制。

一种比较简单的策略是,sleep 的间隔应该指数增加,比如第一次 sleep 10 秒,发现还是被限制,那么就 sleep 20 秒,直到一个比较大的上限或者是解除封禁。

当我们需要换 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 可能是非法行为,需要负法律责任。

爬来的数据是否可信

当你费劲吧啦把数据搞定了,还有一个灵魂问题,爬来的数据可信吗?

如果用来分析的数据本来就是错的,那么得出的结论必然也是有问题的。比如 2016 年美国大选中,由于川普的支持者经常被侮辱,导致在电话调查选民中,大家都声称自己支持希拉里,可是实际上大家都投给了川普。电话调查的结果本来就是错的,所以大家都认为希拉里会赢。川普团队则采取的是问选民你认为你的邻居会投谁,从而得到了正确结果。

爬虫爬到的数据中也有可能是有问题的,比如租房网站的假房源,招聘网站上的虚假职位,用户故意不填写真实信息以保护隐私等等;微信文章被刷多的阅读数;而且编写不良的爬虫很可能误入蜜罐,得到的数据更有问题。

比如说借助爬来的新闻分析房产数据,实际上住建部禁止发布涨价相关预测,也就是对于市场的情绪表达是有影响的,那么我们如果按照这个数据来做预测显然是不对的。

总体来说,遇到蜜罐的情况是比较简单的,因为对方伪造数据也需要花费精力,所以伪造的数据一般都是粗制滥造,特征很明显的。而其他的一些假数据问题都是业务问题了,在爬虫程序层面不好也不用解决。

最后,总结一下

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

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

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

就是这些吧,本文核心观点 — 最简单粗暴的还是加大电量(误加 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;

在上面的简单例子中,我们直接使用值作为 setState 的参数,但是在部分更新 state 的时候,最好使用一个函数来作为参数:

const [state, setState] = useState({"showText": true, "showShadow": true})

<button onClick={() => setState((s) => {...s, showText: false})}>Hide Text</button>

使用 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 的第二个参数来指定监听的事件或者说状态。当第二个参数只使用一个空数组 [] 的时候就只会在组件加载的时候调用。数组中有哪些变量,表示在这些变量变化的时候调用。

一般建议把不依赖 props 和 state 的函数提到你的组件外面,并且把那些仅被 effect 使用的函数放到 effect 里面。

使用 useContext hook

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

  1. 首先需要通过 React.createContext 定义一个高层次 Context,
  2. 然后在最外层使用 <MyContext.Provider /> 来包裹需要接受这个 context 的所有组件。
  3. 在需要使用状态的元素中调用 const ctx = useContext(MyContext), 然后使用 ctx 访问 Context 中的值

在基于类的 React 组件中,需要使用 <MyContext.Consumer /> 来实现读取值,现在我们都用 useContext 钩子了。

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 = (Better) Redux

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

两者的组合使用很简单,使用 Context.Provider 把 state 和 dispatch 这两个变量广播给 root 下的所有元素,这样在需要使用 state 和 dispatch 的地方直接 useContext(Context) 就好了。

useMemo/useCallback 钩子

用来避免重复计算或者重复生成函数。

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. https://medium.com/@wisecobbler/using-a-function-in-setstate-instead-of-an-object-1f5cfd6e55d1
  6. https://stackoverflow.com/questions/56615931/react-hook-setstate-arguments
  7. https://overreacted.io/zh-hans/a-complete-guide-to-useeffect/

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

Python 3 中的 dataclass

在 Python 中,如果要为一个类添加一些数据成员的话,需要做的事情还挺多,比如说编写 __init__,
__str__ 这些函数,代码都是重复的,没啥意义。在 Python 3.7 中,终于添加了一个语法糖,叫做
dataclass. 下面我们就来看一下吧~

# 注意!包名叫 dataclasses, 多了个 es
from dataclasses import dataclass

@dataclass
class InventoryItem:
    """Class for keeping track of an item in inventory."""
    name: str
    unit_price: float
    quantity_on_hand: int = 0

    def total_cost(self) -> float:
        return self.unit_price * self.quantity_on_hand

上面的代码就相当于以前的:

def __init__(self, name: str, unit_price: float, quantity_on_hand: int=0):
    self.name = name
    self.unit_price = unit_price
    self.quantity_on_hand = quantity_on_hand

def __repr__(self):
    ...

def __eq__(self):
    ...

...

我们知道,在 Python 的默认参数中,使用 mutable(可变) 的对象是一种常见的坑,在 dataclass 中当然也存在
了,还好标准库中给我们提供了一个方法。

# 会报错
@dataclass
class Request:
    headers: dict = {}

# 相当于
class Request:
    def __init__(self, headers={}):
        self.headers = headers

# 正确的写法
from dataclasses import field

@dataclass
class Request:
    headers: dict = field(default_factory=dict)

字典这种类型是比较容易想起来不能直接做参数的,比较坑的是对于其他的自定义对象,Python 解释器并不会提
示有问题,比如说这样:

# 千万别这么做
@dataclass
class Request:
    headers: Headers = Headers()

这时候坑爹的事情就发生了,每次创建新的 Request 对象引用的都是同一个 Headers 对象,也就是在声明这个类
的同时产生的这个 Headers 对象!原因也很简单,就像是上面的 dict 一样,这个 Headers 并不是在 init 函数
中,所以只会产生一次。所以,需要牢记的是:dataclass 中的所有对象默认值就相当于函数的默认参数,永远不
要传递一个 mutable 就好了。

# 上面的例子相当于
class Request:
    def __init__(self, headers=Headers()):
        self.headers = headers

# 正确的做法
@dataclass
class Request:
    headers: Headers = field(default_factory=Headers)

dataclasses 模块中还提供了 asdict 方法,这样就可以方便地转换为 json 对象啦。

from dataclasses import asdict

@dataclass
class Request:
    url: str = ""
    method: str = ""

req = asdict(Request())

参考资料

  1. https://docs.python.org/3/library/dataclasses.html