



























去年我就在 Bear Blog 里折腾过一个 Stream 页面。原因无他,只是想减少对社交媒体的依赖。
然而,当时的实现方案大致是这样的:利用 Tana Outliner 的 Make API request 功能,把特定笔记推送到 Telegram Channel;再借助 Channel 的 RSS,让 Cloudflare Worker 读取,然后嵌入到 Bear Blog 的 Stream 页面。
很明显,这恰恰违背了我「减少对社交媒体依赖」的初衷。
我真正想要的是:让 Tana Outliner 里带有 #post 标签的笔记,能直接进入我自己博客的 Stream 页面。中间可以保留必要的技术层,但不应该再有一个社交媒体充当事实来源。
于是,真正的问题变成了:Stream 页面中的内容源头,到底应该在哪里?
对我来说,答案只能是 Tana Outliner。
因为内容最初就诞生在 Outliner 里。某个节点是否值得公开,通常也是在整理笔记时顺手判断出来的。既然如此,最自然的动作就不是「复制到另一个地方再发布」,而是给这个节点打上一个专门的标签,然后直接在 Outliner 中手动触发推送。
这个「手动」很重要。它不是自动同步,也不意味着所有笔记默认公开。它保留了一个微小却关键的判断动作:我仍然需要点一下按钮,确认这段内容可以进入公共页面。
那么,这条链路就应该是:
Tana Outliner 中的特定内容 -> Cloudflare Worker -> D1 / R2 -> Bear Blog Stream page
Bear Blog 无须承担内容管理的职责,它只是页面,是展示层。Outliner 才是输入端和判断端。中间的 Worker 和数据库则负责接收、保存、更新、删除,以及处理网页展示所需的各种细节。
这样一来,系统的边界就清晰多了。
一开始,我也想过用 Playwright 模拟登录 Bear Blog 后台,再直接修改某个页面。
技术上这当然可行。Bear Blog 提供了自定义页面和注入脚本的能力,如果非要用浏览器自动化,也不是做不到。
但这个方向很快就暴露出问题:它是在模拟人的操作,而不是重新定义数据流。
如果每次发布都要让脚本去「打开后台、找到页面、改 HTML、保存」,看起来像是自动化,实际上只是给手工操作包了一层外壳。它并没有回答更根本的问题:内容的状态由谁维护?更新时如何识别出是同一条?删除时如何形成闭环?图片和媒体文件又归谁所有?
所以我最终没有把 Bear Blog 当作后台,而是把它当作前台。
Bear Blog 的 Stream 页面里只需要放一个容器,再通过全局脚本加载内容。页面本身很轻,数据由 Cloudflare Worker 提供。这样,Bear Blog 依然保持着它原本的干净,复杂性也不会被硬塞进一个 Markdown 页面里。
最初看起来,Stream 页面似乎只需要「追加」。
比如,Outliner 里有一条打了 #post 标签的内容,点一下按钮,Worker 收到文本,存进 D1,页面刷新后便显示出来。到这里为止,一切都很简单。
但真正用上几分钟就会发现,追加只是最表层的需求,真正需要的是状态管理。
第一,同一条内容可能会被修改。 错别字、日期、措辞等,都可能在推送之后才发现。如果每次重新推送都新增一条,Stream 很快就会变成一堆重复记录。
所以每条内容都必须带上 Outliner 的节点 ID。这个 ID 会作为 source_id 存入数据库。Worker 收到请求后,如果数据库里已存在相同的 source_id,就更新旧记录;如果没有,才创建新记录。
这一步看似微不足道,却决定了这套系统能否长期使用。没有它,发布按钮就会带来清理负担;有了它,重新推送便只是一次修订。
第二,删除也要从源头发起。 Outliner 的 Make API request 只支持 POST、GET、PUT,没有 DELETE。于是我给 Worker 增加了一个专门的 POST 删除入口:照样传入当前节点 ID,由 Worker 在数据库里找到对应记录并软删除。
这比在数据库里手动删除更贴合工作流。既然发布发生在 Outliner 里,撤回也理应能在 Outliner 里完成。
第三,发布时间不应等于推送时间。
一开始,页面显示的是 Worker 接收请求的时间。这在技术上很自然,但在写作语境里却不对。我在 Outliner 的 #post 标签中本就设置了 Published Date 字段。重新推送一条旧内容只是修订,不应把它伪装成今天刚写出来的东西。
所以 payload 里需要把 Published Date 一并传给 Worker。页面展示时优先使用这个日期,只有当它缺失或解析失败时,才退回到接收时间。
当然还有一些其他细节,它们都不复杂,但共同决定了 Stream 页面究竟是「能跑的 demo」,还是「真正可以成为日常工作流一部分」的东西。
另一个真实的问题来自 Outliner 的内容结构。
Outliner 里的正文是 bullet list,在界面里看起来层次分明,但如果只取 ${Content},有时会被压平成一串文本,中间夹杂着半角逗号和空格。对中文内容来说,这种 ,, 或 。, 的痕迹非常刺眼。
这说明,我不能把 Outliner 的内容简单理解成一段字符串,它更像是一棵带结构的节点树。
后来我把 ${sys:context} 也传给了 Worker,让它有机会从更完整的上下文里恢复段落、列表和图片。这样,Stream 页面才更接近 Bear Blog 原生文章的观感:日期、段落、图片都自然地呈现出来,而不是像一段被 API 挤压过的日志。
这里也藏着一个有意思的边界:我并不想把 Stream 做成一个复杂的富文本系统。
它不需要支持所有排版,不需要评论,不需要点赞,也不需要转发。它只需要把 Outliner 里最常见的表达形式完好地呈现出来:短段落、列表、图片,以及必要的链接。
够用,比完整更重要。
Outliner 里的图片有自己的存储地址,但这些地址未必适合公开页面长期引用。即便当前能正常显示,也可能遇到跨域、权限、过期、缓存和可访问性等问题。把一个公开的 Stream 建立在这些临时地址之上,心里总是不踏实。
所以,图片需要在推送时被 Worker 接管。
当正文里出现 Markdown 图片,Worker 会尝试下载原图,并转存到我自己的 R2 路径下。这样,Stream 页面展示的图片就不再依赖 Outliner 的内部资源地址,而是进入了我自己可控的存储体系。
这一步让我重新意识到:所谓「发布」,并不是把文字从 A 复制到 B;发布意味着为内容承担起可访问性的责任。
文字要有稳定的记录,图片也要有稳定的位置。否则,页面今天还能打开,明天就可能只剩下一堆失效链接。
表面上看,这次是在给 Bear Blog 增加一个 Stream 页面。但更准确地说,是给我的写作系统增加了一个新的出口。
Bear Blog 仍然是 Bear Blog。它没有变成 CMS,也没有变成微博客平台。它只是通过一段脚本读取外部数据,把那些碎片内容展示出来。
Outliner 也仍然是 Outliner。它不需要变成发布后台,只是在 #post 上多了几个操作按钮:发布、更新、删除。
复杂性被收纳进了 Cloudflare Worker、D1 和 R2 这样的技术层,但这种复杂性属于代码、数据库和对象存储;一旦出了问题,我知道该去哪里排查,也清楚哪一层负责什么。
这比把状态分散在几个平台之间,更让我感到可控。
以前,一条内容在 Outliner、Telegram、Bear Blog 之间流转,我很难说清到底哪里才是源头。现在,源头回到了 Outliner:Bear Blog 是窗口,Cloudflare 是桥,D1 和 R2 是仓库。
系统不一定变少了,但关系更清楚了。
这次重做之后,Stream 页面对我来说不再是某个社交平台的影子,而是博客里一条更小、更可控的支流。内容依然从我的笔记系统出发,而公开,只是它经过判断之后的一种去向。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。