<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>周以安 的博客</title><description>写构建、交付和做小产品的一些记录</description><link>https://dev-demo.zanesites.com/</link><item><title>独立开发第三年：我删掉了 60% 的功能</title><link>https://dev-demo.zanesites.com/blog/indie-year-three/</link><guid isPermaLink="true">https://dev-demo.zanesites.com/blog/indie-year-three/</guid><description>回头翻三年的产品数据，发现大多数功能一年也没人点几次。这是一次痛快但不轻松的大扫除。</description><pubDate>Sun, 02 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;今年夏天我做了一件很反直觉的事：把 Beacon 里 60% 的功能删了。&lt;/p&gt;
&lt;h2&gt;起因&lt;/h2&gt;
&lt;p&gt;有天晚上我导出了过去一年的使用记录，按功能统计点击次数。结果很安静——排名后六成的功能，加起来的使用量还不到总量的 5%。它们每一个我都花过一两周去做，每一个都在维护成本里默默吃掉我的时间。&lt;/p&gt;
&lt;h2&gt;怎么删&lt;/h2&gt;
&lt;p&gt;删功能比加功能难，我给自己定了三条规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;先下线入口，不删代码。&lt;/strong&gt; 入口藏起来观察两周，没人来问再动代码。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;老用户的数据永远可导出。&lt;/strong&gt; 删功能不能让人觉得被抛弃。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;每删一个，写一句话说明原因。&lt;/strong&gt; 写不出原因的，说明我也没想清楚，先留着。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;之后发生了什么&lt;/h2&gt;
&lt;p&gt;页面加载快了一倍，客服邮件少了三分之一，最意外的是：续费率反而涨了两个点。我的理解是，产品终于&quot;看起来在做一件事&quot;了。&lt;/p&gt;
&lt;p&gt;当然也有后悔的时候。有位用户来信问某个小功能去哪了，我又给他单独开了回来。这没关系——独立开发的好处就是，可以为一个人改变主意。&lt;/p&gt;
</content:encoded></item><item><title>在微信小程序里做离线的自然语言记账</title><link>https://dev-demo.zanesites.com/blog/offline-nlp-bookkeeping/</link><guid isPermaLink="true">https://dev-demo.zanesites.com/blog/offline-nlp-bookkeeping/</guid><description>说一句&quot;午饭 28 面&quot;就能入账，而且不联网也能用。拾光账本的解析器是怎么写的。</description><pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;拾光账本最受欢迎的功能是&quot;一句话记账&quot;：输入&quot;昨天打车 35&quot;，自动识别成日期、分类、金额。很多人以为背后是大模型，其实它是一个 200 行左右的规则解析器，跑在小程序端，完全离线。&lt;/p&gt;
&lt;h2&gt;为什么不用大模型&lt;/h2&gt;
&lt;p&gt;记账是高频、低价值的动作。一次调用慢半秒、贵一分钱、需要网络，用户都会觉得烦。而且账目内容敏感，能不出手机就不出手机。&lt;/p&gt;
&lt;h2&gt;解析器的三层&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;金额。&lt;/strong&gt; 找出最像金额的数字，处理&quot;28.5&quot;&quot;二十八&quot;&quot;两百块&quot;这类写法。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;时间。&lt;/strong&gt; 把&quot;昨天&quot;&quot;上周五&quot;&quot;前天晚上&quot;换算成具体日期，默认今天。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;分类。&lt;/strong&gt; 一份只有 300 多个词的词表，按最长匹配归类；匹配不到就放&quot;其他&quot;，并记住用户之后的手动修改。&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;export function parse(input: string, now = new Date()) {
  const amount = extractAmount(input);
  const date = extractDate(input, now);
  const category = matchCategory(input) ?? learned.get(input) ?? &quot;其他&quot;;
  return { amount, date, category, note: input.trim() };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;最大的收获&lt;/h2&gt;
&lt;p&gt;&quot;能学习&quot;比&quot;能识别&quot;重要。用户改过一次分类，下次同样的说法就自动对了。三个月后，准确率从 82% 涨到了 94%，靠的不是更聪明的算法，而是用户自己的习惯。&lt;/p&gt;
&lt;p&gt;小程序的包体积限制很严，这个方案整个解析器压缩后不到 8KB，对我来说这就是最大的优点。&lt;/p&gt;
</content:encoded></item><item><title>SQLite 够用了：单文件部署笔记</title><link>https://dev-demo.zanesites.com/blog/sqlite-is-enough/</link><guid isPermaLink="true">https://dev-demo.zanesites.com/blog/sqlite-is-enough/</guid><description>夜航是一个单二进制加 SQLite 的 RSS 阅读器。记录为什么选 SQLite，以及怎么让它在小机器上稳定跑。</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;夜航的目标很明确：让人在一台最便宜的小服务器上，一条命令就跑起来一个 RSS 阅读器。不要 Redis，不要 Postgres，不要消息队列。所以从第一天起我就选了 SQLite。&lt;/p&gt;
&lt;h2&gt;几个必开的设置&lt;/h2&gt;
&lt;p&gt;SQLite 默认配置不适合 Web 服务，下面这几行几乎是标配：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;WAL 模式让读写互不阻塞，这是它能撑住&quot;抓取任务在后台写、用户在前台读&quot;的关键。&lt;/p&gt;
&lt;h2&gt;备份其实很简单&lt;/h2&gt;
&lt;p&gt;很多人担心单文件不安全。夜航内置了一个定时任务，用 &lt;code&gt;VACUUM INTO&lt;/code&gt; 每天生成一份一致性的备份，保留最近七天。恢复就是把文件拷回去。比起运维一套数据库，这个方案出错的地方少太多了。&lt;/p&gt;
&lt;h2&gt;它的边界&lt;/h2&gt;
&lt;p&gt;我给自己划了线：单用户或几十人的小团体，写入并发不高。真有一天需要多实例横向扩展，那说明夜航已经不是现在的夜航了，到时候再换也不晚。&lt;/p&gt;
&lt;p&gt;不到 40MB 的内存占用，一年没出过一次数据问题。对一个个人项目来说，这就是够用。&lt;/p&gt;
</content:encoded></item><item><title>把 tidemark 的构建从 38 秒压到 4 秒</title><link>https://dev-demo.zanesites.com/blog/tidemark-build-4s/</link><guid isPermaLink="true">https://dev-demo.zanesites.com/blog/tidemark-build-4s/</guid><description>一个开源 CLI 的构建为什么会慢到让人想关电脑，以及我分三步把它压到 4 秒的过程。</description><pubDate>Fri, 18 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;tidemark 是我维护的一个小工具，把 Markdown 转成公众号能直接粘贴的 HTML。代码量不大，但 CI 里的构建要跑 38 秒，本地改一行主题样式也要等十几秒。对一个&quot;调样式&quot;的工具来说，这个反馈速度是致命的。&lt;/p&gt;
&lt;h2&gt;先量，再改&lt;/h2&gt;
&lt;p&gt;我没有一上来就换构建工具，而是先给每个阶段打了时间戳：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;time pnpm build --reporter=verbose
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果很扎心：真正的编译只占 6 秒，剩下的时间都花在两件事上：&lt;strong&gt;每次都重新解析 12 套主题的 CSS&lt;/strong&gt;，以及&lt;strong&gt;把测试快照一起打进产物&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;三步优化&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;主题预编译。&lt;/strong&gt; 主题 CSS 在发布前就编译成 JSON，运行时只读不算。这一步省掉 19 秒。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;拆掉不该打包的东西。&lt;/strong&gt; 快照和 fixtures 从构建入口里剔出去，产物体积小了 70%。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;增量缓存。&lt;/strong&gt; 用文件内容哈希做缓存键，主题没改就直接复用，本地热更新降到 300 毫秒左右。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;一个小教训&lt;/h2&gt;
&lt;p&gt;回头看，最大的收益来自&quot;不做事&quot;，而不是&quot;把事做快&quot;。很多慢构建都是在重复做一件不需要重复的事。下次觉得构建慢，先别急着换工具，打开时间戳看一眼。&lt;/p&gt;
&lt;p&gt;现在 CI 的构建稳定在 4 秒上下，我也终于敢在咖啡凉掉之前把主题调完了。&lt;/p&gt;
</content:encoded></item><item><title>用 Workers 做多地区探测，踩过的五个坑</title><link>https://dev-demo.zanesites.com/blog/workers-multi-region-probe/</link><guid isPermaLink="true">https://dev-demo.zanesites.com/blog/workers-multi-region-probe/</guid><description>Beacon 的探测节点跑在 Cloudflare Workers 上。这是上线一年里遇到的五个坑和对应的解法。</description><pubDate>Sun, 21 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Beacon 需要从多个地区同时探测用户的接口，Workers 天然适合：部署一次，到处都有节点。但&quot;适合&quot;不等于&quot;没坑&quot;。&lt;/p&gt;
&lt;h2&gt;坑一：探测请求被当成爬虫拦掉&lt;/h2&gt;
&lt;p&gt;不少用户的接口前面有 WAF。我们给探测请求带上固定的 &lt;code&gt;User-Agent&lt;/code&gt; 和一个可公开查询的 IP 段说明页，用户一键就能加白名单。&lt;/p&gt;
&lt;h2&gt;坑二：Worker 的 CPU 时间不是墙钟时间&lt;/h2&gt;
&lt;p&gt;一次探测等响应可能要 8 秒，但这段等待不算 CPU 时间。反过来，解析一个大响应体才会真正吃 CPU。所以我们只读响应头和前 4KB，足够判断状态。&lt;/p&gt;
&lt;h2&gt;坑三：时间戳别信本地&lt;/h2&gt;
&lt;p&gt;多地区汇总时，各节点时钟有微小偏差。统一用中心节点收到结果的时间排序，节点上报的时间只用来算耗时。&lt;/p&gt;
&lt;h2&gt;坑四：重试要有上限，还要有间隔&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;const delays = [0, 1500, 4000];
for (const delay of delays) {
  await sleep(delay);
  const res = await probe(target);
  if (res.ok) return res;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三次失败才算真的挂了，否则一次网络抖动就会让用户半夜收到告警。&lt;/p&gt;
&lt;h2&gt;坑五：告警疲劳&lt;/h2&gt;
&lt;p&gt;同一个故障每分钟发一次通知，用户很快就会把我们静音。现在是：故障开始通知一次，持续 30 分钟再提醒一次，恢复时通知一次。就这三次。&lt;/p&gt;
&lt;p&gt;做监控产品最大的体会：&lt;strong&gt;少打扰用户，比多发现问题更难。&lt;/strong&gt;&lt;/p&gt;
</content:encoded></item></channel></rss>