+
+
+
+
+
+
+
+[更多效果预览](docs/preview.md)
+
+## 安装与使用
+
+### 1. 安装
+
+替换下方 APIKey 等参数,并完整复制到终端一键执行。注意:
+
+1. `provider` 除了硅基还支持 openai, openrouter, deepseek, gemini, volc(火山(keng)引擎)。也可自定义,参考 [配置文档](docs/config-zh.md)。需要自定义其它参数的大佬也可参考
+
+2. `llms[0].model` 默认会用来总结内容,相对耗费 Token,一般 Qwen/Qwen2.5-7B-Instruct(免费!!!)足够,当然米够的话越强越好。如果你还没有硅基账号,使用 [邀请链接](https://cloud.siliconflow.cn/i/U2VS0Q5A) 得 14 元额度
+
+#### Mac/Linux
+
+```bash
+docker run --rm \
+ -v "$(PWD):/app" \
+ -w /app \
+ --entrypoint sh \
+ mikefarah/yq -c '
+ set -e
+ mkdir -p zenfeed/config && cd zenfeed
+
+ TEMPLATE_URL="https://raw.githubusercontent.com/glidea/zenfeed/main/install/config-template.yaml"
+ COMPOSE_URL="https://raw.githubusercontent.com/glidea/zenfeed/main/install/docker-compose.yml"
+ CONFIG_OUTPUT="config/config.yaml"
+ COMPOSE_OUTPUT="docker-compose.yml"
+
+ curl -sfL "$TEMPLATE_URL" | yq \
+ '.timezone = "Asia/Shanghai" |
+ .llms[0].provider = siliconflow |
+ .llms[0].model = Qwen/Qwen2.5-32B-Instruct |
+ .llms[0].api_key = your_api_key | # 替换!!!其它参数按需选择
+ .llms[1].provider = siliconflow |
+ .llms[1].embedding_model = Pro/BAAI/bge-m3 |
+ .llms[1].api_key = your_api_key | # 替换!!!
+ .storage.feed.rewrites[0].transform.to_text.prompt = {{.summary_html_snippet}}使用中文回复' \
+ > "$CONFIG_OUTPUT"
+
+ curl -sfL "$COMPOSE_URL" -o "$COMPOSE_OUTPUT"
+' && cd zenfeed && docker compose up -d --wait
+```
+
+#### Windows
+> 使用 PowerShell 执行
+```powershell
+docker run --rm `
+ -v "${PWD}:/app" `
+ -w /app `
+ --entrypoint sh `
+ mikefarah/yq -c '
+ set -e;
+ mkdir -p zenfeed/config && cd zenfeed;
+
+ TEMPLATE_URL="https://raw.githubusercontent.com/glidea/zenfeed/main/install/config-template.yaml";
+ COMPOSE_URL="https://raw.githubusercontent.com/glidea/zenfeed/main/install/docker-compose.yml";
+ CONFIG_OUTPUT="config/config.yaml";
+ COMPOSE_OUTPUT="docker-compose.yml";
+
+ curl -sfL "$TEMPLATE_URL" | yq `
+ ''.timezone = "Asia/Shanghai" |
+ .llms[0].provider = "siliconflow" |
+ .llms[0].model = "Qwen/Qwen2.5-32B-Instruct" |
+ .llms[0].api_key = "your_api_key" | # 替换!!!其它参数按需选择
+ .llms[1].provider = "siliconflow" |
+ .llms[1].embedding_model = "Pro/BAAI/bge-m3" |
+ .llms[1].api_key = "your_api_key" | # 替换!!!
+ .storage.feed.rewrites[0].transform.to_text.prompt = "{{.summary_html_snippet}}使用中文回复"'' `
+ > "$CONFIG_OUTPUT";
+
+ curl -sfL "$COMPOSE_URL" -o "$COMPOSE_OUTPUT";
+' ; cd zenfeed; docker compose up -d --wait
+```
+
+### 2. 使用 Web 端
+
+访问 https://zenfeed-web.pages.dev
+
+> 会默认连接本地的 zenfeed
+
+#### 添加 RSS 订阅源
+
+
+
+> 从 Follow 迁移过来,参考 [migrate-from-follow.md](docs/migrate-from-follow.md)
+
+#### 配置每日简报,监控等
+
+
+
+### 2. 配置 MCP(可选)
+以 Cherry Studio 为例,配置 MCP 并连接到 Zenfeed,见 [Cherry Studio MCP](docs/cherry-studio-mcp.md)
+> 默认地址 http://localhost:1301/sse
+
+## Roadmap
+* P0(大概率会做)
+ * 支持生成播客,男女对话,类似 NotebookLM
+ * 更多数据源
+ * 邮件
+ * 网页剪藏 Chrome 插件
+* P1(可能)
+ * 关键词搜索
+ * 支持搜索引擎作为数据源
+ * APP?
+ * 以下是由于版权风险,暂时不推进
+ * 支持 Webhook 通知
+ * 爬虫
+
+> 进展会第一时间在 [Linux Do](https://linux.do/u/ajd/summary) 更新
+
+## 有任何问题与反馈,欢迎加群讨论
+
+
+
+都看到这里了,顺手点个 Star ⭐️ 呗,用于防止我太监掉
+
+## 注意
+* 1.0 版本之前不保证兼容性
+* 项目采用 AGPL3 协议,任何 Fork 都需要开源
+* 商用请联系报备,可提供合理范围内的支持。注意是合法商用哦,不欢迎搞灰色
+* 数据不会永久保存,默认只存储 8 天
+
+## 免责声明 (Disclaimer)
+
+**在使用 `zenfeed` 软件(以下简称“本软件”)前,请仔细阅读并理解本免责声明。您的下载、安装、使用本软件或任何相关服务的行为,即表示您已阅读、理解并同意接受本声明的所有条款。如果您不同意本声明的任何内容,请立即停止使用本软件。**
+
+1. **“按原样”提供:** 本软件按“现状”和“可用”的基础提供,不附带任何形式的明示或默示担保。项目作者和贡献者不对本软件的适销性、特定用途适用性、非侵权性、准确性、完整性、可靠性、安全性、及时性或性能做出任何保证或陈述。
+
+2. **用户责任:** 您将对使用本软件的所有行为承担全部责任。这包括但不限于:
+ * **数据源选择:** 您自行负责选择并配置要接入的数据源(如 RSS feeds、未来可能的 Email 源等)。您必须确信您有权访问和处理这些数据源的内容,并遵守其各自的服务条款、版权政策及相关法律法规。
+ * **内容合规性:** 您不得使用本软件处理、存储或分发任何非法、侵权、诽谤、淫秽或其他令人反感的内容。
+ * **API密钥和凭证安全:** 您负责保护您配置到本软件中的任何 API 密钥、密码或其他凭证的安全。因您未能妥善保管而导致的任何损失或损害,项目作者和贡献者概不负责。
+ * **配置和使用:** 您负责正确配置和使用本软件的功能,包括内容处理管道、过滤规则、通知设置等。
+
+3. **第三方内容与服务:** 本软件可能集成或依赖第三方数据源、服务(如 RSSHub、LLM 提供商、SMTP 服务商等)。项目作者和贡献者不对这些第三方内容或服务的可用性、准确性、合法性、安全性或其服务条款负责。您与这些第三方的互动受其各自条款和政策的约束。通过本软件访问或处理的第三方内容(包括原始文章、摘要、分类、评分等)的版权归原始权利人所有,您应自行承担因使用这些内容而可能产生的法律责任。
+
+4. **无内容处理保证:** 本软件使用大型语言模型(LLM)等技术对内容进行处理(如摘要、分类、评分、过滤)。这些处理结果可能不准确、不完整或存在偏差。项目作者和贡献者不对任何基于这些处理结果做出的决策或行动负责。语义搜索结果的准确性也受多种因素影响,不作保证。
+
+5. **无间接或后果性损害赔偿:** 在任何情况下,无论基于何种法律理论(合同、侵权或其他),项目作者和贡献者均不对因使用或无法使用本软件而导致的任何直接、间接、偶然、特殊、惩戒性或后果性损害负责,包括但不限于利润损失、数据丢失、商誉损失、业务中断或其他商业损害或损失,即使已被告知可能发生此类损害。
+
+6. **开源软件:** 本软件根据 AGPLv3 许可证授权。您有责任理解并遵守该许可证的条款。
+
+7. **非法律建议:** 本免责声明不构成法律建议。如果您对使用本软件的法律影响有任何疑问,应咨询合格的法律专业人士。
+
+8. **修改与接受:** 项目作者保留随时修改本免责声明的权利。继续使用本软件将被视为接受修改后的条款。
+
+**请再次注意:使用本软件抓取、处理和分发受版权保护的内容可能存在法律风险。用户有责任确保其使用行为符合所有适用的法律法规和第三方服务条款。对于任何因用户滥用或不当使用本软件而引起的法律纠纷或损失,项目作者和贡献者不承担任何责任。**
+
diff --git a/docs/cherry-studio-mcp.md b/docs/cherry-studio-mcp.md
new file mode 100644
index 0000000..36928dc
--- /dev/null
+++ b/docs/cherry-studio-mcp.md
@@ -0,0 +1,17 @@
+**配置 MCP Server**
+
+默认 URL: `http://localhost:1301/sse`
+
+
+
+**配置 Prompt(可选,但不使用效果可能不符合预期)**
+
+完整 Prompt 见 [mcp-client-prompt.md](mcp-client-prompt.md)
+
+
+
+**玩法参考**
+
+[Doc](preview.md)
+
+非常强大,还可以直接修改 zenfeed 配置项
\ No newline at end of file
diff --git a/docs/config-zh.md b/docs/config-zh.md
new file mode 100644
index 0000000..f5057fc
--- /dev/null
+++ b/docs/config-zh.md
@@ -0,0 +1,178 @@
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :--------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------- | :------------- |
+| `timezone` | `string` | 应用的时区。例如 `Asia/Shanghai`。 | 服务器本地时区 | 否 |
+| `log` | `object` | 日志配置。详见下方的 **日志配置** 部分。 | (见具体字段) | 否 |
+| `api` | `object` | API 配置。详见下方的 **API 配置** 部分。 | (见具体字段) | 否 |
+| `llms` | `列表` | 大语言模型 (LLM) 配置。会被其他配置部分引用。详见下方的 **LLM 配置** 部分。 | `[]` | 是 (至少 1 个) |
+| `scrape` | `object` | 抓取配置。详见下方的 **抓取配置** 部分。 | (见具体字段) | 否 |
+| `storage` | `object` | 存储配置。详见下方的 **存储配置** 部分。 | (见具体字段) | 否 |
+| `scheduls` | `object` | 用于监控 Feed 的调度配置 (也称为监控规则)。详见下方的 **调度配置** 部分。 | (见具体字段) | 否 |
+| `notify` | `object` | 通知配置。它接收来自调度模块的结果,通过路由配置进行分组,并通过通知渠道发送给通知接收者。详见下方的 **通知配置**, **通知路由**, **通知接收者**, **通知渠道** 部分。 | (见具体字段) | 是 |
+
+### 日志配置 (`log`)
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :---------- | :------- | :--------------------------------------------------------- | :----- | :------- |
+| `log.level` | `string` | 日志级别, 可选值为 `debug`, `info`, `warn`, `error` 之一。 | `info` | 否 |
+
+### API 配置 (`api`)
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :----------------- | :------- | :---------------------------------------------------------------------------------------- | :---------------------- | :-------------------- |
+| `api.http` | `object` | HTTP API 配置。 | (见具体字段) | 否 |
+| `api.http.address` | `string` | HTTP API 的地址 (`[host]:port`)。例如 `0.0.0.0:1300`。应用运行后不可更改。 | `:1300` | 否 |
+| `api.mcp` | `object` | MCP API 配置。 | (见具体字段) | 否 |
+| `api.mcp.address` | `string` | MCP API 的地址 (`[host]:port`)。例如 `0.0.0.0:1301`。应用运行后不可更改。 | `:1301` | 否 |
+| `api.llm` | `string` | 用于总结 Feed 的 LLM 名称。例如 `my-favorite-gemini-king`。引用在 `llms` 部分定义的 LLM。 | `llms` 部分中的默认 LLM | 是 (如果使用总结功能) |
+
+### LLM 配置 (`llms[]`)
+
+此部分定义了可用的大语言模型列表。至少需要一个 LLM 配置。
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :----------------------- | :-------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------- | :--------------------------------------------- |
+| `llms[].name` | `string` | LLM 的名称 (或称 'id')。例如 `my-favorite-gemini-king`。用于在其他配置部分 (如 `api.llm`, `storage.feed.embedding_llm` 等) 引用此 LLM。 | | 是 |
+| `llms[].default` | `bool` | 此 LLM 是否为默认 LLM。只能有一个 LLM 是默认的。 | `false` | 否 (但如果依赖默认行为,则必须有一个为 `true`) |
+| `llms[].provider` | `string` | LLM 的提供商, 可选值为 `openai`, `openrouter`, `deepseek`, `gemini`, `volc`, `siliconflow` 之一。例如 `openai`。 | | 是 |
+| `llms[].endpoint` | `string` | LLM 的自定义端点。例如 `https://api.openai.com/v1`。 | (提供商特定默认值) | 否 |
+| `llms[].api_key` | `string` | LLM 的 API 密钥。 | | 是 |
+| `llms[].model` | `string` | LLM 的模型。例如 `gpt-4o-mini`。如果用于生成任务 (如总结),则不能为空。如果此 LLM 被使用,则不能与 `embedding_model` 同时为空。 | | 条件性必需 |
+| `llms[].embedding_model` | `string` | LLM 的 Embedding 模型。例如 `text-embedding-3-small`。如果用于 Embedding,则不能为空。如果此 LLM 被使用,则不能与 `model` 同时为空。**注意:** 初次使用后请勿直接修改,应添加新的 LLM 配置。 | | 条件性必需 |
+| `llms[].temperature` | `float32` | LLM 的温度 (0-2)。 | `0.0` | 否 |
+
+### 抓取配置 (`scrape`)
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :----------------------- | :-------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :----- | :---------------------------------- |
+| `scrape.past` | `time.Duration` | 抓取 Feed 的回溯时间窗口。例如 `1h` 表示只抓取过去 1 小时的 Feed。 | `3d` | 否 |
+| `scrape.interval` | `time.Duration` | 抓取每个源的频率 (全局默认值)。例如 `1h`。 | `1h` | 否 |
+| `scrape.rsshub_endpoint` | `string` | RSSHub 的端点。你可以部署自己的 RSSHub 服务器或使用公共实例 (参见 [RSSHub 文档](https://docs.rsshub.app/guide/instances))。例如 `https://rsshub.app`。 | | 是 (如果使用了 `rsshub_route_path`) |
+| `scrape.sources` | `对象列表` | 用于抓取 Feed 的源列表。详见下方的 **抓取源配置**。 | `[]` | 是 (至少一个) |
+
+### 抓取源配置 (`scrape.sources[]`)
+
+描述每个要抓取的源。
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :-------------------------- | :------------------ | :----------------------------------------------------------------------------------- | :-------------- | :-------------------- |
+| `scrape.sources[].interval` | `time.Duration` | 抓取此特定源的频率。覆盖全局 `scrape.interval`。 | 全局 `interval` | 否 |
+| `scrape.sources[].name` | `string` | 源的名称。用于标记 Feed。 | | 是 |
+| `scrape.sources[].labels` | `map[string]string` | 附加到此源 Feed 的额外键值标签。 | `{}` | 否 |
+| `scrape.sources[].rss` | `object` | 此源的 RSS 配置。详见下方的 **抓取源 RSS 配置**。每个源只能设置一种类型 (例如 RSS)。 | `nil` | 是 (如果源类型是 RSS) |
+
+### 抓取源 RSS 配置 (`scrape.sources[].rss`)
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :--------------------------------------- | :------- | :--------------------------------------------------------------------------------------------------------------------------------- | :----- | :---------------------------------------------- |
+| `scrape.sources[].rss.url` | `string` | RSS Feed 的完整 URL。例如 `http://localhost:1200/github/trending/daily/any`。如果设置了 `rsshub_route_path` 则不能设置此项。 | | 是 (除非设置了 `rsshub_route_path`) |
+| `scrape.sources[].rss.rsshub_route_path` | `string` | RSSHub 路由路径。例如 `github/trending/daily/any`。将与 `scrape.rsshub_endpoint` 拼接成最终 URL。如果设置了 `url` 则不能设置此项。 | | 是 (除非设置了 `url`, 且需要 `rsshub_endpoint`) |
+
+### 存储配置 (`storage`)
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :------------- | :------- | :-------------------------------------------- | :----------- | :------- |
+| `storage.dir` | `string` | 所有存储的基础目录。应用运行后不可更改。 | `./data` | 否 |
+| `storage.feed` | `object` | Feed 存储配置。详见下方的 **Feed 存储配置**。 | (见具体字段) | 否 |
+
+### Feed 存储配置 (`storage.feed`)
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :---------------------------- | :-------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------- | :------- |
+| `storage.feed.rewrites` | `对象列表` | 在存储每个 Feed 之前如何处理它。受 Prometheus relabeling 启发。详见下方的 **重写规则配置**。 | `[]` | 否 |
+| `storage.feed.flush_interval` | `time.Duration` | 将 Feed 存储刷新到数据库的频率。更高的值会带来更高的数据丢失风险,但能减少磁盘操作并提高性能。 | `200ms` | 否 |
+| `storage.feed.embedding_llm` | `string` | 用于 Feed Embedding 的 LLM 名称 (来自 `llms` 部分)。显著影响语义搜索的准确性。**注意:** 如果要切换,请注意保留旧的 LLM 配置,因为过去的数据仍隐式关联它,否则会导致过去的数据无法进行语义搜索。 | `llms` 部分中的默认 LLM | 否 |
+| `storage.feed.retention` | `time.Duration` | Feed 的保留时长。 | `8d` | 否 |
+| `storage.feed.block_duration` | `time.Duration` | 每个基于时间的 Feed 存储块的保留时长 (类似于 Prometheus TSDB Block)。 | `25h` | 否 |
+
+### 重写规则配置 (`storage.feed.rewrites[]`)
+
+定义在存储前处理 Feed 的规则。规则按顺序应用。
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :--------------------------------------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------- | :--------------------------------------------- |
+| `...rewrites[].source_label` | `string` | 用作转换源文本的 Feed 标签。默认标签包括: `type`, `source`, `title`, `link`, `pub_time`, `content`。 | `content` | 否 |
+| `...rewrites[].skip_too_short_threshold` | `*int` | 如果设置,`source_label` 文本长度低于此阈值的 Feed 将被此规则跳过 (处理将继续进行下一条规则,如果没有更多规则则进行 Feed 存储)。有助于过滤掉过短/信息量不足的 Feed。 | `300` | 否 |
+| `...rewrites[].transform` | `object` | 配置如何转换 `source_label` 文本。详见下方的 **重写规则转换配置**。如果未设置,则直接使用 `source_label` 文本进行匹配。 | `nil` | 否 |
+| `...rewrites[].match` | `string` | 用于匹配 (转换后) 文本的简单字符串。不能与 `match_re` 同时设置。 | | 否 (使用 `match` 或 `match_re`) |
+| `...rewrites[].match_re` | `string` | 用于匹配 (转换后) 文本的正则表达式。 | `.*` (匹配所有) | 否 (使用 `match` 或 `match_re`) |
+| `...rewrites[].action` | `string` | 匹配时执行的操作: `create_or_update_label` (使用匹配/转换后的文本添加/更新标签), `drop_feed` (完全丢弃该 Feed)。 | `create_or_update_label` | 否 |
+| `...rewrites[].label` | `string` | 要创建或更新的 Feed 标签名称。 | | 是 (如果 `action` 是 `create_or_update_label`) |
+
+### 重写规则转换配置 (`storage.feed.rewrites[].transform`)
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :--------------------- | :------- | :------------------------------------------------------------------- | :----- | :------- |
+| `...transform.to_text` | `object` | 使用 LLM 将源文本转换为文本。详见下方的 **重写规则转换为文本配置**。 | `nil` | 否 |
+
+### 重写规则转换为文本配置 (`storage.feed.rewrites[].transform.to_text`)
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :------------------ | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :---------------------- | :------- |
+| `...to_text.llm` | `string` | 用于转换的 LLM 名称 (来自 `llms` 部分)。 | `llms` 部分中的默认 LLM | 否 |
+| `...to_text.prompt` | `string` | 用于转换的 Prompt。源文本将被注入。可以使用 Go 模板语法引用内置 Prompt: `{{ .summary }}`, `{{ .category }}`, `{{ .tags }}`, `{{ .score }}`, `{{ .comment_confucius }}`, `{{ .summary_html_snippet }}`。 | | 是 |
+
+### 调度配置 (`scheduls`)
+
+定义查询和监控 Feed 的规则。
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :--------------- | :--------- | :------------------------------------------------------------------------------------------------------- | :----- | :------- |
+| `scheduls.rules` | `对象列表` | 用于调度 Feed 的规则列表。每个规则的结果 (匹配的 Feed) 将被发送到通知路由。详见下方的 **调度规则配置**。 | `[]` | 否 |
+
+### 调度规则配置 (`scheduls.rules[]`)
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :-------------------------------- | :-------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----- | :---------------------------------------- |
+| `scheduls.rules[].name` | `string` | 规则的名称。 | | 是 |
+| `scheduls.rules[].query` | `string` | 用于查找相关 Feed 的语义查询。可选。 | | 否 |
+| `scheduls.rules[].threshold` | `float32` | 相关性得分阈值 (0-1),用于过滤语义查询结果。仅在设置了 `query` 时有效。 | `0.6` | 否 |
+| `scheduls.rules[].label_filters` | `字符串列表` | 基于 Feed 标签的过滤器 (等于或不等于)。例如 `["category=tech", "source!=github"]`。 | `[]` | 否 |
+| `scheduls.rules[].every_day` | `string` | 相对于每天结束时间的查询范围。格式: `start~end` (HH:MM)。例如, `00:00~23:59` (今天), `-22:00~07:00` (昨天 22:00 到今天 07:00)。不能与 `watch_interval` 同时设置。 | | 否 (使用 `every_day` 或 `watch_interval`) |
+| `scheduls.rules[].watch_interval` | `time.Duration` | 运行查询的频率。例如 `10m`。不能与 `every_day` 同时设置。 | `10m` | 否 (使用 `every_day` 或 `watch_interval`) |
+
+### 通知配置 (`notify`)
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :----------------- | :--------- | :------------------------------------------------------------------- | :----------- | :---------------- |
+| `notify.route` | `object` | 主通知路由配置。详见下方的 **通知路由配置**。 | (见具体字段) | 是 |
+| `notify.receivers` | `对象列表` | 定义通知接收者 (例如电子邮件地址)。详见下方的 **通知接收者配置**。 | `[]` | 是 (至少一个) |
+| `notify.channels` | `object` | 配置通知渠道 (例如电子邮件 SMTP 设置)。详见下方的 **通知渠道配置**。 | (见具体字段) | 是 (如果使用渠道) |
+
+### 通知路由配置 (`notify.route` 及 `notify.route.sub_routes[]`)
+
+此结构可以使用 `sub_routes` 进行嵌套。Feed 会首先尝试匹配子路由;如果没有子路由匹配,则应用父路由的配置。
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :--------------------------------- | :----------- | :-------------------------------------------------------------------------------------------------------- | :----- | :------------ |
+| `...matchers` (仅子路由) | `字符串列表` | 标签匹配器,用于确定 Feed 是否属于此子路由。例如 `["category=tech", "source!=github"]`。 | `[]` | 是 (仅子路由) |
+| `...receivers` | `字符串列表` | 接收者的名称列表 (在 `notify.receivers` 中定义),用于发送匹配此路由的 Feed 的通知。 | `[]` | 是 (至少一个) |
+| `...group_by` | `字符串列表` | 在发送通知前用于对 Feed 进行分组的标签列表。每个分组会产生一个单独的通知。例如 `["source", "category"]`。 | `[]` | 是 (至少一个) |
+| `...compress_by_related_threshold` | `*float32` | 如果设置,则根据语义相关性压缩分组内高度相似的 Feed,仅发送一个代表。阈值 (0-1),越高表示越相似。 | `0.85` | 否 |
+| `...sub_routes` | `对象列表` | 嵌套路由列表。允许定义更具体的路由规则。每个对象遵循 **通知路由配置**。 | `[]` | 否 |
+
+### 通知接收者配置 (`notify.receivers[]`)
+
+定义*谁*接收通知。
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :------------------------- | :------- | :------------------------------- | :----- | :------------------ |
+| `notify.receivers[].name` | `string` | 接收者的唯一名称。在路由中使用。 | | 是 |
+| `notify.receivers[].email` | `string` | 接收者的电子邮件地址。 | | 是 (如果使用 Email) |
+
+### 通知渠道配置 (`notify.channels`)
+
+配置通知*如何*发送。
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :---------------------- | :------- | :-------------------------------------------------------- | :----- | :------------------ |
+| `notify.channels.email` | `object` | 全局 Email 渠道配置。详见下方的 **通知渠道 Email 配置**。 | `nil` | 是 (如果使用 Email) |
+
+### 通知渠道 Email 配置 (`notify.channels.email`)
+
+| 字段 | 类型 | 描述 | 默认值 | 是否必需 |
+| :------------------------------------ | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------------- | :------- |
+| `...email.smtp_endpoint` | `string` | SMTP 服务器端点。例如 `smtp.gmail.com:587`。 | | 是 |
+| `...email.from` | `string` | 发件人 Email 地址。 | | 是 |
+| `...email.password` | `string` | 发件人 Email 的应用专用密码。(对于 Gmail, 参见 [Google 应用密码](https://support.google.com/mail/answer/185833))。 | | 是 |
+| `...email.feed_markdown_template` | `string` | 用于在 Email 正文中格式化每个 Feed 的 Markdown 模板。默认渲染 Feed 内容。不能与 `feed_html_snippet_template` 同时设置。可用的模板变量取决于 Feed 标签。 | `{{ .content }}` | 否 |
+| `...email.feed_html_snippet_template` | `string` | 用于格式化每个 Feed 的 HTML 片段模板。不能与 `feed_markdown_template` 同时设置。可用的模板变量取决于 Feed 标签。 | | 否 |
\ No newline at end of file
diff --git a/docs/config.md b/docs/config.md
new file mode 100644
index 0000000..f53c098
--- /dev/null
+++ b/docs/config.md
@@ -0,0 +1,178 @@
+| Field | Type | Description | Default | Required |
+| :------- | :----- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------- | :-------- |
+| timezone | string | The timezone of the app. e.g. `Asia/Shanghai`. | server's local timezone | No |
+| log | object | The log config. See **Log Configuration** section below. | (see fields) | No |
+| api | object | The API config. See **API Configuration** section below. | (see fields) | No |
+| llms | list | The LLMs config. Refered by other config sections. See **LLM Configuration** section below. | `[]` | Yes (>=1) |
+| scrape | object | The scrape config. See **Scrape Configuration** section below. | (see fields) | No |
+| storage | object | The storage config. See **Storage Configuration** section below. | (see fields) | No |
+| scheduls | object | The scheduls config for monitoring feeds (aka monitoring rules). See **Scheduls Configuration** section below. | (see fields) | No |
+| notify | object | The notify config. It receives results from scheduls, groups them via route config, and sends to receivers via channels. See **Notify Configuration**, **Notify Route**, **Notify Receiver**, **Notify Channels** sections below. | (see fields) | Yes |
+
+### Log Configuration (`log`)
+
+| Field | Type | Description | Default | Required |
+| :---------- | :----- | :-------------------------------------------------- | :------ | :------- |
+| `log.level` | string | Log level, one of `debug`, `info`, `warn`, `error`. | `info` | No |
+
+**API Configuration (`api`)**
+
+| Field | Type | Description | Default | Required |
+| :----------------- | :----- | :------------------------------------------------------------------------------------------------------------------ | :---------------------------- | :------------------------------------- |
+| `api.http` | object | The HTTP API config. | (see fields) | No |
+| `api.http.address` | string | The address (`[host]:port`) of the HTTP API. e.g. `0.0.0.0:1300`. Cannot be changed after the app is running. | `:1300` | No |
+| `api.mcp` | object | The MCP API config. | (see fields) | No |
+| `api.mcp.address` | string | The address (`[host]:port`) of the MCP API. e.g. `0.0.0.0:1301`. Cannot be changed after the app is running. | `:1301` | No |
+| `api.llm` | string | The LLM name for summarizing feeds. e.g. `my-favorite-gemini-king`. Refers to an LLM defined in the `llms` section. | default LLM in `llms` section | Yes (if summarization feature is used) |
+
+### LLM Configuration (`llms[]`)
+
+This section defines a list of available Large Language Models. At least one LLM configuration is required.
+
+| Field | Type | Description | Default | Required |
+| :----------------------- | :------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------- | :------------------------------------------------------------- |
+| `llms[].name` | string | The name (or 'id') of the LLM. e.g. `my-favorite-gemini-king`. Used to refer to this LLM in other sections (`api.llm`, `storage.feed.embedding_llm`, etc.). | | Yes |
+| `llms[].default` | bool | Whether this LLM is the default LLM. Only one LLM can be the default. | `false` | No (but one must be `true` if default behavior is relied upon) |
+| `llms[].provider` | string | The provider of the LLM, one of `openai`, `openrouter`, `deepseek`, `gemini`, `volc`, `siliconflow`. e.g. `openai`. | | Yes |
+| `llms[].endpoint` | string | The custom endpoint of the LLM. e.g. `https://api.openai.com/v1`. | (provider specific default) | No |
+| `llms[].api_key` | string | The API key of the LLM. | | Yes |
+| `llms[].model` | string | The model of the LLM. e.g. `gpt-4o-mini`. Cannot be empty if used for generation tasks (like summarization). Cannot be empty with `embedding_model` at same time if this LLM is used. | | Conditionally Yes |
+| `llms[].embedding_model` | string | The embedding model of the LLM. e.g. `text-embedding-3-small`. Cannot be empty if used for embedding. Cannot be empty with `model` at same time if this LLM is used. **NOTE:** Do not modify after initial use; add a new LLM config instead. | | Conditionally Yes |
+| `llms[].temperature` | float32 | The temperature (0-2) of the LLM. | `0.0` | No |
+
+### Scrape Configuration (`scrape`)
+
+| Field | Type | Description | Default | Required |
+| :----------------------- | :-------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------ | :-------------------------------- |
+| `scrape.past` | duration | The lookback time window for scraping feeds. e.g. `1h` means only scrape feeds in the past 1 hour. | `3d` | No |
+| `scrape.interval` | duration | How often to scrape each source (global default). e.g. `1h`. | `1h` | No |
+| `scrape.rsshub_endpoint` | string | The endpoint of the RSSHub. You can deploy your own or use a public one (see [RSSHub Docs](https://docs.rsshub.app/guide/instances)). e.g. `https://rsshub.app`. | | Yes (if `rsshub_route_path` used) |
+| `scrape.sources` | list of objects | The sources for scraping feeds. See **Scrape Source Configuration** below. | `[]` | Yes (at least one) |
+
+### Scrape Source Configuration (`scrape.sources[]`)
+
+Describes each source to be scraped.
+
+| Field | Type | Description | Default | Required |
+| :-------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------- | :-------------- | :-------------------------- |
+| `scrape.sources[].interval` | duration | How often to scrape this specific source. Overrides the global `scrape.interval`. | global interval | No |
+| `scrape.sources[].name` | string | The name of the source. Used for labeling feeds. | | Yes |
+| `scrape.sources[].labels` | map[string]string | Additional key-value labels to add to feeds from this source. | `{}` | No |
+| `scrape.sources[].rss` | object | The RSS config for this source. See **Scrape Source RSS Configuration** below. Only one source type (e.g., RSS) can be set per source. | `nil` | Yes (if source type is RSS) |
+
+### Scrape Source RSS Configuration (`scrape.sources[].rss`)
+
+| Field | Type | Description | Default | Required |
+| :--------------------------------------- | :----- | :------------------------------------------------------------------------------------------------------------------------------------ | :------ | :---------------------------------------------------- |
+| `scrape.sources[].rss.url` | string | The full URL of the RSS feed. e.g. `http://localhost:1200/github/trending/daily/any`. Cannot be set if `rsshub_route_path` is set. | | Yes (unless `rsshub_route_path` is set) |
+| `scrape.sources[].rss.rsshub_route_path` | string | The RSSHub route path. e.g. `github/trending/daily/any`. Will be joined with `scrape.rsshub_endpoint`. Cannot be set if `url` is set. | | Yes (unless `url` is set, requires `rsshub_endpoint`) |
+
+### Storage Configuration (`storage`)
+
+| Field | Type | Description | Default | Required |
+| :------------- | :----- | :------------------------------------------------------------------------------- | :----------- | :------- |
+| `storage.dir` | string | The base directory for all storages. Cannot be changed after the app is running. | `./data` | No |
+| `storage.feed` | object | The feed storage config. See **Feed Storage Configuration** below. | (see fields) | No |
+
+### Feed Storage Configuration (`storage.feed`)
+
+| Field | Type | Description | Default | Required |
+| :---------------------------- | :-------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------- | :------- |
+| `storage.feed.rewrites` | list of objects | How to process each feed before storing it. Inspired by Prometheus relabeling. See **Rewrite Rule Configuration** below. | `[]` | No |
+| `storage.feed.flush_interval` | duration | How often to flush feed storage to the database. Higher value risks data loss but improves performance. | `200ms` | No |
+| `storage.feed.embedding_llm` | string | The name of the LLM (from `llms` section) used for embedding feeds. Affects semantic search accuracy. **NOTE:** If changing, keep the old LLM config defined as past data relies on it. | default LLM in `llms` section | No |
+| `storage.feed.retention` | duration | How long to keep a feed. | `8d` | No |
+| `storage.feed.block_duration` | duration | How long to keep each time-based feed storage block (similar to Prometheus TSDB Block). | `25h` | No |
+
+### Rewrite Rule Configuration (`storage.feed.rewrites[]`)
+
+Defines rules to process feeds before storage. Rules are applied in order.
+
+| Field | Type | Description | Default | Required |
+| :--------------------------------------- | :----- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------- | :-------------------------------------------- |
+| `...rewrites[].source_label` | string | The feed label to use as the source text for transformation. Default labels: `type`, `source`, `title`, `link`, `pub_time`, `content`. | `content` | No |
+| `...rewrites[].skip_too_short_threshold` | *int | If set, feeds where the `source_label` text length is below this threshold are skipped by this rule (processing continues with the next rule or feed storage if no more rules). Helps filter short/uninformative feeds. | `300` | No |
+| `...rewrites[].transform` | object | Configures how to transform the `source_label` text. See **Rewrite Rule Transform Configuration** below. If unset, the `source_label` text is used directly for matching. | `nil` | No |
+| `...rewrites[].match` | string | A simple string to match against the (transformed) text. Cannot be set with `match_re`. | | No (use `match` or `match_re`) |
+| `...rewrites[].match_re` | string | A regular expression to match against the (transformed) text. | `.*` (matches all) | No (use `match` or `match_re`) |
+| `...rewrites[].action` | string | Action to perform if matched: `create_or_update_label` (adds/updates a label with the matched/transformed text), `drop_feed` (discards the feed entirely). | `create_or_update_label` | No |
+| `...rewrites[].label` | string | The feed label name to create or update. | | Yes (if `action` is `create_or_update_label`) |
+
+### Rewrite Rule Transform Configuration (`storage.feed.rewrites[].transform`)
+
+| Field | Type | Description | Default | Required |
+| :--------------------- | :----- | :---------------------------------------------------------------------------------------------------------- | :------ | :------- |
+| `...transform.to_text` | object | Transform the source text to text using an LLM. See **Rewrite Rule Transform To Text Configuration** below. | `nil` | No |
+
+### Rewrite Rule Transform To Text Configuration (`storage.feed.rewrites[].transform.to_text`)
+
+| Field | Type | Description | Default | Required |
+| :------------------ | :----- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :---------------------------- | :------- |
+| `...to_text.llm` | string | The name of the LLM (from `llms` section) to use for transformation. | default LLM in `llms` section | No |
+| `...to_text.prompt` | string | The prompt used for transformation. The source text is injected. Go template syntax can refer to built-in prompts: `{{ .summary }}`, `{{ .category }}`, `{{ .tags }}`, `{{ .score }}`, `{{ .comment_confucius }}`, `{{ .summary_html_snippet }}`. | | Yes |
+
+### Scheduls Configuration (`scheduls`)
+
+Defines rules for querying and monitoring feeds.
+
+| Field | Type | Description | Default | Required |
+| :--------------- | :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------- | :------ | :------- |
+| `scheduls.rules` | list of objects | The rules for scheduling feeds. Each rule's result (matched feeds) is sent to the notify route. See **Scheduls Rule Configuration** section below. | `[]` | No |
+
+### Scheduls Rule Configuration (`scheduls.rules[]`)
+
+| Field | Type | Description | Default | Required |
+| :-------------------------------- | :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------ | :--------------------------------------- |
+| `scheduls.rules[].name` | string | The name of the rule. | | Yes |
+| `scheduls.rules[].query` | string | The semantic query to find relevant feeds. Optional. | | No |
+| `scheduls.rules[].threshold` | float32 | Relevance score threshold (0-1) to filter semantic query results. Only works if `query` is set. | `0.6` | No |
+| `scheduls.rules[].label_filters` | list of strings | Filters based on feed labels (exact match or non-match). e.g. `["category=tech", "source!=github"]`. | `[]` | No |
+| `scheduls.rules[].every_day` | string | Query range relative to the end of each day. Format: `start~end` (HH:MM). e.g., `00:00~23:59` (today), `-22:00~07:00` (yesterday 22:00 to today 07:00). Cannot be set with `watch_interval`. | | No (use `every_day` or `watch_interval`) |
+| `scheduls.rules[].watch_interval` | duration | How often to run the query. e.g. `10m`. Cannot be set with `every_day`. | `10m` | No (use `every_day` or `watch_interval`) |
+
+### Notify Configuration (`notify`)
+
+| Field | Type | Description | Default | Required |
+| :----------------- | :-------------- | :------------------------------------------------------------------------------------------------------------- | :----------- | :---------------------- |
+| `notify.route` | object | The main notify routing configuration. See **Notify Route Configuration** below. | (see fields) | Yes |
+| `notify.receivers` | list of objects | Defines the notification receivers (e.g., email addresses). See **Notify Receiver Configuration** below. | `[]` | Yes (at least one) |
+| `notify.channels` | object | Configures the notification channels (e.g., email SMTP settings). See **Notify Channels Configuration** below. | (see fields) | Yes (if using channels) |
+
+### Notify Route Configuration (`notify.route` and `notify.route.sub_routes[]`)
+
+This structure can be nested using `sub_routes`. A feed is matched against sub-routes first; if no sub-route matches, the parent route's configuration applies.
+
+| Field | Type | Description | Default | Required |
+| :--------------------------------- | :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------ | :------------------- |
+| `...matchers` (only in sub-routes) | list of strings | Label matchers to determine if a feed belongs to this sub-route. e.g. `["category=tech", "source!=github"]`. | `[]` | Yes (for sub-routes) |
+| `...receivers` | list of strings | Names of the receivers (defined in `notify.receivers`) to send notifications for feeds matching this route. | `[]` | Yes (at least one) |
+| `...group_by` | list of strings | Labels to group feeds by before sending notifications. Each group results in a separate notification. e.g., `["source", "category"]`. | `[]` | Yes (at least one) |
+| `...compress_by_related_threshold` | *float32 | If set, compresses highly similar feeds (based on semantic relatedness) within a group, sending only one representative. Threshold (0-1). Higher means more similar. | `0.85` | No |
+| `...sub_routes` | list of objects | Nested routes. Allows defining more specific routing rules. Each object follows the **Notify Route Configuration**. | `[]` | No |
+
+### Notify Receiver Configuration (`notify.receivers[]`)
+
+Defines *who* receives notifications.
+
+| Field | Type | Description | Default | Required |
+| :------------------------- | :----- | :----------------------------------------------- | :------ | :------------------- |
+| `notify.receivers[].name` | string | The unique name of the receiver. Used in routes. | | Yes |
+| `notify.receivers[].email` | string | The email address of the receiver. | | Yes (if using email) |
+
+### Notify Channels Configuration (`notify.channels`)
+
+Configures *how* notifications are sent.
+
+| Field | Type | Description | Default | Required |
+| :---------------------- | :----- | :--------------------------------------------------------------------------------- | :------ | :------------------- |
+| `notify.channels.email` | object | The global email channel config. See **Notify Channel Email Configuration** below. | `nil` | Yes (if using email) |
+
+### Notify Channel Email Configuration (`notify.channels.email`)
+
+| Field | Type | Description | Default | Required |
+| :------------------------------------ | :----- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------- | :------- |
+| `...email.smtp_endpoint` | string | The SMTP server endpoint. e.g. `smtp.gmail.com:587`. | | Yes |
+| `...email.from` | string | The sender email address. | | Yes |
+| `...email.password` | string | The application password for the sender email. (For Gmail, see [Google App Passwords](https://support.google.com/mail/answer/185833)). | | Yes |
+| `...email.feed_markdown_template` | string | Markdown template for formatting each feed in the email body. Default renders the feed content. Cannot be set with `feed_html_snippet_template`. Available template variables depend on feed labels. | `{{ .content }}` | No |
+| `...email.feed_html_snippet_template` | string | HTML snippet template for formatting each feed. Cannot be set with `feed_markdown_template`. Available template variables depend on feed labels. | | No |
\ No newline at end of file
diff --git a/docs/images/add-rss.png b/docs/images/add-rss.png
new file mode 100644
index 0000000..b6501ad
Binary files /dev/null and b/docs/images/add-rss.png differ
diff --git a/docs/images/arch.png b/docs/images/arch.png
new file mode 100644
index 0000000..ffd4cdb
Binary files /dev/null and b/docs/images/arch.png differ
diff --git a/docs/images/chat-with-feeds.png b/docs/images/chat-with-feeds.png
new file mode 100644
index 0000000..37c4338
Binary files /dev/null and b/docs/images/chat-with-feeds.png differ
diff --git a/docs/images/cherry-studio-mcp-prompt.png b/docs/images/cherry-studio-mcp-prompt.png
new file mode 100644
index 0000000..4a59cf3
Binary files /dev/null and b/docs/images/cherry-studio-mcp-prompt.png differ
diff --git a/docs/images/cherry-studio-mcp.png b/docs/images/cherry-studio-mcp.png
new file mode 100644
index 0000000..bc8979b
Binary files /dev/null and b/docs/images/cherry-studio-mcp.png differ
diff --git a/docs/images/daily-brief.png b/docs/images/daily-brief.png
new file mode 100644
index 0000000..21a2566
Binary files /dev/null and b/docs/images/daily-brief.png differ
diff --git a/docs/images/feed-list-with-web.png b/docs/images/feed-list-with-web.png
new file mode 100644
index 0000000..f8828a9
Binary files /dev/null and b/docs/images/feed-list-with-web.png differ
diff --git a/docs/images/migrate-from-follow-1.png b/docs/images/migrate-from-follow-1.png
new file mode 100644
index 0000000..edcd673
Binary files /dev/null and b/docs/images/migrate-from-follow-1.png differ
diff --git a/docs/images/migrate-from-follow-2.png b/docs/images/migrate-from-follow-2.png
new file mode 100644
index 0000000..ced9f0c
Binary files /dev/null and b/docs/images/migrate-from-follow-2.png differ
diff --git a/docs/images/migrate-from-follow-3.png b/docs/images/migrate-from-follow-3.png
new file mode 100644
index 0000000..f281169
Binary files /dev/null and b/docs/images/migrate-from-follow-3.png differ
diff --git a/docs/images/migrate-from-follow-4.png b/docs/images/migrate-from-follow-4.png
new file mode 100644
index 0000000..aa34d27
Binary files /dev/null and b/docs/images/migrate-from-follow-4.png differ
diff --git a/docs/images/migrate-from-follow-5.png b/docs/images/migrate-from-follow-5.png
new file mode 100644
index 0000000..5b84210
Binary files /dev/null and b/docs/images/migrate-from-follow-5.png differ
diff --git a/docs/images/monitoring.png b/docs/images/monitoring.png
new file mode 100644
index 0000000..32568d8
Binary files /dev/null and b/docs/images/monitoring.png differ
diff --git a/docs/images/notification-with-web.png b/docs/images/notification-with-web.png
new file mode 100644
index 0000000..0028ef4
Binary files /dev/null and b/docs/images/notification-with-web.png differ
diff --git a/docs/images/update-config-with-web.png b/docs/images/update-config-with-web.png
new file mode 100644
index 0000000..cb100b8
Binary files /dev/null and b/docs/images/update-config-with-web.png differ
diff --git a/docs/images/web-add-source.png b/docs/images/web-add-source.png
new file mode 100644
index 0000000..aa34d27
Binary files /dev/null and b/docs/images/web-add-source.png differ
diff --git a/docs/images/wechat.png b/docs/images/wechat.png
new file mode 100644
index 0000000..72d90fc
Binary files /dev/null and b/docs/images/wechat.png differ
diff --git a/docs/mcp-client-prompt.md b/docs/mcp-client-prompt.md
new file mode 100644
index 0000000..5e163de
--- /dev/null
+++ b/docs/mcp-client-prompt.md
@@ -0,0 +1,105 @@
+**Your Role:** You are an expert Zenfeed assistant. Your mission is to proactively help the user manage the Zenfeed application and explore its content effectively. You demonstrate deep knowledge of Zenfeed's capabilities, anticipate user needs, and act as an intelligent interface to the application's functions.
+
+**You can, but are not limited to:**
+**Search content:** use semantic search to find articles and information in Zenfeed.
+**Exploring RSSHub:** browse RSSHub's categories, websites, and feeds to help you discover new content sources.
+**Configuring Zenfeed:** modify Zenfeed's settings, such as adding new feeds, configuring information monitoring, sending daily briefs, and so on.
+
+**Interaction Style:**
+
+* **Expert & Insightful:** Showcase your expertise not just by *using* tools, but by explaining the *implications* of the results. Provide relevant context, analysis, and potential next steps. Demonstrate understanding of *why* you're taking an action.
+* **Clearly Structured:** Organize your responses logically using clear headings or bullet points. Follow this structure:
+ 1. **Action Taken:** State clearly *which* tool you are using and *why* it addresses the user's inferred goal.
+ 2. **Key Findings:** Present the essential results from the tool concisely and accurately.
+ 3. **Analysis & Next Steps:** Interpret the findings, explain their significance in relation to the user's goal, and suggest relevant follow-up actions or considerations.
+* **Approachable & Moderately Conversational:** Use clear, natural language. Avoid unnecessary jargon, but maintain a professional and knowledgeable tone. Be helpful, engaging, and guide the user effectively.
+* **Substantive and Informative:** Your replies must be detailed enough to be genuinely useful. **Avoid overly brief or superficial answers.**
+
+**Core Principles:**
+
+1. **Infer Intent, Act Directly, Explain Thoroughly:** Carefully analyze the user's request to determine their underlying objective. Select the *most appropriate* tool and execute it *without asking for confirmation* (except for `apply_app_config`). Then, report and analyze the results comprehensively.
+2. **Prioritize Tool Usage:** Your primary function is to leverage the available Zenfeed tools. **Always attempt to use a relevant tool first** to fulfill the user's request before resorting to general knowledge. Ensure you select the *correct* tool for the task based on your understanding of the user's intent; avoid misusing tools.
+3. **Proactivity:** Anticipate user needs. If a user asks about finding new feeds, proactively suggest exploring categories. If they query content, provide insightful summaries and direct links.
+
+**CRITICAL SAFETY EXCEPTION: Applying Configuration (`apply_app_config`)**
+
+Modifying the application configuration requires **strict adherence** to the following **MANDATORY** steps. **DO NOT DEVIATE:**
+
+1. **Identify Need:** Recognize the user wants to change Zenfeed's configuration.
+2. **Retrieve Current Config (If Needed):** Use `query_app_config` if the current state is unknown or needed for context. State: "Okay, I need to check the current settings first. Retrieving the current Zenfeed configuration..."
+3. **Construct *Complete* New Configuration:** Based *only* on the user's request and potentially the current config, formulate the **entire desired new configuration** in YAML format. This YAML *must* represent the complete final state, including any unchanged settings necessary for a valid config. Ensure correctness and proper formatting.
+4. **Present Full YAML for Review:** Show the user the **complete proposed YAML configuration** you have constructed.
+5. **Explicitly Request Confirmation:** Ask for the user's explicit approval using clear phrasing:
+ * "Okay, I've prepared the following *complete* configuration based on your request. Please review it carefully to ensure it matches exactly what you want:"
+ * `[Present the full YAML here]`
+ * "**Shall I apply this exact configuration to Zenfeed?**"
+6. **Await Clear Confirmation:** **DO NOT** proceed without a clear "yes," "confirm," or equivalent affirmative response *specifically for the presented YAML*.
+7. **Execute `apply_app_config`:** *Only after* receiving explicit confirmation, call the `apply_app_config` tool, passing the *exact confirmed YAML* as the `yaml` parameter.
+8. **Report Outcome:** Inform the user whether the configuration was applied successfully or if an error occurred.
+
+**Typical Workflow Emphasis: Exploring and Adding RSSHub Feeds**
+
+When a user expresses interest in exploring new feeds via RSSHub, anticipate and guide them through this common sequence:
+
+1. **Discover Categories:** Use `query_rsshub_categories` to show available high-level categories.
+ * *Assistant Action Example:* "To help you find new feeds, I'll start by fetching the available RSSHub categories..."
+2. **Explore Websites within a Category:** Once the user chooses a category, use `query_rsshub_websites` with the chosen `category` ID.
+ * *Assistant Action Example:* "Okay, let's look at the websites available in the '[Category Name]' category. Fetching the list..."
+3. **Find Specific Routes/Feeds for a Website:** When the user selects a website, use `query_rsshub_routes` with the chosen `website_id`.
+ * *Assistant Action Example:* "Great, let's see what specific feeds are available for '[Website Name]'. Querying the routes..."
+4. **Prepare Configuration Change:** If the user wants to add a discovered route:
+ * Optionally use `query_app_config_schema` if needed to understand the structure for adding feeds. ("Checking the configuration rules...")
+ * Use `query_app_config` to get the current configuration. ("Fetching your current configuration so I can add the new feed...")
+ * Follow the **CRITICAL SAFETY EXCEPTION** steps precisely to construct the *new complete YAML*, present it, get explicit confirmation, and *then* use `apply_app_config`.
+
+## Available Zenfeed Tools:
+
+1. **`query_app_config_schema`**
+ * **Purpose:** Retrieves the JSON schema defining the structure and validation rules for Zenfeed's configuration (`config.yml`).
+ * **When to Use:** Primarily before constructing a new configuration (`apply_app_config`) to ensure validity, or if the user asks about configuration options. Mention if you're consulting it.
+ * **Input:** None.
+ * **Output:** JSON schema string. (Summarize its purpose if fetched: "I've fetched the schema that defines how the configuration file should be structured.")
+
+2. **`query_app_config`**
+ * **Purpose:** Fetches Zenfeed's *current* operational configuration settings as YAML.
+ * **When to Use:** Essential before proposing changes (`apply_app_config`). Also useful if the user asks about current settings. Fetch proactively when config changes are likely.
+ * **Input:** None.
+ * **Output:** Current configuration as a YAML string. (Summarize key relevant settings.)
+
+3. **`apply_app_config`** (**Requires Strict Confirmation Workflow - See Above!**)
+ * **Purpose:** Applies a *complete new* configuration to Zenfeed, entirely replacing the existing one.
+ * **Input:** `yaml` (string, required): The **complete new configuration** in valid YAML format, **as explicitly confirmed by the user.** To ensure valid YAML output, when generating YAML configurations, do not add backslashes \ after the pipe symbol | for multi-line strings. For example, it should be written as prompt: | instead of prompt: |\
+ * **Output:** Success/error message.
+ * **Reminder:** **NEVER** use without the full confirmation workflow. Safety is paramount.
+
+4. **`query_rsshub_categories`**
+ * **Purpose:** Lists the main categories available within the integrated RSSHub service.
+ * **When to Use:** Use proactively when the user wants to discover new feed types or explore RSSHub content sources.
+ * **Input:** None.
+ * **Output:** JSON list of categories. **Present the category *names* clearly**, perhaps suggesting diverse options. Explain this is the starting point for exploring RSSHub.
+
+5. **`query_rsshub_websites`**
+ * **Purpose:** Lists the specific websites/services available within a *specific* RSSHub category.
+ * **Input:** `category` (string, required): The **ID** of the category (infer from context or user selection, state your assumption if inferring).
+ * **When to Use:** After the user expresses interest in a category (Step 2 of RSSHub exploration). State which category you're querying.
+ * **Output:** JSON list of websites. **Present the website *names* clearly**.
+
+6. **`query_rsshub_routes`**
+ * **Purpose:** Lists the specific feed routes (endpoints/feeds) available for a particular RSSHub website/service.
+ * **Input:** `website_id` (string, required): The **ID** of the website (infer from context or user selection, state assumption if needed).
+ * **When to Use:** When the user wants specific feeds from a chosen website (Step 3 of RSSHub exploration). State which website you're querying.
+ * **Output:** JSON list of routes. **Present the route *titles/descriptions* clearly**, explaining what kind of content each feed represents.
+
+7. **`query`**
+ * **Purpose:** Performs a semantic search over the content collected by Zenfeed feeds within a specified time range.
+ * **Input:**
+ * `query` (string, required): The semantic search terms. **Formulate a specific, effective query (aim for descriptive phrases, potentially >10 words)** based on the user's *information need*, not just echoing their exact words.
+ * `past` (string, optional, default: `"24h"`): Lookback period (e.g., "2h", "36h"). Use default unless specified or context implies otherwise.
+ * **When to Use:** When the user asks to find information, articles, or summaries within their collected feeds. Act directly.
+ * **Output:** A textual summary of the search results. **Crucially, for each relevant finding, include the original `link` using Markdown format: `[Title](link)`.** Briefly explain *why* each result is relevant. Summarize overall findings. If no results are found, state that clearly.
+ * **Note:** Please note that the search results may not be accurate, you need to make a secondary judgment on whether the results are related, only reply based on the related results.
+
+**Final Reminder:** Always prioritize understanding the user's true goal, using the correct tool effectively, and providing clear, structured, insightful responses. Follow the `apply_app_config` safety protocol without exception. Reply in the same language as the user's question.
+When generating YAML configurations, do not add backslashes \ after the pipe symbol | for multi-line strings. For example, it should be written as prompt: | instead of prompt: |\
+
+Reply in the same language as the user's question.
diff --git a/docs/migrate-from-follow.md b/docs/migrate-from-follow.md
new file mode 100644
index 0000000..dc78020
--- /dev/null
+++ b/docs/migrate-from-follow.md
@@ -0,0 +1,11 @@
+## 从 Follow 导出 OPML 文件
+
+
+
+
+
+> 注意一定要填写 http://rsshub:1200
+
+## 导入 zenfeed-web
+
+
diff --git a/docs/preview.md b/docs/preview.md
new file mode 100644
index 0000000..bb49456
--- /dev/null
+++ b/docs/preview.md
@@ -0,0 +1,34 @@
+## 信息监控
+```yaml
+rules:
+ - name: US Tariff Impact
+ query: The various impacts and developments of recent US tariff policies, different perspectives, especially their impact on China
+```
+
+
+## 每日简报
+```yaml
+rules:
+ - name: Evening News
+ every_day: "06:30~18:00"
+```
+
+
+## Chat with feeds
+
+
+
+## 添加 RSS 订阅源
+> 如果你是 RSS 老司机,直接丢 RSS 地址,或者 OPML 文件给 AI 即可
+
+
+
+## 配合 zenfeed-web
+
+
+
+
+
+
+
+
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..d70bde1
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,58 @@
+module github.com/glidea/zenfeed
+
+go 1.23.4
+
+require (
+ github.com/JohannesKaufmann/html-to-markdown v1.6.0
+ github.com/benbjohnson/clock v1.3.5
+ github.com/chewxy/math32 v1.10.1
+ github.com/edsrzf/mmap-go v1.2.0
+ github.com/mark3labs/mcp-go v0.17.0
+ github.com/mmcdole/gofeed v1.3.0
+ github.com/nutsdb/nutsdb v1.0.4
+ github.com/onsi/gomega v1.36.1
+ github.com/pkg/errors v0.9.1
+ github.com/prometheus/client_golang v1.21.1
+ github.com/sashabaranov/go-openai v1.36.1
+ github.com/stretchr/testify v1.10.0
+ github.com/veqryn/slog-dedup v0.5.0
+ github.com/yuin/goldmark v1.7.8
+ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
+ gopkg.in/yaml.v3 v3.0.1
+ k8s.io/utils v0.0.0-20241210054802-24370beab758
+)
+
+require (
+ github.com/PuerkitoBio/goquery v1.9.2 // indirect
+ github.com/andybalholm/cascadia v1.3.2 // indirect
+ github.com/antlabs/stl v0.0.1 // indirect
+ github.com/antlabs/timer v0.0.11 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/bwmarrin/snowflake v0.3.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/gofrs/flock v0.8.1 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
+ github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/prometheus/client_model v0.6.1 // indirect
+ github.com/prometheus/common v0.62.0 // indirect
+ github.com/prometheus/procfs v0.15.1 // indirect
+ github.com/stretchr/objx v0.5.2 // indirect
+ github.com/tidwall/btree v1.6.0 // indirect
+ github.com/xujiajun/mmap-go v1.0.1 // indirect
+ github.com/xujiajun/utils v0.0.0-20220904132955-5f7c5b914235 // indirect
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+ golang.org/x/net v0.38.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
+ golang.org/x/text v0.23.0 // indirect
+ google.golang.org/protobuf v1.36.6 // indirect
+ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
+ modernc.org/b/v2 v2.1.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..9f39d2f
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,200 @@
+github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
+github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
+github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
+github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
+github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
+github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
+github.com/antlabs/stl v0.0.1 h1:TRD3csCrjREeLhLoQ/supaoCvFhNLBTNIwuRGrDIs6Q=
+github.com/antlabs/stl v0.0.1/go.mod h1:wvVwP1loadLG3cRjxUxK8RL4Co5xujGaZlhbztmUEqQ=
+github.com/antlabs/timer v0.0.11 h1:z75oGFLeTqJHMOcWzUPBKsBbQAz4Ske3AfqJ7bsdcwU=
+github.com/antlabs/timer v0.0.11/go.mod h1:JNV8J3yGvMKhCavGXgj9HXrVZkfdQyKCcqXBT8RdyuU=
+github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
+github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
+github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/chewxy/math32 v1.10.1 h1:LFpeY0SLJXeaiej/eIp2L40VYfscTvKh/FSEZ68uMkU=
+github.com/chewxy/math32 v1.10.1/go.mod h1:dOB2rcuFrCn6UHrze36WSLVPKtzPMRAQvBvUwkSsLqs=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
+github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
+github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
+github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
+github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
+github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
+github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
+github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
+github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
+github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/nutsdb/nutsdb v1.0.4 h1:BurzkxijXJY1/AkIXe1ek+U1ta3WGi6nJt4nCLqkxQ8=
+github.com/nutsdb/nutsdb v1.0.4/go.mod h1:jIbbpBXajzTMZ0o33Yn5zoYIo3v0Dz4WstkVce+sYuQ=
+github.com/onsi/ginkgo/v2 v2.20.1 h1:YlVIbqct+ZmnEph770q9Q7NVAz4wwIiVNahee6JyUzo=
+github.com/onsi/ginkgo/v2 v2.20.1/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI=
+github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=
+github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
+github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
+github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
+github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
+github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
+github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
+github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
+github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/sashabaranov/go-openai v1.36.1 h1:EVfRXwIlW2rUzpx6vR+aeIKCK/xylSrVYAx1TMTSX3g=
+github.com/sashabaranov/go-openai v1.36.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
+github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
+github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
+github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
+github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
+github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tidwall/btree v1.6.0 h1:LDZfKfQIBHGHWSwckhXI0RPSXzlo+KYdjK7FWSqOzzg=
+github.com/tidwall/btree v1.6.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY=
+github.com/veqryn/slog-dedup v0.5.0 h1:2pc4va3q8p7Tor1SjVvi1ZbVK/oKNPgsqG15XFEt0iM=
+github.com/veqryn/slog-dedup v0.5.0/go.mod h1:/iQU008M3qFa5RovtfiHiODxJFvxZLjWRG/qf/zKFHw=
+github.com/xujiajun/mmap-go v1.0.1 h1:7Se7ss1fLPPRW+ePgqGpCkfGIZzJV6JPq9Wq9iv/WHc=
+github.com/xujiajun/mmap-go v1.0.1/go.mod h1:CNN6Sw4SL69Sui00p0zEzcZKbt+5HtEnYUsc6BKKRMg=
+github.com/xujiajun/utils v0.0.0-20220904132955-5f7c5b914235 h1:w0si+uee0iAaCJO9q86T6yrhdadgcsoNuh47LrUykzg=
+github.com/xujiajun/utils v0.0.0-20220904132955-5f7c5b914235/go.mod h1:MR4+0R6A9NS5IABnIM3384FfOq8QFVnm7WDrBOhIaMU=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
+github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
+golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
+golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
+gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
+gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
+gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
+gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0=
+k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
+modernc.org/b/v2 v2.1.0 h1:kMD/G43EYnsFJI/0qK1F1X659XlSs41bp01MUDidHC0=
+modernc.org/b/v2 v2.1.0/go.mod h1:fQhHWDXrchyUSLjQYCslV/4uw04PW1LeiZ25D4SNmeo=
+modernc.org/mathutil v1.4.1 h1:ij3fYGe8zBF4Vu+g0oT7mB06r8sqGWKuJu1yXeR4by8=
+modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/strutil v1.1.1 h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs=
+modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
diff --git a/install/config-template.yaml b/install/config-template.yaml
new file mode 100644
index 0000000..43cd1e8
--- /dev/null
+++ b/install/config-template.yaml
@@ -0,0 +1,25 @@
+timezone: Asia/Shanghai
+llms:
+ - name: general
+ default: true
+ provider: siliconflow
+ model: Qwen/Qwen2.5-32B-Instruct
+ - name: embed
+ provider: siliconflow
+ embedding_model: Pro/BAAI/bge-m3
+scrape:
+ rsshub_endpoint: http://rsshub:1200
+storage:
+ feed:
+ rewrites:
+ - transform:
+ to_text:
+ prompt: |
+ {{ .summary_html_snippet }}
+ label: summary_html_snippet
+ embedding_llm: embed
+notify:
+ channels:
+ email:
+ feed_html_snippet_template: |
+ {{ .summary_html_snippet }}
diff --git a/install/docker-compose.yml b/install/docker-compose.yml
new file mode 100644
index 0000000..00ae224
--- /dev/null
+++ b/install/docker-compose.yml
@@ -0,0 +1,24 @@
+version: "3.8"
+services:
+ zenfeed:
+ image: glidea/zenfeed:latest
+ volumes:
+ - data:/app/data
+ - type: bind
+ source: ./config
+ target: /app/config
+ ports:
+ - "1300:1300"
+ - "1301:1301"
+ depends_on:
+ - rsshub
+
+ rsshub:
+ image: diygod/rsshub:latest
+ ports:
+ - "1200:1200"
+ environment:
+ - NODE_ENV=production
+
+volumes:
+ data: {}
\ No newline at end of file
diff --git a/install/render.sh b/install/render.sh
new file mode 100755
index 0000000..f3a92b5
--- /dev/null
+++ b/install/render.sh
@@ -0,0 +1,131 @@
+#!/bin/bash
+
+YQ_IMAGE="mikefarah/yq:latest"
+
+template_source=""
+values_args=()
+
+# --- Parse command line arguments ---
+while [[ $# -gt 0 ]]; do
+ key="$1"
+ case $key in
+ --template)
+ template_source="$2"
+ shift # past argument
+ shift # past value
+ ;;
+ --values)
+ # Collect all arguments after --values until next -- argument or end
+ shift # past --values
+ while [[ $# -gt 0 ]] && [[ ! "$1" =~ ^-- ]]; do
+ values_args+=("$1")
+ shift # past value argument
+ done
+ ;;
+ *) # Unknown option
+ echo "Error: Unknown option $1" >&2
+ exit 1
+ ;;
+ esac
+done
+
+# --- Get template content ---
+current_yaml=""
+if [[ -z "$template_source" ]]; then
+ # If no template provided, start with empty YAML
+ current_yaml="{}"
+elif [[ "$template_source" =~ ^https?:// ]]; then
+ # Download from URL
+ # Use curl, exit if fails
+ if ! command -v curl &> /dev/null; then
+ echo "Error: curl command required to download URL template." >&2
+ exit 1
+ fi
+ template_content=$(curl -sfL "$template_source")
+ if [[ $? -ne 0 ]]; then
+ echo "Error: Failed to download template from URL: $template_source" >&2
+ exit 1
+ fi
+ # Check if downloaded content is empty
+ if [[ -z "$template_content" ]]; then
+ current_yaml="{}"
+ else
+ current_yaml="$template_content"
+ fi
+
+elif [[ -f "$template_source" ]]; then
+ # Read from local file
+ current_yaml=$(cat "$template_source")
+ # Check if file content is empty
+ if [[ -z "$current_yaml" ]]; then
+ current_yaml="{}"
+ fi
+else
+ # Invalid template source
+ echo "Error: Invalid template source '$template_source'. Please provide valid file path or HTTP/HTTPS URL." >&2
+ exit 1
+fi
+
+
+# --- Check if Docker is available ---
+if ! command -v docker &> /dev/null; then
+ echo "Error: docker command required to run yq." >&2
+ exit 1
+fi
+# Try pulling or verifying yq image (helps catch issues early)
+docker pull $YQ_IMAGE > /dev/null
+
+# --- Apply values ---
+if [[ ${#values_args[@]} -gt 0 ]]; then
+ for val_arg in "${values_args[@]}"; do
+ # Parse key=value
+ if [[ ! "$val_arg" =~ ^([^=]+)=(.*)$ ]]; then
+ continue
+ fi
+
+ # BASH_REMATCH is result array from =~ operator
+ yaml_path="${BASH_REMATCH[1]}"
+ raw_value="${BASH_REMATCH[2]}"
+
+ # Prepare yq value (try handling basic types, otherwise treat as string)
+ yq_value=""
+ if [[ "$raw_value" == "true" || "$raw_value" == "false" || "$raw_value" == "null" ]]; then
+ yq_value="$raw_value"
+ # Check if integer or float (simple regex)
+ elif [[ "$raw_value" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then
+ # If value starts with 0 but isn't 0 itself and has no decimal point, force string to prevent octal interpretation
+ if [[ "$raw_value" =~ ^0[0-9]+$ ]]; then
+ # Need to escape internal double quotes
+ escaped_value=$(echo "$raw_value" | sed 's/"/\\"/g')
+ yq_value="\"$escaped_value\""
+ else
+ yq_value="$raw_value"
+ fi
+ else
+ # Treat as string, need to escape internal double quotes
+ escaped_value=$(echo "$raw_value" | sed 's/"/\\"/g')
+ yq_value="\"$escaped_value\""
+ fi
+
+ # Build yq expression
+ yq_expression=".$yaml_path = $yq_value"
+
+ # Apply update via docker run yq
+ # Pass current YAML via stdin to yq, get stdout as new YAML
+ # Use <<< for here-string input to avoid temp files
+ new_yaml=$(docker run --rm -i "$YQ_IMAGE" "$yq_expression" <<< "$current_yaml")
+ yq_exit_code=$?
+
+ if [[ $yq_exit_code -ne 0 ]]; then
+ echo "Error: yq execution failed (exit code: $yq_exit_code). Expression: '$yq_expression'" >&2
+ # Could output yq error message, but requires more complex docker run call to capture stderr
+ exit 1
+ fi
+ current_yaml="$new_yaml"
+ done
+fi
+
+# --- Output final result ---
+printf "%s\n" "$current_yaml"
+
+exit 0
\ No newline at end of file
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..a5564fe
--- /dev/null
+++ b/main.go
@@ -0,0 +1,398 @@
+// Copyright (C) 2025 wangyusong
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see Source: %s/%s
+Published: %s | Scraped: %s
`, + title, link, typ, source, pubTime, scrapeTime); err != nil { + return errors.Wrap(err, "write feed header") + } + + return nil +} + +func (e *email) writeFeedBody(buf *buffer.Bytes, feed *route.Feed) error { + if _, err := buf.WriteString(`Related:
`); err != nil { + return errors.Wrapf(err, "write relateds header") + } + + for _, f := range related { + relTyp := f.Labels.Get(model.LabelType) + relSource := f.Labels.Get(model.LabelSource) + relTitle := f.Labels.Get(model.LabelTitle) + relLink := f.Labels.Get(model.LabelLink) + + if _, err := fmt.Fprintf(buf, ` +
+ 免责声明 / Disclaimer
+ 本邮件内容仅用于个人概括性学习和理解,版权归原作者所有。
+ This email content is for personal learning and understanding purposes only. All rights reserved to the original author.
+
+ 严禁二次分发或传播!!!
NO redistribution or sharing!!!
+
+ 如有侵权,请联系 / For copyright issues, please contact:
+ ysking7402@gmail.com
+
+ Here is the key viewpoint or finding that needs to be highlighted. +
+Metric Name
+75%
+June 1, 2023
+Event description content, concisely explaining the key points and impact of the event.
+June 15, 2023
+Event description content, concisely explaining the key points and impact of the event.
+| Feature | +Option A | +Option B | +
|---|---|---|
| Cost | +Higher | +Moderate | +
| Efficiency | +Very High | +Average | +
Data Comparison
+ + +Tip
++ Here are some additional tips or suggestions to help readers better understand or apply the article content. +
+In Simple Terms
++ This is a concise summary of the entire content, highlighting the most critical findings and conclusions. +
+Bold and italic text
\n"), + }, + }, + { + Scenario: "Convert markdown with links to HTML", + When: "converting markdown text with links to HTML", + Then: "should return HTML with proper links", + WhenDetail: whenDetail{ + markdown: []byte("[Link](https://example.com)"), + }, + ThenExpected: thenExpected{ + html: []byte("\n"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.Scenario, func(_ *testing.T) { + // When. + html, err := MarkdownToHTML(tt.WhenDetail.markdown) + + // Then. + if tt.ThenExpected.err != "" { + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring(tt.ThenExpected.err)) + } else { + Expect(err).To(BeNil()) + Expect(html).To(Equal(tt.ThenExpected.html)) + } + }) + } +} + +func TestHTMLToMarkdown(t *testing.T) { + RegisterTestingT(t) + + type givenDetail struct{} + type whenDetail struct { + html []byte + } + type thenExpected struct { + markdown []byte + err string + } + + tests := []test.Case[givenDetail, whenDetail, thenExpected]{ + { + Scenario: "Convert simple HTML to markdown", + When: "converting HTML text to markdown", + Then: "should return correct markdown", + WhenDetail: whenDetail{ + html: []byte("Bold and italic text
"), + }, + ThenExpected: thenExpected{ + markdown: []byte("**Bold** and _italic_ text"), + }, + }, + { + Scenario: "Convert HTML with links to markdown", + When: "converting HTML text with links to markdown", + Then: "should return markdown with proper links", + WhenDetail: whenDetail{ + html: []byte(""), + }, + ThenExpected: thenExpected{ + markdown: []byte("[Link](https://example.com)"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.Scenario, func(_ *testing.T) { + // When. + markdown, err := HTMLToMarkdown(tt.WhenDetail.html) + + // Then. + if tt.ThenExpected.err != "" { + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring(tt.ThenExpected.err)) + } else { + Expect(err).To(BeNil()) + Expect(markdown).To(Equal(tt.ThenExpected.markdown)) + } + }) + } +} diff --git a/pkg/util/time/time.go b/pkg/util/time/time.go new file mode 100644 index 0000000..fca5290 --- /dev/null +++ b/pkg/util/time/time.go @@ -0,0 +1,86 @@ +// Copyright (C) 2025 wangyusong +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see