基于闲置 Azure 免费服务器的信息流重构
信息获取
这是一个充满信息的时代,甚至称得上是信息过载。我每天都有大量的时间花费在信息获取这件事情上。当然,本篇博文提及的「信息」,更多指的是「被动接收的常态信息」,而非「主动获取的目的信息」,后者例子有遇到问题去搜索等。
先来聊聊我平时是怎么获取信息的吧。就以 B 站为例子。
我是一个几乎不关注 UP 的人,关注的人一只手可以数得过来。但是我平时常看的 UP,却远远超过这个数字。甚至,我看得最多、能到期期不落地步的,基本上我都没有关注。
这样自然有第一个问题,既然没关注,我是怎么实现「追更」的呢?答案其实就是最为原始的——搜索。
因为看得很多,地址栏历史记录非常自然地记住了我的习惯。当我在地址栏迅速输入关键词的时候,第一个一般就是我要的结果了,只需回车,就能直接到达我想去的地方。例如说我输入「影视」,第一个就是「影视飓风」的动态。这个速度其实很快[1],比去关注列表点进去更快。
当然,过了一段时间后我可能会清理浏览器数据,例如说浏览历史记录等,这时候怎么办呢?其实就是重新「养」,一开始重新搜索,进入,然后再多看几遍,就重新记住了。
我并没有记录一个 UP 名单,按照什么顺序去遍历。其实就是想到哪个就去哪个瞅瞅。甚至完全可以前一分钟看了一遍,下一分钟再看一眼。
这种方式基本上只适用于我比较常看的 UP,而且要是更新比较高频的,即我一般也是会按更新频率调整的,那些更新周期相当长的,甚至已经停更好一段时间的,负反馈下也比较少再反复检查了。
固定来源之余,我其实还希望有一点新意。像是一些算法会为了避免陷入局部最值,而选择接受一些其他的选择,我也会接收一些推荐,以更新自己的认知。例如说可能会看到新的有意思的内容,当然大部分时候依旧是沙里淘金。
只是呢,我靠的并不是 B 站自己的推荐[2],而是「热榜」。因为推荐是无穷无尽的,太容易浪费时间了,我开摆的时候,大部分时间确实就是在不断刷新着推荐。因此非常早的时候我就用 Bilibili Evolved 去除了首页推荐,并在最近卸掉了手机应用,彻底杜绝了不断刷推荐的路子。我的 B 站首页,没有什么醒目的横幅、轮转的图片,也没有很多看着反胃的推荐,有的只有我关注的 UP 的视频。
但我上面也说了,我确实是需要一点新意的。因此「热榜」就成为我「推荐」之外的一个替代选择了。首先「热榜」算是经过一定筛选的,其次与个人的关注度有少量契合,再者这部分是有限的,而不会陷入到一个无尽的循环中。因此我在网页的时候,会频繁去看热榜来代替刷推荐。虽然还是在其中不断徘徊、浪费时间,但多次碰壁以后,也不会无休止地进行下去了。
其他也是类似的,例如说知乎。之前我用过一个浏览器扩展,限制不可以访问知乎的推荐页,一旦访问会强制跳转回热榜,也是同样的道理。不过这个现在好像不咋用了,也许是习惯了,我不会像刷 B 站那样刷知乎了,即便偶尔点进推荐页,也基本上就看看前几个话题,不会继续往下翻。
我跟顾问交流了解到,这是我的一种「自我防御机制」,其中一点是我对控制感的「极度渴求」。我是将互联网平台推送的、让我被动接收的信息,转换成我主动获取的信息,尝试以此夺回对我自身注意力的控制权,即便这种方式非常低效也在所不惜。
确实如此,这种方式非常原始。并不是每次获取都有结果,我虽然没有长时间花费在刷推荐上,但却花了相当多的时间进行一遍「信息轮询」。我没有 UP 的名单,因此想到哪个就去哪个看一眼。同时不仅仅是 B 站,我还会上 GitHub 看看有没有通知、去各个论坛看一圈有没有什么新闻、去社媒看看热点消息等等。
这样走了一圈后有很多时候其实一无所获,但这时候已经花了一些时间了,又会禁不住想,我最开始扫描过的 UP,现在更新了吗?于是再走一遍轮询。有新东西的话那再好不过了,但处理完信息后,一样又过了一阵子,自然又开始了新的一轮循环。
这样做也许跟不停刷推荐差不多,是我逃避正事的一种方式,不停消磨时间。但看推荐起码还是有一些新内容的,而轮询大部分时刻都在做无用功。
那么为何不关注呢?说了这么多废话,关注了 UP 不就行了,有新动态自动推送,不必再管什么推荐,只用去管「动态」那边的小红点就是了。
这就要提到顾问所说「自我防御机制」的第二点了——信息洁癖。
为什么我只有少量关注的 UP,为什么我常看的 UP 却不在关注列表内呢?因为广告。
我是一个挺有强迫症的人,一定要消除「小红点」——也就是新的内容——一定要确保目前所有的新信息均被我处理了,不管是实际上消除还是物理上消除[3]。即这些不仅仅是提醒,还是一种高优先级、紧急的待办事项。
而广告内容破坏了这个体系。当我点开新消息的时候,看到居然只是个广告,或者什么垃圾内容时,就会产生一种强烈的负向奖赏,这种挫败感,让我愈加反感、厌恶系统的通知机制。
我的意思并不是反对 UP 等群体加入广告——虽然说纯感性、心理上不觉得反感也是不可能的——而是不希望自己的信息流被广告污染。
消息提醒的时候,往往我是预设这条信息是有价值的,因此才会有这么强烈的反应。但我主动获取的时候并没有这种假设(因为大部分时候,别说广告,其实是啥都没更新),因此即便我自己看 UP 时常常也是只能看到广告的更新,但这种我自动就过滤了,并没有什么动摇。
这有点像去外面吃饭,我一家店一家店看的时候,是可以理解会发现垃圾的店家。但当我坐在里面的餐桌上等上菜时,就不能容忍端上一盘大便了。
上面的部分讨论其实在前面的博文就有简单地涉及,当时一样举了 B 站的例子:
小学的时候还是用着 360 全家桶,装着 360,浏览器也是 360 浏览器(也许是极速版),自然导航也是 360 网址导航。我当时自然是不知道什么是「牛皮藓」,什么是「篡改主页」的,就觉得这个主页有好多信息哇。
……现在我自然是对这玩意避之不及,要求自己的新标签页是干干净净的,要什么咨询自己去搜。之前觉得也许我后面就都是这样了,但又想了想,我现在有精力去折腾、自己看想要的,但也许等我年老体弱气衰后,需要接收的信息更多了、渠道也更繁杂了,再无精力去像现在这样一个一个看,可能会再度成为这样的导航的受众。
……一个属于自己的信息源,可能是我未来会探索的一个方向。
我——至少现在——还是难以容忍自己成为算法推荐的奴仆,也无法接受自己后面要与「牛皮藓」一同度日。
但是信息导航的思路确实不错,只是需要变化一下想法。我能否将「我关注的信息」聚合起来,弄一个自己的「导航」呢?这样既避免了轮询的低效,同时也避免了系统自身通知的垃圾。除此以外,我将信息集中起来,也不用奔波在多个平台不断关注新内容了。
信息集中这个想法确实很不错,像是 GitHub,以及 Discourse 论坛什么的,都有邮件通知服务,因此我轮询的时候往往就不用每个论坛都走一遍,翻一下收件箱就够了。
可惜的是现在的互联网似乎在由开放转向封闭,例如说搜索引擎索引等事情,各个网站变成了信息孤岛,互不相同,平台只想把用户绑架在上面,不希望流量不经过其而流向用户。从商业角度我自然是能够理解这样的选择的,但从信息开放、共享的互联网视角我又觉得有点悲哀。
说回正题。想要信息聚合,可以说唯一的选择就是这个古老、但也是现在最为成熟的技术,「简易信息聚合」(Really Simple Syndication),或者更为人所熟知的名称——RSS。
RSS 是一种协议,可以将把不同网站的更新聚合在一起里。它的核心原理也并不复杂,就是将我人工轮询的机制,用机器代劳了。
本篇内容比起「教程」,更像是「记录」,因此不会详尽地介绍各种细节,更多地会选择呈现我的一些想法与见闻。
Folo
与多数追寻桌面端 RSS 阅读器的人不同,我其实更倾向于网页 RSS 阅读器,因为这样我可以充分利用浏览器的插件,主要是沉浸式翻译翻译外文。
顾问给我的第一个选择是 Folo。当然,顾问的知识还停留在它之前叫 Follow 的时候。
我去尝试了一下,有一点惊艳之处,那就是它将信息流区分为了文章、社媒、图片、视频四类,除了图片外的三类,可以说是一下子切中了我的想法。
看到视频部分的时候我就想起来了,之前在某个群聊中就见过一张截图,里面就是将多个 B 站视频聚合起来了,当时就觉得有点惊奇,只不过没深入去了解。
不过我很快就厌倦了。首先是看到了信息源的数量限制,即便我短期内达不到,看到未免也会有所反感。其次弄了一些,发现有的 UP 上次视频都是一年前了,除了我没人订阅,还有的直接就是校验失败了。还有就是我似乎没看到对信息源的筛选、过滤机制。
插一句比较有意思的,加入信息源的时候自搜了一下,惊讶地发现了居然有一个人用 Folo 订阅了我的博客,着实是有点受宠若惊了。
此外前几天还了解了 Folo 的一些风波,都没过多久,不到两个月。大概就是跟商业化相关的,团队矛盾吧。令人感慨。
因此我就问了一下顾问,打算了解一下原理。不过其实一开始的想法就是满足一下我的好奇心与求知欲,因为我认为我短期内是没法做到的,因此就加了一句「我现在不打算弄,但未来可能会需要一个自己的源」。
然后我得到了答案:RSSHub + Miniflux。
首先要先说一下 RSSHub 是什么。上面提到了信息孤岛,并非每一个网站都提供了 RSS 服务。例如说国内的互联网应用大多数都无法直接用 RSS 订阅。因此就需要一种中间件,允许从各种各样的内容生成 RSS 订阅源,RSSHub 就是目前最大的做这个事情的东西。
为了满足我的需求,应该有这样三个层级:
- 生成层:对于我想要的信息源,不管有没有 RSS,都要转成 RSS 的形式。
- 聚合层:对 RSS 信息内容进行过滤、清洗、处理等。
- 展示层:将 RSS 内容最终呈现给用户。
RSSHub 负责的是第一层的工作,因此二三层的内容就交给了 Miniflux。
当然为了满足我的无数量限制等需求,RSSHub 与 Miniflux 都必须是自建的,因此我必须有一个自己的服务器。
Azure
我最初想,短期内无法实现的两个理由,其中一个是不了解技术,以为会触及到我的能力范围之外,于是自然而然先放弃了;另一个原因就是自建所需的云服务器了。
但我了解过后,猛地想到了,我有免费的云服务器啊!那就是 Azure 学生优惠。
Azure 学生优惠提供了 Linux 和 Windows 免费的虚拟机,差不多一年前我参考 大学生都可领取的免费服务器!使用Azure学生订阅创建免费的Linux和Windows服务器 - 知乎 这篇文章,领取了两个不消耗免费一百刀的服务器:
- Linux Server, ubuntu-24_04-lts server, 2vcpu 1GiBmem 64GiB SSD
- Windows Server, 2016-datacenter-smalldisk-g2, 1vcpu, 1GiBmem, 64GiB SSD
当然后面那个什么也不干都非常卡,甚至很难 RDP 连上去,常常 0x112f 错误。我后面可能直接废掉得了。
在此之前我其实不知道我能用云服务器干些什么,只是想着免费的羊毛不薅到手就亏了。像 Azure 大学第一年 100 刀一分没花,但第二年还是立刻就续上了。
不过后面还是把原本放在本机跑的定时任务丢到了虚拟机上,不然闲置也有点浪费。
但是顾问这样一讲的时候,我顿时惊觉,我不是有这样一个现成的免费云服务器吗?何不试试看呢?于是就开始了。
很快也就结束了,比我预想简单好多好多。顾问给了我一串命令,我执行完后,就结束了,完成了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | # RSSHub 可能需要一个内置浏览器,需要开启虚拟内存 # 创建一个 2GB 的文件 $ sudo fallocate -l 2G /swapfile # 设置权限 $ sudo chmod 600 /swapfile # 格式化为 Swap $ sudo mkswap /swapfile # 启用 Swap $ sudo swapon /swapfile # 设为开机自启 $ echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab # 安装 Docker $ curl -fsSL https://get.docker.com | bash # 编写配置文件 $ mkdir ~/my-rss && cd ~/my-rss $ vi docker-compose.yml ... # 运行 $ sudo docker compose up -d # 创建管理员账号 $ sudo docker compose exec miniflux miniflux -create-admin -username <username> # 查看所有服务实时日志(-f 代表 follow) $ sudo docker compose logs -f # 某一个服务的日志 $ sudo docker compose logs -f rsshub |
具体的 docker-compose.yml 这里暂时不列出[4]。
完事后,先去 Azure 虚拟机网络设置那边,设置入站端口规则,放行 RSSHub 与 Miniflux 的两个端口(TCP 协议),直接用公网地址 + 端口就能访问了。
简单到我有点难以置信,这就是 Docker 的力量吗?此前从未用过 Docker,除了用 docker-easyconnect 将 EasyConnect 封印到 Docker 中,以干净地在校外使用 VPN 访问校内网站。这也是参考了一些教程。
在之后我还了解到了 RSSHub Radar 浏览器扩展,可以探测当前页面的 RSS 与 RSSHub 源。
不过就在弄好并添加了第一个订阅源后,我又有点犹豫了。因为我感觉似乎页面有点「简洁过头」了。此外 Miniflux 是顾问直接抛给我的选择,我并不知道有什么同类竞品,它们之间有什么差异。
于是我表达了我的观念与看法,并咨询了一些替代方案,得到了两个答案,FreshRSS 与 Tiny Tiny RSS。
顾问告诉我,Miniflux 极度轻量、简洁,可以与其他第三方客户端阅读器连通。但同样的,它的颜值极简,扩展性极小。而 FreshRSS 拥有插件系统,可以提供更多选择。
斟酌后,我试用了一下 FreshRSS,然后没有继续折腾了。
FreshRSS
建立
FreshRSS 便是我的最终答案。因为「一定要在浏览器」这个需求,顾问建议我迁移到 FreshRSS。此外当初还有一个想法就是关于扩展性,只是我迁移后感觉也就那样吧,其实也没好好体验一下 Miniflux 的功能。
不过 FreshRSS 一开始俘获我的,就是它的主题了,设置了个 Ansum,感觉挺好看的。
下面根据跟顾问的交流,零零散散记录一些内容吧。
1 2 | $ docker compose up -d unable to get image 'freshrss/freshrss': permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.51/images/freshrss/freshrss/json": dial unix /var/run/docker.sock: connect: permission denied |
Docker 守护进程 Daemon 默认绑定到 Unix socket,而不是 TCP 端口。该 socket 默认属于 root 用户。因此,普通用户直接运行 docker 命令时,如果没有 sudo,就会有权限问题。
解决方法要么是像上面的命令一样,加 sudo,要么就是将用户加入 docker 用户组:
1 2 3 4 | # 将用户加入组 $ sudo usermod -aG docker $USER # 更新用户组 $ newgrp docker |
插件
装了一些插件,主要是官方的和 cntools_FreshRssExtensions,外带一个 freshrss-af-readability。
为了比较方便地解压,容器内装了 unzip,不过得现在外面下好。在里面编辑东西也好麻烦,vi, nano 都没有,得 cp 出来改。
环境变量配置
为了提取 B 站的内容,还需要添加 Cookies。我直接完整复制了,加在 RSSHub 的环境变量中。
同理还有 GitHub 的 Token,只启用了 Notifications 权限,因为 RSSHub Radar 可以探测到私有 RSS,这个不必用 RSSHub,而通知则需要 Token。
GitHub 通知空路由
/github/notifications 路由只有在非空的时候才会返回 RSS 源,为空的时候,即此时还没有通知的时候则会报 503 错误,FreshRSS 会有报错警告,看着很不舒服。这个问题我提了一个 PR 解决:fix(github): allow empty notifications feed by pilgrimlyieu · Pull Request #20875 · DIYgod/RSSHub,已经合并了。
不过我后面再看了看,GitHub 这块路由似乎有好几个都是空直接报错,因为我没用所以没一块修了,刚刚测试的时候遇到了。
域名与 HTTPS
简单弄好桌面网页端后,就想手机也弄一个看看。推荐了个 Read You,安装,开启了 API,然后弄了。
用了一阵子后发现 Read You 有点问题,最为严重的是与 FreshRSS 那边数据不统一。
有一个订阅源每天都会有很多信息,而且从标题上看有相当一部分我有点兴趣,打算后面看看。因为主要是在清未读,不想给打断,所以要读的要么就是改回未读,要么就是加个收藏(一般也会取消已读)。不过这只是标准做法,更常见的做法是放那里不管了,也不清,堆积起来了。
结果在堆积好久后,有了几百条了(200+ 吧?忘了,就记得中间节点是 150 左右),终于下定决心用了挺久的时间这样「分类」了一下,最后有 60+ 条是打算后面看看的。这个量也有点太大了,当然其实里面也不一定会细看,有的可能匆匆看一点就发觉内容不感兴趣而废弃了。结果同步到 FreshRSS 那边一看,只有 10 条收藏的留痕了,其他全部已读。
当时我就直接麻了,之前其实未读数字就对不上,但我也没太在意。在这期间(也就是上面提及的中间节点)我也有注意到似乎有一部分文章我明明记得打了收藏,怎么星标没了呢?但当时没太在意,随手重标了继续弄。结果这次大规模行动后就彻底暴露出来这个致命的缺陷了。我再收藏一篇试试,然后同步,结果那个收藏在同步后消失不见了。这个在后面也搜到了类似的问题,且没有修复的迹象。
然后这个事情后又发现个问题。本来进软件主页显示的是未读的订阅源,结果莫名其妙变成了所有订阅源,我也没动啥设置,设置找了一下也搞不回来了。此外还有软件内的图片无法分享只能保存等最初我使用时遇到而后面决定忍受的小问题。
当然其实这个「损失」也跟我的习惯不好有关系,堆积那么久才来看,那没了其实倒也无所谓了。但不管怎么说这也都反映了软件本身的问题,于是我就决定放弃这个 Read You 了,查了一番,改用了 Capy Reader。现在用了几天,感觉还不错。
首先里面的图片可以直接分享了,然后最重要的也就是这个数值,我经过几天的使用,也包含了一些相对小规模的清理,数值都是比较准确的。
一开始的时候界面稍稍有点不习惯,但现在感觉其实更合适了。
不过其实没这么简单,因为这时候我想到了一个事情,我现在一直都是 http://IP:port 访问的,这岂不是在裸奔?问了一下确认了,需要上 HTTPS,告诉我要一个域名。
然后给它晃得晕头转向,差点就掏钱买了域名了,好在没找到底价的。然后在我的「逼问」下,顾问才说了 Azure 虚拟机自带一个 DNS 名称,进去设置以后,就有一个永久域名了,虽然很长很丑。不过顾问还是告诉我了,还是有一点隐私安全代价,就是没法隐藏 IP,这个还好,我不暴露域名就是了。
然后就是用 Caddy 配置了 HTTPS,下面是部分命令:
1 2 3 4 5 6 7 8 9 10 | # 安装 Caddy $ sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https $ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg $ curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list $ sudo apt update $ sudo apt install caddy # 写入配置 $ sudo vi /etc/caddy/Caddyfile # 重启 Caddy $ sudo systemctl restart caddy |
下面是信息聚合相关服务的配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <domain> { handle_path /freshrss* { reverse_proxy localhost:8081 { header_up Host {host} header_up X-Forwarded-Proto https header_up X-Forwarded-Port {port} header_up X-Forwarded-Prefix /freshrss } } handle_path /rsshub* { basic_auth { ... } reverse_proxy localhost:1200 } } |
RSSHub 因为不想公开使用,因为带了 Cookies 等,有隐私泄露或是滥用的风险,于是加了 Basic Auth 身份验证。FreshRSS 就不用了,本身就要登录。而 FreshRSS 中用的 RSSHub 是内网地址,还更快一点。那为啥还要开放呢?因为这样我还是能检查一下 RSS 内容,挺有必要的。
不过刚开始有点傻了,DNS 名称写了个 RSS,后面后知后觉这是代表整个服务器的,我又不一定要局限在信息聚合这一项服务中,于是改名了。之前 FreshRSS 是在根目录的,后面改成了 /freshrss,因为这个还重置了一下 FreshRSS,虽然插件、数据什么还在,不过配置消失了,好在当时还没配置多少,手动恢复了。
这样以后可以 HTTP 访问,不需要开放端口了,于是我后面又去将端口规则删掉了,只用保留 SSH 的 22 与 HTTP 的 80, 443。
只是后面为了安全,把 SSH 端口也改掉了,不再是默认的 22。不过这个也折腾了一番。
一开始就是按顾问说的,去改 /etc/ssh/sshd_config,换一个端口。结果连不进来,秒拒,还好我连着的 SSH 没关。
然后再 sudo ss -tpln 检查了一下连接,发现还是在监听 22 端口:
1 2 | $ sudo ss -tulpn | grep sshd tcp LISTEN 0 4096 *:22 *:* users:(("sshd",pid=3823487,fd=3),("systemd",pid=1,fd=101)) |
然后顾问跟我讲,Ubuntu 比较新的版本(22.10 及以后)默认启用了 Socket Activation 代管模式,由 systemd 监听端口,当有人连接的时候才会唤醒 SSH 服务,这也同时无视了 /etc/ssh/sshd_config 里面的配置。
一种方式是关掉代管模式,回到原来的独立服务模式:
1 2 3 | $ sudo systemctl stop ssh.socket $ sudo systemctl disable ssh.socket $ sudo systemctl restart ssh |
不过我了解了一下这两种模式的差异后,认可了,于是决定保留代管模式,用另一种方式配置。使用 sudo systemctl edit ssh.socket 创建一个覆写配置,在里面写:
1 2 3 4 5 | [Socket] # 清空默认 22 端口设置 ListenStream= # 新端口 ListenStream=<port> |
然后重启:
1 2 3 | $ sudo systemctl daemon-reload $ sudo systemctl stop ssh.service # 重启失败了,因为我先换回了独立服务,所以要先停止 $ sudo systemctl restart ssh.socket |
快捷键
然后提一点 FreshRSS 的配置,先是快捷键,改了一小点,挺有意思,是 Vim 风格的。
- 8 1 9 切换列表、全屏和阅读视图。默认是 1 2 3,因为全屏不常用就换了。
- J/K 上下移动,并自动展开内容。Shift + J/K 是在订阅源之间上下移动,Alt + J/K 是在类别之间移动。不过类别移动后,不能再订阅源移动了。
- H 打开下一篇未读文章。
- N/M 上下移动但不展开。默认是 N/P,默认的 M 是载入更多文章,我切换了这两个。
- Space 打开原网页查看。
- E 收藏。默认是 F,因为 F 在 Vimium-C 太常用了。
- R 切换已读状态。Alt + R 将上面的内容全部标记为已读,Shift + R 则是将该类别所有内容标记为已读。
- C 切换内容折叠状态(列表视图中)。
- T 切换侧边栏显示状态。
- A 打开搜索框。
- S 分享,我弄了一个剪贴板还有一个系统分享源,不过系统分享除了邮件,都是国外的社媒……
- L 给内容打标签(自定义的标签,而非识别的类别)。
- Q 更新订阅源。
比较常用的大概就是这些了,虽然感觉有些地方还不是很够(如排序什么的),但也差不多了,挺方便了。
RSSHub Radar
前面为 RSSHub 添加了身份验证,为了预览功能用的是公网地址,但我订阅的时候想要用内网,于是写了一个油猴脚本:
具体代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | // ==UserScript== // @name FreshRSS Internalizer // @namespace http://tampermonkey.net/ // @version 1.0 // @description 进入 FreshRSS 订阅页时,强制将公网 RSSHub 参数重定向为内网地址。 // @author PilgrimLyieu & Gemini // @match <freshrss_url>/i/?c=feed&a=add* // @run-at document-start // ==/UserScript== (function () { 'use strict'; // ================= 配置区域 ================= const CONFIG = { // RSSHub Radar 填写的公网前缀 public_base: "<rsshub_url>", // 实际订阅使用的内网前缀 internal_base: "http://rsshub:1200" }; // =========================================== // 1. 解析当前 URL 参数 const params = new URLSearchParams(window.location.search); const targetUrl = params.get('url_rss'); // 2. 只有当存在 url_rss 且以公网地址开头时,才执行重定向 if (targetUrl && targetUrl.startsWith(CONFIG.public_base)) { // 3. 执行替换:将公网部分换成内网部分,保留后续路径参数 const newTargetUrl = targetUrl.replace(CONFIG.public_base, CONFIG.internal_base); // 4. 更新参数对象 params.set('url_rss', newTargetUrl); // 5. 组装新 URL 并立即执行重定向 const newPageUrl = `${window.location.pathname}?${params.toString()}`; window.location.replace(newPageUrl); } })(); |
不过这其实经过了一些波折,一开始用的是公网,然后后面要加身份验证,最开始想的是用提供的 Access Key,但这样会在订阅源链接上额外加一点料。因此后面才改成了用 Caddy 来做 Basic Auth。不过即便如此,还是要添加 HTTP 账号密码,或者直接写在链接里,还是不够好。
随后我放弃了预览,写了内网地址,这样可以直接添加,就是不再能预览了。
但还是不爽,于是与顾问交流研究一番后,最终用了上面这样的方式:依旧是公网地址,这样可以预览。但是添加到 FreshRSS 时,立刻替换为内网,这样就完美了。
B 站嗅探
不过还是有点问题,我也不知道是怎么回事,B 站嗅探不出来 RSS 源,只有下面的文档(虽然点开也不存在,没更新?)。
搜索一番尝试解决无果后,我再写了一个油猴脚本,内置了一些匹配规则(VC 写的,只简单测试了几个,不能保证全部正确),可以预览、添加等,还挺方便:
具体代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 | // ==UserScript== // @name Bilibili RSSHub Generator // @namespace http://tampermonkey.net/ // @version 1.0 // @description 为 B 站页面自动生成 RSSHub 订阅链接,支持内网/公网双模配置,适配 FreshRSS // @author PilgrimLyieu & Gemini // @match *://*.bilibili.com/* // @grant GM_addStyle // @grant GM_setClipboard // @grant GM_xmlhttpRequest // @connect */rsshub // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ========================================================================= // 🟢 Configuration // ========================================================================= const CONFIG = { // 1. RSSHub 内网地址 rsshub_internal: "http://rsshub:1200", // 2. RSSHub 公网地址 rsshub_external: "<rsshub_url>", // 3. FreshRSS 地址 freshrss_host: "<freshrss_url>", }; // ========================================================================= // 🎨 UI Styles // ========================================================================= GM_addStyle(` #bili-rss-btn { position: fixed; bottom: 100px; right: 20px; z-index: 99999; width: 48px; height: 48px; background: #fb7299; border-radius: 50%; box-shadow: 0 4px 10px rgba(0,0,0,0.2); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.3s; color: white; font-weight: bold; font-size: 14px; } #bili-rss-btn:hover { transform: scale(1.1); background: #f45a8d; } #bili-rss-panel { position: fixed; bottom: 160px; right: 20px; z-index: 99999; background: white; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.15); padding: 10px; width: 360px; max-height: 500px; overflow-y: auto; display: none; font-family: sans-serif; border: 1px solid #eee; } .rss-item { margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #f0f0f0; } .rss-item:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; } .rss-title { font-size: 14px; color: #333; margin-bottom: 5px; font-weight: bold; display:block; } .rss-actions { display: flex; gap: 5px; } .rss-btn { flex: 1; padding: 6px 0; text-align: center; border-radius: 4px; font-size: 11px; cursor: pointer; text-decoration: none; color: white; display: block; } .btn-preview { background-color: #23ade5; } .btn-preview:hover { background-color: #1aa0d6; } .btn-add { background-color: #fb7299; } .btn-add:hover { background-color: #f45a8d; } .btn-raw { background-color: #fca106; } .btn-raw:hover { background-color: #e59100; } .empty-tip { color: #999; text-align: center; font-size: 12px; padding: 10px; } #rss-preview-mask { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 100000; display: none; align-items: center; justify-content: center; } #rss-preview-box { background: white; width: 600px; max-width: 90%; max-height: 80vh; border-radius: 8px; box-shadow: 0 5px 20px rgba(0,0,0,0.3); display: flex; flex-direction: column; overflow: hidden; } .preview-header { padding: 15px; border-bottom: 1px solid #eee; background: #f9f9f9; display: flex; justify-content: space-between; align-items: center; } .preview-title { font-weight: bold; font-size: 16px; color: #fb7299; } .preview-close { cursor: pointer; font-size: 20px; color: #999; } .preview-content { padding: 15px; overflow-y: auto; } .preview-item { margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px dashed #eee; } .preview-item-title { font-size: 14px; font-weight: bold; margin-bottom: 5px; display: block; color: #333; text-decoration: none; } .preview-item-title:hover { color: #00a1d6; } .preview-item-date { font-size: 12px; color: #999; } .preview-desc { font-size: 13px; color: #666; margin-top: 5px; line-height: 1.5; max-height: 100px; overflow: hidden; text-overflow: ellipsis; } `); // ========================================================================= // 🧠 Route Matchers // ========================================================================= const RULES = [ // --- UP 主相关 --- { name: "UP主", regex: /space\.bilibili\.com\/(\d+)/, // 生成一组订阅 generator: (match, url) => { const uid = match[1]; // 尝试从 URL 参数获取 sid const urlObj = new URL(url); const sid = urlObj.searchParams.get('sid'); const isCollection = url.includes('collectiondetail'); const isChannel = url.includes('channel/seriesdetail'); let feeds = [ { title: "UP 主动态", path: `/bilibili/user/dynamic/${uid}` }, { title: "UP 主投稿", path: `/bilibili/user/video/${uid}` }, { title: "UP 主所有视频", path: `/bilibili/user/video-all/${uid}` }, { title: "UP 主专栏", path: `/bilibili/user/article/${uid}` }, { title: "UP 主粉丝", path: `/bilibili/user/followers/${uid}` }, { title: "UP 主投币视频", path: `/bilibili/user/coin/${uid}` }, { title: "UP 主默认收藏夹", path: `/bilibili/user/fav/${uid}` }, ]; // 如果在合集/列表页面 if (sid) { if (isCollection) { feeds.unshift({ title: `UP 主频道合集(SID: ${sid})`, path: `/bilibili/user/collection/${uid}/${sid}` }); } else if (isChannel) { feeds.unshift({ title: `UP 主频道视频(SID: ${sid})`, path: `/bilibili/user/channel/${uid}/${sid}` }); } } return feeds; } }, // --- 视频播放页 --- { name: "视频", regex: /bilibili\.com\/video\/(BV\w+)/, generator: (match) => { const bvid = match[1]; return [ { title: "视频评论", path: `/bilibili/video/reply/${bvid}` }, { title: "视频弹幕", path: `/bilibili/video/danmaku/${bvid}` }, // 注意:选集通常用于多 P 视频,单 P 也可以用 { title: "视频选集列表", path: `/bilibili/video/page/${bvid}` } ]; } }, // --- 番剧 / 电影 / 国创 --- { name: "番剧/影视", regex: /bilibili\.com\/(bangumi|movie)\/media\/md(\d+)/, generator: (match) => { const mediaid = match[2]; return [ { title: "番剧/影视更新", path: `/bilibili/bangumi/media/${mediaid}` } ]; } }, // --- 分区热门 --- { name: "分区热门", regex: /bilibili\.com\/v\/([a-z0-9]+)\/([a-z0-9]+)/, generator: (match, url) => { // 这个匹配比较宽泛,针对 /v/popular/all 这种 if (url.includes('/v/popular/all')) { return [{ title: "综合热门", path: "/bilibili/popular/all" }]; } if (url.includes('/v/popular/weekly')) { return [{ title: "每周必看", path: "/bilibili/weekly" }]; } return []; } }, // --- 收藏夹详情页 --- { name: "收藏夹", regex: /space\.bilibili\.com\/(\d+)\/favlist\?fid=(\d+)/, generator: (match) => { const uid = match[1]; const fid = match[2]; return [{ title: `收藏夹(FID: ${fid})`, path: `/bilibili/fav/${uid}/${fid}` }]; } }, // --- 漫画 --- { name: "漫画", regex: /manga\.bilibili\.com\/detail\/mc(\d+)/, generator: (match) => { const id = match[1]; return [{ title: "漫画更新", path: `/bilibili/manga/update/${id}` }]; } }, // --- 搜索 --- { name: "搜索", regex: /search\.bilibili\.com\/.*keyword=([^&]+)/, generator: (match) => { const kw = decodeURIComponent(match[1]); return [{ title: `视频搜索:${kw}`, path: `/bilibili/vsearch/${kw}` }]; } }, // --- 排行榜 --- { name: "排行榜", regex: /bilibili\.com\/v\/popular\/rank\/all/, generator: () => { return [{ title: "全站排行榜", path: "/bilibili/ranking/0/3/1" }]; } } ]; // ========================================================================= // 🚀 Logic & UI Rendering // ========================================================================= let panel = null; function initUI() { if (document.getElementById('bili-rss-btn')) return; // Create Button const btn = document.createElement('div'); btn.id = 'bili-rss-btn'; btn.innerHTML = 'RSS'; btn.onclick = (e) => { e.stopPropagation(); togglePanel(); }; // Create Panel panel = document.createElement('div'); panel.id = 'bili-rss-panel'; panel.onclick = (e) => e.stopPropagation(); // Prevent closing when clicking inside document.body.appendChild(btn); document.body.appendChild(panel); // Click outside to close document.addEventListener('click', () => { panel.style.display = 'none'; }); } function togglePanel() { if (panel.style.display === 'block') { panel.style.display = 'none'; } else { generateFeeds(); panel.style.display = 'block'; } } function generateFeeds() { const currentUrl = window.location.href; let foundFeeds = []; RULES.forEach(rule => { const match = currentUrl.match(rule.regex); if (match) { const generated = rule.generator(match, currentUrl); foundFeeds = foundFeeds.concat(generated); } }); // Deduplicate foundFeeds = foundFeeds.filter((v, i, a) => a.findIndex(t => (t.path === v.path)) === i); renderPanelContent(foundFeeds); } function previewRSS(url, title) { // 创建或获取弹窗容器 let mask = document.getElementById('rss-preview-mask'); if (!mask) { mask = document.createElement('div'); mask.id = 'rss-preview-mask'; mask.innerHTML = ` <div id="rss-preview-box"> <div class="preview-header"> <span class="preview-title">加载中...</span> <span class="preview-close">×</span> </div> <div class="preview-content"></div> </div>`; document.body.appendChild(mask); // 绑定关闭事件 mask.querySelector('.preview-close').onclick = () => { mask.style.display = 'none'; }; mask.onclick = (e) => { if (e.target === mask) mask.style.display = 'none'; }; } const boxTitle = mask.querySelector('.preview-title'); const boxContent = mask.querySelector('.preview-content'); // 显示 Loading mask.style.display = 'flex'; boxTitle.innerText = `正在获取:${title}`; boxContent.innerHTML = '<div style="text-align:center;padding:20px;">正在请求 RSSHub...<br>如果是第一次,可能需要输入密码</div>'; // 发起请求 GM_xmlhttpRequest({ method: "GET", url: url, onload: function (response) { if (response.status === 401) { boxContent.innerHTML = `<div style="color:red;text-align:center;"> ⚠️ 401 未授权<br>请先<a href="${url}" target="_blank">点击此处在新窗口打开</a>并输入账号密码,然后重试。 </div>`; return; } if (response.status !== 200) { boxContent.innerHTML = `❌ 请求失败:HTTP ${response.status}`; return; } // 解析 XML try { const parser = new DOMParser(); const xmlDoc = parser.parseFromString(response.responseText, "text/xml"); const items = xmlDoc.querySelectorAll("item"); const feedTitle = xmlDoc.querySelector("channel > title").textContent; boxTitle.innerText = feedTitle; let html = ''; // 只显示前 5 条 items.forEach((item, index) => { if (index >= 5) return; const iTitle = item.querySelector("title").textContent; const iLink = item.querySelector("link").textContent; const iPubDate = item.querySelector("pubDate") ? item.querySelector("pubDate").textContent : ''; // 简单处理描述,去掉太复杂的 HTML 标签 let iDesc = item.querySelector("description") ? item.querySelector("description").textContent : ''; iDesc = iDesc.replace(/<[^>]+>/g, "").substring(0, 100) + '...'; html += ` <div class="preview-item"> <a href="${iLink}" target="_blank" class="preview-item-title">${iTitle}</a> <div class="preview-item-date">${iPubDate}</div> <div class="preview-desc">${iDesc}</div> </div> `; }); if (items.length === 0) html = '<div style="padding:10px;">📭 该订阅源暂时没有内容</div>'; boxContent.innerHTML = html; } catch (e) { boxContent.innerHTML = `❌ 解析 XML 失败:${e.message}`; } }, onerror: function (err) { boxContent.innerHTML = `❌ 网络错误`; } }); } function renderPanelContent(feeds) { if (feeds.length === 0) { panel.innerHTML = '<div class="empty-tip">当前页面未探测到 RSS 源<br>或脚本尚未覆盖此规则</div>'; return; } let html = ''; feeds.forEach(feed => { // 1. 生成内网地址 const internalUrl = CONFIG.rsshub_internal + feed.path; const freshRssLink = `${CONFIG.freshrss_host}/i/?c=feed&a=add&url_rss=${encodeURIComponent(internalUrl)}`; // 2. 生成公网地址 const externalUrl = CONFIG.rsshub_external + feed.path; html += ` <div class="rss-item"> <span class="rss-title">${feed.title}</span> <div class="rss-actions"> <a href="${externalUrl}" target="_blank" class="rss-btn btn-raw">🟧RSS</a> <a href="javascript:void(0);" onclick="return false;" class="rss-btn btn-preview" data-preview-url="${externalUrl}" data-preview-title="${feed.title}"> 👀预览 </a> <a href="${freshRssLink}" target="_blank" class="rss-btn btn-add">➕订阅</a> </div> </div> `; }); panel.innerHTML = html; panel.querySelectorAll('.btn-preview').forEach(btn => { btn.addEventListener('click', function () { previewRSS(this.getAttribute('data-preview-url'), this.getAttribute('data-preview-title')); }); }); } // ========================================================================= // 🔄 SPA/Ajax Navigation Listener // ========================================================================= // Bilibili is a heavy SPA. We need to watch URL changes. let lastUrl = location.href; new MutationObserver(() => { const url = location.href; if (url !== lastUrl) { lastUrl = url; // 如果面板是打开的,重新生成内容 if (panel && panel.style.display === 'block') { generateFeeds(); } } }).observe(document, { subtree: true, childList: true }); // Run Initialization initUI(); })(); |
效果大概是这样:

在 B 站页面右下角提供了一个悬浮按钮,点击后会有一些通过规则提取的嗅探 RSS 源,然后有三个按钮,分别是「RSS」「预览」与「订阅」。
「RSS」就是公网打开 RSS 源文件;「订阅」就是跳转到 FreshRSS,订阅内网地址;「预览」会使用公网地址获取 RSS 信息,然后手搓了一个预览效果:

代理
RSSHub 有通用参数,如可以用 filter 筛选内容。FreshRSS 却并没有这样的功能,勉强接近的也就是「过滤动作」设置中的自动标记已读。
像我订阅了一下 Obsidian 的更新日志,结果里面好多都是 Early Access 的,我完全不关心。而且里面直接给我塞满了,400 多条更新日志,全给我干嘛,我又不看早期的。我还记得当时添加获取文章时,等了好久,我还以为卡死了呢。
于是我就想,能否将已经提供 RSS 的源,也经过 RSSHub,使用其通用参数进行过滤呢?我搜索了一番,得到的答案是不可行。
一开始认命了,就用 FreshRSS 自带的自动已读吧。但还是苦呀西,于是研究起来,顺带探索更多 Docker 命令。
具体修改
最终是创建了两个新文件:
1 2 3 4 5 6 7 8 | // lib/routes/custom/namespace.ts import type { Namespace } from '@/types'; export const namespace: Namespace = { name: 'Custom Proxy', url: 'rsshub.app', lang: 'en', }; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | // lib/routes/custom/proxy.ts import { promisify } from 'util'; import zlib from 'zlib'; import RSSParser from 'rss-parser'; import type { Data, Route } from '@/types'; import got from '@/utils/got'; const gunzip = promisify(zlib.gunzip); const inflate = promisify(zlib.inflate); const brotliDecompress = promisify(zlib.brotliDecompress); export const route: Route = { path: '/proxy', categories: ['other'], example: '/custom/proxy?url=https://rsshub.app/sspai/index', parameters: { url: 'RSS feed URL to proxy', }, features: { requireConfig: false, requirePuppeteer: false, antiCrawler: false, supportBT: false, supportPodcast: false, supportScihub: false, }, name: 'RSS Proxy', maintainers: [], handler, description: 'RSS Proxy', }; async function handler(ctx): Promise<Data> { const url = ctx.req.query('url'); if (!url) { throw new Error('URL parameter is required'); } // 1. 实例化一个新的 Parser,配置 customFields 以强制捕获 Atom 的 category 标签 const parser = new RSSParser({ customFields: { item: [ ['category', 'categories', { keepArray: true }], ['content', 'content', { keepArray: false }], ['summary', 'summary', { keepArray: false }] ], }, }); // 2. 发起请求 const response = await got({ method: 'get', url: url, responseType: 'buffer', headers: { 'Accept-Encoding': 'gzip, deflate, br', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 RSSHub/1.0', }, }); // 3. 数据获取与防御性处理 let data: Buffer; // @ts-ignore const rawData = response.data || response.body || response; if (Buffer.isBuffer(rawData)) { data = rawData; } else if (typeof rawData === 'string') { data = Buffer.from(rawData); } else { data = Buffer.from(''); } // 4. 安全读取 Headers // @ts-ignore const headers = response.headers || {}; const contentEncoding = (headers['content-encoding'] || '').toLowerCase(); // 5. 智能解压 try { if (data.length > 2 && data[0] === 0x1f && data[1] === 0x8b) { data = await gunzip(data); } else if (data.length > 2 && data[0] === 0x78) { data = await inflate(data); } else if (contentEncoding === 'br') { data = await brotliDecompress(data); } } catch (e) { console.error(`[Proxy] Decompression error for ${url}:`, e); } // 6. 解析 XML const contentString = data.toString('utf-8'); if (!contentString.trim()) { throw new Error('Empty response body'); } const feed = await parser.parseString(contentString); // 7. 映射数据 const items = (feed.items || []).map((item: any) => { const enclosure = item.enclosure; let categories: string[] = []; if (Array.isArray(item.categories)) { categories = item.categories.map((c: any) => { // 情况 1:RSS 标准 <category>Text</category> -> 解析为字符串 "Text" if (typeof c === 'string') return c; // 情况 2:Atom 标准 <category term="Text" /> -> 解析为 { $: { term: "Text" } } if (c?.$?.term) return c.$.term; // 兜底:尝试读取 name 或 label if (c?.name) return c.name; if (c?.label) return c.label; if (c?._) return c._; return null; }).filter((c) => c && typeof c === 'string'); } let authorName = ''; if (typeof item.creator === 'string') { authorName = item.creator; } else if (typeof item.author === 'string') { authorName = item.author; } else if (item.author && item.author.name) { // 处理 Atom 的 author 结构 authorName = Array.isArray(item.author.name) ? item.author.name[0] : item.author.name; } return { title: item.title ?? item.link ?? 'Untitled', // 优先顺序:content -> summary (Atom) -> description (RSS) description: item.content || item.summary || item.description || '', link: item.link, pubDate: item.isoDate ?? item.pubDate, author: authorName, category: categories, guid: item.guid || item.id || item.link, ...(enclosure?.url && { enclosure_url: enclosure.url, enclosure_type: enclosure.type, enclosure_length: enclosure.length ? Number(enclosure.length) : undefined, }), }; }); return { title: feed.title ?? 'RSS Proxy Feed', description: feed.description ?? '', link: feed.link ?? url, image: feed.image?.url, language: feed.language, item: items, ...(feed.itunes?.author && { itunes_author: feed.itunes.author }), ...(feed.itunes?.categories?.[0] && { itunes_category: feed.itunes.categories[0] }), }; } |
上面的改过很多次,初版 category, content 什么的都弄不了。详细展开太累了,我懒得写了。
最终可以用 /custom/proxy?url=<url> 进行代理转发,这样就可以用通用参数了,如 /custom/proxy?url=https://obsidian.md/changelog.xml&filter_title=Public&limit=10,Obsidian 更新日志 RSS 源只保留标题中含有 Public 的内容,同时限制在 10 条:

一开始就是在服务器里面,clone 在 ~/my-rss/RSSHub 中,然后 docker-compose.yml 的 image: diygod/rsshub:latest 改成 build: ./RSSHub。
非常久啊非常久,第一次构建跑了我快半个小时。虽然可行了,但还有一些瑕疵,例如说我上面说的 category 等没能成功转换等。而修改以后,即便有了缓存,第二次也要二十分钟左右。因此我急急急急死了,然后等待时间开摆了。
然后后面照着顾问讲的,开发模式进容器内部手动操作,安装个依赖四十多分钟还没弄完,看 kb 级别的速度我直接爆炸。而且 SSH 连接过去也好卡。
最后想到,我要不在本地 WSL 调试完成后,再打包成 Image 发过去呢?
确实可行,下载依赖、磁盘操作、编译、打包什么的都快多了。而且可以先 pnpm dev 调试,不用改一下重新构建一下看结果。
最后根据我的计时,大概更新一次需要 3min,过程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 | # 本机 WSL ## 构建与打包(约两分钟) $ docker build -t my-rsshub . && docker save my-rsshub | gzip > my-rsshub.tar.gz [+] Building 96.0s (35/35) FINISHED docker:default => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 8.39kB 0.0s => [internal] load metadata for docker.io/library/node:24-bookworm-slim 1.2s => [internal] load metadata for docker.io/library/debian:bookworm-slim 1.2s => [internal] load metadata for docker.io/library/node:24-bookworm 1.2s => [internal] load .dockerignore 0.0s => => transferring context: 738B 0.0s => [dep-builder 1/8] FROM docker.io/library/node:24-bookworm@sha256:b2b2184ba9b78c022e1d6a 0.0s => [docker-minifier 1/8] FROM docker.io/library/node:24-bookworm-slim@sha256:bf22df20270b6 0.0s => [dep-version-parser 1/4] FROM docker.io/library/debian:bookworm-slim@sha256:56ff6d36d4e 0.0s => [internal] load build context 0.5s => => transferring context: 422.26kB 0.5s => CACHED [chromium-downloader 2/5] WORKDIR /app 0.0s => CACHED [app 3/6] RUN set -ex && apt-get update && apt-get install -yq --no- 0.0s => CACHED [chromium-downloader 3/5] COPY ./.puppeteerrc.cjs /app/ 0.0s => CACHED [dep-version-parser 2/4] WORKDIR /ver 0.0s => CACHED [dep-version-parser 3/4] COPY ./package.json /app/ 0.0s => CACHED [dep-version-parser 4/4] RUN set -ex && grep -Po '(?<="rebrowser-puppete 0.0s => CACHED [chromium-downloader 4/5] COPY --from=dep-version-parser /ver/.puppeteer_version 0.0s => CACHED [chromium-downloader 5/5] RUN set -ex ; if [ "1" = 0 ] && [ "linux/amd64 0.0s => CACHED [app 4/6] COPY --from=chromium-downloader /app/node_modules/.cache/puppeteer /ap 0.0s => CACHED [app 5/6] RUN set -ex && if [ "1" = 0 ] && [ "linux/amd64" = 'linux/amd6 0.0s => CACHED [dep-builder 2/8] WORKDIR /app 0.0s => CACHED [dep-builder 3/8] RUN set -ex && corepack enable pnpm && if [ "0" = 0.0s => CACHED [dep-builder 4/8] COPY ./tsconfig.json /app/ 0.0s => CACHED [dep-builder 5/8] COPY ./patches /app/patches 0.0s => CACHED [dep-builder 6/8] COPY ./pnpm-lock.yaml /app/ 0.0s => CACHED [dep-builder 7/8] COPY ./package.json /app/ 0.0s => CACHED [dep-builder 8/8] RUN set -ex && export PUPPETEER_SKIP_DOWNLOAD=true && 0.0s => CACHED [docker-minifier 2/8] WORKDIR /minifier 0.0s => CACHED [docker-minifier 3/8] COPY --from=dep-version-parser /ver/* /minifier/ 0.0s => CACHED [docker-minifier 4/8] RUN set -ex && if [ "0" = 1 ]; then npm co 0.0s => [docker-minifier 5/8] COPY . /app 2.1s => [docker-minifier 6/8] COPY --from=dep-builder /app /app 11.3s => [docker-minifier 7/8] WORKDIR /app 0.0s => [docker-minifier 8/8] RUN set -ex && pnpm build && rm -rf /app/lib && 75.8s => [app 6/6] COPY --from=docker-minifier /app /app 2.0s => exporting to image 1.5s => => exporting layers 1.5s => => writing image sha256:655c4a88fd9425113f9ed71ea023f18f67e96842839040034a83d683711712a 0.0s => => naming to docker.io/library/my-rsshub 0.0s ## 打包大小大概 130M 左右(我排除了近 1G 的 .git) $ ls -l Permissions Size User Date Modified Name .rw-r--r-- 131M fesmoph 15 Jan 13:22 my-rsshub.tar.gz ## 传输(约 10s,我配置了 ~/.ssh/config) $ scp my-rsshub.tar.gz azure:~/my-rss my-rsshub.tar.gz 100% 125MB 15.2MB/s 00:08 # Azure 服务器 ## 加载(约半分钟) $ docker load < my-rsshub.tar.gz Loaded image: my-rsshub:latest ## 重新启动服务(约 5s) $ docker compose up -d [+] up 3/3 ✔ Container postgres Running 0.0s ✔ Container rsshub Recreated 0.9s ✔ Container freshrss Running 0.0s ## 大小 $ docker images IMAGE ID DISK USAGE CONTENT SIZE EXTRA my-rsshub:latest 098e1c2d9de8 982MB 463MB U |
不过没 .git 日志中会报错,但可以正常用。我也不知道为啥要带 .git[5],而且这也太大了吧。
B 站消息通知
写完这篇博文后,我再去加了点源,例如说 X 的关注信息、Pixiv 的图片动态、一些新闻资讯等。
然后跟顾问交流,用 Unhook - Remove YouTube Recommended & Shorts - Chrome 应用商店 将 YouTube 所有干扰项去掉了,甚至包括评论,让我只能看视频本身。
然后把 B 站 UP 全部取关,在看的都加入订阅源。类似 YouTube,将动态什么的都去掉。
但随即我想到,B 站消息通知能不能一起加入?这样把 B 站也当成一个纯粹看视频的工具。
不过我找了一下,没有。既然都改了这么多了,那就自己动手、丰衣足食,再写新的路由。
我打开消息通知界面,然后去网络那边找了一些可能的 API[6],写了个 prompt,VC 起来。然后再微调一下,结果不错,提了个 PR:feat(bilibili): add messages routes by pilgrimlyieu · Pull Request #20890 · DIYgod/RSSHub。自己先打包一个用上了。
这样一来 B 站的小红点也可以物理消灭了。不过这个新路由效果还得再看看。
最终结果
最后下面是我目前的 docker-compose.yml 的内容:
最终内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 | services: db: image: postgres:15-alpine container_name: postgres environment: - POSTGRES_USER=freshrss - POSTGRES_PASSWORD=freshrss_secret - POSTGRES_DB=freshrss volumes: - db-data:/var/lib/postgresql/data deploy: resources: limits: memory: 150M restart: always rsshub: # image: diygod/rsshub:latest # 原来的 image: my-rsshub container_name: rsshub ports: - 1200:1200 environment: - NODE_ENV=production - CACHE_TYPE=memory - DEBUG_INFO=false - GITHUB_ACCESS_TOKEN=<token> - BILIBILI_COOKIE_<uid>=<cookie> deploy: resources: limits: memory: 200M restart: always freshrss: image: freshrss/freshrss container_name: freshrss ports: - 8081:80 environment: - TZ=Asia/Shanghai - CRON_MIN=1,31 # 每半小时自动刷新一次 volumes: - freshrss-data:/var/www/FreshRSS/data - freshrss-extensions:/var/www/FreshRSS/extensions depends_on: - db restart: always volumes: db-data: freshrss-data: freshrss-extensions: |
这里 FreshRSS 设置了 CRON_MIN=1,31,即每小时的 1 分与 31 分会自动刷新,但是我后面注意到日志显示的实际上是每 1.5h 才刷新一次。
于是去翻找了一下,发现 FreshRSS 有一个配置,在「配置 > 归档」中,叫「最小自动刷新间隔」,默认是 1h。大概就是这个设置引发的问题,于是我改成了 25min(因为即便设置的是 1h,最终实际刷新却是 1.5h,我怀疑在 1h 的时候还是没到点,因此比 30min 略微调小一点)。现在确实能做到每半小时刷新一次了,更加及时了。至于后面会不会进一步提高频率就再说了。
其他服务
部署了 RSS 服务后,我想到,也许还可以部署点其他服务,因此让顾问给我提了一些建议。
目前除了上面提到的信息聚合外,我部署了 6 个运维服务:
- Homepage:主页,用来看服务器资源占用,同时作为一个目录
- Dozzle:监视器,可以监视容器的占用情况与日志
- Dockge:容器管理,用来线上管理 Docker 容器
- IT-Tools:开发中工具箱,内含许多有用的工具
- PrivateBin:在线剪贴板,可以用以分享内容、代码等
- Memos:碎片笔记,可以当备忘录用






当然,其实我就是随便玩玩 Docker,这些服务就是为了充分利用一下资源罢了,实际上我自己可能并不怎么会去用。但不得不说,看着挺爽的,确实勾起了我的兴趣——我以后也要搞个自己的服务器部署点服务玩玩。
一开始有的服务,如 Dockge, Memos 不支持设置 Base URL,我都打算放弃了。但最终还是开放端口给它们了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | <domain> { reverse_proxy localhost:3001 handle /monitor* { basic_auth { ... } reverse_proxy localhost:8888 } handle_path /paste* { reverse_proxy localhost:8083 } } # Dockge <domain>:5001 { reverse_proxy localhost:5002 } # Memos <domain>:5230 { reverse_proxy localhost:5231 } # IT-Tools <domain>:8443 { reverse_proxy localhost:8082 } |
下面是目前 ~/ops/docker-compose.yml 的内容:
配置内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | services: dozzle: image: amir20/dozzle:latest container_name: dozzle volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - DOZZLE_BASE=/monitor ports: - "8888:8080" restart: always privatebin: image: privatebin/nginx-fpm-alpine container_name: privatebin volumes: - ./privatebin-data:/srv/data ports: - "8083:8080" restart: always homepage: image: ghcr.io/gethomepage/homepage:latest container_name: homepage volumes: - ./homepage_config:/app/config - /var/run/docker.sock:/var/run/docker.sock:ro environment: - TZ=Asia/Shanghai - HOMEPAGE_ALLOWED_HOSTS=<domain> ports: - "3001:3000" restart: always dockge: image: louislam/dockge:1 container_name: dockge restart: always ports: - "5002:5001" volumes: - /var/run/docker.sock:/var/run/docker.sock - ./data/dockge:/app/data - /home/<user>/ops:/app/stacks/ops - /home/<user>/my-rss:/app/stacks/my-rss environment: - DOCKGE_STACKS_DIR=/app/stacks memos: image: neosmemo/memos:stable container_name: memos volumes: - ./memos-data:/var/opt/memos ports: - "127.0.0.1:5231:5230" restart: always it-tools: image: corentinth/it-tools:latest container_name: it-tools ports: - "127.0.0.1:8082:80" restart: always |
~/ops/homepage_config/settings.yaml 与 ~/ops/homepage_config/services.yaml 就没什么好说的了,中规中矩。不过为了性能考虑,改了一下 ~/ops/homepage_config/widgets.yaml 减小刷新率:
1 2 3 4 5 6 7 8 9 | - resources: cpu: true memory: true disk: / refresh: 30000 - search: provider: google target: _blank |
后记
已经部署服务几天了,从上面的图可以看出来我还没太适应。加了一些之前不会看的源,已读了一批还是不咋感兴趣的。
B 站热榜因为物理用 uBlock Origin 去除了,也好几天没看了,不过其他的平台似乎还是照旧,只是因为这几天基本上一直专注于 Docker,没在学校的时候那么拼命循环了。
依旧在不断适应中,例如说 UP 还没有完全加入,后面就是想去看哪个 UP 后,再次通过地址栏关键词进入,然后订阅后删掉这个历史建议。而其他的平台,可能会探索一下有什么更高质量的替代品,抑或是根据需要再订阅,然后再用物理手段隔绝。
也许这几天探索的信息聚合服务只会是昙花一现,但它已经留下了宝贵的资产了,不管是 RSS 相关知识、对 Docker 的更进一步认识,还是对信息聚合的渴望和对自己的服务器的憧憬,都已经足够回馈这些天的整日专注了。
Ctrl + L, Y K U 1, ↓ Enter ↩︎
当然,仅限于电脑网页端。只不过我最近手机卸载了 B 站应用,因此现在确实可以这么讲了。 ↩︎
例如知乎,我选择直接 uBlock Origin 去除消息通知。因为我之前已经注销了知乎账号,不过之前不登录看不了热榜了,再注册一遍,但不打算参与社区,于是通知完全就是累赘了。直接物理消除,眼不见心不烦。 ↩︎
上面说「RSSHub 可能需要一个内置浏览器」,一开始 RSSHub 是带了 Puppeteer 的,不过后面在优化过程中,注意到我没有使用过,以及占用不小,就移除了。同时被移除的还有 chromium 容器。 ↩︎
后面知道了,RSSHub 调试网页(即主页)以及报错网页会显示具体的 Git 信息(如 commit 等)。但是这个代价真的太大了。 ↩︎
手动抓完以后才想到,可以找整理过现成的:BAC Document。 ↩︎