普通视图

发现新文章,点击刷新页面。
昨天以前首页

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

2024年3月11日 03:29

我有一个维护了六年的开源项目 —— 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 正在帮助你,也希望你可以积极参与进来,为信息自由的未来贡献一份自己的微小力量

轻松创建一万个 Twitter 账号

2023年12月15日 18:14

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 奴隶主的战争已经阶段性胜利

优雅使用 Cloudflare WARP 应对 RSSHub 反爬难题

2023年8月18日 06:01

🕊️ 本文送给更开放的互联网


起因是看到 @geekbb 介绍 Warp 的推文。尽管 Warp 已经发布了很长时间,就保护 IP 隐私而言,它并没有 iCloud Private Relay 好用,我也没有魔法上网的需求。但是我突然意识到,我还是有隐藏 IP 的需求。

在开发 RSSHub 的几年中,我发现提供公共 API 的站点非常少,许多站点还会采取严格的反爬控制来限制其平台内容的获取。有些站点会屏蔽同一 IP 发出过多请求,而还有一些站点则会全面屏蔽常见云服务器厂商的 IP 地址。因此,仅仅为了获取最新几条内容更新却变得非常困难。

lord-of-the-rings-my-precious

这种情况需要使用代理,但是专门的爬虫代理通常价格昂贵,性价比极低,如果 Cloudflare WARP 的无限流量和丰富的 IP 资源能被 RSSHub 利用就太棒了。RSSHub 已经支持了通用的代理协议,只要能将 WARP 包装为通用的 proxy 就可以。

image

虽然无法直接在命令行环境中方便地使用官方客户端,但这么容易想到的点子肯定已经被别人实现过了。我在 GitHub 上找到了一个封装的 Docker。

然后只需要在 RSSHub 的 docker-compose.yml 中再添加这样一个 service 来启用代理服务

warp-socks:
    image: monius/docker-warp-socks:latest
    privileged: true
    volumes:
        - /lib/modules:/lib/modules
    cap_add:
        - NET_ADMIN
        - SYS_ADMIN
    sysctls:
        net.ipv6.conf.all.disable_ipv6: 0
        net.ipv4.conf.all.src_valid_mark: 1
    healthcheck:
        test: ["CMD", "curl", "-f", "https://www.cloudflare.com/cdn-cgi/trace"]
        interval: 30s
        timeout: 10s
        retries: 5

最后给 RSSHub 加一个 PROXY_URI 环境变量来使用代理

PROXY_URI: 'socks5h://warp-socks:9091'

我选取了一个我经常使用的 hotukdeals 路由(英国版的什么值得买)进行测试。该站点会屏蔽所有 DigitalOcean 的 IP,因此一直处于 403 状态。

image

加上 WARP 后可顺利访问

image

此外,我发现每次重启 WARP 时,都会输出新的 IP。尽管我没有时间验证,但我感觉 IP 应该会经常自动更改,这对解决反爬是一个好消息。

image

还可以进一步自定义 WireGuard 的配置,包括使用付费版 WARP+ 和自定义 endpoint,以获取可能更好的结果。

生成 WireGuard 配置文件可以使用

刷 WARP+ 流量和筛选 endpoint 可以使用

有说法是 WARP+ 的速度并无明显差异(《WARP、WARP + 速度对比,以及 WARP 速度上限》),但是是否影响反爬效果还需要进一步验证。

如果一切顺利,RSSHub 官方实例中许多严格反爬的路由应该能重新使用。我将在几天内进行验证并在此更新。

image

在博客融入一个跨平台作品集

2023年8月8日 06:34

长久以来

我一直将个人博客视为一个理想的展示个人 IP 的 “个人网站”,而不仅仅是发布文章的平台。我曾在 2014 年初学编程时使用 WordPress 建站 《世界,你好!》;入了前端坑后,在 2017 年我转向了 Hexo 《做了一点微小的改动》;Web3 飞升后 2022 年我换成了 xLog 《第一个开源链上博客系统 xLog》。然而,无论我使用什么博客系统,一直都存在一个问题,那就是如何优雅地汇集和展示我在其他平台发布的作品,最好还能直接显示外站的数据。我之前通常以文章形式发布作品,并在文章中附上链接,然而这样做显然不够优雅,读者还需要额外点击链接进行跳转。

灵感降临

我在学习达芬奇剪辑时,发现了影视飓风的网站,它通过外链方式列出了他们在 B 站发布的视频,其中包括标题、封面图、发布时间、播放量等信息。这个发现给了我启发,我完全可以在 xLog 上制作一个装载了我在各个平台作品的作品集,这里面可以有我发布在 B 站的视频、我在 GitHub 上维护的仓库、我参与的小宇宙播客甚至是我在 pixiv 上创作的画作。这样,当人们访问我的博客时,将不只是看到文章,而是会看到更丰富多元的我,这让我的博客更接近一个真正意义上的 “个人网站”。

下手

想法萌发后,实现就简单了。

  1. 对 xLog 后台进行了优化和清晰的分类:文章、页面、作品集,以消除类型增多后可能带来的用户困扰。

image

  1. 设计了一个全新的编辑页,不同于文章和页面,这里只保留封面、标题、摘要、发布时间,并新增外部链接字段。

image

  1. 实现了作品信息的自动填充功能,减轻了手动输入的负担。这是通过获取链接的 Open Graph 信息实现的,涉及到的字段包括 og:image og:title og:description og:date

image

  1. 把作品展示在首页和独立的作品集页

image

  1. 数据的获取和展示,对于 “偷数据” 经验丰富的 RSSHub 作者来说,这是得心应手的一环,首先针对 bilibili、小宇宙、GitHub、pixiv、Twitter 这几个平台进行了抓取,获取到播放量和评论数并在 xLog 的卡片上进行展示,同时考虑到源站可能的压力和反爬,我特别设置了足够长的数据缓存。

image

如今,这个简单实用的小功能已经落地实现了,可以看看我的作品集页,你是否也想要尝试在 xLog 建立属于自己的个人作品集呢?

❌
❌