一个 Cloudflare 上的爬虫

我第一次接触 Cloudflare 大概是在两年前,当时急切寻找一个能帮助我抵御对服务器 API DDoS 攻击的服务 —— 尽管我的应用流行度远远还没到被攻击的地步。当时,哪怕现在,国内的云服务商 DDoS 服务价格依旧昂贵,大部分服务器的带宽严重不足,DDoS 就像是悬在头顶的剑,让人寝食难安。

我对 Cloudflare 调研了一圈下来,非常兴奋:它不是一个 OpenStack,提供老套的虚拟化主机、路由器、IPv4 地址以及负载均衡的基础云平台,也不是在此基础上剽窃 PostgreSQL、Redis、Kafka、ElasticSearch 之类的流行中间件放在自己服务器售卖的 PaaS 服务商,比如 AWS,更不是一个传统提供 GitDevOps 服务的 SaaS 平台,就像 Vercel。如果说 OpenStack 提供以 Linux 生态为切面的服务,Vercel 提供的则是以 JavaScript 生态为切面的服务,那么 Cloudflare 提供的则是以 ECMAScript 作为操纵点,HTTP 作为接口的网络服务。OpenStack 会要求你把所有的操作系统全部迁移到虚拟机,Vercel 则想通过 JavaScript 生态来纳管你的所有前端、后端应用,而 Cloudflare 则只需要 "吞掉" 你的域名 DNS,自动的代理一切来往网络流量:映射、变换、重定向、缓存网络流量到任意位置的客户服务端点,从而利用全球最大的网络来加速任意位置的业务访问。从这一点来说,Cloudflare 是最适合作为服务提供商的生意,客户有足够的灵活性来决定在何处,何时,如何的将任意服务接入这个庞大的网络:它没有任何其他要求:不要求你使用任何特定的技术栈,也不要求你使用任何特定的服务端点。

那次调研之后,我灰心了很久,因为 Cloudflare 全球网络唯一绕开了中国大陆,这意味着对于源在国内、目标在国内的流量,接入 Cloudflare 仅仅是无谓的绕到美国西海岸一圈,平白增加了 10 倍的延迟。尽管如此,我还是接入了 Cloudflare,不过只是利用 DNS 和 Email 转发,没有代理任何网络流量,因为 DNS 缓存机制的存在,这对于实际域名解析没有什么太大的问题。

Cloudflare 基于纳管的 DNS 和代理的域名解析,在庞大的网络中解决了很多客户痛点,比如 HTTP 缓存、HTTPS 加密、DDoS 缓解、WAF 等,其中备受好评的则是 URL 重写,比如将 https://oldwebsite.com/path 重写为 https://www.newwebsite.com/old/path。可以说,Cloudflare 以 HTTP 为中心的服务为客户提供了跨时间的 URL 稳定性,这对于大型组织而言非常重要。

为了进一步提高对代理的功能支持,Cloudflare 提供了 Worker,这是一个 JavaScript 运行时,可以部署在 Cloudflare 的边缘网络中,并且可以访问全球部署的 KV 键值对, D1 RDM 数据库 和 R2 这个 S3 兼容存储。这意味着流量可以编程式的进行操纵,比如增添 HTTP 头,增加 CORS 支持,AB 测试,甚至可以部署一个简单的 API 网关,Worker 不限于通过 HTTP 触发,由于 Cloudflare 托管了 DNS 以及 Email,Worker 支持 CRON 以及 Email 触发器,并且具备在线调试能力。在此基础上,Cloudflare 提供了 Pages,一个前端项目 DevOps:全生命周期的编译、测试与部署服务,类似于 Vercel,支持通过 Functions 以访问 KV, D1 和 R2。

借助对网络的完全托管,Cloudflare 将客户的服务端点从传统的 SaaS 平台中解放出来,借助于“几乎”全球的网络,加速全球的消费者的访问。不管是简单 Pages 应用还是复杂的 API 网关,Cloudflare 都可以提供服务。对于客户而言,一切的资产都归属自身,脱离了 Cloudflare,代码还是代码,服务还是服务,只不过可能速度稍慢而已。

在国内,由于政策原因,Cloudflare 不面向普通用户开放,但对企业提供了基于京东云基础设施的支持。这未免是让人遗骸的事。不过替代办法也有:纯前端的项目可以用 Pages 或 Vercel 部署,然后通过阿里云等全站 CDN 服务来加速,这即利用了 Github 等 Git 代码托管商的 CI 能力,又利用了 Cloudflare 等 SaaS 服务商的部署、自动域名配置、流量监控和防攻击服务,且 CDN 能很好地加速访问,我的博客就是这样的例子。如果 Github 访问困难,那么搭建一个 Gitea,做 Github 仓库镜像是个好主意。另一种前端项目部署方案是利用阿里云等 OSS,OSS 可以作为简单的 Nginx 提供静态文件路由,也可以绑定 CNAME 域名,实现静态网站的访问,只需要将 CI 脚本中添加上传到 OSS 的逻辑即可。

对于 API,问题要严重得多 —— 因为其要求实时性,所以缓存是很困难的。传统的方案是 Nginx 或 OpenResty 反向代理应用服务器从而隐藏 IP 地址,只暴露网关。当然,国内的这些云平台也都有 API 网关产品售卖,其能实现负载均衡、AB 测试、灰度发布等用途。此外,可以在自己的应用中自行通过 HTTP Header 缓存、Redis 缓存、迁移到 JDK 21 使用虚拟线程来降低 DDoS 和大规模流量的峰值,保证服务可用性。

总而言之,虽然替代方案很多,但这些方案都损失了应用的简单性,对于那些初学者、希望快速解决手头问题的开发者而言,要在多个云平台创建账号、实名认证账户、购买各种服务,忍受垃圾电子邮件、短信(腾讯云)以及销售代表(阿里云)的轰炸,费劲的搭建反向代理,配置 DNS、实名认证域名、公安认证网站、甚至还有小程序审核和认证,这些足以让很多人望而却步,让问题离解决更远,更难。

至于本文的题目:一个 Cloudflare 上的爬虫,这并不是什么隐喻,而是昨天晚上,庆余年 2 更新大结局的时候,我希望自己能立刻从资源站得到网盘更新链接写的几行简单程序。为此,我不需要在自己的服务中新建一个定时任务,不需要重新测试和部署我的服务到服务器,而只需要几个命令:

bun create cloudflare qyn
bunx wrangler kv:namespace create YOUR_NS
cd qyn
cat << EOF > worker-configuration.d.ts
interface Env {
	KV: KVNamespace;
}
EOF
cat << EOF > src/index.ts
export default {
    async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
        console.log(`reading from mini4k`);
        const r = await fetch("https://www.mini4k.com/torrents/522416");
        const html = await r.text();
        const result = html.match(/第\s(\d+)\s集/g)?.[0];
        if (await env.KV.get("qyn") != result) {
            await env.KV.put("qyn", result as string);
            await this.notice(`庆余年已更新到 ${result}`,
                await env.KV.get("token") ?? "",
                await env.KV.get("endpoint") ?? "",
                "C3");
        }
    },
    async notice(msg: string,
        token: string,
        endpoint: string,
        from = "UNKNOWN") {
        const res = await (await fetch(endpoint, {
            method: "POST",
            headers: {
                "Content-Type": "application/json; charset=utf-8",
                "Authorization": `Basic ${token}`
            },
            body: JSON.stringify({
                "from": from,
                "channel": "SERVER",
                "message": msg
            })
        })).json();
        console.log(res);
    }
};
EOF
bun dev -r
bun deploy

通过几分钟的编码,一个健壮的爬虫就立刻被部署在全球边缘网络中,并且实现自己的使命。

如下是我的 Slack 接收到的通知:

当然,定时任务的可玩性很多,下面是另外一个例子:通过定时获取报价,报价变动发送通知,并且提供了一个 HTTP 接口获取现在的报价。

async fetch(evnet, env, ctx) {
  const data = await env.db.get("dongchedi")
  const raw = await env.db.get("dongchedi-raw")
  return new Response(JSON.stringify({message: data, raw: JSON.parse(raw)}), 
                      {headers: {"Content-Type": "application/json"}});
},
async scheduled(event, env, ctx) {
  const url = "https://www.dongchedi.com/motor/pc/car/series/car_dealer_price?aid=1839&app_name=auto_web_pc&car_ids=93835&city_name=武汉";
  const data = await (await fetch(url)).json();
  const map = data.data[Object.keys(data.data)[0]];
  const res = `${map.series_name}${map.car_name} 售价${map.official_price}, 优惠${map.cut_price}, 报价${map.dealer_price}`;
  if (await env.db.get("dongchedi") != res) {
    await env.db.put("dongchedi", res);
    await env.db.put("dongchedi-raw", JSON.stringify(map));
    await this.notice(res, await env.db.get("token"), "DONGCHEDI");
  }
},