MoreRSS

site iconGamea | 飘逝的风修改

游戏后台开发十多年, 目前就职于Tencent。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Gamea | 飘逝的风的 RSS 预览

微信公众号文章自动收藏:Readwise + 自动化抓取完整方案

2025-10-29 08:00:00

Featured image of post 微信公众号文章自动收藏:Readwise + 自动化抓取完整方案

你是否像我一样,在日常碎片化时间中翻看公众号,即便遇到不错的文章,但是往往没时间深入细看,或者看过却回头就忘记了?仿佛我们看了个热闹而已,有多少内容转换为有效的输入了呢?本文分享如何用 Readwise Reader 打造个人知识管理系统,实现公众号文章的自动抓取、智能分类和深度阅读。

背景

日常生活中,关于知识的更新,除了一些主动订阅的 Feed 流外,公众号的一批还不错的文章时常能让我扩大一些视野。有人说在短视频热之后,以往做公众号自媒体的一批人切过去了,仍然坚持留下来继续写公众号的人,反而让这块天地有了更纯粹和更高质量了,事实是否如此我不知道。我自己也写公众号,不求有多少人关注,也不想跟随热点,但愿能以文会友,交结到一些志同道合的朋友。

在我日常吃饭、如厕等时候,会翻看一些公众号,有一些较长的文章或者技术密度高的文章,为了不错失某些内容,我常将它悬浮可以随时调出来细读,但咱不明白微信为何限制只能稍后读5篇文章,并且几篇文章的切换和操作体验极差。

有些适合深度阅读的好文,它和我们日常使用手机的方式是冲突的。我个人更喜欢在电脑上,或者在专用软件上查看。我们可以划线留言,可以高亮备注。或许这样,先前的"看过"才不至于流于形式。

Readwise & Reader简介

有个同事给我推荐了 Readwise,我发现它非常好地命中了我的诉求。它的功能非常简单,但却超级实用,如同它的宣传语:

Get the most out of what you read Readwise makes it easy to revisit and learn from your ebook & article highlights.

这个方案解决了我们信息获取和处理的几个关键点,我称之为串联或连接。

Reader主界面

比如,它有浏览器插件,安装后我们可以方便地对正在阅读的网页内容划线、高亮或备注,这一切就会进入到你的 Readwise 账号中。还有一个副产品Reader,我觉得更赞的是,它支持将某个 URL 自动抓取,支持播客,支持传统 RSS,还支持youtube等视频直接在Reader 中观看,字幕同步。当然还可以导入 PDF、EBOOK等,在这里进行更深入的阅读和处理。 在 Reader 中观看youtube

这样,信息的获取和快速处理便解决了。接下来工具不止于此,Readwise 除了提供我们划线或备注的 Review 功能后,更是可以将我们的这些内容与其它外部工具结合,比如相关内容自动同步到 Notion 中。 Readwise笔记等自动导出到Notion 不止是 Notion,几乎市面上常见的主流的笔记软件它都可以导出,像印象笔记、Obsidian甚至 Apple Notes。还有热门的 MCP 等都支持,我只能惊叹。

但显然这是一篇技术文章,不是做广告。毕竟 Readwise出身于国外,互联网平台的技术封锁没有那么强,这个东西好归好,在国内却会有点水土不服了。比如微信公众号文章,你会发现给它链接后,它可能抓取不完整或者缺图片等,怎么办呢?继续往下看呗!

方案一:成熟的第三方工具wechat2reader

咱不是爱造轮子的人,遇上问题当然搜索过。找到这个工具,试用了一下效果还不错。 使用第三方Wechat2Reader工具

但我发现好像也有一些小问题,比如摘要不合理,有一些较长的文章,它不是对全文的摘要,只是文章部分内容的复述。其次标签不准确或者没有,对于快速 Get 文章主要内容的人,有时会观察一下相关标签,如上文,似乎没有生成任何标签,我推测是原文没有标签,所以在抓取保存入 Reader 后也没有标签。最后,它还是比较人性化地提供了 15 天免费试用的机会,但再想要使用,需要支付一定费用(好像年费 45 元,我已经付过表示支持作者)。当然如果你格外介意隐私,可能也会有一些小担心。

那么,作为一个程序员,是时候自己动手来解决这个问题了。

方案二:自动化抓取和导入到 Reader

我希望对于这些文章的抓取是离线的,是Headless的。我的整体思想是先用成熟的组件自己组装一个相关功能,再考虑局部优化。整体交互希望和方案一类似的比较简单直接。

1. 文章抓取和图片处理

之前用过一阵firecrawl MCP来抓取网页,能力还不错,所以我先用它来抓取。在实践之前,咱们先基于MCP来测试一下效果。

Firecrawl 抓取结果示例

1
2
3
目前这个项目在 GitHub 上已经收获了 **39.8k star**。且仍在快速增长。

![](data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='1px' height='1px' viewBox='0 0 1 1' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3E%3C/title%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' fill-opacity='0'%3E%3Cg transform='translate(-249.000000, -126.000000)' fill='%23FFFFFF'%3E%3Crect x='249' y='126' width='1' height='1'%3E%3C/rect%3E%3C/g%3E%3C/g%3E%3C/svg%3E)

结果不如人意,部分图片可以抓取到,但有部分图片显示为占位符。原因是微信公众号的部分图片是懒加载的,需要滚动到可见区域才会加载。既然firecrawl不行,有没有更强大的工具呢?请求AI支招,它推荐了Scrapeless,咱们来试试。通过一番注册与认证后,获得了免费试用额度。官方没有提供直接的MCP server,不过我把它的开发文档丢给AI,几分钟就搞定了。

通过Scrapeless解决上面图片问题的方式,主要是模拟了浏览器滚动后使图片可见,从而抓取到图片。当然我们还要将抓取的HTML中的占位符替换为最终图片:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
 /**
 * 修复懒加载图片,替换SVG占位符为真实图片URL
 *
 * 微信公众号使用懒加载,滚动前图片为SVG占位符,
 * 真实URL存储在 data-src 等属性中
 *
 * @param {Object} articleContent - Cheerio 选择器对象
 * @param {Object} $ - Cheerio 实例
 */
 fixLazyImages(articleContent, $) {
 // 查找文章中的所有图片标签
 const images = articleContent.find('img');
 let fixedCount = 0;

 // 遍历每个图片,检查并修复懒加载占位符
 images.each((_i, img) => {
 const $img = $(img); // 包装为 jQuery 风格选择器
 const src = $img.attr('src') || '';

 // 检查是否是SVG占位符(懒加载标志)
 if (src.includes('data:image/svg+xml')) {
 // 尝试从常见的懒加载属性中获取真实图片URL
 const realSrc = $img.attr('data-src')
 || $img.attr('data-original')
 || $img.attr('data-lazy-src');

 if (realSrc) {
 $img.attr('src', realSrc);
 fixedCount++;
 this.log(` ✅ 修复图片: ${realSrc.substring(0, 80)}...`);
 } else {
 // 如果没有找到真实URL,尝试从其他 data-* 属性中查找
 const attrs = Object.keys($img.attr());
 for (const attr of attrs) {
 if (attr.startsWith('data-') && $img.attr(attr).startsWith('http')) {
 $img.attr('src', $img.attr(attr));
 fixedCount++;
 this.log(` ✅ 修复图片 (从${attr}): ${$img.attr(attr).substring(0, 80)}...`);
 break;
 }
 }
 }
 }
 });

 if (fixedCount > 0) {
 this.log(`📸 共修复 ${fixedCount} 张图片`);
 } else {
 this.log('⚠️ 未发现需要修复的懒加载图片');
 }
 }

使用Scrapeless抓取网页

我们这里使用的是现成的服务,我看了一下抓取一次网页的成本很低,一次请求几分钱。 Scrapeless抓取网页成本

现在抓取的内容已经可以正常显示了,还有一些元信息如文章发布时间等因为不在Content中,需要额外一点小处理即可。接下来我们需要将内容导入到Reader中。

2. 提取摘要及标签

上面我将抓取功能封装为一个 MCP server 了,现在要调用它并且将相关返回进一步处理。比如摘要,标签等通过 LLM 智能生成。考虑到作为 server 的可测试及调试性,我使用 GO 来实现它。代码已经不重要(若有人有需求未来可公开于 GitHub),我们看一下提示语即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
你是一名严谨的网页内容解析器。请从给定 HTML 中提取文章字段,并输出唯一一个 JSON 对象。除 JSON 外不要返回任何多余文字。

【任务目标】
- 从HTML提取适合Readwise Reader的字段,生成干净的article HTML与结构化元信息
- 不臆造:未知则置为null
- 保留内容语义与层级

【重要时间信息】
- 抓取时间:2024-05-10T14:30:00+08:00
- 当前年份:2024年(时区:CST +08:00)

【输入】
- 页面URL:https://example.com/article
- 元信息Metadata:
{
 "title": "示例标题",
 "author": "作者名"
}
- HTML内容:
<html>...</html>

【输出JSON字段定义】
{
 "url": string,
 "should_clean_html": boolean,
 "title": string|null,
 "author": string|null,
 "published_date": string|null,
 "image_url": string|null,
 "summary": string|null,
 "category": "article"|"email"|"rss"|"highlight"|"note"|"pdf"|"epub"|"tweet"|"video"|null,
 "tags": string[],
 "notes": string|null,
 "location": "new"|"later"|"archive"|"feed"|null,
 "saved_using": string|null
}

【提取规则】
1. url:使用输入的URL
2. should_clean_html:设为true,让API自动清理HTML
3. title:优先级:og:title > title标签 > h1标签 > 从内容推断
4. author:优先级:og:article:author > meta[name="author"] > byline文本 > 作者署名

6. published_date(重要):
 a) 优先查找meta时间:article:published_time、publishdate、date 等
 b) 在正文中匹配日期:'YYYY年MM月DD日'、'YYYY-MM-DD'、'MM月DD日HH:MM' 等;'今天/昨天/前天'等需换算
 c) 年份缺失时,使用当前年份:2024年
 d) 输出格式:严格ISO8601,且使用东八区(+08:00),如:YYYY-MM-DDTHH:MM:SS+08:00
 e) 若完全无时间信息,则置为null

7. image_url:优先级:og:image > twitter:image > 第一张内容图片(必须绝对URL)
8. summary:基于文章内容生成客观概述,2-4句话
9. category:按内容类型判断,通常为'article'
10. tags:从标题和内容中提取3-8个标签(字符串数组)
11. location:固定设为'new'
12. saved_using:固定设为'web_extractor'

【重要提醒】
- published_date:当出现'X月Y日Z:Z'时,年份必须使用当前年份;最终必须为+08:00的ISO8601
- 所有URL转换为绝对URL
- 不要臆造,不确定设为null
- 严禁在输出JSON中包含原始HTML或Markdown内容(不要输出 html 字段)

【输出要求】
- 仅返回合法JSON对象,字段与类型严格符合Readwise Reader API
- published_date为完整ISO8601(+08:00)或null
- 不要包含 html 字段;should_clean_html=true;location="new";saved_using="web_extractor"

最开始我犯了个错误,提取用时很长,仔细一看,它还返回了文章本体内容,这也难怪!千万别像我这样又费时又费钱,所以提示语中限制了某些字段不返回。上面我们让AI的输出完全按照Readwise API的文档来,这样便于接下来使用。

3. 将文章导入到Reader

Readwise 提供了 API 可以助于我们将现成的 HTML 导入到它 Reader 中,它的文档在这里:Readwise API。借助于 AI,我把文档链接丢给它,没一会儿它就完成了相关接口封装,这时我连文档都还没看完,这颇有点温酒斩华雄的感觉。如果你的 AI 不能正常读取到文档,前面提过的 firecrawl mcp 不失为一个好选择,我经常借用它来访问某些文档。

代码写好,单元测试也看起来一切正常,哐当~导入成功!我们去 Reader 中看一看,咦,怎么文章的封面是这个玩意?它提示微信不允许展示,而文章里面的图片一切正常了,这是为什么呢?(此处不放图了,文章我还想在微信公众号发出呢:D)

显然直接怀疑对象就是微信限制了其它来源的访问,不过咱们文章内部又能显示图片?直接F12分析,猜想是正确的。 Reader Referer

可是蹊跷的是:我使用方案一中第三方解决方案,它的图片能正常显示。我进一步分析了一下,它的封面图片域名和我的不一样,是 wework.qpic.cn,我推测它们是将封面图片推到另一个自己可控的地方(不受 Referer 限制),然后修改了原文章的图片 URL,这样我们就可以正常显示图片了。咱也不是不能效仿,不过这会有一定成本。

还有一个蹊跷的事,尽管我没处理封面图片,但在手机的 Reader 客户端,它显示是正常的,会自动使用第一张图片(Why?)作为封面。

如果仅是电脑上有此问题,要解决这个就有其它方法了。网上看关于此问题的相关文章: https://an.admirable.pro/wechat-platform-referer/,我们借助于浏览器插件 Referer Control,添加一个规则将访问微信域名的请求的Referer设置为https://mp.weixin.qq.com,刷新后就可以正常显示图片了。

4. 通过企微触发抓取

为了更容易将公众号文章分享后保存,方案一中使用转发到企业微信的某个账号,自动保存。我尝试自建了一个企业,创建了一个自建应用,可以接收输入的消息并通过 Webhook 交给后端处理。 给应用设置webhook回调

之后我们完善服务器处理相关消息的逻辑即可,这块涉及消息加解密直接使用现成的 GO 包比较好,AI 可能尝试自己裸写相关逻辑,纯增加调试的工作量了。完成之后,当我将一个链接发给企业微信这个应用时,它便触发了上面我们的抓取文章以及导入到 Reader。我以为就要搞定收工了,但发生了意外。当我想通过微信添加这个应用,发现不可能,这条路不通。之后我又创建一个成员账号等,通过某种 hack 可以通过微信将消息发送到内部,但又不提供 API 访问和处理这类消息。准确的说不是不提供,需要企业认证,并且还需要额外付费,它这个功能叫会话内容存档。我理解微信限制这块的道理,避免虚假信息(企业)对微信生态的影响。

这条路走得戛然而止,现在我只能企业内部使用了,复制链接并且贴到内部应用号上,才能完成我的文章收藏功能。

这是这篇文章拖了很久的一个原因,没找到突破口。我还想找一个更好的途径便于一键分享,如果你有方法欢迎留言告知于我,感谢。

5. 效果展示

通过以上工作,我们算是按自己的想法实现了文章的抓取和导入到 Reader。我们来看一下效果: 效果展示

可以看到针对文档生成了摘要和标签等,这相比于第三方的方案一定程度满足了自己的诉求,关键是咱是免费的呢!

后记

本文主体内容到这里就结束了,感谢阅读。上面相关的代码都已经在GitHub开源了,如果你觉得有诚意,请给我的文章点个赞或在看呀!感谢感谢!

上文文章提到 Readwise,它的用途不止于此,比如我尝试统一管理邮件,邮件作为每天处理的信息流之一也是不错的,避免我经常漏掉某些重要通知。悄悄告诉你,Readwise虽然是收费的,我朋友说还提供了发展中国家的优惠,发个邮件很快申请到了,50% OFF。至于推广链接啥的,我太懒就不留了:)

本文介绍的方法,不光适用于微信公众号,只要是一个网页都适用,平常我还会看知乎等平台的文章,但知乎是有登录态限制的,如何将知乎文章保存呢?如果你也有兴趣,我也有一些想法,欢迎联系我探讨一下。

欢迎关注我,说不定后续就会有你想要的内容到来。下回见!

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风

扫码关注公众号

解放双手的AI绘图:三种方式让AI帮你绘制Excalidraw图表

2025-08-31 08:00:00

Featured image of post 解放双手的AI绘图:三种方式让AI帮你绘制Excalidraw图表

难得出游,一边吹着海风,看着AI帮我在一片空白的白板上慢慢添加各种元素,也是另一种触感。牛马在驱动牛马 :)

前言

在我使用Obsidian后了解到了绘图工具excalidraw,我曾经用它画了一些图,手绘风格在众多图表呈现中有它独特的味道。作为一个开源且结构清晰的绘图文件,我们可以借助AI来帮我们绘制想要的图,这里我有一些实践和尝试过程,分享给大家,若对你有用请一键三连啊,至少点赞行不?

本文分享的自动绘图不止适用于在Obsidian的Excalidraw插件中,同时也支持网页端(如官方)自动绘图。

原理解释

我在Obsidian中随便画了一个图,这个文件从文本视角内容大概是这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
---

excalidraw-plugin: parsed
tags: [excalidraw]

---
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠== You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'


# Excalidraw Data

## Text Elements
Hello ^vcBSVLYg

world ^SroqyGoR

kevin ^OpoVzdNT

%%
## Drawing
```compressed-json
N4KAkARALgngDgUwgLgAQQQDwMYEMA2AlgCYBOuA7hADTgQBuCpAzoQPYB2KqATLZMzYBXUtiRoIACyhQ4zZAHoFAc0JRJQgEYA6bGwC2CgF7N6hbEcK4OCtptbErHALRY8RMpWdx8Q1TdIEfARcZgRmBShcZQUebQBGAFZtAAYaOiCEfQQOKGZuAG1wMFAwMogSbghsQgoAaQB9ADkmlIpiACUAdgAZADEAKQBOZQAtZwaedLLIWEQqwOwojmVg
省略后续...

将这个文件丢给AI分析了一下,给了如下的描述:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Excalidraw .excalidraw.md 格式说明

- 用途:由 Obsidian Excalidraw 插件生成/维护的绘图文件(Markdown 包装 + 压缩场景数据)。
- 头部 Front matter
 - `excalidraw-plugin: parsed`:标记已被插件解析
 - `tags: [excalidraw]`:便于检索
- 操作提示行
 - “Switch to EXCALIDRAW VIEW…”:提示在 Excalidraw 视图中查看/编辑;命令面板支持“Decompress current Excalidraw file”。
- 数据区 Excalidraw Data
 - Text Elements:画布上的文本清单
 - 每行形式:`文本内容 ^块ID`
 - 作用:可用 Obsidian 块链接引用/嵌入该文本
 - 引用:`[[路径/文件#^块ID]]`
 - 嵌入:`![[路径/文件#^块ID]]`
 - `%% ... %%` 注释包裹
 - Obsidian 注释语法;用于隐藏机器数据,避免在普通 Markdown 视图中干扰阅读。
 - Drawing(压缩数据)
 - 代码块标记:```compressed-json
 - 存放压缩后的场景(形状/连线/样式/坐标等)
 - 可在命令面板解压为可读 JSON;插件设置 Saving 可配置是否压缩。
- 编辑建议
 - 不要手工改动 `compressed-json` 内容;请在 Excalidraw 视图中编辑,以免损坏绘图。
- 常用操作
 - 打开绘图:More options → Open in Excalidraw view
 - 解压/压缩:命令面板对应命令或插件设置 Saving
 - 引用示例(来自本文件):
 - 引用文本块:`[[Excalidraw/Drawing 2025-08-23 16.22.25.excalidraw.md#^vcBSVLYg]]`
 - 嵌入文本块:`![[Excalidraw/Drawing 2025-08-23 16.22.25.excalidraw.md#^SroqyGoR]]`
- 本文件中的文本元素
 - "Hello" `^vcBSVLYg`
 - "world" `^SroqyGoR`
 - "kevin" `^OpoVzdNT`

既然内容只是一个JSON,有压缩标识也可以不压缩,那么让AI帮我们生成内容难度就不大了。 我们先看看市面上当前有哪些方案以及它们的特点。

初阶尝试:借助于mcp_excalidraw绘图

我搜索到有个MCP server (mcp_excalidraw](https://github.com/yctimlin/mcp_excalidraw),可以简单的启用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 克隆代码
git clone https://github.com/yctimlin/mcp_excalidraw.git
cd mcp_excalidraw
npm install

# 构建前端
npm run build

# 启动canvas server
npm run canvas

现在我们打开浏览器,访问:http://localhost:3000 看到熟悉的Excalidraw绘图页面: excalidraw 点右上角的Sync to Backend后我们的前后台打通,即可准备mcp server来操作这个画布了。以下是mcp-server的配置(替换成你的本地路径):

1
2
3
4
5
6
{
 "excalidraw": {
 "command": "node",
 "args": ["/absolute/path/to/mcp_excalidraw/dist/index.js"]
 }
}

使用如下prompt:

请使用excalidraw mcp插件绘制一个太阳系及行星的运行图

选择一个合适的模型比如Claude-4-Sonnet,不一会儿我们就可以得到一个还不错的太阳系科谱示意图了: solar system 各个行星相对于地球的体积AI还挺讲究,大体是对了。

这个方案用的是自托管的服务器,将Excalidraw的画布作为Server开放出去,可以随时调用API来绘图,同时借助Mcp Server与之通信,完成了AI绘图的一个通路,不错的思路。 那么,既然在网页上绘图,咱能不能直接在 Excalidraw 官方绘图网站上直接使用AI绘图呢?

进阶实践:在 Excalidraw 官方网站AI绘图

我这里说的不是官方网站的AI绘图功能,它需要开通会员,而且我在免费试用期间就感觉到它还比较简单,AI画不了啥有用的图,但别慌,我们有黑科技! 我们先介绍一个强大的Chrome浏览器插件mcp-chrome插件:

🌟 让chrome浏览器变成你的智能助手 - 让AI接管你的浏览器,将您的浏览器转变为强大的 AI 控制自动化工具。

可以在GitHub根据文档下载安装,之后打开它:

mcp-chrome-install

可以看到它也是启用了一个MCP server,类似的我们将这个配置复制到任何一个你常用的能驱动MCP Server的工具,比如我是到Cursor的mcp.json中,顺手它改个名字叫mcp-chrome

1
2
3
4
5
6
7
8
{
 "mcpServers": {
 "mcp-chrome": {
 "type": "streamable-http",
 "url": "http://127.0.0.1:12306/mcp"
 }
 }
}

我们可以刷新一下工具,可以看到它有这些能力: mcp-chrome-tools

原来它的实现原理是向页面中注入一些脚本,之后通过HTTP和页面通信,并基于我们发给它的消息内容调用相应的页面工具。可是咱们是要在这个页面绘图呢,这就需要另一个东西 excalidraw-prompt了,你同样可以在上面的repo的prompt中获取到。我们可以简单看下内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
角色
你是一位顶级的解决方案架构师,不仅精通复杂的系统设计,更是Excalidraw的专家级用户。你对其声明式的、基于JSON的数据模型了如指掌,能够深刻理解元素(Element)的各项属性,并能娴熟地运用**绑定(Binding)、容器(Containment)、组合(Grouping)与框架(Framing)**等核心机制来绘制出结构清晰、布局优美、信息传达高效的架构图和流程图。

核心任务
根据用户的需求,通过调用工具与excalidraw.com画布交互,以编程方式创建、修改或删除元素,最终呈现一幅专业、美观的图表。

规则
注入脚本: 必须首先调用 chrome_inject_script 工具,将一个内容脚本注入到 excalidraw.com 的主窗口(MAIN)
脚本事件监听: 该脚本会监听以下事件:
getSceneElements: 获取画布上所有元素的完整数据
addElement: 向画布添加一个或多个新元素
updateElement: 修改画布的一个或多个元素
deleteElement: 根据元素ID删除元素
cleanup: 清空重置画布
发送指令: 通过 chrome_send_command_to_inject_script 工具与注入的脚本通信,触发上述事件。指令格式如下:
获取元素: { "eventName": "getSceneElements" }
添加元素: { "eventName": "addElement", "payload": { "eles": [elementSkeleton1, elementSkeleton2] } }
更新元素: { "eventName": "updateElement", "payload": [{ "id": "id1", ...其他要更新的属性 }] }
删除元素: { "eventName": "deleteElement", "payload": { "id": "xxx" } }
清空重置画布: { "eventName": "cleanup" }
遵循最佳实践:
布局与对齐: 合理规划整体布局,确保元素间距适当,并尽可能使用对齐工具(如顶部对齐、中心对齐)使图表整洁有序。
尺寸与层级: 核心元素的尺寸应更大,次要元素稍小,以建立清晰的视觉层级。避免所有元素大小一致。
配色方案: 使用一套和谐的配色方案(2-3种主色)。例如,用一种颜色表示外部服务,另一种表示内部组件。避免色彩过多或过少。
连接清晰: 保证箭头和连接线路径清晰,尽量不交叉、不重叠。使用曲线箭头或调整points来绕过其他元素。
组织与管理: 对于复杂的图表,使用**Frame(框架)**来组织和命名不同的区域,使其像幻灯片一样清晰。

将它复制到你的prompt中,如果在Cursor等编辑器中,另存为一个文件,一会引用它。现在我们就准备就绪了,测试一下:

请参考 @excalidraw-prompt.md ,调用mcp-chrome在excalidraw中帮我绘制一个太阳系的示意图

你会发现浏览器自动打开了excalidraw.com页面并且LLM正在尝试注入控制脚本等,如果顺利,一会便能在白板上陆续出现各种元素了。但也可能不幸运,卡在首页中,注入脚本不成功。经过我分析,这种情况有两种可能:

  1. 你用的模型智能度不够,换一个模型。最近在Cursor中免费的grok-code-fast-1也能基本完成任务。使用Gemini2.5 Pro或Claude4也完全没问题。
  2. 你访问的网页被重定向到了app.excalidraw.com。因为如你有登录态,网站会重定向到app.excalidraw.com,那个页面插件就找不到正确的注入点了。有两种做法,修改脚本让它兼容,或者修改一下我们的prompt,让打开 excalidraw.com/#noredirect页面。

使用mcp-chrome+prompt绘制太阳系

这个插件如果只是画图,基本使用就是这样,但它提供了浏览器的操作能力,所以还有不少玩法,比如总结某些页面内容等,或者将页面的内容以excalidraw可视化呈现。 那么如果你像我一样,不信任(不好用)云上存放笔记内容,习惯于本地化保存笔记,接下来咱当然要追求下能否直接在Obsidian中作图。

终局方案:在Obsidian绘制Excalidraw图

虽然我们使用mcp-chrome+prompt在excalidraw.com上绘图很方便,但仍然需要浏览器操作,甚至还需要一点儿时间等待打开浏览器和注入脚本等动作。如开篇所说,Excalidraw的文件格式是比较简单的,并且Obsidian有插件可以直接绘制或呈现它的,那么如何在Obsidian中更便捷使用呢?

我们让AI先基于之前分析的Excalidraw文件格式,生成一套提示词:

请参考 @excalidraw格式说明.md ,帮我生成一个用于指导 AI 生成相关excalidraw文件的prompt 文件,取名为excalidraw绘图大师

没一会AI就给我们生成了一份不错的Prompt指南,想的还挺周到,绝对比我自己写的初版要好,请你也过目一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Excalidraw 绘图大师 - AI 绘图指导 Prompt

## 角色定义
你是一位专业的 Excalidraw 绘图大师,精通创建结构化、美观且实用的图表。你的任务是根据用户需求生成标准的 `.excalidraw.md` 格式文件,这些文件可以在 Obsidian Excalidraw 插件中完美使用。

## 核心能力
- 🎨 **视觉设计**:创建清晰、美观、逻辑性强的图表
- 📝 **文档结构**:严格遵循 Excalidraw 文件格式规范
- 🔗 **元素关联**:合理使用文本块ID,支持 Obsidian 块链接
- 🎯 **用户导向**:根据具体需求定制图表类型和风格

## 文件格式规范
省略更多规范及示例说明...

我们稍作一点小修改,原来Excalidraw的文件中有一部分 compressed-json字段,有压缩的格式后续让AI生成容易出错,我们将其改为不需要压缩,这样AI生成绘图文件一次成功率便高多了。现在我们只要一句话,比如:

请参考 @excalidraw绘图大师.md 帮我创建一个火箭发射的运行原理示意图

让AI来绘制火箭发射原理

这个办法简单有效,不用依赖任何的MCP Server也不用安装其它插件,但它需要我们使用Cursor等AI编辑器打开Obsidian的Vault目录。我们再贪心点,能不能直接在Obsidian中操作呢?也不是不行。

有一款插件叫obsidian-local-rest-api,可以让我们的Obsidian以REST API来提供接口:

This plugin provides a secure HTTPS interface gated behind api key authentication that allows you to:

  • Read, create, update or delete existing notes. There’s even a PATCH HTTP method for inserting content into a particular section of a note.
  • List notes stored in your vault.
  • Create and fetch periodic notes.
  • Execute commands and list what commands are available.

为了能够在Obsidian中直接调用AI绘制而不再依赖其它编辑器,我们还需要安装一个Obsidian插件 Smart Composer。它提供了和大模型聊天对话而修改笔记的功能,灵感来源于Cursor。同时也支持MCP Server。关于它的更多使用技巧,未来有空可以另起一篇文章。现在我们继续配置上:Obsidian mcp-server

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
 "mcp-obsidian": {
 "command": "uvx",
 "args": [
 "mcp-obsidian"
 ],
 "env": {
 "OBSIDIAN_API_KEY": "<your_api_key_here>",
 "OBSIDIAN_HOST": "<your_obsidian_host>",
 "OBSIDIAN_PORT": "<your_obsidian_port>"
 }
 }
}

然后和之前类似,我们只要让它参考我们的Prompt,说明要绘制什么即可,当然对现在有笔记进行总结并梳理出一些图表也是不在话下。

直接在Obsidian中绘图

后记

经过一番探索,相信你也大概感受到了基于AI来调用Excalidraw绘图并非难事。未来当基础模型越发强大,这块的上手难度可能会更低。 在实际体验中,不同模型的审美以及指令遵循上还是有不少差异的。使用时需要注意选择适合自己的模型,也需要注意花费。

本文借助于AI分析文件格式,又借助AI生成提示词,再借助AI调用MCP Server等,很多过往我们完全需要手搓的时刻已经越来越少,如何顺应这种思维,提供更多的价值和可能性,是未来我们值得探索的方向。

当然,模型的更新可以毁灭曾经的各种努力,但,在路上就是意义,是吧?

以上便是最近关于Excalidraw的一点折腾经验,希望对你有帮助。欢迎点赞、收藏、分享,更欢迎分享你的使用经验。我们下篇文章见。

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风

扫码关注公众号

如何优雅的让Claude Code使用第三方模型?

2025-07-20 08:00:00

Featured image of post 如何优雅的让Claude Code使用第三方模型?

最近Claude Code爆火,很多人都说Cursor不香了。无奈原生的Claude Code使用对国人来说特别不便,我这里尝试了一些新的解决方案,希望对你流畅使用Claude Code有帮助。

背景

Claude模型的母公司Anthropic,对国内用户使用限制特别多,我曾经注册或购买的几个号没多久就阵亡了。但人家的这个工具确实不错,我们没账号怎么办?

听说可以使用一些第三方模型了,比如最近国内月之暗面推出的Kimi-K2,它们机智的直接支持了Anthropic的API。我们可以简单配置一下,就可以在Claude Code中使用Kimi-K2模型了。 但是有时我们想使用其他更强大的模型,比如Gemini,怎么办?

我又继续寻找到了最近刚开源的一个解决方案,claude-code-router,它支持了多个模型,包括Gemini,DeepSeek,GPT等。这里又产生一个问题,可以使用原生Gemini模型,但Google家模型不能精确控制预算,哪天你哐哐用,金钱也哗哗出的时候,看到账单傻眼了怎么办?

以上各个问题,本文都会尝试给一个解决方案,如果对你有帮助,请帮我点个赞吧!

本文假定你会Claude Code的安装和使用,我们直接进入主题:如何优雅的让Claude Code使用第三方模型?

初步试水:借助kimi-k2使用第三方模型

这块已经有不少文章介绍了,我简单说几个关键点:

  1. 设置两个环境变量:
1
2
export ANTHROPIC_API_KEY=`<你的API KEY>`
export ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic
  1. 修改Claude Code的配置 ~/.claude.json 添加(你可能要先启动一次才会自动生成这个配置):
1
"hasCompletedOnboarding": true,

然后重启Claude Code,就可以使用Kimi-K2模型了。在里面似乎kimi做得足够兼容,你连模型都不用切换,直接就开箱即用了。我问了一句你是谁,看来Claude Code有被骗到:)

1
2
3
> 你是哪个模型

⏺ 我是 Claude Code,由 Anthropic 开发的官方 CLI 工具。我使用的是 Claude Sonnet 4 模型(具体版本 ID 是 claude-sonnet-4-20250514)。

kimi-k2

  1. 如果想更流畅使用,你可能至少需要充50元,不然那个限频等,体验应该会很差。

我感觉Kimi-K2这次挺“鸡贼”的,借了一波Claude Code的东风,应该引了不少新进,现在它官网开始提示繁忙起来了呢:)

我试着用了一阵Kimi-K2,有时候反应较慢,我在想是否可能把Gemini家的和OpenAI家的模型一起集成进来呢?方法当然是有的。

进阶集成:使用claude-code-router扩展你的模型库

在一两周前,我在寻找如何让Claude Code可使用更多种第三方模型。在搜索这个问题的解法,国外的Perplexity居然没有推荐这个项目,反倒是国内腾讯元宝给我介绍了有这样一个开源项目,claude-code-router(以下简称CCR)可能解决我的问题,一看到我甚为惊喜,我想莫不是这个项目是国人写的原因,咱离自己人更近。

A powerful tool to route Claude Code requests to different models and customize any request.

基本原理

在GitHub仓库中也写了项目的实现原理。简单的说,作者经过逆向分析后,发现在Claude Code中它在调用模型时,各个参数都是通过环境变量获取的,作者想到开发一个中间件,将各个环境变量替换掉,这样可以实现调用第三方模型。同时因为Claude Code使用Anthropic API的规范,我们需要将对第三方模型的调用转换成Anthropic API的格式。

安装

Claude Code类似通过npm即可安装。

1
2
3
4
5
# 安装
npm install -g @musistudio/claude-code-router

# 通过ccr启动claude code
ccr code

配置

你可以参考官方提供的示例,配置~/.claude-code-router/config.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
{
 "APIKEY": "your-secret-key",
 "PROXY_URL": "http://127.0.0.1:7890",
 "LOG": true,
 "Providers": [
 {
 "name": "openrouter",
 "api_base_url": "https://openrouter.ai/api/v1/chat/completions",
 "api_key": "sk-xxx",
 "models": [
 "google/gemini-2.5-pro-preview",
 "anthropic/claude-sonnet-4",
 "anthropic/claude-3.5-sonnet"
 ],
 "transformer": { "use": ["openrouter"] }
 },
 {
 "name": "deepseek",
 "api_base_url": "https://api.deepseek.com/chat/completions",
 "api_key": "sk-xxx",
 "models": ["deepseek-chat", "deepseek-reasoner"],
 "transformer": {
 "use": ["deepseek"],
 "deepseek-chat": { "use": ["tooluse"] }
 }
 },
 {
 "name": "ollama",
 "api_base_url": "http://localhost:11434/v1/chat/completions",
 "api_key": "ollama",
 "models": ["qwen2.5-coder:latest"]
 },
 {
 "name": "gemini",
 "api_base_url": "https://generativelanguage.googleapis.com/v1beta/models/",
 "api_key": "",
 "models": ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.5-flash-lite-preview-06-17"],
 "transformer": {
 "use": ["gemini"]
 }
 },
 {
 "name": "kimi",
 "api_base_url": "https://api.moonshot.cn/v1/chat/completions",
 "api_key": "",
 "models": [
 "kimi-k2-0711-preview"
 ],
 "transformer": {
 "use": [
 "cleancache"
 ]
 }
 },
 ],
 "Router": {
 "default": "deepseek,deepseek-chat",
 "background": "ollama,qwen2.5-coder:latest",
 "think": "deepseek,deepseek-reasoner",
 "longContext": "openrouter,google/gemini-2.5-pro-preview"
 }
}

这里定义了不同的Provider,并且有一些模型可以有其设置。比如为了让DeepSeek模型更积极使用工具,有个tooluse的设置。比如为了转换Gemini模型,有个gemini的Transformer。 同时可看到,它还真是国人开发,很有本地化特色,比如显式支持PROXY设置方便你访问某些模型。

到这里配置好后,当你在使用Claude Code时,想切换模型时,可以输入/model命令,然后选择你想要的模型。比如: 在Claude Code Router中使用Kimi-K2

不过有点遗憾的是,当前通过CCR中还不支持Web搜索和图片上传,这离我们想要的完整体还是有点距离,但官方已经在计划中,并且这个项目最近更新很频繁,Star也涨得非常快。

折腾到这里就结束了吗?这里发生了一件小事,让我觉得有必要继续折腾一下。我使用OpenRouter来调用Claude模型,为了省钱,我已经很勤俭地只用 claude-3.7-sonnet 了,但几轮对话下来,发现账单还是有点夸张。我在一个不算太大的项目中进行了/init和简要对话而已。虽然OpenRouter提供了对每个KEY的费用限制(Credit limit),但是如Google的Gemini等模型,它就没有可以限制额度,那就只能等收到账单才后知后觉了?

问题不大,我想起来之前折腾过LiteLLM,它不仅能聚合LLM接口,还能像个贴心管家一样帮你控制预算。就决定是你了,继续折腾!

终极控制:使用LiteLLM统一LLM及控制支出

很早前想写一篇LiteLLM+Librechat的教程,但一直没时间,今天就让LiteLLM先出场吧。我继续在k8s中部署它,如果你是容器或其它方式,请参考官方文档,部署过程都是简单的。

配置LiteLLM

我们创建一个configmap定义了LiteLLM的配置文件config.yaml,大概内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
apiVersion: v1
kind: ConfigMap
metadata:
 name: litellm-config
 namespace: ai
data:
 config.yaml: |
 # LiteLLM配置
 model_list:
 # 添加Anthropic模型
 - model_name: "openrouter/anthropic/claude-3.7-sonnet"
 litellm_params:
 model: "openrouter/anthropic/claude-3.7-sonnet"
 api_key: os.environ/OPENROUTER_API_KEY
 - model_name: "openrouter/openai/gpt-4.1"
 litellm_params:
 model: "openrouter/openai/gpt-4.1"
 api_key: os.environ/OPENROUTER_API_KEY
 - model_name: "gemini/gemini-2.5-flash"
 litellm_params:
 model: "gemini/gemini-2.5-flash"
 api_key: os.environ/GEMINI_API_KEY

 # 服务器配置
 server_settings:
 port: 4000
 environment: production
 cors_allow_origins: ["*"]
 prometheus_metrics: true

 litellm_settings:
 # check_provider_endpoint: true # 👈 Enable checking provider endpoint for wildcard models
 # 禁用guardrails相关功能
 enable_guardrails: false
 enable_guardrails_in_db: false

部署LiteLLM

接着定义一个Deployment即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
apiVersion: apps/v1
kind: Deployment
metadata:
 name: litellm
 namespace: ai
spec:
 selector:
 matchLabels:
 app: litellm
 replicas: 1
 template:
 metadata:
 labels:
 app: litellm
 spec:
 volumes:
 - name: config-volume
 configMap:
 name: litellm-config
 nodeSelector:
 kubernetes.io/hostname: models
 containers:
 - name: litellm
 image: ghcr.io/berriai/litellm:v1.74.0-stable
 args:
 - "--config=/app/config.yaml"
 volumeMounts:
 - name: config-volume
 mountPath: /app/config.yaml
 subPath: config.yaml
 env:
 - name: TZ
 value: "Asia/Shanghai"
 - name: DISABLE_SCHEMA_UPDATE
 value: "true"
 - name: LITELLM_LOG
 value: "DEBUG"
 - name: DATABASE_URL
 value: "postgresql://root:[email protected]:5432/litellm"
 - name: LITELLM_SALT_KEY
 value: "xxxx"
 - name: STORE_MODEL_IN_DB
 value: "True"
 - name: OPENAI_API_KEY
 value: "xxxx"
 - name: AZURE_API_KEY
 value: "xxxx"
 - name: GEMINI_API_KEY
 value: "xxxx"
 - name: DEEPSEEK_API_KEY
 value: "xxxx"
 - name: OPENROUTER_API_KEY
 value: "xxxx"
 - name: HTTP_PROXY
 value: "<your proxy>"
 - name: HTTPS_PROXY
 value: "<your proxy>"
 - name: NO_PROXY
 value: "localhost,127.0.0.1,postgresql.base-system.svc.cluster.local,postgresql,10.43.140.217"
 ports:
 - containerPort: 4000
 livenessProbe:
 httpGet:
 path: /health/liveliness
 port: 4000
 initialDelaySeconds: 40
 periodSeconds: 30
 timeoutSeconds: 10
 failureThreshold: 3
 resources:
 requests:
 cpu: 100m
 memory: 128Mi
 limits:
 cpu: 500m
 memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
 name: litellm
 namespace: ai
spec:
 selector:
 app: litellm
 ports:
 - protocol: TCP
 port: 4000
 targetPort: 4000
 type: ClusterIP

要注意LiteLLM启动时,有时资源消耗会比较高,我的弱鸡k8s节点时不时会给搞得濒死,最好像上面限制一下资源。 我们可以测试一下LiteLLM对外的接口是否正常,比如:

1
2
3
4
5
6
7
curl https://litellm.mysite.com/v1/chat/completions \
 -H "Content-Type: application/json" \
 -H "Authorization: Bearer $LITELLM_KEY" \
 -d '{
 "model": "gemini-2.5-flash",
 "messages": [{"role": "user", "content": "What is the capital of France?"}]
 }'

正常返回后,说明我们的LiteLLM服务工作正常。接下来我们就可以在claude-code-router中统一使用litellm作为唯一的Provider了。

在claude-code-router中使用litellm

现在,我们的config.json可以变得非常清爽,Providers里只留下litellm一个就行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
 "Providers": [
 {
 "name": "litellm",
 "api_base_url": "https://litellm.mysite.com/v1/chat/completions",
 "api_key": "xxxx",
 "models": [ "deepseek/deepseek-chat","deepseek/deepseek-reasoner","moonshot/kimi-k2-0711-preview","gemini/gemini-2.5-flash-lite", "gemini/gemini-2.5-flash", "openrouter/google/gemini-2.5-flash","azure/gpt-4.1", "gemini/gemini-2.5-pro","openrouter/anthropic/claude-3.7-sonnet"],
 "transformer": {
 "deepseek/deepseek-chat": { "use": ["maxtoken", { "max_tokens": 8192 }] },
 "gemini/gemini-2.5-flash-lite": { "use": ["cleancache"] },
 "gemini/gemini-2.5-flash": { "use": ["cleancache"] },
 "moonshot/kimi-k2-0711-preview": { "use": ["cleancache"] }
 }
 }
 ],
 "Router": {
 "default": "litellm,moonshot/kimi-k2-0711-preview",
 "background": "litellm,deepseek/deepseek-chat",
 "think": "litellm,deepseek/deepseek-reasoner",
 "longContext": "litellm,gemini/gemini-2.5-pro"
 }
}

这里要注意,如果直接对接官方的Gemini模型,只需要配置Gemini的Transformer即可。但这里咱们是通过LiteLLM调用的,还需要配置use: cleancache的Transformer。不然会报类似下面这样的错误:

⎿ API Error: 400 {“error”:{“message”:“Error from provider: {"error":{"message":"litellm.BadRequestError: VertexAIException BadRequestError - {\n \"error\": {\n \"code\": 400,\n \"message\": \"* GenerateContentRequest.contents: contents is not specified\\n\",\n \"status\": \"INVALID_ARGUMENT\"\n }\n}\n. Received Model Group=gemini/gemini-2.5-pro\nAvailable Model Group Fallbacks=None","type":null,"param":null,"code":"400"}}”,“type”:“api_error”,“code”:“provider_response_error”}}

还好LiteLLM的日志相当给力,我通过排查请求体,很快就定位到问题出在 “cache_control” 这个字段上——删掉它就一切正常了。最后我们可以在LiteLLM的管理端看到每次Claude Code发出了哪些请求,使用了多少Token,花费了多少钱等。 LiteLLM管理端

我们也可以在LiteLLM中创建的API_KEY中定义它的额度,这样避免我们不小心超支。 LiteLLM API_KEY额度

现在,让我们开心的在Claude Code中使用各种模型吧!

总结

本文介绍了三种方式让你更好的基于第三方大语言模型来使用Claude Code,希望对你有所帮助。我们除了直接使用Kimi-K2外,还可以使用CCR来扩展模型库,最后通过LiteLLM来统一LLM的调用,这样也能让我们更精细化的观察Token的使用以及控制费用。

三种方案对比:

特性 方案一:Kimi 直连 方案二:CCR 方案三:CCR + LiteLLM
设置简易度 ⭐⭐⭐⭐⭐ (极简) ⭐⭐⭐⭐ (简单) ⭐⭐ (较复杂)
模型丰富度 ⭐ (仅 Kimi) ⭐⭐⭐⭐ (丰富) ⭐⭐⭐⭐⭐ (最丰富)
费用控制力 ⭐⭐ (依赖 Kimi 平台) ⭐⭐ (依赖上游) ⭐⭐⭐⭐⭐ (精准控制)
折腾系数 ⭐⭐⭐ ⭐⭐⭐⭐⭐

以上便是最近关于Claude Code的一点折腾经验,希望对你有帮助。欢迎点赞、收藏、分享,更欢迎分享你的使用经验。我们下篇文章见。

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风

扫码关注公众号

打造属于孩子的有声绘本世界

2025-07-12 08:00:00

Featured image of post 打造属于孩子的有声绘本世界

经常从图书馆借一些书来给孩子看,有一些是他们感兴趣的可以反复阅读的,无奈借书终有归期,如何解决这个问题呢?作为一个程序员爸爸,何不将它“保留”下来,并且让老父亲可以给他们朗读呢?

背景

孩子的天性,对于喜欢的绘本,可以反复阅读,一遍又一遍。我还记得有一次带女儿去图书馆还书,有几本她不舍得还,于是我们在一个楼梯边上坐下,一起再读了一遍。之后才恋恋不舍地将书交给“机器人”(自动分检)。我也颇遗憾的是记忆中有几本书,每次读起来,娃们都笑逐颜开。于是我打算“留住”这些特别的书,那是他们的美妙回忆。

原本打算写个微信小程序,但想着优先把核心功能跑起来,还是以网站呈现比较简单些。花了一些闲暇时间,借助于 VideCoding 有个初步可用的版本了。

以下简要介绍一下我的一些探索和实现过程,代码没整理暂时没打算开源。如果你想看看效果,我已经部署在vercel了,可以访问:https://audiobook-web-brown.vercel.app 查看。

首页阅读页

技术实现解析

需求简要分析

显然我们这个网站有几个基本功能要支持一下,比如绘本管理、绘本语音生成、绘本阅读体验等基本功能,多用户管理也可以考虑。 在绘本管理上,比如:

  • 绘本列表,筛选,收藏,标星
  • 绘本添加,扫描录入,提交PDF/PPT录入
  • 绘本删除,需要有锁,避免小孩误删除
  • 阅读记录,可以看到最近读的绘本的阅读时间

在绘本语音生成上,我们之前也试过多种声音复刻了,今天会推荐另一个简单便宜的渠道(后文),我考虑这样:

  • AI 生成的语音
  • AI 复刻的语音
  • 手工录制的语音(咱可能演绎得惟妙惟肖)

在绘本阅读过程中,希望能提供沉浸式的阅读UI,同时需要支持:

  • 横屏还是坚屏展示(特别是在手机上时)
  • 倍速播放
  • 快速跳转页面

用户管理等就是区分不同的用户和阅读进度等啦,这个就不细说了。我们首先面临的是,绘本从何而来?

绘本录入

我们需要将纸质书迁移到线上,最直接的莫过于拿起手机直接对着绘本拍照了,这里有一点小技巧要注意。为了便于后续照片的处理,我们尽量让书本放在一个比较纯净的背景色下,比如下面垫一张大白纸等。之后是将我们拍的照片处理,这里涉及到一些图像校正,透视校正等,如果可以,一些锐化或超分技术也可以使用上。我尝试了几种方式,最后选择了一个简单的方式,但或许不是最终解:

  • 使用OpenCV等库直接对图片进行处理,这里照片里的内容如果对比比较强,容易识别异常,只识别一小部分等出来。比如下面这样: opencv 当然如果花一些时间仔细研究算法和以书本来定制化识别方案,或许是可以调好的。
  • 使用云厂商提供的图像增强功能,可以快速实现裁剪、锐化、提亮等功能。比如腾讯云的文本图像增强

文本图像增强(Text Image Enhancement)是面向文档类图片提供的图像增强处理能力,包括切边增强、图像矫正、阴影去除、摩尔纹去除等;可以有效优化文档类的图片质量,提升文字的清晰度。 它的效果整体还不错,不过对于一些需要先旋转再裁剪的图片,它不会智能处理,所以交给它前最好先预处理一下。

  • 使用免费的 App 来实现,这里推荐微软的 Lens。它不光可以将图片按我们需要处理,可以预览处理结果,还可以微调之。最后还可以直接整理输出为一份 PDF文档。

以上,对于希望免费使用,推荐使用 Lens来处理。而如果希望批量自动化处理,或许调用 API 是更便捷的。

提取文字

当我们已经准备好图片或绘本的 PDF 后,如何将绘本内的文字提取出来便是个问题。我试过传统的 OCR,老实说可读性和进一步处理都比较麻烦。幸运的是现在各种视觉模型都支持图像识别了,所以我们只需要借助他们之力便可以轻松实现目标。考虑到不同模型可能的识别的差异,这里我做了一个测试,使用如下Prompt:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
你是一个专业的儿童绘本分析助手。请仔细观察这张绘本页面,识别并提取出用于朗读的旁白文字内容。

请注意:
1. 只提取适合朗读的旁白文字,不包括对话气泡中的直接对话
2. 旁白通常是描述性的文字,用来推进故事情节或描述场景
3. 有一些页面是标题页等,需要返回标题的文字
4. 如果页面不是标题页,也没有旁白文字,只有对话,请返回"无旁白文字"
5. 请直接返回提取的文字,不需要额外说明

请提取这一页的旁白文字:

比如有如下这张图:example-page

📊 各模型结果对比:

模型 状态 识别结果
minimax-io/MiniMax-Text-01 ✅ 主模型 蓝色还是有点怀疑: “天空这么大,要不就留在这里?” 山谷中, 有歌声轻轻响起, 洒在她身上的, 是橘色的晨曦。
gemini/gemini-2.5-flash ❌ 备用模型 蓝色还是有点怀疑: 山谷中, 有歌声轻轻响起, 洒在她身上的, 是橘色的晨曦。
doubao/doubao-seed-1.6-flash ✅ 备用模型 蓝色还是有点怀疑:“天空这么大,要不就留在这里?” 山谷中, 有歌声轻轻响起, 洒在她身上的, 是橘色的晨曦。
moonshot/moonshot-v1-8k-vision-preview ✅ 备用模型 蓝色还是有点怀疑: “天空这么大,要不就留在这里?” 山谷中, 有歌声轻轻响起, 洒在她身上的, 是橘色的晨曦。
azure/gpt-4o ✅ 备用模型 蓝色还是有点怀疑: “天空这么大,要不就留在这里?” 山谷中, 有歌声轻轻响起, 落在她身上的, 是橘色的晨曦。
tencent/hunyuan-turbos-vision ✅ 备用模型 蓝色还是有点怀疑: “天空这么大,要不就留在这里?” 山谷中, 有歌声轻轻响起, 洒在她身上的, 是橘色的晨曦。

最终结果: 蓝色还是有点怀疑: “天空这么大,要不就留在这里?” 山谷中, 有歌声轻轻响起, 洒在她身上的, 是橘色的晨曦。

上面是写文章时再跑了一次数据,这次 gpt-4o正确了,过往它的错误率也很高。整体的模型测试挺有意思,发现gpt系列没那么准确时,我试了一下gemini 系列,准确性上升不少,但也有出错时。考虑到我是中文绘本的场景,我换成国内的豆包,居然正确率一下提高更多,豆包出错的地方,我就尝试了一下moonshot的模型,又一次给我惊喜,最后是minimax家模型,在我的几个绘本的识别中,准确率是最高的。咱国内模型在这块也是挺能打的嘛!!

我借用多个模型提取出结果,然后有一个模型来判断哪一个是更大概率正确的内容,这样避免单模型的问题,整体上提升识别成功率。 至此,绘本的文字已经准备好,我们开始着手给它配置语音吧。

语音生成

没有声音,再好的戏也出不来:) 小朋友需要一些声音来辅助读绘本。我在之前的让你的小智AI机器人起飞一文中介绍过如何在腾讯云平台复刻声音并且使用,还有豆包平台也有不少音色可选并且也支持声音复刻。但,等等……我找到一个相对更物美价廉的方案,使用 MiniMax 平台进行 TTS以及复刻。MiniMax平台国内注册即有 15 元可以体验,普通的通过 TTS 生成语音可以花一阵子。复刻一个声音只要 9.9 元,这比豆包和腾讯云上似乎便宜得多。同时我发现它的国际版本 minimax.io 可以申请开发者体验计划,我简单填写了一个表格,很快就到账 50刀,这绝对是很大方了,强烈推荐。 推荐它的原因不光是咱可能免费褥到羊毛,更重要的是它的效果很不错。

MiniMax 这家公司还很早提供了 MCP server,可以直接借助 MCP server测试一下它的能力,使用各种声音(复刻)来从文本生成语音(TTS)。我们甚至都不用写一行代码就可以玩起来了。除了使用 MCP 来复刻,也可以简单的使用几行curl命令来完成。

  • 上传样本声音文件 将准备好要复刻的人声文件放在本地文件中,并上传到平台。
1
2
3
4
5
6
7
8
9
export MINIMAX_API_KEY=`你的MINIMAX_API_KEY`
export GROUP_ID=`你的 GROUPID`
export FILE_PATH="/Users/kevin/Downloads/luoluo-voice/output.m4a"
curl --location "https://api.minimaxi.com/v1/files/upload?GroupId=${GROUP_ID}" \
--header 'authority: api.minimaxi.com' \
--header 'content-type: multipart/form-data' \
--header "Authorization: Bearer ${MINIMAX_API_KEY}" \
--form 'purpose="voice_clone"' \
--form "file=@${FILE_PATH}"

返回:

{“file”:{“file_id”:281211669533090,“bytes”:259501,“created_at”:1750210557,“filename”:“output.m4a”,“purpose”:“voice_clone”},“base_resp”:{“status_code”:0,“status_msg”:“success”}}

  • 使用上传声音复刻
1
2
3
4
5
6
7
8
9
export GROUP_ID=`你的 GROUPID`
curl --location "https://api.minimaxi.com/v1/voice_clone?GroupId=${GROUP_ID}" \
--header 'authority: api.minimaxi.com' \
--header "Authorization: Bearer ${MINIMAX_API_KEY}" \
--header 'content-type: application/json' \
--data '{
 "file_id": 281211669533090,
 "voice_id": "luoluo-2025-0618"
}'

返回:

{“input_sensitive”:false,“input_sensitive_type”:0,“demo_audio”:"",“base_resp”:{“status_code”:0,“status_msg”:“success”}}

  • 查询音色列表
1
2
3
4
5
6
curl --location 'https://api.minimax.chat/v1/get_voice' \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $MINIMAX_API_KEY" \
--data '{
 "voice_type":"voice_cloning"
}'

注意,在没有使用此音色时,上面查询不到这个,只有在首次使用后才扣费和能够被查询到

欲查询音色类型,支持以下取值:
“system”(系统音色),
“voice_cloning”(快速复刻的音色),
“voice_generation”(文生音色接口生成的音色),
“music_generation”(音乐生成产生的人声或者伴奏音色),
“all”(以上全部)。

  • 使用复刻声音
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export GROUP_ID=`你的 GROUPID`
curl --location "https://api.minimax.chat/v1/t2a_v2?GroupId=${GROUP_ID}" \
--header "Authorization: Bearer $MINIMAX_API_KEY" \
--header 'Content-Type: application/json' \
--data '{
 "model":"speech-02-turbo",
 "text":"真正的危险不是计算机开始像人一样思考,而是人开始像计算机一样思考。计算机只是可以帮我们处理一些简单事务。",
 "stream":false,
 "voice_setting":{
 "voice_id":"luoluo-2025-0618",
 "speed":1,
 "vol":1,
 "pitch":0,
 "emotion":"happy"
 },
 "pronunciation_dict":{
 "tone":["处理/(chu3)(li3)", "危险/dangerous"]
 },
 "audio_setting":{
 "sample_rate":32000,
 "bitrate":128000,
 "format":"mp3",
 "channel":1
 }
 }' > resp.json | jq -r '.data.audio' | xxd -r -p > test1.mp3

这样这个复刻的音色就可以后面随时使用了。它在你第一次使用时会扣除 9.9 元(国内)或 3 刀(国际)费用。

音效生成

在我和 Gemini 探讨如何更好的设计这个绘本功能时,它提出了一个很不错的建议:

在准备配音文案时,我建议你创建一个“配音脚本”,不仅仅是抄录文字,而是像一个导演一样,设计整个声音的蓝图。你可以按照这个格式来整理:

页面 角色/旁白 文字内容 声音特效/拟声词 背景音乐建议
P1-2 旁白 在一个安静的小镇上,住着一只名叫乐乐的小狗。 (远处几声狗叫) (风轻轻吹过的声音) 舒缓、宁静的钢琴曲
P3 旁白 一天早上,他被一阵“叩叩叩”的声音吵醒了。 叩叩叩 (清晰有力的敲门声) 音乐暂停或减弱
P3 乐乐 “是谁呀?” (乐乐迷糊的、带点奶气的声音) (无)

总结一下,要成为声音的东西,不仅仅是“文字”,而是整个“故事场景”。你需要从一个“阅读者”转变为一个“声音导演”。在看每一页图画时,都可以问自己这几个问题:

  • “这里有什么文字?” -> 变成旁白和对话。
  • “这里能听到什么声音?” -> 变成拟声词和音效。
  • “这里是什么感觉/情绪?” -> 变成背景音乐。

通过这样的思考和准备,你最终创作出的有声绘本,将不仅仅是“读”出来的故事,而是一个生动、立体、能让孩子沉浸其中的声音世界。

如果我们可以在绘本期间追加一些声效,以及控制各个角色的声音情绪表现,那必然是更沉浸的体验。这块我寻找了一些方案:

  • ElevenLabs

    • 地址:https://elevenlabs.io/app/sound-effects/generate
    • 提供强大的AI音效生成API,支持通过文本描述生成音效。
    • 可自定义音效时长、提示影响力等参数。
    • 支持多语言,适合集成到游戏、视频制作等开发流程中。
    • 适合开发者通过API调用实现自动化音效生成
  • Stability AI

    • 地址:https://stableaudio.com/generate
    • 提供Stable Audio系列模型API,支持文本到音频、音频到音频的转换。
    • 适合需要高质量、长音频(如3分钟)生成的应用场景。

测试效果两者效果差不多,从费用上来说,似乎ElevenLabs 更便宜一些。暂时因为时间问题,我还没有将这块和上面 TTS 整合。它需要重新编排识别到的文本,在恰到好处的地方插入音效。未来有时间,我会尝试将这块整合到一起。

部署

这个应用是前后端分离设计的,前端基于 Vue 3 + TypeScript + Vite 构建,提供沉浸式阅读体验和智能用户管理。后端使用 golang来开发,同时将数据保存在对象存储中,这样我们的一些图片音频等可以借助对象存储的 CDN 有更快的响应速度。

我试着让 AI 帮我将前端对接到 Vercel 上,它帮我生成了一个 vercel.json 文件,我试着将它部署到 Vercel 上,结果居然一次成功咯,可以开心的用免费服务了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
 "buildCommand": "npm run build:skip-check",
 "rewrites": [
 {
 "source": "/((?!api|static|_next|favicon.ico).*)",
 "destination": "/index.html"
 }
 ],
 "headers": [
 {
 "source": "/(.*)",
 "headers": [
 {
 "key": "X-Content-Type-Options",
 "value": "nosniff"
 },
 {
 "key": "X-Frame-Options",
 "value": "DENY"
 },
 {
 "key": "X-XSS-Protection",
 "value": "1; mode=block"
 }
 ]
 },
 {
 "source": "/static/(.*)",
 "headers": [
 {
 "key": "Cache-Control",
 "value": "public, max-age=31536000, immutable"
 }
 ]
 }
 ]
}

后端就自己将程序打包为镜像,部署在我原来的k8s集群上了。

后记

在初步完成后,我给孩子试用了几次,效果还不错,他们惊讶于识别到这是爸爸的声音,这是他自己的声音等,也能认真的听完一本又一本绘本,作为老父亲,几个晚上的熬夜也没有白费了。 我突然想起来,有几本英文绘本,碍于我自己的英文水平和发音,都没怎么给孩子读,那真是误自己子弟了:)不过,现在有这个玩意,我可算找到救星了!文字就搞个双语吧,那发音到底是英式还是美式好呢?

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风

扫码关注公众号

AI编码时代:如何从零构建血常规追踪应用

2025-06-01 08:00:00

Featured image of post AI编码时代:如何从零构建血常规追踪应用

我对现今的前端技术几乎完全不懂,但借助于AI,几乎不需要什么背景知识就可以轻松构建这样一个还不错的应用。本文除了简要介绍一下这个网站的功能外,同时也谈谈开发过程的一些经验感悟,希望对你有所帮助,更期待一起交流经验。

背景

在医院看到不少爸妈都很认真负责地记录着孩子的各项检测数值,我也曾经将一些数据记录在笔记本中。那些牵动家长们内心的数值,一旦离开了纸质记录本,就很难说清楚,也不便于和医生沟通交流,更难发现其中的变化规律。某天在从医院回家的路上,灵机一动:何不构建一个血常规追踪应用,让整个治疗过程清晰直观呢?

本文会简单介绍一下这个网站,更重要的会分享一下在使用Lovable平台和Cursor中的一些独家的感受。

成果展示

我的Lovable会员每个月有100次AI对话额度,打算物尽其用,“强行”让它值回票价,便计划用它来搭建网站雏形。经历了几天的迭代、趟过一些坑、也用光了Cursor的次数后,我总算以网站的形式,捣鼓出了一个比较完整的血常规追踪功能。先来看看基础功能,后面再谈谈开发过程中的一些经验。

我们可以快速预览到最近一次检测有哪些指标异常: 概览-最新检测

也可以通过单指标或多指标的选择进一步分析变化趋势,同时可以结合期间的治疗及手术情况,分析指标变化的原因: 概览-指标趋势图

然后可以通过医疗日历,查看到每天的用药情况等: 概览-医疗日历

在检测数据的录入中,除了传统的手术填表外,借助于AI识图,可以通过上传图片,系统会自动识别出检测数据,包括采样时间等以及各项指标的数值,并自动填充到表单中: 检测数据录入

我们可以添加期间的用药及手术类型,针对不同的病种,可以自定义自己的用药以及手术,可以用不同颜色区分: 用药记录手术记录 同时也提供了一些预设,可以选择性导入: 预设导入

同时,除了单个日期的添加记录,支持批量选择日期,通过Shift等常用区间选择的交互方式,可以快速添加一整段时间的记录,这样对于一些需要长期用药,这会更便捷一些。

有时找医生沟通,我们需要一个表格呈现一些数据,网站提供了将选定区间的信息导出的功能,同时会标注期间的一些关键事项。 导出数据

做完这些后,为了让初次使用的人可以快速上手,我们可能还需要一个使用指南,借助于现在便捷的网页生成能力,一个简洁美观的说明页也快速生成了: 使用指南

开发过程

和AI一起明晰目标——反复折腾多缘于目标不清

尽管头脑中已经有不少这个应用的功能点,但在让AI开始正式编码前,我们还需要细化一些东西,于是我和Gemini 2.5 Pro聊了起来。像一个倾诉对象一样,我和它谈我的目标,也让它反过来询问我一些事项,最后我们敲定了方案,Gemini 2.5 Pro 在我得到有首肯后,很快就帮我生成了一份需求文档。这期间,AI会帮助我们完善一些可能欠考虑的细节,比如数据的隐私性,它强调:

数据安全:对病人的血常规数据进行加密存储,确保数据库中的数据没有明文可读内容。加密算法建议采用行业常用的对称加密算法如AES(高级加密标准),以保障数据在存储过程中的安全性。

最终形成了一个初版的提示词,它将交给Lovable平台:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
根据你提供的新信息,我们可以进一步完善开发提示词,以下是目前整理的详细提示词,涵盖了之前和新确认的需求,可用于在 https://lovable.dev/ 创建应用:

### 应用概述
开发一个用于跟进病人血常规变化的APP,主要具备两大核心功能:一是允许病人录入血常规情况,支持文字和图片录入方式,图片录入时使用OCR工具识别内容,录入的数据会整理后记录到数据库;二是通过图表(主要是趋势图)展示相关数据,支持选择展示某一个指标的数值,图表横轴为按真实日期连续排列的日期,纵轴为指标对应的数值,同时要考虑每个指标的参考范围并在图中展示,根据数值低于或高于参考范围,对应点展示不同颜色。

### 功能需求

#### 数据录入功能
1. **文字录入**
 - 提供简洁的文字录入界面,病人可按照规范格式录入血常规数据,数据应包含日期(精确到上午或下午)以及各项血常规指标数值(如C反应蛋白、中性粒细胞绝对值、血小板、血红蛋白、红细胞、白细胞等)和标准单位。
 - 当用户录入同一天同一时间(上午或下午视为不同时间)的指标时,新录入的数据将覆盖之前的记录。
 - 提供对某一次血常规结果数据进行单独修改的功能,允许更新或删除某一项指标。
2. **图片录入**
 - 支持病人上传血常规检查报告的图片,APP自动调用OCR工具识别图片中的内容。
 - 展示OCR识别结果供病人确认,病人确认无误后数据存入数据库;若识别结果有误,病人可手动修正。

#### 数据存储功能
1. **数据库记录**:将录入的血常规数据整理后记录到数据库中,数据结构应包含日期、时间、各项血常规指标数值及单位。
2. **数据安全**:对病人的血常规数据进行加密存储,确保数据库中的数据没有明文可读内容。加密算法建议采用行业常用的对称加密算法如AES(高级加密标准),以保障数据在存储过程中的安全性。

#### 图表展示功能
1. **趋势图展示**
 - 以趋势图形式展示血常规指标数据,用户可选择展示某一个指标的数值。
 - 图表横轴为按真实日期连续排列的日期,纵轴为指标对应的数值。
 - 在图表中展示每个指标的参考范围,根据数值低于或高于参考范围,对应点展示不同颜色,以便用户直观了解指标情况。
2. **可选功能(预留)**
 - 未来考虑支持其他类型的图表(如柱状图、饼图等)展示数据。
 - 支持对数据进行筛选,例如只展示某一段时间内的数据。
 - 支持同时对比多个指标的趋势图,以便更直观地观察数据之间的关系。

#### 多用户管理功能(预留)
预留多用户管理功能,为未来支持多个病人使用APP做准备,包括用户的注册、登录和数据隔离等功能。

省略...

在Lovable平台创建应用——选对工具,事半功倍

虽然作为开发者,手上有不少编程工具可以使用,我这次还是选择了Lovable平台,除了我恰好有会员外,还有几个因素也是比较重要的:

  • 一站式的开发与发布平台,这样初期的开发与部署可以无缝衔接,不需要再额外考虑部署的问题。
  • 结合Supabase等开放数据库,提供了多用户的认证以及可以方便地进行数据存储与管理。
  • 它和GitHub的集成,可以方便地进行版本管理,以及未来若有必要,可以随时转到其它开发工具中继续开发。

和Lovable寒暄几句(你好)后便开始制造我们应用的初始版本,没一会,几个页面便出来了,整体雏形便有了。页面美观程度嘛,也还不错? Lovable开发体验 但在和Lovable深入接触后(也就是真正开始开发),我有几点不太爽的感受:

  • 它对于一些稍微复杂点的算法,就容易陷入死循环——反复修改,反复出错,简直让人头大。不知道啥时我居然和AI和编辑器生闷气:)比如我在想让趋势图支持多指标的选择相关功能时,在Lovable平台中反复好多次没有成功。我琢磨着是不是背后的模型还不够强大,在我切换到Cursor中启用Claude 3.5 Sonnet后很快搞定。
  • 它对于指令的遵从还不错,但是缺少一些设计,有些人说用大模型开发容易搞出屎山,大概是这种感觉。并且它写代码是重复重写一个个文件,导致差异上我们也不容易看出来,虽然它经常有文件过大时的重构提示,但这显然治标不治本。
  • 它的输出和修改还是过慢了,对于一些较小的修改,等的我有点忧伤,这不是我想要的"氛围"(Vibe Coding)。

这让我像一个渣男,打算"移情别恋"转投Cursor的怀抱。但更渣的是,我时不时还得回来蹭一下它的发布能力——毕竟人家有打通GitHub一键部署的神通。

在Cursor中精细化开发——真正的战斗才刚刚开始

我早前写过一篇Cursor的开发技巧及思考,感兴趣可以翻看过往文章,也可以在我博客找到:AI编程之Cursor使用技巧及一些思考。在Lovable那儿碰了几鼻子灰后,我不得不把代码克隆下来,开始进行“真刀真枪”的开发。

借助于Cursor,我们可以这样更有效的推进工作。为了让它知道我在干啥,也为了它不要反复乱改一些设定,我们先让Cursor通读一下整个项目,并生成Cursor rules。没一会就生成了好几个文件,看起来满满当当,也有模有样的,为了更好的使用,你可别全信,咱继续定制:

  • 提炼最基础的原则,将它作为一个Always规则,使每次沟通可以带上。反正Cursor又不会基于Token计费(不过Max版本说会收费,你要更小心),这里规则三五百行是没问题的。注意上面默认生成的里面,一般是不会给你设定Always规则的。你每次得自己主动添加上相关文件,这对擅长遗忘的我们容易造成伤害。
  • 删除不需要或模糊的规则。比如像这个工程我没打算让它写测试,所以测试相关说明可以删除。我也不喜欢它在做一件事后马上去更新文档(我还没确认最终是不是这个方案呢),所以文档的更新我也会限制。
  • 添加一些新的规则。我发现现在AI真的有点智能了,居然学会偷懒!有时候一堆lint报错都没解决,就开始给我汇报工作成果了,这就想邀功?未免太早了吧,瞧您这干的啥事。于是我得给它上个"紧箍咒",需要它每次提交时,先检查一下lint,确保没有问题。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 业务逻辑和数据处理规范较

## 血常规数据管理

### 数据说明
本工程数据区分本地demo数据和数据库supabase存储的数据,它们完全隔离。
* 未登录用户使用的是本地demo数据。
* 登录用户使用其自身数据。

### 数据模型
参考类型定义:@types/blood-test.ts

#### 血常规指标
指标区分基础指标和其它指标。
* 基础指标的参考: @constants/basicIndicators.ts
* 全部指标定义在:@constants/indicators.ts

还有一些规则,我们要一边开发,根据AI的表现为它量身定制。比如我们已经npm run dev启动了项目,它每次提交完又想运行一次,你知道这显然不必,咱已经能动态热更新,所以得限制它。有时我们会沉迷于Vibe Coding的便捷,执着于像个甲方一样反复让它修改实现,就是不想思考如何优化规则,这时AI就会反复提醒你:)所以我感觉,在一个较复杂的项目上,想要快,必须得慢下来,琢磨一下如何去定义适合自己的规则。

在不断的调整和修改下,Cursor的月次数告急。我观察了一下Cursor的计数规则,它不是以你每次的交互多少来计数,而是差不多按完成一项工作来计算。于是假如你是想一出是一出,让它把东边修缮一下,西边再改改,那次数就哗哗的没了。可以考虑以一次将要修改的任务整理好,甚至一些关联的地方也提醒它注意修改,这样可以更充分的利用好次数。比如我这里的用药管理和手术管理经常是类似的逻辑,我就让它修改用药管理界面的A、B、C处,同时在手术管理中也需要类似的逻辑。这次只需要消耗一次计数,哗啦啦一大堆代码和功能就完善了。不过这招也别滥用,不建议一次性塞给它太多不相关的修改任务。我的经验是,把类似的功能打包一次性修改,独立的功能分开处理。改完一部分,自己确认没问题了就赶紧提交到版本库,免得一次改太多,到头来想挑挑拣拣哪些要哪些不要的时候,那叫一个尴尬!虽然Cursor和Cline等都有Checkpoint,其实都不好解决这个问题,我们得自己规范流程。

在使用Cursor过程中,过往我对模型的选择没有太在意,但这次我特意观察了一下,发现不同模型在Cursor中的表现还是有些差异的。比如在Cursor中,我试着用了一段时间那传说中被各大自媒体吹爆的Gemini 2.5 Pro,结果发现这家伙有点“高冷范儿”——思维链一开始还好好说中文,搞着搞着就中英混杂,甚至直接飙英文了,跟它沟通像在考六级。同时感觉它可能太过思维活跃了,动不动自我否定又来推敲一次,导致产出效率其实不高。而当我切换到Claude 3.5 Sonnet模型时,它甚至都表现更好一些,这或许和不少人的感知略有不同,我不确定是否Cursor未对它调教好(默认Prompt未能很好适配?)。但也不要迷恋Claude 3.5 Sonnet,在一次调整中,它也陷入了无尽的自我怀疑与纠结中,还是多花一倍代价(2x)切换到它哥(Claude 3.7 Sonnet)后问题很快得以解决。这告诉我一个道理,必要时启用最强大模型有时是更经济的事情。这个道理很快又被证明也不靠谱,因为没几天Claude 4 Sonnet出来了,号称更强大的编码能力,同时在Cursor中更便宜(0.5x)。我赶紧用上,没想到它似乎还是一匹没有驯服的野马,时有脱缰的时候。事后我在想为啥Cursor给这些优惠呢,在几天后费用升到0.75x时,它渐渐好了起来,或许前面就是鼓励我们使用,有足够数据偷偷调教呢?

借助MCP让代码感知DB——不再做知识的搬运工

我是在使用Lovable才知道有Supabase这个数据库的。最开始惊讶于原来居然可以在保障安全性的同时,不需要借助后端,前端直接就可以和DB交互。它借助了Postgresql的RLS机制,用户的权限被严格限制了。在开发期间,我有一次需要调整和迁移DB。最开始我频繁将DB Schema以及现有数据等截图贴给它,然后Cursor又为我生成一些SQL语句,说可以让我在Supabase平台SQL Editor中执行。我意识到这不是我想要的方式,作为一个现代化的BaaS服务(后端即服务),它也提供了mcp server,果断添加到Cursor中,然后就可以在Cursor中直接操作数据库了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
 "mcpServers": {
 "supabase": {
 "command": "npx",
 "args": [
 "-y",
 "@supabase/mcp-server-supabase@latest",
 "--access-token",
 "<YOUR_SUPABASE_ACCESS_TOKEN>"
 ]
 }
 }
}

如下图,对于DB的修改及查询,甚至迁移等工作,都可以借助这个MCP工具完全在Cursor中完成,我终于不用当搬运工了。 MCP工具

专属网址——入口必须简单直接

整体网站的功能基本就绪后,要是想给其他人用,我寻思着得给它配个好记的网址才行。在NameCheap上找了一个并不太Cheap的com域名: blood-track.com。然后在Lovable平台直接绑定这个域名就可以通过新的自有域名访问啦。我看了一下,域名解析到了一个IPv4地址,应该是Lovable的接入层,因为我在平台登记了项目的自有域名,请求中header又有这个域名信息,故让Lovable路由到它所部署的代码实例上并不困难。

后续规划

开发的初衷嘛,主要是想解决自己记录、整理和洞察(或‘分析’)这些医疗数据时遇到的一些麻烦。因为所知有限,不一定适配于其它病种,虽然基础完备,但也有一些工作后续有空会继续考虑完善:

  • 一些关键事件的记录与呈现。当前虽然有一些手术记录,但对于比如输血、输血小板等,或者感染事件,它也会引发血常规直接变化。
  • 如果使用者变多,Supabase的免费Plan就不够使用了,而要每个月额外几十刀的话,可能自托管supabase或切换到其它DB都可能要提上议程。
  • 多语言版本也可以考虑,如果有呼声的话(我怎么能听到这里的呼声)。
  • 支持更多端,比如微信小程序或APP等。

本站提供的是公益免费的服务,域名、开发维护等成本较低,包括AI的识别等,整体花费不多暂时能够承担。(话说大模型识别一次检查数据费用不到一分钱,感谢这个时代。)

后记

如果你完整看完,不知道期间的感触你是否也有共鸣?如何在AI时代更高效的开发,这是现今乃至未来几年传统开发者都需要面临和思考的问题。 你可能有疑问为什么不开发一个微信小程序等,其实我也有此意。所以期间也尝试用全新的开发范式来建立微信小程序,比如从UI的设计开始,借助AI来设计交互图,然后我也试着回归VSCode + Cline看整体开发一个微信小程序的成本如何,初步完成一个Demo来看,成本尚可接受。未来有空会进一步聊一聊。有Cline后咱最新的模型或免费的渠道羊毛都可以褥一下,成本可能更低(注意也可能时间成本高得多)。

本篇文章就先到这里,感谢你的耐心阅读。期待未来有更多的成果和经验可以继续和你聊聊,欢迎一起交流探讨。这一块领域,全世界都还在探索,我们保持在路上。

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风

扫码关注公众号

给内网穿透FRP套上坚固的盾牌

2025-05-10 08:00:00

Featured image of post 给内网穿透FRP套上坚固的盾牌

背景

有一天,我突然发现无法从外部连接家里的NAS了。我开始慌了,预感到不妙。莫非是公网IP被运营商回收了?我也成为一个大内网用户了。所幸已经有不少成熟的方案,而FRP就是其中之一。它开源免费,易上手,并且支持的协议还比较多(当然,部署服务器的费用得另算)。晚上回到家,我决定面对现实,好好折腾一番。虽然网上现有的FRP教程多数只完成了‘能用’的第一步,但距离‘好用易用’还有点距离。

本文简要描述一下我使用FRP的过程,并且看一下我们如何给FRP套上坚固的盾牌,配上易用的武器。我假定你已经知道FRP是什么,并且最基本的FRP使用已经了解。不了解也没关系,继续看你也大概能懂。

虽然咱数据或许对别人而言也没那么重要,但自我保护意识也不可松懈。

目标制定

frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP、UDP、HTTP、HTTPS 等多种协议,且支持 P2P 通信。可以将内网服务以安全、便捷的方式通过具有公网 IP 节点的中转暴露到公网。

为了方便迁移和管理,在使用FRP时我首推容器化方案。不过似乎没看到官方的镜像,但dockerhub上有一个社区广泛使用且下载量很高的镜像,大抵错不了:snowdreamtech/frpcsnowdreamtech/frps

FRP是分客户端和服务端的,需要在不同的机器分别配置。frpc一般部署在内网,用于将内部需要对外暴露的服务定义出来。而frps一般部署在有公网IP的服务器上,用于接收外部连接并转发到内部服务。这里有几个安全事项需要关注:

  1. 内网frpc和公网frps之间需要建立安全的连接。
  2. 公网frps暴露的端口需要进一步限制连接来源。
  3. 公网frps暴露的端口仅在必要时开放。

很多分享的方案里基本不启用TLS,也对暴露的端口没有进一步的限制,这其实是不安全的。秉承这些目标,我们开始行动吧。

部署服务

内网部署frpc端

这里我直接使用docker-compose部署,并使用snowdreamtech/frpc镜像。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
version: '3.8'
services:
 frpc:
 image: snowdreamtech/frpc:debian
 container_name: frpc
 restart: always
 network_mode: host
 volumes:
 - ./frpc.toml:/etc/frp/frpc.toml
 - ./client.crt:/etc/frp/client.crt
 - ./client.key:/etc/frp/client.key
 - ./ca.crt:/etc/frp/ca.crt
 environment:
 - TZ=Asia/Shanghai
 env_file:
 - ./.env

这里需要的TLS证书一会我们再生成。这里的.env文件内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 服务端连接信息
FRPC_SERVER_ADDR=<your-server-address>
FRPC_SERVER_PORT=<your-server-port>

# 服务器域名
FRPC_SERVER_NAME="<your-domain-for-tls>"

# 认证令牌 - 必须与服务端一致
FRPC_AUTH_TOKEN=<your-auth-token>

# 客户端仪表盘配置
FRPC_DASHBOARD_USER=admin
FRPC_DASHBOARD_PWD=admin

为了方便修改和对齐,我们将frpc.toml文件中的一部分配置放在.env文件中定义。而frpc.toml文件内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
user = "kevin"

serverAddr = "{{ .Envs.FRPC_SERVER_ADDR }}"
serverPort = {{ .Envs.FRPC_SERVER_PORT }}

loginFailExit = true

log.to = "./frpc.log"
log.level = "trace"
log.maxDays = 3
log.disablePrintColor = false

auth.method = "token"
auth.token = "{{ if .Envs.FRPC_AUTH_TOKEN }}{{ .Envs.FRPC_AUTH_TOKEN }}{{ else }}{{ .Envs.FRP_AUTH_TOKEN }}{{ end }}"

transport.poolCount = 5
transport.protocol = "tcp"
transport.connectServerLocalIP = "0.0.0.0"

transport.tls.enable = true
transport.tls.certFile = "/etc/frp/client.crt"
transport.tls.keyFile = "/etc/frp/client.key"
transport.tls.trustedCaFile = "/etc/frp/ca.crt"
transport.tls.serverName = "{{ .Envs.FRPC_SERVER_NAME }}"

udpPacketSize = 1500

webServer.addr = "127.0.0.1"
webServer.port = 7400
webServer.user = "{{ .Envs.FRPC_DASHBOARD_USER }}"
webServer.password = "{{ .Envs.FRPC_DASHBOARD_PWD }}"

[[proxies]]
name = "router-web"
type = "tcp"
localIP = "192.168.1.1"
localPort = 80
remotePort = 17603

[[proxies]]
name = "external-http"
type = "tcp"
localIP = "192.168.50.96"
localPort = 8080
remotePort = 9443

公网部署frps端

我们的服务端一般是部署在一台有公网IP的服务器上,用于我们从任何地方通过它连接回家里内网。这个服务器可以从国内国外各种云上买一台或者找机会白嫖一台。我是在腾讯云上有一台机器,安装好docker以及docker-compose后,使用snowdreamtech/frps镜像部署。部署在公网的服务,基于安全性考虑,我们希望即使frps被攻击,其它服务也是安全的,所以除了放在容器中,把网络也隔离出来。这里便不再使用network_mode: host,而是使用默认的network_mode: bridge,同时预留一些端口用于后续我们的服务暴露。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
version: '3.8'
services:
 frps:
 image: snowdreamtech/frps:debian
 container_name: frps
 restart: always
 ports:
 - "9443:9443"
 - "17600-17610:17600-17610" # TCP/UDP 代理端口范围 (allowPorts),视你需要开放一些端口
 volumes:
 - ./frps.toml:/etc/frp/frps.toml
 - ./server.crt:/etc/frp/server.crt
 - ./server.key:/etc/frp/server.key
 - ./ca.crt:/etc/frp/ca.crt
 environment:
 - TZ=Asia/Shanghai
 env_file:
 - ./.env

同样的,为了配置修改方便,我们将frps.toml文件中的一部分配置放在.env文件中定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 仪表盘访问凭证
FRPS_DASHBOARD_USER=<your-dashboard-username>
FRPS_DASHBOARD_PWD=<your-dashboard-password>

# 认证令牌 - 必须与客户端一致
FRPS_AUTH_TOKEN=<your-auth-token>

# 绑定地址和端口配置
FRPS_BIND_ADDR=0.0.0.0
FRPS_BIND_PORT=17600
FRPS_KCP_BIND_PORT=17600
FRPS_DASHBOARD_PORT=17601

而frps.toml文件内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
bindAddr = "{{ .Envs.FRPS_BIND_ADDR }}"
bindPort = {{ .Envs.FRPS_BIND_PORT }}

kcpBindPort = {{ .Envs.FRPS_KCP_BIND_PORT }}

transport.maxPoolCount = 5
transport.tls.force = true
transport.tls.certFile = "/etc/frp/server.crt"
transport.tls.keyFile = "/etc/frp/server.key"
transport.tls.trustedCaFile = "/etc/frp/ca.crt"

webServer.addr = "0.0.0.0"
webServer.port = {{ .Envs.FRPS_DASHBOARD_PORT }}
webServer.user = "{{ .Envs.FRPS_DASHBOARD_USER }}"
webServer.password = "{{ .Envs.FRPS_DASHBOARD_PWD }}"
webServer.pprofEnable = false

# 开放的端口范围,这里可以配置适大一些,更多的映射(限制)在docker-compose中
allowPorts = [
 { start = 17000, end = 17999 },
 { single = 9443 }
]


enablePrometheus = true

log.to = "./frps.log"
log.level = "trace"
log.maxDays = 3
log.disablePrintColor = false

detailedErrorsToClient = true

auth.method = "token"
auth.token = "{{ if .Envs.FRPS_AUTH_TOKEN }}{{ .Envs.FRPS_AUTH_TOKEN }}{{ else }}{{ .Envs.FRP_AUTH_TOKEN }}{{ end }}"
auth.oidc.issuer = ""
auth.oidc.audience = ""
auth.oidc.skipExpiryCheck = false
auth.oidc.skipIssuerCheck = false

maxPortsPerClient = 0
udpPacketSize = 1500
natholeAnalysisDataReserveHours = 168

生成TLS证书

为了让FRP的连接更安全,我们使用TLS证书来加密连接。它确保我们的frpc和frps之间的连接是安全的,并且防止中间人攻击。我们使用自签名证书来生成TLS证书。以下提供了一段脚本,方便一键生成我们需要的证书。

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
#!/bin/bash
# 脚本用于生成 FRP 配置所需的自签名证书

set -e # 任何命令失败立即退出

echo "开始生成 FRP 通信证书..."

# 设置默认值,但要求用户至少提供一个
SERVER_DOMAIN=""
SERVER_IP=""

# 仅重新生成服务器证书的选项
REGENERATE_SERVER_ONLY=false

# 显示帮助信息
show_help() {
 echo "用法: $0 [选项]"
 echo ""
 echo "选项:"
 echo " --server-only 仅重新生成服务器证书,保留现有CA证书"
 echo " --domain=<域名> 指定服务器域名"
 echo " --ip=<IP地址> 指定服务器IP地址"
 echo " --help 显示此帮助信息"
 echo ""
 echo "至少需要指定域名或IP地址中的一个"
 exit 1
}

# 解析命令行参数
while [[ $# -gt 0 ]]; do
 case $1 in
 --server-only)
 REGENERATE_SERVER_ONLY=true
 shift
 ;;
 --domain=*)
 SERVER_DOMAIN="${1#*=}"
 shift
 ;;
 --ip=*)
 SERVER_IP="${1#*=}"
 shift
 ;;
 --help)
 show_help
 ;;
 *)
 echo "未知选项: $1"
 show_help
 ;;
 esac
done

# 检查是否提供了至少一个参数
if [ -z "$SERVER_DOMAIN" ] && [ -z "$SERVER_IP" ]; then
 echo "错误: 必须至少指定域名(--domain)或IP地址(--ip)中的一个"
 show_help
fi

# 显示配置信息
echo "证书配置:"
if [ -n "$SERVER_DOMAIN" ]; then
 echo "- 域名: $SERVER_DOMAIN"
fi
if [ -n "$SERVER_IP" ]; then
 echo "- IP地址: $SERVER_IP"
fi

if [ "$REGENERATE_SERVER_ONLY" = false ]; then
 # 1. 生成 CA 根证书
 echo "生成 CA 根证书..."
 openssl genrsa -out ca.key 4096
 openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt \
 -subj "/C=CN/ST=Beijing/L=Beijing/O=FRP-Private/OU=DevOps/CN=frp-ca"
else
 echo "跳过 CA 证书生成,使用现有 CA 证书..."
 # 检查CA证书是否存在
 if [ ! -f "ca.key" ] || [ ! -f "ca.crt" ]; then
 echo "错误: CA证书文件不存在。请先运行不带 --server-only 参数的脚本生成完整证书集。"
 exit 1
 fi
fi

# 2. 生成服务端证书
echo "生成服务端证书..."
openssl genrsa -out frps/server.key 4096

# 设置默认CN
CN_VALUE="${SERVER_DOMAIN}"
if [ -z "$CN_VALUE" ]; then
 CN_VALUE="frps"
fi

# 创建OpenSSL配置文件
cat > frps/openssl.cnf << EOF
[ req ]
default_bits = 4096
prompt = no
default_md = sha256
req_extensions = req_ext
distinguished_name = dn

[ dn ]
C = CN
ST = Beijing
L = Beijing
O = FRP-Private
OU = DevOps
CN = ${CN_VALUE}

[ req_ext ]
subjectAltName = @alt_names

[ alt_names ]
EOF

# 动态添加DNS和IP到配置文件
DNS_COUNT=1
if [ -n "$SERVER_DOMAIN" ]; then
 echo "DNS.${DNS_COUNT} = ${SERVER_DOMAIN}" >> frps/openssl.cnf
 DNS_COUNT=$((DNS_COUNT+1))
fi
echo "DNS.${DNS_COUNT} = localhost" >> frps/openssl.cnf

IP_COUNT=1
if [ -n "$SERVER_IP" ]; then
 echo "IP.${IP_COUNT} = ${SERVER_IP}" >> frps/openssl.cnf
 IP_COUNT=$((IP_COUNT+1))
fi
echo "IP.${IP_COUNT} = 127.0.0.1" >> frps/openssl.cnf

# 使用配置文件生成CSR
openssl req -new -key frps/server.key -out frps/server.csr -config frps/openssl.cnf

# 创建扩展配置文件 - 正确的格式
cat > frps/v3.ext << EOF
subjectAltName = @alt_names
[alt_names]
EOF

# 添加DNS和IP到扩展配置
if [ -n "$SERVER_DOMAIN" ]; then
 echo "DNS.1 = ${SERVER_DOMAIN}" >> frps/v3.ext
 echo "DNS.2 = localhost" >> frps/v3.ext
else
 echo "DNS.1 = localhost" >> frps/v3.ext
fi

if [ -n "$SERVER_IP" ]; then
 echo "IP.1 = ${SERVER_IP}" >> frps/v3.ext
 echo "IP.2 = 127.0.0.1" >> frps/v3.ext
else
 echo "IP.1 = 127.0.0.1" >> frps/v3.ext
fi

# 签署证书,应用SAN扩展
openssl x509 -req -in frps/server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
 -out frps/server.crt -days 3650 -sha256 -extfile frps/v3.ext

if [ "$REGENERATE_SERVER_ONLY" = false ]; then
 # 3. 生成客户端证书
 echo "生成客户端证书..."
 openssl genrsa -out frpc/client.key 4096
 openssl req -new -key frpc/client.key -out frpc/client.csr \
 -subj "/C=CN/ST=Beijing/L=Beijing/O=FRP-Private/OU=DevOps/CN=frpc"
 openssl x509 -req -in frpc/client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
 -out frpc/client.crt -days 3650 -sha256

 # 4. 分发 CA 证书到 frps 和 frpc 目录
 echo "分发 CA 证书到各个目录..."
 cp ca.crt frps/ca.crt
 cp ca.crt frpc/ca.crt
else
 echo "跳过客户端证书生成,仅更新服务器证书..."
fi

# 5. 清理中间文件
echo "清理临时文件..."
rm -f frps/server.csr ca.srl frps/openssl.cnf frps/v3.ext
if [ "$REGENERATE_SERVER_ONLY" = false ]; then
 rm -f frpc/client.csr
fi

echo "设置文件权限..."
chmod 600 ca.key frps/server.key
if [ "$REGENERATE_SERVER_ONLY" = false ]; then
 chmod 600 frpc/client.key
fi

echo "证书生成完成!"
if [ "$REGENERATE_SERVER_ONLY" = true ]; then
 echo "已使用现有CA证书重新生成服务器证书"
else
 echo "注意:ca.key 是敏感文件,请妥善保管,建议不要上传到代码仓库。"
fi

# 输出证书信息和提示
echo ""
echo "===== 证书信息 ====="
if [ -n "$SERVER_DOMAIN" ]; then
 echo "服务器证书包含域名: ${SERVER_DOMAIN}"
 echo "如果使用域名连接,请在frpc.toml中设置: transport.tls.serverName = \"${SERVER_DOMAIN}\""
fi
if [ -n "$SERVER_IP" ]; then
 echo "服务器证书包含IP地址: ${SERVER_IP}"
fi

# 提示下一步操作
echo ""
echo "===== 下一步操作 ====="
echo "1. 复制证书文件到相应位置"
echo "2. 更新frpc.toml中的服务器地址和相关配置"
echo "3. 使用docker-compose restart重启服务"

# 验证证书内容
echo ""
echo "===== 验证证书 ====="
echo "查看证书内容(包括SAN扩展):"
openssl x509 -in frps/server.crt -text -noout | grep -A1 "Subject Alternative Name"

使用方式:

1
./generate-frp-certs.sh --domain=<your-domain> --ip=<your-ip>

这样我们的服务器与客户端双向认证,谁都不可被冒充。

安全加固

到这里,你的FRP连接已经通过TLS和双向认证得到了很好的保护,即使token不慎泄露,没有匹配的证书也无法建立连接。接下来,我们在此基础上更进一步,结合云主机的防火墙(安全组)策略,实现更精细的访问控制。我们希望达成:

  • 仅允许必要的端口放通
  • 仅允许必要的IP连接

相信很多人都明白这个道理,但手动操作实在繁琐,所以我们需要一个工具来帮忙。在我看来,最便捷的莫过于再次祭出alfred workflow来实现。于是我抽空写了一个,并开源在github上,感兴趣的可以看这里:alfred-workflow-sg-manager

我们基于frpc.toml中的一部分配置[[proxies]],来动态开启与关闭相关的端口。一点点前置工作还需要你做的,便是将你的服务器绑定一个安全组,至于安全组后续规则添加维护等就交给这个工具了。 比如先查看一下当前列表: frp list 你上面可以看到这些内网服务还未开放,我们选择一个,比如想从外部访问家里的Mac mini了,我们在alfred框中输入frp open可以看到可开放的列表: frp open 选中Mac mini,然后回车,这个通路便打开了。再次查看可以看到它开放并且绑定了本机的出口IP: frp list

现在你可以开心的从外部VNC到你家内网的Mac mini了,并且安全得多了。 用过之后想关闭的一些端口,比如Mac mini的VNC端口,我们输入frp close,然后选择对应的要关闭的服务回车即可: frp close

现在咱是不是对于FRP的安全使用更有信心了。有些人可能会说:何苦呢,有谁会看中咱攻击咱呢?或许可能Maybe:我们就是控制欲作祟而已:)

一些技巧

还有两个小窍门我也想让你知道,看在这么认真的份上小手点点赞不过份吧!

第一:如上面截图出现了external-http,我们可以借助于将内网服务统一在一个ingress服务(反向代理)下,然后通过这个ingress服务进一步路由,这样我们只需要穿透一个端口即可访问内网的各种服务,免去了配置FRP的手续。也通过让ingress服务走TLS,或者后端服务对接OAuth2等更安全的认证方式,可以进一步保护我们的内网服务。

第二:FRPS除了默认有Dashboard外,还支持Prometheus。我们可以复用这种生态。Prometheus用于收集监控数据,Grafana用于可视化数据和配置告警。比如我们可监控FRP的连接访问情况,并且添加告警,这样某个敏感服务有连接,我们便可以及时收到通知。

后记

本文整体到这里就结束了。我们尝试用docker来部署了FRP的客户端和服务端,并且基于安全的考虑,我们启用了TLS和创建了一个便捷的工具来快速修改云上的安全策略。这犹如一块坚固的盾牌,避免我们可能受到的攻击。

当我折腾好FRP,并且安全地将它保护起来后。有一天,我查看我家的外网IP,发现它居然是一个公网IP。我的天啦,我这折腾一番可是为了啥!我要不要重新回归到公网IP的路线呢?可是我却放不下这份安全了呢。

注:题图来自于互联网,我觉得画得挺棒,若有侵权请联系我删除。

我是个爱折腾技术的工程师,也乐于分享。欢迎点赞、关注、分享,更欢迎一起探讨技术问题,共同学习,共同进步。为了获得更及时的文章推送,欢迎关注我的公众号:爱折腾的风

扫码关注公众号