<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>automation on Reorx’s Forge</title><link>https://reorx.com/tags/automation/</link><description>Recent content in automation on Reorx’s Forge</description><image><url>https://reorx.com/images/forge-v2-compat.svg</url><link>https://reorx.com/images/forge-v2-compat.svg</link></image><generator>Hugo -- gohugo.io</generator><lastBuildDate>Wed, 14 Sep 2022 00:00:00 +0800</lastBuildDate><atom:link href="https://reorx.com/tags/automation/feed.xml" rel="self" type="application/rss+xml"/><item><title>使用 Railway 和 Supabase 零成本搭建 n8n 自动化平台</title><link>https://reorx.com/blog/0-cost-self-hosted-n8n-with-railway-and-supabase/</link><pubDate>Wed, 14 Sep 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/0-cost-self-hosted-n8n-with-railway-and-supabase/</guid><description>我的 n8n 「白嫖」托管新方案。</description><content:encoded><![CDATA[<p>在前文 <a href="/blog/sharing-my-footprints-automation/">使用自动化工作流聚合信息摄入和输出</a> 中，我介绍了如何在 NAS 提供的 Docker 环境安装 n8n，以及 n8n workflow 的使用方式。经过 3 个月的使用，我有了一些新的体会和尝试，重新设计了 n8n 的部署方案。本文将对这套新的方案进行说明，并分享数据迁移和第三方服务接入的实践。</p>
<h2 id="系统架构">系统架构</h2>
<figure class="center align-center">
  <img loading="lazy" src="/blog/0-cost-self-hosted-n8n-with-railway-and-supabase/images/system.svg" type="" alt=""  />

  <figcaption><p>系统架构图</p></figcaption>
</figure>

<p>我们所要搭建的这套服务有着如图所示的系统关系。</p>
<ul>
<li>Cloudflare: CDN 和 Proxy，用于加速网站访问，可选组件</li>
<li>Railway: PaaS 平台，提供 Docker 环境运行 n8n 的后端进程</li>
<li>Supabase: 作为数据库供 n8n 使用</li>
</ul>
<h2 id="完整部署流程">完整部署流程</h2>
<h3 id="supabase-创建数据库">Supabase: 创建数据库</h3>
<p>首先我们要在 <a href="https://supabase.com/">Supabase</a> 上注册一个账号，登入之后，点击 New project 创建新的数据库，命名为 n8n，注意要记住创建流程中输入的密码。</p>
<p>定位到 Project settings / Database / Connection info，将图中的信息记录下来即可进入下一步。</p>
<figure class="center align-center">
  <img loading="lazy" src="/blog/0-cost-self-hosted-n8n-with-railway-and-supabase/images/database-info.png" type="" alt=""  />

  <figcaption><p>Supabase Connection info</p></figcaption>
</figure>

<h3 id="github-创建-repo">GitHub: 创建 Repo</h3>
<p>在 Railway 上部署服务最便捷的方式就是关联一个 GitHub repo，你可以 fork 我准备好的: <a href="https://github.com/reorx/n8n-on-railway">reorx/n8n-on-railway</a></p>
<p>这个 repo 的内容非常简单，其中最核心的就是 <code>Dockerfile</code>，只有一行代码：</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="k">FROM</span><span class="s"> reorx/n8n-custom</span><span class="err">
</span></span></span></code></pre></div><p>它的作用是基于 <a href="https://hub.docker.com/repository/docker/reorx/n8n-custom/">reorx/n8n-custom</a> 定义新的镜像。Railway 会使用这个 <code>Dockerfile</code> 来自动构建和部署服务。</p>
<p><a href="https://hub.docker.com/repository/docker/reorx/n8n-custom/">reorx/n8n-custom</a> 是我个人维护的镜像，基于版本 <a href="https://github.com/n8n-io/n8n/tree/n8n%400.193.5">0.193.5</a> 构建 <sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>，目的是提前使用 <a href="https://github.com/n8n-io/n8n/pull/3354">PR</a> 的代码，以解决无法接入 Twitter 的问题。如果你对安全性有所担忧，也可以使用官方镜像 <a href="https://hub.docker.com/r/n8nio/n8n">n8nio/n8n</a>。</p>
<p>Repo 中还有 <code>example.env</code> 和 <code>export_workflow.sh</code> 两个文件，稍后我们会用到。</p>
<h3 id="railway-创建-project">Railway: 创建 Project</h3>
<p>注册或登入 <a href="https://railway.app/">Railway</a>，点击 New Project，选择 Deploy from GitHub repo，找到上一步创建的 repo 名字（n8n-on-railway）并选择，在下一个界面点击 Deploy now 开始部署。</p>
<h4 id="修改域名">修改域名</h4>
<p>不需要等待部署完成，我们直接进入 Project，点击代表 service 的卡片，打开 Settings。</p>
<figure class="center align-center">
  <img loading="lazy" src="/blog/0-cost-self-hosted-n8n-with-railway-and-supabase/images/railway-domains.png" type="" alt=""  />

  <figcaption><p>Railway Settings Domains</p></figcaption>
</figure>

<p>可以看到 Railway 已经为我们的服务分配了一个 <code>.up.railway.app</code> 的四级域名，这将是你访问 n8n Web UI 的入口。如果你对域名没有要求，可以保持不变，将这个域名记录下来；如果你希望使用自己的域名，请参考 <a href="https://docs.railway.app/deploy/exposing-your-app#custom-domains">Exposing Your App</a> 文档中的 Custom Domains 章节进行设置。</p>
<p>下图是我的域名 <code>n8n.reorx.com</code> 在 Cloudflare 的配置信息。由于 n8n 是一个重前端交互的网站，需要加载很多庞大的 JavaScript 文件，直接通过 Railway 访问效率很低。使用 Cloudflare 的 Proxy 功能，可以让这些静态资源走 Cloudflare CDN，加速网站访问。因此我非常推荐用 Cloudflare 来托管 n8n 自定义域名。</p>
<figure class="center align-center">
  <img loading="lazy" src="/blog/0-cost-self-hosted-n8n-with-railway-and-supabase/images/1663094576.png" type="" alt=""  />

  <figcaption><p>Cloudflare DNS config for n8n.reorx.com</p></figcaption>
</figure>

<h4 id="设置环境变量">设置环境变量</h4>
<p>确认域名后，我们要切换到 Variables 页面对环境变量进行设置。</p>
<figure class="center align-center">
  <img loading="lazy" src="/blog/0-cost-self-hosted-n8n-with-railway-and-supabase/images/railway-variables-editor.png" type="" alt=""  />

  <figcaption><p>Railway Variables Raw Editor</p></figcaption>
</figure>

<p>点击 Raw Editor 按钮，在弹出的输入框中粘贴 GitHub repo 中的 <code>example.env</code> 文件的内容，并调整各变量的值。其中：</p>
<ul>
<li><code>DB_</code> 开头的部分要根据 Supabase 的 Connection info 对号入座</li>
<li><code>VUE_APP_URL_BASE_API</code> 和 <code>WEBHOOK_URL</code> 填写上一步中所使用的域名。</li>
<li><code>N8N_ENCRYPTION_KEY</code> 是 n8n 加密 credentials 所使用的 key，请务必使用自己生成的随机字符串。如果没有这个变量，n8n 会随机生成一个并保存到文件系统中，在 Railway 的运行环境下重启后就会丢失，导致已保存的 credentials 无法解密 <sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>。</li>
<li><code>EXECUTIONS_DATA_PRUNE</code>: 打开 n8n 的自动化数据清理功能，使 n8n 定期清理在 <code>EXECUTIONS_DATA_MAX_AGE</code> 小时以前的执行记录，确保数据库体积不会上升过快 <sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup>。（Supabase 的免费额度只有 500M）</li>
</ul>
<p>以上这些变量均可在官方文档 <a href="https://docs.n8n.io/hosting/configuration/">Configuration</a> 中找到详细说明。</p>
<p>编辑完成后，点击 Update Variables，Railway 会开始新的部署任务。至此，我们就完成了 n8n 在 Railway 上使用 Supabase 数据库的部署流程。</p>
<h2 id="初始化和数据迁移">初始化和数据迁移</h2>
<p>如果一切正常，等 Railway 的部署任务完成后，即可通过 <a href="#%E4%BF%AE%E6%94%B9%E5%9F%9F%E5%90%8D">修改域名</a> 环节所确定的域名打开 n8n 网站。第一次访问时，n8n 会引导用户创建管理员账号，安全起见，请尽快完成这一步骤。</p>
<h3 id="workflow-导入">Workflow 导入</h3>
<p>如果你有已经备份好的 workflow，此时就可以进行导入了。先创建一个空的 workflow，然后在左侧菜单点击 &ldquo;Import from File&rdquo;，选择已有 workflow 的 json 文件即可完成导入。导入后，原 workflow 所使用的 credentials 会失效，需要手动选择或创建新的 credentials 才可以正常使用。</p>
<p><a href="https://github.com/reorx/n8n-workflows">reorx/n8n-workflows</a> 是我自己使用的一些 workflow，供读者参考。</p>
<h3 id="workflow-导出">Workflow 导出</h3>
<p>出于备份或分享的目的，我们可以导出 n8n 的 workflows。下面讲解如何将运行在 Railway 中的 n8n 的 workflows 进行导出。</p>
<ol>
<li>
<p>准备一个 Docker 环境，包含一个 Docker host 以及命令行工具 <code>docker</code></p>
</li>
<li>
<p>安装 Railway 的命令行工具 <code>railway</code> <sup id="fnref:4"><a href="#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup></p>
</li>
<li>
<p>进入 GitHub Repo 所在的目录，执行 <code>railway link</code>，选择 n8n 对应的 project</p>
</li>
<li>
<p>执行如下命令</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">EXPORT_ROOT</span><span class="o">=</span>/path/to/volume railway run bash export_workflows.sh
</span></span></code></pre></div><p>其中 <code>EXPORT_ROOT</code> 是文件输出路径，将作为 volume 挂载到所要执行的 Docker 容器中。</p>
<p><code>railway run</code> 会将线上 project 的环境变量注入到当前 shell 中，使最终执行的脚本 <code>export_workflows.sh</code> 使用与线上一致的环境。</p>
<p>执行完毕后，即可在 <code>EXPORT_ROOT</code> 下找到当前日期命名的目录，其中的 json 文件即为导出的 workflows。这些文件是以 workflow 的 id 命名，你还可以使用我的 <a href="https://github.com/reorx/n8n-workflows/blob/master/get_workflows.py">get_workflows.py</a> 脚本修改文件名为实际的可读名称，并清理其中的无用数据:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python get_workflows.py <span class="nv">$EXPORT_ROOT</span>/n8n-<span class="k">$(</span>date +%Y%m%d<span class="k">)</span> workflows/
</span></span></code></pre></div></li>
</ol>
<p>这个导出方法同样适用于 NAS Docker 中部署的 n8n，只需要去掉 Railway 相关的步骤即可。</p>
<h2 id="接入第三方服务">接入第三方服务</h2>
<p>n8n 的强大在于它内置了很多线上服务的 Integrations，仅需简单的配置即可完成接入。虽然官方有文档说明，但仍然有一些不大不小的坑，这里记录下我的一些配置技巧，希望能帮助你节省一些时间。</p>
<h3 id="google">Google</h3>
<p>Google 的接入相对比较复杂，请跟随文档 <a href="https://docs.n8n.io/integrations/builtin/credentials/google/">Integrations: Google</a> 的详细说明进行操作。这里只说下文档中没有提到的一个注意事项。</p>
<p><del>在创建 OAuth consent screen 后，要将 Publishing status 设为 Testing，否则 Google 的 OAuth 页面会显示应用未通过审核的警告。还要将想要接入的 Google 账号邮箱加入 Test users 列表，否则无法再 Testing 模式下通过 OAuth 验证。</del></p>
<blockquote>
<p>以下内容为 2022-09-24 更新</p>
</blockquote>
<p>在创建 OAuth consent screen 后，要将 Publishing status 设为 <strong>Production</strong>，否则一周后 OAuth token 就会过期 <sup id="fnref:5"><a href="#fn:5" class="footnote-ref" role="doc-noteref">5</a></sup>。虽然这种方式会导致 OAuth 认证页面显示应用未通过审核的警告（点击左下角 &ldquo;Go to …&rdquo; 可以绕过），但总好过每周重新连接一次的麻烦。</p>
<blockquote>
<p>以下内容为 2022-12-01 更新</p>
</blockquote>
<p>只有当服务的域名为自定义域名且为 authorized domain 时才可以将 Publishing status 设为 Production，否则一段时间后 OAuth 将不可用，<a href="https://support.google.com/cloud/answer/10311615#verification-status">Verification Status</a> 异常。关于如何将自己的域名在 Google 进行验证，请参考 <a href="https://support.google.com/a/topic/1409901">Verify your domain (host-specific steps) - Google Workspace Admin Help</a>.</p>
<figure class="center align-center">
  <img loading="lazy" src="/blog/0-cost-self-hosted-n8n-with-railway-and-supabase/images/1663950818.png" type="" alt=""  />

  <figcaption><p>OAuth consent screen: Publishing status</p></figcaption>
</figure>

<figure class="center align-center">
  <img loading="lazy" src="/blog/0-cost-self-hosted-n8n-with-railway-and-supabase/images/1663950640.png" type="" alt=""  />

  <figcaption><p>OAuth warning for Production app without verification</p></figcaption>
</figure>

<h3 id="twitter">Twitter</h3>
<p>Twitter 由于这两年来 Developer Portal 的大幅改造，实际操作中可能与文档 <a href="https://docs.n8n.io/integrations/builtin/credentials/twitter/">Integrations: Twitter</a> 有许多不一致，但只要确保以下几点，应该可以避免大部分问题。</p>
<ol>
<li>确保创建的 App 在 &ldquo;Standalone app&rdquo; 这个分类下</li>
<li>确保 &ldquo;User authentication settings&rdquo; 按下图所示配置 
<figure class="center align-center">
  <div class="image-size-control" style="height: 500px;">
    <img loading="lazy" src="/blog/0-cost-self-hosted-n8n-with-railway-and-supabase/images/1662887928.png" type="" alt=""  />
  </div>

  <figcaption><p>User authentication settings</p></figcaption>
</figure>
</li>
<li>确保向 n8n 填入的 Consumer Key 和 Consumer Secret 来自下图中红框所在的位置 
<figure class="center align-center">
  <div class="image-size-control" style="height: 500px;">
    <img loading="lazy" src="/blog/0-cost-self-hosted-n8n-with-railway-and-supabase/images/1662887896.png" type="" alt=""  />
  </div>

  <figcaption><p>Consumer Keys</p></figcaption>
</figure>
</li>
</ol>
<h3 id="pinboard">Pinboard</h3>
<p>n8n 没有内置 Pinboard 接入，不过 <a href="https://www.pinboard.in/api/">Pinboard API</a> 设计非常简洁，我们可以手动实现接入。</p>
<p>创建 credential 时选择 &ldquo;Query Auth&rdquo;，向 &ldquo;Name&rdquo; 填入 <code>auth_token</code>，向 &ldquo;Value&rdquo; 填入从 <a href="https://pinboard.in/settings/password">Pinboard password</a> 页面得到的 API Token。完成后，即可在 HTTP Request node 中使用。</p>
<figure class="center align-center">
  <img loading="lazy" src="/blog/0-cost-self-hosted-n8n-with-railway-and-supabase/images/1663144028.png" type="" alt=""  />

  <figcaption><p>HTTP Request node with pinboard Query Auth</p></figcaption>
</figure>

<h3 id="github">GitHub</h3>
<p>n8n 虽然有内置的 GitHub 接入，但并非所有 API 都被支持，因此我建议使用 HTTP Request 手动配置验证。</p>
<p>GitHub 提供 PAT (Personal Access Token)，与 Pinboard 的 API Token 类似，相比 OAuth 更容易配置。</p>
<p>GitHub API 支持在 HTTP Header 中通过 <code>Authorization</code> 字段进行验证，其值为 <code>Bearer</code> + PAT <sup id="fnref:6"><a href="#fn:6" class="footnote-ref" role="doc-noteref">6</a></sup>。</p>
<p>首先打开 <a href="https://github.com/settings/tokens">https://github.com/settings/tokens</a>, 点击 Generate new token，勾选所需权限。具体根据所要请求的 API 来决定，一般来说至少要勾上 <code>repo</code> 和 <code>user</code>。创建完成后复制结果即为 <code>$PAT</code>。</p>
<p>然后在 n8n 中创建 Header Auth，&ldquo;Name&rdquo; 填写 <code>Authorization</code>，Value 填写 <code>Bearer $PAT</code>（将 <code>$PAT</code> 替换为上一步的结果）。完成后，即可在 HTTP Request node 中使用。</p>
<figure class="center align-center">
  <img loading="lazy" src="/blog/0-cost-self-hosted-n8n-with-railway-and-supabase/images/1663145536.png" type="" alt=""  />

  <figcaption><p>HTTP Request node with GitHub Header Auth</p></figcaption>
</figure>

<h2 id="总结">总结</h2>
<p>随着 PaaS 和 Serverless 平台的兴起，在很多场景下它们都能够代替 NAS 成为自托管服务的最佳选择。个人使用免费额度一般都绰绰有余，无论对于想要快速试验新产品的开发者，还是喜欢体验 SaaS 服务的业余爱好者，现在都是一个非常好的时代。</p>
<p>之前将 n8n 部署在 NAS 上平均每天会有几十条错误报警，一部分是 SQLite 在机械硬盘上频繁读写触发事务锁竞争，一部分则是代理不稳定造成网络访问失败。在迁移到 Railway + Supabase 的方案后，两个问题都得到了解决。PostgreSQL 有着更好的连接和并发性能；而 Railway 的运行环境本身就处于外网，自然也不会遇到代理失效的问题。</p>
<p>Railway 也可以提供包含 PostgreSQL 的全托管方案，但独立运行 PostgreSQL 不仅资源消耗大，显著占用免费额度，而且不如 Supabase 这种专业的 DaaS 稳定和安全。Cloudflare 的免费 CDN 服务，补充了 Railway 分发能力的不足，也降低了出口带宽的成本。几个平台相互搭配，取长补短，使新的方案可以用最低的成本实现最佳的性能和体验。</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>代码见 <a href="https://github.com/reorx/n8n/tree/self-use">https://github.com/reorx/n8n/tree/self-use</a>&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>论坛里有人遇到过同样的问题: <a href="https://community.n8n.io/t/credentials-error/181">https://community.n8n.io/t/credentials-error/181</a>&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3">
<p>参考官方文档 <a href="https://docs.n8n.io/hosting/scaling/execution-data/#enable-data-pruning">Execution data - Enable data pruning</a>&#160;<a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:4">
<p>参考官方文档 <a href="https://docs.railway.app/develop/cli">CLI</a>&#160;<a href="#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:5">
<p><a href="https://community.n8n.io/t/youtube-refresh-token-expired/5319">YouTube Refresh Token Expired? - Questions - n8n</a>&#160;<a href="#fnref:5" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:6">
<p>见 GitHub 文档 <a href="https://docs.github.com/en/rest/guides/getting-started-with-the-rest-api#authenticating">Getting started with the REST API - Authenticating</a>&#160;<a href="#fnref:6" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><item><title>使用自动化工作流聚合信息摄入和输出</title><link>https://reorx.com/blog/sharing-my-footprints-automation/</link><pubDate>Wed, 25 May 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/sharing-my-footprints-automation/</guid><description>展示我是如何用 n8n 将 Twitter, YouTube, GitHub, Douban 等服务的动态同步到 Telegram Channel，实现个人数字生活的信息聚合。</description><content:encoded><![CDATA[<p>欢迎关注我的 Telegram 频道: <a href="https://t.me/reorx_share">Reorx’s Footprints</a>。这篇文章讲述我为什么创建这个频道，以及如何用 n8n 将 Twitter, YouTube, GitHub, Douban 等服务的动态同步过去，实现个人数字生活的信息聚合。</p>
<h2 id="前言">前言</h2>
<p><a href="https://en.wikipedia.org/wiki/Cyberspace">赛博空间</a>构成了我的精神世界。</p>
<p>我的大脑从苏醒的那一刻开始渴求信息，大量新鲜的信息，它们比饮用水更早地进入我的身体。每天睁开眼睛，我做的第一件事情是拿起手机，逐个查看昨夜的消息提醒。起床之后，我会在马桶上浏览 RSS 阅读器和 Telegram 频道中的新闻，让思维复苏的过程伴随新知识的冲击。我已经习惯了如此，一边加载昨日的进展并思考今日的安排，一边点开链接，使用不同的工具进行阅读、收藏和记录。</p>
<p>一天之中的碎片时间，我也会不断地在 Twitter、RSS、Telegram 之间切换，这对我来说并不是信息焦虑，而是自然而然的生活习惯。在工作上，我也完全依赖于网络，在解决问题的过程中搜索阅读大量的网站，将之转化为代码和学习笔记。</p>
<p>我就像一台不停运行的收集器和过滤器，持续地消费、生产信息。但大脑被设计用于分析和联想，却不擅长高效精确的索引，这些信息散落在各个服务中，我可以想起一些关键词或模糊的上下文，但总是很难快速获取到。我需要一个能将它们聚合在一起的工具，或者工作流，这样既可以方便自己的回溯，也可以将这些经过提纯的信息分享出来，为和我兴趣接近的人提供另一个输入管道。</p>
<h2 id="聚合什么信息">聚合什么信息？</h2>
<p>不是所有在赛博空间产生的信息都需要被聚合。一些被动产生的如搜索记录、地理位置记录、个人健康信息等，他们可以用作 <a href="https://en.wikipedia.org/wiki/Quantified_self">quantified self</a>（这是另一个话题），但并不在我的考虑范畴内。</p>
<p>我要聚合的主要有两类信息，一类是是由我主动创造的，比如Twitter 上发表的看法、GitHub 上为开源项目提交的问题；一类是我主动收藏或标记的，Pinboard 上收藏的书签、YouTube 上点赞过的视频均属此列。它们有的直接流入我的知识库<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>，有的保存在线上服务中，作为知识的原材料储备，留待日后进行消化吸收。</p>
<p>考虑到分享的目的，这些信息中还要去掉功能性的部分，只保留我确认有价值的那些。比如 Twitter Like 有时会用于社交目的，作为一种赞赏的回应；YouTube, Spotify 中各种 playlist 很多只是为了满足收集癖，不一定具有普适性；豆瓣的想看、想读只是待确认的 enqueue，只有看过、读过才是值得推荐的。</p>
<h3 id="内容来源">内容来源</h3>
<p>基于以上考虑，我将自己主要摄入和产生信息的来源进行了整理，得到如下列表：</p>
<ul>
<li>
<p><strong>Telegram</strong></p>
<p>我在 Telegram 中分享一些一闪而过的想法、菜谱，转发浏览其他频道时看到特别有意思的内容。</p>
<ul>
<li>Fleeting thoughts 💭</li>
<li>Recipe 🍳</li>
<li>Forward 🔄</li>
</ul>
</li>
<li>
<p><strong>Twitter</strong> 🐦</p>
<p>我的所有 Tweet 和 Retweet 都经过认真思考，是希望被阅读的内容创作。</p>
<ul>
<li>Tweet</li>
<li>Retweet</li>
</ul>
</li>
<li>
<p><strong>Instapaper</strong> 📖</p>
<p>我这样管理我的待看列表：对于没确认是否有价值的那些，我会加入 TODO 或 Telegram Saved Messages。只有当我认真阅读过并认为值得再次阅读，我才会将其加入 Instapaper 中。</p>
</li>
<li>
<p><strong>Blog</strong> 📝</p>
<p>我的个人博客，通过 RSS 发布更新。</p>
</li>
<li>
<p><strong>Music</strong> 🎵</p>
<p>我主要使用 Spotify 听歌，类似 Instapaper，只有我愿意反复听的歌曲才会加入 Liked Songs，可以看做是我基于个人音乐品味的推荐。</p>
<ul>
<li>Spotify</li>
</ul>
</li>
<li>
<p><strong>YouTube</strong> ▶️</p>
<p>YouTube 是我探索世界无限可能性的地方，it&rsquo;s about everything and nothing。每个 Like 过的视频都有着让我会心一笑或拍案叫绝的亮点。</p>
</li>
<li>
<p><strong>Bookmark</strong> 🔖</p>
<p>我从 2013 年开始使用 Pinboard，虽然 Pinboard 的社交属性很弱，但我一直坚持用 private/public 属性来区分我的收藏，所有 public 的书签都具备一定的分享价值。</p>
<ul>
<li>Pinboard</li>
</ul>
</li>
<li>
<p><strong>GitHub</strong></p>
<p>由于对 Pinboard 的重度使用，大部分 GitHub 项目都收藏在 Pinboard 中，Star 的是我觉得值得鼓励的项目。每个 Issue 和 Pull-request 我都有认真撰写，他们是我参与开源活动的记录。</p>
<ul>
<li>Star 🌟</li>
<li>Issue ⚡️</li>
<li>Pull-request ⤴️</li>
</ul>
</li>
<li>
<p><strong>Douban</strong></p>
<p>我主要用 Douban 记录看过的电影和书。如上文所说，这里只分享在看、看过的电影和在读、读过的书籍。后续考虑对评分进行过滤。</p>
<ul>
<li>Movie 📺</li>
<li>Book 📖</li>
</ul>
</li>
</ul>
<h3 id="展现形式">展现形式</h3>
<p>我选择 Telegram Channel 作为信息聚合的目的地。一方面我许多信息都来源于 Telegram ，另一方面它也非常易于使用和接入。</p>
<p>Telegram 以下几种功能极大程度地丰富了信息的展现形式<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>：</p>
<ul>
<li>
<p>链接预览</p>
<p>Telegram 会将消息中附带的链接进行抓取，将摘要和图片显示在消息下方。</p>
</li>
<li>
<p>富文本格式</p>
<p>Telegram 消息支持 Markdown 和 HTML 两种富文本格式的解析，可以实现粗体、斜体、下划线、超链接、行内和块级代码等样式。</p>
</li>
<li>
<p>标签索引</p>
<p>Telegram 的消息支持 <code>#</code> 为前缀的标签，在频道/群组内点击标签即可对消息进行筛选，这让结构化的信息索引成为可能。</p>
</li>
</ul>
<h2 id="实现自动化同步">实现自动化同步</h2>
<p>我使用 <a href="https://github.com/n8n-io/n8n">n8n</a> 来实现整套信息收集和聚合的自动化工作流<sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup>。下面将对我的操作方法和配置进行讲解，参照这些说明，你也可以快速搭建一套属于自己的自动化信息分享系统。</p>
<h3 id="n8n-概念说明">n8n 概念说明</h3>
<ul>
<li>
<p><strong>workflow</strong></p>
<p>用于定义一条自动化工作流，由多个 node 以及 node 之间的指向关系构成。</p>
</li>
<li>
<p><strong>node</strong></p>
<p>构成 workflow 的基本单位，每个 node 可以完成一个独立的任务，比如定时触发、获取数据、筛选数据、发送信息等。</p>
</li>
<li>
<p><strong>credential</strong></p>
<p>访问凭据，用于在访问在线服务接口时进行身份验证。workflow 内只存储 credential 的 id，只有在运行时才会获取其中的实际内容，这一机制确保了 workflow 在分享时不会造成凭据泄露。</p>
</li>
</ul>
<h3 id="安装-n8n">安装 n8n</h3>
<p>n8n 支持使用 Docker 进行自部署，它由 Nodejs 开发，使用 SQLite 作为数据库，没有其他外部依赖，因此理论上可以部署在任何 Docker 或 Nodejs 运行环境。</p>
<p>为了方便调试，我将 n8n 部署在家里的 NAS 上，你也可以尝试将其部署在 <a href="https://railway.app?referralCode=XkWaVV">Railway</a> 等 PaaS 平台。</p>
<p>正常情况下，参照 n8n 的 <a href="https://docs.n8n.io/hosting/installation/docker/">Docker Installation</a> 文档即可完成初步运行。但在使用中，我发现了 n8n Twitter 接入的 bug，在对代码进行了修改后，不得不在本地构建 Docker 镜像 (见 <a href="https://github.com/n8n-io/n8n/tree/master/docker/images/n8n-custom">n8n - Custom Image</a>)。如果你需要使用 Twitter 接入，在这个 <a href="https://github.com/n8n-io/n8n/pull/3354">PR</a> 合并之前，建议使用我构建好的镜像 <a href="https://hub.docker.com/r/reorx/n8n-custom">reorx/n8n-custom</a>。</p>
<p><details >
  <summary markdown="span">附上我的 docker-compose.yml 文件作为参考</summary>
  <pre tabindex="0"><code>version: &#39;3&#39;
services:
  n8n:
    #image: n8nio/n8n
    image: n8n-custom
    ports:
      - 5678:5678
    volumes:
      - /share/CACHEDEV2_DATA/Misc/AppData/n8n:/home/node/.n8n
      - /share/CACHEDEV1_DATA/homes/reorx/Misc_Backup/:/backup
    environment:
      - PUID=1000
      - PGID=1000
      - VUE_APP_URL_BASE_API=http://harrogath-local.com:5678/
      - WEBHOOK_URL=http://harrogath-local.com:5678/
      - GENERIC_TIMEZONE=Asia/Shanghai
      - TZ=Asia/Shanghai
      - N8N_LOG_LEVEL=verbose
    restart: unless-stopped
</code></pre>
</details></p>

<p>由于许多服务需要通过 OAuth 进行接入，我们运行的 n8n 需要有一个固定的访问地址，以便接收 OAuth callback url 的访问。我的做法是在局域网内将 <code>harrogath-local.com</code> 这个域名指向 n8n 所在的机器，并为 n8n 设置如下环境变量，使其显示的 OAuth callback 与域名一致。</p>
<pre tabindex="0"><code>VUE_APP_URL_BASE_API=http://harrogath-local.com:5678/
WEBHOOK_URL=http://harrogath-local.com:5678/
</code></pre><p>启动完成后，即可通过 <code>http://harrogath-local.com:5678/</code> 访问 n8n 的 web 界面了。</p>
<h3 id="创建-workflows">创建 Workflows</h3>
<p>我的每个 workflow 都分享到了 GitHub <a href="https://github.com/reorx/n8n-workflows">reorx/n8n-workflows</a> 仓库中，通过复制 <code>.json</code> 文件的内容、粘贴在 n8n 的 workflow 编辑界面，即可对 workflow 进行复用。也可以使用 n8n 的命令直接将 workflow 导入到 n8n 的数据库中 (见文档 <a href="https://docs.n8n.io/reference/cli-commands/#import-workflows-and-credentials">Import workflows and credentials</a>)。注意无论使用何种方式导入，credentials 都需要重新配置。</p>
<h4 id="new-tweet-to-telegram">New tweet to telegram</h4>
<p>Link: <a href="https://github.com/reorx/n8n-workflows/blob/master/workflows/new%20tweet%20to%20tg.json">n8n-workflows/workflows/new tweet to tg.json</a></p>
<p>这一 workflow 实现了每 5 分钟搜索一次我的 Twitter 时间线，将最新的 tweets 发送到 Telegram Channel。</p>
<p><img loading="lazy" src="/blog/sharing-my-footprints-automation/images/n8n.png" type="" alt=""  /></p>
<p>第一个 node 是 Interval，它是一个 trigger，可以设置重复运行的间隔时长。</p>
<p>随后是 Twitter node，它通过 OAuth 连接了我的账户，使用 <code>from:novoreorx</code> 搜索语法来获取我最近的推文条目。</p>
<p>IF reply to other 是一个 IF node，通过条目中的 <code>in_reply_to_screen_name</code> 字段判断推文是否是对别人的回复，只有否才可以走向下一步。意味着只有单独创建或回复自己的推文才会被转发。</p>
<p>Set Retweeted node 根据条目中的 <code>retweeted_status</code> 字段，识别 retweet 条目，并将原推的 URL 拼凑出来，赋值给新的字段 <code>retweeted</code> 和 <code>retweetedUrl</code>，以便后续使用。</p>
<blockquote>
<p><strong>2022-08-12 updated:</strong></p>
<p>Set Retweeted node 在后续的维护中打开了 &ldquo;Keep Only Set&rdquo; 选项，只保留其中所定义的字段，实际作用已经与名称不符，称为 &ldquo;Set properties&rdquo; 更准确一些。</p>
<p>新增了 <code>proxyUrl</code> 字段，使用 <a href="https://github.com/dylanpdx/BetterTwitFix">vxTwitter</a> 服务的域名 <code>vxtwitter.com</code> 替代 <code>twitter.com</code>，以在 Telegram 中有更好的预览效果</p>
</blockquote>
<p>Function 是我写的一段 JavaScript 代码，它使用了 n8n runtime 内置的 <a href="https://docs.n8n.io/integrations/core-nodes/n8n-nodes-base.function/#method-getworkflowstaticdatatype">getWorkflowStaticData</a> 函数，记录每次更新的第一个条目 <code>lastItemId</code>，通过与上一次的记录进行对比，确保只有新的条目会被输送到下一个 node 执行。如果没有 <code>lastItemId</code>，则只返回第一个条目，避免冷启动时造成大量信息的无效转发。这段代码基本在每个 workflow 中都有用到，下面将不再赘述。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="cm">/* only return new items */</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">staticData</span> <span class="o">=</span> <span class="nx">getWorkflowStaticData</span><span class="p">(</span><span class="s1">&#39;global&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">lastItemId</span> <span class="o">=</span> <span class="nx">staticData</span><span class="p">.</span><span class="nx">lastItemId</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;lastItemId&#39;</span><span class="p">,</span> <span class="nx">lastItemId</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">firstItem</span> <span class="o">=</span> <span class="nx">items</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">newItems</span> <span class="o">=</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">getId</span><span class="p">(</span><span class="nx">item</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">item</span><span class="p">.</span><span class="nx">json</span><span class="p">.</span><span class="nx">id</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">lastItemId</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">item</span> <span class="k">of</span> <span class="nx">items</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">getId</span><span class="p">(</span><span class="nx">item</span><span class="p">)</span> <span class="o">===</span> <span class="nx">lastItemId</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">break</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="nx">newItems</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="nx">item</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">newItems</span> <span class="o">=</span> <span class="p">[</span><span class="nx">firstItem</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">staticData</span><span class="p">.</span><span class="nx">lastItemId</span> <span class="o">=</span> <span class="nx">getId</span><span class="p">(</span><span class="nx">firstItem</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="c1">// Reverse the order so that items are sent from old to new
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="k">return</span> <span class="nx">newItems</span><span class="p">.</span><span class="nx">reverse</span><span class="p">()</span>
</span></span></code></pre></div><p>经过 Function 的处理后，有效条目会被送往 Telegram node，进行 message 组装，最终发送到频道。</p>
<p><img loading="lazy" src="/blog/sharing-my-footprints-automation/images/n8n-1.png" type="" alt=""  /></p>
<p>n8n 的表达式 (Expression) 有一个可以预览的编辑界面，左侧会展示当前 node 从上一个 node 获取到的输入数据 (Input Data)，点击即可将模板变量插入到 Expression 中。模板语法 <code>{{ }}</code> 中可以使用 JavaScript 语法，这里我通过 <code>$json[&quot;retweeted&quot;]</code> 来决定 tag 为 <code>#retweet</code> 还是 <code>#tweet</code>。推文 URL 放在了 <code>&lt;a&gt;</code> 标签中，既可以触发 Telegram 的 link preview 功能，也避免展示太长的 URL 影响可读性。</p>
<blockquote>
<p><strong>2022-08-12 updated:</strong></p>
<p>Twitter 的 search API 默认会将长推文截断，只有加了 <code>tweet_mode=extended</code> 参数才能确保显示全文，不过推文内容字段会从 <code>tweet</code> 变为 <code>full_text</code>。按下图所示添加参数后，请在 &ldquo;Set Retweeted&rdquo; node 中修改字段映射 <code>text</code> → <code>tweet</code> 为 <code>full_text</code> → <code>tweet</code>。
<img loading="lazy" src="/blog/sharing-my-footprints-automation/images/tweet-mode-extended.png" type="" alt=""  /></p>
</blockquote>
<h4 id="blog-rss-to-telegram">Blog RSS to telegram</h4>
<p>Link: <a href="https://github.com/reorx/n8n-workflows/blob/master/workflows/blog%20rss%20to%20tg.json">n8n-workflows/workflows/blog rss to tg.json</a></p>
<blockquote>
<p>从第二个 workflow 开始，我将只对大致逻辑进行说明，不再详细讲解每个 node 的实现方式，有兴趣的读者可以在 workflow 编辑器中自行查看。</p>
</blockquote>
<p>这一 workflow 实现了每 10 分钟检查一次博客的 RSS，将最新的文章发送到 Telegram Channel。</p>
<p><img loading="lazy" src="/blog/sharing-my-footprints-automation/images/n8n-2.png" type="" alt=""  /></p>
<p>RSS Feed Read node 填写了博客的 RSS 地址，Function 同样是实现了有状态的更新判断，唯一的不同是将 <code>getId</code> 函数改为从 <code>item.json.guid</code> 获取 item id。</p>
<h4 id="spotify-like-to-telegram">Spotify like to telegram</h4>
<p>Link: <a href="https://github.com/reorx/n8n-workflows/blob/master/workflows/spotify%20likes%20to%20tg.json">n8n-workflows/workflows/spotify likes to tg.json</a></p>
<p>这一 workflow 实现了每 1 小时检查一次 Spotify Liked Tracks，将最新的歌曲发送到 Telegram Channel。</p>
<p><img loading="lazy" src="/blog/sharing-my-footprints-automation/images/n8n-3.png" type="" alt=""  /></p>
<h4 id="youtube-like-to-telegram">YouTube like to telegram</h4>
<p>Link: <a href="https://github.com/reorx/n8n-workflows/blob/master/workflows/youtube%20like%20to%20tg.json">n8n-workflows/workflows/youtube like to tg.json</a></p>
<p><img loading="lazy" src="/blog/sharing-my-footprints-automation/images/n8n-4.png" type="" alt=""  /></p>
<p>这一 workflow 实现了每 30 分钟检查一次 YouTube Liked Playlist，将最新的视频发送到 Telegram Channel。</p>
<p>这里需要注意的是，Liked 是一个内置的 playlist，因此其 ID 与自己创建的不一样，通过访问 YouTube get playlists API，得到其 ID 为 <code>LL</code>，顺利完成了这一 node 的配置。</p>
<h4 id="pinboard-new-bookmark-to-telegram">Pinboard new bookmark to telegram</h4>
<p>Link: <a href="https://github.com/reorx/n8n-workflows/blob/master/workflows/pinboard%20to%20tg.json">n8n-workflows/workflows/pinboard to tg.json</a></p>
<p>这一 workflow 实现了每 5 分钟检查一次 Pinboard 最近的书签，将最新的书签发送到 Telegram Channel。</p>
<p><img loading="lazy" src="/blog/sharing-my-footprints-automation/images/n8n-5.png" type="" alt=""  /></p>
<p>n8n 没有 Pinboard 支持，但我们可以通过 HTTP Request 直接访问 Pinboard 的接口。古怪的是 Pinboard 没有官方的 API 文档，于是我对一些开源的 Pinboard SDK 代码进行阅读，得到了我所需要的接口 <code>https://api.pinboard.in/v1/posts/recent</code>。</p>
<p>Pinboard 接口返回的数据并不直接是一个列表，而是一个形如 <code>{&quot;posts&quot;: [...]}</code> 的结构，这里用了 Item Lists 将 <code>posts</code> 字段提取出来作为 items 向后传递。</p>
<h4 id="github-activities-to-telegram">GitHub activities to telegram</h4>
<p>Link: <a href="https://github.com/reorx/n8n-workflows/blob/master/workflows/github%20activities%20to%20tg.json">n8n-workflows/workflows/github activities to tg.json</a></p>
<p>这一 workflow 实现了每 10 分钟检查一次 GitHub 的动态，筛选出 star, pull-request 和 issue 发送到 Telegram Channel。</p>
<p><img loading="lazy" src="/blog/sharing-my-footprints-automation/images/n8n-6.png" type="" alt=""  /></p>
<p>GitHub 可以通过 <code>https://github.com/$username.atom</code> 获取用户公开的动态信息，因此不需要复杂的 API 接入，直接使用 RSS Feed Read 即可获取到我们所需要的数据。</p>
<p>在 Function filter 中，我用正则为动态进行了分类，并组成最终所需的消息格式，代码如下:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="cm">/* filter items */</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">newItems</span> <span class="o">=</span> <span class="p">[];</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">regexes</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="s1">&#39;star&#39;</span><span class="o">:</span> <span class="sr">/^reorx starred/</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s1">&#39;pull-request&#39;</span><span class="o">:</span> <span class="sr">/^reorx opened a pull request/</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s1">&#39;issue&#39;</span><span class="o">:</span> <span class="sr">/^reorx opened an issue/</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">tagSymbols</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="s1">&#39;star&#39;</span><span class="o">:</span> <span class="s1">&#39;🌟&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s1">&#39;pull-request&#39;</span><span class="o">:</span> <span class="s1">&#39;⤴️&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s1">&#39;issue&#39;</span><span class="o">:</span> <span class="s1">&#39;⚡️&#39;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">item</span> <span class="k">of</span> <span class="nx">items</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">title</span> <span class="o">=</span> <span class="nx">item</span><span class="p">.</span><span class="nx">json</span><span class="p">.</span><span class="nx">title</span>
</span></span><span class="line"><span class="cl">  <span class="kd">let</span> <span class="nx">tag</span> <span class="o">=</span> <span class="s1">&#39;&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">key</span> <span class="k">in</span> <span class="nx">regexes</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">regex</span> <span class="o">=</span> <span class="nx">regexes</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">regex</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">title</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">tag</span> <span class="o">=</span> <span class="nx">key</span>
</span></span><span class="line"><span class="cl">      <span class="k">break</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">tag</span> <span class="o">!==</span> <span class="s1">&#39;&#39;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">item</span><span class="p">.</span><span class="nx">json</span><span class="p">.</span><span class="nx">tag</span> <span class="o">=</span> <span class="nx">tag</span>
</span></span><span class="line"><span class="cl">    <span class="nx">item</span><span class="p">.</span><span class="nx">json</span><span class="p">.</span><span class="nx">tgTitle</span> <span class="o">=</span> <span class="sb">`</span><span class="si">${</span><span class="nx">tagSymbols</span><span class="p">[</span><span class="nx">tag</span><span class="p">]</span><span class="si">}</span><span class="sb"> </span><span class="si">${</span><span class="nx">title</span><span class="p">.</span><span class="nx">replace</span><span class="p">(</span><span class="sr">/^reorx /</span><span class="p">,</span> <span class="s1">&#39;&#39;</span><span class="p">)</span><span class="si">}</span><span class="sb"> #github #</span><span class="si">${</span><span class="nx">tag</span><span class="si">}</span><span class="sb">`</span>
</span></span><span class="line"><span class="cl">    <span class="nx">item</span><span class="p">.</span><span class="nx">json</span><span class="p">.</span><span class="nx">guid</span> <span class="o">=</span> <span class="nx">item</span><span class="p">.</span><span class="nx">json</span><span class="p">.</span><span class="nx">id</span>
</span></span><span class="line"><span class="cl">    <span class="nx">newItems</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="nx">item</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="nx">newItems</span>
</span></span></code></pre></div><blockquote>
<p>这个 workflow 还有一个使用 GitHub API 的版本，但 RSS 可以获得所有动态，更加方便，最终胜出。</p>
<p>Link: <a href="https://github.com/reorx/n8n-workflows/blob/master/workflows/github%20issues%20to%20tg.json">n8n-workflows/workflows/github issues to tg.json</a></p>
</blockquote>
<h4 id="douban-activities-to-telegram">Douban activities to telegram</h4>
<p>Link: <a href="https://github.com/reorx/n8n-workflows/blob/master/workflows/douban%20activities%20to%20tg.json">n8n-workflows/workflows/douban activities to tg.json</a></p>
<p>这一 workflow 实现了每 10 分钟检查一次 Douban 的动态，筛选出看过、在看、读过、在读发送到 Telegram Channel。</p>
<p><img loading="lazy" src="/blog/sharing-my-footprints-automation/images/n8n-7.png" type="" alt=""  /></p>
<p>Douban 也有一个隐藏的 RSS 订阅源 <code>https://www.douban.com/feed/people/$username/interests</code>，包含书影音的所有动态。</p>
<p>Function filter 与上一个 workflow 类似，这里做了一些优化，将正则和符号定义在一个 object 中。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">SYMBOL_MOVIE</span> <span class="o">=</span> <span class="s1">&#39;📺&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">SYMBOL_BOOK</span> <span class="o">=</span> <span class="s1">&#39;📖&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">tagInfoMap</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">watched</span><span class="o">:</span> <span class="p">{</span> <span class="nx">re</span><span class="o">:</span> <span class="sr">/看过/</span><span class="p">,</span> <span class="nx">symbol</span><span class="o">:</span> <span class="nx">SYMBOL_MOVIE</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nx">watching</span><span class="o">:</span> <span class="p">{</span> <span class="nx">re</span><span class="o">:</span> <span class="sr">/在看/</span><span class="p">,</span> <span class="nx">symbol</span><span class="o">:</span> <span class="nx">SYMBOL_MOVIE</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nx">read</span><span class="o">:</span> <span class="p">{</span> <span class="nx">re</span><span class="o">:</span> <span class="sr">/读过/</span><span class="p">,</span> <span class="nx">symbol</span><span class="o">:</span> <span class="nx">SYMBOL_BOOK</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nx">reading</span><span class="o">:</span> <span class="p">{</span> <span class="nx">re</span><span class="o">:</span> <span class="sr">/在读/</span><span class="p">,</span> <span class="nx">symbol</span><span class="o">:</span> <span class="nx">SYMBOL_BOOK</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><h3 id="异常通知">异常通知</h3>
<p>n8n 作为一个自动化服务，在配置完成后，一般我们不会去主动查看系统的运行情况，因此需要有监控手段得知异常的发生，以便及时维护。</p>
<p>n8n 支持通过 <a href="https://docs.n8n.io/integrations/core-nodes/n8n-nodes-base.errortrigger/">Error Trigger</a> 创建 Error Workflow，用于接收其他 workflow 的错误信息。这一步虽然不是必须，但我强烈建议创建一个全局的 Error Workflow，并在每个 workflow 的 Settings 中设置错误处理指向它。</p>
<p><img loading="lazy" src="/blog/sharing-my-footprints-automation/images/n8n-8.png" type="" alt=""  /></p>
<p>我在 <a href="https://github.com/reorx/n8n-workflows/blob/master/workflows/ERROR%20TO%20TG.json">n8n-workflows/workflows/ERROR TO TG.json</a> 中实现了将错误信息发送到 Telegram Group 的功能，效果如下。
<div class="image-size-control" style="max-width: 400px;">
  <img loading="lazy" src="/blog/sharing-my-footprints-automation/images/telegram.png" type="" alt=""  />
</div>
</p>
<h3 id="使用-saas-自动化服务">使用 SaaS 自动化服务</h3>
<p>对于一些 n8n 尚不支持，或配置比较麻烦的服务，也可以通过 SaaS 服务来实现自动化。IFTTT 是一个不错的选择，它应该是最早被广泛使用的自动化服务，免费用户可以设置 5 个 applet (即 workflow)，同步间隔 1 小时。</p>
<p>下图是我在 IFTTT 中配置的 Instapaper 新文章推送到 Telegram 频道的 applet。</p>
<div class="image-size-control" style="max-width: 400px;">
  <img loading="lazy" src="/blog/sharing-my-footprints-automation/images/ifttt.png" type="" alt=""  />
</div>

<p><a href="https://www.integromat.com/">Integromat</a> 曾是一个非常好用的 IFTTT alternative，有着现代化的交互界面，和更为灵活的定制功能，但后来 rebrand 成 make.com，变得非常难用，不仅加载速度缓慢，而且连 migration 都无法正常完成<sup id="fnref:4"><a href="#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup>。建议大家远离这个奇怪的公司。</p>
<figure class="center align-center">
  <img loading="lazy" src="/blog/sharing-my-footprints-automation/images/integromat.png" type="" alt=""  />

  <figcaption><p>曾经美好的 Integromat</p></figcaption>
</figure>

<figure class="center align-center">
  <img loading="lazy" src="/blog/sharing-my-footprints-automation/images/integromat-scenario.png" type="" alt=""  />

  <figcaption><p>和它出色的交互界面</p></figcaption>
</figure>

<h2 id="结语">结语</h2>
<p>使用 n8n 的过程总体是非常顺畅的，虽然一开始就在 Twitter 上栽了跟头，好在我能看懂 Nodejs 代码，自己动手修复后一路坦途。</p>
<p>曾经我特别痴迷于自己实现工具、制造轮子，现有工具稍有不顺就要全盘抛弃，自己解决。后来我在 happy xiao 的<a href="https://happyxiao.com/system/">忘记系统</a>一文中看到 <a href="https://twitter.com/johnvoorhees">John Voorhees</a> 的一段话，改变了我对使用工具的态度:</p>
<blockquote>
<p>If there&rsquo;s anything I&rsquo;ve learned about productivity systems, it&rsquo;s that the best one is the one that works for you. Approaching the problem is tricky, but my advice is to use an app first and adopt a system later. That way, you&rsquo;ll have a better understanding of what your needs are before you dive in.</p>
</blockquote>
<p>实现的结果才是最重要的，工具只是手段，不是目的。</p>
<p>这也让我对自己正在开发的产品有了更多的思考，它是一个用于信息前处理的工具，同样是为了解决信息的分散和封闭而做出的努力，我设想了很多功能要去开发，但哪些是核心需求，哪些有代替品，或者换个思路通过其他方式实现，则必须放在实际场景中去考虑，而不是凭自己作为开发者的喜欢来武断确定。</p>
<p>回到赛博空间这个话题，我一直觉得人类对电子设备的依赖是一个不可逆的过程，人脑与电脑的边界会逐渐模糊。这是当我们有了智慧、抛弃自然为我们提供的进化之路后，自己所选择的进化方式。正如 Ghost In The Shell 开篇所描绘的越来越近的未来：</p>
<blockquote>
<p>企業のネットが星を被い、電子や光が駆け巡っても</p>
<p>国家や民族が消えてなくなるほど、情報化されていない近未来</p>
</blockquote>
<p>而在这一天到来之前，我在精神上已经完成了自然人向 <a href="https://en.wikipedia.org/wiki/Cyborg">cyborg</a> 的转变。</p>
<h2 id="revision">Revision</h2>
<ul>
<li>2022-05-26: created</li>
<li>2022-05-27: published</li>
<li>2022-05-28: fixed &ldquo;Function&rdquo; node script items order by adding <code>.reverse()</code> to return items</li>
<li>2022-08-12:
<ul>
<li>added notes about using <code>tweet_mode=extended</code> to avoid tweet text truncation</li>
<li>added notes about vxTwitter proxy url</li>
</ul>
</li>
</ul>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>我的知识库中有三个分类:「制品」、「技术」、「事实」。制品 (artifacts) 是人所创造的作品、产品，如一个开源项目、一个软件；技术 (techniques) 是完成一类事情的方法或经验，也可以叫做 know-how，比如做饭的菜谱、编程语言的技巧、健身动作说明；事实 (facts) 是对概念、词汇的客观解释，多数来源于维基百科的词条。这三个分类可以基本涵盖我摄入的各类信息。&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>预计微信还需要 100 年才能赶上&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3">
<p>self-hosted workflow automation 的另一个选择是 <a href="https://github.com/huginn/huginn">Huginn</a>, 我没有尝试和对比，在看过它的项目页面，感觉 UI 非常简陋就放弃了。&#160;<a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:4">
<p>见我在 Twitter 的吐槽: <a href="https://twitter.com/novoreorx/status/1528985317847736320">make.com 大概是我 10 年来用过最糟糕的服务</a>&#160;<a href="#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item></channel></rss>