写一个 CRUD 还挺难的

让我们只从后端角度出发,考虑写一个简单的博客系统会有哪些问题。这篇文章谈论的并不是某个 Web 框架的 TODO list demo 之类的东西,那都是玩具性质的,而是会谈一谈生产环境中的要考虑的一些实际问题。本文中,我们也不会涉及到像是 MySQL 的几种隔离模式或者是 Kafka 是不是 Exactly Once 这种后端面试常问的八股文,而是从全局考虑一些简单但是又避不开的繁琐问题。

数据库

首先,需要定义一下数据库的表吧。在博客中,我们至少需要两个表:

  1. articles
  2. users

其中 articles 表中应该有一个 author_id 字段关联到 users 表中。那么问题来了,要不要在数据库层面定义物理外键呢?还是仅仅从逻辑上把两者关联起来。

使用物理外键的好处是可以少处理很多异常情况,因为数据库层面已经帮你解决了。但是在开发阶段,当需要删除数据的时候会有很繁琐的依赖问题。在生产环境中,外键也可能带来一些写入的性能问题。

如果只使用逻辑外键呢?那么就需要经常考虑有非法外键的情况,代码中要做好处理,不然就异常满天飞了。

关于数据库的第二个问题,要不要使用 ORM 呢?或许你已经习惯了使用 SQLAlchemy 或是 Hibernate 这类 ORM,并且认为这就是最佳实践。实际上,ORM 并不一定是最好的选择,选择 ORM 可能会有三个缺点:

  1. 意味着你在 SQL 语法之外还要另外学习一门 ORM 中包含的 DSL;
  2. ORM 也不能覆盖所有的 SQL 语句,很多时候你还是得手写 SQL;
  3. ORM 生成的 SQL 经常性能有问题,比如说经常就不小心 N+1 问题或者 select * 了。

甚至于,有的人认为 ORM 技术就是一团泥潭,堪比计算机界的越南战争(美国视角)

当然,不用 ORM 问题也很多,手工拼接 SQL 非常恶心不说,还容易出现 SQL 注入攻击的漏洞。

安全漏洞

正如刚刚提到的 SQL 注入攻击一样,作为一个生产环境的应用,面向大众开放,自然要考虑一些恶意攻击者的访问。

举个例子,你为了方便前端调用,在 API 中留下了一个 order_by=xxx 的参数,为了开发更灵活,这个参数直接对应到了数据库的字段而没有过滤。正常情况下,在你的客户端或者前端代码中,只是简单调用了一下 order_by=create_time,而这个字段是加了索引的,那么皆大欢喜。但是,恶意攻击者可不是这么想的,『我干嘛不调用一下没有索引的字段呢?』。比如说,发送大量的攻击请求到 order_by=first_name 上,那么很快你的数据库就可能被慢查询拖垮了。

这个例子蕴含了一个普遍的道理:灵活和安全不可得兼,你必须针对你的应用,选择一个恰当的地方做好折中。

上面的例子还没有涉及到数据,只是把网站搞挂了而已。另一个容易产生漏洞的地方,就是权限管理了。

假设你有一个用户更新自己文章的 API。那么就应该在其中校验用户传过来的 article_id 是不是他自己的文章。前端校验是不够的,假如恶意用户构造一个请求呢?如果不校验,就可能导致任意修改他人文章的漏洞。再比如,文章状态可能包含草稿和已发布,那么应该只有已发布的文章才可以浏览,但是用户也应该能预览和编辑自己未发布的草稿,你都需要判断是否有适当的权限。问题还可以变得更复杂一点,用户可能分为普通用户和管理员用户,管理员用户又可以查看所有的文章。在稍微大型一点的应用中,文章可能有不同的属性分类,用户更是可能有不同的角色,比如编辑、审核、管理员等等,文章的展现形式也可能多种多样,比如说是完全不可见,还是仅列表隐藏,还是仅列表可见标题,实际访问才会提示不可见/付费内容呢?

后台系统

前面提到了管理员用户,那么就引入了又一个问题——后台管理系统。对于一个博客系统的普通浏览者,看到的可能就是一篇篇的博客,但是对于博客的作者或者管理员来说,一定还有一个后台管理系统来写博客。后台管理系统需要有权限控制,对普通作者,需要限制访问后台的权限,比如说只能访问写作模块,对于管理员,则可以访问用户管理模块。一般来说,后台系统是一个复杂度不亚于前台的部分,这工作量一下就 double 了。现实中的应用,比如说新浪博客,可能还要复杂一些,至少分为三个部分。普通用户看到的博客页面,这部分在水面上,大家都能看到;博主撰写文章,管理自己文章的个人后台;而在新浪博客内部,一定还有一个审核文章,管理用户的内部后台,这工作量一下子就 triple 了。

你可能会说,django admin 这种自动生成的后台系统它不香么?等你尝试着添加一些 JS/CSS 或者是更改一下权限系统就知道了。有人专程写文章批判过:CRUD 代码生成的反模式典范:django admin

性能问题

等你把一切业务功能性的开发都搞定了,是时候考虑性能了。还是以博客系统为例,假如你有一个点赞的功能,那么根据学校里面教的数据库范式,这是一个典型的多对多关系啊,应该弄一个关联表,大概像这样:

users(n) <-> user_liked_articles <-> articles(n)

问题来了,如果首页要展现一个用户最喜欢的文章列表怎么办?在首页上有一个三个表的 join 操作对性能的影响是可想而知的。这时候至少有两种思路:

  1. 放弃正交化,在 article 表中增加一个 num_likes 字段,这样直接只查一个表就出来结果啦。缺点是要在代码中做好冗余信息的同步。
  2. 增加一个缓存,可以缓存整个首页,也可以缓存这条 SQL 的查询结果。缺点是你又得多一个组件,而且查询结果是有延迟的。

这已经是简单到不能再简单的一个小小性能问题了,真正的性能瓶颈可能需要通过使用 profile 工具或者是 wrk 这类的压测工具才能找出并修复。

部署

当你把性能问题也解决差不多的时候,终于到部署了。相对来说,部署还算中规中矩,没有多少幺蛾子。但是至少也要涉及到配置 nginx,申请 SSL 证书这些方面。

在 2021 年的今天,肯定得上 docker 吧,那还得写 dockerfile。如果进一步,要用 k8s 的话,还得写 deployments。或许你还需要配置一下 Jenkins 或者其他 CI 工具……

另外,静态文件怎么放也得考虑一下,就不说 CDN 或者优化图片大小了。至少开发时候你存放的目录得挪到 nginx 对应的目录吧?都是不疼不痒但是必须考虑的杂活儿。

结语

大概写到这里吧,还有好多问题没有涉及,包括但不限于:

  1. 搜索。总不能用 select * like %keyword% 吧?如果上 ES 的话,ES 的查询语法大概也得了解吧,还得考虑到配置从 SQL 数据库到 ES 的同步问题。
  2. 监控和日志。从两个方面来说,一是程序的性能监控,服务挂了得即使知道。另一方面是业务数据的监控,比如说 DAU 是多少。
  3. 测试。代码的单元测试,集成测试等等。
  4. 异步消息。比如说刚刚提到的点赞,被点赞的用户要不要收到一个通知呢?是否要发送邮件通知?邮件通知是不是该搞个消息队列异步操作?redis/kafka?又引入了一个新系统。
  5. 反爬。前面提到的恶意攻击都是以破坏数据为目的的。爬虫稍微好点,只是想把你的数据(全部)搞走。或许你会愿意搜索引擎赶紧收录你的博客文章,但是肯定不希望一个竞争对手把你的所有用户信息全都爬走,然后挨个邀请过去。举两个在反爬上很简单的错误:
    1. 暴露数据库的物理 id。假如你的数据库直接用的自增 id,并且把这个 id 暴露在 API 中,那好了,我直接遍历就完了,所有用户信息拿到。
    2. 没有频控。最起码也要针对 IP 做一个漏桶或者令牌桶的频控吧,不然爬虫流量消耗服务器资源都是很大的问题。

看到这里,希望你对一个系统的复杂和琐碎性能有一个大体的印象,就不要再问出

  1. 10 万块钱能不能做个淘宝?
  2. 4 个月能不能山寨个抖音?
  3. 不就是加个字段么,为啥还要排期?

这种奇葩问题了。总体来说,以上没有什么技术难点。但是每一个点都需要做出取舍折中,特别琐碎。要把每一个小事都考虑好了,还挺复杂的,工作量也不小,而且也不一定是一个人能搞定的。以上所有的这些还都只是后端的问题,另一半前端的问题还完全没有考虑……

对于前端,使用 React 还是 Vue。考虑到 SEO 的话,哪些页面还需要做静态化,这些都是需要考虑的众多问题之一……

最后,或许你还是看不起一个简单的 CRUD 的 web 应用。什么大数据啊、深度学习啊、高并发才是应该掌握的知识嘛!但是,要知道互联网的根基或者说起点从来都是一个简单的后端+前端。你首先做出一个有用的东西,有了用户,慢慢才会产生大数据,才会有高并发的需求。

本文像之前写的「爬虫思路」那篇文章一样,纯意识流瞎写,没有什么架构图,也没有任何参考文章。或许过几个月,我还会写一篇关于前端或者机器学习的文章吧。

放弃 requests,拥抱 httpx

httpx 是新一代的 Python http 请求库,它几乎和 requests 的 API 无缝兼容,几乎不用改代码。相对于 requests 来说,有以下优点:

  • 支持 asyncio, 可以直接 async/await 啦
  • 支持 http 2, requests 一直都不支持
  • 实现了正确的 http 代理
  • Cookie 的 API 更友好
  • 有自己的网络连接池,requests 是基于第三方的 urllib3
  • 文档更有条理,更深入

还有就是 requests 有比较明显的内存泄漏,目前我还没有测试过 httpx,所以就不列到优点里了。

httpx 不支持在 client.request 中使用 proxies,必须在 Client 初始化时候指定,这样做应该是考虑到链接池的实现。

Axios 的基本使用

fetch 虽然在现代浏览器中已经支持很好了,但是 API 还是没有那么好用。比如说增加 GET 参数必须使用 URLSearchParm 这个类。相比之下,Axios 的 API 还是更接近直觉一些。

import axios from 'axios'

axios(url, [config]);
axios(config);
axios.get(url, [config])
axios.delete(url, [config])
axios.put(url, data, [config])
axios.post(url, data, [config])

// 请求中 config 的属性
const config = {
    headers: {}, // Headers
    params: {},  // GET 参数
    data: {},  // 默认情况下 {} 会按照 JSON 发送
    timeout: 0, // 默认是没有 timeout 的
}

// 如果要发送传统的 POST 表单
const config = {
    data: new FormData()
}

// headers 会根据 data 是 json 还是表单自动设置,非常贴心。

// 响应的属性
let res = await axios.get("api")
res = {
    data: {},  // axios 会自动 JSON.parse
    status: 200,
    statusText: "OK",
    headers: {},
    config: {},
    request: {}
}
// 和 fetch 不同的是,res.data 可以直接使用,而 fetch 还需要 await res.json()

// 如果要添加一些默认的设置,使用
axios.defaults.headers.common["X-HEADER"] = "value"
axios.defaults.timeout = 3000
// 具体参见这里: https://github.com/axios/axios/blob/master/lib/defaults.js#L28

重定向与其他错误状态码

对于 3XX 重定向代码,axios 只能跟中,这是浏览器决定的,不是 axios 可以改变的。
对于 4XX 和 5XX 状态码,axios 会抛出异常,可以 catch 住。

上传文件

如果一个表单中包含的文件字段,那么传统的方法是把这个字段做为表单的一部分上传。然而现代的 API 大多是 json 接口,并没有 POST 表单这种格式。
这时候可以单独把上传作为一个接口,通过 POST 表单的方式上传,然后返回上传后的路径。在对应的 API 的接口中附件的字段填上这个路径就好了。

参考

  1. https://stackoverflow.com/questions/4083702/posting-a-file-and-associated-data-to-a-restful-webservice-preferably-as-json

Boto3 访问 S3 的基本用法

以前我以为文档坑爹只有一种方式,那就是没有文档。最近在用 boto3, 才让我认识到了文档的另一种坑爹方式:太多。具体来说是这样的:Boto3 中有两种 API, 低级和高级。其中低级 API 是和 AWS 的 HTTP 接口一一对应的,通过 boto3.client(“xxx”) 暴露。高级接口是面向对象的,更加易于使用,通过 boto3.resource(“xxx”) 暴露,美中不足是不一定覆盖了所有的 API.

坑爹的 AWS 文档中,经常混用 resource 和 client 两套接口,也没有任何提示,文档的首页除了简单的提了一句有两套 API 外再没有单独的介绍了。在没写这篇文章之前,我的脑子里都是乱的,总觉得 S3(Simple Storage Service) 的这个狗屁接口哪里配得上 Simple 这个词,一会儿是 listobject, 一会儿是 listobject_v2 的。高级 API 是很简单易用的,然而这样一个简单的 API 被深深地埋在了一大堆的低级 API 中,网上的文章也是一会儿 boto3.client, 一会儿 boto3.resource. 除了有人特意提问两者的区别,很难看到有人说这俩到底是啥。

吐槽完毕。

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

如果没有特殊需求的话,建议使用高级 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()
body = rsp["Body"].read()  # 文件内容
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

对比 Flask,学习 FastAPI

本文假定你对 Flask 等 web framework 已经比较熟悉。不过多解释为什么要这么做,直接上干货。FastAPI 的一些优势大概有:类型检查、自动 swagger 文档、支持 asyncio、强大的依赖注入系统,我在 这个回答 里具体写过,就不展开了。

安装

注意一定要加上这个 [all], 免得后续缺什么依赖。

pip install fastapi[all]

Hello, World

# main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def hello():
    return "Hello, World!"

在命令行开启调试服务器:

uvicorn main:app --reload --port 8000

然后访问 http://localhost:8000 就可以看到 hello world 啦。更妙的是,访问 http://localhost:8000/docs 就可以看到自动生成,可以交互的 Swagger UI 文档了。

注意在上面的装饰器中,直接使用了 app.get 来定义 GET 访问的路径,其他的 post/put/delete 也都同理。Flask 在 1.x 中还必须使用 app.route,在 2.0 中才赶了上来。

同步和异步

# 同步函数
@app.get("/sync")
def hello():
    return "Hello, World!"

# 异步函数
@app.get("/async")
async def hello2():
    return "Hello, World!"

FastAPI 原生支持同时同步和异步函数,而且是完全一样的用法。使用异步函数肯定性能最好,但是有好多库,尤其是访问数据库的库用不了。使用同步函数的话,也不会把整个 Event Loop 锁死,而是会在线程池中执行。所以我个人的建议是,如果你已经习惯了写 Async Python,那么完全可以全部都写异步函数。如果你刚刚开始接触 asyncio 这一套,那么完全可以大部分都写同步函数,通过 profile 找到调用最多,或者对性能影响最大的地方,把这部分切换到异步函数。

请求参数

废话不多说,看请求参数都有哪些。

路径参数

@app.get("/{name})
def hello(name: str):
    return "Hello " + name

访问 http://127.0.0.1:8000/docs 就可以看到 OpenAPI 的文档了。

如果在文档中需要使用 Enum 的话,那么创建一个 Enum 的子类:

from enum import Enum

class ModelName(str, Enum):
   alexnet = "alexnet"
   resnet = "resnet"

@app.get("/models/{model_name}")
def get_model(model_name: ModelName):
    ...

值得注意的一点是,对于结尾的 “/”, FastAPI 不会强制要求匹配。如果你的请求和定义的不一样,那么 FastAPI 会 307 重定向到定义的那一个。比如定义了 app.get("/items/"), 而请求是 requests.get("/items"), 那么会 307 到 /items/. 但是如果部署在代理后面,重定向的地址可能不对,这个可能会引起问题,建议还是前后端统一一下。

另一个需要注意的地方,顺序是很重要的,FastAPI 按照定义的顺序来匹配路由。比如说你定义了两个路径 /users/me/users/{id}, 那么,一定要把 /users/me 放在前边,否则就会匹配到 users/{id}.

url query 参数

GET 参数是 FastAPI 和 Flask 不一样的地方。FastAPI 中统一用函数参数获取,这样也是为了更好地实现类型检查:

fake_items_db = [{"item_name": "Foo"}, {"item_name": "Bar"}, {"item_name": "Baz"}]

# GET /items/?skip=100&limit=10
@app.get("/items/")
async def read_item(skip: int = 0, limit: int = 10):
    return fake_items_db[skip : skip + limit]

对于 bool 类型,URL 中的 1/true/True/on/yes 都会被转化为 True, 其他都是 False.

http body 参数

POST 参数统一使用 json, 需要使用 PyDantic 的 BaseModel 来定义,只要参数是 BaseModel 类型的,FastAPI 就会从 body 中获取:

from pydantic import BaseModel

class Item(BaseModel):
    name: str
    description: Optional[str] = None
    price: float
    tax: Optional[float] = None

@app.post("/items/")
async def create_item(item: Item):
    return item

@app.put("/items/{item_id}")
async def create_item(item_id: int, item: Item):
    return {"item_id": item_id, **item.dict()}

如果不想定义 Model 验证,只想直接读取请求,那么可以使用 Request 对象,不过 Request 对象只有 async 版本,要想在同步函数中使用,需要一点点 work-around,这算是一个小小的坑吧:

from fastapi import Request, Depends

async def get_json(req: Request):
    return await req.json()

@app.get("/", response_model=None)
def get(req=Depends(get_json)):
    print(req)

其中用得到的 Depends 是 FastAPI 中非常强大的依赖注入系统,后面有时间了再详细讲。

Request 的文档详见:https://www.starlette.io/requests/, 参考:https://github.com/tiangolo/fastapi/issues/2574

Cookie 参数

使用 Cookie 来定义一个从 Cookie 中读取的参数:

from fastapi import Cookie, FastAPI

@app.get("/items")
def read_items(ads_id: Optional[str] = Cookie(None)):
    return {"ads_id": ads_id}

这里有个坑爹的地方,限于浏览器的安全模型,在生成的 Swagger UI 中 Cookie 参数是不能使用的。这是上游 Swagger/OpenAPI 项目的 bug(https://github.com/tiangolo/fastapi/issues/880), 所以在 FastAPI 这里也无解。

一个简单的 work-around 是:如果你有生成 Cookie 的 API, 那么先调用一下生成 Cookie 的 API, 再调用需要 Cookie 认证的 API 的时候就会自动附上这个 Cookie.

Header 参数

和 Cookie 是类似的。FastAPI 会自动把 “User-Agent” 转化成 user_agent 的形式。

from fastapi import FastAPI, Header

@app.get("/items/")
async def read_items(user_agent: Optional[str] = Header(None)):
    return {"User-Agent": user_agent}

参数验证

如果需要对参数进行验证,需要使用 Query, Path 等对象来指定,分别对应 GET 参数和路径:

from fastapi import FastAPI, Query, Path

@app.get("/items")
def read_items(q: Optional[str] = Query(None, min_length=3, max_length=50, regex="^fixedquery$"))
    ...

# 如果不使用默认值的话,可以使用特殊对象 `...`
def read_items(q: Optional[str] = Query(..., max_length=50))
# 数字可以使用 lt/le 或者 gt/ge 定义
def read_items(item_id: int = Path(..., gt=0, lt=100))

响应参数

需要在 app.get 中的 response_model 参数指定响应的 BaseModel:

from pydantic import BaseModel

class Item(BaseModel):
    name: str
    description: Optional[str] = None
    price: float
    tax: Optional[float] = None
    tags: List[str] = []

@app.post("/items/", response_model=Item)
async def create_item(item: Item):
    return item

请求的 body 和响应的 body 经常公用一个 Model, 有些敏感信息可能不适合在响应的 body 中返回,这时候可以使用 response_model_includeresponse_model_exclude 来指定需要包含或者需要过滤出去的字段。

@app.get(
    "/items/{item_id}/name",
    response_model=Item,
    response_model_include=["name", "description"],
    response_model_exclued=["password"]
)
async def read_item_name(item_id: str):
    return items[item_id]

使用 status_code 参数可以更改返回的状态码:

@app.get("/", status_code=201)
def get():
    ...

更新 Cookie

使用 Reponse 对象的 set_cookiedelete_cookie 方法实现增删 Cookie. 注意的是,只需要对 response 对象进行操作,不需要 return response 对象。

from fastapi import FastAPI, Response

@app.post("/cookie-and-object/")
def create_cookie(response: Response):
    response.set_cookie(key="fakesession", value="fake-cookie-session-value")
    response.delete_cookie(key)
    return {"message": "Come to the dark side, we have cookies"}

产生错误

直接 raise HTTPException 就好了

from fastapi import HTTPException

@app.get("/")
def hello():
    raise HTTPException(status_code=404, detail="item not found")

使用路由模块化

FastAPI 中的 router 相当于 Flask 中的 Blueprint, 用来切分应用到不同的模块。

# views/users.py
from fastapi import APIRouter

router = APIRouter()

@router.get("/me")
def myinfo():
    ...

# main.py
from fastapi import FastAPI
from views import users

app = FastAPI()

app.include_router(users.router, prefix="/users")

就到这里吧,简单写了下基本的请求响应和 Router 的使用,这是这个系列的第一篇,敬请期待后续。最后说一句,FastAPI 的官方文档非常详尽,值得一看。不过对于已经有 web 开发知识的人来说稍显啰嗦。

访问日志

FastAPI 默认的日志似乎有一些问题,不会显示出访问日志来

参考

FastAPI 官方文档

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(
        extra_http_headers={},
        ignore_https_erros=True,
        proxy={"server": "http://proxy.com"},
        user_agent="Mozilla/5.0...",
        viewport={"height": 1280, "width": 800}
    )  # 需要创建一个 context
    page = context.new_page()  # 创建一个新的页面
    page.goto("https://www.apple.com")
    print(page.content())
    webkit.close()

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

pw = playwright().start()
pw.webkit.launch()

Context 和 Page 没有提供 with 语句来确保关闭,可以使用 with closing 来实现,或者直接用 try…finally

from contextlib import closing

with closing(webkit.new_context()) as context:
    page = context.new_page()

context = webkit.new_context()
try:
    ...
finally:
    context.close()

新概念: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.keyboard.press("Z")
# 支持的按键名字
# F1 - F12, Digit0- Digit9, KeyA- KeyZ, Backquote, Minus, Equal, Backslash, Backspace, Tab, Delete, Escape, ArrowDown, End, Enter, Home, Insert, PageDown, PageUp, ArrowRight, ArrowUp
# 如果是单个字母,那么是大小写敏感的,比如 a 和 A 发送的是不同的按键
# 组合按键
page.keyboard.press("Shift+A")  # Shift, Ctrl, Meta, Alt, 其中 Meta 应该是相当于 Win/Cmd 键
# 其他的按键参考这里:https://playwright.dev/python/docs/api/class-keyboard

# 模拟输入
page.keyboard.type("hello")

在编写爬虫的过程中,向下滚动触发数据加载是个很常见的操作。但是如果想要向下滚动页面的话,似乎现在没有特别好的方法。本来我以为会有 page.scroll 这种方法的,然而并没有。在没有官方支持之前,暂时可以使用 page.keyboard.press("PageDown") 来向下滚动。

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

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 和自己定义的两种简单表达式,并且是自动识别的。

自动识别实际上是有一点点坑的,凡是 . 开头的都会被认为是 CSS 表达式,比如说 .close, .pull-right 等等,但是 .//a 这种也会被认为是 CSS 导致选择失败,所以使用 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 的参数默认是通配符,也可以传递编译好的正则表达式对象

# 1
context.route("**/*.{png,jpg,jpeg}", lambda route: route.abort())
# 2
context.route(re.compile(r"(\.png$)|(\.jpg$)"), lambda route: route.abort())
# 3
def no_static(route, req):
    if req.resource_type in {"image", "stylesheet", "media", "font"}:
        route.abort()
    else:
        route.continue_()
context.route("**/*", no_static)
page.goto("https://example.com")

其中 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. 支持丰富的选择表达式

快点用起来吧!

后记:实战采坑

使用浏览器类工具做爬虫的最大隐患就是内存爆了,所以一定要记得使用 with 语句来确保浏览器关掉了。

参考

  1. https://playwright.dev/python/docs/core-concepts
  2. https://www.checklyhq.com/learn/headless/request-interception/

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) {
  function handleSubmit(e) {
    e.preventDefault();
    const data = form2kv(e.target);
    axios.get("/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">
      <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-forms

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

使用 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 设置

参考

  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

放弃 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