MoreRSS

site iconDiygod修改

RSSHub、xLog的作者。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Diygod的 RSS 预览

一个六岁开源项目的崩溃与新生

2024-03-11 03:29:05

我有一个维护了六年的开源项目 —— RSSHub,它正在面临崩溃

背景

表面上,它有接近 30k Stars、900 多 Contributors、每月 3 亿多次请求和数不清的用户、每月几十刀的赞助、有源源不断的 issue 和 pr、代码几乎每天更新,非常健康和充满活力,但在不可见的地方,持续数年高昂的维护时间成本、每月一千多刀的服务器费用、每天重复繁琐且逐渐积累的维护工作,都让它在崩溃的边缘反复横跳

项目是六年前开发的,不少当时以 Next Generation 为口号的时髦 Node.js 技术栈和依赖库已经成为时代眼泪,现在看非常陈旧,很多现在流行的新技术没法应用,比如 JSX、TypeScript、Serverless 等;它的架构也非常不合理,每个路由的信息散落在多个地方,开发或者变更一个路由需要多处修改,一个地方去注册路由,一个地方去编写路由脚本,一个地方去编写 Radar 规则,一个地方去编写文档...... 这增加了很多工作量,也很容易出错,之前路由少的时候并不是个问题,但现在已经变得难以忍受

在如此糟糕的基础架构下能保持现状已经是竭尽全力,开发新功能更是无本之木,只会增加以后更新的难度,所以我有时候脑子蹦出的新奇想法也很难实现

要解决这些问题,唯一的办法是使用现代化的框架和新设计的架构来重写内核,但随着路由越来越多,改造成本也越来越高,每个基础改动可能都需要多达数月的工作量,所以虽然问题越来越严重,但秉承着又不是不能用的原则一拖再拖

但这又是不得不做的事情,所以我抽空花了几个月的时间重新设计和重写了它

技术栈更新

koa -> Hono

第一步也是最基础和难度最大的是换掉之前使用的 Web 框架 koa,作为六年前流行的下一代 Web 框架,作者早就弃坑了,调研之后决定换用对 JSX、TypeScript、Serverless 支持最好的 Hono

它们的 API 差异很大,需要重写所有中间件和替换所有路由中使用的 koa API

主要改动:
https://github.com/DIYgod/RSSHub/pull/14295

image

Hono 作者也很喜欢这个改造

JavaScript -> TypeScript

改用 TypeScript 可以避免很多类型问题和低级错误,最重要的是可以保证数百名贡献者保持一致难以出错和后续贡献的路由代码质量不至于太糟糕

主要改动:

image

CommonJS -> ESM

ESM 是几年前一些 Node.js 核心开发者强推的规范,它有一些优点,但最多的是与之前 CommonJS 不兼容带来的生态割裂和功能简化带来的诟病

经过这几年的发展,现在可以说大部分场景勉强可用了,tsx 也为 CommonJS 和 ESM 混用的场景提供了支持

虽然已经尽了最大努力,但还是有一些 CommonJS 代码暂时难以迁移,导致现在只能使用 tsx 运行,与一些 Serverless 比如 Vercel 没法兼容,但也有机会后续慢慢解决

主要改动:

image

image

art-template -> JSX

art-template 是一个支持 koa 的模板引擎,记得六年前还有一个更流行的模板引擎,但是不记得名字了,选用 art-template 是因为那个更流行的我当时没看懂,这个很简单

Hono 自带了 JSX 支持,JSX 就不用多介绍了,根正苗红的 JavaScript 的语法扩展,等同于用 React

主要改动:

Jest -> Vitest

Jest 是曾经流行的测试框架,但是在 ESM 时代到来之后就越来越不行了,对 ESM 的支持一直是实现性「experimental support」,现在更流行的是 Vitest 了

主要改动:
https://github.com/DIYgod/RSSHub/commit/38e42156a0622a2cd09f328d2d60623813b8df28

Got -> ?

目前使用的 Got 也已经是不积极维护的状态了,也没有找到好的替代品,后续也许会换成原生 Fetch 或者自封装的 Fetch,还没有动手

新路由标准

我自己能力还是不够的,在与社区开发者们讨论的过程中学习和改进了很多,过程很有意思:https://github.com/DIYgod/RSSHub/issues/14685

主要改动:
https://github.com/DIYgod/RSSHub/pull/14718

image

历史

新标准主要为了解决路由信息过于分散的问题,这次应该算第三版

第一版来自 RSSHub 开发阶段,当时没有预见到路由数量会有这么多,所以几乎没什么规划,所有路由在同一个文件中注册,然后再去增加路由脚本和文档,后来这个文件越来越大,很容易冲突,另外所有路由脚本都会在启动阶段被加载,程序性能越来越差

第二版来自 NeverBehave 维护的时期,引入了命名空间,切割了 router.js、radar.js,同命名空间的路由集中在了一个同文件夹中和一个或多个 Markdown 文档中,还实现了懒加载,极大提升了可维护性和性能,但还是会分散在多个文件中,不同文件的信息也容易出现不一致导致错误

现在

本次把路由文件分为了两类,namespace.ts 和任意名字的路由文件

namespace.ts 会通过导出名为 namespace 的对象来定义命名空间的信息

import type { Namespace } from '@/types';

export const namespace: Namespace = {
    // ...
};

namespace 包含的字段通过 TypeScript 限制为

interface Namespace {
    name: string;
    url?: string;
    categories?: string[];
    description?: string;
}

这些信息会经过编译后被文档和 RSSHub Radar 利用

路由文件会通过导出名为 route 的对象来定义路由的信息

import { Route } from '@/types';

export const route: Route = {
    // ...
};

route 包含的字段通过 TypeScript 限制为

interface Route {
    path: string | string[];
    name: string;
    url?: string;
    maintainers: string[];
    handler: (ctx: Context) => Promise<Data> | Data;
    example: string;
    parameters?: Record<string, string>;
    description?: string;
    categories?: string[];

    features: {
        requireConfig?: string[] | false;
        requirePuppeteer?: boolean;
        antiCrawler?: boolean;
        supportRadar?: boolean;
        supportBT?: boolean;
        supportPodcast?: boolean;
        supportScihub?: boolean;
    };
    radar?: {
        source: string[];
        target?: string;
    };
}

之前 route.js mantainer.js radar.js 和文档的信息都被集中在这一个文件中,减少了多处定义也减少了出错的可能

实现

实现逻辑就是开发环境通过遍历整个 route 文件夹,找到所有 namespace.ts 和路由文件,读取信息,加载路由,在生成环境使用提前编译好的路径列表来避免遍历和不必要的加载过程,代码在:https://github.com/DIYgod/RSSHub/blob/master/lib/registry.ts

文档也是通过遍历 route 文件夹,找到所有需要的信息然后合成一系列的 Markdown 文件,不再需要手动维护,代码在:https://github.com/DIYgod/RSSHub/blob/master/scripts/workflow/build-routes.ts

当然使用之前路由标准开发的路由都需要迁移到新标准而不是直接放弃掉,已经通过脚本批量抓取整理信息后做了替换,但特别是文档比较混乱也有很多错误,所以抓取的信息也有很多错误,只能在后续逐渐人工修改了

未来

通过这一系列改进,RSSHub 终于能够扔掉历史包袱,安心开发新功能了,这里列出我积累的一些想法抛砖引玉:

  • 既然 RSSHub 是一个数据集合,用途不一定只有 RSS,JSON 输出功能可以做一些增强,作为通用的 RESTful API 来使用,比如可以提供获取下一页接口或者输出类似 Twitter 关注数的非 feed 数据
  • 用户系统和用户自定义配置,生成自己的私有订阅地址 #14706
  • 路由错误通知和健康度检测 #14712
  • 与 RSS3 节点的联动和加密货币收益共享 https://twitter.com/rss3_/status/1731822029199094012
  • AI 翻译和摘要
  • 更详细的实例数据分析及反向推导自动推荐的 Radar 规则
  • 与本地浏览器或客户端绑定的 RSSHub 实例,有希望真正解决反爬难题
  • ...

最后,开源是一件很昂贵的事情,RSSHub 能活到现在离不开这些开发者的帮助

以及这些赞助的好心人

如果 RSSHub 正在帮助你,也希望你可以积极参与进来,为信息自由的未来贡献一份自己的微小力量

和帕鲁生活在一起的两周

2024-02-19 03:20:33

宝可梦幻想

image

哪个小男孩没幻想过生活在这样一个充满宝可梦伙伴的世界,它们能听懂且愿意倾听我们说话,有着不同的特点和强大的技能,而且都很可爱,我们可以跟它们睡觉吃饭、并肩作战探索世界,永远不再孤独,不管是谁都可以找到最适合自己的宝可梦

这一切早就可以发生,但这一切终究没有发生,宝可梦复杂的版权问题和躺平在功劳簿上的版权公司 Game Freak 让我们的幻想一直也仅仅是幻想

当然也有人说最近的几个新作阿尔宙斯、朱紫比之前好多了,“虽然好多了,但只是臭味稀薄一点的狗屎罢了”,剑盾、阿尔宙斯、朱紫、Pokemon Sleep 游戏时长并不短的宝可梦玩家 DIYgod 义愤填膺地说道

帕鲁实现

image

但只要幻想一直存在,变成现实也只是时间问题,横空出世的幻兽帕鲁给所有宝可梦粉丝带来了一个一本满足的平行幻想世界,终于苦于没有 Windows 的我也专门买了一台 ROG Ally 来沉迷其中

游戏中与帕鲁的互动是前无仅有的,衣食住行战斗探索的过程都与帕鲁深度融入,真的是和帕鲁生活在一起

从刚落地进入初始台地互动就开始了,我们的第一个落脚点不是自己造的,而是跟捣蛋猫一起造的

image

image

再到后来帕鲁包办我们的一切后勤,我们的每一顿大餐,都需要经历播种、浇水、收割、搬运、存储、烹饪,分别由有着不同特长和分工的帕鲁来完成,会制冷的企丸丸去运行冰箱,会喷火的燎火鹿去烹饪

image

在我们外出探索时,过河骑滑水蛇,赶路骑云海鹿,翻山越岭骑烽歌龙

image

在我们战斗时,举着火绒狐充当、喷火器,骑着暴电熊扫射机关枪,甚至虽然不太人道,可以把电棘鼠当作手榴弹扔出去,同时据点里的焰煌在帮我们炼钢造枪,阿努比斯在帮我们手搓子弹

image

从徒手撸树到机关枪横着走,从无人问津到人满为患,在不知不觉的建造中我已经在帕鲁世界中倾注了太多的感情

从饿着肚子的第一天

Pal   6_2_2024 下午 9_54_32

到现在

Pal   19_2_2024 上午 2_45_40

Pal   18_2_2024 上午 12_42_29

一些额外的探索

数据展示

image

毕竟是单机游戏,数据都在本地,可以很方便地做处理

有一个开源项目可以把存档文件解析成 JSON 方便第三方开发和展示 https://github.com/cheahjs/palworld-save-tools

还有一个开源项目可以把 JSON 再生成一个前端页面 https://github.com/zaigie/palworld-server-tool

于是我把存档同步到 NAS 上,再在 NAS 上部署一个展示页面,就可以很方便地查看我的帕鲁们了 https://pal.diygod.me

模组

帕鲁有很丰富的社区模组,比如把皮皮鸡变成坤或者把帕鲁变成宝可梦什么的,还有一些可以在不影响游戏平衡的前提下极大提升游戏体验

我用的主要来自一个模组社区 Nexus Mods,他们有一个很方便的模组安装和管理工具 Vortex Mod Manager,以下是我正在使用的模组

  • Basic MiniMap:增加一个不知道为什么官方没做的感觉很基础的小地图功能,但是记得到设置里把旋转关掉

  • Better Night Light:晚上太眼瞎了,啥都看不见,像得了夜盲症一样,用这个模组可以变亮一些,还改了一个绝美的星空

    Pal   18_2_2024 下午 8_05_05

  • Golden Statue of Power:把力量石像变得金灿灿的更有质感

    Pal   18_2_2024 下午 9_06_28

  • Play as ZoeHide All Human Hair All female Head All HeadEquip:隐藏掉原有的装扮,自己变成老婆佐伊

  • Paimon Replace DaeDream:把寐魔变成派蒙

    Pal   19_2_2024 上午 2_09_33

  • Pal Analyzer:捕捉前就可以看到帕鲁的属性和特质,掌机需要到设置里改下显示为 always on

    Pal   18_2_2024 下午 7_45_54

  • Stuck Pal Rescuer:救命模组,据点里的帕鲁经常被 bug 卡住然后饿死,这个模组可以自动检测帕鲁是否卡住并重置

  • FSR3 支持:官方不支持 A 卡的帧生成,用上这个 Roy Ally 可以在中等画质从 40 多帧提升到 70 多帧

Twitter 对开源项目发起 DDoS 攻击

2024-01-28 18:24:53

背景

Twitter 被马斯克收购后,从去年 8 月开始,他们对开源第三方集成和第三方客户端进行了一系列明里暗里的打压和攻击,这样做是为了阻止用户通过非官方客户端访问和使用 Twitter,来增加公司的广告和会员营收

而开源社区中以 Nitter 和 RSSHub 为代表的开源项目并没有放弃向信息自由的努力,通过众多聪明的开源开发者们想出来的一个个奇妙操作(issue),在一轮轮封锁和反封锁的对抗中短暂占了上风,其中最流行的做法是通过 Android 客户端使用的接口功能生成临时账号(细节

经过

在两天前(1 月 26 日),许多 Nitter 实例的运行者和开发者报告称,他们正在使用的接口已被封锁。与此同时,他们的实例也开始遭受报复性 DDoS 攻击

image

image

起初,我并没有太在意这件事情。毕竟,谁会相信 Twitter 官方会做出如此令人不耻且自降身段的 DDoS 行为呢?我对此深感怀疑

然而,昨天当我打开 RSSHub 的 GitHub 仓库时,却意外地发现了以下内容

image

最近一个月的请求数达到了 4.5 亿,比正常水平高出 50%(正常水平仅为 3 亿多)

然后登录 Cloudflare 查看日志

image

自从 26 日 0 点(当天官方接口被封锁和 Nitter 遭受 DDoS 攻击)以来,RSSHub 也一直遭受大规模的 DDoS 攻击。最近两天,请求量是平时的 170 多倍,每秒约 1 千次请求

尽管数量看起来可怕,但 Cloudflare 出色的缓存功能已经成功缓存了超过 99% 的 DDoS 请求,甚至没有触发报警

image

RSSHub 的负载均衡和自动扩容功能非常完善,没有受到太大的压力

🤣 就这样一直没有发现

进一步分析发现,所有请求都来自 IP 地址为 139.255.221.98 的设备。这些请求都是针对 /twitter/keyword 路由,并且后面跟着一串不同且无意义的参数

image

我很清楚为什么只针对 keyword 路由,尽管代码中没有明确表达,但根据我的使用经验,此路由使用的搜索接口受到最严格的访问频率限制,通过攻击该路由可以实现最大化效果。由此可以推断出 DDoS 攻击者也对 Twitter 的接口非常熟悉

虽然无法直接证明是官方人员所为,但各种无法解释的 “巧合” 已经清楚地表明了事情的真相,马斯克的简单粗暴行事风格也正在深刻影响这家公司

影响

进一步封锁 API 和进行 DDoS 攻击这两个操作可以说非常有效

Nitter 开发者 zedeus 表示 Nitter 已死

image

Twitter Monitor 开发者 MANKA 表示不愿意再浪费时间

nitter-status 开发者更是直接放出了告别页面

image

看起来就到此为止了吗?不,这远非终点。自由无法被阻挡,我们还有很多事情可以做

image

29 日 更新:RSSHub 已经恢复

如何优雅编译一个 Markdown 文档

2024-01-18 20:50:37

Markdown 是一种广泛使用的轻量级标记语言,允许人们使用易读易写的纯文本格式编写文档,也是 xLog 主要使用的文章格式,本文就以 xLog Flavored Markdown 为例来说明如何优雅地解析一个 Markdown 文档

架构

解析过程可以用这样一个架构来表示:

flowchart TB subgraph input Markdown end subgraph unified subgraph remark Markdown:::inputClass --string--> remark-parse:::remarkClass --mdast--> remarkPlugins[remark plugins]:::remarkClass remarkPlugins --mdast--> remark-rehype:::remarkClass & mdast-util-toc:::remarkClass end subgraph rehype remark-rehype --hast--> rehypePlugins[rehype plugins]:::rehypeClass rehypePlugins --hast--> hast-util-to-text:::rehypeClass & hast-util-to-html:::rehypeClass & hast-util-to-jsx-runtime:::rehypeClass end rehypePlugins --hast--> unist-util-visit:::rehypeClass end subgraph output mdast-util-toc --tocResult--> TOC:::inputClass hast-util-to-text --string--> plainText[Plain Text]:::inputClass hast-util-to-html --string--> HTML:::inputClass hast-util-to-jsx-runtime --JSX.Element--> ReactElement[React Element]:::inputClass unist-util-visit --custom--> Metadata:::inputClass end style input fill:#bbf7d0,stroke:#4ade80,color:#15803d classDef inputClass fill:#22c55e,stroke:#16a34a style output fill:#bbf7d0,stroke:#4ade80,color:#15803d style unified fill:#bfdbfe,stroke:#60a5fa,color:#1d4ed8 style remark fill:#fecaca,stroke:#f87171,color:#b91c1c classDef remarkClass fill:#f87171,stroke:#dc2626 style rehype fill:#fef08a,stroke:##facc15,color:#a16207 classDef rehypeClass fill:#facc15,stroke:#ca8a04

关键概念:

  • unified:通过语法树和插件来解析、检查、转换和序列化内容的库
  • remark:unified 的生态项目之一,由插件驱动的 Markdown 处理库
  • rehype:unified 的生态项目之一,由插件驱动的 HTML 处理库
  • mdast:remark 使用的用于表示 Markdown 的抽象语法树规范
  • hast:rehype 使用的用于表示 HTML 的抽象语法树规范

简单来说就是把 Markdown 文档交给一个 unified 生态的解析器解析成 unified 可识别的语法树,再通过一系列 unified 生态的插件转换为需要的内容,再通过一系列 unified 生态的工具库输出为需要的格式,下面就从 解析、转换、输出 这三个步骤来分别说明

解析 Parse

flowchart TB subgraph input Markdown end subgraph unified subgraph remark Markdown:::inputClass --string--> remark-parse:::remarkClass end end style input fill:#bbf7d0,stroke:#4ade80,color:#15803d classDef inputClass fill:#22c55e,stroke:#16a34a style unified fill:#bfdbfe,stroke:#60a5fa,color:#1d4ed8 style remark fill:#fecaca,stroke:#f87171,color:#b91c1c classDef remarkClass fill:#f87171,stroke:#dc2626

无论输入是 Markdown、HTML 还是纯文本,都需要将其解析为可操作的格式。这种格式被称为语法树。规范(例如 mdast)定义了这样一个语法树的外观。处理器(如 mdast 的 remark)负责创建它们。

最简单的一步,我们需要解析的是 Markdown,所以这里就应该使用 remark-parse 来把 Markdown 文档编译成 mdast 格式的语法树

对应 xLog Flavored Markdown 中的

const processor = unified().use(remarkParse)

const file = new VFile(content)
const mdastTree = processor.parse(file)

转换 Transform

flowchart TB subgraph remark remark-parse:::remarkClass --mdast--> remarkPlugins[remark plugins]:::remarkClass remarkPlugins --mdast--> remark-rehype:::remarkClass end subgraph rehype remark-rehype --hast--> rehypePlugins[rehype plugins]:::rehypeClass end style remark fill:#fecaca,stroke:#f87171,color:#b91c1c classDef remarkClass fill:#f87171,stroke:#dc2626 style rehype fill:#fef08a,stroke:##facc15,color:#a16207 classDef rehypeClass fill:#facc15,stroke:#ca8a04

这就是魔法发生的地方。用户组合插件以及它们运行的顺序。插件在此阶段插入并转换和检查它们获得的格式。

这一步最为关键,不仅包含了从 Markdown 到 HTML 的转换,还包含我们想在编译过程中夹带的私货,比如增加一些非标准的语法糖、清理 HTML 防止 XSS、增加语法高亮、嵌入自定义组件等

unified 的插件非常多,更新也比较及时,基本需求几乎都能满足,对于不能满足的特定需求,自己编写转换脚本也很容易实现

里面有一个特殊的插件是 remark-rehype,它会把 mdast 语法树转为 hast 语法树,所以在它之前必须使用处理 Markdown 的 remark 插件,在它之后必须使用处理 HTML 的 rehype 插件

xLog Flavored Markdown 中就加入了非常多的转换插件

const processor = unified()
  .use(remarkParse)
  .use(remarkGithubAlerts)
  .use(remarkBreaks)
  .use(remarkFrontmatter, ["yaml"])
  .use(remarkGfm, {
    singleTilde: false,
  })
  .use(remarkDirective)
  .use(remarkDirectiveRehype)
  .use(remarkCalloutDirectives)
  .use(remarkYoutube)
  .use(remarkMath, {
    singleDollarTextMath: false,
  })
  .use(remarkPangu)
  .use(emoji)
  .use(remarkRehype, { allowDangerousHtml: true })
  .use(rehypeRaw)
  .use(rehypeIpfs)
  .use(rehypeSlug)
  .use(rehypeAutolinkHeadings, {
    behavior: "append",
    properties: {
      className: "xlog-anchor",
      ariaHidden: true,
      tabIndex: -1,
    },
    content(node) {
      return [
        {
          type: "text",
          value: "#",
        },
      ]
    },
  })
  .use(rehypeSanitize, strictMode ? undefined : sanitizeScheme)
  .use(rehypeTable)
  .use(rehypeExternalLink)
  .use(rehypeMermaid)
  .use(rehypeWrapCode)
  .use(rehypeInferDescriptionMeta)
  .use(rehypeEmbed, {
    transformers,
  })
  .use(rehypeRemoveH1)
  .use(rehypePrism, {
    ignoreMissing: true,
    showLineNumbers: true,
  })
  .use(rehypeKatex, {
    strict: false,
  })
  .use(rehypeMention)

const hastTree = pipeline.runSync(mdastTree, file)

下面介绍部分用到的插件

  • remarkGithubAlerts:增加 GitHub 风格的 Alerts 语法,演示
  • remarkBreaks:不再需要空一行才能被识别为新的自然段
  • remarkFrontmatter:支持前置内容(YAML、TOML 等)
  • remarkGfm:支持非标准的 GitHub 在原版 Markdown 语法上扩展的一系列语法(但其实这系列语法已经被非常广泛使用,成为了事实意义上的标准)
  • remarkDirective remarkDirectiveRehyp:支持非标准的 Markdown 通用指令提案
  • remarkMath rehypeKatex:支持复杂的数学公式,演示
  • rehypeRaw:支持 Markdown 中夹杂的自定义 HTML
  • rehypeIpfs:自定义插件,为图片、音频、视频支持 ipfs:// 协议的地址
  • rehypeSlug:为标题添加 id
  • rehypeAutolinkHeadings:为标题添加指向自身的链接 rel = "noopener noreferrer"
  • rehypeSanitize:清理 HTML,用于确保 HTML 安全避免 XSS 攻击
  • rehypeExternalLink:自定义插件,给外部链接添加 target="_blank"rel="noopener noreferrer"
  • rehypeMermaid:自定义插件,渲染绘图和制表工具 Mermaid,本文的架构图就是通过 Mermaid 渲染的
  • rehypeInferDescriptionMeta:用于自动生成文档的描述
  • rehypeEmbed:自定义插件,用于根据链接自动嵌入 YouTube、Twitter、GitHub 等卡片
  • rehypeRemoveH1:自定义插件,用于把 h1 转为 h2
  • rehypePrism:支持语法高亮
  • rehypeMention:自定义插件,支持 @DIYgod 这样艾特其他 xLog 用户

输出 Stringify

flowchart TB subgraph unified subgraph remark remarkPlugins[remark plugins]:::remarkClass --mdast--> mdast-util-toc:::remarkClass end subgraph rehype rehypePlugins[rehype plugins]:::rehypeClass rehypePlugins --hast--> hast-util-to-text:::rehypeClass & hast-util-to-html:::rehypeClass & hast-util-to-jsx-runtime:::rehypeClass end rehypePlugins --hast--> unist-util-visit:::rehypeClass end subgraph output mdast-util-toc --tocResult--> TOC:::inputClass hast-util-to-text --string--> plainText[Plain Text]:::inputClass hast-util-to-html --string--> HTML:::inputClass hast-util-to-jsx-runtime --JSX.Element--> ReactElement[React Element]:::inputClass unist-util-visit --custom--> Metadata:::inputClass end classDef inputClass fill:#22c55e,stroke:#16a34a style output fill:#bbf7d0,stroke:#4ade80,color:#15803d style unified fill:#bfdbfe,stroke:#60a5fa,color:#1d4ed8 style remark fill:#fecaca,stroke:#f87171,color:#b91c1c classDef remarkClass fill:#f87171,stroke:#dc2626 style rehype fill:#fef08a,stroke:##facc15,color:#a16207 classDef rehypeClass fill:#facc15,stroke:#ca8a04

最后一步是将(调整后的)格式转换为 Markdown、HTML 或纯文本(可能与输入格式不同!)

unified 的工具库也很多,可以输出各种我们需要的格式

比如 xLog 需要在文章右侧展示自动生成的目录、需要输出纯文本来计算预估阅读时间和生成 AI 摘要、需要生成 HTML 来给 RSS 使用、需要生成 React Element 来渲染到页面、需要提取文章的图片和描述来展示文章卡片,就分别使用了 mdast-util-toc、hast-util-to-text、hast-util-to-html、hast-util-to-jsx-runtime、unist-util-visit 这些工具

对应 xLog Flavored Markdown 中的

{
  toToc: () =>
    mdastTree &&
    toc(mdastTree, {
      tight: true,
      ordered: true,
    }),
  toHTML: () => hastTree && toHtml(hastTree),
  toElement: () =>
    hastTree &&
    toJsxRuntime(hastTree, {
      Fragment,
      components: {
        // @ts-expect-error
        img: AdvancedImage,
        mention: Mention,
        mermaid: Mermaid,
        // @ts-expect-error
        audio: APlayer,
        // @ts-expect-error
        video: DPlayer,
        tweet: Tweet,
        "github-repo": GithubRepo,
        "xlog-post": XLogPost,
        // @ts-expect-error
        style: Style,
      },
      ignoreInvalidStyle: true,
      jsx,
      jsxs,
      passNode: true,
    }),
  toMetadata: () => {
    let metadata = {
      frontMatter: undefined,
      images: [],
      audio: undefined,
      excerpt: undefined,
    } as {
      frontMatter?: Record<string, any>
      images: string[]
      audio?: string
      excerpt?: string
    }

    metadata.excerpt = file.data.meta?.description || undefined

    if (mdastTree) {
      visit(mdastTree, (node, index, parent) => {
        if (node.type === "yaml") {
          metadata.frontMatter = jsYaml.load(node.value) as Record<
            string,
            any
          >
        }
      })
    }
    if (hastTree) {
      visit(hastTree, (node, index, parent) => {
        if (node.type === "element") {
          if (
            node.tagName === "img" &&
            typeof node.properties.src === "string"
          ) {
            metadata.images.push(node.properties.src)
          }
          if (node.tagName === "audio") {
            if (typeof node.properties.cover === "string") {
              metadata.images.push(node.properties.cover)
            }
            if (!metadata.audio && typeof node.properties.src === "string") {
              metadata.audio = node.properties.src
            }
          }
        }
      })
    }

    return metadata
  },
}

这样我们就优雅地从原始 Markdown 文档开始,获得了我们需要的各种格式的输出

除此之外,我们还能利用解析出的 unified 语法树来编写一个可以左右同步滚动和实时预览的 Markdown 编辑器,可以参考 xLog 的双栏 Markdown 编辑器(代码),有机会我们下次再聊

2023 平稳过渡

2024-01-08 22:23:18

</2023>
<2024>

发小觉得今年经历了巨大的变化,从苏格兰搬到新加坡,从未婚到已婚,从一家两口变成了一家三口,但我觉得都在预料之中,所以感觉还算很平稳

身边的物品最能反映一个人的状态,所以还是跟 2022 一样从年度最佳开始

🏆 年度最佳

🍿 影视

看过的 28 部动漫和 12 部剧集和 8 部电影:https://movie.douban.com/people/62759792/collect?mode=list

而去年是 19 部动漫和 18 部剧集和 6 部电影,二次元含量显著上升了

DIY 年度番剧: 《鬼灭之刃 锻刀村篇》

image

尽管有数集节奏过缓,然而该剧的燃点皆燃,泪点亦催人泪目,尤以最后以为会是虐人的剧情却华丽逆转为甜蜜结局,令人心潮澎湃,难以自抑

去年最佳是《鬼灭之刃 游郭篇》,今年是《鬼灭之刃 锻刀村篇》,已经可以预料到了明年是《鬼灭之刃 柱训练篇》,希望可以保持下去

除此之外还有《天国大魔境》、《间谍过家家》、《【我推的孩子】》(第一集)、《为美好的世界献上爆焰!》也非常喜欢

DIY 年度剧集: 《最后生还者 第一季》

image

非常还原游戏,又胜于游戏,主角也非常契合角色,完美的一季,HBO 太强了

📚 图书

album_temp_WeRead_2023_Year_In_Review_11704021416

今年看过的 22 本书:

  • 《被讨厌的勇气》
  • 《长安的荔枝》
  • 《那些古怪又让人忧心的问题》
  • 《上帝掷骰子吗》
  • 《人类灭绝》
  • 《来自新世界》 (两本)
  • 《莱博维茨的赞歌》
  • 《赡养人类》
  • 《朝闻道》
  • 《埃隆・马斯克传》
  • 《巨人的陨落》(三本)
  • 《世界的凛冬》(三本)
  • 《翦商》(在读)
  • 《自私的基因》(在读)
  • 《一想到还有 95% 的问题留给人类,我就放心了》(在读)
  • 《猫咪家庭医学大百科》(在读)
  • 《未来简史》(在读)

去年是 9 本,没想到今年不知不觉看了这么多,大部分是旅游和 Boox Leaf 3 的功劳

DIY 年度图书:《人类灭绝》

image

差点被奇怪的标题和封面错过,是我看过最悬疑、血腥、脑洞的一本书,作者从三条看似不相关的线分别展开,最后再完美融合,揭露一个最大的阴谋

如果有一天智人不再是地球最聪明的物种,智人一定会毫不犹豫暴露出残忍丛林天性,不一定是因为书里假设的超人类新物种的出现,AI 的技术爆炸也许会让这一天马上到来

非常期待有一天被拍成电视剧

排名第二的是 《上帝掷骰子吗》

读到了很多难以置信的物理知识和猜想,比如空间和时间无法无限分割下去,它们不是连续的;所有观测者死掉的世界都没意义,所以观测者只会一直活下来,无法自杀,无法死亡,所有人都会量子永生;世界是不确定的,世界的底色不是因果律而是概率,上帝是掷骰子的。简直比科幻还要魔幻

排名第三的是《巨人的陨落》

世纪三部曲中的第一部,是那种开头比较难读下去但是一旦投入进去就无法自拔的书,学到了很多欧洲历史看到了时代变迁

🕹️ 好物

DIY 年度好物: Boox Leaf 3

image

第一次用电纸书,续航足够长,刷新速度也很快,内置墨水屏版微信读书,让我多看了很多书,缺点就是长时间拿着有点硌小拇指,还好买了 Randy 推荐的手机气囊支架解决了

其他还有一些值得推荐的小东西

Followcat 猫咪项圈

image

可以配套一个 AirTag 壳装入 AirTag 来定位猫咪,不仅可以预防出门跑丢,在家里不知道藏在哪里的时候也更好找了

博士眼镜

IMG_5716

线下配眼镜要几千块,一直让我觉得眼镜是个很珍贵的物品,平时需要小心翼翼地保护,但发现差不多的蔡司镜片加镜框线上只要 200 多,差价很夸张,实际使用也感觉不出什么区别

索尼 ZV1 M2 Twitter

image

从第一代 ZV1 换到了第二代,虽然画质没什么升级,但解决了很多痛点,使用体验好了非常多,包括人类可以使用的新版菜单、更适合 vlog 的 18mm 焦距、内置指向性麦克风、视频拍照切换实体按键、USB-C 等

👩🏻‍💻 应用

DIY 年度应用:Arc

image

竖向标签栏、工作区用了就回不去,而且并不是简单地把标签栏竖过来,很多交互细节还是挺用心的

另外分享一些自己在使用的喜欢的应用

  • 邮件客户端:Mimestream
  • 浏览器:Arc
  • 命令行:Warp
  • Docker 管理:OrbStack
  • 时间追踪:Toggl Track
  • 笔记:Obsidian
  • RSS 阅读:RSSHub、Reeder、Miniflux、RSS to Telegram Bot、RSSHub Radar
  • 待办事项:TickTick
  • 图片管理:Eagle
  • 记账:MoneyWiz
  • 影音:Plex、Alist、AutoBangumi、UnblockNeteaseMusic、Xiaoya
  • 自动化:n8n
  • 网站统计:Umami
  • 加密钱包:OneKey、MetaMask
  • 翻译:Bob、沉浸式翻译
  • 广告拦截:AdGuard Home、AdGuard for Mac
  • 截图:Snip
  • 休息提醒:BreakTimer
  • 足迹:世界迷雾
  • 睡眠记录:Pokemon Sleep
  • 应用管理:Homebrew Cask

🎮 游戏

DIY 年度游戏:《塞尔达传说:王国之泪》

image

今年玩的唯一的游戏

🧸 生活

🏡 饮食居住

11 月从贫瘠的苏格兰来到了生活便利的新加坡,生活质量还是高了一大大大截

  • 饮食:跟英国比新加坡简直是天堂,终于有了便宜好吃的外卖和餐厅
  • 住:租房更贵更小了,但多了游泳池和健身房,位置也很好,也算一丝慰藉

🛫 旅游

发小去年 12 月滑雪扭伤了腿,所以前几个月哪都没去

  1. 5 月跟 Harry 去 Lochgelly 上山徒步 Twitter

我和 Harry 背着三大桶水累得在地上爬,发小健步如飞

image

image

image

  1. 跟粗粗去了土耳其 Twitter Twitter Twitter Twitter Twitter Twitter Twitter

景区里好玩的很多,山路自驾、游泳、跳海、跳伞、骑马、热气球、晒太阳,大海和热气球风景也超好看,就是价格很感人,景点物价很高,会宰游客,离开景点区域又很贫瘠

带着游泳圈在水里泡了几天,差一点学会了游泳

image

image

image

image

image

image

image

  1. 8 月去了伦敦、剑桥、利兹、约克 Twitter Twitter Twitter Twitter Twitter Twitter

带发小妈妈在英国到处玩

看悲惨世界话剧 Do You Hear the People Sing Twitter

image

唐顿庄园里面 Cosplay Twitter

image

  1. 被 Harry 带着逛了爱丁堡神奇的化石店和外科手术博物馆

外科手术博物馆展示了泡在福尔马林中成堆的尸体和各个部位肢体,发小看得津津有味

跟 Harry 在爱丁堡海边放火烤棉花糖 Twitter Twitter

烤棉花糖是外面脆脆的里面软绵绵的很奇妙,Harry 做的酥肉也超级好吃

image

  1. 9 月去了法国、德国、奥地利、匈牙利、捷克

该玩的都玩了但时间很赶,走马观花

  1. 10 月去了成都、重庆、郑州、乌鲁木齐、上海

在成都见了Tyzual七夏浅笑,去看了大熊猫,在重庆大吃特吃,在乌鲁木齐吃羊肉配皮芽子

成都大熊猫基地

image

乌鲁木齐的大草原

image

  1. 11 月去了香港

香港金融科技周见到了很多同事,涨了很多见识,也是成立了两年多的公司的联合创始人们第一次团聚

IMG_3450_副本

🤼‍♀️ 社交

今年不但去了很多地方还见了很多朋友

Harry粗不粗Tyzual七夏浅笑花生chTonyBruce、Anni、Atlas、birdring、Dmoo、Jeff、Joshua、Kate、Maggie、Usagi、Yingzi、birdring、yingzi,大部分是同事,都是远程工作见一面着实不容易

🐱 家庭

回老家结婚了,过程很累,但看到发小穿上婚纱打扮一番一切都值了 Twitter

4803c6bf4efd14f8a6085d8586cc3aff

image

一回国就跟酸奶团聚了,还一起来了新加坡 Twitter

被问的最多的是 “分开了两年多,酸奶还认识你么”,虽然我永远无法知道答案,但从黏人程度来看,我倾向于是认识的

image

🏊 技能

游泳

从小学过几次,大学还上过游泳课,但都没有学会

如上所述,今年在土耳其旅游时候套个游泳圈泡了几天大海,克服了对游泳的恐惧,大概学会了自由泳的姿势但是不会换气

到新加坡之后又在小区的游泳池泡了几周后完全学会了,现在每天都会跟发小去游泳,运动强度足够大又有水冷,已经是我最喜欢的运动了,感觉之前错过了很多乐趣

IMG_5549 2 Large

播客

为了宣传 xLog,接受了 Web Worker 的邀请录了人生第一次播客,主播辛宝、刘威、小白菜都很专业,过程意外地非常顺利,也没有任何冷场

但是作为一个资深社恐,虽然第一次很顺利,应该也很难鼓起勇气进行下一次了

打麻将和打掼蛋

跟着发小各去了一次打麻将和打掼蛋,学会了这两种游戏,但是要说话所以都没有第二次了

🌌 印象深刻的日常

过生日发小精心设计的 DIY 诱捕器

过生日 st 送的黑哥哥祝福

在发小学校穿苏格兰裙拍婚纱照

在格拉斯哥参加了发小的毕业典礼

在新加坡过圣诞节

在新加坡跨年看烟花表演,好像还是第一次看这么盛大的烟花表演,之前看过最大的是迪士尼

还有陪发小第一次去看演唱会,第一次去抓娃娃

🪄 生活管理

跟去年差不多,主要还是在使用这一套基于 Obsidian 的生活记录系统,现在日记是这样的结构

当天发生的事情、正在进行中的事项、日程表、富兰克林美德表

image

再补充上用 TickTick 的 Habit 来记录每天每周各个 OKR 目标完成得怎么样,用 Toggl Track 记录做各个事情的时间,比之前的在 Obsidian 里 all in one 方便很多

image

🌿 输出

👩🏻‍💻 代码

Contributions 快赶上 2019 年巅峰时期了

contributions

又是没有新项目的一年,今年中前期还是以 xLog 为主,后期也做了一些 RSSHub 和 RSSHub Radar 的工作,和为公司 2024 年的一些转变做准备

xLog 开发得热火朝天(相关 Twitter 列表),成功举办了 10000 刀的创作者激励活动,吸引了大批优秀的创作者,最多时候还每周有十几个社区开发者来贡献代码一起完善 xLog,完成了数不清的 Feature。现在有 3k 多创作者在上面发布了 26k 多高质量内容

RSSHub 重构了文档,让它更容易被维护,改造成了以英文为优先语言;用更现代化的架构 Plasmo 完全重写了 RSSHub Radar,变得更好看更稳定更容易维护,还能自动发布到各大应用商店。现在 RSSHub 官方实例每月承载三亿多次请求,docker 有一千五百多万次下载,RSSHub Radar 有差不多 9 万周活用户

📙 文字

这一年文字也非常高产

认真写了 9 篇博客

水了 243 条 Twitter

列表

🎥 影音

今年在 B 站发布了两个 Vlog,旅游积攒了好多素材,至少还有 6 个视频还没剪,很惭愧

《女友浴室沉思录,非正常录音》

《英国旅游 VLOG | 爱丁堡圣诞节》

录了一个播客《和 xlog.app 的作者 DIYgod 聊区块链和博客平台、前端学习和生活感悟》

https://www.xiaoyuzhoufm.com/episode/645a76f67d934b85051081c8

🎊 2024

⭐️ OKR

  • O1: In excellent health

    • KR1: Body control, body weight 70kg and body fat 17%
    • KR2: Early to bed and early to rise, 12:00-8:00
    • KR3: Healthy Eating, light and less, increase the proportion of white meat and reduce the sugar
    • KR4: More exercise, at least 3 times per week
  • O2: Highly productive like a sow

    • KR1: Blog, at least 1 blog per month
    • KR2: Vlog, publish the 6 vlogs from last year
  • O3: Knowledge overflows my brain

    • KR1: Reading, at least 7 hours per week
    • KR2: Professional learning, at least 5 hours per week
    • KR3: English learning, at least 5 hours per week
  • O4: I am Franklin

    • KR1: Guarantee 1 virtue per week
    • KR2: Record virtue status daily
  • O5: Run like clockwork

    • KR1: Write bullet journal daily
    • KR2: Bookkeep weekly and monthly
    • KR3: Do things on a daily schedule
  • O6: Writing code is like writing the poem of life

    • KR1: Focused work for at least 7 hours per day
    • KR2: Actively maintain and contribute to open source projects for at least 3 hours per week
    • KR3: Develop a new RSS reader using Tauri, with comprehensive support for RSSHub and AI
  • O7: Combat inflation

    • KR1: Achieve a 10% annual return through simple and healthy financial management strategies

轻松创建一万个 Twitter 账号

2023-12-15 18:14:46

Information freedom does not naturally evolve, it degrades.
—— Open Information Manifesto

Twitter 在 8 月决定了全面限制公开访问和 API 接口,导致第三方集成均无法再正常工作。开放用户数据被绑架成私人敛财工具,曾经的 Open Web 标杆 Twitter 竟沦落到这种境地,数字奴隶制在最不应该的地方出现,令人唏嘘。这也致使许多用户流向 Fediverse,但社交关系和习惯一旦形成,要让其迅速改变并不易,更多人还是选择了忍受,Musk 也是看穿了这一点才有恃无恐

然而,我们也不能武断地说 Twitter 封闭,毕竟它仍然开放了一个起步价为每月 4 万美元,上限不设的企业 API

什么?你说用不起?

那么你可以像我一样,通过创建一万个账号以绕开封锁

尽管 Twitter 限制了所有公开访问,但我们发现新下载的 Twitter 移动客户端仍可以正常查看用户动态。这为我们提供了潜在的利用方法,通过抓包,我们可以看到客户端是通过请求一系列特殊接口来创建一个权限较低、频率限制严格的临时账号。我们可以用这个账号获取我们需要的大部分数据。然而,这种账号对请求频率的限制非常严格,因此需要大量的这样的账号才能满足基本的使用需求。同时,每个 IP 在一段时间内只能获取一个临时账号,因此我们也需要大量的 IP 代理

具体拆包和抓包过程可以参考 BANKA 的《怎么爬 Twitter(Android)》。站在 BANKA 肩膀上,我们可以写出一个这样的注册脚本(来自 Nitter - Guest Account Branch Deployment):

#!/bin/bash

guest_token=$(curl -s -XPOST https://api.twitter.com/1.1/guest/activate.json -H 'Authorization: Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F' | jq -r '.guest_token')

flow_token=$(curl -s -XPOST 'https://api.twitter.com/1.1/onboarding/task.json?flow_name=welcome' \
          -H 'Authorization: Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F' \
          -H 'Content-Type: application/json' \
          -H "User-Agent: TwitterAndroid/10.10.0" \
          -H "X-Guest-Token: ${guest_token}" \
          -d '{"flow_token":null,"input_flow_data":{"flow_context":{"start_location":{"location":"splash_screen"}}}}' | jq -r .flow_token)

curl -s -XPOST 'https://api.twitter.com/1.1/onboarding/task.json' \
          -H 'Authorization: Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F' \
          -H 'Content-Type: application/json' \
          -H "User-Agent: TwitterAndroid/10.10.0" \
          -H "X-Guest-Token: ${guest_token}" \
          -d "{\"flow_token\":\"${flow_token}\",\"subtask_inputs\":[{\"open_link\":{\"link\":\"next_link\"},\"subtask_id\":\"NextTaskOpenLink\"}]}" | jq -c -r '.subtasks[0]|if(.open_account) then {oauth_token: .open_account.oauth_token, oauth_token_secret: .open_account.oauth_token_secret} else empty end'

以及这样的批量注册脚本(来自我自己):

const got = require('got');
const { HttpsProxyAgent } = require('https-proxy-agent');
const fs = require('fs');
const path = require('path');

const concurrency = 5; // Please do not set it too large to avoid Twitter discovering our little secret
const proxyUrl = ''; // Add your proxy here

const baseURL = 'https://api.twitter.com/1.1/';
const headers = {
    Authorization: 'Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F',
    'User-Agent': 'TwitterAndroid/10.10.0',
};

const accounts = [];

function generateOne() {
    // eslint-disable-next-line no-async-promise-executor
    return new Promise(async (resolve) => {
        const timeout = setTimeout(() => {
            // eslint-disable-next-line no-console
            console.log(`Failed to generate account, continue... timeout`);
            resolve();
        }, 30000);

        const agent = {
            https: proxyUrl && new HttpsProxyAgent(proxyUrl),
        };

        try {
            const response = await got.post(`${baseURL}guest/activate.json`, {
                headers: {
                    Authorization: headers.Authorization,
                },
                agent,
                timeout: {
                    request: 20000,
                },
            });
            const guestToken = JSON.parse(response.body).guest_token;

            const flowResponse = await got.post(`${baseURL}onboarding/task.json?flow_name=welcome`, {
                json: {
                    flow_token: null,
                    input_flow_data: {
                        flow_context: {
                            start_location: {
                                location: 'splash_screen',
                            },
                        },
                    },
                },
                headers: {
                    ...headers,
                    'X-Guest-Token': guestToken,
                },
                agent,
                timeout: {
                    request: 20000,
                },
            });
            const flowToken = JSON.parse(flowResponse.body).flow_token;

            const finalResponse = await got.post(`${baseURL}onboarding/task.json`, {
                json: {
                    flow_token: flowToken,
                    subtask_inputs: [
                        {
                            open_link: {
                                link: 'next_link',
                            },
                            subtask_id: 'NextTaskOpenLink',
                        },
                    ],
                },
                headers: {
                    ...headers,
                    'X-Guest-Token': guestToken,
                },
                agent,
                timeout: {
                    request: 20000,
                },
            });

            const account = JSON.parse(finalResponse.body).subtasks[0].open_account;

            if (account) {
                accounts.push({
                    t: account.oauth_token,
                    s: account.oauth_token_secret,
                });
            } else {
                // eslint-disable-next-line no-console
                console.log(`Failed to generate account, continue... no account`);
            }
        } catch (error) {
            // eslint-disable-next-line no-console
            console.log(`Failed to generate account, continue... ${error}`);
        }

        clearTimeout(timeout);
        resolve();
    });
}

(async () => {
    const oldAccounts = fs.readFileSync(path.join(__dirname, 'accounts.txt'));
    const tokens = oldAccounts.toString().split('\n')[0].split('=')[1].split(',');
    const secrets = oldAccounts.toString().split('\n')[1].split('=')[1].split(',');
    for (let i = 0; i < tokens.length; i++) {
        accounts.push({
            t: tokens[i],
            s: secrets[i],
        });
    }

    for (let i = 0; i < 1000; i++) {
        // eslint-disable-next-line no-console
        console.log(`Generating accounts ${i * concurrency}-${(i + 1) * concurrency - 1}, total ${accounts.length}`);

        // eslint-disable-next-line no-await-in-loop
        await Promise.all(Array.from({ length: concurrency }, () => generateOne()));
        fs.writeFileSync(path.join(__dirname, 'accounts.txt'), [`TWITTER_OAUTH_TOKEN=${accounts.map((account) => account.t).join(',')}`, `TWITTER_OAUTH_TOKEN_SECRET=${accounts.map((account) => account.s).join(',')}`].join('\n'));
    }
})();

这些脚本已放到了 RSSHub 仓库: https://github.com/DIYgod/RSSHub/blob/master/scripts/twitter-token/generate.js

在使用前,你需要填入你购买的 IP 代理服务地址。脚本会自动处理超时、请求等错误,并且以 5 并发来自动获取临时账号,当获取到 1000 个账号后将会停止。需注意并发不要设置得过高,我从观察发现,当 Twitter 发现大量请求时会暂停接口一段时间

我购买了 5 家代理服务以进行测试,感觉效果相差无几,选择一个最便宜的服务就可以。通常,最低价的 1G 套餐就足够获取大约几万到十几万个账号了。我目前找到的最便宜的服务是 proxy-cheap,如果你有更好的选择请告知我

这种方法已经在 Nitter 上稳定运行了一段时间,现在也已实装到了 RSSHub 及其官方示例上,我们可以宣布与邪恶 Twitter 奴隶主的战争已经阶段性胜利