<?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>Blog on Reorx’s Forge</title><link>https://reorx.com/blog/</link><description>Recent content in Blog 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, 11 Feb 2026 12:00:00 +0800</lastBuildDate><atom:link href="https://reorx.com/blog/feed.xml" rel="self" type="application/rss+xml"/><item><title>My OpenClaw Desperately Needs a DevOps Agent</title><link>https://reorx.com/blog/devops-agent-is-the-next-openclaw-moment/</link><pubDate>Wed, 11 Feb 2026 12:00:00 +0800</pubDate><guid>https://reorx.com/blog/devops-agent-is-the-next-openclaw-moment/</guid><description>A few days ago I shared how OpenClaw has been helping me — it can truly take over the programmer role, freeing me from the grind of development tasks so I can think about project decisions, product design, and where the company is heading. I can finally act like a CEO of a one-person company, doing more of what a manager should do. That&amp;rsquo;s been a huge level-up for me.</description><content:encoded><![CDATA[<p>A few days ago I shared <a href="https://reorx.com/blog/openclaw-is-changing-my-life/">how OpenClaw has been helping me</a> — it can truly take over the programmer role, freeing me from the grind of development tasks so I can think about project decisions, product design, and where the company is heading. I can finally act like a CEO of a one-person company, doing more of what a manager should do. That&rsquo;s been a huge level-up for me.</p>
<p>But when I was about to ship a project the other day, I realized it still can&rsquo;t replace me when it comes to DevOps:</p>
<ol>
<li>
<p><strong>Extremely high complexity</strong>: DevOps work is far more complex than programming. You&rsquo;re operating command lines, logging into servers to check things, and you need to be incredibly careful — one mistake can have massive consequences.</p>
</li>
<li>
<p><strong>Multi-dimensional interaction</strong>: You need to open various web UIs to manage cloud resources, copy-paste critical keys, deploy services, and operate cloud platform dashboards.</p>
</li>
<li>
<p><strong>Constant context switching</strong>: You&rsquo;re jumping between the terminal and web UIs, constantly verifying and handling edge cases. These operations go beyond what plain text can express.</p>
</li>
</ol>
<p>While OpenClaw can already replace the programmer, no agent can truly handle DevOps yet. I think there&rsquo;s a huge opportunity here. Maybe it&rsquo;s time to build a real, autonomous DevOps Agent.</p>
<h2 id="a-real-deployment-story">A Real Deployment Story</h2>
<p>To show what DevOps difficulties actually look like, let me walk through my recent experience deploying Vocalflow — a voice input and transcription service I built.</p>
<p>First, I created a project on my self-hosted Dokploy instance and set up the app inside it. Then I manually clicked through the configuration to set the Docker image path pointing to GitHub&rsquo;s container registry. Since the image is private, I had to dig up a GitHub PAT and paste it in.</p>
<p>Next, I wrote GitHub Actions for building the image — this was the one thing I had OpenClaw help with. Then I added another Action: once the image build completes, it calls the deployment platform&rsquo;s API via curl to trigger auto-deployment. Since this step requires token authentication, I had to open the GitHub Actions settings page and add the Secret Value.</p>
<p>After all that, I pushed the code. The auto-deployment kicked in, but the app failed to start. I checked and found that better-sqlite3 couldn&rsquo;t find its native Node extension. I had OpenClaw debug and search for a while:</p>
<ol>
<li>First it tried switching the image base from Alpine to Debian Slim — didn&rsquo;t help.</li>
<li>After several fruitless searches, I manually went to the project&rsquo;s Issues and searched for &ldquo;Alpine,&rdquo; finally finding a relevant thread about Docker startup problems. <a href="https://github.com/WiseLibs/better-sqlite3/issues/146">https://github.com/WiseLibs/better-sqlite3/issues/146</a></li>
<li>In there I found a reply about pnpm workspaces — turns out pnpm was blocking better-sqlite3&rsquo;s post-install build step, and you need to add an &ldquo;approve-builds&rdquo; config.</li>
</ol>
<p>Once I told OpenClaw, it added the file and pushed again. The project finally came alive.</p>
<p>Then came domain configuration. I had to go to the Cloudflare Dashboard, find my top-level domain, add a subdomain, and point it to the deployment server. After that, I went back to Dokploy to map the new domain to the service and configure automatic HTTPS via Let&rsquo;s Encrypt. I also needed to get the port right — I asked OpenClaw which port the service was actually running on. Only then was everything set up. I then checked Docker&rsquo;s runtime logs to verify whether auto-migration ran correctly. It didn&rsquo;t. I notified OpenClaw to look into it, but since it couldn&rsquo;t directly access the production logs, verifying the fix was hard. I went back and forth debugging several times. Once that was sorted, I started a new session and had it research and write a CLI tool for fetching Dokploy service logs — just to make life easier next time. <a href="https://github.com/reorx/scripts/blob/master/dokploy-logs">https://github.com/reorx/scripts/blob/master/dokploy-logs</a></p>
<h2 id="the-future-of-the-devops-agent">The Future of the DevOps Agent</h2>
<p>This is what DevOps work looks like — extremely tedious, fragmented, and unpredictable. There&rsquo;s no universal approach to standardize it. Especially in the early stages of a project, entropy is incredibly high. Only after a project stabilizes can you start managing things through standardized text-based tools like Ansible or Terraform, forming SOPs.</p>
<p>In an era where development moves this fast and everyone is constantly building new projects, general-purpose Coding LLMs trained on existing data do have DevOps knowledge, but they can&rsquo;t truly do the job. They can&rsquo;t close the loop the way they can with development work. That&rsquo;s the reality, and it&rsquo;s the direction I believe future agents need to explore.</p>
<p>The core ideas behind such an agent:</p>
<ul>
<li><strong>Not another DevOps platform</strong>: Don&rsquo;t try to build yet another IaaS or ops platform.</li>
<li><strong>Adapt to the real world</strong>: The agent should adapt to real-world DevOps workflows, learning and mastering various skills — like operating AWS, Dokploy, or Cloudflare infrastructure.</li>
<li><strong>Pluggable architecture</strong>: The agent should be pluggable, like a person who can work at any company in any role. It can be installed into any system — whether that&rsquo;s OpenClaw, Claude Code, or Cursor.</li>
</ul>
<p>This would complete the last missing piece, freeing developers and small companies from DevOps busywork and closing the entire loop from idea → development → launch.</p>
]]></content:encoded></item><item><title>OpenClaw Is Changing My Life</title><link>https://reorx.com/blog/openclaw-is-changing-my-life/</link><pubDate>Sun, 08 Feb 2026 10:00:00 +0800</pubDate><guid>https://reorx.com/blog/openclaw-is-changing-my-life/</guid><description>I want to share some thoughts on my recent experience with OpenClaw. Over the past year, I&amp;rsquo;ve been actively using Claude Code for development. Many people believed AI could already assist with programming—seemingly replacing programmers—but I never felt it brought any revolutionary change to the way I work.
Sure, agentic coding tools like Claude Code and Cursor have made writing code easier, but at the end of the day, I was still the one writing.</description><content:encoded><![CDATA[<p>I want to share some thoughts on my recent experience with OpenClaw. Over the past year, I&rsquo;ve been actively using Claude Code for development. Many people believed AI could already assist with programming—seemingly replacing programmers—but I never felt it brought any revolutionary change to the way I work.</p>
<p>Sure, agentic coding tools like Claude Code and Cursor have made writing code easier, but at the end of the day, I was still the one writing. It might look like the AI is doing the work, but &ldquo;writing&rdquo; is a broad term—writing is execution. As the person making code happen, I&rsquo;m the one writing code. Whether I&rsquo;m editing line by line, copy-pasting, or telling an AI what I want and letting it finish—it&rsquo;s still me &ldquo;writing.&rdquo; My role as the programmer responsible for turning code into reality hasn&rsquo;t changed.</p>
<p>My productivity did improve, but for any given task, I still had to jump into the project, set up the environment, open my editor and Claude Code terminal. I was still the operator; the only difference was that instead of typing code manually, I was typing intent into a chat box. That only changed one dimension. Testing, debugging—most of it still fell on me. There was some change, sure, but it wasn&rsquo;t mature, and there was no fundamental shift. I still had to stay deeply involved and monitor everything. And it was exactly this deep involvement that kept me stuck in the role of code executor.</p>
<p>Then OpenClaw came along, and everything changed.</p>
<p>I once discussed with my wife: in the age of AI, should you aim to be a &ldquo;super individual&rdquo; or build a &ldquo;super team&rdquo;? My answer is: become a &ldquo;super manager.&rdquo; A super individual who can juggle multiple threads and coordinate numerous AI tools is essentially demonstrating great management skills. Being a super individual means using AI tools to lift yourself from a basic executor to a higher-level one, and eventually into a manager. So even if you&rsquo;re going the super individual route, you need solid management awareness and methods to keep everything running smoothly.</p>
<p>OpenClaw gave me the chance to become that super manager. After a few rounds of practice, I found that I could completely step away from the programming environment and handle an entire project&rsquo;s development, testing, deployment, launch, and usage—all through chatting on my phone. That&rsquo;s something Claude Code simply can&rsquo;t do, or rather, it was never designed to.</p>
<p>As a general-purpose agent, OpenClaw interacts through messaging apps via voice, accurately understands what I mean, works independently for extended periods, and has solid memory—it can persist the methods and rules it picks up during work, gradually evolving through use. These are the capabilities that make it the real turning point for replacing me as the code executor. The biggest change is this: I just need to express my intent, and it automatically creates the project, writes up a plan for me to review. I can discuss changes with it by voice, and then it executes—even directing Claude Code to do the actual coding.</p>
<p>It replaced the &ldquo;me&rdquo; that used to write code, truly stepping into the programmer role and freeing me to act as a manager. A manager shouldn&rsquo;t get bogged down in the specifics—they should focus on the higher-level, abstract work. That&rsquo;s what management really is. You could even flip it around: you&rsquo;re only a true manager when you can get things done purely through communication. Before, Claude Code alone couldn&rsquo;t get you there. But when you have a dedicated machine running 24/7, set up with all your tools, and an agent that understands your intent sitting at the computer writing and debugging code for you—that&rsquo;s when things truly change. That&rsquo;s when the revolution arrives.</p>
<p>This is the biggest shift OpenClaw has brought—it completely transformed my workflow. Whether it&rsquo;s personal or commercial projects, I can step back and look at things from a management perspective. It&rsquo;s like having a programmer who&rsquo;s always on standby, ready to hop into meetings, discuss ideas, take on tasks, report back, and adjust course at any time. It can even juggle multiple roles, like having several programmers working on different projects simultaneously. Meanwhile, I can be the tech lead keeping tabs on specific project progress, or the project manager steering the overall schedule and direction.</p>
<p>This has truly freed up my productivity, letting me pursue so many ideas I couldn&rsquo;t move forward on before. I feel like my life genuinely changed at this moment. I used to have way too many ideas but no way to build them all on my own—they just kept piling up. But now, everything is different.</p>
<p>It&rsquo;s like I suddenly have a team, achieving the dream scenario I always imagined: owning a company, hiring people to bring my ideas to life, while I just focus on product design and planning. I&rsquo;m closer than ever to that dream state. Before, that required serious capital. Without money, you can&rsquo;t hire anyone, and you can&rsquo;t just be the idea person. Unless you&rsquo;re some trust fund kid doing it for fun, you&rsquo;re stuck bouncing between &ldquo;indie developer who wants to build multiple projects&rdquo; and &ldquo;solo hustler just trying to survive.&rdquo;</p>
<p>But now, I can finally break out of that trap and move toward actually having a team. It keeps all my projects moving forward at any time. It&rsquo;s not perfect yet, but I&rsquo;ve taken the first step.</p>
<p>Thank you, OpenClaw. Thank you, AGI—for me, it&rsquo;s already here. The gears of fate are turning in directions I never imagined.</p>
]]></content:encoded></item><item><title>Rabbit R1 - The Upgraded Replacement for Smart Phones</title><link>https://reorx.com/blog/rabbit-r1-the-upgraded-replacement-for-smart-phones/</link><pubDate>Wed, 10 Jan 2024 11:22:46 +0800</pubDate><guid>https://reorx.com/blog/rabbit-r1-the-upgraded-replacement-for-smart-phones/</guid><description>A milestone in the evolution of our digital organ.</description><content:encoded><![CDATA[<p>It all started with <em>The Light Phone</em>.</p>
<p>Living in a world full of technology and digital devices today, I constantly feel distracted, unfocused, and lost. The smartphone has become a part of our bodies, and we have already evolved into a new species - cyborgs. We no longer solely rely on our biological brains for looking and thinking; much of our important information is stored in our phones, our new &ldquo;organ.&rdquo; However, it is dumb in comparison because the only efficient way to interact with it is to use our fingers, which is much slower than other natural organs connected and controlled by neural networks. I want to get rid of it, but deep down I know I cannot live without it.</p>
<p>After discovering <a href="https://www.thelightphone.com/">The Light Phone</a>, I realized that we may not rely on apps and information as much as we think. However, despite initially being intrigued, I eventually found myself unable to resist returning to my iPhone 7. This begs the question: why did I make this decision?</p>

<figure class="center align-center">
  <div class="image-size-control" style="max-width: 350px;">
    <img loading="lazy" src="/blog/rabbit-r1-the-upgraded-replacement-for-smart-phones/images/the-light-phone.png" type="" alt=""  />
  </div>

  <figcaption><p>The Light Phone</p></figcaption>
</figure>

<p>It&rsquo;s a downgrade of a person&rsquo;s capabilities. I can no longer take photos wherever I find something interesting, listen to music whenever I feel like it, or call a taxi wherever I want to go.</p>
<p>It shouldn&rsquo;t be this way when it comes to enhancing our lifestyle. From a young age, I have never held a high opinion of those individuals in history who chose to live in the wilderness as a means to find mental tranquility. I greatly admire those who can actively engage in society while maintaining a clear mind, living in a manner that brings them personal satisfaction.</p>
<p>Years passed, and it suddenly came to the era of LLMs. One day, I found <a href="https://hu.ma.ne/aipin">AI Pin</a>, a wearable device made by Humane with the power of OpenAI. My eyes lit up; I sensed a similar aesthetic and philosophy to the Light Phone. However, that was not enough. The AI Pin has no screen, meaning that I cannot interact with it through vision and touch, a significant loss of HCI technology of the past 60 years.</p>

<figure class="center align-center">
  <div class="image-size-control" style="max-width: 300px;">
    <img loading="lazy" src="/blog/rabbit-r1-the-upgraded-replacement-for-smart-phones/images/ai-pin-lunar-front.png" type="" alt=""  />
  </div>

  <figcaption><p>AI Pin</p></figcaption>
</figure>

<p>That being said, AI Pin is heading in the right direction, because what wastes our time and distracts us when using smartphones are the tedious and complex operations with apps. LLM excels at converting natural language into a sequence of machine-understandable commands, which is clearly the way to reduce the friction of using a digital device. I feel that the future is near, only a few steps away, but I didn&rsquo;t expect it to arrive so quickly.</p>
<p>Today, <a href="https://www.rabbit.tech/">Rabbit R1</a> has been released, and I view it as a milestone in the evolution of our digital organ.</p>

<figure class="center align-center">
  <div class="image-size-control" style="max-width: 400px;">
    <img loading="lazy" src="/blog/rabbit-r1-the-upgraded-replacement-for-smart-phones/images/rabbit-r1.png" type="" alt=""  />
  </div>

  <figcaption><p>Rabbit R1</p></figcaption>
</figure>

<p>R1 is definitely an upgraded replacement for smartphones. It&rsquo;s versatile and fulfills all everyday requirements, with an interaction style akin to talking to a human. Other devices, such as the Light Phone and AI Pin, though minimalist and distraction-free, fall short of enabling us to accomplish our daily tasks. The disadvantages outweigh the benefits in the ultimate goal of improving our lifestyle.</p>
<p>So, why is R1 great? In my opinion, it&rsquo;s primarily due to the two new technologies it employs: Agent and LAM.</p>
<p>The concept of an Agent is simple: you instruct the AI what to do, and it will analyze your words to formulate a clear goal. Subsequently, it breaks this goal down into various tasks, orchestrates the completion of these tasks, and finally assembles the results to fulfill the goal. The Agent resolves the issue of LLM&rsquo;s inefficiency at task completion. <a href="https://github.com/Significant-Gravitas/AutoGPT">AutoGPT</a> is the first proof-of-concept that has popularized the Agent. From a certain perspective, R1 can be perceived as a physical embodiment of an Agent.</p>
<p>The following is <a href="https://www.rabbit.tech/rabbit-os">LAM</a>, which I believe is the most impressive and exciting feature in R1&rsquo;s presentation. LAM, or Large-Action-Model, possesses the capability to comprehend any user interfaces and act accordingly. Utilizing LAM, Rabbit has developed a framework. This endows R1 with its most potent tool - the ability to learn.</p>
<p>With the power of teaching mode, R1 can acquire a new skill simply through screen recording and voice instructions. In the <a href="https://twitter.com/rabbit_hmi/status/1744781083831574824">presentation</a>, Jesse showed how to instruct R1 to use Midjourney for creating images from user voice commands. This proves that R1 is not limited to what the developers built in; users can tweak it to tailor it to their own needs. There&rsquo;s no compromise of losing capacities while maintaining a minimalist shape and intuitive use.</p>
<p>Someone once stated, the primary distinction between humans and robots is self-learning. While R1 cannot learn independently, it certainly serves as a commendable human companion.</p>
<p>Generally, I believe R1 has the potential to change the world. This is a thought that seldom comes to my mind, as I have seen numerous new technologies and inventions. However, R1 is different; it&rsquo;s not just another device to please a certain niche. It&rsquo;s meticulously designed to serve one significant goal for all people: to improve lifestyle in the digital world.</p>
]]></content:encoded></item><item><title>Debounce and Throttle</title><link>https://reorx.com/blog/debounce-and-throttle/</link><pubDate>Thu, 21 Dec 2023 20:23:46 +0800</pubDate><guid>https://reorx.com/blog/debounce-and-throttle/</guid><description>介绍 Debounce 和 Throttle 两种频率限制手段，以具体场景为例展示两者的区别。</description><content:encoded><![CDATA[<h2 id="概念">概念</h2>
<p>Debounce 和 Throttle 是两种相似的频率限制手段。Debounce 顾名思义，去掉弹跳/抖动，能看出防止误操作的意味；Throttle 的意思是节流阀，更加直接了当。作为两种常见的设计模式，理解他们的工作原理和细微区别能够帮助我们写出更健壮的应用程序。</p>
<p>虽然是两个通用的概念，但它们的确主要在 JavaScript 中被提及和使用，究其原因，JavaScript 中常常出现连续发生的大量事件，如果不对调用频率做出限制，会产生严重的性能问题。且这些事件是可以舍弃的，一段时间内只需要产生一次有效调用即可。而在后端则很少出现这种情况，所有的事件都必须要处理，性能问题通常通过异步和分布式调用来解决。</p>
<p>下面是我对这两种设计模式的理解。</p>
<ul>
<li>
<p><strong>Debounce</strong>: 将间隔不超过设定时间的多次连续调用变成一次。</p>
<p>从工作原理上来讲，Debounce 会使目标函数变为延迟生效，当对其进行连续多次调用时，若前后两次调用的时间间隔不超过设定值，则前一次调用会被取消。直到某次调用后，在设定的时间内没有出现下一次调用，那么这次调用将不会被取消，从而最终被执行。</p>
</li>
<li>
<p><strong>Throttle</strong>: 确保一个函数被连续多次调用时，在设定时间内最多只实际执行一次。</p>
</li>
</ul>
<p>放在一起对比的话：</p>
<ul>
<li><strong>相似之处</strong>：Debounce 和 Throttle 都限制了函数执行的最大频率不超过每设定时间一次</li>
<li><strong>不同之处</strong>：在快速（间隔小于设定时间）连续调用时，Throttle 确保了函数会规律执行，但 Debounce 只有当连续调用放缓（间隔大于设定时间）时才会执行。</li>
</ul>
<h2 id="应用场景">应用场景</h2>
<p>设想如下几个场景:</p>
<ol>
<li>当用户改变网页窗口的大小后，调用一个函数以调整 UI 布局</li>
<li>当用户在滚动浏览网页内容时，根据内容所处的位置，持续更新大纲目录中的链接高亮</li>
<li>在一个输入框的下方，让搜索提示结果随着用户的输入持续不断地更新</li>
</ol>
<p>我们一起来看看每个场景分别应用 Debounce 和 Throttle 会有什么样的效果，并评判哪个是更加合适的频率控制方式。</p>
<h3 id="场景-1">场景 1</h3>
<p>用户按住鼠标不松一直改变窗口大小，使用 Debounce 的情况下，UI 在用户停顿或者松开鼠标时才会改变；使用 Throttle 的情况下，用户会观察到 UI 在拖拽窗口大小的过程中每隔一会改变一次，容易给人一种反应迟钝或卡住的错觉，因此 Debounce 是更好的选择。</p>
<h3 id="场景-2">场景 2</h3>
<p>用户持续向下滚动鼠标滑轮，使用 Debounce 的情况下，大纲的高亮只有当用户停止滚动时才会更新。所以当用户一次性滚动很长时，只能看到一次高亮的改变，中间仿佛跳过了一般；而使用 Throttle 的情况下，随着用户滚动，高亮会稳定地以设定的时间间隔更新，因此 Throttle 是更好的选择。</p>
<h3 id="场景-3">场景 3</h3>
<p>用户以较快地速度连续输入字符，使用 Debounce 的情况下，只有当用户停止输入时搜索提示才会更新；使用 Throttle 的情况下，搜索提示会稳定地以设定的时间间隔更新，但如果用户输入最后一个字符的时间，正好处于上一次调用后的间隔期，无法触发新的调用，那么用户所看到的提示就不是根据完整的输入内容做出的。Debounce 由于能够保证函数总是在用户停止输入时执行，是比 Throttle 更好的选择。</p>
<h2 id="库的使用">库的使用</h2>
<p>首选 lodash，因为它是一个非常流行且久经考验的库。但如果不想让整个 lodash 混入项目的构建结果，可以安装 <code>lodash.throttle</code> 和 <code>lodash.debounce</code> 两个独立的库。如果你使用的 bundler 支持 tree-shaking，也可以通过 <code>lodash-es</code> 来 import 这两个函数，最终构建结果中只会包含与之相关的代码。</p>
<p>npm 狂魔 sindresorhus 也维护了两个包，<a href="https://github.com/sindresorhus/debounce">debounce</a> 和 <a href="https://github.com/sindresorhus/throttleit">throttleit</a>, 如果你想要更简洁的实现，可以考虑使用。</p>
<p>下面以 <code>lodash.throttle</code> 为例，展示其如何在一个 TypeScript 项目里安装和使用 :</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">npm i lodash.throttle
</span></span><span class="line"><span class="cl"><span class="c1"># 还需要额外安装 `@types/` 的类型定义</span>
</span></span><span class="line"><span class="cl">npm i -D @types/lodash.throttle
</span></span></code></pre></div><p>引入和调用:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">throttle</span> <span class="kr">from</span> <span class="s1">&#39;lodash/throttle&#39;</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">onScroll</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=</span> <span class="p">{</span><span class="cm">/* 实现细节 */</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// onScroll 执行的最高频率为每 100 毫秒一次
</span></span></span><span class="line"><span class="cl"><span class="c1"></span><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;scroll&#39;</span><span class="p">,</span> <span class="nx">throttle</span><span class="p">(</span><span class="nx">onScroll</span><span class="p">,</span> <span class="mi">100</span><span class="p">));</span>
</span></span></code></pre></div><h2 id="后续思考">后续思考</h2>
<p>这篇笔记来源于<a href="https://t.me/reorx_share/4866">重构 GitHub TOC Sidebar 扩展</a>时对场景 2 的思考，之前用的是自己手写的 Debounce，在滚动过程中经常看不到 ToC 的高亮变化，这次换成了 lodash.throttle，终于达到了预期的效果。其实这三个场景我都在过往的开发经历中遇到过，并且是在不了解这两个概念的情况下独立思考出了（简陋或丑陋的）解决方案，直到最近才重新审视，阅读了相关的文章，学习了更好的实现方式。这也是为什么我在<a href="https://t.me/reorx_share/4864">关于状态机的短文</a>中感叹基础知识的重要性，如果能更早地知道这两个概念，就能避免曾经在黑暗中摸索的痛苦。当然，因为自己琢磨过，当看到更系统更高级的实现时，就会有更深刻的理解，这大概是<a href="https://x.com/noworkforsixian/status/1737122354013360488">这位推友</a>希望自己是通过 Vanilla JS 学习前端的原因。</p>
<div class="tweet social-quote">
  <div class="title">
    <svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><title>Twitter</title><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>

    
  </div><blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">如果重学前端，我肯定老老实实地用原生 JS 去完整地做几个项目，去 appendChild，去切身体会一下手动管理一切状态，命令式更新 UI，还要时刻让它和状态保持一致的痛苦。然后再去学框架，把这些项目重写一遍。 很遗憾我没有写过 jQuery，太早接触框架的最大问题就是对它们在解决什么问题理解得不够深刻</p>&mdash; Sixian (@noworkforsixian) <a href="https://twitter.com/noworkforsixian/status/1737122354013360488?ref_src=twsrc%5Etfw">December 19, 2023</a></blockquote>


</div>


<p>一言以蔽之，开发遇到困难免不了自己琢磨，但在琢磨时多想想能否将问题定义出来，符合一个已有的概念，然后去参考现实世界中系统和标准的解决方案；如果没有也无所谓，未来某一刻这种思考过程会化作某种领悟，不会白费。</p>
<h2 id="参考资料">参考资料</h2>
<ul>
<li><a href="https://css-tricks.com/debouncing-throttling-explained-examples/">Debouncing and Throttling Explained Through Examples</a>: css-tricks 的经典老文</li>
<li><a href="https://kettanaito.com/blog/debounce-vs-throttle">Debounce vs Throttle: Definitive Visual Guide</a>: 用动态可交互的方式展现了 Throttle 和 Debounce 的差异，非常推荐阅读</li>
<li><a href="https://underscorejs.org/docs/modules/throttle.html">THROTTLE.JS</a>: underscore 的 throttle 源码</li>
<li><a href="https://lodash.com/docs/4.17.15#debounce">_.debounce</a>: lodash 的 debounce 文档</li>
</ul>
]]></content:encoded></item><item><title>浅谈 Chrome Manifest V3 的优缺点</title><link>https://reorx.com/blog/understanding-chrome-manifest-v3/</link><pubDate>Thu, 29 Sep 2022 23:39:26 +0800</pubDate><guid>https://reorx.com/blog/understanding-chrome-manifest-v3/</guid><description>Manifest V3 会对用户有什么影响，是好是坏？最近对此终于有了一些具体的理解，来辩证地看待这一改变。</description><content:encoded><![CDATA[<p>自从 2018 年 Google 发布 Chrome 新的 Extension API Manifest V3 以来，不时会看到反对的声音，认为它限制了扩展的能力，降低了对用户隐私的保护云云。我自己虽然是一个 Chrome 插件开发者，也第一时间使用了 Manifest V3，却没有感受到具体的变化，因此一直对这些观点所描述的问题缺乏实感。</p>
<p>最近阅读了一篇文章，是 AdGuard 开发基于 Manifest V3 新插件的技术性回顾:<br>
<a href="https://adguard.com/en/blog/adguard-mv3.html">AdGuard publishes the world&rsquo;s first ad blocker built on Manifest V3</a></p>
<p>这篇文章以 AdGuard 自身作为范例，对比 Manifest V2/V3 的差异，让我终于理解了 V3 API 变化的影响之所在。</p>
<p>简而言之，Manifest V3 对 AdGuard 造成了如下影响：</p>
<ul>
<li>
<p><strong>规则数量限制</strong></p>
<ul>
<li>
<p>每个扩展的静态规则数量被限制在了 30000 个以下，所有扩展的静态规则之和不能超过 330000 个。</p>
<blockquote>
<p>For static rules, Chrome set a minimum guaranteed limit of 30,000 rules per extension and a total limit of 330,000 rules for all extensions installed by a single user (this also takes into account the limit of 1,000 regexp rules per extension).</p>
</blockquote>
</li>
<li>
<p>预设的过滤器最多 50 个，用户自定义过滤器和规则之和不得超过 5000 个。</p>
<blockquote>
<p>blockers must use pre-set filters (no more than 50), and we have to be very selective about which filters will be available to users. Of course, you can still set your own filter manually. But don&rsquo;t forget the 5000 rule limit <strong>on all</strong> custom filters and user rules.</p>
</blockquote>
</li>
</ul>
</li>
<li>
<p><strong>API 变更</strong>: <code>onBeforeRequest</code> → <code>declarativeNetRequest</code></p>
<ul>
<li>
<p><code>onBeforeRequest</code> 是 Chrome Extension 中的事件回调函数，广告屏蔽插件的工作原理就是在这个函数中使用自己的一套逻辑来判断是否要中断当前的连接。从 Manifest V3 开始，这个函数就被废除了，取而代之的是 <code>declarativeNetRequest</code>，因此 AdGuard 必须将原来的私有动态规则转换成使用 <a href="https://developer.chrome.com/docs/extensions/reference/declarativeNetRequest/#rules">declarative rules</a> 所描述的新规则。</p>
</li>
<li>
<p>而受 declarative rules 的语法所限，一些 AdGuard 原有动态规则的功能将无法被实现</p>
</li>
<li>
<p>rulesets（一组 declarative rules）只能在扩展安装和更新时加载，导致 AdGuard 失去了动态更新规则的能力</p>
<blockquote>
<p>declarative rules must be combined into rulesets</p>
<p>Rulesets are specified in the <code>manifest.json</code> file and are loaded only when the extension is installed or updated.</p>
</blockquote>
</li>
</ul>
</li>
<li>
<p><strong>无法获得统计信息</strong></p>
<ul>
<li>
<p>由于 Chrome 自己实现了一套拦截机制，Manifest V3 不会将请求的统计信息开放给生产环境下的扩展，仅能在开发模式下使用。这意味着 AdGuard 将无法真正确认到动态规则的执行情况，只能通过模拟运行来猜测规则是否可能被应用了，对于规则的维护者来说是灾难性的打击。</p>
<blockquote>
<p>Chrome itself now blocks requests and shares statistics only with extensions unzipped and installed in Developer Mode</p>
<p>only show which rules may have been applied.</p>
</blockquote>
</li>
</ul>
</li>
<li>
<p><strong>Service Workers 不能保证常驻，需要主动唤醒</strong></p>
<ul>
<li>
<p>AdGuard 的 <a href="https://kb.adguard.com/en/general/how-to-create-your-own-ad-filters#example-cosmetic-rule">cosmetic rules</a>（在网页上屏蔽元素的规则）原本运行在 <code>background.js</code> 中，这个机制在 Manifest V3 中被 Service Worker 取代，由于不能常驻后台，cosmetic rules 有一定无法生效。</p>
<blockquote>
<p>When the browser stops the service worker, the extension goes into a kind of sleep mode: the declarative rules work, but the cosmetic rules that are loaded dynamically do not.</p>
</blockquote>
</li>
</ul>
</li>
</ul>
<p>可以看出，AdGuard 这款新的 Manifest V3 插件在各方面都受到了「阉割」，无怪乎文章传达出了一种艰难求存、呼吁支持的态度。</p>
<p>在我看来，其实 Manifest V3 的核心非常明确，就是<mark>限制扩展对系统资源的使用</mark>。一直以来高资源占用都是 Chrome 为人诟病的痛点。相信每个用户都遇到过 CPU 或内存飙升的情景，许多人甚至说 Mac 没有 16G 以上的内存不要用 Chrome。而扩展由于在后台运行，如果出现问题，更是难以定位和管理，我想这就是 Google 的工程师想要做出改变的原因。</p>
<p>虽然增加了诸多限制，但我确实感受到 Manifest V3 的一些有益之处：</p>
<ul>
<li>Service Worker 使得扩展不再能常驻后台，让扩展所占用的资源可以被回收，降低了浏览器整体的开销</li>
<li>限制规则的数量，相当于控制了单一扩展在规则计算方面的资源使用上限</li>
<li>以无法在运行时更新为代价，declarative rules 实现了更高效的动态规则</li>
<li>不再提供请求底层的统计信息，缩短了请求生命周期中的调用链，提升了处理效率</li>
</ul>
<p>这些变化可以让 Chrome 变得更加流畅，对于 99% 的用户来说都是好事。</p>
<p>未来的方向，也许 Chrome 可以提供一个 IPC 或者其他更有效率的通信方式，让另一个进程参与到请求的处理流程中，这样 ad blocker 插件的最大开销——规则匹配——就不再是效率和资源的瓶颈了。就像 LSP 的工作模式，把复杂的事情分离出来让独立的组件来做，说不定会诞生一个用 Rust 开发的新一代 rule filter engine 呢。</p>
<p>希望这篇文章能够帮助你理解 Chrome Manifest V3 的变化，辩证地看待它。</p>
<h2 id="后记">后记</h2>
<p>补充一些本文完成后，与 Manifest V3 有关的趣闻</p>
<h3 id="2022-09-29">2022-09-29</h3>
<p>昨天 Chrome 官方博客发布了一个声明 (<a href="https://developer.chrome.com/en/blog/more-mv2-transition/">More details on the transition to Manifest V3</a>)，将 Manifest V2 的废除时间从 2023 年 1 月向后推迟了一年:</p>
<blockquote>
<p>Starting in June in <strong>Chrome 115</strong>, Chrome <strong>may</strong> run experiments to turn off support for Manifest V2 extensions in all channels, including stable channel.</p>
<p>In <strong>January 2024</strong>, following the expiration of the Manifest V2 enterprise policy, the Chrome Web Store will remove all remaining Manifest V2 items from the store.</p>
</blockquote>
<p>再来看看两年前对废除 Manifest V2 的声明:</p>
<blockquote>
<p><strong>January 2023</strong>: The Chrome browser will no longer run Manifest V2 extensions. Developers may no longer push updates to existing Manifest V2 extensions.</p>
</blockquote>
<p>从原本的斩钉截铁，变成现在的含糊和留有余地，看来强如 Google，想要执行一个影响全世界 65% <sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> 互联网用户的 breaking change，也不是那么容易呀。</p>
<h3 id="2022-10-03">2022-10-03</h3>
<p>著名单文件网页存档插件 <a href="https://github.com/gildas-lormeau/SingleFile">SingleFile</a> 作者依照 Manifest V3 标准重构了原版插件，命名为 <a href="https://github.com/gildas-lormeau/SingleFile-Lite">SingleFile Lite</a>，并用宣传产品升级的语气描述了新插件的 “features” 和 “benefits”，非常讽刺，可以说是一场代码行为艺术了。</p>
<blockquote>
<p>Feel the power of the Manifest V3. The future, right now!</p>
<p><strong>Notable features of SingleFile Lite</strong></p>
<ul>
<li>unreliable auto-updates</li>
<li>no auto-save</li>
<li>save time limited to 5 minutes max.</li>
<li>no &ldquo;Referrer&rdquo; header injection</li>
<li>limited support for fonts dynamically loaded with the FontFace API</li>
<li>and more to come!</li>
</ul>
<p><strong>Benefits of the Manifest V3</strong></p>
<ul>
<li>none</li>
</ul>
</blockquote>
<p>Hacker news: <a href="https://news.ycombinator.com/item?id=33063619">Feel the power of the Manifest v3 | Hacker News</a></p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p><a href="https://en.wikipedia.org/wiki/Usage_share_of_web_browsers#cite_ref-14">https://en.wikipedia.org/wiki/Usage_share_of_web_browsers#cite_ref-14</a>&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><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/split-keyboards/</link><pubDate>Tue, 13 Sep 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/split-keyboards/</guid><description>介绍分体式键盘的优点、相关产品和购买建议</description><content:encoded><![CDATA[<hr>
<p>我从 2018 年开始使用分体式键盘，如今它已成为我不可缺少的设备之一。</p>
<p>如果你看过我的 <a href="https://reorx.com/blog/2022-07-and-08-summary/">2022 年 7 月和 8 月总结</a>，就会知道我的手腕一直都不是非常健康。在接触分体式键盘之前，我就有意识地不用笔记本电脑的自带键盘，并尝试了一些机械键盘，但提升不大，手腕还是会经常感到疲劳。</p>
<p>直到某天我在 Kickstarter 闲逛，发现了 <a href="https://ergodox-ez.com/">Ergodox EZ</a>（下简称 Ergodox）这款产品，这才了解到分体式键盘这一概念。本着健康至上的消费原则，我第一时间就下了单，就像 2015 年购买 Herman Miller 时一样果决。</p>
<div class="tweet social-quote">
  <div class="title">
    <svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><title>Twitter</title><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>

    
  </div><blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">今天终于拔草了 ErgoDox 的键盘，其实几个月前就想买，当时以为这是个美国发货的产品，觉得只转运一个不划算，想等 kickstarter 上买的东西到货一起运。今天终于到货了，立刻去下单键盘，才发现他们是从台湾发货的😂，而且送达至少要三周…默默写上了香港的地址…</p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1007250881513811974?ref_src=twsrc%5Etfw">June 14, 2018</a></blockquote>


</div>


<p>Ergodox 对我的影响是巨大的，它大幅提高了我的打字效率和电脑操作效率，并且最终为我解决了长时间打字手腕疼痛的问题。</p>
<p>对于第一次使用分体式键盘的人，肌肉记忆是最大的挑战。我花了两个月的时间才逐渐适应 Ergodox 的键位，并学会了如何为自己设计键盘布局。这个过程还纠正了我多年的手指键位错误，比如我原本是用右食指敲 <kbd>B<kbd>，但键盘分体后就必须用左手了；之前我总是随机用任意手指按数字键，分体式键盘让 1-5 归左手、6-10 归右手，使我盲打数字的精准度产生了飞跃。</p>
<p>分体式键盘的另一个也是最重要的优点，是可以自由调整间距、倾斜度、旋转角度，这使手腕总是可以处于最舒适的位置。换句话说，<strong>是键盘来适应你的手腕，而不是反过来</strong>。没有用过的人，无法想象这种打字如呼吸般自如的畅快感。甚至在胳膊出现伤痛时，它也能继续使用而不造成伤害，简直可以归于医疗复健设备的范畴了。上个月我因颈椎和肌肉损伤右臂无法活动，也是因为有 Ergodox 我才能尽早恢复打字（实际上，病痛产生的很大一部分原因就是半年没有使用它）。</p>
<p>Ergodox 的布局属于 <a href="https://deskthority.net/wiki/Staggering#Columnar_layout">Columnar stagger</a>，中文里有人把它叫做「直排」，即键帽竖直对齐。常见的键盘是 <a href="https://deskthority.net/wiki/Staggering#Symmetric_stagger">Symmetric stagger</a>——水平对齐，分体式键盘中也有这样的布局。选择哪种在我看来并不重要，因为分体式键盘自由摆放的特性会最大程度上消除布局的差异。我在使用 Ergodox 前并没有接触过直排键盘，但习惯后并没有觉得有什么问题。</p>
<h2 id="购买建议">购买建议</h2>
<p>我将分体式键盘推荐给任何使用键盘进行工作的人。</p>
<p>如果预算不是问题，我建议购买 Ergodox 家（现在改名叫 ZSA）的新产品 <a href="https://www.zsa.io/moonlander/">Moonlander</a>，它是 Ergodox EZ 的升级版，更加简洁优雅。一体化的设计便于携带，拇指区域的键位也做出了优化，虽然少了两个键，但反正也用不了那么多，位置合理才是更重要的。</p>
<div class="tweet social-quote">
  <div class="title">
    <svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><title>Twitter</title><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>

    
  </div><blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">ZSA 的 Moonlander 分体式键盘竟然以「猫猫喜欢待在键盘中间」为宣传点，还专门做了个页面放猫片，这谁顶得住啊<a href="https://t.co/cfQ5byakUw">https://t.co/cfQ5byakUw</a> <a href="https://t.co/vJ6r0DuhWI">pic.twitter.com/vJ6r0DuhWI</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1569166098385502208?ref_src=twsrc%5Etfw">September 12, 2022</a></blockquote>


</div>


<p>其实上手一个新的产品，$365 的价格还是挺高的，海淘转运也断绝了试错机会。所以如果不是特别肯定，可以试试在闲鱼/淘宝上找客制化键盘的卖家，几百块买一个尝试一下。搜索的关键词就是「分体键盘」。</p>
<p>如果你喜欢 DIY，也可以找一个开源的分体键盘项目，自己动手做一套，不仅有趣，而且省钱，我相信会是一次非常有成就感的体验。我的 <a href="https://pinboard.in/u:reorx/t:keyboard/t:split">Pinboard</a> 上收藏过一些相关链接，其中我比较喜欢的是 <a href="https://github.com/rtitmuss/torn">rtitmuss/torn</a> 和 <a href="https://github.com/kata0510/Lily58">kata0510/Lily58</a>。</p>
<figure class="center align-center">
  <img loading="lazy" src="https://github.com/rtitmuss/torn/raw/master/doc/img/torn.jpg" alt=""  />

  <figcaption><p>Torn</p></figcaption>
</figure>

<figure class="center align-center">
  <img loading="lazy" src="https://user-images.githubusercontent.com/6285554/84393842-13960900-ac37-11ea-811e-65db2948ca73.jpg" alt=""  />

  <figcaption><p>Lily58</p></figcaption>
</figure>

]]></content:encoded></item><item><title>2022 年 9 月苹果发布会观后感</title><link>https://reorx.com/blog/2022-09-apple-event/</link><pubDate>Fri, 09 Sep 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/2022-09-apple-event/</guid><description>论 iPhone 重量变化与我的购买决策之间的关系</description><content:encoded><![CDATA[<p>刚过完 2022 年的科技春晚——苹果9月发布会，我也照例观看（指阅读 ifanr 的汇总文章）了一遍，大致总结如下：</p>
<table>
<thead>
<tr>
<th>产品</th>
<th>买点</th>
<th>售价</th>
</tr>
</thead>
<tbody>
<tr>
<td>iPhone 14 Pro</td>
<td>💊 灵动岛</td>
<td>￥7999</td>
</tr>
<tr>
<td>Apple Watch Ultra</td>
<td>🔋 双倍续航</td>
<td>￥6299</td>
</tr>
<tr>
<td>Airpods Pro 2</td>
<td>📡 如 AirTag 般方便寻找</td>
<td>￥1899</td>
</tr>
</tbody>
</table>
<p>总价是￥16197，好贵！相当于一台中配 MacBook Pro。但这么一想，反而不觉得贵了，同样的价格可以得到三个不同产品的体验，简直太划算了不是吗 😏</p>
<p>我对苹果产品的购买通常从三点进行考量，一是产品设计是否有颠覆式的变革，或者足够创新有趣，iPhone 14 Pro 的灵动岛显然满足这点，并且是业界独一无二的。许多人（如罗胖）说这是硬件落后倒逼软件妥协，有道理，但我不会因此否认它的人机交互设计价值。</p>
<p>第二点则是目前的设备是否拖累了我的生产力，新的设备能否做出有效的提升。我手里的 iPhone 11 Pro 还挺顺畅，iPhone 14 Pro 也没有 life-saving 级的变化，就看库克啥时候按下降频按钮了。</p>
<p>最后一点, 是手机的大小和重量，而且不容妥协。我非常爱惜自己的右手和手腕，手机太大握持不舒服，太重则手腕容易疲劳。在 iPhone X 发布时就对我产生了巨大的考验，好在 6.1 英寸我还能勉强接受，后续也都维持了这个大小。因此每次看新 iPhone 的 spec，我总是先关注重量。下面是我整理的 iPhone 从 X 到今年的 14，重量变化的表格（X 算作初代的 Pro）：</p>
<table>
<thead>
<tr>
<th></th>
<th>iPhone Pro<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup></th>
<th>iPhone<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup></th>
</tr>
</thead>
<tbody>
<tr>
<td>X</td>
<td>174g</td>
<td>-</td>
</tr>
<tr>
<td>11</td>
<td>188g</td>
<td>194g</td>
</tr>
<tr>
<td>12</td>
<td>189g</td>
<td>164g</td>
</tr>
<tr>
<td>13</td>
<td>204g</td>
<td>174g</td>
</tr>
<tr>
<td>14</td>
<td>206g</td>
<td>172g</td>
</tr>
</tbody>
</table>
<p>画成图表看更加直观：</p>
<div class="image-size-control" style="width: 100%;">
  <img loading="lazy" src="/images/blog/iphone-weight-chart.svg" type="" alt=""  />
</div>

<p>你一定想不到，当时使我决定购买 iPhone 11 Pro 而不是 iPhone 11 的原因，是前者比后者轻了 6 克！可惜的是，Pro 系列的重量在之后逐年递增，不再是我的首选项了。希望明年不带 Pro 的 iPhone 也能拥有灵动岛，这样我就会乖乖掏钱啦（库克！）。</p>
<p>再看看另外两个产品。Watch Ultra 的情况和 iPhone 14 Pro 差不多，不予考虑。Airpods Pro 2 则决定购买，已经在今晚抢到。现在我还在用着几年前买的 Airpods 2，有些旧了，正好换成最新产品。何况又有了可以像 AirTag 那样去寻找的便利性呢。</p>
<p>关于苹果，从 iPhone 11 到 13 我一直都不觉得有什么值得称道的创新，今年终于让我有些刮目相看。但我不觉得这是什么值得吹捧的事情，作为地球上最有钱的科技公司之一，本就应以引领行业发展为己任，不然赚那么多钱是干什么吃的。有人会说，资本家本就是以最低成本创造最大收益为目标呀。但这个世界还是需要有理想的人存在，哪怕只有些许的人、些许的理想，这样人类文明才不会太过无趣不是吗。</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>Specs: <a href="https://support.apple.com/kb/sp770?locale=en_US">iPhone X</a>, <a href="https://support.apple.com/kb/SP805?locale=en_US">iPhone 11 Pro</a>, <a href="https://support.apple.com/kb/SP831?locale=en_US">iPhone 12 Pro</a>, <a href="https://support.apple.com/kb/SP852?locale=en_US">iPhone 13 Pro</a>&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>Specs: <a href="https://support.apple.com/kb/SP804?locale=en_US">iPhone 11</a>, <a href="https://support.apple.com/kb/SP830?locale=en_US">iPhone 12</a>, <a href="https://support.apple.com/kb/SP851?locale=en_US">iPhone 13</a>&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><item><title>2022 年 7 月和 8 月总结</title><link>https://reorx.com/blog/2022-07-and-08-summary/</link><pubDate>Sat, 03 Sep 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/2022-07-and-08-summary/</guid><description>讲述过去两个月间我是如何生病和康复，并回顾这段时间的所见所思所得。</description><content:encoded><![CDATA[<p>似乎每年我都会生一场大病，今年也不例外。整个7月加上8月上旬基本在生病和修养中度过。由于这段时间过得太过荒废，使我首次产生写一篇月度总结的念头，想要梳理自己的所见所思所得，也希望能平复内心的压抑和焦虑，重拾生活节奏。</p>
<h2 id="健康">健康</h2>
<p>回顾一下过去十年我所经历的病痛：</p>
<ul>
<li>2013: 严重的中耳炎，耳膜破裂，听觉受损</li>
<li>2014: 肩膀和手臂拉伤，一个月不能使用右臂；右腿拉伤</li>
<li>2015: 右手腱鞘炎导致右手肿大压迫神经，住院一个月</li>
<li>2016: 过敏性支气管炎，咳嗽一个月</li>
<li>2018: 过敏性鼻炎，咳嗽两个月</li>
<li>2020: 支气管炎和轻度肺炎，在家治疗一个月</li>
<li>2021: 过敏性支气管炎，咳嗽一个半月</li>
</ul>
<p>除了 2017 和 2019 年比较幸运，其余年份都有跨度至少一个月的异常时期，简直让人对生病产生仪式感了。</p>
<p>虽然生病本身并不是什么愉快的经历，但我也因此磨练出了比较坚韧的心态，无论去医院遇到什么麻烦和挫折，都能用平常心面对；不管是出门看病，还是自主治疗，总是能做好周全的准备；家人和朋友的悉心照顾，也让我体会到亲情和友情的珍贵。</p>
<p>生命本身就是在等价交换，有得有失，真的没什么可抱怨的了。所以每次病后，也只当是一次年度提醒，告诉自己要继续坚持锻炼身体，才能持续探索这个无限精彩的世界。</p>
<p>现在来大致回顾下今年的赛况。</p>
<p>上半场是<strong>鼻炎和支气管炎</strong>。7月3日从老家回到杭州，立刻就开始感冒，随后是持续的鼻炎和咳嗽。期间我没有去医院就诊，依照自己丰富的过往经验，先是在嗓子发炎时服用了针对细菌感染的<strong>阿莫西林</strong>。进入鼻炎阶段后，没有吃内服药物，而是用<strong>布地奈德鼻喷雾剂</strong>进行（这是因为我清楚自己是过敏性鼻炎，抑制鼻部症状，等待身体免疫恢复即可）。鼻炎减轻后，按照惯例引发支气管炎和咳嗽，于是我又购买了针对自身情况的<strong>孟鲁司特钠片</strong>，吃了几天后咳嗽减轻，随后慢慢恢复适度的有氧锻炼，大约在月中彻底结束。</p>
<p>下半场比较意外，是右臂<strong>肌肉劳损</strong>。前面鼻炎时为了转移注意力，游戏时间增加了很多，由于没掌握好手柄的使用姿势，胳膊一直隐隐作痛，但被我忽视了。等到呼吸系统恢复，我又迫不急待地连续写了两天代码，终于使三角肌不堪重负，剧烈疼痛，整个右臂都肿了起来。这让我一下子回到 2015 年的夏天，好在我对此也有经验，我清楚这种程度的肌肉劳损不是一时半刻能够恢复，便用绷带固定右手，开始练习使用左手打字、用筷子、洗澡、擦屁屁。头两天我买了冰袋冷敷，等到触碰肌肉不再那么疼时，我没事就用左手慢慢按摩三角肌。最后临近恢复时，又购置了握力器来复健因长期不活动而难以握拳的右手。折腾到 7 月 30 日前后，才重新正常使用右臂。</p>
<p>时间来到 8 月，又一个意外出现，进入加时赛阶段——<strong>颈椎疼痛</strong>，脖子转不动了。这一方面是受右肩的影响和左臂的过渡使用，脖子本身有些僵硬了；另一方面，我把睡了一年的宜家弹簧床垫换成了棕榈床垫，一时间睡不习惯，导致了落枕。第二天发现脖子没法往左右转动，稍微使劲就很疼。但是不慌，我想起 5 年前有过同样的症状，火速在 JD 下单了「颈椎牵引器」，顶着极端高温天气戴了一个星期，终于圆满完赛。</p>
<p>这次持续生病的起因，主要在于 6 月回家乡度假时，作息被打乱，不仅中断了日常锻炼，还几乎没有睡好觉，导致身体素质短时间大幅下降，病也就趁虚而入了。我的体质比较特别，如果持续锻炼，即使量比较少，也能表现出很好的运动机能。但只要停止锻炼，很快就会低于常人，容易困倦和生病。以后要更加关注自己的身体状况，尤其是对于睡眠质量。睡眠关乎身体的自我调节和平衡能力，如果连续产生低质量睡眠，一定要以最高优先级应对。</p>
<p>生病期间，在 Twitter 上知道了 <a href="https://www.msdmanuals.cn/home">默沙东诊疗手册</a> 和 <a href="https://baike.qq.com/">腾讯医典</a> 两个医学参考手册 <sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>，帮助我重新梳理了对一些医学概念的认知。这个意外收获让我越发觉得，人要在任何情况下保持交流，不断更新自己。</p>
<h2 id="游戏">游戏</h2>
<p>多亏了游戏，让我在对工作力不从心的时候保持了活跃的精神状态。但游戏玩多了确实有些收不住，会感觉自己该做的事情拖欠了太多，产生空虚感和负罪感。但这不是游戏的问题，而是我需要继续提高控制力、合理安排时间（哪怕是在生病的特殊时期）。</p>
<p>下面按平台列举我这两个月玩过的游戏。</p>
<h3 id="steam">Steam</h3>
<ul>
<li>
<p><a href="https://store.steampowered.com/app/1794680/Vampire_Survivors/">Vampire Survivors</a> (MacOS)</p>
<p>好玩又上头，Vampire Survivors 属于仅靠优秀的机制就能支撑整个游戏体验的作品，画面几乎就是贴图平移，但并不影响其出色的游戏性（梦回 Dream Quest）。每局 20 分钟，围绕武器系统构建 Build，发现不同组合的趣味，挑战更高的收割效率。它开创了一个新的游戏模式，也带来了很多模仿者 <sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>。不过没关系，这么好玩的东西请多来点。</p>
<p>操作上，基本只需要上下左右四个按键，对于只能使用左手的我来说特别友好。</p>
<p>如果你有兴趣，可以尝试下目前已知的几款相似游戏：</p>
<ul>
<li><a href="https://store.steampowered.com/app/2066020/Soulstone_Survivors/">Soulstone Survivors</a></li>
<li><a href="https://store.steampowered.com/app/1966900/20_Minutes_Till_Dawn/">20 Minutes Till Dawn</a></li>
<li><a href="https://kay-yu.itch.io/holocure">HoloCure by Kay Yu</a></li>
<li><a href="https://play.google.com/store/apps/details?id=com.vkslrzm.Zombie&amp;hl=en_US&amp;gl=US">Magic Survival - Apps on Google Play</a></li>
</ul>
</li>
<li>
<p><a href="https://store.steampowered.com/app/1948280/Stacklands/">Stacklands</a> (MacOS)</p>
<p>Sokpop 7 月的作品（每月出一个新游戏），SLG + DBG 组合，同样有着优秀耐玩的机制。朋友来看望我时，用 Stacklands 度过了一个愉快的下午。</p>
</li>
<li>
<p><a href="https://store.steampowered.com/app/1604030/V_Rising/">V Rising</a></p>
<p>来自表妹的礼物 <sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup>，一开始我以为它是类似 Diablo 的 ARPG，打开才发现是 Don&rsquo;t Starve 类型的沙盒经营游戏。总的来说画面不错，但是一个人玩进度太慢，推荐和朋友们一起开黑。</p>
</li>
<li>
<p><a href="https://store.steampowered.com/app/1253920/Rogue_Legacy_2/">Rogue Legacy 2</a></p>
<p>白嫖朋友的游戏库。Rogue Legacy 一代很多年前就玩过，非常喜爱 <sup id="fnref:4"><a href="#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup>，二代的感受更像是一代的高清重置 + 新职业和地图 DLC，有时间是可以慢慢通关的。可惜的是，开发团队没有资源将游戏移植到 Switch，对我来说更希望能在 Switch 上玩这种横版的 platformer 游戏。</p>
</li>
<li>
<p><a href="https://store.steampowered.com/app/1332010/Stray/">Stray</a></p>
<p>没有买，看 streamer 的录播视频云通关。之所以特别记录一下，是因为 Stray 和我 2020 年关注的一款游戏 Little Kitty, Big City (后称 LKBC) 太像了。LKBC 的开发者只有一个人，进展缓慢，但光是预告片就深深吸引了我，我能感觉到作者在其中倾注的对猫咪的爱，相比而言 Stray 就有些缺少温度了。<sup id="fnref:5"><a href="#fn:5" class="footnote-ref" role="doc-noteref">5</a></sup></p>
<p>Stray 虽然也很优秀，但作为一款短流程的风格化游戏，本应该充满彩蛋和细节，让人可以不停地把玩，但现实却是缺少打磨、bug 众多。Stray 大火让我对 LKBC 的未来感到担忧：等到发售时，人们会不会将其视作模仿者而不予以足够的重视呢？</p>
</li>
<li>
<p><a href="https://store.steampowered.com/app/632470/Disco_Elysium__The_Final_Cut/">Disco Elysium</a></p>
<p>极乐迪斯科！无需多言，这是一个早该玩但拖到现在的艺术品。由于还没有通关，对游戏剧情就不做评价了。值得一提的是游戏有一键瞬间切换语言的功能，这让一贯坚持用英语完成所有游戏的我非常开心，毕竟叙事型游戏，文案难度还是很高的，中英对照会容易很多。 <sup id="fnref:6"><a href="#fn:6" class="footnote-ref" role="doc-noteref">6</a></sup></p>
</li>
</ul>
<h3 id="switch">Switch</h3>
<ul>
<li>
<p><a href="https://www.nintendo.com/store/products/crosscode-switch/">CrossCode</a></p>
<p>我很喜欢《远星物语》这个译名，这个游戏我在 Steam 上关注了很久，却在 Switch 上等来了打折 <sup id="fnref:7"><a href="#fn:7" class="footnote-ref" role="doc-noteref">7</a></sup>。游戏无论音乐、画面、UI 都很出色，就是走路太费劲了，2D 表现 3D 的地形实在是有些费眼，更别说制作者将其当做 feature 深度耕耘，竟使我回忆起玩仙剑奇侠传走迷宫时的恐惧。最终我只好遗憾地放弃了。</p>
</li>
<li>
<p><a href="https://www.nintendo.com/store/products/scott-pilgrim-vs-the-world-the-game-complete-edition-switch/">Scott Pilgrim vs. the World: The Game</a></p>
<p>同样是打折购买，一款非常好玩的 Beat &rsquo;em up 游戏。就像是 Streets of Rage 4 的小人儿版。鼻炎的时候在玩，还差一关打通，右臂损伤之后就没敢玩了。</p>
</li>
<li>
<p><a href="https://www.nintendo.com/store/products/ninja-gaiden-master-collection-deluxe-edition-switch/">忍者龙剑传大师合集</a></p>
<p>忍龙是大学时的记忆，工作后我还拥有过一台 PS Vita，在上面把 Sigma 2 玩了一半流程，这次是冲着回味和补完去买的。总的来说，一代虽然经典，但是太难了 <sup id="fnref:8"><a href="#fn:8" class="footnote-ref" role="doc-noteref">8</a></sup>；二代是初恋，而且难度和乐趣也都刚刚好；三代是狗屎，10 分钟我就退出了。</p>
</li>
<li>
<p><a href="https://www.nintendo.com/store/products/super-mario-odyssey-switch/">Super Mario Odyssey</a></p>
<p>一个我不好意思说我现在才玩的游戏，不多评价了，问就是「任天堂TM就是世界的主宰」。目前有 300 🌙 的收集进度。</p>
</li>
<li>
<p><a href="https://www.nintendo.com/store/products/xenoblade-chronicles-3-switch/">异度神剑 3</a></p>
<p>XB3 发售的时候，我的右臂刚摆脱绷带，不敢轻易使用，于是全程用左手操作手柄，好在这个游戏不怎么需要走位之类的复杂操作，好感 +1。连续玩了几天，进度大概有一大半。</p>
<p>XB2 之前我曾有心去补，但对我来说还是有点太大了🥵。XB3 则恰到好处，几乎没有我不喜欢的角色。剧情方面，XB3 虽然更有深度，但 XB2 的王道少年漫套路或许更适合游戏整体的画风一些。音乐方面，XB3 是好听的，但没有到让人回味的程度；我单独听过 XB2 的 OST，觉得更胜一筹。</p>
</li>
<li>
<p><a href="https://www.nintendo.com/store/products/the-legend-of-zelda-breath-of-the-wild-switch/">旷野之息</a></p>
<p>6月购入 Switch 的时候玩的第一个游戏就是旷野之息，当时打到城堡主殿就停下来了。最近有天打开游戏想要随便逛逛，不小心走进主殿触发了 BOSS 战，通关了…于是顺手买了 DLC 开启大师模式，经过 2 小时的练习，成功拿初始装备攻克初始台地人马，成就感满满。开完四神兽后，又尝试了剑之试炼，没想到难度过高，打得我自闭了两天，决定放弃，转头去玩英杰之诗。</p>
<p>旷野之息对我而言，既有如《风之旅人》般自由写意的精神享受，又有比肩《巫师3》的战斗乐趣。之后我会慢慢把所有的神庙和呀哈哈都刷完，这是对我心目中最好的游戏所能表示的最大敬意。</p>
</li>
<li>
<p><a href="https://www.nintendo.com/store/products/splatoon-3-switch/">Splatoon 3</a></p>
<p>由于发售日将近，Splatoon 3 的宣传视频最近也多了起来，看完之后我就种草了。其实我还挺喜欢多人竞技的游戏，在我看来 Splatoon 就是更好玩的 Overwatch，只恨当年不知道，不然我早就去玩 Splatoon 2 了。但最近身体好了之后也要恢复工作，虽然买了预购，一时半会也不敢轻易去玩了。</p>
</li>
</ul>
<h3 id="itch">Itch</h3>
<ul>
<li>
<p><a href="https://houkanshan.itch.io/nainais-recipe">奶奶的菜谱</a></p>
<p>非常有趣的互动式小说 (Interactive Fiction) 游戏，是两位留学生的毕设作品。玩家扮演一个孤独的社畜用手机向千里之外的奶奶学习做饭。让我想起了刚毕业那两年的自己。 <sup id="fnref:9"><a href="#fn:9" class="footnote-ref" role="doc-noteref">9</a></sup></p>
</li>
<li>
<p><a href="https://alarts.itch.io/die-in-the-dungeon">Die in the Dungeon</a></p>
<p>筛子、DBG、Roguelite、爬塔，全都是我喜欢的元素，放在一起自然是加倍好玩，而且还是全平台免费，快来和🐸一起🎲吧。 <sup id="fnref:10"><a href="#fn:10" class="footnote-ref" role="doc-noteref">10</a></sup></p>
</li>
<li>
<p><a href="https://roboatino.itch.io/shogunshowdown">Shogun Showdown</a></p>
<p>同样具有 Roguelite 和爬塔元素，玩家扮演一名武士，只身对抗左右不断涌现的敌人。游戏将武士的动作和武器融入到卡牌构筑中，策略非常多样，鼓励进攻，玩起来真的有种狭路相逢勇者胜的刺激感。适合摸鱼或编译的时候随手来两把。 <sup id="fnref:11"><a href="#fn:11" class="footnote-ref" role="doc-noteref">11</a></sup></p>
</li>
<li>
<p><a href="https://overboy.itch.io/mobs-inc">MOBS, INC</a></p>
<p>你是一个给魔王打工的怪物，被派去干掉闯入地城的人类冒险者，如果被打败，地狱的老板会让你不要偷懒继续工作。这种反串阵营的设定在 <a href="https://store.steampowered.com/app/1147560/Skul_The_Hero_Slayer/">Skul</a> 和 <a href="https://store.steampowered.com/app/978520/Legend_of_Keepers_Career_of_a_Dungeon_Manager/">Legend of Keepers</a> 上见到过，非常有趣。</p>
</li>
<li>
<p><a href="https://sokpop.itch.io/luckitown">Luckittown</a></p>
<p>掷骰子的 SLG 塔防游戏。设计亮点是有多种骰子，一次投掷多个，可以保留一部分用在下个回合。地图很开放，刚开始玩有点不知道怎么下手，但也很值得挖掘不同玩法。这个游戏也是 Sokpop 出的，在 Itch 购买后会提供 Steam key，可以说是买一送一了。</p>
</li>
</ul>
<h3 id="其他">其他</h3>
<ul>
<li>
<p><strong>炉石</strong></p>
<p>女友一直很喜欢看我玩炉石，恰好炉石节奏比较休闲，也很适合左手操作，就在右臂恢复期间久违地玩了一阵子。或许是心态进步了，明显感觉比之前打的好了很多，5000 分后越打越顺手，动不动就连续第一名。最后在到达 6000 分时，趁着还没被吊打，带着已获得的乐趣急流勇退了。一些截图记录在了<a href="https://twitter.com/novoreorx/status/1559187855565393920">这串推文</a>里。</p>
</li>
<li>
<p><strong>风暴英雄</strong></p>
<p>有半年多没玩 HOTS 了，这次其实也没玩，只是听到了一个消息，风暴开发组宣布停止更新游戏内容，于是再次哀悼了我最喜欢的 MOBA 游戏。还看了职业选手 Grubby 的视频，一开始他强做平静，说着说着痛哭出声，作为5年游戏玩家的我也产生了感同身受的悲伤 <sup id="fnref:12"><a href="#fn:12" class="footnote-ref" role="doc-noteref">12</a></sup>。</p>
<p>好在机核的这篇文章——<a href="https://www.gcores.com/articles/153481">有的游戏三岁就死了，直到七岁才埋：写在《风暴英雄》发布新公告后</a>——冲淡了难过的情绪，里面还有一个让人忍俊不禁的比喻：</p>
<blockquote>
<p>大致想象一下，你有一位异地恋的女友。四年前，她打电话来告诉你说：“咱们就继续维持这种异地关系吧”。</p>
<p>在随后的几年里，你们保持着电话联系，偶尔她会和你分享一下最近的趣事，包括她现在在干嘛，是不是毕业了，是不是工作了，是不是升职了？反正有一搭无一搭的还会说几句话。</p>
<p>大约一年半以前你们通了最后一次电话，从此之后大概一两个月会发一发短信，无非也就是在干嘛，没什么，睡了，再见，有时候刷到几条你还可见的朋友圈，也能去点个赞。</p>
<p>今天她突然告诉你说：咱们散了吧。作为笔者自己的话，我可能的回复也许是：合着还没分呐？</p>
</blockquote>
</li>
</ul>
<h2 id="影视">影视</h2>
<h3 id="老番">老番</h3>
<ul>
<li>
<p><a href="https://movie.douban.com/subject/35177664/">平稳世代的韦驮天们</a></p>
<p>原本只是想补下去年的番，没想到成为我今年最喜欢的动画。反套路、快节奏的剧情，张力十足的战斗画面，MAPPA 在这部番中淋漓尽致地释放了他们业界顶尖的制作水准。唯一的遗憾是故事还未结束便戛然而止，也暂时看不到第二季制作的可能性。</p>
</li>
<li>
<p><a href="https://movie.douban.com/subject/26997470/">Devilman: Crybaby</a></p>
<p>在看《平稳世代的韦驮天们》时联想到了恶魔人，便重看了这部汤浅政明 2018 年拍的老番。震撼依旧，但相比第一次观看注意到更多细节，有了更深刻的理解。许是内心的承受力这些年来有所提升，太郎吃妈妈那段情节我终于能面不改色地看下去，这才发现诺埃尔（太郎的爸爸）几次举枪又放下的镜头，对人性的刻画有多么深刻。真有你的汤浅，不愧是我最欣赏的动画导演之一。</p>
</li>
<li>
<p><a href="https://movie.douban.com/subject/35007099/">Burn the Witch</a></p>
<p>很精彩的小品番，只有3集，令人意犹未尽。原作漫画的作者是久保带人，代表作是《死神》。在这个新人只会画异世界的时代，只有老一辈艺术家还在继续着纯粹的、不受约束的幻想。以及我再次感受到日本人「边境心态」的优点，不需要对英国的魔法和童话体系有多么深刻的理解，不用怕 Harry Potter 珠玉在前，想画什么就画什么完事了。这并不是对文化的源流缺少敬畏，恰恰相反，作者的心中其实并没有「我」的位置，「我」是一个边境人，没有政治立场或民族情绪。故能对所有的文化一视同仁，以纯粹的热爱，将「我」的认知糅合想象力，创造出新的世界。《来自深渊》和《宝石之国》在我看来也都是在这样的心态下才能创造出的，瑰丽奇幻的世界。（这两部的作者都是女性漫画家，究竟是巧合还是必然呢？）</p>
</li>
<li>
<p><a href="https://movie.douban.com/subject/27102516/">黄金神威</a></p>
<p>日俄战争历史背景，<a href="https://zh.m.wikipedia.org/zh-hans/%E9%98%BF%E4%BC%8A%E5%8A%AA%E6%97%8F">阿伊努人</a>民族风俗，这两大元素让黄金神威在近年的番剧中显得如此特立独行。2018 年我在 B 站看完了第一季，后来因为 B 站下架一直没追。最近发现第四季都出了，遂下载，一口气补完三季。朋友说<a href="https://mox.moe/c/11149.htm">漫画版</a>不容错过，得空一定要慢慢品鉴。</p>
</li>
</ul>
<h3 id="新番">新番</h3>
<ul>
<li>
<p><a href="https://movie.douban.com/subject/35722670/">Lycoris Recoil</a></p>
<p>挑不出毛病的 JK 战斗番，不挑观众，就是纯粹的好看，无论是老二次元还是普通观众，无论有什么口味偏好，都能得到极好的观看体验。角色设定上有个很有意思的地方，和《吊带袜天使》一样，都是一个黑人大叔带着两个会打枪的妹子维持世界和平，不知是巧合还是致敬 <sup id="fnref:13"><a href="#fn:13" class="footnote-ref" role="doc-noteref">13</a></sup>。</p>
<p>关于这部番还有一则逸闻，知名游戏制作人小岛秀夫每周都会发推文记录自己的看番感受。 <sup id="fnref:14"><a href="#fn:14" class="footnote-ref" role="doc-noteref">14</a></sup></p>
</li>
<li>
<p><a href="https://movie.douban.com/subject/35656960/">Call of the Night</a></p>
<p>吸血鬼与少年的故事，梦回物语系列。第一话台词超棒，让我有找到同类的欣喜 <sup id="fnref:15"><a href="#fn:15" class="footnote-ref" role="doc-noteref">15</a></sup>：</p>
<blockquote>
<p>人为什么会熬夜
是因为有想看的节目？有想做的事？还是因为对明天感到不安？
这全都起因于一件事
就是对今天不满足
来熬夜吧，熬到对今天满意为止</p>
</blockquote>
<p>另外这段<a href="https://imgur.com/eQpcs3q">脱衣服的镜头</a>简直太赞了。</p>
</li>
<li>
<p><a href="https://movie.douban.com/subject/35417872/">异世界归来的舅舅</a></p>
<p>舅舅在异世界穿越了 17 年，回来后用魔法拍视频当 YouTuber，和主角回忆异世界生活的日常。总体来讲是部好看的番，有很多可圈可点的地方。首先是反套路设定，舅舅在异世界过的并不好，因此本番的笑点多数都有黑色幽默的气质。舅舅的人设是一个 SEGA 游戏机的死忠粉，游戏几乎就是他人生的全部，看的时候映照出一些自己的影子，对舅舅的傻里傻气感觉的很亲切。缺点则是女角色没什么意思，而且傲娇 + 直男梗看多了容易疲劳。</p>
</li>
<li>
<p><a href="https://movie.douban.com/subject/35351365/">夏日重现</a></p>
<p>夏日重现其实是4月新番，本季还在继续更新。如果要推荐国产剧观众来观看，我会说这是更加精彩的《开端》——虽然这个说法并不准确。</p>
<p>在连续两季的更新中，夏日重现整体维持了比较平稳的质量，剧情也是越来越引人入胜。最近快要大结局了，每次我都是攒两集再看，只看一集简直百爪挠心。有次和朋友聊天提到夏日重现动画的真实感很强，朋友说那是你没有看过漫画，随后向我展示了一张男主在漫画中的表情，看到后我毫不犹豫的将其加入到了漫画待看列表中。</p>
</li>
</ul>
<h3 id="纪录片">纪录片</h3>
<ul>
<li>
<p><a href="https://movie.douban.com/subject/34960008/">The Social Dilemma</a></p>
<p>最早是 2 年前好友 @<a href="https://twitter.com/chiusx">chiusx</a> 向我推荐的，最近又重温一次 <sup id="fnref:16"><a href="#fn:16" class="footnote-ref" role="doc-noteref">16</a></sup>。这 2 年发生了许多事情，我们的生活越发依赖互联网公司所提供的服务。或许我们最终也无法摆脱作为数据商品被操纵的命运，至少也要尽可能多地了解真相，哪怕它是残酷的，也好过活在无知的快乐中。</p>
</li>
<li>
<p><a href="https://www.bilibili.com/video/BV1nT4y137sr">A Dream Cast</a></p>
<p>因为《异世界归来的舅舅》而想起 SEGA 的游戏主机，便找到了这部纪录片。看完颇有感触。Dreamcast 最大的挫折就是在发售时被 PS2 狙击了。更低的售价，更高的附加值（DVD 播放器），索尼终于报了 PS1 和 Saturn 打价格战时的仇。但也怪世嘉自己不争气，在 PS2 发售前两年的时间窗口里没有推出杀手级的游戏单品和足够丰富的游戏阵容。任天堂一直以来就是在这一点做到极致，永远先有游戏，再配合推出硬件，发售的时候就是双拳出击，不给同行任何机会。但不是每个公司都是任天堂，世嘉已经尽力了。</p>
<p>Dreamcast 承载着社长大川功先生做出极致主机的梦想，在那个时代造就了一系列拥有独立游戏精神的作品，并深深影响了业界的发展。这个梦想投射在了一代制作人和玩家的记忆里，that&rsquo;s why it&rsquo;s called a dream cast.</p>
</li>
</ul>
<h2 id="阅读">阅读</h2>
<p>久居在家，促使我翻出遗忘许久的「泡面盖」重新使用。看的书不多，但是比以往都要仔细。</p>
<ul>
<li>
<p><a href="https://book.douban.com/subject/35005045/">夜晚的潜水艇</a></p>
<p>看到和菜头提及小说家陈春成，就去找了他的第一本书《夜晚的潜水艇》。它应该是十年来最打动我的文字和故事。作者的文笔非常独特，虽然没有华丽的词藻，但却有种将他对世界独一无二的感受传达给人的能力。作者年岁和我相近，把我们那一代人年幼时的懵懂、长大后的迷惘，种种最细微幽深的情结，全部重现在他天马行空的幻想故事中。</p>
</li>
<li>
<p><a href="https://book.douban.com/subject/30394154/">山月记</a></p>
<p>这是日本作家中岛敦取材中国古代文学作品所编撰的故事集，大部分原著我都没有看过，因此读起来颇有趣味。最喜欢其中《悟净出世》和《悟净叹异》两篇，让我对沙僧甚至整个西游记都有了很多新的理解。</p>
</li>
</ul>
<h2 id="见闻">见闻</h2>
<ul>
<li>
<p>看到名为「人间观察」的频道主 Hayami 关于「男的」的言论，感到过于好笑，就<a href="https://twitter.com/novoreorx/status/1545438934519652352">发推</a>抨击了一下。</p>
</li>
<li>
<p>某天晚上在小区人行道上发现一只踽踽爬行的小蝉，把它放到了路边的草丛，免得被路人踩到。回忆起童年时光，夏夜可以在树上找到它们，动作笨笨的很听话，捉回家放到花盆里，等它们第二天表演金蝉脱壳。 <sup id="fnref:17"><a href="#fn:17" class="footnote-ref" role="doc-noteref">17</a></sup></p>
</li>
<li>
<p>因为感受到买药的麻烦，我发出了「你最好不要生病」的感叹 <sup id="fnref:18"><a href="#fn:18" class="footnote-ref" role="doc-noteref">18</a></sup>，谁知没过几天就应验了：女友用美团外卖购买了午时茶（一种消食冲剂），我们谁也没意识到这个药会和疫情风控有关，第二天她就因买药后 24 小时内没做核酸而被赋为黄码，不得不去附近的医院按照规定流程重新检测。</p>
</li>
<li>
<p>在天桥上散步时，偶遇在杭州见过最美的黄昏。</p>
<p><img loading="lazy" src="/blog/2022-07-and-08-summary/images/dawn.jpg" type="" alt=""  /></p>
</li>
<li>
<p>看了《回村三天，二舅治好了我的精神内耗》这个视频，鉴定为新时代精神毒品。当时写了点评价，之后整理下单独发一篇短文吧。</p>
</li>
</ul>
<h2 id="好奇心">好奇心</h2>
<ul>
<li>
<p><strong>Retro Game Console &amp; Android Box</strong></p>
<p>在 YouTube 上看到两个关于 Retro Game Console 的视频 <sup id="fnref:19"><a href="#fn:19" class="footnote-ref" role="doc-noteref">19</a></sup>，勾起了我对掌机运行 FC/GB/NES 模拟器玩老游戏的兴趣。以前购买过一个 <a href="https://www.clockworkpi.com/gameshell">GameShell</a>，价格接近 Switch 了不说，手感还非常差，对比这次发现的新产品，个个既好看又实用，感觉真是亏爆。</p>
<p>我便想，不如直接在电视盒子上安装 RetroArch，连接手柄就可以把电视变成复古游戏主机了。但在 Oppo 电视上直接安装没有成功，这时朋友向我推荐了 HK1，于是我又去研究 Android TV Box，找到一篇对比多个盒子的博文 <a href="https://jdhao.github.io/2021/06/13/android_tv_box_reviews/">安卓外贸电视盒子体验</a>。在翻看作者的介绍时，发现了他的 GitHub 项目 <a href="https://github.com/jdhao/nvim-config/">jdhao/nvim-config</a>，为我升级 nvim 配置提供了重要的参考，不禁感叹 “down the rabbit hole” 之奇妙。</p>
</li>
<li>
<p><a href="https://departmentmap.store/timemachine">The Apple Store Time Machine</a></p>
<p>在 Hacker News 发现这个网站，作者用 Unity 写了一个名叫 Shop Different 的「游戏」，以第一人称视角游览不同时期的 Apple Store。我在这座数字博物馆中流连忘返了一个晚上，还发现了之前未曾听过的 <a href="https://zh.wikipedia.org/wiki/IBook">iBook</a> 笔记本电脑 <sup id="fnref:20"><a href="#fn:20" class="footnote-ref" role="doc-noteref">20</a></sup>。</p>
</li>
<li>
<p><strong>VLC &amp; LibVLC</strong></p>
<p>过去一年我的主要观影工具是 Plex，时常会遇到一些视频无法播放的问题，开始我怀疑是 NAS 硬件的问题，经过仔细检查发现，大多数情况都是在串流 1080p 码率的原始视频文件时，客户端解码出错。于是我给电视的安卓系统安装了 VLC，测试发现同样的文件都可以播放，这才意识到是 Plex for Android TV 内置播放器的问题。</p>
<p>但 VLC 毕竟不是完整的媒体服务器，使用体验还是差一些。为了解决这两个月大量的观影需求，我给 NAS 部署了 Jellyfin 并安装了其客户端，在客户端设置里发现了「首选播放器」选项，选择 LibVLC 后，大部分 Plex 不能播放的视频就都可以正常播放了，只剩少数顽固份子还是只能用 VLC 播放。经过这番折腾，我得出了解码能力 VLC &gt; LibVLC &gt; Plex 的结论。 <sup id="fnref:21"><a href="#fn:21" class="footnote-ref" role="doc-noteref">21</a></sup></p>
</li>
<li>
<p><strong>模块化机械键盘</strong></p>
<p>看了B站稚晖君的视频 <a href="https://www.bilibili.com/video/BV19V4y1J7Hx">【自制】我做了一把模块化机械键盘 !【软核】</a>，好生羡慕他的动手能力，而且设计非常硬核，比某何同学不知高到哪里去了。这个键盘最让我感兴趣的是它的可拆卸的智能交互模块，它使用 <a href="https://zh.wikipedia.org/wiki/%E7%9B%B4%E6%B5%81%E7%84%A1%E5%88%B7%E9%9B%BB%E5%8B%95%E6%A9%9F">无刷电机</a> 实现了一个功能极其丰富的旋钮，不仅可以模拟不同程度的阻尼感，作为一个输入设备使用，还可以由软件控制其主动旋转，搭配侧边的屏幕作为仪表盘显示数据。</p>
<p>说到无刷电机，就想到步进电机，之前在 B 站看过一位 up 主通过步进电机演奏音乐也给我留下了深刻的印象：<a href="https://www.bilibili.com/video/BV13y4y1W734">【电机】来吧，甜蜜的死亡 / come on, sweet death - EVA剧场版OST</a></p>
<p>DIY 硬件真的很有趣，希望以后我也能像稚晖君或 <a href="https://www.youtube.com/c/DIYPerks">DIY Perks</a> 那样创造属于自己的电子设备。</p>
</li>
<li>
<p><a href="https://nota.md/">Nota</a> &amp; <a href="https://ibar.app/">iBar</a></p>
<p>由于我主要使用 Obsidian 记录笔记，写博客自然也还是用它。写下十来篇后，逐渐感觉 Obsidian 写作体验并不是那么好。写作需要 distraction free 的环境，但 Obsidian 是个折腾插件的生产力工具，气氛不对；Obsidian 的编辑器中，图片会使段落高度经常发生变化，影响思考状态。最后，我发现笔记系统与博客写作是相互冲突的，笔记系统打开关闭文件的频率很高，而一篇文章的草稿可能要持续打开三五天之久；笔记的目的是将当下的想法快速记录下来，不像博客写作有文笔规范的要求。笔记最终会成为博客的素材，将它们分开在两个软件中似乎是更好的选择。</p>
<p>于是我找到了 Nota，一个新的 Markdown 编辑器，它的前身是 <a href="https://caret.io/">Caret</a>，作者是两位保加利亚的开发者。Nota 和 Typora 类似，都是所见即得的编辑器，但 Nota 有更好的文件浏览器，以及类似 Obsidian 的文件导航工具；与 Obsidian 相比，又少了许多不那么必要的功能，在简洁和强大之间找到了一个非常恰当的平衡点。总的来说，就是手感对了，我一下子就喜欢上了 Nota，决定开将它作为我的写作专门编辑器。本文是使用 Nota 创作的第一篇博客，截止此处文字，各方面体验都很让人满意。只有一个功能缺失，就是在粘贴图片时将图片存放在当前文件的相对路径上。我追踪了一个 <a href="https://github.com/notaapp/talk/issues/123">Issue</a>，希望它能尽快得到实现。</p>
<p><a href="https://ibar.app/">iBar</a> 是 Nota 作者的另一个产品，用来快速搜索浏览器历史记录。原本我只抱着随便试试的心态，使用后却发现相当不错，不仅 fuzzy 的准确性很高，搜索速度也不差。现在它已经是我日常工作流的一部分了，推荐给任何大量浏览网页的人。</p>
</li>
</ul>
<h2 id="学习">学习</h2>
<p>虽然身体不适并且只能单手打字，我还是设法学到一些新的知识和技能。</p>
<ul>
<li>
<p><strong>PWA</strong></p>
<p>7月中旬受人之托要实现对一个手机 App 的模拟，我想到最快的方式是写一个符合 PWA <sup id="fnref:22"><a href="#fn:22" class="footnote-ref" role="doc-noteref">22</a></sup> 标准的网页。经过研究，为网页增加了 <a href="https://developer.mozilla.org/en-US/docs/Web/Manifest">Web app manifests</a>，还根据 Apple 的标准添加了一些 <a href="https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html">Safari 特有的 meta tags</a>，最终效果非常不错，达到了像素级还原 App 的体验，之后也写一篇文章来总结下。</p>
</li>
<li>
<p><strong>Vite</strong></p>
<p>在实现上文所讲的 PWA 网页时，由于没有复杂的交互，不需要用到 UI 框架，我想回归 vanilla web 的开发方式，即手写 HTML + CSS。手动刷新太麻烦，我还是需要一个能够自动刷新的 dev server。用 Webpack 显得没有必要，而且我也不够熟悉，不能短时间内完成配置。</p>
<p>最终我发现 <a href="https://vitejs.dev/">Vite</a> 是最佳选择。一直以为 Vite 是 Vue 生态专属，没想到是通用的前端开发和构建工具，无论是开箱即用的流程，还是清晰的文档和 API 设计，都给我带来非常好的使用体验。<sup id="fnref:23"><a href="#fn:23" class="footnote-ref" role="doc-noteref">23</a></sup></p>
</li>
<li>
<p><strong>Mantine 5.0</strong></p>
<p>我最喜欢的 React 组件库 Mantine 发布了 5.0，仔细看过 release note 后，发现还是有不少 breaking change 的。等再发几个 minor/patch 版本就对自己使用 Mantine 的项目进行全面更新。</p>
</li>
<li>
<p><strong>IELTS</strong></p>
<p>考 IELTS 是 6 月回家之后萌生的想法，一是想检测下自己的英语水平，二是可以给未来带来多一些选择。在把好友给我的一套真题完整做了一遍之后，发现自己与裸考通过还是有很大差距的，还是老老实实地背单词 + 做题吧。</p>
<p>在对英语学习有了更多的重视之后，我整理了一些常用的词典文件 (mdx)，分享到了 Telegram 频道里: <a href="https://t.me/reorx_share/2040">https://t.me/reorx_share/2040</a></p>
</li>
</ul>
<h2 id="whats-next">What&rsquo;s Next?</h2>
<ul>
<li>首先是重启之前的工作，原本我一直在开发一款 SaaS 产品，最近3个月却是全无进展。为了让工作进行得更有节奏，我会写下每日的 devlog，但可能没有时间润色并发布出来。</li>
<li>维护开源软件：我开发的 Obsidian 插件 <a href="https://github.com/reorx/obsidian-paste-image-rename">Paste image rename</a> 和 Hugo 主题 <a href="https://github.com/reorx/hugo-PaperModX">PaperModX</a> 不知不觉也有了一些用户，这两个月也收到不少 issue。回复正常工作状态后，我会在周末抽时间去维护这些项目。</li>
<li>开始写 Newsletter：最近写的笔记很多，有一部分既符合同样的主题，也有分享的价值。因此我准备尝试写 Newsletter，我将其命名为 <strong>Maker&rsquo;s Daily</strong>。这是一个面向创造者和开发者的通讯，1-3 日更新一篇，内容包含我对产品的思考、技术的探索、工具的使用，以及作为一个 Maker 的成长和反思。几乎每天都有与之相关的灵感涌出，所以不愁没素材可写，但我想尽量让文章短小精悍，来培养简洁写作的能力和习惯。</li>
<li>调整博客结构：在产品和技术创作之外，我也有许多关于这个世界的思考，写出来比推文长，又比一篇正式的博客要短。其实若只写长文，博客的更新频率必然不高，不利于持续输出。因此，我准备为博客增加一个新的栏目，名为 Essay，也就是短文，专门用于发表片段化的想法。</li>
</ul>
<p>这篇文章断断续续写了3天，一共7小时50分钟。如果你能看到这里，真的很感谢你的阅读，希望我的这些琐事能给你带来一些有用的信息和启发。</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1544207392002682880">twitter.com/novoreorx/status/1544207392002682880</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">查咽喉疼痛相关的资料，发现妙佑医疗 Mayo Clinic 的网站写得相当不错，简单易懂而不乏专业性，非常适合用来了解各种疾病的治疗常识，相比之下中文互联网在医疗领域的内容简直就是粪坑。<a href="https://t.co/VehBGL6VVD">https://t.co/VehBGL6VVD</a><br><br>妙佑还有一本关于家庭健康的书评价不错，准备买本读一下：<a href="https://t.co/A2r1msbG3m">https://t.co/A2r1msbG3m</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1544207392002682880?ref_src=twsrc%5Etfw">July 5, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1557237847211798528">twitter.com/novoreorx/status/1557237847211798528</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">好耶，又一个类 Vampire Survivors 游戏 <a href="https://t.co/g2Ic4jlXYX">https://t.co/g2Ic4jlXYX</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1557237847211798528?ref_src=twsrc%5Etfw">August 10, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1542889584388100096">twitter.com/novoreorx/status/1542889584388100096</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">收到表妹送的游戏，开心 <a href="https://t.co/vA0WcEAYhP">pic.twitter.com/vA0WcEAYhP</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1542889584388100096?ref_src=twsrc%5Etfw">July 1, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:4">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/885774936161357828">twitter.com/novoreorx/status/885774936161357828</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">收拾桌面的文件，发现上个月通关 Rogue Legacy 的截图，算算这游戏我一共完了有 3、4 年了呢。 <a href="https://t.co/NH0lere5Jq">pic.twitter.com/NH0lere5Jq</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/885774936161357828?ref_src=twsrc%5Etfw">July 14, 2017</a></blockquote>


</div>
&#160;<a href="#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:5">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1552557062538022913">twitter.com/novoreorx/status/1552557062538022913</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">最近 Stray 大火，虽然这是款很优秀的游戏，但我心里却不是滋味，因为它和我2020年初就关注的一款游戏 Little Kitty, Big City 太像了。它的开发者只有一个人，所以进展缓慢，但光是预告片就深深吸引了我，我能感觉到作者在其中倾注的对猫咪的爱，相比而言 Stray 只不过是冷冰冰的猫咪走路模拟器。 <a href="https://t.co/y0bYNMaiW2">https://t.co/y0bYNMaiW2</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1552557062538022913?ref_src=twsrc%5Etfw">July 28, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:5" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:6">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1564296139821555712">twitter.com/novoreorx/status/1564296139821555712</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">还有什么游戏比 Disco Elysium 更适合学英语的呢？ <a href="https://t.co/k0aWX56oyA">pic.twitter.com/k0aWX56oyA</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1564296139821555712?ref_src=twsrc%5Etfw">August 29, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:6" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:7">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1538758661241470976">twitter.com/novoreorx/status/1538758661241470976</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">没想到在 Steam wishlist 里躺了几年的 CrossCode 竟然让我等到了 Switch 上的好价 <a href="https://t.co/VGTQPL0F2i">pic.twitter.com/VGTQPL0F2i</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1538758661241470976?ref_src=twsrc%5Etfw">June 20, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:7" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:8">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1546430597685727232">twitter.com/novoreorx/status/1546430597685727232</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">全程笑死，完全是我第一次玩 Ninja Gaiden Sigma 的感受，以我有限的魂系游戏经验来说，我觉得这个游戏还真是更难一些<a href="https://t.co/iLsbHkPOGu">https://t.co/iLsbHkPOGu</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1546430597685727232?ref_src=twsrc%5Etfw">July 11, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:8" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:9">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1563036228378800129">twitter.com/novoreorx/status/1563036228378800129</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">推荐游戏《奶奶的菜谱》，两位留学生的毕设作品。扮演一个孤独的社畜用手机向千里之外的奶奶学习做饭。想起了刚毕业那两年的自己。<br><br>游戏: <a href="https://t.co/M5CcSIhQ9S">https://t.co/M5CcSIhQ9S</a><br>文章: <a href="https://t.co/KjoS9AXMlM">https://t.co/KjoS9AXMlM</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1563036228378800129?ref_src=twsrc%5Etfw">August 26, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:9" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:10">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1560940880881917952">twitter.com/novoreorx/status/1560940880881917952</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">筛子、DBG，Roguelite、爬塔，这游戏有多好玩就不用我多说了吧，而且还是全平台免费，快来和🐸一起🎲<br><br> <a href="https://t.co/d0xkLGJJjP">https://t.co/d0xkLGJJjP</a> <a href="https://t.co/GkoQqrOwow">pic.twitter.com/GkoQqrOwow</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1560940880881917952?ref_src=twsrc%5Etfw">August 20, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:10" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:11">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1561736754834866177">twitter.com/novoreorx/status/1561736754834866177</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">又找到一个特别好玩的小游戏 Shogun Showdown，同样具有 Roguelite 和爬塔元素，玩家扮演一名武士，只身对抗左右不断涌现的敌人。游戏将武士的动作和武器融入到卡牌构筑中，策略非常多样，鼓励进攻，玩起来真的有种狭路相逢勇者胜的刺激感。适合摸鱼或编译的时候来两把。<a href="https://t.co/PXusFm2hws">https://t.co/PXusFm2hws</a> <a href="https://t.co/4gLbOyMlbY">pic.twitter.com/4gLbOyMlbY</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1561736754834866177?ref_src=twsrc%5Etfw">August 22, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:11" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:12">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1546380424322363394">twitter.com/novoreorx/status/1546380424322363394</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">Grubby 都哭了，操他妈的暴雪😭<a href="https://t.co/nHWMQRrjiI">https://t.co/nHWMQRrjiI</a> <a href="https://t.co/xzQ5E5RSRO">pic.twitter.com/xzQ5E5RSRO</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1546380424322363394?ref_src=twsrc%5Etfw">July 11, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:12" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:13">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1546336180144570369">twitter.com/novoreorx/status/1546336180144570369</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">一个黑人大叔带着两个会打枪的妹子维持世界和平，请问这是哪个动画？</p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1546336180144570369?ref_src=twsrc%5Etfw">July 11, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:13" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:14">
<p><div class="tweet-ref">From <a href="https://twitter.com/Kojima_Hideo/status/1551212854631878661">twitter.com/Kojima_Hideo/status/1551212854631878661</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="ja" dir="ltr">アマプラはあまり慣れておらず、「コーダ あいのうた」を探すのに戸惑った。探索中に「リコリス・ピザ？」風の名のアニメ「リコリス・リコイル」を偶然見つけた。暗殺ライセンスを持つ少女アサシンが喫茶店へ出向？！ありえへん設定と導入！日本アニメらしい？と、気づくと3話まで観た。4話は何処だ！ <a href="https://t.co/0M46gDJpfV">pic.twitter.com/0M46gDJpfV</a></p>&mdash; 小島秀夫 (@Kojima_Hideo) <a href="https://twitter.com/Kojima_Hideo/status/1551212854631878661?ref_src=twsrc%5Etfw">July 24, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:14" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:15">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1546081640069595136">twitter.com/novoreorx/status/1546081640069595136</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">本季新番 Call of the Night——彻夜之歌，台词也太棒了，这是多少人熬夜的人内心的写照：<br><br>人为什么会熬夜<br>是因为有想看的节目？有想做的事？还是因为对明天感到不安？<br>这全都起因于一件事<br>就是对今天不满足<br>来熬夜吧，熬到对今天满意为止 <a href="https://t.co/9GrHNgBhsL">pic.twitter.com/9GrHNgBhsL</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1546081640069595136?ref_src=twsrc%5Etfw">July 10, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:15" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:16">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1551980629038092288">twitter.com/novoreorx/status/1551980629038092288</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">刚做了两件事情：<br>1. 安装了 Minimal Theme for Twitter 扩展<br>2. 将 Timeline 改成了 Latest Tweets<br>算是重温 The Social Dilemma 之后的一点自我救赎吧。 <a href="https://t.co/75E5hgsBJo">pic.twitter.com/75E5hgsBJo</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1551980629038092288?ref_src=twsrc%5Etfw">July 26, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:16" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:17">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1543942298815176705">twitter.com/novoreorx/status/1543942298815176705</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">路遇一只小蝉，把它放到了路边的草丛。不禁回忆起童年时光，夏夜可以在树上找到它们，动作笨笨的很听话，捉回家放到花盆里，等它们第二天表演金蝉脱壳 <a href="https://t.co/Ev9KbwYPyh">pic.twitter.com/Ev9KbwYPyh</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1543942298815176705?ref_src=twsrc%5Etfw">July 4, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:17" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:18">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1544141872364666885">twitter.com/novoreorx/status/1544141872364666885</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">不去医院就诊，想买个阿莫西林都很不容易。在这里生活，你最好不要生病。</p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1544141872364666885?ref_src=twsrc%5Etfw">July 5, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:18" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:19">
<p><a href="https://www.youtube.com/watch?v=1Za-FU6Wbg4">Top 10 Best Retro Handhelds Of 2021</a> 和 <a href="https://www.youtube.com/watch?v=LXj5A9ZhPSE">Where did these AWESOME Retro Consoles come from??? - Anbernic, Retroid, ODROID</a>&#160;<a href="#fnref:19" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:20">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1552162963561521152">twitter.com/novoreorx/status/1552162963561521152</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">逛了会 The Apple Store Time Machine，才知道苹果曾有 iBook 这款产品存在。<br><br>• <a href="https://t.co/OfvJnzCQFZ">https://t.co/OfvJnzCQFZ</a><br>• <a href="https://t.co/NCAvzJ1Ai4">https://t.co/NCAvzJ1Ai4</a> <a href="https://t.co/xzVRtAoEnG">pic.twitter.com/xzVRtAoEnG</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1552162963561521152?ref_src=twsrc%5Etfw">July 27, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:20" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:21">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1553324876739792896">twitter.com/novoreorx/status/1553324876739792896</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">在 Android TV 上用 Plex 快一年了，遇到过一些番剧无法直接播放必须转码的情况，但我的 NAS 转码很慢，非常卡顿。后来尝试发现这些视频文件用 VLC 走 SMB 是可以正常播放的，便怀疑问题出在 Plex 客户端上。今天换 Jellyfin 试了下，将播放器换成 LibVLC 之后，果然所有视频都能流畅播放了！ <a href="https://t.co/CdwjSPVO4Z">pic.twitter.com/CdwjSPVO4Z</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1553324876739792896?ref_src=twsrc%5Etfw">July 30, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:21" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:22">
<p>参考 <a href="https://web.dev/learn/pwa/progressive-web-apps/">Google (web.dev) 的介绍</a> 和 <a href="https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps">MDN 的介绍</a>&#160;<a href="#fnref:22" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:23">
<p><div class="tweet-ref">From <a href="https://twitter.com/novoreorx/status/1548366397528764416">twitter.com/novoreorx/status/1548366397528764416</a>:<blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">之前一直以为 Vite 是 Vue 生态专属，没想到是通用的前端开发和构建工具，今天试着用它进行 vanilla HTML + JavaScript 开发，无论是开箱即用的流程，还是清晰的文档和 API 设计，都给人非常好的使用体验。<a href="https://t.co/n4oO2RvNfp">https://t.co/n4oO2RvNfp</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1548366397528764416?ref_src=twsrc%5Etfw">July 16, 2022</a></blockquote>


</div>
&#160;<a href="#fnref:23" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><item><title>种草无线便携路由器</title><link>https://reorx.com/blog/travel-routers/</link><pubDate>Thu, 30 Jun 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/travel-routers/</guid><description>介绍无线便携路由器 Travel Router 的功能和产品，并对比说明它与 Wi-Fi 信号放大器和随身 Wi-Fi 的差异。</description><content:encoded><![CDATA[<p>时间回到两个月前的某天，和好友 <a href="https://github.com/xwjdsh">@iwendellsun</a> 一起在咖啡馆工作时，他提出了一个问题：有没有一种路由器，它可以先连接一个已有的 Wi-Fi，再发射一个新的 Wi-Fi 供自己的设备连接？</p>
<p>我立刻理解了这个诉求的来源。咖啡馆、图书馆等场所的公共 Wi-Fi 一般都有身份验证机制，连接超过一个设备就会感觉很不方便，更不用说每个设备都还要独立设置代理；如果多人在一起工作，联机调试很可能会遇到公共 Wi-Fi 的端口限制而无法成功。我们对这个需求讨论了一番，但两人都缺乏对这类产品的认识，便不了了之。</p>
<p>最近在 Twitter 上看到 <a href="https://twitter.com/pengchujin">@酱紫表</a> 分享了一款产品，突然意识到这可能正是我们想要的东西:</p>
<div class="tweet social-quote">
  <div class="title">
    <svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><title>Twitter</title><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>

    
  </div><blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">收到了一款非常有趣的产品— GL-AXT1800，我喜欢这款路由器的地方实在有点多：迷你的体积带出门很方便、高通 IPQ6000 的 CPU 性能足够强大、出厂自带 openwrt 和 uboot 真的方便、type c 电源口、支持 usb 3.0 还有 Wi-Fi 6、还自带 pwm 风扇。 <a href="https://t.co/5gAFEdiGkH">pic.twitter.com/5gAFEdiGkH</a></p>&mdash; 酱紫表 (@pengchujin) <a href="https://twitter.com/pengchujin/status/1542120681520844801?ref_src=twsrc%5Etfw">June 29, 2022</a></blockquote>


</div>


<p>虽然没有提及这款名为 <a href="https://www.gl-inet.com/products/gl-axt1800/">GL-AXT1800</a> 的路由器是否可以连接已有 Wi-Fi，但既然设计的这么小巧便携，应该是针对出门在外的应用场景吧？我如此寻思，注意到它在 GL.iNet 的产品矩阵中属于 Travel Router 这一分类，于是顺着这个关键词进行了一番搜索，果然发现这类产品大都有着和我们的需求一致的应用场景。</p>
<p>我随后去阅读了 GL.iNet 的<a href="https://docs.gl-inet.com/en/4/user_guide/">官方使用手册</a>，弄清了 GL-AXT1800 所支持的四种连接互联网的模式：</p>
<ul>
<li>
<p><a href="https://docs.gl-inet.com/en/4/tutorials/internet_ethernet">Connect to the Internet via an ethernet cable</a></p>
<p>通过网线连接互联网。GL 的路由器都有一个 WAN 口，这种用法与通常的家用路由器一致，连接在光猫或已有路由器的后面。</p>
</li>
<li>
<p><a href="https://docs.gl-inet.com/en/4/tutorials/internet_repeater">Connect to the Internet via an existing Wi-Fi</a></p>
<p>通过已有 Wi-Fi 连接互联网。这就是本文一开始所提到的在咖啡馆中的使用场景，将公共 Wi-Fi 扩展成为相对安全可控的局域网。</p>
<p>一些公共 Wi-Fi 要求对手机号/身份证等进行验证方可连接互联网，使用无线便携路由器是否会影响验证流程呢？我初步判断是不会，这种验证发生在连接 Wi-Fi 时的密码验证之后，此时无线便携路由器已完成了连接已有 Wi-Fi 并创建新的局域网的工作，假设公共 Wi-Fi 的 IP 是 192.168.1.1，无线便携路由器是 192.168.50.1，我们访问互联网触发的对 192.168.1.1 验证界面的访问是完全没有问题的，类似在家庭双层 NAT 环境下访问光猫的管理界面，DHCP 会处理好不同网段 IP 的访问关系。</p>
</li>
<li>
<p><a href="https://docs.gl-inet.com/en/4/tutorials/internet_tethering">Connect to the Internet via usb tethering</a></p>
<p>通过 USB-A 口连接手机，使用手机的移动网络连接互联网。这种模式需要打开手机的热点功能，使用有线以降低传输损耗。</p>
</li>
<li>
<p><a href="https://docs.gl-inet.com/en/4/tutorials/internet_cellular">Connect to the Internet via usb modem</a></p>
<p>通过 USB 调制解调器连接互联网。这种模式将只能插在笔记本电脑上的传统的无线上网卡变成可供多人使用的 Wi-Fi。</p>
</li>
</ul>
<p>这些模式基本可以定义出 Travel Router——无线便携路由器的主要功能，读者可思考其中是否有符合自己需求的功能，从而对这种产品做出种草或拔草的判断。</p>
<h2 id="同类产品介绍">同类产品介绍</h2>
<p>说完了什么是无线便携路由器，我们再来看看有哪些可供选择的同类产品。</p>
<h3 id="单板机-diy">单板机 DIY</h3>
<p>弄清楚产品功能之后，我发现家里被用作软路由<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> 的 <a href="https://www.friendlyelec.com/index.php?route=product/product&amp;product_id=282">NanoPi R2S</a> 是可以成为无线便携路由器的，只不过还需要对系统和配件进行一些 DIY 配置。</p>
<p>一个未经雕琢的 R2S 长这样:</p>

<figure class="center align-center">
  <div class="image-size-control" style="max-width: 500px;">
    <img loading="lazy" src="/blog/travel-routers/images/r2s.png" type="" alt=""  />
  </div>

  <figcaption><p>NanoPi R2S original</p></figcaption>
</figure>

<p>插上一个外置的无线扩展模块，它从结构上就与 GL-AXT1800 一般无二了:</p>

<figure class="center align-center">
  <div class="image-size-control" style="max-width: 500px;">
    <img loading="lazy" src="/blog/travel-routers/images/r2s-2.png" type="" alt=""  />
  </div>

  <figcaption><p>NanoPi R2S with wireless adapter</p></figcaption>
</figure>

<blockquote>
<p>Note 1: 不过这样使用也有局限，由于 R2S 只有一个 USB-A 口，当扩展为 Wi-Fi 天线后，就无法实现上文提到的 USB tethering/modem 模式了。</p>
<p>Note 2: 据推友 <a href="https://twitter.com/q1ngyang">@q1ngyang</a> 告知 <sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>，R2S 网卡方案有点麻烦，固件大多用不了无线网卡或无法驱动 AC，因此 R2S 不见得是最好的选择，若要使用 SBC 进行 DIY，还请读者在多方考察后慎重行事。</p>
</blockquote>
<p>实际上树莓派等其他 <a href="https://en.wikipedia.org/wiki/Single-board_computer">SBC</a> 也可以用同样的思路配置实现，不过我更喜欢 NanoPi 的外形和亲民的价格（去年好像涨价了），感兴趣的读者可以在淘宝认准 FriendlyElec 官方店购买。</p>
<p>固件方面，使用 OpenWrt 或其发行版即可，通过 Wi-Fi 连接互联网是 OpenWrt 的基础功能之一，官方文档对此有着非常详细的说明: <a href="https://openwrt.org/docs/guide-user/network/wifi/relay_configuration">Wi-Fi extender / repeater / bridge configuration</a>.</p>
<h3 id="纯硬件产品">纯硬件产品</h3>
<p><a href="https://www.gl-inet.com/products/gl-axt1800/">GL-AXT1800</a> 即属于此类，在硬件层面已经高度集成和完善，但固件、代理等还需用户自行配置。GL.iNet 系产品的优点之一是自带基于 OpenWrt 开发的固件 <sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup>，功能足够强大，省去了自己刷的麻烦。</p>
<p>下面列举 GL.iNet 的其他几款 Travel Routers:</p>
<ul>
<li>
<p><a href="https://www.gl-inet.com/products/gl-mt300n-v2/">GL-MT300N-V2</a></p>
<p>昵称 Mango 芒果，有着鲜明的颜色和与 R2S 近似的小巧外形。电源输入是 5V/2A，相比 GL-AXT1800 的 5V/4A 对充电头更加友好。</p>

<figure class="center align-center">
  <div class="image-size-control" style="max-width: 500px;">
    <img loading="lazy" src="/blog/travel-routers/images/mango.png" type="" alt=""  />
  </div>

  <figcaption><p>GL-MT300N-V2, AKA Mango</p></figcaption>
</figure>

</li>
<li>
<p><a href="https://www.gl-inet.com/products/gl-ar300m/">GL-AR300M</a></p>
<p>黑色并升级了 CPU 的 GL-MT300N-V2。</p>

<figure class="center align-center">
  <div class="image-size-control" style="max-width: 500px;">
    <img loading="lazy" src="/blog/travel-routers/images/gl-ar300m.png" type="" alt=""  />
  </div>

  <figcaption><p>GL-AR300M</p></figcaption>
</figure>

</li>
<li>
<p><a href="https://www.gl-inet.com/products/gl-usb150/">GL-USB150</a></p>
<p>传统 U 盘的大小，可以直接插在充电器 USB-A 口上使用。</p>

<figure class="center align-center">
  <div class="image-size-control" style="max-width: 500px;">
    <img loading="lazy" src="/blog/travel-routers/images/gl-usb150.png" type="" alt=""  />
  </div>

  <figcaption><p>GL-USB150</p></figcaption>
</figure>

</li>
</ul>
<p>其他品牌中，TP-Link 有一款 <a href="https://www.tp-link.com/us/home-networking/wifi-router/tl-wr902ac/">TL-WR902AC</a> 和 GL-MT300N-V2 类似，支持刷入 OpenWrt <sup id="fnref:4"><a href="#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup>.</p>

<figure class="center align-center">
  <div class="image-size-control" style="max-width: 500px;">
    <img loading="lazy" src="/blog/travel-routers/images/TL-WR902AC.png" type="" alt=""  />
  </div>

  <figcaption><p>TP-Link TL-WR902AC</p></figcaption>
</figure>

<h3 id="硬件--服务">硬件 + 服务</h3>
<p>推友 <a href="https://twitter.com/BigEyeSmolMouth">@BigEyeSmolMouth</a>  向我分享了<a href="https://www.meihuavpn.com/">梅花VPN</a> <sup id="fnref:5"><a href="#fn:5" class="footnote-ref" role="doc-noteref">5</a></sup>，使我得知有这类产品的存在。它们就像网易的 UU 加速盒，在硬件上内置了自己的网络代理服务，实现成软硬一体化的翻墙 Wi-Fi。</p>
<p>在我看来，这种产品优缺点都很明显。优点是开箱即用，省事不折腾；缺点是可控性差，无法更换代理提供商，且商品的溢价较高，其硬件性能不会有多少剩余空间。从安全的角度考虑，这样一个黑盒设备也无法让人信任。</p>

<figure class="center align-center">
  <div class="image-size-control" style="max-width: 500px;">
    <img loading="lazy" src="/blog/travel-routers/images/meihua.png" type="" alt=""  />
  </div>

  <figcaption><p>梅花M3家用版</p></figcaption>
</figure>


<figure class="center align-center">
  <div class="image-size-control" style="max-width: 500px;">
    <img loading="lazy" src="/blog/travel-routers/images/meihua-1.png" type="" alt=""  />
  </div>

  <figcaption><p>梅花M3便携版</p></figcaption>
</figure>

<blockquote>
<p><a href="https://www.meihuavpn.com/collections/shop/products/11offer-h">官网</a>的 spec 中没有关于 CPU、固件、协议等信息的描述。</p>
</blockquote>
<h2 id="相似但不同的产品">相似但不同的产品</h2>
<p>看过上文对无线便携路由器的介绍后，你是否有种似曾相识的感觉？没错，其实有一些与之类似但却不同的产品存在，平时没有用过也多少见过，这也是我在一开始未能准确把握自己想要什么产品的原因，它们都存在硬件或功能上的共性。下面我将针对两种可以明确定义出功能边界的设备进行阐述。</p>
<h3 id="wi-fi-信号放大器">Wi-Fi 信号放大器</h3>
<p>也有人将其称为「无线中继」，比较准确的英文名称应该是 Range Extender。这类产品的核心用途是接收已有 Wi-Fi 的信号，作为 <a href="https://en.wikipedia.org/wiki/Wireless_access_point">AP</a> 发射出相同的 SSID，以达到延长其信号范围的目的。一般用在家庭、办公室等空间较大、Wi-Fi 信号容易衰减的环境。</p>
<p>它和无线便携路由器在接收 Wi-Fi 信号这件事情上使用了相同的技术 <a href="https://en.wikipedia.org/wiki/Wireless_distribution_system">WDS</a>，但一个是扩展已有的 LAN，一个是创建新的 LAN，用途的差异使两者最终呈现出不同的产品形态。可以把 Wi-Fi 信号放大器看做特化的无线便携路由器。</p>
<p>TP-Link 有很多<a href="https://www.tp-link.com/hk/home-networking/range-extender/">此类产品</a>，它们的外形通常像耳朵长着天线的机器人🤖</p>

<figure class="center align-center">
  <div class="image-size-control" style="max-width: 500px;">
    <img loading="lazy" src="/blog/travel-routers/images/TPLink-RE605X.png" type="" alt=""  />
  </div>

  <figcaption><p>TP-Link RE605X</p></figcaption>
</figure>

<h3 id="随身-wi-fi">随身 Wi-Fi</h3>
<p>也叫「无线上网卡」，比较准确的英文名称应该是 Mobile Wi-Fi Router (or 4G Wi-Fi Router)。这类产品的目的是使用 3G/4G LTE 创建随时随地可供使用的 Wi-Fi。分为内置 eSIM 卡和依赖外部 SIM 卡两种。</p>
<p>国内的随身 Wi-Fi 一般都是内置 eSIM 卡的，外形像 U 盘或充电宝，在设计上并没有提供多少可定制性，但由于其普及程度非常高，广大 DIY 爱好者早已将其研究通透。<a href="https://twitter.com/pengchujin">@酱紫表</a> 分享过一篇 <a href="https://qust.me/post/msm8916/">4g 随身 Wi-Fi 刷 openwrt 变成软路由</a> 教程，成品展示在这条<a href="https://twitter.com/pengchujin/status/1536310680109735936">推文</a>中。</p>
<blockquote>
<p>其实在刷上 OpenWrt 后，随身 Wi-Fi 就等同于上文中 <a href="https://docs.gl-inet.com/en/4/tutorials/internet_cellular/">Connect to the Internet via cellular</a> 模式下的  GL.iNet，因此它同样可以看做无线便携路由器的特化版。</p>
</blockquote>
<p>Netgear 的 <a href="https://www.netgear.com/home/mobile-wifi/hotspots/mr1100/">MR1100</a> 则是插卡的，黑色金属质地体现出服务器特有的冷酷和科技感，还有着让我无法抗拒的数据指示屏。</p>

<figure class="center align-center">
  <div class="image-size-control" style="max-width: 500px;">
    <img loading="lazy" src="/blog/travel-routers/images/netgear.png" type="" alt=""  />
  </div>

  <figcaption><p>Netgear MR1100</p></figcaption>
</figure>

<p>另一款值得一提的产品是 GL.iNet 的 <a href="https://www.gl-inet.com/products/gl-e750/">GL-E750</a>，它同时具备无线便携路由器和随身 Wi-Fi 的功能，并且可以通过内置的 7000mAh 供电，相当于 GL-MT300N-V2 + 上网卡 + 充电宝的 all-in-one 组合。在咖啡馆可以连接公共 Wi-Fi 并接线充电，在户外则可以使用移动网络不依赖电源，简直是理想中的产品。但一般来说，越是什么都会，越可能什么都不精，GL-E750 具体表现如何，只有真正入手使用后才能知道。</p>

<figure class="center align-center">
  <div class="image-size-control" style="max-width: 500px;">
    <img loading="lazy" src="/blog/travel-routers/images/gl-e750.png" type="" alt=""  />
  </div>

  <figcaption><p>GL.iNet Mudi (GL-E750)</p></figcaption>
</figure>

<h2 id="结语">结语</h2>
<p>在移动办公越来越广泛的今天，无论你是时常出差办公的技术工作者，还是四海为家的数字游民，无线便携路由器都是值得了解和尝试的产品。数据和隐私是无价的，当连接到公共 Wi-Fi 时，我们的设备便存在被攻击和窃取数据的风险，而通过无线便携路由器，我们可以在一个可控的局域网中管理设备、增加防护，获得更安全的办公环境和更高效的生产力。</p>
<p>本文仅对一些无线便携路由器做了简单的列举，不涉及传输和加密性能的测评，由于我还没有实际使用过，因此无法对这些产品的好坏做出评价，读者将本文当做科普知识的分享即可。在未来我会补充一篇实际的产品使用体验，如果你使用过这一类产品，欢迎在评论区或 Twitter 分享你的经验。</p>
<h2 id="revision">Revision</h2>
<ul>
<li>2022-06-30: created</li>
<li>2022-07-02: add GL-E750</li>
<li>2022-07-09: update R2S notes</li>
<li>2022-07-10: 添加关于「使用无线便携路由器是否会影响公共 Wi-Fi 手机号验证流程」的描述</li>
</ul>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>见推文 <a href="https://twitter.com/novoreorx/status/1412406192845033473">https://twitter.com/novoreorx/status/1412406192845033473</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://twitter.com/q1ngyang/status/1543116478987190275">https://twitter.com/q1ngyang/status/1543116478987190275</a>&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3">
<p>见 GitHub 项目页 <a href="https://github.com/gl-inet/openwrt">https://github.com/gl-inet/openwrt</a>&#160;<a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:4">
<p>见 OpenWrt 官网的硬件支持页面: <a href="https://openwrt.org/toh/tp-link/tl-wr902ac_v3">https://openwrt.org/toh/tp-link/tl-wr902ac_v3</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://twitter.com/BigEyeSmolMouth/status/1542379014970417153">https://twitter.com/BigEyeSmolMouth/status/1542379014970417153</a>&#160;<a href="#fnref:5" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><item><title>更换博客评论系统</title><link>https://reorx.com/blog/blog-commenting-systems/</link><pubDate>Sun, 29 May 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/blog-commenting-systems/</guid><description>整理了一份比较完整的评论系统评测列表，并详细介绍了最终选择的 Remark42。</description><content:encoded><![CDATA[<h2 id="告别-disqus">告别 Disqus</h2>
<p>博客建成也有一段时日了，渐渐也会收到一些读者的评论，在和大家交流中学到了很多新的知识，我很喜欢这种感觉，把评论看做我和读者之间重要的交流渠道。有一天我收到了 <a href="https://twitter.com/geekdada">@geekdada</a> 在 <a href="/blog/deploy-umami-for-personal-website/">搭建 umami …</a> 下的留言:</p>
<blockquote>
<p>非常感谢推荐。不知道作者有没有想把 Disqus 换掉，又臃肿又收集数据。</p>
</blockquote>
<p>于是我开始留意起 Disqus 的使用体验。其实我在 2013 年就注册了 Disqus，用在了自己最早的一版个人网站上，对它也算是有些感情，这也是一开始快速实现时选择它的原因。</p>
<p>我打开 Chrome Dev Tools，刷新文章页，果然看见一大堆请求，一共有 94 次，这可太恶心了！且不论它收集了哪些统计数据和用户隐私，光是数量之多就让有代码洁癖的我无法忍受。</p>
<p><img loading="lazy" src="/blog/blog-commenting-systems/images/disqus.png" type="" alt=""  /></p>
<p>再试探性地关掉 ublock，这下更不得了，显示出有半个屏幕之大的广告区域，充斥着猎奇的图片和 clickbait 标题的链接。去设置页看了下，果然只有付费用户才可以关闭广告，但接连被恶心到，我已经彻底决定抛弃 Disqus。</p>
<p>就像之前从 Google analytics 换到 umami 那样，我踏上了寻找新的评论系统的旅程。</p>
<h2 id="评论系统的选择">评论系统的选择</h2>
<p>经过一番调查，我大致将评论系统分为了如下几类，每类都放上我认为最好的几种可选项，为同样带着自己的需求寻找评论系统的读者提供参考。</p>
<ul>
<li>
<p><strong>Full-fledged</strong></p>
<p>全功能的评论系统，从前端的 UI 组件到后端的 API、数据存储都完整实现。其下又分为 SaaS 和 Self-hosted 两类。</p>
<ul>
<li>
<p><strong>SaaS</strong></p>
<p>Disqus 就是属于这一类产品，优点是配置便捷，不用操心；缺点则是可能会收集隐私数据，缺乏可控性，并且价格都比较贵。</p>
<ul>
<li><a href="https://commento.io/">Commento</a>: 目前是我最喜欢的一个 SaaS 评论系统，界面简洁清爽，能适应大部分博客的主题样式，无广告和追踪。价格为每月 $10。</li>
<li><a href="https://commentbox.io/">CommentBox</a>: 我很早收藏过的一个评论系统，和 Commento 一样的 slogan 和价格，就是功能和设计看起来要弱一些。</li>
</ul>
</li>
<li>
<p><strong>Self-hosted</strong></p>
<p>与一般的互联网服务不同，评论系统中 Self-hosted 的比例反而比 SaaS 化的大很多，这大概是因为自建博客的市场比较小众，且使用者也多为喜欢造轮子的程序员。Self-hosted 虽然较为麻烦，但优势也非常明显——由开源带来的高可控性、高定制性，并且近年来 PaaS 平台的蓬勃发展大大降低了部署成本，不需要拥有服务器便可一条龙 0 元购搭建起来。因此如果你有一定的技术基础，非常建议尝试自部署一套评论系统。</p>
<ul>
<li><a href="https://github.com/umputun/remark42">Remark42</a>: 我最喜欢的一个 self-hosted 评论系统，也是本文接下来所要讲解的对象。</li>
<li><a href="https://github.com/posativ/isso">Isso</a>: 使用 Python 开发，数据库为 SQLite。界面复古而简洁，适合 minimalist。Isso 可以配置评论自动通过，或者先审核再通过。</li>
<li><a href="https://github.com/djyde/cusdis">Cusdis</a>: 我很佩服的开发者 <a href="https://lutaonan.com/">Randy</a> 的作品，也是使用 SQLite 作为数据库，就像是 Isso 的 Nodejs 翻版，但界面更加美观。与 Isso 不同的是，Cusdis 要求评论必须通过人工审核才可以显示，提供了 Email 和 Dashboard 两种审核方式。</li>
<li><a href="https://github.com/adtac/commento">Commento</a>: SaaS Commento 的开源版，使用 Go 开发，支持各种 social auth 登录，甚至可以配置 SSO。但这个项目已经停止更新一年多了，如果你有兴趣，可以考虑使用社区 fork 维护的 <a href="https://github.com/souramoo/commentoplusplus">Commento++</a>，作者的更新还挺活跃的。</li>
<li><a href="https://twikoo.js.org/">Twikoo</a>, <a href="https://artalk.js.org/">Artalk</a>, <a href="https://valine.js.org/">Valine</a>: 三者都是面向国内用户所开发的评论系统，有很多相似之处，因此放在一起介绍。与上述其他项目不同的是，三者的接入方式并非 iframe，而是直接操作网页的 DOM 元素。
<ul>
<li>Twikoo 主要支持腾讯云 CloudBase，也可以部署到 Vercel。数据库是 MongoDB。更新<a href="https://github.com/imaegoo/twikoo/graphs/contributors">比较活跃</a>。</li>
<li>Artalk 使用 Go 开发，无部署环境限制，支持常见的 RDBMS。更新<a href="https://github.com/ArtalkJS/Artalk/graphs/contributors">近期较为活跃</a>。
<mark>Artalk 的作者 <a href="https://qwqaq.com/about/">qwqcode</a> 去年高考步入大学，开发 Artalk 的时候刚上高一。在三年里独立完成 Artalk 从 PHP 向 Go 的架构转变，TypeScript 也写的贼溜，<a href="https://twitter.com/novoreorx/status/1532175668766683136">现在的年轻人真是越来越强了</a></mark>。</li>
<li>Valine 似乎只支持 LeanCloud 部署，已经<a href="https://github.com/xCss/Valine/graphs/contributors">很久没有更新</a>了。</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li>
<p><strong>Proxied</strong></p>
<p>类似 full-fledged，有自己实现的 UI 和 API，但数据存储依托于一个已有的其他服务。我个人并不偏爱此类应用，一者登录方式被限制在其所依托的服务上，再者数据也不好维护。</p>
<ul>
<li><a href="https://github.com/utterance/utterances">utterances</a>: 应该是最早的基于 GitHub 的评论系统，提供了一个用于接入评论的公开服务。</li>
<li><a href="https://github.com/giscus/giscus">giscus</a>: utterances 的 fork 版，增加了自部署的能力。</li>
</ul>
</li>
<li>
<p><strong>IM Plugin</strong></p>
<p>由即时通讯服务提供的插件。这是个顺理成章的思路，对于即时通许服务商来说，可以利用已有的基础设施提供更多的 user engagement，何乐而不为呢。其中最为知名的当属 Facebook，Google 曾经也有，但和 Google Plus 一同被埋葬了。这种评论系统也存在登录方式单一的问题，请谨慎选择。</p>
<ul>
<li><a href="https://comments.app/">Comments.app</a>: Telegram 提供的网站评论插件，使用简便，界面美观，支持 dark mode 和 accent color。</li>
<li><a href="https://core.telegram.org/widgets/discussion">Discussion Widget</a>: 同样是 Telegram 的服务，但与 Comments.app 的不同在于，Discussion Widget 原本的设计目的是将 Channel 消息中的评论嵌入到网页中，但我发现这同样是一种评论系统，并且多出了将评论对应到 Channel 消息的功能。我在探索中已经顺便将 Discussion Widget 实现到了博客主题中，你可以在这个页面中预览和尝试: <a href="/playground/comment-telegram/">Commenting System: Telegram Widget</a></li>
<li><a href="https://developers.facebook.com/docs/plugins/comments/?locale=en_US">Meta Comments Plugin</a>: Meta (Facebook) 的评论插件，我的社交圈里基本没人使用 Facebook，因此不予评价。</li>
</ul>
</li>
<li>
<p><strong>Static</strong></p>
<p>静态评论，即评论作为静态网站原始数据的一部分，跟随网站的构建流程生成到页面中。不同于其他评论系统可以灵活嵌入到任何页面，静态评论只能用于静态网站中。评论的数据是否应该属于网站本身呢？这是一个非常两极分化的问题。</p>
<ul>
<li><a href="https://welcomments.io/">Welcomments</a>: 静态评论 SaaS 服务，需要用户绑定自己网站的 GitHub 仓库。当有人评论时，Welcomments 会生成一个 commit 包含评论的数据提交到仓库中，触发 GitHub 的自动构建流程，将评论更新到静态网页中。Welcomments 支持在它的网站上进行评论审核，还接入了 <a href="https://akismet.com/">Akismet</a> anti-spam 服务，每月 $6，年付减为 $4。</li>
<li><a href="https://github.com/eduardoboucas/staticman">Staticman</a>: 更为强大的 Welcomments，并且是 self-hosted。Staticman 支持更多的代码托管平台，并且可以通过 pull-request 的方式审核评论，这才是基于 Git 的工作流应有的样子。但 Staticman 已经有 2 年没更新了，在进行更深入的调研之前，不建议作为第一选择。</li>
<li>手动维护: 任何手动维护评论的方式。有的博主会在网站会留下邮箱、即时通讯号等联系方式，让读者将想要评论的内容发送过去，然后将评论内容手动更新到文章的评论区。这种方式虽然简陋，但也不失为一种最简单的实现手段，毕竟评论的本质就是交流。</li>
</ul>
</li>
</ul>
<h2 id="我的选择-remark42">我的选择: Remark42</h2>
<p>基于如下这些原因，我在众多评论系统中选择了 Remark42。</p>
<h3 id="技术层面">技术层面</h3>
<ul>
<li>它使用 Go 语言实现，我很难抗拒二进制单文件的诱惑。</li>
<li>它使用基于文件的数据库。我最喜欢的数据库是 SQLite，单文件无任何依赖，Remark42 使用的 <a href="https://github.com/etcd-io/bbolt">boltdb</a> 也是基于文件，对我来说和 SQLite 一样美好。</li>
<li>它有较为现代化的前端技术栈，使用 TypeScript, Preact, Postcss 开发。</li>
<li>它是一个成熟的开源项目，文档全面、开发时间长，基本上我的任何问题都可以在文档和 Issues 中找到解答。</li>
<li>它的 <a href="https://remark42.com/docs/contributing/api/">API</a> 非常完善，比如导入导出都可以用一行 curl 来完成。在其他方面作者也显示出很高的<a href="https://remark42.com/docs/contributing/technical-details/">技术品味</a>。</li>
</ul>
<h3 id="视觉层面">视觉层面</h3>
<p>Remark42 界面简洁美观，虽然比 Isso 和 Cusdis 复杂，但依然保持了足够的适应性和可塑性。</p>
<figure class="center align-center">
  <img loading="lazy" src="/blog/blog-commenting-systems/images/remark42-1.png" type="" alt=""  />

  <figcaption><p>Remark42 简洁的界面</p></figcaption>
</figure>

<h3 id="功能层面">功能层面</h3>
<p>我其实不喜欢 pre-moderation，即评论必须先通过审核才可以显示，这会给我带来额外的工作量，并且评论者也没法得到即时的效果反馈。但我也不想开放无审核的匿名评论，给自己带来被评论机器人攻击的风险。</p>
<p>我想到的折中方案是，首先提供尽可能多的 social auth 登录方式，比如 Twitter, Google, GitHub，这些 Remark42 都支持；再提供一种基于邮箱的认证方式，这是 Remark42 最大的亮点——Passwordless Authentication。评论者输入邮箱，点击 login，Remark42 会向其发送一个含有 token 的邮件，评论者使用这个 token 完成登入便可以进行评论。</p>
<p>邮箱认证是我个人最喜欢的登录方式，因为社交账号登录总是会存在风险，服务会拿到可以访问用户数据的 access token，你不知道它会做些什么 <sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>。而邮箱不存在用户数据，唯一的担忧是被 spam 骚扰，但有很多匿名邮箱和邮件转发服务帮助我们解决这个问题。<del>使用邮箱登录进行的评论，在被回复后也会发送邮件提醒评论者，确保了双向沟通的流畅性</del><sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>。</p>
<p><figure>
      <video class="video-shortcode" preload="metadata" controls>
        <source src="/blog/blog-commenting-systems/images/remark42-email-login.mov">
        There should have been a video here but your browser does not seem to support it.
      </video>
      </figure></p>
<p>Remark42 还有一个特别的地方，它没有 Admin 界面，或者 Dashboard，这得益于其清晰的设计理念和完善的功能实现。一般对于评论系统来说，Admin 主要用于查看和审核评论，但 Remark42 不需要审核，而对评论和评论者的操作（隐藏、删除、屏蔽）都集成在评论控件中。加上 API 简单易用，去掉 Admin 不仅没有让人感到不便，反而成为一个值得一提的 feature 了。</p>
<p>最后，Remark42 在设计上就支持 multi-site，只需要部署一个实例，便可以为多个不同域名的站点提供评论服务。</p>
<h3 id="隐私层面">隐私层面</h3>
<p>Remark42 的标语是「Privacy-focused lightweight commenting engine」，而它的确在隐私这件事情上做了许多事情。无追踪和广告自不必说，尽可能少的申请 OAuth 权限也只是基操，Remark42 还对用户数据做了这些考虑：</p>
<blockquote>
<p>Users can request all information Remark42 knows about them and receive the export in the gz file.</p>
</blockquote>
<p>用户可将自己在一个 Remark42 服务中的所有评论数据导出，多希望我高中时使用的百度空间也有这个功能。</p>
<blockquote>
<p>Supports complete cleanup of all information related to user&rsquo;s activity by user&rsquo;s &ldquo;deleteme&rdquo; request.</p>
</blockquote>
<p>用户可以通过 &ldquo;deleteme&rdquo; 请求将所有与自己相关的数据全部清除，类似近年来用户呼吁各大网站去支持的 opt-out <sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup>。</p>
<h2 id="小结">小结</h2>
<p>Remark42 还有许多功能，比如评论的 RSS 输出、Telegram 通知接入、Webhook 等等，限于本文篇幅，就不做展开了。下一篇博客我会对如何在 <a href="https://fly.io/">Fly.io</a> 部署 Remark42、自定义 UI、接入 CDN 等内容进行详细的讲解，让读者也可以快速搭建自己的 Remark42 实例，敬请期待。</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>虽然一个优秀的服务应尽可能少的申请用户权限（减少 scope），但遇到糟糕的服务申请过多权限，我们一个不留神点了 Allow 的风险依然存在。&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>经过尝试和搜索，发现 Remark42 的用户提醒是一项单独的功能（Subscribe），与是否用邮箱登录无关。我打算为邮箱登录实现一个「subscribe to notification」的勾选框，实现登录后即可自动订阅邮件提醒，见此 issue 下的<a href="https://github.com/umputun/remark42/issues/745#issuecomment-1144526472">评论</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://justdeleteme.xyz/">JustDeleteMe</a> 列出了各大网站对删除账户数据的支持情况，并提供直达链接。&#160;<a href="#fnref:3" 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><item><title>我关注的独立开发者们</title><link>https://reorx.com/blog/indie-makers-im-following/</link><pubDate>Sun, 22 May 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/indie-makers-im-following/</guid><description>列举一些我所知的独立开发者们，让更多的人看到他们的经历和作品，获得启发。</description><content:encoded><![CDATA[<p>独立开发者，AKA indie makers, solo developers, or quite-my-job makers，是一群特别的人。他们或厌倦了大厂上班做重复性的工作，或不甘于只作为别人设计的实现者，或想要挑战自己的创造力、寻求被动收入甚至实现财务自由，初始的动机虽然各异，但却有一个共同点：不满足于一件事情已有的做法和存在方式，想用自己的手去改变和创造，实现心中那个理想的产品。</p>
<blockquote>
<p>An indie maker is an <strong>independent creator</strong>. They build projects with their own resources, often without funding, taking care of all aspects: development, design, marketing&hellip;</p>
<p>by <a href="https://www.whatisanindiemaker.com/">What is an Indie Maker?</a></p>
</blockquote>
<p>独立开发者有着两个特质，勇气和追求。放弃待遇优渥的工作，投身不确定的未来，是勇气；对已有的产品不满，用更高的标准要求打磨，则是追求。</p>
<p>我一直关注着一些独立开发者们，怀着敬意欣赏他们的作品，为他们的成就欢呼喝彩，从他们身上汲取勇气和追求的养分，让踏上同样道路的自己得到志同道合者的陪伴。这篇文章将我所知的独立开发者们列举出来<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>，让更多的人能看到他们的经历和作品，获得启发。</p>
<blockquote>
<p>如果看到本文的你也是一位独立开发者（或者认识某位），欢迎在评论和推文中分享你的经历和作品。</p>
</blockquote>
<h2 id="zh">ZH</h2>
<ul>
<li><a href="https://blog.guyskk.com/about">Guyskk</a>
<ul>
<li>Experiences
<ul>
<li><a href="https://blog.guyskk.com/notes/onebiz-begin">自宅创业 - 开始</a></li>
<li><a href="https://blog.guyskk.com/notes/onebiz-1-recent-and-product">自宅创业 - 两周近况和新产品</a></li>
<li><a href="https://blog.guyskk.com/notes/onebiz-6">自宅创业 - 创业三个月了</a></li>
<li><a href="https://blog.guyskk.com/notes/onebiz-13">自宅创业 - #13 创业半年回顾</a></li>
<li><a href="https://blog.guyskk.com/notes/review-2021">2021自宅创业10个月总结，我会再次挑战</a></li>
</ul>
</li>
<li>Products
<ul>
<li><a href="https://github.com/anyant/rssant">蚁阅</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="https://timqian.com/">Tim Qian</a>
<ul>
<li>Experiences
<ul>
<li><a href="https://blog.t9t.io/transparent-startup-experiment-2019-05-20/">我的透明创业实验</a></li>
<li><a href="https://blog.t9t.io/2019-2020-01-03/">2019 年 t9t.io 赚了多少钱</a></li>
<li><a href="https://blog.t9t.io/t9t-year1-2020-05-18/">我的一年独立开发经历</a></li>
</ul>
</li>
<li>Products
<ul>
<li><a href="https://feeds.pub/">Feeds Pub</a></li>
<li><a href="https://github.com/timqian/chart.xkcd">chart.xkcd</a></li>
<li><a href="https://github.com/bytebase/star-history">star-history</a></li>
</ul>
</li>
</ul>
</li>
<li>Tob
<ul>
<li>Experiences
<ul>
<li><a href="https://eleduck.com/posts/MkRfER">独立开发挑战:做一款产品,6个月之内实现月收入30,000元 | 电鸭</a></li>
<li><a href="https://eleduck.com/posts/ez1fBR">从硅谷到北京再到东南亚，为自己而工作的306天 | 电鸭</a></li>
</ul>
</li>
<li>Products
<ul>
<li><a href="https://www.21cloudbox.com/">21YunBox</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="https://ranhe.xyz/about/">染河</a>
<ul>
<li>Experiences
<ul>
<li><a href="https://mp.weixin.qq.com/s?__biz=Mzg4NDYwNjk2NA==&amp;mid=2247483680&amp;idx=1&amp;sn=1797b7e6b02724abd02caf2865117801&amp;chksm=cfb4d3bbf8c35aad173a1de7313e6b49d1f0fec3bd627b01a5aec768479887fa54934bfa4032&amp;mpshare=1&amp;scene=1&amp;srcid=0314XxUXk5LuIVDw6P4D1yTJ&amp;sharer_sharetime=1647247408010&amp;sharer_shareid=499a00e1cb53b0df2fa10b0279edd40a#rd">我为什么从字节跳动裸辞全职开发浏览器扩展</a></li>
</ul>
</li>
<li>Products
<ul>
<li><a href="http://circlereader.com/">Circle 阅读助手</a></li>
</ul>
</li>
</ul>
</li>
<li>zy5a59
<ul>
<li>Experiences
<ul>
<li><a href="https://www.v2ex.com/t/850671">人生自由实验 - V2EX</a></li>
<li><a href="https://www.v2ex.com/t/759394#reply11">『独立开发』复盘一个“可能”月入过万的小项目 - V2EX</a></li>
<li><a href="https://space.bilibili.com/20667846">我是张万里的个人空间_哔哩哔哩_bilibili</a></li>
</ul>
</li>
<li>Products
<ul>
<li><a href="https://yezishuzhai.com/">叶子书摘</a></li>
</ul>
</li>
</ul>
</li>
<li>Hawstein
<ul>
<li>Experiences
<ul>
<li><a href="https://hawstein.com/2020/02/17/be-an-indie-hacker-for-613-days/">不上班的 613 天</a></li>
<li><a href="https://hawstein.com/2020/01/08/indie-hacker-notes-issue-14/">Indie Hacker 笔记</a></li>
</ul>
</li>
<li>Products
<ul>
<li><a href="https://algocasts.io/">AlgoCasts 简明、轻松、易懂的算法教学视频</a></li>
</ul>
</li>
</ul>
</li>
</ul>
<h2 id="en">EN</h2>
<ul>
<li><a href="https://mtlynch.io/">Michael Lynch</a>
<ul>
<li>Experiences
<ul>
<li><a href="https://mtlynch.io/why-i-quit-google/">Why I Quit Google to Work for Myself · mtlynch.io</a></li>
<li><a href="https://mtlynch.io/solo-developer-year-1/">My First Year as a Solo Developer</a></li>
<li><a href="https://mtlynch.io/solo-developer-year-2/">My Second Year as a Solo Developer</a></li>
<li><a href="https://mtlynch.io/solo-developer-year-3/">My Third Year as a Solo Developer</a></li>
<li><a href="https://mtlynch.io/solo-developer-year-4/">My Fourth Year as a Bootstrapped Founder</a></li>
</ul>
</li>
<li>Products
<ul>
<li><a href="https://tinypilotkvm.com/">The Modern, Open-Source KVM over IP | TinyPilot</a></li>
<li><a href="https://refactoringenglish.com/">Refactoring English</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="https://benstokes.dev/">Ben Stokes</a>
<ul>
<li>Experiences
<ul>
<li><a href="https://tinyprojects.dev/posts/i_spent_two_years_launching_tiny_projects">I Spent 2 years Launching Tiny Projects | Tiny Projects</a></li>
<li><a href="https://daily.tinyprojects.dev/paper_website">I blew $720 on 100 notebooks from Alibaba and started a Paper Website business | Tiny Projects</a></li>
<li><a href="https://tinyprojects.dev/projects/earlyname">Earlyname: Reserving Usernames on the Internet | Tiny Projects</a></li>
</ul>
</li>
<li>Products
<ul>
<li><a href="https://tinyprojects.dev/">Tiny Projects</a></li>
<li><a href="https://paperwebsite.com/">Paper Website: Start a tiny website from your notebook</a></li>
<li><a href="https://earlyname.com/">Earlyname | Get Your Username Before Anyone Else</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="https://twitter.com/levelsio">@levelsio</a>
<ul>
<li>Experiences
<ul>
<li><a href="https://levels.io/how-i-built-a-remote-jobs-board/">How I built Nomad Jobs, a remote job board for 100% distributed startups</a></li>
<li><a href="https://levels.io/12-startups-12-months/">I&rsquo;m Launching 12 Startups in 12 Months</a></li>
<li><a href="https://levels.io/product-hunt-hacker-news-number-one/">How I got my startup to #1 on both Product Hunt and Hacker News by accident</a></li>
</ul>
</li>
<li>Products
<ul>
<li><a href="https://nomadlist.com/">Nomad List - Best Places to Live for Digital Nomads</a></li>
<li><a href="https://remoteok.com/">Remote Jobs in Programming, Design, Sales and more #OpenSalaries</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="https://twitter.com/damengchen">@damengchen</a>
<ul>
<li>Experiences
<ul>
<li><a href="https://testimonial.to/blog/one-year-of-indie-hacking-i-have-learned-a-lot">One year of indie hacking, I&rsquo;ve learned a lot!</a></li>
</ul>
</li>
<li>Products
<ul>
<li><a href="https://testimonial.to/">Testimonial - Collect and embed testimonials in minutes</a></li>
<li><a href="https://embed.so/">Embed.so - The video hosting platform for your business</a></li>
<li><a href="https://supportman.io/">Supportman - Intercom Metrics &amp; Ratings in Slack</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="https://tonydinh.com/">Tony Dinh</a>
<ul>
<li>Experiences
<ul>
<li><a href="https://twitter.com/tdinh_me/status/1427930913355100167">After 7 years working as a dev, I quit my job to pursue my dream: build software I love and make a living out of it.</a></li>
<li><a href="https://newsletter.tonydinh.com/">Tony Dinh’s Newsletter | Revue</a></li>
</ul>
</li>
<li>Products
<ul>
<li><a href="https://blackmagic.so/">Twitter Analytics &amp; CRM - BlackMagic.so</a></li>
<li><a href="https://devutils.app/">DevUtils.app - All-in-one Toolbox for Developers</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="https://alyssax.com/">Alyssa</a>
<ul>
<li>Experiences
<ul>
<li><a href="https://medium.com/women-make/how-i-built-a-macos-app-and-made-5000-in-a-week-de25c7be0458">How I built a MacOS app and made $5000+ in a week | Women Make</a></li>
<li><a href="https://newsletter.alyssax.com/">The Serial Entrepreneur Newsletter by Alyssa X</a></li>
</ul>
</li>
<li>Products
<ul>
<li><a href="https://chrome.google.com/webstore/detail/omni-bookmark-history-tab/mapjgeachilmcbbokkgcbgpbakaaeehi?hl=en&amp;authuser=0">Omni - Bookmark, History, &amp; Tab Manager - Chrome Web Store</a></li>
<li><a href="https://getlater.app/">Later - Save your Mac apps for later</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="https://twitter.com/SachinNeravath">@SachinNeravath</a>
<ul>
<li>Experiences
<ul>
<li>See twitter timeline</li>
</ul>
</li>
<li>Products
<ul>
<li><a href="https://www.paritydeals.com/">ParityDeals - Price Smarter, Sell bigger!</a></li>
<li><a href="https://www.samay.live/">Samay - Supercharge your focus and productivity.</a></li>
<li><a href="https://www.lightgalleryjs.com/">lightGallery - Full featured javascript gallery for web and mobile.</a></li>
<li><a href="http://tack.one">http://tack.one</a> (100% free)</li>
</ul>
</li>
</ul>
</li>
<li><a href="https://www.thomas-sanlis.com/">Thomas Sanlis</a>
<ul>
<li>Experiences
<ul>
<li><a href="https://www.thomas-sanlis.com/blog/when-why-and-how-i-have-changed-my-life/">When, Why and How I&rsquo;ve changed my life</a></li>
</ul>
</li>
<li>Products
<ul>
<li><a href="https://www.uneed.best/">Uneed, the best tools of the internet.</a></li>
<li><a href="https://www.griddely.com/">Griddely</a></li>
</ul>
</li>
</ul>
</li>
<li><a href="https://destroytoday.com/">Jonnie Hallman</a>
<ul>
<li>Products
<a href="https://cushionapp.com/">Cushion</a></li>
<li>Experiences
<ul>
<li><a href="https://cushionapp.com/journal/launch">Cushion Journal - Launch</a></li>
<li><a href="https://cushionapp.com/journal/funding-cushion">Cushion Journal - Funding Cushion</a></li>
<li>…all in <a href="https://destroytoday.com/blog/categories/cushion">https://destroytoday.com/blog/categories/cushion</a></li>
</ul>
</li>
</ul>
</li>
</ul>
<h2 id="一些链接">一些链接</h2>
<p>本文不打算整理对如何成为一个独立开发者的建议或工具（以后有机会可以做下分享<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>），如果你看到这里感到意犹未尽，想了解更多关于独立开发的事情，可以对下面的链接进行探索。</p>
<ul>
<li><a href="https://www.whatisanindiemaker.com/">What is an Indie Maker?</a></li>
<li><a href="https://news.ycombinator.com/item?id=29667095">Ask HN: Those making $500/month on side projects in 2021 – Show and tell | Hacker News</a></li>
<li><a href="https://github.com/mezod/awesome-indie">mezod/awesome-indie: Resources for independent developers to make money</a></li>
<li><a href="https://www.indiehackers.com/">Indie Hackers: Work Together to Build Profitable Online Businesses</a></li>
<li><a href="https://josephchang10.github.io/chinese-indie-hackers/">独立 | 中国独立 iOS 和 macOS 开发者图鉴</a></li>
<li><a href="https://microacquire.com/">MicroAcquire, the #1 Startup Acquisition Marketplace</a></li>
</ul>
<h2 id="revision">Revision</h2>
<ul>
<li>2022-05-22: created</li>
<li>2022-08-11: added Hawstein in ZH</li>
</ul>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>本文列举的独立开发者都分享过自己的经历，这是对我来讲特别重要的一点，但并不是只有分享经历的独立开发者才值得关注。每个独立开发者都是人间宝藏。&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>但正如我的一个推文所说，专注于具体创作的人在输出经验方面的精力会比较少 <a href="https://twitter.com/novoreorx/status/1528309074550747138">https://twitter.com/novoreorx/status/1528309074550747138</a>&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><item><title>我理想中的 Newsletter platform</title><link>https://reorx.com/blog/the-newsletter-platform-i-long-for/</link><pubDate>Fri, 20 May 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/the-newsletter-platform-i-long-for/</guid><description>一些我对 Newsletter platform 产品形态非常主观的探索和思考</description><content:encoded><![CDATA[<p>近年来 Newletter 这种发布形式越来越受到欢迎，我也在考虑是否要创建一个自己的邮件专栏。由于我的博客是由静态网站生成器构建的，因此我也希望通过同样的方式来撰写邮件通讯。</p>
<p>我的设想是为博客创建一个新的路径 <code>/weekly</code>，当我在这个路径下发布文章，比如 <code>/weekly/2022W20/</code>，就触发 GitHub Actions 的 CI 任务，将文章的 HTML 通过 Newsletter 服务商的接口发送出去。</p>
<p>带着这个目的，我调研了市面上的一些服务，选定了 4 家进行对比。</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Pricing</th>
<th>API</th>
<th>Send email via API</th>
</tr>
</thead>
<tbody>
<tr>
<td>Buttondown</td>
<td>小于 100 订阅者免费; 100~1000 收费 $9/每月</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>Revue</td>
<td>免费使用，无限订阅者; 付费订阅收取 5% 佣金</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>Substack</td>
<td>免费使用，无限订阅者; 付费订阅收取 10% 佣金</td>
<td>No</td>
<td>No</td>
</tr>
<tr>
<td>ConvertKit</td>
<td>小于 300 订阅者免费; 大于 300 收费 $9/每月</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>Curated</td>
<td>小于 1500 订阅者免费; 1500~2500 收费 39$/每月</td>
<td>Yes</td>
<td>No</td>
</tr>
</tbody>
</table>
<p>&ldquo;Send email via API&rdquo; 一列表示是否可以通过 API 创建并发送邮件 (或叫 issue, broadcast，每家称呼不同)，这是我最关注的一个功能，其中只有 Buttondown 和 ConvertKit 支持，他们都是基于订阅者数量的收费模式。但每月 $9+ 的费用让我却步，我想写的是分享见闻和思考的 Weekly Newsletter，这种内容并不足以支撑起付费订阅的模式。我喜欢写作并乐意分享，但为此负担额外的费用就不大令人开心了，这也是为什么我特别感谢像 Cloudflare 这样为独立开发者考虑的公司<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>。</p>
<blockquote>
<p>经 EGOIST 提醒，<a href="https://documentation.mailgun.com/en/latest/api-mailinglists.html#mailing-lists">Mailgun</a> 的 API 有 mailing list 管理功能，同样可以实现 Newletter 所需的订阅者管理和群发。实际上 Newsletter 的本质就是 mailing list，之前我潜意识里觉得 Mailgun, SES 这些服务只是 SMTP 协议的接口化，忽略了他们其实有一些面向邮件订阅服务开发者的高级功能。</p>
</blockquote>
<p><details >
  <summary markdown="span">References</summary>
  <ul>
<li><a href="https://buttondown.email/">Buttondown</a>
<ul>
<li><a href="https://buttondown.email/pricing">https://buttondown.email/pricing</a></li>
</ul>
</li>
<li><a href="https://www.getrevue.co/">Revue</a>
<ul>
<li><a href="http://help.getrevue.co/en/articles/4754361-how-pricing-works-on-revue">http://help.getrevue.co/en/articles/4754361-how-pricing-works-on-revue</a></li>
<li><a href="https://www.getrevue.co/api">https://www.getrevue.co/api</a></li>
</ul>
</li>
<li><a href="https://substack.com/">Substack</a>
<ul>
<li><a href="https://support.substack.com/hc/en-us/articles/360037607131-How-much-does-Substack-cost-">How much does Substack cost? – Substack, Inc</a></li>
</ul>
</li>
<li><a href="https://convertkit.com/">ConvertKit</a>
<ul>
<li><a href="https://convertkit.com/pricing">https://convertkit.com/pricing</a></li>
<li><a href="https://developers.convertkit.com/#create-a-broadcast">https://developers.convertkit.com/#create-a-broadcast</a></li>
</ul>
</li>
<li><a href="https://www.curated.co/">Curated</a>
<ul>
<li><a href="https://www.curated.co/pricing">https://www.curated.co/pricing</a></li>
<li><a href="https://support.curated.co/help/api">https://support.curated.co/help/api</a></li>
</ul>
</li>
</ul>

</details></p>

<h2 id="竹白">竹白</h2>
<p>由于订阅了 <a href="https://shyrz.zhubai.love/">Shyrism.News</a> 和 <a href="https://xpaidia.zhubai.love/">落日间</a>，我得知了竹白这个国内的 Newsletter 发布平台。我很喜欢它简单易用的特性和清晰可读的设计风格，甚至让我这个只相信本地编辑器的人也有了在网页上创作的冲动。</p>
<p>这改变了我的想法，我开始思考，基于 Newsletter 平台去管理文章和发布，再使用静态网站生成器来构建网站是否是可行的。</p>
<p>如果以这种架构为目标，竹白还缺少一些东西，一个更为理想的竹白应该是这样的：</p>
<ul>
<li>
<p><input checked="" disabled="" type="checkbox"> 一个内容管理和发布平台</p>
<p>Newsletter 平台首先其实是一个标准的 CMS</p>
</li>
<li>
<p><input checked="" disabled="" type="checkbox"> 一个 Newsletter 管理后台</p>
<p>管理订阅者，收集和展示统计数据</p>
</li>
<li>
<p><input checked="" disabled="" type="checkbox"> 一个付费平台</p>
<p>支持接收用户的 donation；支持付费订阅</p>
</li>
<li>
<p><input disabled="" type="checkbox"> 提供 email subscrible API</p>
<p>这个接口用于在个人网站上创建订阅表单，类似于 <a href="https://formspree.io/">Formspree</a> 和 <a href="https://fieldgoal.io/">FieldGoal</a>，通过 HTML 的 <code>&lt;form&gt;</code> 标签提交用户的订阅请求。</p>
</li>
<li>
<p><input disabled="" type="checkbox"> 提供 headless CMS API</p>
<p>让创作者可以使用 Hugo, Next.js, Gatsby 等 JAMStack 工具获取数据，自由地开发定制属于自己的网站形态。</p>
</li>
</ul>
<p>以上几点中，竹白还有最后两点没有实现，其他我所知道的产品中，最为接近的是 <a href="http://ghost.io/">Ghost</a>，除了 email subscribe API 不确定之外，其他全部都能满足。但 Ghost 是专注于网站内容的管理，最近才有 Newsletter 的功能，在专业性上会略逊于其他 Newsletter 发布平台。并且 Ghost 提供的功能太多（甚至支持 Podcast），且价格昂贵，如果我购买后只用它的 Newsletter 功能显然是非常不划算的<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>。</p>
<h2 id="proselog">Proselog</h2>
<p>我的朋友 <a href="https://egoist.sh/">EGOIST</a> 今天发布了他开发的博客平台: <a href="https://blog.proselog.com/meet-proselog">Meet Proselog: The Open Blogging Platform</a></p>
<p>Proselog 的界面相比竹白更加简洁清晰，界面上处处体现出 EGOIST 特有的克制而明快设计审美，这让基于网页编辑器的创作流程变得舒适而愉悦。</p>
<p>Proselog 不仅仅是一个 Substack/竹白 alternative，它还是一个<a href="https://github.com/proselog/proselog">开源项目</a>，意味着创作者的数据和隐私将完全由自己掌控，这与 Fediverse 系的发布平台如 <a href="https://github.com/writefreely/writefreely">Writefreely</a> 的理念不谋而合。</p>
<p>和 EGOIST 讨论后，他表示 Proselog 的确有 headless CMS 的开发计划，这让我异常期待 Proselog 后续的发展。也许这次，我找到了那个理想中的 Newsletter 发布平台。</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>Cloudflare 在<a href="https://blog.cloudflare.com/r2-open-beta/">一篇文章</a>中提到: “One of our main goals has been to serve the developers who can’t negotiate large discounts with cloud vendors”&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>这里不讨论 Ghost 的 self-hosted 版本，我更希望使用 hosting 的 Newsletter 服务，因为网站已经需要自己去维护，不想再增加维护一个复杂系统的负担&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><item><title>搭建 umami 收集个人网站统计数据</title><link>https://reorx.com/blog/deploy-umami-for-personal-website/</link><pubDate>Thu, 19 May 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/deploy-umami-for-personal-website/</guid><description>教你如何免费搭建和使用美丽的 umami 网站统计服务，和 Google Analytics 说再见。</description><content:encoded><![CDATA[<p>距离博客第一篇文章发布正好一个月，我想查看一下网站的统计数据，在 Google Analytics 上操作了一番，发觉自己已无法忍受这个复杂且迟缓的老旧产品，这让我开始思考有没有更好的解决方案。</p>
<p>我首先想到的是切换到另一家 hosting 的网站统计服务，如果可以的话，最好能有免费额度。然而开公司并不是做慈善，Google 之所以开放 Analytics 免费使用，除了本身体量足够大、边际成本够低之外，也因为网站统计与其核心的广告业务是密不可分的，Google 乐于让尽可能多的网站使用 Analytics，以得到可以推动广告和搜索业务发展的数据。果不其然，目前风评比较好的服务如 <a href="https://usefathom.com/pricing">fathom</a> 都没有免费套餐，价格也不便宜；<a href="https://plausible.io/#pricing">plausible</a> 价格较低，并且提供开源可部署的版本，这让我开始考虑自建统计服务的可行性。</p>
<p>要选择一个 self-hosted 网站统计服务，我有以下几个考量：</p>
<ul>
<li><strong>实现语言</strong>：我非常倾向于 JavaScript 的实现，这并非出于我对它更加熟悉或者偏爱，而是因为近年来 Cloudflare 在 serverless 和 edge computing 领域不断深耕，推动了一系列基于 JavaScript 的运行环境和基础设施的发展，最近还联合 Vercel 等友商成立了 <a href="https://blog.cloudflare.com/introducing-the-wintercg/">WinterCG</a> 社区组织，让我看到了 JavaScript 一站式云端开发部署的未来。我自己也尝试过 Workers 开发，体验很好，免费额度充裕。如果一个服务是 JavaScript 实现，就有在 Workers 上部署的可能性。</li>
<li><strong>数据库</strong>：最好是 SQLite。SQLite 的存储基于单文件，非常适合小型服务的开发部署。<a href="https://litestream.io/">Litestream</a> 的存在让基于 S3 等对象存储的低成本数据库维护成为可能。</li>
<li><strong>便于部署</strong>：有官方维护的 docker image，或提供 PaaS/Serverless 平台的一键部署模板</li>
<li><strong>尊重数据隐私</strong>：其实 self-hosted 统计服务产生的目的之一就是为了抵制商业服务滥用用户数据，因此这一点大部分项目都可以满足，稍加注意即可。</li>
<li><strong>ublock</strong>：作为一个 webmaster，肯定希望统计数据越精确越好，因此这个服务得有绕过 ublock 拦截的能力。虽然我个人也会使用 ublock 拦截各种 tracker，但 self-hosted 统计服务本身不会过度收集用户数据，更不会将其用于商业目的，可以理直气壮地想办法确保其正常加载。</li>
</ul>
<p>经过一番调查，我选择了 umami，它基本满足以上所有需求：</p>
<ul>
<li>前后端全部使用 TypeScript 和 Next.js 开发</li>
<li>数据库是 PostgreSQL，我比较喜欢，可以接受</li>
<li>项目页面有个大大的 “Deploy on Railway”，也有 docker image</li>
<li>项目 Slogan 就是 “privacy focused”
<blockquote>
<p>Umami does not collect any personally identifiable information and anonymizes all data collected.</p>
</blockquote>
</li>
<li>提供 <code>TRACKER_SCRIPT_NAME</code> 环境变量用于自定义 script 名称，可以绕过 ublock 的默认规则</li>
</ul>
<p>择日不如撞日，选定工具后我立刻开始尝试部署。umami 的部署流程是如此顺滑，只花了大概 15 分钟，我就确信自己做了一个十分正确的选择。</p>
<h2 id="部署说明">部署说明</h2>
<p>我之前就对 Railway 有所了解，所以直接点击 “Deploy on Railway” 按钮开始部署流程。</p>
<p><a href="https://railway.app">Railway</a> 是一个 PaaS 平台，可运行 JavaScript, Python, Go 等多种语言，提供 Redis, MySQL, MongoDB 等主流数据库存储。由于支持全面、界面友好，加上和 Cloudflare 一样有充裕的<a href="https://railway.app/pricing">免费额度</a>，足够个人项目免费使用，Railway 一直是我心中最好的 PaaS 之一。如果你有兴趣，欢迎通过我的 <a href="https://railway.app?referralCode=XkWaVV">referral link</a> 注册。</p>
<p>umani 有一篇 <a href="https://umami.is/docs/running-on-railway">Running on Railway</a> 文档，我遵照这个流程很快就完成了初步部署。其实只有一个 <code>HASH_SALT</code> 需要输入，其他只需点点点即可。</p>
<p>Railway Project 创建后，在管理界面可以看到两个 block，左边是数据库实例，右边是 umami 的后端服务。</p>
<p><img loading="lazy" src="/blog/deploy-umami-for-personal-website/images/railway.png" type="" alt=""  /></p>
<h3 id="安装依赖">安装依赖</h3>
<p>在等待 umami 的镜像构建和部署的过程中，我们可以在命令行进行一些其他工作。</p>
<p>打开命令行，使用以下命令安装 Railway CLI 和 pgsql 两个软件依赖。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">brew install railwayapp/railway/railway
</span></span><span class="line"><span class="cl">brew install libpg
</span></span></code></pre></div><p>完成后，将 libpg 的 bin 路径添加到 PATH 中，在 <code>.zshrc</code> 或 <code>.bashrc</code> 中添加一行:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">PATH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$PATH</span><span class="s2">:/opt/homebrew/opt/libpq/bin&#34;</span>
</span></span></code></pre></div><p>你可以通过执行 <code>type pgsql</code> 确认 <code>pgsql</code> 是否存在。</p>
<h3 id="更新数据库">更新数据库</h3>
<p>Railway 在创建 Project 时，会将 umami 的代码复制到在你的 GitHub 账户所创建的名为 <code>umami</code> 的仓库中，我们需要先将此仓库 clone 下来:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone git@github.com:username/umami.git
</span></span></code></pre></div><p>进入 umami 目录，执行以下命令，登录 Railway CLI 并完成链接:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> umami
</span></span><span class="line"><span class="cl">railway login
</span></span><span class="line"><span class="cl">railway link
</span></span></code></pre></div><p>这一步完成后，可以通过 <code>railway variables</code> 看到 umami 服务的环境变量，确认 CLI 可以访问到线上数据。</p>
<pre tabindex="0"><code>DATABASE_URL:        postgresql://postgres:xxxxxxxxx@containers-us-west.railway.app:6595/railway
DISABLE_TELEMETRY:   1
HASH_SALT:           xxXXxxXXxxXXxx
HOSTNAME:
PGDATABASE:          railway
PGHOST:              containers-us-west.railway.app
PGPASSWORD:          xxXXxxXXxxXXxx
PGPORT:              6595
PGUSER:              postgres
PORT:                3000
</code></pre><p>执行以下命令，为 umami 创建表格、添加初始数据。注意要将 bash variable 替换为上文中的数据:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">railway run psql -h <span class="nv">$PGHOST</span> -U <span class="nv">$PGUSER</span> -d <span class="nv">$PGDATABASE</span> -f sql/schema.postgresql.sql
</span></span></code></pre></div><h3 id="配置服务">配置服务</h3>
<p>数据库初始化完毕后，回到 Project 管理界面，确认 umami 部署完毕，即可打开服务的主页。</p>
<h4 id="更换密码">更换密码</h4>
<p>使用初始用户名密码 <code>admin:umami</code> 登录进去，然后立刻到 Settings &gt; Accounts 界面更换一个强密码。</p>
<h4 id="绑定个人域名">绑定个人域名</h4>
<p>在 Project 管理界面点击 umami，选择 Settings，在 Service Domains 下添加个人域名。比如我使用的是 <code>oishii.reorx.com</code>。</p>
<p><img loading="lazy" src="/blog/deploy-umami-for-personal-website/images/railway-1.png" type="" alt=""  /></p>
<p>打开域名的 DNS 服务商，根据 Railway 中的提示，为自定义域名添加 CNAME 记录。</p>
<p><img loading="lazy" src="/blog/deploy-umami-for-personal-website/images/cloudflare.png" type="" alt=""  /></p>
<p>等待几分钟，生效后 Railway 中会显示上图所示的 ✅ 符号。</p>
<h4 id="修改脚本名称">修改脚本名称</h4>
<p>为了避免 umami 被 ublock 误杀（有的规则集中有匹配 umami 的正则），还需要为它的统计脚本更换一个名称。<a href="https://umami.is/docs/environment-variables">Environment variables</a> 文档中的 <code>TRACKER_SCRIPT_NAME</code> 提供了这一功能。</p>
<p>再次进入 Railway，打开 umami 的 Variables 选项卡，按下图所示添加环境变量，我使用的名称为 <code>oishii</code>，可以更换为任意非 tracker 特征词汇。</p>
<p><img loading="lazy" src="/blog/deploy-umami-for-personal-website/images/railway-2.png" type="" alt=""  /></p>
<p><del>完成这一步后，需要重新构建 umami 的镜像，目前我还没有找到触发 rebuild 的简单方法，只能通过修改 Dockerfile 实现</del></p>
<blockquote>
<p>2022-09-25  更新</p>
</blockquote>
<p>保存 Variables 后，等待新的部署完成，即可生效。</p>
<h2 id="接入个人网站">接入个人网站</h2>
<p>一切准备就绪，我们重新使用自定义域名和修改后的密码登录 umami。</p>
<p>要进行统计，第一件事情是添加一个 Website。操作非常简单，读者可以参考 <a href="https://umami.is/docs/add-a-website">Add a website</a> 文档来进行。唯一需要注意的是 Enable share URL 选项，如果你想将自己网站的统计数据分享出去，则需要勾选之。你可以在 <a href="https://oishii.reorx.com/share/jKljpE4T/reorx.com">https://oishii.reorx.com/share/jKljpE4T/reorx.com</a> 访问本站的统计信息。</p>
<p>添加完成后，点击网站列表中对应网站的 Get tracking code 按钮，在浮窗中获得需要加入到网站 HTML 中的代码。</p>
<p><img loading="lazy" src="/blog/deploy-umami-for-personal-website/images/umami.png" type="" alt=""  /></p>
<p>需要注意将 <code>src</code> 结尾的 <code>umami.js</code> 替换为 <code>$TRACKER_SCRIPT_NAME.js</code>。</p>
<p>复制代码并添加到自己个人网站的 <code>&lt;head&gt;</code> 标签下的任意位置，由于使用了 <code>defer</code> 属性，这个 script 不会对网页加载造成阻塞。</p>
<p>网站更新完成后，呼朋唤友点击访问一番，就可以看到 umami 清晰、美观的统计数据了。</p>
<p><img loading="lazy" src="/blog/deploy-umami-for-personal-website/images/umami-1.png" type="" alt=""  /></p>
]]></content:encoded></item><item><title>Frontend Guide 01: Webpack babel-loader 使用指南</title><link>https://reorx.com/blog/migrate-from-ts-loader-to-babel-loader/</link><pubDate>Wed, 18 May 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/migrate-from-ts-loader-to-babel-loader/</guid><description>Frontend Guide 系列的第一篇文章，讲述我为何从 ts-loader 迁移到 babel-loader，以及我的 babel-loader 详细配置方案说明。</description><content:encoded><![CDATA[<h2 id="引子">引子</h2>
<p>大约在两个月前，为了做出一款属于自己的产品，我正式开始学习 TypeScript 和 React 开发<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>。虽然接触 JavaScript 时间不短了，但一直热衷于使用 Vanilla JS，也没有正式做过比较严肃的项目。因此，我把自己定位为一个前端新手，决定从 0 开始学习现代化前端开发。</p>
<p>这两个月来，我在学习实践过程中累计了一些自己的心得和体会，算不上高深，但应该可以为同样在学习中的新手起到参考作用。从这篇文章开始，我会逐一将我认为有价值的部分写成博客，归类于 #frontend-guide 标签下，并在未来的学习中持续更新。作为一个非专业的前端学习者，这些文章中一定会出现错误和疏漏，请读者朋友们不吝指正，如果能从批评中学到什么，将会是我写作的最大收获。</p>
<p>先大致列举一些可写的话题，以作备忘：</p>
<ul>
<li>如何构建一个最基本的 TypeScript 项目
<ul>
<li>介绍 package.json 和基本的 devDependencies</li>
<li>介绍 tsconfig.json 中必须了解的选项</li>
<li>tsc 和 esbuild</li>
</ul>
</li>
<li>如何使用 jest 进行细粒度的 TDD</li>
<li>从零开始构建自己的 React project template
<ul>
<li>不使用 create-react-app 有哪些好处</li>
<li>webpack</li>
<li>babel: modular import and hot reload</li>
<li>config and APP_ENV</li>
</ul>
</li>
<li>如何构建一个最基本的 monorepo
<ul>
<li>npm workspace</li>
<li>tsconfig reference</li>
<li>webpack: resolve alias and tsconfig paths plugin</li>
</ul>
</li>
<li>TypeScript caveats and cheatsheet</li>
<li>如何使用 TypeScript 和 React 开发 Chrome Extension</li>
<li>Mobx 使用指南</li>
<li>实现一个简单的 useFetch Hook</li>
<li>如何使用 swr 调用 HTTP API</li>
<li>如何使用 Protobuf 和 TypeScript 封装 HTTP API</li>
</ul>
<h2 id="ts-loader">ts-loader</h2>
<p>在现代化前端项目中，TypeScript 因其静态类型的特性，为代码的可维护性、健壮性带来了极大的提升，已逐渐成为前端开发的标准语言。但 TypeScript 不能直接在浏览器中运行，因此在项目构建流程中需要引入 <a href="https://en.wikipedia.org/wiki/Source-to-source_compiler">transpiler</a> 来将其编译为浏览器可用的 JavaScript。<a href="https://github.com/TypeStrong/ts-loader">ts-loader</a> 就是为 Webpack 设计的 transpiler 之一。</p>
<p>过去的开发生涯中，我虽然不是专业的前端，也对 JavaScript 世界中的基石 babel 有所耳闻目见。但在这次从零开始学习 TypeScript 开发时，因为对 babel 有种过于复杂的印象，我选择了 ts-loader 作为第一个学习和上手的插件。</p>
<p>ts-loader 的使用非常简单，文档也足够清晰，如果你的项目本身有正确配置 <code>tsconfig.json</code> 并可以使用 <code>tsc -b</code> 完成编译，那么在 webpack 中引入 ts-loader 后不需要额外的配置即可工作。</p>
<p>下面是一份使用 ts-loader 的 <code>webpack.config.js</code> 示例:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">entry</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">index</span><span class="o">:</span> <span class="nx">path</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="nx">srcDir</span><span class="p">,</span> <span class="s1">&#39;index.tsx&#39;</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">output</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">path</span><span class="o">:</span> <span class="nx">destDir</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">filename</span><span class="o">:</span> <span class="s1">&#39;[name].bundle.js&#39;</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">module</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">rules</span><span class="o">:</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">test</span><span class="o">:</span> <span class="sr">/\.tsx?$/</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">loader</span><span class="o">:</span> <span class="s1">&#39;ts-loader&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">exclude</span><span class="o">:</span> <span class="sr">/node_modules/</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="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>经过一段时间的使用，我发现了对 ts-loader 不太满意的几个地方：</p>
<ul>
<li>
<p>偶尔出现莫名的错误</p>
<p>有的时候，当对 TypeScript 源文件进行了某种变更，或许是大量的类型变化，或许是文件重命名和移动目录，可能会触发 ts-loader 报错，但此时 VSCode 的静态检查器却没有显示出任何问题（所有源文件），重启 webpack serve 也无济于事。这时只能通过删除所有与构建过程和结果有关的文件，如 <code>build</code>, <code>dist</code>, <code>**/*.d.ts</code>, <code>*.tsbuildinfo</code>，重新运行才可以消除这个不存在的错误。</p>
</li>
<li>
<p>难以 Debug 某些 transpiling 过程中的错误</p>
<p>如果你的 <code>tsconfig.json</code> 中设置了 <code>noEmit: true</code> 或 <code>noEmitOnError: true</code>, 那你很有可能会看到 <code>Error: TypeScript emitted no output for…</code> 这样的报错，这是因为 ts-loader 在将 TypeScript 转换成 JavaScript 时无法成功，于是没有输出 js 文件。但具体是什么错误，完全无法搞清楚。我在 GitHub 上跟踪了一个 <a href="https://github.com/TypeStrong/ts-loader/issues/1257">issue</a>, 目前仍然没有收获。</p>
</li>
<li>
<p>项目内多个 package 引用必须生成描述文件</p>
<p>如果想在项目内想要拆分多个 package 并互相引用，需要使用 TypeScript 的 <a href="https://www.typescriptlang.org/docs/handbook/project-references.html">Project References</a> 功能，而 <code>declaration</code>  必须设置为 true，此时 ts-loader 会为被引用的包生成 <code>.d.ts</code> 的描述文件，使文件浏览器变得混乱。</p>
</li>
<li>
<p>在插件生态中不是一等公民</p>
<p>一些优化项目开发流程的插件，如 <a href="https://github.com/pmmmwh/react-refresh-webpack-plugin">react-refresh-webpack-plugin</a>, 优先支持 babel 而非 ts-loader；<a href="https://github.com/umijs/babel-plugin-import">babel-plugin-import</a> 的 ts-loader 实现 <a href="https://github.com/Brooooooklyn/ts-import-plugin">ts-import-plugin</a> 远不如其本身流行。</p>
</li>
</ul>
<p>于是我决定对 babel-loader 进行一次尝试。</p>
<h2 id="babel-loader">babel-loader</h2>
<p><a href="https://github.com/babel/babel-loader">babel-loader</a> 的项目页面有详细的安装配置说明，在此不做赘述。下面是一个在 TypeScript + React 项目中工作的最小化配置示例:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">module</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">rules</span><span class="o">:</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">test</span><span class="o">:</span> <span class="sr">/\.tsx?$/</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">exclude</span><span class="o">:</span> <span class="sr">/node_modules/</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">use</span><span class="o">:</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">loader</span><span class="o">:</span> <span class="s1">&#39;babel-loader&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nx">options</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">              <span class="nx">presets</span><span class="o">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">                <span class="p">[</span><span class="s1">&#39;@babel/preset-env&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">targets</span><span class="o">:</span> <span class="s2">&#34;defaults&#34;</span> <span class="p">}],</span>
</span></span><span class="line"><span class="cl">                <span class="s1">&#39;@babel/preset-typescript&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="p">[</span><span class="s1">&#39;@babel/preset-react&#39;</span><span class="p">,</span> <span class="p">{</span><span class="s1">&#39;runtime&#39;</span><span class="o">:</span> <span class="s1">&#39;automatic&#39;</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="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="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></code></pre></div><p>配置完成后，代码顺利编译，随后我开始了对构建配置优化的探索。</p>
<h3 id="webpack-bundle-analyzer">webpack-bundle-analyzer</h3>
<p>我首先想到的是对构建出的 bundle 的大小进行检查，一看竟然有 7MB 之大，于是安装了 <a href="https://github.com/webpack-contrib/webpack-bundle-analyzer">webpack-bundle-analyzer</a>, 对 bundle 中所包含的依赖进行分析。</p>
<p>下面是引入 webpack-bundle-analyzer 之后的 <code>webpack.config.js</code> 文件，通过环境变量 <code>WEBPACK_USE_ANALYZE</code> 判断是否进入分析模式并修改 webpack 配置。</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">useAnalyze</span> <span class="o">=</span> <span class="o">!!</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">WEBPACK_USE_ANALYZE</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">config</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="k">if</span> <span class="p">(</span><span class="nx">useAnalyze</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">BundleAnalyzerPlugin</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s1">&#39;webpack-bundle-analyzer&#39;</span><span class="p">).</span><span class="nx">BundleAnalyzerPlugin</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nx">config</span><span class="p">.</span><span class="nx">mode</span> <span class="o">=</span> <span class="s1">&#39;development&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">config</span><span class="p">.</span><span class="nx">plugins</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="k">new</span> <span class="nx">BundleAnalyzerPlugin</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">      <span class="nx">analyzerPort</span><span class="o">:</span> <span class="mi">18888</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="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="nx">config</span>
</span></span></code></pre></div><p>运行 <code>WEBPACK_USE_ANALYZE=1 webpack</code> 在浏览器中查看分析结果。</p>
<p><img loading="lazy" src="/blog/migrate-from-ts-loader-to-babel-loader/images/webpack-analyze.png" type="" alt=""  /></p>
<p>原来 tabler-icons-react 一个库就占了 5.28MB，其他库中 lodash 也不算正常，似乎和 tabler-icons-react 一样被完整包含进来了。</p>
<h3 id="babel-plugin-import">babel-plugin-import</h3>
<p><a href="https://github.com/konradkalemba/tabler-icons-react">tabler-icons-react</a> 是一个 SVG 图标库，我只是使用了其中一部分图标。lodash 是非常通用的工具函数库，同样的，我只对其中一部分函数进行了引用。那么有没有办法按需加载依赖中的模块，只输出被 import 到的部分呢？答案就是 <a href="https://github.com/umijs/babel-plugin-import">babel-plugin-import</a>。</p>
<p>继续修改 <code>webpack.config.js</code>，为 babel-loader 增加 plugins:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">loader</span><span class="o">:</span> <span class="s1">&#39;babel-loader&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">plugins</span><span class="o">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">[</span><span class="s1">&#39;import&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">libraryName</span><span class="o">:</span> <span class="s1">&#39;tabler-icons-react&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">libraryDirectory</span><span class="o">:</span> <span class="s1">&#39;dist/icons&#39;</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="s1">&#39;import&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">libraryName</span><span class="o">:</span> <span class="s1">&#39;lodash&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">libraryDirectory</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="nx">camel2DashComponentName</span><span class="o">:</span> <span class="kc">false</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="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>再次运行 <code>WEBPACK_USE_ANALYZE=1 webpack</code> ，可以看出 bundle 结构有了显著的改进（红色的部分是 tabler-icons-react 和 lodash）:</p>
<p><img loading="lazy" src="/blog/migrate-from-ts-loader-to-babel-loader/images/webpack-analyze-2.png" type="" alt=""  /></p>
<blockquote>
<p><a href="https://github.com/lodash/babel-plugin-lodash">babel-plugin-lodash</a> 也可以实现 lodash 的按需加载，但它是专为 lodash 开发的，不具备 babel-plugin-import 的通用性。</p>
</blockquote>
<h3 id="babelpreset-env">@babel/preset-env</h3>
<p><a href="https://babeljs.io/docs/en/babel-preset-env">@babel/preset-env</a> 是 babel 中最重要的一个 preset。所谓 preset，即预设配置的封装，让需求相近的用户可以不用关注细节直接使用，preset-env 提供了非常丰富的选项，让使用者可以快速定制出符合目标需求的编译结果。</p>
<p>首先要关注的是 <a href="https://babeljs.io/docs/en/options#targets">targets</a> 选项，它决定了 babel 所编译出的 JavaScript 能否在特定平台上运行。<code>targets</code> 支持 <a href="https://github.com/ai/browserslist">browserslist</a> 语法，上文中我们的初始配置 <code>['@babel/preset-env', { targets: &quot;defaults&quot; }],</code> 代表使用 browserslist 的 defaults 查询<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>，虽然可以编译，但在浏览器中是无法运行的，会产生
<code>Uncaught ReferenceError: regeneratorRuntime is not defined</code> 错误，这是为什么呢？</p>
<p>为了统一不同浏览器的 JavaScript 实现差异，使 ES2015+ 代码可以正确运行，babel 会根据 targets 决定是否需要在编译结果中注入 polyfill。
最早这一功能由 <a href="https://babeljs.io/docs/en/babel-polyfill">@babel/polyfill</a> 实现，但它在 babel 7.4.0 之后被废弃，由 <a href="https://github.com/zloirock/core-js">core-js </a> 接替。我们的代码之所以运行报错，就是因为没有指定 preset-env 使用 core-js，导致用于模拟 ES2015+ 运行环境的 <code>regeneratorRuntime</code> 缺失。</p>
<p>修改后的 preset-env 配置如下:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="p">[</span><span class="s2">&#34;@babel/preset-env&#34;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">targets</span><span class="o">:</span> <span class="s2">&#34;defaults&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">corejs</span><span class="o">:</span> <span class="mi">3</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">useBuiltIns</span><span class="o">:</span> <span class="s1">&#39;usage&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">}]</span>
</span></span></code></pre></div><p>重新编译，代码即可成功运行。并且通过 webpack-bundle-analyzer 可以发现 bundle 中多出了 core-js 的部分。</p>
<p>如果想在对浏览器的支持上激进一些，可以尝试将 targets 设为 <code>{browsers: '&gt; 5%'}</code>，即仅支持占有率超过 5% 的浏览器，你会发现 bundle 中 core-js 的部分会再次消失，因为这些浏览器不需要 core-js 就有完整的 ES2015 支持。</p>
<h3 id="react-refresh-webpack-plugin">react-refresh-webpack-plugin</h3>
<p>webpack 提供 <a href="https://webpack.js.org/concepts/hot-module-replacement/">HMR</a> 热更新功能，不需要刷新页面即可将改动反映到页面中。在 babel-loader 中，需要通过 <a href="https://github.com/pmmmwh/react-refresh-webpack-plugin">react-refresh-webpack-plugin</a> 插件来实现 React JSX 的热更新。</p>
<p>首先要在 devServer 中开启 <code>hot</code> 选项:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">devServer</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// Enable hot reloading
</span></span></span><span class="line"><span class="cl"><span class="c1"></span>    <span class="nx">hot</span><span class="o">:</span> <span class="kc">true</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></code></pre></div><p>然后为 webpack 添加 <code>ReactRefreshWebpackPlugin</code> 插件:</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">ReactRefreshWebpackPlugin</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s1">&#39;@pmmmwh/react-refresh-webpack-plugin&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">plugins</span><span class="o">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="k">new</span> <span class="nx">ReactRefreshWebpackPlugin</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></code></pre></div><p>最后为 babel-loader 添加 <code>react-refresh/babel</code> 插件，通过 <code>isDevelopment</code> 决定是否存在:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">loader</span><span class="o">:</span> <span class="s1">&#39;babel-loader&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">options</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">plugins</span><span class="o">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="nx">isDevelopment</span> <span class="o">&amp;&amp;</span> <span class="s1">&#39;react-refresh/babel&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">].</span><span class="nx">filter</span><span class="p">(</span><span class="nb">Boolean</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></code></pre></div><h3 id="summary">Summary</h3>
<p>将以上插件和技巧综合起来，最终我的 babel-loader 配置如下:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">module</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">rules</span><span class="o">:</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">test</span><span class="o">:</span> <span class="sr">/\.tsx?$/</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">exclude</span><span class="o">:</span> <span class="sr">/node_modules/</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">use</span><span class="o">:</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">loader</span><span class="o">:</span> <span class="s1">&#39;babel-loader&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="nx">options</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">              <span class="nx">presets</span><span class="o">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">                <span class="p">[</span><span class="s1">&#39;@babel/preset-env&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                  <span class="nx">targets</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                    <span class="nx">browsers</span><span class="o">:</span> <span class="s1">&#39;&gt; 5%&#39;</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">corejs</span><span class="o">:</span> <span class="mi">3</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                  <span class="nx">useBuiltIns</span><span class="o">:</span> <span class="s1">&#39;usage&#39;</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="s1">&#39;@babel/preset-typescript&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="p">[</span><span class="s1">&#39;@babel/preset-react&#39;</span><span class="p">,</span> <span class="p">{</span><span class="s1">&#39;runtime&#39;</span><span class="o">:</span> <span class="s1">&#39;automatic&#39;</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">plugins</span><span class="o">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">                <span class="p">[</span><span class="s1">&#39;import&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                  <span class="nx">libraryName</span><span class="o">:</span> <span class="s1">&#39;tabler-icons-react&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                  <span class="nx">libraryDirectory</span><span class="o">:</span> <span class="s1">&#39;dist/icons&#39;</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="s1">&#39;import&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                  <span class="nx">libraryName</span><span class="o">:</span> <span class="s1">&#39;lodash&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                  <span class="nx">libraryDirectory</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="nx">camel2DashComponentName</span><span class="o">:</span> <span class="kc">false</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">isDevelopment</span> <span class="o">&amp;&amp;</span> <span class="s1">&#39;react-refresh/babel&#39;</span>
</span></span><span class="line"><span class="cl">              <span class="p">].</span><span class="nx">filter</span><span class="p">(</span><span class="nb">Boolean</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="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="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>它实现了：</p>
<ul>
<li>输出支持现代主流浏览器的 JavaScript</li>
<li>按需加载 tabler-icons-react 和 lodash 模块</li>
<li>React JSX 开发热更新</li>
</ul>
<p>如果你想看到一个可运行的例子，请参考我的最小化 React 项目模板 <a href="https://github.com/reorx/minireact">reorx/minireact</a>，其中有完整的 <a href="https://github.com/reorx/minireact/blob/6e5b086e309cdbe7132c268ba8cf1e6e5d5ab32e/webpack.config.js">webpack.config.js</a> 文件。</p>
<h2 id="revision">Revision</h2>
<ul>
<li>2022-05-18: created</li>
</ul>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>开始学习时的推文: <a href="https://twitter.com/novoreorx/status/1500385952942157828">学习了俩小时如何开始一个 TypeScript 项目，现在已经不省人事了</a>&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>根据 browerslist 文档，<code>defaults</code> 代表 <code>&gt; 0.5%, last 2 versions, Firefox ESR, not dead</code>，是一个非常宽泛的规则，可覆盖全世界所有浏览器中的 <a href="https://browserslist.dev/?q=ZGVmYXVsdHM%3D">90%</a>&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><item><title>Google I/O 2022 Web Platform 新特性展示观看笔记</title><link>https://reorx.com/blog/notes-on-google-io-2022-web-platform-presentation/</link><pubDate>Wed, 18 May 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/notes-on-google-io-2022-web-platform-presentation/</guid><description>了解 Web 新技术从未如此充满乐趣。</description><content:encoded><![CDATA[<p>几天前临睡时看了 2022 Google IO 的一个 presentation: <a href="https://www.youtube.com/watch?v=5b4YcLB4DVI">What&rsquo;s new for the web platform</a>, 两位 Google 员工的解说让了解 Web 新技术的过程变得充满乐趣，于是做了些笔记在 <a href="https://threadreaderapp.com/thread/1525875286096904192.html">Twitter thread</a> 中。这篇文章对笔记进行了整理，并为每个特性附加详细说明的链接。</p>
<div class="tweet social-quote">
  <div class="title">
    <svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><title>Twitter</title><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>

    
  </div><blockquote class="twitter-tweet" data-dnt="true"><p lang="zh" dir="ltr">开个串记录下观看 Google IO What&#39;s new for the web platform 中发现的有趣的 feature<a href="https://t.co/LZPGGn7NHZ">https://t.co/LZPGGn7NHZ</a></p>&mdash; Reorx (@novoreorx) <a href="https://twitter.com/novoreorx/status/1525875286096904192?ref_src=twsrc%5Etfw">May 15, 2022</a></blockquote>


</div>


<h2 id="html">HTML</h2>
<h3 id="dialog"><code>&lt;dialog&gt;</code></h3>
<p><a href="https://web.dev/building-a-dialog-component/">https://web.dev/building-a-dialog-component/</a></p>
<p><code>&lt;dialog&gt;</code> 和 <code>&lt;selectmenu&gt;</code>，基本上所有 UI Kit 都会在组件库里实现的功能，终于被 HTML 原生实现了。如果 HTML 能多一些这种常用的交互组件，我会很乐意手写 Vanilla JS 来使用它们。</p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-dialog.png" type="" alt=""  /></p>
<h3 id="selectmenu"><code>&lt;selectmenu&gt;</code></h3>
<p><a href="https://open-ui.org/prototypes/selectmenu">https://open-ui.org/prototypes/selectmenu</a></p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-selectmenu.png" type="" alt=""  /></p>
<h3 id="input-typedatetile-local"><code>&lt;input type=datetile-local&gt;</code></h3>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local">https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local</a></p>
<p>记得 7 年前在豌豆荚工作时，因为这个标签不被 Firefox 支持，被迫找了一个 JavaScript 库来代替 (Pikaday, I still remember)。如今终于可以在主流浏览器上全面通用，太不容易了😭。</p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-datetime-local.png" type="" alt=""  /></p>
<h2 id="css">CSS</h2>
<h3 id="has-selector"><code>:has</code> selector</h3>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:has">https://developer.mozilla.org/en-US/docs/Web/CSS/:has</a></p>
<p>用于声明父子元素关系的 :has 选择器！这个功能前阵子正好搜索过，现在只能用 JavaScript 实现。</p>
<p>图中的代码可以解释为，在包含 <code>&lt;figcaption&gt;</code> 的 <code>&lt;figure&gt;</code> 元素中，将其 children 中的 <code>&lt;img&gt;</code> 增加 1rem 的 bottom margin。</p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-has.png" type="" alt=""  /></p>
<h3 id="aspect-ratio">Aspect ratio</h3>
<p><a href="https://web.dev/aspect-ratio/">https://web.dev/aspect-ratio/</a></p>
<p>CSS aspect-radio，使元素保持固定的宽高比。这是曾经在 <code>&lt;img&gt;</code> 标签上的实验特性，如今终于被所有元素支持了。</p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-aspect-ratio.png" type="" alt=""  /></p>
<h3 id="cascade-layer">Cascade Layer</h3>
<p><a href="https://developer.chrome.com/blog/cascade-layers/">https://developer.chrome.com/blog/cascade-layers/</a></p>
<p>Cascade Layers, 给 CSS 加上了层级的概念，layer 可以自由排列顺序，后面的比前面的有更高的优先级 (specificity)；还展示了下越来越复杂的 CSS 优先级关系图，其实也可以把每一级都当做一个 layer。</p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-cascade-layer.png" type="" alt=""  /></p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-cascade-layer-1.png" type="" alt=""  /></p>
<h3 id="container-queries">Container queries</h3>
<p>Container queries，在响应式中能够发挥重要作用，但我基本没听懂，未来应该会影响一些 CSS 框架的 API，到时候再来学习吧。</p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-container-queries.png" type="" alt=""  /></p>
<h3 id="accent-color">Accent color</h3>
<p><a href="https://web.dev/accent-color/">https://web.dev/accent-color/</a></p>
<p>CSS accent-color，为 HTML 基本元素设定一个统一的色调，主流浏览器在一年内都争先恐后地实现了😂，毕竟这是最能体现自己颜值的功能。不知道能不能和操作系统的 accent-color 互通，这样体验就更一致了。</p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-accent-color.png" type="" alt=""  /></p>
<h2 id="javascript">JavaScript</h2>
<h3 id="arrayprototypeat">Array.prototype.at</h3>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at">https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at</a></p>
<p>Array 增加了 at 方法，JavaScript 终于可以用类似 Python <code>list[-1]</code> 的写法了。这曾经是我刚学习 JavaScript 时觉得它不如 Python 最主要的原因。</p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-array-at.png" type="" alt=""  /></p>
<h3 id="urlpattern">URLPattern</h3>
<p><a href="https://web.dev/urlpattern/">https://web.dev/urlpattern/</a></p>
<p>新类型 URLPattern，定义 URL 路径中的参数并解析出对应的 object。routing library 不用再写麻烦的正则了</p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-urlpattern.png" type="" alt=""  /></p>
<h3 id="structuredclone">structuredClone</h3>
<p><a href="https://web.dev/structured-clone/">https://web.dev/structured-clone/</a></p>
<p>有了 structuredClone, 再也不用写 JSON.parse JSON.stringify 这种扭曲心智的代码了，非常适合在面试官问到 deep clone 的问题时反将一军。还可以用来 clone 其他数据结构如 blobs, bitmaps, typed arrays。</p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-structuredclone.png" type="" alt=""  /></p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-structuredclone-1.png" type="" alt=""  /></p>
<h3 id="createimagebitmap">createImageBitmap</h3>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/API/createImageBitmap">https://developer.mozilla.org/en-US/docs/Web/API/createImageBitmap</a></p>
<p>createImageBitmap, 把 blob 直接转化成 Image 对象，改善了一直以来复杂而脆弱的写法。</p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-blob.png" type="" alt=""  /></p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-blob-1.png" type="" alt=""  /></p>
<h2 id="miscellaneous">Miscellaneous</h2>
<h3 id="chips">CHIPS</h3>
<p><a href="https://developer.chrome.com/docs/privacy-sandbox/chips/">https://developer.chrome.com/docs/privacy-sandbox/chips/</a></p>
<p>CHIPS 全称 Cookies Having Independent Partitioned State, 是 Cookie 在安全策略上的新特性，为 Set-Cookie 增加了 <code>Partitioned</code> 关键词。可以使特定 cookie 在 iframe 中只用于当前站点。自两年前 <code>SameSite</code> 关键词推广后，third-party cookie 只能选择全有或者全无，这次终于有了一个折中的选择。</p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-chips.png" type="" alt=""  /></p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-chips-1.png" type="" alt=""  /></p>
<h3 id="window-controls-overlay">Window controls overlay</h3>
<p><a href="https://web.dev/window-controls-overlay/">https://web.dev/window-controls-overlay/</a></p>
<p>Window controls overlay 是一组 <a href="https://developer.mozilla.org/en-US/docs/Web/Manifest">Web app manifests</a>, CSS, JavaScript API 的集合，实现了定制 PWA 窗口在 window bar 部分区域的显示效果，用于获得更有沉浸感的使用体验。</p>
<p><img loading="lazy" src="/blog/notes-on-google-io-2022-web-platform-presentation/images/google-io-2022-web-wco.png" type="" alt=""  /></p>
]]></content:encoded></item><item><title>PyYAML 使用技巧</title><link>https://reorx.com/blog/python-yaml-tips-zh/</link><pubDate>Sun, 15 May 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/python-yaml-tips-zh/</guid><description>分享我在 Python 下使用 PyYAML 的技巧和代码片段，并介绍几个相关的库。</description><content:encoded><![CDATA[<blockquote>
<p>英文版: <a href="https://reorx.com/blog/python-yaml-tips/">Tips that may save you from the hell of PyYAML</a>
</p>
</blockquote>
<p>YAML 是一个被广泛使用的数据序列化和配置语言，作为一个开发者，总是不免和它打交道。
但处理 YAML 文档，尤其是使用 PyYAML 的过程总是非常痛苦。</p>
<p>这篇文章分享我在 Python 下使用 PyYAML 的技巧和代码片段，并介绍几个相关的库。</p>
<blockquote>
<p>注意：本文中的代码仅保证在 Python 3 下正常工作</p>
</blockquote>
<h2 id="总是使用-safe_loadsafe_dump">总是使用 <code>safe_load/safe_dump</code></h2>
<p>PyYAML 的 <code>load</code> 函数可以构造任意 Python 对象（Pickle 协议），这意味着一次 <code>load</code> 可能导致任意 Python 函数被执行。</p>
<p>为了确保应用程序的安全性，尽量在任何情况下使用 <code>yaml.safe_load</code> 和 <code>yaml.safe_dump</code>。</p>
<h2 id="保留字段顺序">保留字段顺序</h2>
<p>Python 3.7+ 中，<code>dict</code> keys 具备保留插入顺序的特性，所以通过 <code>yaml.safe_load</code> 得到的 <code>dict</code>，其 keys 顺序会与原始文件保持一致。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="kn">import</span> <span class="nn">yaml</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">text</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;---
</span></span></span><span class="line"><span class="cl"><span class="s2">... c: 1
</span></span></span><span class="line"><span class="cl"><span class="s2">... b: 1
</span></span></span><span class="line"><span class="cl"><span class="s2">... d: 1
</span></span></span><span class="line"><span class="cl"><span class="s2">... a: 1
</span></span></span><span class="line"><span class="cl"><span class="s2">... &#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span> <span class="o">=</span> <span class="n">yaml</span><span class="o">.</span><span class="n">safe_load</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span>
</span></span><span class="line"><span class="cl"><span class="p">{</span><span class="s1">&#39;c&#39;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s1">&#39;b&#39;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s1">&#39;d&#39;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s1">&#39;a&#39;</span><span class="p">:</span> <span class="mi">1</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">list</span><span class="p">(</span><span class="n">d</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="s1">&#39;c&#39;</span><span class="p">,</span> <span class="s1">&#39;b&#39;</span><span class="p">,</span> <span class="s1">&#39;d&#39;</span><span class="p">,</span> <span class="s1">&#39;a&#39;</span><span class="p">]</span>
</span></span></code></pre></div><p>当把 <code>dict</code> 导出为 YAML 字符串时，为 <code>yaml.safe_dump</code> 传递 <code>sort_keys=False</code> 来保留 keys 的顺序。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span><span class="n">d</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">b</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">c</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">d</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span><span class="p">[</span><span class="s1">&#39;e&#39;</span><span class="p">]</span> <span class="o">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">sort_keys</span><span class="o">=</span><span class="kc">False</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">c</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">b</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">d</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">e</span><span class="p">:</span> <span class="mi">1</span>
</span></span></code></pre></div><p>如果 Python 版本较低，或者你想确保代码能在更广泛的环境下工作，你可以使用 <a href="https://github.com/wimglenn/oyaml">oyaml</a> 库来代替 PyYAML 的 <code>yaml</code> 包。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="kn">import</span> <span class="nn">oyaml</span> <span class="k">as</span> <span class="nn">yaml</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span> <span class="o">=</span> <span class="n">yaml</span><span class="o">.</span><span class="n">safe_load</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span>
</span></span><span class="line"><span class="cl"><span class="n">OrderedDict</span><span class="p">([(</span><span class="s1">&#39;c&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">),</span> <span class="p">(</span><span class="s1">&#39;b&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">),</span> <span class="p">(</span><span class="s1">&#39;d&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">),</span> <span class="p">(</span><span class="s1">&#39;a&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)])</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span><span class="p">[</span><span class="s1">&#39;e&#39;</span><span class="p">]</span> <span class="o">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">sort_keys</span><span class="o">=</span><span class="kc">False</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">c</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">b</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">d</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">e</span><span class="p">:</span> <span class="mi">1</span>
</span></span></code></pre></div><h2 id="优化列表项的缩进">优化列表项的缩进</h2>
<p>默认情况下，PyYAML 输出的列表缩进与其父元素一致。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span> <span class="o">=</span> <span class="p">{</span><span class="s1">&#39;a&#39;</span><span class="p">:</span> <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">3</span><span class="p">]}</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span><span class="n">d</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span>
</span></span><span class="line"><span class="cl"><span class="o">-</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="o">-</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl"><span class="o">-</span> <span class="mi">3</span>
</span></span></code></pre></div><p>这并不是很好的格式，根据
<a href="https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html">Ansible</a>
和 <a href="https://developers.home-assistant.io/docs/documenting/yaml-style-guide/#block-style-sequences">HomeAssistant</a>
等 YAML 书写规范，列表项应该缩进 2 空格。</p>
<p>这种格式也会对导致列表项不会被如 VSCode 等编辑器识别，进而无法使用编辑器的折叠功能。</p>
<p>要解决这个问题，使用如下代码片段，在代码中定义 <code>IndentDumper</code> class:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">IndentDumper</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">Dumper</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">increase_indent</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">flow</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">indentless</span><span class="o">=</span><span class="kc">False</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="nb">super</span><span class="p">(</span><span class="n">IndentDumper</span><span class="p">,</span> <span class="bp">self</span><span class="p">)</span><span class="o">.</span><span class="n">increase_indent</span><span class="p">(</span><span class="n">flow</span><span class="p">,</span> <span class="kc">False</span><span class="p">)</span>
</span></span></code></pre></div><p>然后将它传递给 <code>yaml.dump</code> 的 <code>Dumper</code> 关键字参数。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">dump</span><span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">Dumper</span><span class="o">=</span><span class="n">IndentDumper</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">  <span class="o">-</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">  <span class="o">-</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl">  <span class="o">-</span> <span class="mi">3</span>
</span></span></code></pre></div><blockquote>
<p>注意，<code>yaml.safe_dump</code> 由于有自己的 Dumper class，传递此参数会造成冲突。</p>
</blockquote>
<h2 id="输出可读的-utf-8-字符">输出可读的 UTF-8 字符</h2>
<p>默认情况下，PyYAML 假设你希望输出的结果里只有 ASCII 字符。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span> <span class="o">=</span> <span class="p">{</span><span class="s1">&#39;a&#39;</span><span class="p">:</span> <span class="s1">&#39;你好&#39;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span><span class="n">d</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span> <span class="s2">&#34;</span><span class="se">\u4F60\u597D</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>这会让输出结果非常难以阅读。</p>
<p>在 UTF-8 足够普及的今天，直接输出 UTF-8 字符是非常安全的。
因此我们可以将 <code>allow_unicode=True</code> 传入 <code>yaml.safe_dump</code> 使 PyYAML 将 Unicode 转换成 UTF-8 字符串。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">allow_unicode</span><span class="o">=</span><span class="kc">True</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span> <span class="n">你好</span>
</span></span></code></pre></div><h2 id="一些-yaml-相关的库">一些 YAML 相关的库</h2>
<h3 id="oyaml">oyaml</h3>
<p>Link: <a href="https://github.com/wimglenn/oyaml">https://github.com/wimglenn/oyaml</a></p>
<p>正如上文中提到的，oyaml 是 <code>yaml</code> 包的替换品，使 <code>dict</code> keys 的顺序在 dump/load 的时候得以保留。</p>
<p>oyaml 是一个单文件库，只有 <a href="https://github.com/wimglenn/oyaml/blob/d0195070d26bd982f1e4e604bded5510dd035cd7/oyaml.py">53 行代码</a>，因此使用起来非常灵活，你可以直接把它的代码复制到自己的项目中，然后根据自己的需求进行修改。</p>
<h3 id="strictyaml">strictyaml</h3>
<p>Link: <a href="https://github.com/crdoconnor/strictyaml">https://github.com/crdoconnor/strictyaml</a></p>
<p>有的人说 YAML 过于复杂和灵活，不是一个好的配置语言。但我认为这不是 YAML 的问题，而是使用方式的问题。如果我们限制程序只使用 YAML 的部分功能，YAML 其实可以变得像它设计的那般好用。</p>
<p>这就是 StrictYAML 的设计意图，它是一个类型安全的 YAML 解析器，实现了 YAML 规范说明中的一个<a href="https:c//hitchdev.com/strictyaml/features-removed">子集</a> 。</p>
<p>如果你对 YAML 的输入输出有较强的安全考虑，建议使用 StrictYAML 代替 PyYAML。</p>
<p>顺带一提的是，StrictYAML 的<a href="https://hitchdev.com/strictyaml/">文档站</a>有很多关于设计细节和配置语言思考的文章，非常值得一看。</p>
<h3 id="ruamelyaml">ruamel.yaml</h3>
<p>Link: <a href="https://yaml.readthedocs.io/en/latest/overview.html">https://yaml.readthedocs.io/en/latest/overview.html</a></p>
<p>ruamel.yaml 是 PyYAML 的一个分叉，于 2009 年发布并持续维护至今。</p>
<p>ruamel.yaml 的文档里详细说明了它和 PyYAML 的<a href="https://yaml.readthedocs.io/en/latest/pyyaml.html#yaml-1-2-support">差异</a>。
总体来说，ruamel.yaml 专注在 <a href="https://yaml.org/spec/1.2.2/">YAML 1.2</a>  上，对一些语法进行了优化。</p>
<p>ruamel.yaml 最令我感兴趣的特性是输入输出的 “round-trip”，可以最大程度地保留输入源的原始格式。官方文档中的定义是这样的：</p>
<blockquote>
<p>A round-trip is a YAML load-modify-save sequence and ruamel.yaml tries to preserve, among others:</p>
<ul>
<li>comments</li>
<li>block style and key ordering are kept, so you can diff the round-tripped source</li>
<li>flow style sequences ( ‘a: b, c, d’) (based on request and test by Anthony Sottile)</li>
<li>anchor names that are hand-crafted (i.e. not of the form<code>idNNN</code>)</li>
<li>merges in dictionaries are preserved</li>
</ul>
</blockquote>
<p>如果你有尽可能保留原始格式的需求，建议使用 ruamel.yaml 代替 PyYAML。</p>
<blockquote>
<p>在使用中我注意到 ruamel.yaml 的 safe load 方法 (<code>YAML(typ='safe').load</code>) 与 PyYAML 有些不同，它无法解析 flow style 的集合定义 (如 <code>a: {&quot;foo&quot;: &quot;bar&quot;}</code>)，这点没有在文档中提及，使用时须多加注意。</p>
</blockquote>
<h2 id="总结">总结</h2>
<p>YAML 有它好的地方和坏的地方。它易于阅读，初期的学习曲线非常平缓。
但 YAML 的规范说明非常复杂，不仅造成了使用中的混乱，也使不同语言的实现在很多细微的地方难以保持一致。</p>
<p>尽管有这些小毛病，YAML 仍然是我心中最好的配置语言。希望这篇文章所介绍的技巧能够帮助你避免问题，获得更好的开发和使用体验。</p>
]]></content:encoded></item><item><title>Tips that may save you from the hell of PyYAML</title><link>https://reorx.com/blog/python-yaml-tips/</link><pubDate>Sun, 15 May 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/python-yaml-tips/</guid><description>Try to make our life with PyYAML easier.</description><content:encoded><![CDATA[<p>YAML is a data-serialization language that is widely used.
As a developer, I&rsquo;m always dealing with YAML from time to time.
But processing YAML, especially using PyYAML in Python is painful and full of traps.
Here I want to share some tips and snippets that can make your life with PyYAML easier.</p>
<blockquote>
<p>Code in this article is only guaranteed to work in Python 3</p>
</blockquote>
<h2 id="always-use-safe_loadsafe_dump">Always use safe_load/safe_dump</h2>
<p>YAML&rsquo;s ability to construct an arbitrary Python object makes it dangerous to use blindly.
It might be harmful to your application to simply <code>yaml.load</code> a document from an untrusted source such as the Internet and user input.</p>
<p>See from <a href="https://pyyaml.org/wiki/PyYAMLDocumentation#loading-yaml">PyYAML official documentation</a>:</p>
<blockquote>
<p><strong>Warning</strong>: It is not safe to call <code>yaml.load</code> with any data received from an untrusted source! <code>yaml.load</code> is as powerful as <code>pickle.load</code> and so may call any Python function.</p>
</blockquote>
<p>In short, you should always use <code>yaml.safe_load</code> and <code>yaml.safe_dump</code> as the standard I/O methods for YAML.</p>
<h2 id="keep-keys-in-order-loaddump">Keep keys in order (load/dump)</h2>
<p>In Python 3.7+, the order of dict keys is naturally preserved <sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>, thus the dict you get from <code>yaml.safe_load</code> has the same order of keys as the original file.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="kn">import</span> <span class="nn">yaml</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">text</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;---
</span></span></span><span class="line"><span class="cl"><span class="s2">... c: 1
</span></span></span><span class="line"><span class="cl"><span class="s2">... b: 1
</span></span></span><span class="line"><span class="cl"><span class="s2">... d: 1
</span></span></span><span class="line"><span class="cl"><span class="s2">... a: 1
</span></span></span><span class="line"><span class="cl"><span class="s2">... &#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span> <span class="o">=</span> <span class="n">yaml</span><span class="o">.</span><span class="n">safe_load</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span>
</span></span><span class="line"><span class="cl"><span class="p">{</span><span class="s1">&#39;c&#39;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s1">&#39;b&#39;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s1">&#39;d&#39;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="s1">&#39;a&#39;</span><span class="p">:</span> <span class="mi">1</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">list</span><span class="p">(</span><span class="n">d</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="s1">&#39;c&#39;</span><span class="p">,</span> <span class="s1">&#39;b&#39;</span><span class="p">,</span> <span class="s1">&#39;d&#39;</span><span class="p">,</span> <span class="s1">&#39;a&#39;</span><span class="p">]</span>
</span></span></code></pre></div><p>When dumping dict into a YAML string, make sure to add keyword argument <code>sort_keys=False</code> to preserve the order of keys.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span><span class="n">d</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">b</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">c</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">d</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span><span class="p">[</span><span class="s1">&#39;e&#39;</span><span class="p">]</span> <span class="o">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">sort_keys</span><span class="o">=</span><span class="kc">False</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">c</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">b</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">d</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">e</span><span class="p">:</span> <span class="mi">1</span>
</span></span></code></pre></div><p>If your Python version is lower, or you want to make sure the keys order preserving always works, you can use this library called <a href="https://github.com/wimglenn/oyaml">oyaml</a> as a drop-in replacement for <code>pyyaml</code>.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="kn">import</span> <span class="nn">oyaml</span> <span class="k">as</span> <span class="nn">yaml</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span> <span class="o">=</span> <span class="n">yaml</span><span class="o">.</span><span class="n">safe_load</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span>
</span></span><span class="line"><span class="cl"><span class="n">OrderedDict</span><span class="p">([(</span><span class="s1">&#39;c&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">),</span> <span class="p">(</span><span class="s1">&#39;b&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">),</span> <span class="p">(</span><span class="s1">&#39;d&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">),</span> <span class="p">(</span><span class="s1">&#39;a&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)])</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span><span class="p">[</span><span class="s1">&#39;e&#39;</span><span class="p">]</span> <span class="o">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">sort_keys</span><span class="o">=</span><span class="kc">False</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">c</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">b</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">d</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">e</span><span class="p">:</span> <span class="mi">1</span>
</span></span></code></pre></div><h2 id="enhance-list-indentation-dump">Enhance list indentation (dump)</h2>
<p>By default, PyYAML indent list items on the same level as their parent.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span> <span class="o">=</span> <span class="p">{</span><span class="s1">&#39;a&#39;</span><span class="p">:</span> <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">3</span><span class="p">]}</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span><span class="n">d</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span>
</span></span><span class="line"><span class="cl"><span class="o">-</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="o">-</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl"><span class="o">-</span> <span class="mi">3</span>
</span></span></code></pre></div><p>This is not a good format according to style guides like
<a href="https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html">Ansible</a>
and <a href="https://developers.home-assistant.io/docs/documenting/yaml-style-guide/#block-style-sequences">HomeAssistant</a>.
It is also not recognized by code editors like VSCode, making the list items unfoldable in the editor.</p>
<p>To solve this problem, you can use the snippet below to define an <code>IndentDumper</code> class:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">IndentDumper</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">Dumper</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">increase_indent</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">flow</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">indentless</span><span class="o">=</span><span class="kc">False</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="nb">super</span><span class="p">(</span><span class="n">IndentDumper</span><span class="p">,</span> <span class="bp">self</span><span class="p">)</span><span class="o">.</span><span class="n">increase_indent</span><span class="p">(</span><span class="n">flow</span><span class="p">,</span> <span class="kc">False</span><span class="p">)</span>
</span></span></code></pre></div><p>Then pass it to the <code>Dumper</code> keyword argument in <code>yaml.dump</code> function.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">dump</span><span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">Dumper</span><span class="o">=</span><span class="n">IndentDumper</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">  <span class="o">-</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">  <span class="o">-</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl">  <span class="o">-</span> <span class="mi">3</span>
</span></span></code></pre></div><blockquote>
<p>Note that <code>Dumper</code> cannot be passed to <code>yaml.safe_dump</code> which has its owner dumper class defined.</p>
</blockquote>
<h2 id="output-readable-utf-8-dump">Output readable UTF-8 (dump)</h2>
<p>By default, PyYAML assumes the user only wants ASCII code in the output,
so it converts UTF-8 characters to Python&rsquo;s Unicode representation.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="n">d</span> <span class="o">=</span> <span class="p">{</span><span class="s1">&#39;a&#39;</span><span class="p">:</span> <span class="s1">&#39;你好&#39;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span><span class="n">d</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span> <span class="s2">&#34;</span><span class="se">\u4F60\u597D</span><span class="s2">&#34;</span>
</span></span></code></pre></div><p>This makes the output hard to read for humans.</p>
<p>In the modern world, UTF-8 is widely supported, it&rsquo;s safe to write UTF-8 in the output.
Pass <code>allow_unicode=True</code> to <code>yaml.safe_dump</code> to enable that.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="o">&gt;&gt;&gt;</span> <span class="nb">print</span><span class="p">(</span><span class="n">yaml</span><span class="o">.</span><span class="n">safe_dump</span><span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">allow_unicode</span><span class="o">=</span><span class="kc">True</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">a</span><span class="p">:</span> <span class="n">你好</span>
</span></span></code></pre></div><h2 id="no-default_flow_style-needed-dump">No <code>default_flow_style</code> needed (dump)</h2>
<p>Most of the time we don&rsquo;t want <a href="https://yaml.org/spec/1.2.2/#chapter-7-flow-style-productions">flow style productions</a> in the output (i.e. no JSON in YAML).
According to <a href="https://pyyaml.org/wiki/PyYAMLDocumentation#dictionaries-without-nested-collections-are-not-dumped-correctly">PyYAML documentation</a>, <code>default_flow_style=False</code> should be passed to <code>yaml.safe_dump</code> to achieve that.</p>
<p>After digging into the source code of the latest PyYaml (6.0), I find it is not needed anymore.
You should remove this keyword argument to keep the code cleaner and less confusing.</p>
<p><img loading="lazy" src="/blog/python-yaml-tips/images/yaml-dump-all.png" type="" alt=""  /></p>
<h2 id="libraries">Libraries</h2>
<h3 id="oyaml">oyaml</h3>
<p>Link: <a href="https://github.com/wimglenn/oyaml">https://github.com/wimglenn/oyaml</a></p>
<p>As mentioned above, oyaml is a drop-in replacement for PyYAML which preserves dict ordering.</p>
<p>I suggest using oyaml if you already use PyYAML in your code.</p>
<p>It&rsquo;s worth mentioning that oyaml is a single-file library with only <a href="https://github.com/wimglenn/oyaml/blob/d0195070d26bd982f1e4e604bded5510dd035cd7/oyaml.py">53 lines of code</a>.
This makes it very flexible to use, you can just copy the code to your library and customize it according to your need.</p>
<h3 id="strictyaml">strictyaml</h3>
<p>Link: <a href="https://github.com/crdoconnor/strictyaml">https://github.com/crdoconnor/strictyaml</a></p>
<p>Some people say YAML is too complex and flexible to be a good configuration language,
but I think this is not the problem of YAML, but the problem of how we use it.
If we restrict our usage to only a subset of its features, it will be as good as it should be.</p>
<p>This is where StrictYAML came up. It is a type-safe YAML parser that parses and validates a <a href="https://hitchdev.com/strictyaml/features-removed">restricted subset</a> of the YAML specification.</p>
<p>I suggest using StrictYAML if you have strong security concerns for your application.</p>
<p>There are tons of great articles on the <a href="https://hitchdev.com/strictyaml/">documentation site</a> of strictyaml, definitely worth having a look at if you have thought about YAML and other configuration languages.</p>
<h3 id="ruamel-yaml">ruamel. yaml</h3>
<p>Link: <a href="https://yaml.readthedocs.io/en/latest/overview.html">https://yaml.readthedocs.io/en/latest/overview.html</a></p>
<p>ruamel.yaml is a fork of PyYAML, it was released in 2009 and continuously maintained in the past decade.</p>
<p>The differences with PyYAML are listed <a href="https://yaml.readthedocs.io/en/latest/pyyaml.html#yaml-1-2-support">here</a>. Generally, ruamel.yaml focuses on <a href="https://yaml.org/spec/1.2.2/">YAML 1.2</a>  with some opinionated enhancements for the syntax.</p>
<p>What interests me most is the ability to round-trip in the loading/dumping process. It works like black magic. Here&rsquo;s the explanation from ruamel.yaml documentation:</p>
<blockquote>
<p>A round-trip is a YAML load-modify-save sequence and ruamel.yaml tries to preserve, among others:</p>
<ul>
<li>comments</li>
<li>block style and key ordering are kept, so you can diff the round-tripped source</li>
<li>flow style sequences ( ‘a: b, c, d’) (based on request and test by Anthony Sottile)</li>
<li>anchor names that are hand-crafted (i.e. not of the form<code>idNNN</code>)</li>
<li>merges in dictionaries are preserved</li>
</ul>
</blockquote>
<p>I suggest using ruamel.yaml if you have the requirement to preserve the original content as much as possible.</p>
<blockquote>
<p>One thing I notice is that ruamel.yaml&rsquo;s <code>safe_load</code> method (<code>YAML(typ='safe').load</code>) cannot parse flow style collection (<code>a: {&quot;foo&quot;: &quot;bar&quot;}</code>), this is a undocumented difference with PyYAML.</p>
</blockquote>
<h2 id="summary">Summary</h2>
<p>YAML has its good and bad. It&rsquo;s easy to read, the learning curve is mild at the beginning,
but the specification is complex, which not only causes chaos in practice, but also makes implementations in different languages inconsistent with each other in many trivial aspects.</p>
<p>Despite these quirks, YAML is still the best configuration language for me, and as long as we can use it properly, problems will be avoided and the experience will be much better.</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>See <a href="https://docs.python.org/3/whatsnew/3.7.html#:~:text=the%20insertion%2Dorder%20preservation%20nature%20of%20dict%20objects%20has%20been%20declared%20to%20be%20an%20official%20part%20of%20the%20Python%20language%20spec.">What’s New In Python 3.7</a>&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><item><title>重新开始使用 RSS 阅读器</title><link>https://reorx.com/blog/reinitiate-rss-reader/</link><pubDate>Thu, 12 May 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/reinitiate-rss-reader/</guid><description>简单明确的尝试，带来意想不到的收获</description><content:encoded><![CDATA[<h2 id="不算成功的开端">不算成功的开端</h2>
<p>我曾经是一个糟糕的 RSS 用户。</p>
<p>早在 Google Reader 流行的时期，我就热衷于将我感兴趣的资讯网站和博客订阅到 Google Reader 中 <sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>，期盼着能从这些信息中获得有价值的东西，却总是因为懒或者忘了而不能及时查看。随着未读数的增多，焦虑感越来越强烈，最后索性不再打开阅读器，安慰自己以后总会去看的，只要订阅了就行，沉浸在虚假的满足感中。</p>
<p>我就像一个喜欢屯书的人，在 RSS 阅读器这个书架上摆满了放上去就再也没动过的书<sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>。</p>
<p>后来我认识到，自己得了信息资源的囤积病，经过长时间的反思后，我下决心清理 RSS 订阅源。</p>
<p>我删除了所有资讯类网站，只留下个人博客；去掉长期未更新的，并重新审视每个博客是否是我真正想要去阅读的。最终留下大约 30 多个 RSS 源，每周的更新量被控制在 10-20 之间。自此以后，我才真正做到阅读每篇更新，也没有再因为未读数量而焦虑了。</p>
<p>再之后，我看到许多人分享自己的 RSS 使用经验<sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup>，将 RSS 作为获取信息的效率工具，聚合和筛选资讯，甚至成为 all-in-one 的信息输入渠道。这些文章偶尔会让我蠢蠢欲动，但因担心摄入压力过大而重蹈覆辙，我总是很快熄灭了尝试的想法。</p>
<p>就这样，我从一个糟糕的 RSS 用户，变成了一个克制但低效的用户，不再每天使用，只在有提醒时打开看看博客的更新。生活继续。</p>
<h2 id="重新发现-rss">重新发现 RSS</h2>
<p>最近我开始经常逛 V2EX 。我最早的活跃时期是 2010~2013，后来因为沉迷工作，和陌生人的交流欲降低，兴趣转移到 Twitter。今年自由工作后，思维又活跃起来，回过头来发现经过 10 年时间，V2EX 依然是国内最好的开发者社区。</p>
<p>V2EX 有着丰富的 RSS 支持，在 <a href="https://v2ex.com/notifications">V2EX  ›  提醒系统</a> 页面可以获得提醒的 RSS 订阅源。为了及时收到消息通知，我开始琢磨如何利用这一功能，于是整理了下需求：</p>
<ul>
<li>直接订阅 RSS，不需要借助第三方服务</li>
<li>支持 macOS 和 iOS，同步未读条目</li>
<li>简洁清爽的界面</li>
</ul>
<p>很快，我锁定了完美满足这三点的 <a href="https://github.com/Ranchero-Software/NetNewsWire">NetNewsWire</a></p>
<p><img loading="lazy" src="/blog/reinitiate-rss-reader/images/netnewswire-1.png" type="" alt=""  /></p>
<p>NetNewsWire 发布于 20 年前，是 macOS 上历史最悠久的 RSS 阅读器。我虽然很早就知道它，却一直没有尝试，使用的是更受追捧的 Reeder，但 Reeder 最近的几次付费更新有些令人失望，没有什么有趣的变更，我也厌倦了它那软绵绵的 UI 风格。</p>
<p>NetNewsWire 让我眼前一亮，它使用 macOS 原生的 UI 组件，适应起来没有任何障碍。打开就算经典的三栏布局，没有多余的选项，让我感觉真正在使用一个信息组织工具，而非另一个漂亮的玩具。</p>
<p>Minimal and no distraction, 这是我对 NetNewsWire 的整体印象。我很快就爱上了它，逐渐把我其他几个正在摄取的信息源也添加了上去。</p>
<p>现在我的 RSS 订阅结构是这样的：</p>
<ul>
<li>
<p>V2EX</p>
<ul>
<li>
<p>节点</p>
<p>我只对一些特定的节点有逐条浏览的兴趣，V2EX 每个节点都可以输出 RSS，只需要将 <code>https://v2ex.com/feed/{node}.xml</code> 的 <code>{node}</code> 替换为节点的英文名即可。</p>
<ul>
<li><a href="https://v2ex.com/feed/create.xml">分享创造</a></li>
<li><a href="https://v2ex.com/feed/share.xml">分享发现</a></li>
<li><a href="https://v2ex.com/feed/programmer.xml">程序员</a></li>
</ul>
</li>
<li>
<p><a href="https://rsshub.app/v2ex/topics/hot">Top Topics</a></p>
<p>V2EX 本身没有最热话题的 RSS，幸运的是，RSSHub 提供了一个公开的源。</p>
</li>
</ul>
</li>
<li>
<p><a href="https://rsshub.app/telegram/channel/hacker_news_feed">Hacker News</a></p>
<p>Hackers News 的信息量太大，不适合直接订阅，好在 <a href="https://t.me/hacker_news_feed">hacker_news_feed</a> 这个 Telegram 频道对内容进行了筛选，只推送分数大于 100 的条目。同样要感谢 RSSHub，用它将 Telegram 频道转换成了 RSS feed。</p>
</li>
<li>
<p>Obsidian</p>
<p>作为 Obsidian 重度使用者和插件维护者，我非常关注社区的动向。Obsidian 社区信息主要来自于<a href="https://forum.obsidian.md/">官方 Discourse 论坛</a>和 <a href="https://www.reddit.com/r/ObsidianMD/">subreddit</a>。两者都是通过在 URL 后面加上 <code>.rss</code> 即可获得订阅源（如 <a href="https://forum.obsidian.md/c/share-showcase/9.rss">Showcase</a> 和 <a href="https://www.reddit.com/r/ObsidianMD.rss">ObsidianMD</a>）</p>
</li>
<li>
<p>提醒</p>
<ul>
<li>V2EX</li>
<li><a href="https://www.reddit.com/prefs/feeds/">Reddit Unread</a></li>
</ul>
</li>
</ul>
<p>经过一段时间的使用，我开心地发现，自己已经能每日有效摄入上百条信息而没有焦虑。我通常是在碎片时间打开手机观看，比如早上蹲马桶时、运动后和睡前。当遇到有趣的信息时，就点击 Star 收藏，如果特别需要关注或后续调研，就在 TickTick 添加一个对应的 TODO 项。要是忙起来积攒了许多未读，我就快速划过快速 Star，然后一次性 Mark All as Read。</p>
<h2 id="进一步优化">进一步优化</h2>
<p>V2EX 的程序员节点中经常出现 Java 相关的话题，但我的技能体系与 Java 无关，因此我想过滤掉这些对我而言无用的信息。</p>
<p>NetNewsWire 有一个相关的 <a href="https://github.com/Ranchero-Software/NetNewsWire/issues/1864">issue</a>，却并没有实现。Inoreader 和 Tiny Tiny RSS 等聚合器虽然支持，但我希望保持目前直接订阅原始 RSS 源的工作流，不想引入另一个工具增加复杂度。怎么办呢？</p>
<p>我决定自己开发一个轻量的 serverless 服务: <a href="https://github.com/reorx/rss-filter">rss-filter</a>。（现在还没有完成，所以读者朋友们可以不急着 star 鼓励。）</p>
<p>rss-filter 的大致设计如下：</p>
<ul>
<li>部署在 serverless 平台</li>
<li>通过 url 参数使用
<ul>
<li><code>rss</code>: rss url</li>
<li><code>exclude_text[]</code>: regex to exclude items from text (title, description, content)</li>
<li><code>exclude_text[]</code>: regex to include items from text
<ul>
<li>if both include and exclude are provided, apply include before exclude.</li>
</ul>
</li>
<li><code>cache_seconds</code>: seconds to cache the result</li>
<li><code>debug</code>: if true, return a json to debug</li>
<li><code>token</code>: prevent abuse by anonymous users</li>
<li><code>full_content</code>: fetch full content of each item</li>
</ul>
</li>
</ul>
<p>我设想  rss-filter 完成后，只需要简单的设定参数即可创造一个过滤后的 RSS 源。比如我想要从程序员节点过滤掉 Java 相关的条目，那么新的 RSS 源应为:</p>
<pre tabindex="0"><code>https://rss-filter.reorx.com/filter?rss=https%3A%2F%2Fv2ex.com%2Ffeed%2Fprogrammer.xml&amp;exclude_text=java%7Cspring+%3Fboot%7Clog4j&amp;cache_seconds=600
</code></pre><p>其中 <code>exclude_text</code> 为 <code>java|spring ?boot|log4j</code>，用正则匹配多种不同的关键词。</p>
<h2 id="一些思考">一些思考</h2>
<p>RSS 的用法因人而异，这篇文章主要目的是分享我个人的探索过程，希望带给读者一些借鉴。选择适合自己的，不要盲目追求别人看起来「高效」「强大」的工作流。</p>
<p>前几天 <a href="https://taoshu.in/">涛叔</a> 向我推荐了他的 <a href="https://taoshu.in/webfeed/web-feed.html">WebFeed</a> 插件 (for Firefox)，它把浏览器变成 RSS 阅读器，并能自动发现网站的 RSS 源。</p>
<p><img loading="lazy" src="/blog/reinitiate-rss-reader/images/webfeed.png" type="" alt=""  /></p>
<p>我很喜欢它的 Web 与 RSS 一体的使用体验，这让我开始思考一个问题，为何近些年来 RSS 逐渐式微，主流浏览器厂商没有做任何相关的支持<sup id="fnref1:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup>，只有少部分开源爱好者还在坚持使用并为 RSS 社区做出贡献。</p>
<p>我能想到的答案之一是，商业公司在控制和引导互联网生态，将公开流量变成自己的私域流量。而主流浏览器背后正好也都是商业公司。想象一下，如果所有内容都来自于独立博客的 RSS，作者可以直达受众，那内容分发市场的商业价值就没他们什么事了，更不用说将用户绑定在自己的平台上。</p>
<p>另一个原因可能是内容农场的肆虐，独立创作者很难靠自己将原创内容推广，内容农场却通过 SEO 技术将自己洗稿后的文章顶在了搜索结果的前面。关于这个问题，少数派有一篇文章值得一读： <a href="https://sspai.com/post/71637">少数派思考 007：关于 RSS</a>。</p>
<p>这是一场没有硝烟的战争，商业巨头们就像<a href="https://zh.wikipedia.org/wiki/%E5%9C%B0%E7%90%86%E5%A4%A7%E5%8F%91%E7%8E%B0">地理大发现</a>时期的西方文明，在开放互联网的蒙昧之地大肆开拓殖民地。国外近些年来还有 Medium、Newsletter 等多种形式的内容分发模式百花齐放，国内已经被头条、微信、微博、百度完成瓜分和镇压，沦为没有生机的焦土。</p>
<p>但即使在这样的环境下，我还是希望能成为一个纯粹的内容创作者，为别人带来启发和帮助，为自己实现学习和思考的价值。</p>
<p>欢迎你来订阅我的博客:</p>
<p class="standout">
  <a href="https://reorx.com/feed.xml">https://reorx.com/feed.xml</a>
</p>

<p>最后推荐一篇文章 <a href="https://kevincox.ca/2022/05/06/rss-feed-best-practices/">RSS Feed Best Practises</a>， 如果你也是一名维护自己博客的 webmaster，可以了解下如何优化自己网站输出的 RSS。</p>
<p>比如加上 <code>&lt;link&gt;</code> tag 使 RSS URL 可以被主流工具自动发现:</p>
<pre tabindex="0"><code>&lt;link rel=alternate title=&#34;Blog Posts&#34; type=application/atom+xml href=&#34;https://reorx.com/feed.atom&#34;&gt;
</code></pre><p>比如最好在 RSS 中附带全文：</p>
<blockquote>
<p>It is generally recommended to provide the full content of your posts in the feed. This is what most readers prefer. Atom has both <code>&lt;summary&gt;</code> element for readers that prefer it. For RSS and the Atom <code>&lt;content&gt;</code> element the full article should be included.</p>
</blockquote>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>当时使用了一款 Chrome 插件，会自动检测当前网页可用的 RSS 源，如果有的话，会在地址栏里显示 RSS 图标，点击图标就能看见 Google Reader 中的目录分类，勾选即是将 RSS 订阅到指定分类中。我非常喜欢这个插件，可惜后来 Chrome 去掉了地址栏图标的 API，再也无法实现相似的功能了。&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>亦像一个喜欢在 Steam 屯游戏的人，买了上千个游戏，但每天玩的还是 Dota 2, CS GO, GTA 5&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3">
<p>Chrome 背后的 Google，在手机 app 上实现了自己的 <a href="https://support.google.com/websearch/answer/2819496?hl=en&amp;co=GENIE.Platform%3DAndroid">Discover Feed</a> ; Edge 有一个叫 <a href="https://support.microsoft.com/en-us/microsoft-edge/organize-your-ideas-with-collections-in-microsoft-edge-60fd7bba-6cfd-00b9-3787-b197231b507e">Collections</a> 的功能，可以 “Follow” 一个网站的更新。那为什么我要说没有支持呢？因为这些都不是真正的 RSS，它们背后的技术可能还是 RSS ，但在产品上都刻意忽略 RSS、强调自己的名词。他们各造轮子实现自己的私有功能，仍是为了将用户关在自己的信息茧房中。&#160;<a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a>&#160;<a href="#fnref1:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><item><title>我的 Vim 自动补全配置变迁史</title><link>https://reorx.com/blog/the-history-of-my-vim-completion-config/</link><pubDate>Sat, 07 May 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/the-history-of-my-vim-completion-config/</guid><description>记录了从 2017 年至今，我的 Vim 自动补全配置的每次变更，从中窥见 Vim 生态发展的一角，也纪念这些曾经给我带来过便利，最终在技术发展中被轮替的插件</description><content:encoded><![CDATA[<p>Vim 是我系统学习的第一个终端编辑器，从学生时代至今，我几乎每天都会使用到它（长时间写前端代码时除外）。</p>
<p>自动补全（auto completion）大概是每个 Vim 用户在掌握了基本用法后，第一个想要进阶配置的功能。这篇文章记录了从 2017 年至今，我的 Vim 自动补全配置的每次变更，从中窥见 Vim 生态发展的一角，也纪念这些曾经给我带来过便利，最终在技术发展中被轮替的插件。</p>
<h2 id="before-2017">Before 2017</h2>
<p>2017 年我从 Vim 切换到 <a href="https://neovim.io/">Neovim</a>（下文简称 nvim），除了增加 nvim 特殊的 <code>init.vim</code>，基本沿用了以往的配置和插件。</p>
<p>彼时我使用的语言以 Python 为主，自动补全插件为 <a href="https://github.com/davidhalter/jedi-vim">jedi-vim</a>。</p>
<p>我对 jedi-vim 的了解最早可以追溯到 2012 年，那时还没有 LSP 的概念。开发者们针对自己的需求，编写如语法增强、文档查看、自动补全等各类插件，非常零散。jedi-vim 对这些插件的功能进行了重构和集成，提供了开箱即用的统一解决方案，一经推出便广受好评，成为使用 Vim 进行 Python 开发的标配。在后来的十年里，它的初心始终不变，得到持续的维护并沿用至今。</p>
<p>jedi-vim 的流行和长寿或许可以说明一个观点，即易用和功能全面才是软件流行的第一因素，无论它的实现有多么不优雅、效率有多么低，只要是能用的、可接受的就行，用户在使用体验上得到满足后，对于小问题的容忍度是相当高的。</p>
<h2 id="2017">2017</h2>
<p>还是 2017 年，在切换到 nvim 后不久，我发现了 <a href="https://github.com/Shougo/deoplete.nvim">deoplete</a> 插件，经过一番尝试将 jedi-vim 替换成了 deoplete + deoplete-jedi。</p>
<p>Commit: <a href="https://github.com/reorx/dotfiles/commit/0760ba6f7d11526e38e15b36a0d1db8709834825">0760ba6f7d11526e38e15b36a0d1db8709834825</a></p>
<blockquote>
<p><strong>use deoplete, remove jedi-vim</strong></p>
<p><em>committed on Jun 28, 2017</em></p>
</blockquote>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-diff" data-lang="diff"><span class="line"><span class="cl"><span class="gd">-Plug &#39;davidhalter/jedi-vim&#39;, { &#39;for&#39;: &#39;python&#39; }
</span></span></span><span class="line"><span class="cl"><span class="gd"></span><span class="gi">+Plug &#39;Shougo/deoplete.nvim&#39;, { &#39;do&#39;: &#39;:UpdateRemotePlugins&#39; }
</span></span></span><span class="line"><span class="cl"><span class="gi">+Plug &#39;zchee/deoplete-jedi&#39;, { &#39;for&#39;: &#39;python&#39; }
</span></span></span></code></pre></div><p>deoplete 的目标是提供一个通用的异步自动补全框架，这在设计理念上是一个巨大的进步。jedi-vim 虽然开箱即用，但却是一堆粘合在一起的 spaghetti code，不仅随着项目功能的增加变得越发庞大和迟缓（这是我想要离开 jedi-vim 的主要原因，文件一大各种操作都变得肉眼可见的慢），代码的可读性也非常糟糕，难以维护和参与。而 deoplete 本身并不提供针对任何语言的分析能力，只专注于与 nvim 的整合和 completion source 的调度，并且利用 nvim 的异步功能（后来 vim 8 也推出了自己的 async 接口），大大提升了补全的流畅度。</p>
<p>但 deoplete 也有着自身的局限性。首先配置变得复杂且麻烦，用户得理解其架构和设计，学会如何通过 deoplete 对接编程语言的 completion source。为了使检查结果的提示贴合自己的使用习惯，还要再去学习 completion source 的配置，每个语言的实现不同，配置也不一样。</p>
<blockquote>
<p>当时我却没有料到，配置复杂的问题在 LSP 时代不仅没能得到解决，反而变本加厉，直到本文完成时也依旧是使用者的巨大痛点</p>
</blockquote>
<p>deoplete 的第二个问题是，它只专注在 completion，缺少对于 go to definition 和显示 function siguature 等功能的支持，这对于从 jedi-vim 的 all-in-one 体验切换过来的我，显然是个巨大的落差。好在我找到了其他插件来解决这些问题。</p>
<p>对于 “go to definition”，通过装回 jedi-vim 并打开无补全模式可以解决。这样既可以使用 jedi-vim 提供的 go to definition 等辅助功能，也不会与 deoplete 的补全产生冲突。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-vim" data-lang="vim"><span class="line"><span class="cl"><span class="nx">Plug</span> <span class="s1">&#39;davidhalter/jedi-vim&#39;</span><span class="p">,</span> { <span class="s1">&#39;for&#39;</span>: <span class="s1">&#39;python&#39;</span> }<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err"></span><span class="c">
</span></span></span><span class="line"><span class="cl"><span class="c">&#34; jedi (only for go to definition)</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err"></span><span class="k">let</span> <span class="nx">g</span>:<span class="nx">jedi</span>#<span class="nx">completions_enabled</span> <span class="p">=</span> <span class="m">0</span><span class="err">
</span></span></span></code></pre></div><p>对于 “function signature”，我找到了 deoplete 作者的另一个插件 <a href="https://github.com/Shougo/echodoc.vim">echodoc</a> 来实现。它将函数的签名信息显示在 cmd 区域，规避了 deoplete 占用 <code>completeopt</code> 导致编辑界面无法显示补全菜单以外的其他信息的问题。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-vim" data-lang="vim"><span class="line"><span class="cl"><span class="nx">Plug</span> <span class="s1">&#39;Shougo/echodoc.vim&#39;</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err"></span><span class="c">
</span></span></span><span class="line"><span class="cl"><span class="c">&#34; echodoc</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err"></span><span class="nx">set</span> <span class="nx">noshowmode</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err"></span><span class="k">let</span> <span class="nx">g</span>:<span class="nx">echodoc</span>#<span class="nx">enable_at_startup</span><span class="p">=</span><span class="m">1</span><span class="err">
</span></span></span></code></pre></div><h2 id="2018">2018</h2>
<p>2018 年是里程碑式的一年，<a href="https://microsoft.github.io/language-server-protocol/">Language Server Protocol</a> 的生态逐渐成熟，新的补全工具涌现。我对 LSP 感到相当兴奋和好奇，迫不及待地从 deoplete 更换到了对 LSP 有更好支持的 <a href="https://github.com/ncm2/ncm2">ncm2</a>。</p>
<p>Commit: <a href="https://github.com/reorx/dotfiles/commit/7a1442c2334673ac17162c101663e220ef43a3c8">7a1442c2334673ac17162c101663e220ef43a3c8</a></p>
<blockquote>
<p><strong>nvim: update completion plugins (a lot!)</strong></p>
<ul>
<li>move and reorg completion plugins definitions and configurations</li>
<li>use LSP completion instead of deoplete</li>
<li>remove eslint from ale_linters</li>
<li>enable virtualenv display for airline</li>
<li>still working on passing settings to pyls through LanguageClient-neovim</li>
</ul>
<p><em>committed on Dec 7, 2018</em></p>
</blockquote>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-diff" data-lang="diff"><span class="line"><span class="cl"><span class="gd">-Plug &#39;Shougo/deoplete.nvim&#39;, { &#39;do&#39;: &#39;:UpdateRemotePlugins&#39; }
</span></span></span><span class="line"><span class="cl"><span class="gd">-Plug &#39;zchee/deoplete-jedi&#39;, { &#39;for&#39;: &#39;python&#39; }
</span></span></span><span class="line"><span class="cl"><span class="gd"></span><span class="gi">+Plug &#39;ervandew/supertab&#39;
</span></span></span><span class="line"><span class="cl"><span class="gi">+Plug &#39;ncm2/ncm2&#39;
</span></span></span><span class="line"><span class="cl"><span class="gi">+Plug &#39;roxma/nvim-yarp&#39;
</span></span></span><span class="line"><span class="cl"><span class="gi">+Plug &#39;autozimu/LanguageClient-neovim&#39;, { &#39;branch&#39;: &#39;next&#39;, &#39;do&#39;: &#39;bash install.sh&#39;, }
</span></span></span></code></pre></div><p>从设计理念上看，ncm2 与 deoplete 并无差别，都是通用的异步自动补全框架，唯有与<a href="https://en.wikipedia.org/wiki/Static_program_analysis">静态分析</a>器的集成方式不同，deoplete 是自己的私有协议，ncm2 则拥抱了更加通用的业界标准 LSP。</p>
<p>我为 deoplete 的作者感到惋惜，他在 LSP 还不够成熟的时期，自己设计了与静态分析器的集成协议，构建了一个完整的补全插件生态<sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>。还写了很多小巧实用的插件，代码也非常优美，让人感到赏心悦目。但因为 LSP 的发展和新一代更加 LSP native 的补全插件的涌现，它已不再是当下的第一选择，势必因为历史包袱而逐渐被淘汰。</p>
<p>说回 ncm2，其实它也有许多瑕疵，印象中配置过程比 deoplete 还要痛苦，但当时已经是让 nvim 用上 LSP 的最好插件了。之后我对 JetBrains 和 VSCode 的使用频率变高，疏于对 nvim 插件的持续跟进，ncm2 于是一直服役到 2021 年。</p>
<p>ncm2 出现后没过多久，<a href="https://github.com/neoclide/coc.nvim">coc</a> 也诞生了，在 2019 年成为最受人关注的 vim 补全插件，国内也看到很多文章（似乎作者就是国内开发者）。由于长期受 Webpack 和 Nodejs 技术栈的折磨，当我了解到 coc 是 Nodejs 实现的，就放弃了尝试的念头 <sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>。一想到 jedi-vim 的缓慢，我实在没办法对同样大而全的 coc 抱有足够的信心<sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup>。</p>
<p>P.S. 从当年的笔记中找到了所参考的项目和文章：</p>
<ul>
<li><a href="https://www.reddit.com/r/neovim/comments/8ejfg0/state_of_lsp_servers/">https://www.reddit.com/r/neovim/comments/8ejfg0/state_of_lsp_servers/</a></li>
<li><a href="https://github.com/autozimu/LanguageClient-neovim">https://github.com/autozimu/LanguageClient-neovim</a></li>
<li><a href="https://github.com/prabirshrestha/vim-lsp">https://github.com/prabirshrestha/vim-lsp</a></li>
<li><a href="https://github.com/ncm2/ncm2">https://github.com/ncm2/ncm2</a></li>
<li><a href="https://github.com/palantir/python-language-server">https://github.com/palantir/python-language-server</a></li>
</ul>
<h2 id="2021">2021</h2>
<p>2021 年的某一天，因为 ncm2 长期存在的一个小问题（现在已经忘了），我一气之下再次打开了 deoplete 的项目页面，惊喜地发现它已经完善了对 LSP 的支持，于是立刻就开始迁移，换回了我更欣赏且代码品质更胜一筹的 deoplete。</p>
<p>Commit: <a href="https://github.com/reorx/dotfiles/commit/cd044fcda603ad5b9ee16bd4d7d7873c9ade9a31">cd044fcda603ad5b9ee16bd4d7d7873c9ade9a31</a></p>
<blockquote>
<p><strong>nvim: rework on languageserver &amp; python completion</strong></p>
<p><em>committed on Mar 26, 2021</em></p>
</blockquote>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-diff" data-lang="diff"><span class="line"><span class="cl"><span class="gd">-Plug &#39;ervandew/supertab&#39;
</span></span></span><span class="line"><span class="cl"><span class="gd">-Plug &#39;ncm2/ncm2&#39;
</span></span></span><span class="line"><span class="cl"><span class="gd">-Plug &#39;roxma/nvim-yarp&#39;
</span></span></span><span class="line"><span class="cl"><span class="gd">-Plug &#39;autozimu/LanguageClient-neovim&#39;, { &#39;branch&#39;: &#39;next&#39;, &#39;do&#39;: &#39;bash install.sh&#39;,
</span></span></span><span class="line"><span class="cl"><span class="gd"></span><span class="gi">+Plug &#39;Shougo/deoplete.nvim&#39;, { &#39;do&#39;: &#39;:UpdateRemotePlugins&#39; }
</span></span></span><span class="line"><span class="cl"><span class="gi">+Plug &#39;prabirshrestha/vim-lsp&#39;
</span></span></span><span class="line"><span class="cl"><span class="gi">+Plug &#39;mattn/vim-lsp-settings&#39;
</span></span></span><span class="line"><span class="cl"><span class="gi">+Plug &#39;lighttiger2505/deoplete-vim-lsp&#39;
</span></span></span></code></pre></div><p>这次变更除了换回 deoplete ，还去掉了陪伴多年的 supertab，在抄了一段看不懂的配置后，实现了我更为习惯的 tab 键触发补全的方式。</p>
<h2 id="2022">2022</h2>
<p>在咖啡馆结束了一天的主要工作后，看着好友 <a href="https://github.com/xwjdsh">@iwendellsun</a> 流畅的 vim 操作，我问起了他的 nvim 自动补全配置，果然有许多我从未听过的东西。于是趁此机会赶紧向他请教，在他的指导下完成了 2022 年的配置升级。</p>
<p>Commit: <a href="https://github.com/reorx/dotfiles/commit/3de43d030ca40b498911c6752a7396af38202fe6">3de43d030ca40b498911c6752a7396af38202fe6</a></p>
<blockquote>
<p><strong>nvim: use nvim-cmp for completion</strong></p>
<p><em>committed on May 08, 2022</em></p>
</blockquote>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-diff" data-lang="diff"><span class="line"><span class="cl"><span class="gd">-Plug &#39;Shougo/deoplete.nvim&#39;, { &#39;do&#39;: &#39;:UpdateRemotePlugins&#39; }
</span></span></span><span class="line"><span class="cl"><span class="gd">-Plug &#39;prabirshrestha/vim-lsp&#39;
</span></span></span><span class="line"><span class="cl"><span class="gd">-Plug &#39;mattn/vim-lsp-settings&#39;
</span></span></span><span class="line"><span class="cl"><span class="gd">-Plug &#39;lighttiger2505/deoplete-vim-lsp&#39;
</span></span></span><span class="line"><span class="cl"><span class="gd">-Plug &#39;w0rp/ale&#39;
</span></span></span><span class="line"><span class="cl"><span class="gd">-Plug &#39;rhysd/vim-lsp-ale&#39;
</span></span></span><span class="line"><span class="cl"><span class="gd"></span><span class="gi">+Plug &#39;williamboman/nvim-lsp-installer&#39;
</span></span></span><span class="line"><span class="cl"><span class="gi">+Plug &#39;neovim/nvim-lspconfig&#39;
</span></span></span><span class="line"><span class="cl"><span class="gi">+Plug &#39;hrsh7th/cmp-nvim-lsp&#39;
</span></span></span><span class="line"><span class="cl"><span class="gi">+Plug &#39;hrsh7th/cmp-buffer&#39;
</span></span></span><span class="line"><span class="cl"><span class="gi">+Plug &#39;hrsh7th/cmp-path&#39;
</span></span></span><span class="line"><span class="cl"><span class="gi">+Plug &#39;hrsh7th/cmp-cmdline&#39;
</span></span></span><span class="line"><span class="cl"><span class="gi">+Plug &#39;hrsh7th/nvim-cmp&#39;
</span></span></span></code></pre></div><p>这次变更分以下几个方面：</p>
<ol>
<li>补全框架从 deoplete 变为 <a href="https://github.com/hrsh7th/nvim-cmp">nvim-cmp</a>，我还没细看，不过据说它就是现在的 meta &amp; state of the art.</li>
<li>LSP 集成从 vim-lsp 换成了 nvim-lspconfig。迟来的官方出品。</li>
<li>去掉了 ale 和 vim-lsp-ale。nvim-cmp 可以将 LSP client 返回的错误提示直接在行内显示，不需要再依赖 ALE 这个 linter 框架了。</li>
<li>Last but not least, 这些插件的配置语法几乎都是用 Lua 写的，这让用了 10 年 Vimscript 的我感到极度陌生和恐慌。</li>
</ol>
<p>相比之前的变更，这是唯一一次生搬硬套而非全部理解的，我想快速上车，免得被社区发展抛在了后面，现在实在没有太多精力可以悠闲地慢慢尝试。虽然有人指导免去了初次上手的痛苦，但可以预见的是，想要让这套插件和我的编程习惯完美契合，还有许多坑等着我去折腾呢。</p>
<p><strong>参考链接</strong>:</p>
<ul>
<li><a href="https://github.com/hrsh7th/nvim-cmp#setup">Setup | nvim-cmp</a>
<ul>
<li><a href="https://github.com/hrsh7th/nvim-cmp/wiki/List-of-sources">List of sources · hrsh7th/nvim-cmp Wiki</a></li>
<li><a href="https://github.com/hrsh7th/nvim-cmp/wiki/Example-mappings">Example mappings · hrsh7th/nvim-cmp Wiki</a></li>
</ul>
</li>
<li><a href="https://github.com/williamboman/nvim-lsp-installer#default-configuration">Default configuration | nvim-lsp-installer</a></li>
<li><a href="https://github.com/neovim/nvim-lspconfig/blob/master/doc/server_configurations.md">Server Configurations | nvim-lspconfig</a>
<ul>
<li><a href="https://github.com/neovim/nvim-lspconfig/blob/master/doc/server_configurations.md#pylsp">pylsp</a></li>
<li><a href="https://github.com/neovim/nvim-lspconfig/blob/master/doc/server_configurations.md#pyright">pyright</a></li>
<li><a href="https://github.com/neovim/nvim-lspconfig/blob/master/doc/server_configurations.md#gopls">gopls</a></li>
</ul>
</li>
<li><a href="https://github.com/ray-x/lsp_signature.nvim">ray-x/lsp_signature.nvim: LSP signature hint as you type</a></li>
</ul>
<p><strong>参考配置</strong>:</p>
<ul>
<li><a href="https://github.com/xwjdsh/dotfiles/blob/master/nvim1/lua/plugins.lua">https://github.com/xwjdsh/dotfiles/blob/master/nvim1/lua/plugins.lua</a></li>
<li><a href="https://github.com/Avimitin/nvim/blob/master/lua/plugins/load.lua">https://github.com/Avimitin/nvim/blob/master/lua/plugins/load.lua</a></li>
</ul>
<h2 id="结语">结语</h2>
<p>Vim 的 LSP 插件生态还有许多有待优化的空间，开发者们对生产力的追求是永无止境的，下一个 5 年编辑器的体验会有着怎样激动人心的变化，我对此充满期待。</p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p><a href="https://github.com/Shougo/deoplete.nvim/wiki/Completion-Sources">https://github.com/Shougo/deoplete.nvim/wiki/Completion-Sources</a>&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>其实 Nodejs 在服务端的性能不差，但 Webpack 和 Electron 给我造成的印象已经根深蒂固，使我看到 Nodejs 就会立刻联想到 slow and bloated.&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3">
<p>2022-05-09: 本文发布后，许多朋友向我推荐了 coc，并告知它的速度很快，修正了我在没有使用过的情况下产生的偏见认知。&#160;<a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded></item><item><title>使用 Sonarr 搭建自动化追番系统</title><link>https://reorx.com/blog/track-and-download-shows-automatically-with-sonarr/</link><pubDate>Mon, 02 May 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/track-and-download-shows-automatically-with-sonarr/</guid><description>A complete guide to deploy and configure Sonarr + Jackett + FlareSolverr + qBittorrent to track and download anime shows automatically.</description><content:encoded><![CDATA[<p>作为一个懒人，我很少折腾 self-hosted 服务，对于追番这种娱乐化的需求，更是有什么用什么将就度日。所以许多年来一直是用 Bilibili 观看新番，中途也尝试过 ebb.io 这类小众服务，但最后都因为连接不畅或数据滞后等原因放弃了。</p>
<p>去年为了实现 Home Assistant 家电自动化 (<a href="https://twitter.com/novoreorx/status/1450334462177263618">tweet</a>)，我购买了一台 QNAP NAS，使家里有了 24 小时常驻的 Linux server 和 Docker host。年初由于 Bilibili 迟迟不上架《鬼滅之刃遊郭篇》，我又在 NAS 上安装了 Plex，将从 <a href="https://share.dmhy.org/">dmhy</a> 手动下载的影片串流到电视上观看。最近又逢 4 月新番播出，想到家里的基础设施逐渐完备，便动起了搭建自动追番系统的心思。以下便是对这次尝试的记录和总结，希望能帮助有同样需求的人解决问题、节省时间。</p>
<h2 id="使用-qnap-download-station">使用 QNAP Download Station</h2>
<p>本着尽量不增加新的系统来解决问题的思想，我首先研究了 QNAP 的下载器 Download Station，发现它自带 RSS 订阅功能，在进行一些手动配置后，即可完成自动追番和下载。</p>
<p>如果对这一章节不感兴趣，你也可以直接跳到 <a href="#%E4%BD%BF%E7%94%A8-sonarr-%E5%92%8C%E5%AE%83%E7%9A%84%E6%9C%8B%E5%8F%8B%E4%BB%AC">使用 Sonarr 和它的朋友们</a>。</p>
<h3 id="1-找到番剧的-rss-url">1. 找到番剧的 RSS URL</h3>
<p><a href="https://bangumi.moe/">Bangume Moe</a> 是我经常使用的资源站，它提供基于 tag 的 RSS 搜索功能，可以非常精确地定位到番剧在特定字幕组、语言、分辨率瞎的视频发布链接。</p>
<ul>
<li>首先在右上角的搜索框中输入想看的番剧名称，这里我使用「夏日重现」作为例子，可以看到下方自动匹配了 <code>Summertime Render</code> 这个 tag。
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/qnap-rss-anime-2.png" type="" alt=""  /></li>
<li>点击这个 tag，搜索结果中会出现许多不同字幕组发布的资源，我们选择其中一个字幕组「喵萌奶茶屋」的资源，在 Torrent Details 中，可以看到其标注的 tags，有 <code>喵萌奶茶屋</code>, <code>720p</code>, <code>chs-jpn</code> 等，妥善使用这些 tags 能够帮助我们缩小搜索结果范围
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/qnap-rss-anime-1.png" type="" alt=""  /></li>
<li>将 <code>喵萌奶茶屋</code>, <code>chs-jpn</code> 添加到 Selected tags 中，现在我们一共有 3 个 tags，搜索结果缩短到了 2 条，已经非常清晰了，点击右上角的 RSS 图标即可得到用于订阅的链接。（美中不足的是，喵萌奶茶屋将 1080p 的资源错标为了 <code>720p</code>，不过这点我们可以在后面的下载管理器中解决）
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/qnap-rss-anime.png" type="" alt=""  /></li>
</ul>
<h3 id="2-将-rss-url-添加到下载器">2. 将 RSS URL 添加到下载器</h3>
<p>RSS 订阅是许多下载管理器的通用功能，如 Synology、qBittorrent 也都具有，使用方式与 Download Station 大同小异。</p>
<ul>
<li>打开 RSS Download Manager，将上一步得到的订阅链接复制到 Feed URL 中。Label 填写番剧名称，两个 Location 根据自己 NAS 的目录结构选择。点击 Apply 添加。
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/qnap-rss-anime-filter.png" type="" alt=""  /></li>
<li>添加完成后，打开 Filter Settings，它可以用于进一步过滤 RSS 返回的结果，避免下载不需要的资源。之前在进行资源搜索时，我们没能通过 tag 过滤掉 720p 的结果，在 Filter Settings 中，我们选择 1080p，这样 720p 就被忽略了。
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/qnap-rss-anime-filter-1.png" type="" alt=""  /></li>
<li>回到主界面，刷新添加的 RSS，可以看到两条结果中只有 1080p 被加入到下载列表。Status 为 Finished 代表下载完成，New 代表新添加到 RSS，但不在下载列表中。</li>
</ul>
<h3 id="小结">小结</h3>
<p><strong>Pros</strong></p>
<ul>
<li>无需配置和增加新系统</li>
<li>操作过程透明清晰，可控性强</li>
</ul>
<p><strong>Cons</strong></p>
<ul>
<li>步骤较为繁琐，需要在多个服务之间切换</li>
<li>下载的文件因为不符合 Plex 的格式需求，需要手动重命名才能在 Plex 上观看。但如果你使用 DLNA 这类基于文件系统定位视频的方式，倒是可以忽略这个缺点。</li>
</ul>
<h2 id="使用-sonarr-和它的朋友们">使用 Sonarr 和它的朋友们</h2>
<p>Download Station RSS 虽然解决了基本的温饱问题，但与真正的自动化之间显然还有很大差距，如果你想充分利用 NAS 的功能，在家庭多媒体设施上更进一步，请继续向下阅读，走进 Sonarr 和它的朋友们的世界。</p>
<h3 id="sonarr">Sonarr</h3>
<blockquote>
<p>Sonarr is a PVR (Personal Video Recorder) for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new episodes of your favorite shows and will grab, sort and rename them.</p>
</blockquote>
<p><a href="https://wiki.servarr.com/sonarr">Sonarr</a> 是一个 PVR 系统，它可以监控多种信息源的剧集发布信息，对剧集进行自动下载、刮削和重命名。</p>
<p>Sonarr 是 Sick Beard 的继任者，Sick Beard 基于 Python 开发，曾经是最著名的 PVR 系统，但后来逐渐式微，后续虽然有 Sick Gear 等 fork，但最终都没能满足人们日益增长的看片需求，最后被一群 C# 爱好者们开发出 Sonarr 给替代了。经过多年发展，Sonarr 的软件质量趋于成熟和稳定，成为构架自动化的家庭多媒体中心不可缺少的组件。</p>
<p>Sonarr 一开始就以多系统低耦合的方式进行设计，这大大降低了代码的复杂度，使其更易于维护，也为其繁盛的社区发展提供了基石。为了满足不同的用户需求，Sonarr 的作者们开发一系列以 <code>rr</code> 或其他双字母结尾的工具，以至于这种命名方式成为了 Sonarr 生态圈的一个标识和事实标准。</p>
<h3 id="jackett">Jackett</h3>
<blockquote>
<p>Jackett works as a proxy server: it translates queries from apps into tracker-site-specific http queries, parses the html or json response, and then sends results back to the requesting software.</p>
</blockquote>
<p><a href="https://github.com/Jackett/Jackett">Jackett</a> 是一个代理，用于将 Sonarr 等系统对剧集信息的查询请求转译成各种 torrent tracker/indexer （即资源发布站）所支持的请求。Jackett 的产生是 Sonarr 社区架构合理性的一个体现，它接管了适配不同 indexer 接口的脏活累活，使 Sonarr 可以专注在任务调度和剧集管理上。</p>
<p>Jackett 既然被用作资源查询的网关，实现缓存功能就顺理成章了，这样一方面可以应对 Sonarr 频繁的查询需求，另一方面也减少了 indexer 站点的负载量，实现了双赢。因此虽然它不是一个必须的组件，但我强烈建议每个使用 Sonarr 的用户都安装它。</p>
<h3 id="flaresolverr">FlareSolverr</h3>
<blockquote>
<p>FlareSolverr is a proxy server to bypass Cloudflare and DDoS-GUARD protection.</p>
</blockquote>
<p><a href="https://github.com/FlareSolverr/FlareSolverr">FlareSolverr</a> 也是一个代理，它帮助 Jackett 解决向 indexer 的请求能否成功的问题。一些 indexer 为了保护自己免于 DDoS 攻击或减少爬虫请求，会使用 Cloudflare 或一些其他的安全防护服务，如果不做处理，直接请求很可能因触发人机验证而失败。FlareSolverr 就是为解决这类问题诞生的。</p>
<p>在 Jackett 中添加新的 indexer 时，会根据站点情况提示是否需要接入 FlareSolverr 以绕过站点的保护措施。</p>
<h3 id="qbittorrent">qBittorrent</h3>
<p>大部分影视资源都使用 BT 协议进行点对点传输，因此我们也需要一个 BT 下载器，qBittorrent 就是一个很好的选择，它的功能非常全面，且与 Sonarr 有很好的接入支持。如果你已经运行了其他下载器如 Aria2，甚至 QNAP/Synology 自带的 Download Station，那么你也可以参考 <a href="https://wiki.servarr.com/sonarr/supported#downloadclient">Sonarr 支持的下载器列表</a>，尝试进行配置。（我并没有成功使 Sonarr 和 QNAP Download Station 协同工作起来）</p>
<h3 id="部署说明">部署说明</h3>
<p>各个组件介绍完毕，现在让我们进入正题，了解如何配置和部署整个 Sonarr 服务组。</p>
<p>Sonarr 现代化地提供 Docker 镜像的部署方式，因此这个章节将会围绕 Docker 相关的技术进行说明。虽然 <a href="https://www.qnapclub.eu/en/qpkg/652">Qnapclub</a> 也有 Sonarr 的安装包，但经过一番糟心的尝试，最终我只得出一个结论，那就是以后部署任何服务，能 Docker 就尽量 Docker 吧。</p>
<p><details >
  <summary markdown="span">A failed attempt to install Sonarr by qpkg</summary>
  <ul>
<li>error after install: <code>MediaInfo Library could not be loaded libmediainfo.so.0 assembly:&lt;unknown assembly&gt; type:&lt;unknown type&gt; member:(null)</code></li>
<li>dependencies
<ul>
<li><a href="https://www.qnapclub.eu/en/qpkg/193">Qmono</a>: the package is crazily 3GB in size</li>
<li><a href="https://www.qnapclub.eu/en/qpkg/712">MediaInfoCLI</a>
<ul>
<li><a href="https://forum.qnap.com/viewtopic.php?t=147702">https://forum.qnap.com/viewtopic.php?t=147702</a></li>
<li>this is not useful at all</li>
</ul>
</li>
</ul>
</li>
</ul>

</details></p>

<p>我们的部署方案的基本原理是在自己的电脑上通过 Docker 和 Docker compose CLI 对远端 NAS 上的 Docker host 进行操作。这要求读者具备基础的 Docker 相关的知识。</p>
<p>QNAP/Synology 等 NAS 系统提供的 Docker host 与标准实现基本没有差异，因此我们可以直接使用 Docker 官方软件包提供的命令行工具。你也可以使用 NAS 提供的图形化界面，但一则那样在部署多个服务时非常不便，二则如果你了解了如何使用 Docker compose 部署，自然也可以反推到图形化界面的操作方式上。</p>
<h4 id="docker-context">Docker context</h4>
<p>为了与本地的 Docker host 区分开，我们要为 NAS 上的 Docker host 创建一个新的 <a href="https://docs.docker.com/engine/context/working-with-contexts/">context</a>。</p>
<blockquote>
<p><code>harrogath</code> 是我的 NAS 的 hostname，可以使用 IP 代替</p>
</blockquote>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 创建 context</span>
</span></span><span class="line"><span class="cl">docker context create harrogath
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 更新 context 的配置信息</span>
</span></span><span class="line"><span class="cl">docker context update harrogath --docker <span class="s2">&#34;host=tcp://harrogath:2376,ca=</span><span class="nv">$HOME</span><span class="s2">/.docker/ca.pem,cert=</span><span class="nv">$HOME</span><span class="s2">/.docker/cert.pem,key=</span><span class="nv">$HOME</span><span class="s2">/.docker/key.pem&#34;</span>
</span></span></code></pre></div><p>QNAP 的 Docker 需要通过证书来访问，因此要在 Container Station 的设置中下载证书。
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/qnap-docker-certs.png" type="" alt=""  /></p>
<p>完成 context 创建后，通过 <code>use</code> 命令切换到 context，之后所有的 Docker 命令都是与 NAS Docker 进行通讯</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="l">docker context use harrogath</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="l">docker context ls</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="l">NAME                TYPE                DESCRIPTION                               DOCKER ENDPOINT                               KUBERNETES ENDPOINT   ORCHESTRATOR</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="l">default             moby                Current DOCKER_HOST based configuration   unix:///var/run/docker.sock                                         swarm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="l">harrogath *         moby</span><span class="w">
</span></span></span></code></pre></div><h4 id="images">Images</h4>
<p>目前主要有两个组织在维护 Sonarr 及相关服务的 Docker 镜像，<a href="https://www.linuxserver.io/">linuxserver.io</a> 和 <a href="https://hotio.dev/">hotio.dev</a>, 我选择前者作为本次部署的镜像源。下面是使用到的镜像列表，可以在其页面上查看镜像的使用说明。</p>
<ul>
<li><a href="https://hub.docker.com/r/linuxserver/sonarr">linuxserver/sonarr - Docker Image | Docker Hub</a></li>
<li><a href="https://hub.docker.com/r/linuxserver/jackett">linuxserver/jackett - Docker Image | Docker Hub</a></li>
<li><a href="https://hub.docker.com/r/linuxserver/qbittorrent">linuxserver/qbittorrent - Docker Image | Docker Hub</a></li>
<li><a href="https://hub.docker.com/r/flaresolverr/flaresolverr">flaresolverr/flaresolverr - Docker Image | Docker Hub</a></li>
</ul>
<h4 id="hierarchy">Hierarchy</h4>
<p>Sonarr 和它的朋友们需要对 NAS 的存储进行读写，因此在运行服务之前，首先要理解并设计一个好用的目录结构。</p>
<p>我们需要在 NAS 上创建一个 AppData 目录，作为容纳所有服务产生的文件的根目录。AppData 所在的 Volume 建议选择适合存放大量媒体数据的精简卷 (Thin Volume)。</p>
<pre tabindex="0"><code>AppData
├── jackett
│   ├── config
│   └── downloads
├── qbittorrent
│   └── config
└── sonarr
    ├── config
    ├── downloads
    ├── media
    ├── torrents
    └── usenet
</code></pre><p>在 AppData 之下，依次为 sonarr, jackett, qbittorrent 创建目录，每个目录下必须有 <code>config</code> 子目录，用于存放配置文件。flaresolverr 由于是纯网络代理服务不需要对应目录存在。</p>
<p>下面对各个服务的 NAS 目录与容器内目录的映射关系进行说明</p>
<blockquote>
<p>表示为 <code>NAS Directory -&gt; Container Directory</code></p>
</blockquote>
<ul>
<li><strong>sonarr</strong>
<ul>
<li><code>sonarr/config -&gt; /config</code>: 配置文件</li>
<li><code>sonarr -&gt; /data</code>: 数据文件，可在 Sonarr 管理界面向下创建子目录</li>
</ul>
</li>
<li><strong>qbittorrent</strong>
<ul>
<li><code>qbittorrent/config -&gt; /config</code>: 配置文件</li>
<li><code>sonarr -&gt; /data</code>: 数据文件，与 sonarr 保持一致，以便 sonarr 在重命名或移动文件时可以直接使用 qbittorrent 返回的文件路径</li>
</ul>
</li>
<li><strong>jackett</strong>
<ul>
<li><code>jackett/config -&gt; /config</code>: 配置文件</li>
<li><code>jackett/downloads -&gt; /downloads</code>: 默认种子文件下载路径</li>
</ul>
</li>
</ul>
<h4 id="compose-file">Compose file</h4>
<p>以我正在使用的 <code>docker-compose.yml</code> 文件作为示例，你可以复制下来修改自己的版本，只需要将 <code>/share/CACHEDEV2_DATA/Misc/AppData</code> 替换为自己在 NAS 上创建的 <code>AppData</code> 路径即可。</p>
<p>端口的配置需要额外注意，这份配置中我尽量保持各服务默认配置端口不变，若与已有服务冲突，修改到未被占用的端口即可。例如 QNAP 自带的 Download Station 已占用 6881 端口，因此我将 qbittorrent 修改到了 16881 端口。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">version</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;3&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">sonarr</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">linuxserver/sonarr</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="m">8989</span><span class="p">:</span><span class="m">8989</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/share/CACHEDEV2_DATA/Misc/AppData/sonarr/config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/share/CACHEDEV2_DATA/Misc/AppData/sonarr:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/etc/localtime:/etc/localtime:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PUID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PGID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=Asia/Shanghai</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">jackett</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">linuxserver/jackett</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="m">9117</span><span class="p">:</span><span class="m">9117</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/share/CACHEDEV2_DATA/Misc/AppData/jackett/config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/share/CACHEDEV2_DATA/Misc/AppData/jackett/downloads:/downloads</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PUID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PGID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=Asia/Shanghai</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">qbittorrent</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">linuxserver/qbittorrent</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PUID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">PGID=1000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=Asia/Shanghai</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">WEBUI_PORT=8080</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/share/CACHEDEV2_DATA/Misc/AppData/qbittorrent/config:/config</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">/share/CACHEDEV2_DATA/Misc/AppData/sonarr:/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="m">8080</span><span class="p">:</span><span class="m">8080</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="m">16881</span><span class="p">:</span><span class="m">6881</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="m">16881</span><span class="p">:</span><span class="m">6881</span><span class="l">/udp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">flaresolverr</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">flaresolverr/flaresolverr</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">LOG_LEVEL=info</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">LOG_HTML=false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">CAPTCHA_SOLVER=${CAPTCHA_SOLVER:-none}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">TZ=Asia/Shanghai</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="m">8191</span><span class="p">:</span><span class="m">8191</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span></code></pre></div><h4 id="run">Run</h4>
<p>一切准备就绪，执行以下命令:</p>
<pre tabindex="0"><code>docker compose up
</code></pre><p>这会启动所有服务并 attach 到终端输出日志，方便查看服务的运行日志。等确认所有服务稳定运行后，加上 <code>-d</code> 参数使其运行在后台。</p>
<pre tabindex="0"><code>docker compose up -d
</code></pre><h3 id="使用说明">使用说明</h3>
<p>下面对各个服务的 Web UI 的使用方法进行说明，以我的 NAS hostname  <code>harrogath</code> 为例展示服务的访问地址。</p>
<h4 id="jackett-1">Jackett</h4>
<p>打开 <code>http://harrogath:9117</code>, Jackett 的界面展示如下:</p>
<p><img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/jackett.png" type="" alt=""  /></p>
<p>使用方式非常简单，Jackett 已经将这个世界上绝大部分 indexer 都内置了，只需要点击 Add indexer 搜索并添加即可。除了部分有访问限制的 indexer 需要配置外，基本上都可以一路点击确认完成添加。之后在 Sonarr 中对接时，需要复制订阅 URL 和 API key，参照截图中所示即可。</p>
<p>我目前使用了 4 个 indexer，其中 Bangumi Moe 和 dmhy 用于下载动画番剧，EZTV 和 RARBG 用于下载美剧。</p>
<p>为了使 FlareSolverr 发挥作用，我们还需要将它的 URL 填在最下方的配置中，然后点击 Apply server settings 保存生效。
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/jackett-1.png" type="" alt=""  /></p>
<h4 id="qbittorrent-1">qBittorrent</h4>
<p>打开 <code>http://harrogath:8080</code>，使用默认用户名密码 <code>admin:adminadmin</code> 登录。</p>
<p>打开 Tools » Options，将 Default Save Path 修改为 <code>/data/downloads</code>。这样做的目的是为了使 qBittorrnet 所记录的文件路径与 Sonarr 保持一致，以便不需要额外配置就能使 Sonarr 的重命名功能正常工作。若你不希望修改下载路径，或使用的是其他下载器，可以参考 <a href="https://trash-guides.info/Sonarr/Sonarr-remote-path-mapping/">Remote Path Mappings</a> 来解决路径不一致的问题。
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/qbittorrent.png" type="" alt=""  /></p>
<p>在 Options 中切换到 BitTorrent 选项卡，将 Seeding Limits 下的 &ldquo;then&rdquo; 修改为 &ldquo;Pause torrent&rdquo;，这是为了避免 Sonarr 在删除种子时产生冲突。&ldquo;When ratio reaches&rdquo; 代表做种分享资源的比率。BT 协议提倡共享精神，既然从别人那里获取到自己想要的资源，理应做出回馈。推荐将分享率设置为 2.0，即上传量为下载量的两倍之后停止分享。如果你担心硬盘过度损耗，可以将比率降低。(<em>也可以修改为 0 关闭做种功能，如果克服了道德感的约束</em>)
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/qbittorrent-3.png" type="" alt=""  /></p>
<h4 id="sonarr-1">Sonarr</h4>
<p>终于到了追番大计最核心的组件——Sonarr，但先不要急，在添加番剧前，还有一些设置要做。</p>
<ol>
<li>
<p>Indexers</p>
<ul>
<li>将先前在 Jackett 中添加的 indexers 逐个对接到 Sonarr，使用 Torznab 协议。
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/sonarr-1.png" type="" alt=""  /></li>
<li>Bangumi.moe 的配置展示，URL 和 API Key 都是从 Jackett 中复制过来。需要注意的是， Categories 是影视剧的分类，应该留空，而在 Anime Categories 中勾选所有与番剧相关的分类项。
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/sonarr-2.png" type="" alt=""  />
<ul>
<li>若不确定自己要搜索在资源属于哪个分类项，可在 Jackett 中使用 Manual Search 来确认
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/jackett-2.png" type="" alt=""  /></li>
</ul>
</li>
<li>rarbg 的配置展示，由于 rarbg 是影视剧 indexer，我们需要在 Categories 中勾选分类项而将 Anime Categories 留空
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/sonarr-3.png" type="" alt=""  /></li>
</ul>
</li>
<li>
<p>Download Clients</p>
<ul>
<li>qBittorrent 配置展示。Host 最好填写 IP，使用 hostname 可能会失败。
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/sonarr-qbittorrent.png" type="" alt=""  /></li>
</ul>
</li>
<li>
<p>Profiles</p>
<p>默认只有 English，需要添加一个中文的 Profile 以在添加番剧时设定语言。<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/sonarr-profile.png" type="" alt=""  /></p>
</li>
<li>
<p>Media Management</p>
<p>这个设置关系到下载的文件能否被 Plex 或其他 media server 识别，但别被眼花缭乱的设置项吓到， 我们只需要关心下图红框圈起的部分。
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/sonarr-mediamanagement.png" type="" alt=""  /></p>
<ul>
<li>首先打开 Rename Episodes 功能</li>
<li>然后配置 Anime Episode Format 和 Season Folder Format，这代表番剧被重命名后的文件名格式和上级目录格式。你不用关心源文件的名称如何被 Sonarr 解析，你只用知道它自信并出色地完成了这项脏活，让我们可以使用变量定义想要的文件和目录名称。这里我使用的是 Plex 的 <a href="https://support.plex.tv/articles/naming-and-organizing-your-tv-show-files/">TV Show Files</a> 格式，实现的结果如下: <img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/finder.png" type="" alt=""  /></li>
<li>最后配置 Root Folders，它们在添加番剧的时候会用到，作为放置番剧的目录。你可以根据自己的需求设置多个，一般来说设置两个将影视剧和动画番剧分开存放即可。还记得我们在 <code>docker-compose.yaml</code> 中配置的目录映射吗？这里的目录 <code>/data/media/anime tv</code> 在 NAS 中对应的是 <code>AppData/sonarr/data/media/anime tv</code>，在 Plex 添加媒体库时不要忘了如何找到它。</li>
<li>Optional: 文件重命名时，Sonarr 默认采取移动策略，这样会导致 BT 下载器无法继续对资源做种，为了避免这种情况，在 Media Management 页面打开 Advanced Settings，找到并打开 &ldquo;Use Hardlinks instead of Copy&rdquo;，这样既保留了下载资源的原始路径，又不会额外占用硬盘空间。</li>
</ul>
</li>
<li>
<p>Connect</p>
<p>实现自动化追剧的目的是为了不需要人工检查剧集的更新情况，因此通知是必不可少的。Connect 可以连接许多通知服务，让你第一时间知道剧集何时上线，资源何时发布，何时完成下载、可以观看。下面说明如何对接 Telegram 获取通知信息。</p>
<ul>
<li>
<p>在 Telegram 搜索机器人 <code>@BotFather</code>，按照提示创建一个新的机器人，获取 API Token。我的机器人名为 <code>@reorx_notify_bot</code>。 <img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/sonarr-telegram-2.png" type="" alt=""  /></p>
</li>
<li>
<p>创建一个群组，将 bot 加入到群组中，请求接口 <code>https://api.telegram.org/bot&lt;TOKEN&gt;/getUpdates</code>, 取出返回结果中的 <code>result[0].channel_post.chat.id</code> 作为 chat id</p>
<blockquote>
<p>方法来自 <a href="https://stackoverflow.com/a/61215414/596206">How to obtain Telegram chat_id for a specific user?</a></p>
</blockquote>
<ul>
<li>2022-08-11 updated: 也可以通过将 <code>@getidsbot</code> 邀请到群组中来获得 chat id，这个方法更加简单便捷</li>
</ul>
</li>
<li>
<p>在 Sonarr 中添加 Telegram connection，勾选自己关心的通知类型，一般至少会选择 On Download，代表新剧集下载完成的时间。
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/sonarr-telegram.png" type="" alt=""  /></p>
</li>
<li>
<p>配置好后，就可以通过 Telegram 第一时间掌握番剧的更新情况了。 <img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/sonarr-telegram-1.png" type="" alt=""  /></p>
</li>
</ul>
</li>
</ol>
<h4 id="开始追剧">开始追剧</h4>
<p>配置完成，现在可以开始追剧了。下面以「盾之勇者成名录 第二季」为例说明在 Sonarr 添加番剧的过程。</p>
<p>打开 <a href="https://thetvdb.com/">TheTVDB.com</a>, 搜索到你想要追踪的番剧的页面 <a href="https://thetvdb.com/series/the-rising-of-the-shield-hero">The Rising of the Shield Hero</a>，获取 URL 中的最后一段 <code>the-rising-of-the-shield-hero</code> 作为在 Sonarr 搜索关键词。</p>
<p>打开 Sonarr，在左上角的 Search 框中输入 <code>the-rising-of-the-shield-hero</code>，点击搜索结果，进入添加番剧的界面。</p>
<p><img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/sonarr-6.png" type="" alt=""  /></p>
<ol>
<li>Root Folder: 选择先前在 Media Management 中设置的 <code>anime tv</code></li>
<li>Monitor: 由于我们只想看最近的第二季，因此选择 Only Latest Season</li>
<li>Quality Profile: 选择 HD-1080p 或其他你需要的格式/分辨率</li>
<li>Language Profile: 选择 Chinese</li>
<li>Series Type: 选择 Anime，这里对应的是添加 Indexer 时的 Anime Categories 选项，告诉 Sonarr 从 Indexer 的 Anime Categories 中搜索资源。如果使用默认的 Standard（对应 Indexer 的 Categories），将会导致无法搜索到资源。</li>
<li>Season Folder: 勾选，为了对应 Plex 要求的目录结构</li>
<li>Start search for missing episodes: 勾选，这会使 Sonarr 在添加番剧后立刻根据 Monitor 所设置的条件开始搜索资源。也可以在添加完成后在详情页手动点击触发。</li>
</ol>
<p>完成添加后，点击番剧进入详情页。</p>
<p><img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/sonarr-7.png" type="" alt=""  /></p>
<ol>
<li>点击放大镜图标对其所在的 Season 进行剧集搜索。</li>
<li>Status 列的图标说明
<ul>
<li>🕓: 尚未播出，不会进行监控</li>
<li>⚠️: 已播出，硬盘中还没有资源，正在进行监控</li>
<li>☁️ 或进度条: 已开始下载</li>
</ul>
</li>
<li>书签符号有填充代表该季处于正在监控状态，无填充代表未监控，可以点击手动设置为监控</li>
</ol>
<p>在 Sonarr 首页可以看到各个番剧的追踪状态，蓝色代表标记追踪的剧集都已下载完成，红色代表有缺失，正在持续监控中。
<img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/sonarr-5.png" type="" alt=""  /></p>
<p>下载了一些资源后，我们打开 Plex，将 <code>anime tv</code> 对应的路径添加到「动画」Library 中，Plex 就会开始自动扫描文件、刮削元数据、下载中文字幕了。</p>
<p><img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/plex.png" type="" alt=""  /></p>
<p>大功告成，在 Plex 上愉快看番吧☺️。</p>
<p><img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/plex-1.png" type="" alt=""  /></p>
<h2 id="番外">番外</h2>
<h3 id="sonarr-的其他朋友们">Sonarr 的其他朋友们</h3>
<ul>
<li><a href="https://radarr.video/">Radarr</a>
Radarr 是 Sonarr 的兄弟项目，Sonarr 专精于追剧，Radarr 专精于追踪电影</li>
<li><a href="https://www.bazarr.media/">Bazarr</a>
Bazarr 用于自动下载字幕文件，由于 Plex 本身具有这个功能，以及大部分动漫资源都有内置字幕，因此不是特别有必要部署</li>
<li><a href="https://lidarr.audio/">Lidarr</a>
Lidarr 是音乐专辑的追踪和下载器，适用于喜欢收藏本地音乐文件的用户</li>
<li><a href="https://readarr.com/">Readarr</a>
Readarr 是电子书的追踪和下载器。我看书不多，偶尔需要看的书一般从 zlibrary 下载，Calibre 足够满足我的管理需求。</li>
</ul>
<h3 id="lunasea">LunaSea</h3>
<p><a href="https://www.lunasea.app/">LunaSea</a> 是一个 Sonarr 和 Usenet 生态圈的远程控制器，能让你在手机上查看 Sonarr / Radarr / Lidarr 的资源和放送时间表。</p>

<figure class="center align-center">
  <div class="image-size-control" style="height: 600px;">
    <img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/lunasea.jpeg" type="" alt=""  />
  </div>

  <figcaption><p>LunaSea iOS app screenshot</p></figcaption>
</figure>

<h3 id="seedboxio">Seedbox.io</h3>
<p>在得知我最近的折腾经过后，我的好朋友 <a href="https://wzyboy.im/">wzyboy</a> 向我科普了一种名为 <a href="https://en.wikipedia.org/wiki/Seedbox">Seedbox</a> 的 hosted service，提供开箱即用的 BT 下载和家庭媒体服务器，并能带来以下几点好处：</p>
<ul>
<li>免于折腾和维护诸多服务/服务器</li>
<li>发扬 BT 分享精神的同时无需损耗自己的硬盘</li>
<li>规避 P2P 下载资源的版权问题</li>
</ul>
<p>其中有一家服务商叫 seedbox.io, 它们的服务器包含本篇介绍的所有组件。如果你有兴趣的话，可以通过这个 <a href="https://panel.seedbox.io/aff.php?aff=1061">affiliate link</a> 注册和购买他们的服务。</p>
<p><img loading="lazy" src="/blog/track-and-download-shows-automatically-with-sonarr/images/seedbox-apps.png" type="" alt=""  /></p>
<h2 id="结语">结语</h2>
<p>Sonarr 和它的朋友们还有许多功能，本篇旨在引导用户完成最基本的自动化追番配置，就不一一介绍了，有兴趣的读者可以自行探索，欢迎在评论区留言分享。</p>
<p>近两年来，在家办公越来越融入到我们的生活，家庭网络和服务的搭建也逐渐从以往对 Geek 的刻板印象，变成或许没被意识到，但人人都有的需求。照片存储、数据备份、远程控制、环境监控、智能家居、多媒体娱乐…这些无一不是我们的日常所需。往后我会写更多这方面的文章，将我使用 NAS 改变生活方式的过程记录下来。The digital life has just begun.</p>
<h2 id="references">References</h2>
<ul>
<li><a href="https://wiki.servarr.com/">WikiArr</a></li>
<li><a href="https://trash-guides.info/">TRaSH Guides</a></li>
</ul>
<h2 id="revision">Revision</h2>
<ul>
<li>2022-04-19: created with &ldquo;使用 QNAP Download Station&rdquo;</li>
<li>2022-05-02: added &ldquo;使用 Sonarr 和它的朋友们&rdquo;, finished &ldquo;部署说明&rdquo;</li>
<li>2022-05-04: finished &ldquo;使用说明&rdquo; and the whole article</li>
<li>2022-08-11: added using <code>@getidsbot</code> to find Chat ID in Telegram</li>
</ul>
]]></content:encoded></item><item><title>Switch open files quickly in Obsidian</title><link>https://reorx.com/blog/obsidian-switching-open-files-quickly/</link><pubDate>Thu, 21 Apr 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/obsidian-switching-open-files-quickly/</guid><description>Imagine a quick switcher with open files at the top.</description><content:encoded><![CDATA[<h2 id="the-problem">The problem</h2>
<p>since I use the “Sliding Panes” plugin, there’s always a bunch of files open in my Obsidian. Sometimes I switch to Obsidian with a very clear goal of opening a specific file, so I press Command+O and type in the file name to find it. Because I don’t want to check whether the file is open or not (rather say there’s no way to check open files quickly), I always press Command+Enter to open the file in a new pane. As this process repeats, some files will have redundant panes, the panes are bloated and the workspace ends up being a mess.</p>
<p><a href="https://github.com/darlal/obsidian-switcher-plus">Quick Switcher++</a> is a very helpful tool, it allows me to search for open editors by typing a special prefix (<code>edt</code> by default) in the quick switcher. But after using it a while, I still feel it’s not very intuitive, I want the “search any file and open it” action and “search for existing open file and activate it” to be combined into one thing to use.</p>
<h2 id="the-solution">The solution</h2>
<p>I forked Quicker Switcher ++ and added a new setting item called “Include open files”. When it’s enabled, the switcher modal shows all the open files at the top with a different color style. If the open file is chosen, make that pane active; Other suggestions behave the same as the original, opening the file in the active pane.</p>
<figure>
<video class="video-shortcode" preload="" controls>
  <source src="https://i.imgur.com/bGLcQKl.mp4" type="">
  There should have been a video here but your browser does not seem to support it.
</video>
</figure>

<p>I’ll test this new feature in my workflow for a while to see if it’s actually useful. Meanwhile, I’d love to hear from the Obsidian community. What do you think about it? Do you have a better idea for my problem? Feel free to leave a message to me.</p>
]]></content:encoded></item><item><title>A look into Heptabase's split writing experience</title><link>https://reorx.com/blog/heptabase-split-writing/</link><pubDate>Tue, 19 Apr 2022 00:00:00 +0800</pubDate><guid>https://reorx.com/blog/heptabase-split-writing/</guid><description>The amazing UX Heptabase creates to help reading, thinking, and writing with heavy context efficiently</description><content:encoded><![CDATA[<p>Yesterday I found Heptabase&rsquo;s new introduction video: <a href="https://www.youtube.com/watch?v=oVXQx9sz_Wk">The 3-step knowledge workflow in Heptabase</a>.</p>
<p>Heptabase is the most promising note-taking app for me in recent years. The idea of combining bi-directional links, cards, and whiteboards together is mind-blowing. The word note-taking or knowledge base is not accurate to define it, in fact, it&rsquo;s more like a tool that aims for boosting the process of thinking.</p>
<p>Traditional note-taking tools are keen on providing more than enough ways for dumping your thoughts into notes, but lack methodologies on how to retrieve and use notes. The way Heptabase organizes and interacts with the data is what really empowers the user to think and write more efficiently.</p>
<p>Back to the video at the beginning, after watching it I noticed a very interesting feature. I can&rsquo;t stop thinking of it and really wish to have something similar in other note-taking or PMK tools like Obsidian (yeah, I&rsquo;m an Obsidian fan).</p>
<h2 id="the-feature">The feature</h2>
<p>Here&rsquo;s the walk though of that amazing feature:</p>
<ul>
<li>On a whiteboard, select several cards you would like to refer to <img loading="lazy" src="/blog/heptabase-split-writing/images/heptabase.png" type="" alt=""  /></li>
<li>Right-click on them and choose &ldquo;Open in new tab&rdquo;, a new page with a left-right split layout appears. The first card selected shows on the left; others show on the right as a folded list. <img loading="lazy" src="/blog/heptabase-split-writing/images/heptabase-1.png" type="" alt=""  /></li>
<li>Click on the <code>+</code> sign button on the top menu bar; a new card is created on the left side, and the card that previously shows on the left is automatically moved to the card list on the right.  <img loading="lazy" src="/blog/heptabase-split-writing/images/heptabase-2.png" type="" alt=""  /></li>
<li>You can fold/unfold notes while browsing the right side, and drag&rsquo;n drop blocks to copy them to the left side. <img loading="lazy" src="/blog/heptabase-split-writing/images/heptabase-3.png" type="" alt=""  /></li>
</ul>
<p>This is the best way I&rsquo;ve ever seen to think and write a note while referring to many other notes. In other note-taking tools, take Obsidian for example, I can split the panes, but it is not convenient to organize the workspace well for more than 3 notes. As new panes are growing, it will become more and more interruptive to switch back and forth.</p>
<p>Even if I managed to build up a tidy workspace, it&rsquo;s still very frustrating to switch to another context. Obsidian has the functionality to manage workspaces, but you have to manually save before switching, otherwise, the context will be missing. In Heptabase, all the workspaces are clearly shown in the sidebar, switching around is fast and easy as a breath.</p>
<h2 id="conclusion">Conclusion</h2>
<p>It&rsquo;s a shock to me how easy and intuitive it could be in Heptabase to create a workspace and focus on writing. I believe anybody with note-taking experience could learn something from Heptabase&rsquo;s design.</p>
<p>Obsidian is still the best PKM tool for me. I&rsquo;m going to create a plugin to achieve a similar workflow in Obsidian, possibly by creating a special pane that can add and show notes in an accordion. Stay tuned for the updates.</p>
]]></content:encoded></item></channel></rss>