From d520444e9f5f8ce9a7142cddd14e33ae62995dbb Mon Sep 17 00:00:00 2001 From: glidea <740696441@qq.com> Date: Thu, 5 Jun 2025 23:29:37 +0800 Subject: [PATCH] add rss & crawl & webhook --- Makefile | 9 +- README-en.md | 6 +- README.md | 19 +- docs/config-zh.md | 69 ++-- docs/config.md | 267 +++++++------ docs/crawl-zh.md | 88 +++++ docs/images/folo-html.png | Bin 0 -> 161022 bytes docs/model-selection-zh.md | 12 + docs/roadmap-zh.md | 19 + docs/rss-api-zh.md | 59 +++ docs/tech/rewrite-zh.md | 4 +- docs/wehook-zh.md | 148 +++++++ go.mod | 4 +- go.sum | 11 +- main.go | 43 +- pkg/api/api.go | 69 ++-- pkg/api/http/http.go | 23 +- pkg/api/rss/rss.go | 231 +++++++++++ pkg/config/config.go | 35 +- pkg/llm/llm.go | 83 +++- pkg/llm/openai.go | 12 +- pkg/llm/prompt/prompt.go | 156 ++++++++ pkg/model/model.go | 71 ++++ pkg/notify/channel/channel.go | 7 +- pkg/notify/channel/email.go | 20 +- pkg/notify/channel/webhook.go | 14 +- pkg/notify/notify.go | 12 +- pkg/notify/route/route.go | 60 +-- pkg/rewrite/rewrite.go | 373 +++++------------- pkg/rewrite/rewrite_test.go | 4 + pkg/schedule/rule/periodic.go | 2 +- pkg/schedule/rule/rule.go | 6 +- pkg/scrape/scraper/rss.go | 1 - pkg/scrape/scraper/scraper.go | 6 +- pkg/storage/feed/block/block.go | 62 +-- .../feed/block/index/inverted/inverted.go | 24 +- .../block/index/inverted/inverted_test.go | 46 ++- pkg/storage/kv/kv.go | 24 +- pkg/telemetry/log/log.go | 5 +- pkg/telemetry/server/server.go | 137 +++++++ pkg/util/crawl/crawl.go | 176 +++++++++ pkg/util/{rpc/rpc.go => jsonrpc/jsonrpc.go} | 34 +- .../rpc_test.go => jsonrpc/jsonrpc_test.go} | 9 +- 43 files changed, 1757 insertions(+), 703 deletions(-) create mode 100644 docs/crawl-zh.md create mode 100644 docs/images/folo-html.png create mode 100644 docs/model-selection-zh.md create mode 100644 docs/roadmap-zh.md create mode 100644 docs/rss-api-zh.md create mode 100644 docs/wehook-zh.md create mode 100644 pkg/api/rss/rss.go create mode 100644 pkg/llm/prompt/prompt.go create mode 100644 pkg/telemetry/server/server.go create mode 100644 pkg/util/crawl/crawl.go rename pkg/util/{rpc/rpc.go => jsonrpc/jsonrpc.go} (75%) rename pkg/util/{rpc/rpc_test.go => jsonrpc/jsonrpc_test.go} (96%) diff --git a/Makefile b/Makefile index 8a8a492..ff676c4 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ REGISTRY ?= glidea FULL_IMAGE_NAME = $(REGISTRY)/$(IMAGE_NAME) -.PHONY: test push build-installer +.PHONY: test push dev-push test: go test -race -v -coverprofile=coverage.out -coverpkg=./... ./... @@ -16,3 +16,10 @@ push: -t $(FULL_IMAGE_NAME):$(VERSION) \ -t $(FULL_IMAGE_NAME):latest \ --push . + +dev-push: + docker buildx create --use --name multi-platform-builder || true + docker buildx build --platform linux/amd64,linux/arm64 \ + --build-arg VERSION=$(VERSION) \ + -t $(FULL_IMAGE_NAME):$(VERSION) \ + --push . diff --git a/README-en.md b/README-en.md index 66de421..684790a 100644 --- a/README-en.md +++ b/README-en.md @@ -73,7 +73,7 @@ Just for the exquisite email styles, install and use it now! ### 1. Installation -By default, uses SiliconFlow's Qwen/Qwen2.5-7B-Instruct (free) and Pro/BAAI/bge-m3. If you don't have a SiliconFlow account yet, use this [invitation link](https://cloud.siliconflow.cn/i/U2VS0Q5A) to get a ¥14 credit. +By default, uses SiliconFlow's Qwen/Qwen3-8B (free) and Pro/BAAI/bge-m3. If you don't have a SiliconFlow account yet, use this [invitation link](https://cloud.siliconflow.cn/i/U2VS0Q5A) to get a ¥14 credit. Support for other vendors or models is available; follow the instructions below. @@ -84,7 +84,7 @@ curl -L -O https://raw.githubusercontent.com/glidea/zenfeed/main/docker-compose. # If you need to customize more configuration parameters, directly edit docker-compose.yml#configs.zenfeed_config.content BEFORE running the command below. # Configuration Docs: https://github.com/glidea/zenfeed/blob/main/docs/config.md -API_KEY=your_apikey TZ=your_local_IANA LANG=English docker-compose -p zenfeed up -d +API_KEY=your_apikey TZ=your_local_IANA LANGUAGE=English docker-compose -p zenfeed up -d ``` #### Windows @@ -94,7 +94,7 @@ Invoke-WebRequest -Uri "https://raw.githubusercontent.com/glidea/zenfeed/main/do # If you need to customize more configuration parameters, directly edit docker-compose.yml#configs.zenfeed_config.content BEFORE running the command below. # Configuration Docs: https://github.com/glidea/zenfeed/blob/main/docs/config.md -$env:API_KEY = "your_apikey"; $env:TZ = "your_local_IANA"; $env:LANG = "English"; docker-compose -p zenfeed up -d +$env:API_KEY = "your_apikey"; $env:TZ = "your_local_IANA"; $env:LANGUAGE = "English"; docker-compose -p zenfeed up -d ``` ### 2. Using the Web UI diff --git a/README.md b/README.md index 9661a80..2607d73 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,19 @@ **1. AI 版 RSS 阅读器** +* 在线服务 + * https://zenfeed.xyz + * 或 Folo 搜索 zenfeed + **2. 实时 “新闻” 知识库** **3. 帮你时刻关注 “指定事件” 的秘书(如 “关税政策变化”,“xx 股票波动”)**,并支持整理研究报告 -开箱即用的公共服务站:https://zenfeed.xyz (集成 Hacker News,Github Trending,V2EX 热榜等常见公开信源) - -每日研究报告(包含播客)(实验性质) +每日研究报告(包含播客)(实验性质) -- 已暂停更新 * [V2EX](https://v2ex.analysis.zenfeed.xyz/) * [LinuxDO](https://linuxdo.analysis.zenfeed.xyz/) +--- 技术说明文档见:[HLD](docs/tech/hld-zh.md) ## 前言 @@ -98,7 +101,7 @@ zenfeed 是你的智能信息助手。它自动收集、筛选并总结关注的 ### 1. 安装 > 最快 1min 拉起 -默认使用硅基流动的 Qwen/Qwen2.5-7B-Instruct(免费) 和 Pro/BAAI/bge-m3。如果你还没有硅基账号,使用 [邀请链接](https://cloud.siliconflow.cn/i/U2VS0Q5A) 得 14 元额度 +默认使用硅基流动的 Qwen/Qwen3-8B (免费) 和 Pro/BAAI/bge-m3。如果你还没有硅基账号,使用 [邀请链接](https://cloud.siliconflow.cn/i/U2VS0Q5A) 得 14 元额度 如果需要使用其他厂商或模型,或自定义部署:请编辑下方 **docker-compose.yml**#configs.zenfeed_config.content. 参考 [配置文档](https://github.com/glidea/zenfeed/blob/main/docs/config-zh.md) @@ -142,6 +145,14 @@ $env:API_KEY = "硅基流动apikey"; docker-compose -p zenfeed up -d 以 Cherry Studio 为例,配置 MCP 并连接到 Zenfeed,见 [Cherry Studio MCP](docs/cherry-studio-mcp.md) > 默认地址 http://localhost:1301/sse +### 后续 + +zenfeed 提供了超多的自定义配置,还有很多玩法等待你挖掘。详细请查阅[文档](/docs/) + +### Roadmap + +[Roadmap](/docs/roadmap-zh.md) + ## 欢迎加群讨论 > 使用问题请提 Issue,谢绝微信私聊。帮助有类似问题的朋友 diff --git a/docs/config-zh.md b/docs/config-zh.md index 380c226..9240669 100644 --- a/docs/config-zh.md +++ b/docs/config-zh.md @@ -1,19 +1,22 @@ -| 字段 | 类型 | 描述 | 默认值 | 是否必需 | -| :--------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------- | :------------- | -| `timezone` | `string` | 应用的时区。例如 `Asia/Shanghai`。 | 服务器本地时区 | 否 | -| `log` | `object` | 日志配置。详见下方的 **日志配置** 部分。 | (见具体字段) | 否 | -| `api` | `object` | API 配置。详见下方的 **API 配置** 部分。 | (见具体字段) | 否 | -| `llms` | `列表` | 大语言模型 (LLM) 配置。会被其他配置部分引用。详见下方的 **LLM 配置** 部分。 | `[]` | 是 (至少 1 个) | -| `scrape` | `object` | 抓取配置。详见下方的 **抓取配置** 部分。 | (见具体字段) | 否 | -| `storage` | `object` | 存储配置。详见下方的 **存储配置** 部分。 | (见具体字段) | 否 | -| `scheduls` | `object` | 用于监控 Feed 的调度配置 (也称为监控规则)。详见下方的 **调度配置** 部分。 | (见具体字段) | 否 | -| `notify` | `object` | 通知配置。它接收来自调度模块的结果,通过路由配置进行分组,并通过通知渠道发送给通知接收者。详见下方的 **通知配置**, **通知路由**, **通知接收者**, **通知渠道** 部分。 | (见具体字段) | 是 | +| 字段 | 类型 | 描述 | 默认值 | 是否必需 | +| :---------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------- | :------------- | +| `timezone` | `string` | 应用的时区。例如 `Asia/Shanghai`。 | 服务器本地时区 | 否 | +| `telemetry` | `object` | Telemetry 配置。详见下方的 **Telemetry 配置** 部分。 | (见具体字段) | 否 | +| `api` | `object` | API 配置。详见下方的 **API 配置** 部分。 | (见具体字段) | 否 | +| `llms` | `列表` | 大语言模型 (LLM) 配置。会被其他配置部分引用。详见下方的 **LLM 配置** 部分。 | `[]` | 是 (至少 1 个) | +| `jina` | `object` | Jina AI 配置。详见下方的 **Jina AI 配置** 部分。 | (见具体字段) | 否 | +| `scrape` | `object` | 抓取配置。详见下方的 **抓取配置** 部分。 | (见具体字段) | 否 | +| `storage` | `object` | 存储配置。详见下方的 **存储配置** 部分。 | (见具体字段) | 否 | +| `scheduls` | `object` | 用于监控 Feed 的调度配置 (也称为监控规则)。详见下方的 **调度配置** 部分。 | (见具体字段) | 否 | +| `notify` | `object` | 通知配置。它接收来自调度模块的结果,通过路由配置进行分组,并通过通知渠道发送给通知接收者。详见下方的 **通知配置**, **通知路由**, **通知接收者**, **通知渠道** 部分。 | (见具体字段) | 是 | -### 日志配置 (`log`) +### Telemetry 配置 (`telemetry`) -| 字段 | 类型 | 描述 | 默认值 | 是否必需 | -| :---------- | :------- | :--------------------------------------------------------- | :----- | :------- | -| `log.level` | `string` | 日志级别, 可选值为 `debug`, `info`, `warn`, `error` 之一。 | `info` | 否 | +| 字段 | 类型 | 描述 | 默认值 | 是否必需 | +| :-------------------- | :------- | :----------------------------------------------------------------------------- | :----------- | :------- | +| `telemetry.address` | `string` | 暴露 Prometheus 指标 & pprof。 | | 否 | +| `telemetry.log` | `object` | Telemetry 相关的日志配置。 | (见具体字段) | 否 | +| `telemetry.log.level` | `string` | Telemetry 相关消息的日志级别, 可选值为 `debug`, `info`, `warn`, `error` 之一。 | `info` | 否 | ### API 配置 (`api`) @@ -40,6 +43,14 @@ | `llms[].embedding_model` | `string` | LLM 的 Embedding 模型。例如 `text-embedding-3-small`。如果用于 Embedding,则不能为空。如果此 LLM 被使用,则不能与 `model` 同时为空。**注意:** 初次使用后请勿直接修改,应添加新的 LLM 配置。 | | 条件性必需 | | `llms[].temperature` | `float32` | LLM 的温度 (0-2)。 | `0.0` | 否 | +### Jina AI 配置 (`jina`) + +此部分用于配置 Jina AI Reader API 的相关参数,主要供重写规则中的 `crawl_by_jina` 类型使用。 + +| 字段 | 类型 | 描述 | 默认值 | 是否必需 | +| :----------- | :------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----- | :------- | +| `jina.token` | `string` | Jina AI 的 API Token。从 [Jina AI API Dashboard](https://jina.ai/api-dashboard/) 获取。提供 Token 可以获得更高的服务速率限制。如果留空,将以匿名用户身份请求,速率限制较低。 | | 否 | + ### 抓取配置 (`scrape`) | 字段 | 类型 | 描述 | 默认值 | 是否必需 | @@ -88,15 +99,16 @@ 定义在存储前处理 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`) | +| 字段 | 类型 | 描述 | 默认值 | 是否必需 | +| :--------------------------------------- | :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------- | :--------------------------------------------- | +| `...rewrites[].if` | `字符串列表` | 用于匹配 Feed 的条件配置。如果未设置,则表示匹配所有 Feed。类似于标签过滤器,例如 `["source=github", "title!=xxx"]`。如果条件不满足,则跳过此规则。 | `[]` (匹配所有) | 否 | +| `...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`) @@ -106,10 +118,13 @@ ### 重写规则转换为文本配置 (`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 }}`。 | | 是 | +此配置定义了如何将 `source_label` 的文本进行转换。 + +| 字段 | 类型 | 描述 | 默认值 | 是否必需 | +| :------------------ | :------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------- | :--------------------------- | +| `...to_text.type` | `string` | 转换的类型。可选值: | `prompt` | 否 | +| `...to_text.llm` | `string` | **仅当 `type` 为 `prompt` 时有效。** 用于转换的 LLM 名称 (来自 `llms` 部分)。如果未指定,将使用在 `llms` 部分中标记为 `default: true` 的 LLM。 | `llms` 部分中的默认 LLM | 否 | +| `...to_text.prompt` | `string` | **仅当 `type` 为 `prompt` 时有效。** 用于转换的 Prompt。源文本将被注入。可以使用 Go 模板语法引用内置 Prompt: `{{ .summary }}`, `{{ .category }}`, `{{ .tags }}`, `{{ .score }}`, `{{ .comment_confucius }}`, `{{ .summary_html_snippet }}`。 | | 是 (如果 `type` 是 `prompt`) | ### 调度配置 (`scheduls`) diff --git a/docs/config.md b/docs/config.md index 1358094..79fffa8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,181 +1,196 @@ -| 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 | +| Field | Type | Description | Default Value | Required | +| :---------- | :------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------- | :--------------- | +| `timezone` | `string` | The application's timezone. E.g., `Asia/Shanghai`. | Server local time | No | +| `telemetry` | `object` | Telemetry configuration. See the **Telemetry Configuration** section below. | (See specific fields) | No | +| `api` | `object` | API configuration. See the **API Configuration** section below. | (See specific fields) | No | +| `llms` | `list` | Large Language Model (LLM) configuration. Referenced by other configuration sections. See the **LLM Configuration** section below. | `[]` | Yes (at least 1) | +| `jina` | `object` | Jina AI configuration. See the **Jina AI Configuration** section below. | (See specific fields) | No | +| `scrape` | `object` | Scrape configuration. See the **Scrape Configuration** section below. | (See specific fields) | No | +| `storage` | `object` | Storage configuration. See the **Storage Configuration** section below. | (See specific fields) | No | +| `scheduls` | `object` | Scheduling configuration for monitoring feeds (also known as monitoring rules). See the **Scheduling Configuration** section below. | (See specific fields) | No | +| `notify` | `object` | Notification configuration. It receives results from the scheduling module, groups them via routing configuration, and sends them to notification receivers via notification channels. See the **Notification Configuration**, **Notification Routing**, **Notification Receivers**, **Notification Channels** sections below. | (See specific fields) | Yes | -### Log Configuration (`log`) +### Telemetry Configuration (`telemetry`) -| Field | Type | Description | Default | Required | -| :---------- | :----- | :-------------------------------------------------- | :------ | :------- | -| `log.level` | string | Log level, one of `debug`, `info`, `warn`, `error`. | `info` | No | +| Field | Type | Description | Default Value | Required | +| :-------------------- | :------- | :--------------------------------------------------------------------------------- | :-------------------- | :------- | +| `telemetry.address` | `string` | Exposes Prometheus metrics & pprof. | | No | +| `telemetry.log` | `object` | Log configuration related to telemetry. | (See specific fields) | No | +| `telemetry.log.level` | `string` | Log level for telemetry-related messages, one of `debug`, `info`, `warn`, `error`. | `info` | No | -**API Configuration (`api`)** +### 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) | +| Field | Type | Description | Default Value | Required | +| :----------------- | :------- | :--------------------------------------------------------------------------------------------------------------------------- | :---------------------------- | :--------------------- | +| `api.http` | `object` | HTTP API configuration. | (See specific fields) | No | +| `api.http.address` | `string` | Address for the HTTP API (`[host]:port`). E.g., `0.0.0.0:1300`. Cannot be changed after the application starts. | `:1300` | No | +| `api.mcp` | `object` | MCP API configuration. | (See specific fields) | No | +| `api.mcp.address` | `string` | Address for the MCP API (`[host]:port`). E.g., `0.0.0.0:1301`. Cannot be changed after the application starts. | `:1301` | No | +| `api.llm` | `string` | Name of the LLM used 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 using summary) | ### LLM Configuration (`llms[]`) -This section defines a list of available Large Language Models. At least one LLM configuration is required. +This section defines the 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 | +| Field | Type | Description | Default Value | Required | +| :----------------------- | :-------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------- | :--------------------------------------------------------- | +| `llms[].name` | `string` | Name (or 'id') of the LLM. E.g., `my-favorite-gemini-king`. Used to refer to this LLM in other configuration sections (e.g., `api.llm`, `storage.feed.embedding_llm`). | | 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 relying on default behavior) | +| `llms[].provider` | `string` | Provider of the LLM, one of `openai`, `openrouter`, `deepseek`, `gemini`, `volc`, `siliconflow`. E.g., `openai`. | | Yes | +| `llms[].endpoint` | `string` | Custom endpoint for the LLM. E.g., `https://api.openai.com/v1`. | (Provider-specific default) | No | +| `llms[].api_key` | `string` | API key for the LLM. | | Yes | +| `llms[].model` | `string` | Model of the LLM. E.g., `gpt-4o-mini`. Cannot be empty if used for generation tasks (e.g., summarization). If this LLM is used, cannot be empty along with `embedding_model`. | | Conditionally Required | +| `llms[].embedding_model` | `string` | Embedding model of the LLM. E.g., `text-embedding-3-small`. Cannot be empty if used for embedding. If this LLM is used, cannot be empty along with `model`. **Note:** Do not modify directly after initial use; add a new LLM configuration instead. | | Conditionally Required | +| `llms[].temperature` | `float32` | Temperature of the LLM (0-2). | `0.0` | No | + +### Jina AI Configuration (`jina`) + +This section configures parameters related to the Jina AI Reader API, primarily used by the `crawl_by_jina` type in rewrite rules. + +| Field | Type | Description | Default Value | Required | +| :----------- | :------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------ | :------- | +| `jina.token` | `string` | API Token for Jina AI. Obtain from [Jina AI API Dashboard](https://jina.ai/api-dashboard/). Providing a token grants higher service rate limits. If left empty, requests will be made as an anonymous user with lower rate limits. | | 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. | `24h` | 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) | +| Field | Type | Description | Default Value | Required | +| :----------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------ | :----------------------------------- | +| `scrape.past` | `time.Duration` | Time window to look back when scraping feeds. E.g., `1h` means only scrape feeds from the past 1 hour. | `24h` | No | +| `scrape.interval` | `time.Duration` | Frequency to scrape each source (global default). E.g., `1h`. | `1h` | No | +| `scrape.rsshub_endpoint` | `string` | Endpoint for RSSHub. You can deploy your own RSSHub server or use a public instance (see [RSSHub Documentation](https://docs.rsshub.app/guide/instances)). E.g., `https://rsshub.app`. | | Yes (if `rsshub_route_path` is used) | +| `scrape.sources` | `list of objects` | List of sources to scrape feeds from. 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) | +| Field | Type | Description | Default Value | Required | +| :-------------------------- | :------------------ | :------------------------------------------------------------------------------------------------------------------------------------ | :---------------- | :-------------------------- | +| `scrape.sources[].interval` | `time.Duration` | Frequency to scrape this specific source. Overrides global `scrape.interval`. | Global `interval` | No | +| `scrape.sources[].name` | `string` | Name of the source. Used to tag feeds. | | Yes | +| `scrape.sources[].labels` | `map[string]string` | Additional key-value labels to attach to feeds from this source. | `{}` | No | +| `scrape.sources[].rss` | `object` | RSS configuration for this source. See **Scrape Source RSS Configuration** below. Each source can only have one type set (e.g., RSS). | `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`) | +| Field | Type | Description | Default Value | Required | +| :--------------------------------------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------ | :-------------------------------------------------------- | +| `scrape.sources[].rss.url` | `string` | 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` | RSSHub route path. E.g., `github/trending/daily/any`. Will be concatenated with `scrape.rsshub_endpoint` to form the final URL. Cannot be set if `url` is set. | | Yes (unless `url` is set, and 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 | +| Field | Type | Description | Default Value | Required | +| :------------- | :------- | :------------------------------------------------------------------------------ | :-------------------- | :------- | +| `storage.dir` | `string` | Base directory for all storage. Cannot be changed after the application starts. | `./data` | No | +| `storage.feed` | `object` | Feed storage configuration. See **Feed Storage Configuration** below. | (See specific 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 | +| Field | Type | Description | Default Value | 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` | `time.Duration` | Frequency to flush feed storage to the database. Higher values risk more data loss but reduce disk operations and improve performance. | `200ms` | No | +| `storage.feed.embedding_llm` | `string` | Name of the LLM used for feed embedding (from `llms` section). Significantly impacts semantic search accuracy. **Note:** If switching, keep the old LLM configuration as past data is implicitly associated with it, otherwise past data cannot be semantically searched. | Default LLM in `llms` section | No | +| `storage.feed.retention` | `time.Duration` | Retention duration for feeds. | `8d` | No | +| `storage.feed.block_duration` | `time.Duration` | Retention duration for 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. +Defines rules to process feeds before storage. Rules are applied sequentially. -| 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`) | +| Field | Type | Description | Default Value | Required | +| :--------------------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :----------------------- | :-------------------------------------------- | +| `...rewrites[].if` | `list of strings` | Conditions to match feeds. If not set, matches all feeds. Similar to label filters, e.g., `["source=github", "title!=xxx"]`. If conditions are not met, this rule is skipped. | `[]` (matches all) | No | +| `...rewrites[].source_label` | `string` | Feed label used as the source text for transformation. Default labels include: `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 will be skipped by this rule (processing continues to the next rule, or feed storage if no more rules). Helps filter out feeds that are too short/uninformative. | `300` | No | +| `...rewrites[].transform` | `object` | Configures how to transform the `source_label` text. See **Rewrite Rule Transform Configuration** below. If not set, the `source_label` text is used directly for matching. | `nil` | No | +| `...rewrites[].match` | `string` | Simple string to match against the (transformed) text. Cannot be set with `match_re`. | | No (use `match` or `match_re`) | +| `...rewrites[].match_re` | `string` | Regular expression to match against the (transformed) text. | `.*` (matches all) | No (use `match` or `match_re`) | +| `...rewrites[].action` | `string` | Action to perform on match: `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` | Name of the feed label 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 | +| Field | Type | Description | Default Value | Required | +| :--------------------- | :------- | :--------------------------------------------------------------------------------------------- | :------------ | :------- | +| `...transform.to_text` | `object` | Transforms source text to text using an LLM. See **Rewrite Rule To Text Configuration** below. | `nil` | No | -### Rewrite Rule Transform To Text Configuration (`storage.feed.rewrites[].transform.to_text`) +### Rewrite Rule 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 | +This configuration defines how to transform the text from `source_label`. -### Scheduls Configuration (`scheduls`) +| Field | Type | Description | Default Value | Required | +| :------------------ | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------- | :-------------------------- | +| `...to_text.type` | `string` | Type of transformation. Options: | `prompt` | No | +| `...to_text.llm` | `string` | **Only valid if `type` is `prompt`.** Name of the LLM used for transformation (from `llms` section). If not specified, the LLM marked as `default: true` in the `llms` section will be used. | Default LLM in `llms` section | No | +| `...to_text.prompt` | `string` | **Only valid if `type` is `prompt`.** Prompt used for transformation. The source text will be injected. You can use Go template syntax to reference built-in prompts: `{{ .summary }}`, `{{ .category }}`, `{{ .tags }}`, `{{ .score }}`, `{{ .comment_confucius }}`, `{{ .summary_html_snippet }}`. | | Yes (if `type` is `prompt`) | + +### Scheduling 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 | +| Field | Type | Description | Default Value | Required | +| :--------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------ | :------- | +| `scheduls.rules` | `list of objects` | List of rules for scheduling feeds. The results of each rule (matched feeds) will be sent to notification routes. See **Scheduling Rule Configuration** below. | `[]` | No | -### Scheduls Rule Configuration (`scheduls.rules[]`) +### Scheduling 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`) | +| Field | Type | Description | Default Value | Required | +| :-------------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------ | :--------------------------------------- | +| `scheduls.rules[].name` | `string` | Name of the rule. | | Yes | +| `scheduls.rules[].query` | `string` | Semantic query to find relevant feeds. Optional. | | No | +| `scheduls.rules[].threshold` | `float32` | Relevance score threshold (0-1) for filtering semantic query results. Only effective if `query` is set. | `0.6` | No | +| `scheduls.rules[].label_filters` | `list of strings` | Filters based on feed labels (equals or not equals). 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` | `time.Duration` | Frequency to run the query. E.g., `10m`. Cannot be set with `every_day`. | `10m` | No (use `every_day` or `watch_interval`) | -### Notify Configuration (`notify`) +### Notification 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) | +| Field | Type | Description | Default Value | Required | +| :----------------- | :---------------- | :-------------------------------------------------------------------------------------------------------------- | :-------------------- | :---------------------- | +| `notify.route` | `object` | Main notification routing configuration. See **Notification Routing Configuration** below. | (See specific fields) | Yes | +| `notify.receivers` | `list of objects` | Defines notification receivers (e.g., email addresses). See **Notification Receiver Configuration** below. | `[]` | Yes (at least one) | +| `notify.channels` | `object` | Configures notification channels (e.g., email SMTP settings). See **Notification Channel Configuration** below. | (See specific fields) | Yes (if using channels) | -### Notify Route Configuration (`notify.route` and `notify.route.sub_routes[]`) +### Notification Routing 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. +This structure can be nested using `sub_routes`. Feeds will first try to match sub-routes; if no sub-route matches, the parent route's configuration is applied. -| 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) | -| `...source_label` | string | The source label to extract the content from each feed, and summarize them. Default are all labels. It is very recommended to set it to 'summary' to reduce context length. | all labels | No | -| `...summary_prompt` | string | The prompt to summarize the feeds of each group. | | No | -| `...llm` | string | The LLM name to use. Default is the default LLM in `llms` section. A large context length LLM is recommended. | default LLM in `llms` section | No | -| `...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 | +| Field | Type | Description | Default Value | Required | +| :--------------------------------- | :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------- | :-------------------- | +| `...matchers` (sub-routes only) | `list of strings` | Label matchers to determine if a feed belongs to this sub-route. E.g., `["category=tech", "source!=github"]`. | `[]` | Yes (sub-routes only) | +| `...receivers` | `list of strings` | List of receiver names (defined in `notify.receivers`) to send notifications for feeds matching this route. | `[]` | Yes (at least one) | +| `...group_by` | `list of strings` | List of labels to group feeds by before sending notifications. Each group results in a separate notification. E.g., `["source", "category"]`. | `[]` | Yes (at least one) | +| `...source_label` | `string` | Source label to extract content from each feed for summarization. Defaults to all labels. Strongly recommended to set to 'summary' to reduce context length. | All labels | No | +| `...summary_prompt` | `string` | Prompt to summarize feeds for each group. | | No | +| `...llm` | `string` | Name of the LLM to use. Defaults to the default LLM in the `llms` section. Recommended to use an LLM with a large context length. | Default LLM in `llms` section | No | +| `...compress_by_related_threshold` | `*float32` | If set, compresses highly similar feeds within a group based on semantic relatedness, sending only one representative. Threshold (0-1), higher means more similar. | `0.85` | No | +| `...sub_routes` | `list of objects` | List of nested routes. Allows defining more specific routing rules. Each object follows **Notification Routing Configuration**. | `[]` | No | -### Notify Receiver Configuration (`notify.receivers[]`) +### Notification 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) | +| Field | Type | Description | Default Value | Required | +| :------------------------- | :------- | :------------------------------------------- | :------------ | :------------------- | +| `notify.receivers[].name` | `string` | Unique name of the receiver. Used in routes. | | Yes | +| `notify.receivers[].email` | `string` | Email address of the receiver. | | Yes (if using Email) | -### Notify Channels Configuration (`notify.channels`) +### Notification Channel 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) | +| Field | Type | Description | Default Value | Required | +| :---------------------- | :------- | :------------------------------------------------------------------------------------------ | :------------ | :------------------- | +| `notify.channels.email` | `object` | Global Email channel configuration. See **Notification Channel Email Configuration** below. | `nil` | Yes (if using Email) | -### Notify Channel Email Configuration (`notify.channels.email`) +### Notification 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 +| Field | Type | Description | Default Value | Required | +| :------------------------------------ | :------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------- | :------- | +| `...email.smtp_endpoint` | `string` | SMTP server endpoint. E.g., `smtp.gmail.com:587`. | | Yes | +| `...email.from` | `string` | Sender's email address. | | Yes | +| `...email.password` | `string` | App-specific password for the sender's 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. Renders feed content by default. 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 | diff --git a/docs/crawl-zh.md b/docs/crawl-zh.md new file mode 100644 index 0000000..2969d79 --- /dev/null +++ b/docs/crawl-zh.md @@ -0,0 +1,88 @@ +# 使用 Zenfeed 爬虫功能 + +Zenfeed 提供了将网页内容抓取并转换为 Markdown 格式的功能。这主要通过重写规则 (`rewrites` rule) 中的 `transform.to_text.type` 配置项实现。 + +## 如何使用 + +在你的配置文件中,找到 `storage.feed.rewrites` 部分。当你定义一条重写规则时,可以通过 `transform` 字段来启用爬虫功能。 + +具体配置如下: + +```yaml +storage: + feed: + rewrites: + - if: ["source=xxx", ...] + source_label: "link" # 指定包含 URL 的标签,例如 feed 中的 'link' 标签 + transform: + to_text: + type: "crawl" # 或 "crawl_by_jina" + # llm: "your-llm-name" # crawl 类型不需要 llm + # prompt: "your-prompt" # crawl 类型不需要 prompt + # match: ".*" # 可选:对抓取到的 Markdown 内容进行匹配 + action: "create_or_update_label" # 对抓取到的内容执行的动作 + label: "crawled_content" # 将抓取到的 Markdown 存储到这个新标签 + # ... 其他配置 ... +jina: # 如果使用 crawl_by_jina,并且需要更高的速率限制(匿名ip: 20 RPM),请配置 Jina API Token + token: "YOUR_JINA_AI_TOKEN" # 从 https://jina.ai/api-dashboard/ 获取 +``` + +### 转换类型 (`transform.to_text.type`) + +你有以下几种选择: + +1. **`crawl`**: + * Zenfeed 将使用内置的本地爬虫尝试抓取 `source_label` 中指定的 URL。 + * 它会尝试遵循目标网站的 `robots.txt` 协议。 + * 适用于静态网页或结构相对简单的网站。 + +2. **`crawl_by_jina`**: + * Zenfeed 将通过 [Jina AI Reader API](https://jina.ai/reader/) 来抓取和处理 `source_label` 中指定的 URL。 + * Jina AI 可能能更好地处理动态内容和复杂网站结构。 + * 同样遵循目标网站的 `robots.txt` 协议。 + * **依赖 Jina AI 服务**: + * 建议在配置文件的顶层添加 `jina.token` (如上示例) 来提供你的 Jina AI API Token,以获得更高的服务速率限制。 + * 如果未提供 Token,将以匿名用户身份请求,速率限制较低。 + * 请查阅 Jina AI 的服务条款和隐私政策。 + +### 关键配置说明 + +* `source_label`: 此标签的值**必须是一个有效的 URL**。例如,如果你的 RSS Feed 中的 `link` 标签指向的是一篇包含完整文章的网页,你可以将 `source_label` 设置为 `link`。 +* `action`: 通常设置为 `create_or_update_label`,将抓取并转换后的 Markdown 内容存入一个新的标签中(由 `label` 字段指定)。 +* `label`: 指定存储抓取到的 Markdown 内容的新标签名称。 + +## 使用场景 + +**全文内容提取**: +很多 RSS 源只提供文章摘要和原文链接。使用爬虫功能可以将原文完整内容抓取下来,转换为 Markdown 格式,方便后续的 AI 处理(如总结、打标签、分类等)或直接阅读。 + +## 免责声明 + +**在使用 Zenfeed 的爬虫功能(包括 `crawl` 和 `crawl_by_jina` 类型)前,请仔细阅读并理解以下声明。您的使用行为即表示您已接受本声明的所有条款。** + +1. **用户责任与授权**: + * 您将对使用爬虫功能的所有行为承担全部责任。 + * 您必须确保拥有访问、抓取和处理所提供 URL 内容的合法权利。 + * 请严格遵守目标网站的 `robots.txt` 协议、服务条款 (ToS)、版权政策以及所有相关的法律法规。 + * 不得使用本功能处理、存储或分发任何非法、侵权、诽谤、淫秽或其他令人反感的内容。 + +2. **内容准确性与完整性**: + * 网页抓取和 Markdown 转换过程的结果可能不准确、不完整或存在偏差。这可能受到目标网站结构、反爬虫机制、动态内容渲染、网络问题等多种因素的影响。 + * Zenfeed 项目作者和贡献者不对抓取内容的准确性、完整性、及时性或质量作任何保证。 + +3. **第三方服务依赖 (`crawl_by_jina`)**: + * `crawl_by_jina` 功能依赖于 Jina AI 提供的第三方服务。 + * Jina AI 服务的可用性、性能、数据处理政策、服务条款以及可能的费用(超出免费额度后)均由 Jina AI 自行决定。 + * 项目作者和贡献者不对 Jina AI 服务的任何方面负责。请在使用前查阅 [Jina AI 的相关条款](https://jina.ai/terms/) 和 [隐私政策](https://jina.ai/privacy/)。 + +4. **无间接或后果性损害赔偿**: + * 在任何情况下,无论基于何种法律理论,项目作者和贡献者均不对因使用或无法使用爬虫功能而导致的任何直接、间接、偶然、特殊、惩戒性或后果性损害负责,包括但不限于利润损失、数据丢失、商誉损失或业务中断。 + +5. **法律与合规风险**: + * 未经授权抓取、复制、存储、处理或传播受版权保护的内容,或违反网站服务条款的行为,可能违反相关法律法规,并可能导致法律纠纷或处罚。 + * 用户需自行承担因使用爬虫功能而产生的所有法律风险和责任。 + +6. **"按原样"提供**: + * 爬虫功能按"现状"和"可用"的基础提供,不附带任何形式的明示或默示担保。 + +**强烈建议您在启用和配置爬虫功能前,仔细评估相关风险,并确保您的使用行为完全合法合规。对于任何因用户滥用或不当使用本软件(包括爬虫功能)而引起的法律纠纷、损失或损害,Zenfeed 项目作者和贡献者不承担任何责任。** diff --git a/docs/images/folo-html.png b/docs/images/folo-html.png new file mode 100644 index 0000000000000000000000000000000000000000..4910feb650222aeb64ff13cd97ba6fa5850c45a8 GIT binary patch literal 161022 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

1zLGOx<$*| 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 literal 0 HcmV?d00001 diff --git a/docs/model-selection-zh.md b/docs/model-selection-zh.md new file mode 100644 index 0000000..3fc80da --- /dev/null +++ b/docs/model-selection-zh.md @@ -0,0 +1,12 @@ +如果无需使用 HTML 总结,模型可以随便选择 + +## 背景 & 原则 +* Token 使用会很多,你可以想象每篇 RSS 都总结一遍会有多少消耗。所以优先选择免费模型,或者按次计费 +* HTML 生成对模型有较高要求。所以你现在知道了为什么自部署的默认总结效果比不上 https://zenfeed.xyz +* 那为什么不支持 Markdown 呢?web 还没精力支持,你可以先用邮件日报替代 +* 总结都是后台任务,且支持有状态重试,对模型速率限制 & 稳定性没有要求 +* 所以 “1. 质量”,“2. 低价”,“3. 稳定”。首选 1,兼顾 2,无需 3 + +## 如果你对默认的模型效果不满意,首选推荐 +* **不缺钱 or “有路子”**:Gemini 2.5 Pro +* **再便宜点的**:Gemini 2.5 Flash diff --git a/docs/roadmap-zh.md b/docs/roadmap-zh.md new file mode 100644 index 0000000..2a6e056 --- /dev/null +++ b/docs/roadmap-zh.md @@ -0,0 +1,19 @@ +## 短期 +* 播客 + * NotebookLM 的播客效果让人惊艳 + * 技术上复刻一个并不难,难的是没有又便宜效果又好的 TTS API(只用得起小帅的声音😭) + * TTS 音色进步也只是近几年的事情,长期需要等成本下降 + * 短期因为我个人很喜欢播客总结(应该也很适合大家通勤),会先本地部署模型,提供给 https://zenfeed.xyz 使用 + +* ebup2rss + * 见过 rss2ebup,但你绝没见过反着来的 + * 严格上这并不属于 zenfeed,顶多算生态项目吧 + * 抛开时效性,书比新闻更有价值。但当你立下 “坚持阅读” 的 flag,然后呢? + * 这个子项目旨在实现:每日更新一章,作为 rss 暴露。在阅读新闻 RSS 时,“顺便” 把书给看了 + * 这里遵循《掌控习惯》的几个原理 + * 让它显而易见:在你的新闻阅读器里 + * 让它简便易行:配合 zenfeed 总结,更轻松地阅读要点(进一步了解原文逃不掉,但这时你已经被勾住了,相信这事已经没那么困难了) + * 让你感觉到爽:zenfeed 阅读完后的木鱼声,嗯这算一个,确信 + +## 中长期 +* WIP diff --git a/docs/rss-api-zh.md b/docs/rss-api-zh.md new file mode 100644 index 0000000..4973bdd --- /dev/null +++ b/docs/rss-api-zh.md @@ -0,0 +1,59 @@ +# 托管源 + +## Folo + +直接搜索 zenfeed + +## Other + +```bash +https://zenfeed.xyz/rss?.... 参数用法见下方《自部署》 + +https://zenfeed.xyz/rss?label_filter=source=知乎热榜 # 你在 zenfeed.xyz 中看到的源名称 + +https://zenfeed.xyz/rss?query=AI # 语义搜索。请不要滥用,成本 cover 不住可能随时下线 +``` + +# 自部署 + +## 1. 配置(可选) + +```yaml +api: + rss: + content_html_template: | # 可自由排版搭配(go template 语法);需要确保渲染后的内容是正确的 HTML + {{ .summary_html_snippet }} # 默认值 +``` + +## 2. enjoy RSS address! + +```bash +your_zenfeed_address/rss?label_filter=label1=value1&label_filter=label2!=value2&query=xxx + +# e.g. + +## Past 24h rss feed for GithubTrending +http://localhost:1302/rss?label_filter=source=GithubTrending + +## Past 24h rss feed for Tech category +http://localhost:1302/rss?label_filter=category=Tech + +## Past 24h rss feed for dynamic query +http://localhost:1302/rss?query=特朗普最新消息 +``` + +# FAQ + +## 添加失败怎么办? + +部分 RSS 阅读器通过服务端间接访问 RSS 地址,如果 zenfeed 部署到本地,将无法访问 + +你需要通过内网穿透,或者 VPS 暴露到公网上,注意仅暴露 1302 端口 + +## Folo 看起来只有纯文本? + +![](images/folo-html.png) + +## 暗黑模式显示有问题? + +嗯就是有问题,请使用白底背景,否则样式渲染会出现问题 diff --git a/docs/tech/rewrite-zh.md b/docs/tech/rewrite-zh.md index 0859be9..f6b1ac2 100644 --- a/docs/tech/rewrite-zh.md +++ b/docs/tech/rewrite-zh.md @@ -5,11 +5,13 @@ ## 1. 设计理念与哲学 * **Prometheus 的 `relabel_config`**: 借鉴其强大的标签重写能力。在 Prometheus 中,`relabel_config` 允许用户在采集指标前后动态地修改标签集,实现服务发现、指标过滤和路由等高级功能。`rewrite` 组件将此思想应用于信息流处理,将每一条信息(如一篇文章、一个帖子)视为一个标签集,通过规则来操作这些标签。 -* **管道 (Pipeline) 处理模式**: 信息的处理过程被设计成一个可配置的管道。每个规则是管道中的一个处理阶段,信息流经这些规则,逐步被转换和打标。这种模式使得复杂的处理逻辑可以被分解为一系列简单、独立的步骤,易于理解和维护。 +* **管道 (Pipeline) 处理模式**: 信息的处理过程被设计成一个可配置的 ETL 管道。每个规则是管道中的一个处理阶段,信息流经这些规则,逐步被转换和打标。这种模式使得复杂的处理逻辑可以被分解为一系列简单、独立的步骤,易于理解和维护。 * **AI 能力的模块化与按需应用**: 大型语言模型 (LLM) 被视为一种强大的"转换函数"。用户可以根据需求,在规则中指定使用哪个 LLM、配合什么样的提示词 (Prompt) 来处理特定的文本内容(例如,从文章正文生成摘要、分类、评分等)。这种设计使得 AI 能力可以灵活地嵌入到信息处理的任意环节。 * **内容即标签 (Content as Labels)**: 这是 zenfeed 的一个核心抽象。原始信息(如标题、正文、链接、发布时间)和经过 AI 或规则处理后产生的衍生信息(如类别、标签、评分、摘要)都被统一表示为键值对形式的"标签"。这种统一表示简化了后续的查询、过滤、路由和展示逻辑。 * **声明式配置优于命令式代码**: 用户通过 YAML 配置文件定义重写规则,而不是编写代码来实现处理逻辑。这降低了使用门槛,使得非程序员也能方便地定制自己的信息处理流程,同时也使得配置更易于管理和版本控制。 +> 简单说这是一条专门针对 Feed 处理的可配置工作流 + ## 2. 业务流程 内容重写组件的核心工作流程是接收一个代表信息单元的标签集 (`model.Labels`),然后按顺序应用预定义的重写规则 (`Rule`),最终输出一个经过修改的标签集,或者指示该信息单元应被丢弃。 diff --git a/docs/wehook-zh.md b/docs/wehook-zh.md new file mode 100644 index 0000000..3aaa655 --- /dev/null +++ b/docs/wehook-zh.md @@ -0,0 +1,148 @@ +# Zenfeed Webhook 通知对接指南 + +Zenfeed 支持通过 Webhook 将分组和总结后的 Feed 通知推送到您指定的 HTTP(S) 端点。这允许您将 Zenfeed 的通知集成到自定义的应用或工作流程中。 + +## 1. 配置方法 + +要在 Zenfeed 中配置 Webhook 通知,您需要在配置文件的 `notify.receivers` 部分定义一个或多个接收者,并为每个 Webhook 接收者指定其唯一的 `name` 和 `webhook` 配置块。 + +**示例配置 (`config.yaml`):** + +```yaml +notify: + # ... 其他通知配置 ... + + receivers: + - name: my_awesome_webhook # 接收者的唯一名称,将在路由规则中引用 + webhook: + url: "https://your-service.com/webhook-endpoint" # 您的 Webhook 接收端点 URL + + # 示例:路由规则中如何使用此接收者 + route: # or sub_routes.. + receivers: + - my_awesome_webhook # 引用上面定义的接收者名称 + # ... 其他路由配置 ... +``` + +在上述示例中: +- 我们定义了一个名为 `my_awesome_webhook` 的接收者。 +- `webhook.url` 字段指定了当有匹配此接收者的通知时,Zenfeed 将向哪个 URL 发送 POST 请求。 + +## 2. 数据格式详解 + +当 Zenfeed 向您的 Webhook 端点发送通知时,它会发送一个 `POST` 请求,请求体为 JSON 格式。 + +请求体结构如下: + +```json +{ + "group": "string", + "labels": { + "label_key1": "label_value1", + "label_key2": "label_value2" + }, + "summary": "string", + "feeds": [ + { + "labels": { + "title": "Feed Title 1", + "link": "http://example.com/feed1", + "content": "Feed content snippet 1...", + "source": "example_source", + "pub_time": "2024-07-30T10:00:00Z" + // ... 其他自定义或标准标签 + }, + "time": "2024-07-30T10:00:00Z", + "related": [ + // 可选:与此 Feed 相关的其他 Feed 对象,结构同父 Feed + ] + } + // ...更多 Feed 对象 + ] +} +``` + +**字段说明:** + +- `group` (`string`): + 当前通知所属的组名。这个名称是根据通知路由配置中 `group_by` 定义的标签值组合而成的。例如,如果 `group_by: ["source", "category"]`,且一个 Feed 组的 `source` 是 `github_trending`,`category` 是 `golang`,那么 `group` 可能类似于 `"github_trending/golang"`。 + +- `labels` (`object`): + 一个键值对对象,表示当前通知组的标签。这些标签是根据通知路由配置中 `group_by` 所指定的标签及其对应的值。 + 例如,如果 `group_by: ["source"]` 且当前组的 `source` 标签值为 `rsshub`,则 `labels` 会是 `{"source": "rsshub"}`。 + +- `summary` (`string`): + 由大语言模型 (LLM) 为当前这一组 Feed 生成的摘要文本。如果通知路由中没有配置 LLM 总结,此字段可能为空字符串或省略 (取决于具体的实现细节,但通常会是空字符串)。 + +- `feeds` (`array` of `object`): + 一个数组,包含了属于当前通知组的所有 Feed 对象。每个 Feed 对象包含以下字段: + * `labels` (`object`): Feed 的元数据。这是一个键值对对象,包含了该 Feed 的所有标签,例如: + * `title` (`string`): Feed 的标题。 + * `link` (`string`): Feed 的原始链接。 + * `content` (`string`): Feed 的内容摘要或全文 (取决于抓取和重写规则)。 + * `source` (`string`): Feed 的来源标识。 + * `pub_time` (`string`): Feed 的发布时间 (RFC3339 格式的字符串,例如 `2025-01-01T00:00:00Z`)。 + * ...以及其他在抓取或重写过程中添加的自定义标签。 + * `time` (`string`): Feed 的时间戳,通常是其发布时间,采用 RFC3339 格式 (例如 `2025-01-01T00:00:00Z`)。此字段与 `labels.pub_time` 通常一致,但 `time` 是系统内部用于时间序列处理的主要时间字段。 + * `related` (`array` of `object`, 可选): + 一个数组,包含了与当前 Feed 语义相关的其他 Feed 对象。这通常在通知路由中启用了 `compress_by_related_threshold` 选项时填充。每个相关的 Feed 对象结构与父 Feed 对象完全相同。如果未启用相关性压缩或没有相关的 Feed,此字段可能为空数组或不存在。 + +## 3. 请求示例 + +以下是一个发送到您的 Webhook 端点的 JSON 请求体示例: + +```json +{ + "group": "my_favorite_blogs", + "labels": { + "category": "tech_updates", + }, + "summary": "今天有多篇关于最新 AI 技术进展的文章,重点关注了大型语言模型在代码生成方面的应用,以及其对未来软件开发模式的潜在影响。", + "feeds": [ + { + "labels": { + "content": "AlphaCode X 展示了惊人的代码理解和生成能力,在多个编程竞赛中超越了人类平均水平...", + "link": "https://example.blog/alphacode-x-details", + "pub_time": "2024-07-30T14:35:10Z", + "source": "Example Tech Blog", + "title": "AlphaCode X: 下一代 AI 编码助手", + "type": "blog_post" + }, + "time": "2024-07-30T14:35:10Z", + "related": [] + }, + { + "labels": { + "content": "讨论了当前 LLM 在实际软件工程项目中落地所面临的挑战,包括成本、可控性和安全性问题。", + "link": "https://another.blog/llm-in-swe-challenges", + "pub_time": "2024-07-30T11:15:00Z", + "source": "Another Tech Review", + "title": "LLM 在软件工程中的应用:机遇与挑战", + "type": "rss" + }, + "time": "2024-07-30T11:15:00Z", + "related": [ + { + "labels": { + "content": "一篇关于如何更经济有效地部署和微调大型语言模型的指南。", + "link": "https://some.other.blog/cost-effective-llm", + "pub_time": "2024-07-30T09:00:00Z", + "source": "AI Infra Weekly", + "title": "经济高效的 LLM 部署策略", + "type": "rss" + }, + "time": "2024-07-30T09:00:00Z", + "related": [] + } + ] + } + ] +} +``` + +## 4. 响应要求 + +Zenfeed 期望您的 Webhook 端点在成功接收并处理通知后,返回 HTTP `200 OK` 状态码。 +如果 Zenfeed 收到任何非 `200` 的状态码,它会将该次通知尝试标记为失败,并可能根据重试策略进行重试 (具体重试行为取决于 Zenfeed 的内部实现)。 + +请确保您的端点能够及时响应,以避免超时。 diff --git a/go.mod b/go.mod index d70bde1..140f8d3 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +7,14 @@ require ( github.com/benbjohnson/clock v1.3.5 github.com/chewxy/math32 v1.10.1 github.com/edsrzf/mmap-go v1.2.0 + github.com/gorilla/feeds 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/sashabaranov/go-openai v1.40.1 github.com/stretchr/testify v1.10.0 github.com/veqryn/slog-dedup v0.5.0 github.com/yuin/goldmark v1.7.8 @@ -45,6 +46,7 @@ require ( 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/temoto/robotstxt v1.1.2 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 diff --git a/go.sum b/go.sum index 9f39d2f..6b5ab8a 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSF 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/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= +github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= 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= @@ -44,8 +46,9 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN 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/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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= @@ -84,8 +87,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6O 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/sashabaranov/go-openai v1.40.1 h1:bJ08Iwct5mHBVkuvG6FEcb9MDTfsXdTYPGjYLRdeTEU= +github.com/sashabaranov/go-openai v1.40.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= @@ -99,6 +102,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P 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/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= +github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= 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= diff --git a/main.go b/main.go index d7f0814..b6d5216 100644 --- a/main.go +++ b/main.go @@ -28,6 +28,7 @@ import ( "github.com/glidea/zenfeed/pkg/api" "github.com/glidea/zenfeed/pkg/api/http" "github.com/glidea/zenfeed/pkg/api/mcp" + "github.com/glidea/zenfeed/pkg/api/rss" "github.com/glidea/zenfeed/pkg/component" "github.com/glidea/zenfeed/pkg/config" "github.com/glidea/zenfeed/pkg/llm" @@ -47,6 +48,7 @@ import ( "github.com/glidea/zenfeed/pkg/storage/feed/block/index/vector" "github.com/glidea/zenfeed/pkg/storage/kv" "github.com/glidea/zenfeed/pkg/telemetry/log" + telemetryserver "github.com/glidea/zenfeed/pkg/telemetry/server" timeutil "github.com/glidea/zenfeed/pkg/util/time" ) @@ -118,6 +120,7 @@ type App struct { configPath string configMgr config.Manager conf *config.App + telemetry telemetryserver.Server kvStorage kv.Storage llmFactory llm.Factory @@ -126,6 +129,7 @@ type App struct { api api.API http http.Server mcp mcp.Server + rss rss.Server scraperMgr scrape.Manager scheduler schedule.Scheduler notifier notify.Notifier @@ -153,6 +157,10 @@ func (a *App) setup() error { return a.applyGlobals(newConf) })) + if err := a.setupTelemetryServer(); err != nil { + return errors.Wrap(err, "setup telemetry server") + } + if err := a.setupKVStorage(); err != nil { return errors.Wrap(err, "setup kv storage") } @@ -174,6 +182,9 @@ func (a *App) setup() error { if err := a.setupMCPServer(); err != nil { return errors.Wrap(err, "setup mcp server") } + if err := a.setupRSSServer(); err != nil { + return errors.Wrap(err, "setup rss server") + } if err := a.setupScraper(); err != nil { return errors.Wrap(err, "setup scraper") } @@ -209,8 +220,8 @@ func (a *App) applyGlobals(conf *config.App) error { if err := timeutil.SetLocation(conf.Timezone); err != nil { return errors.Wrapf(err, "set timezone to %s", conf.Timezone) } - if err := log.SetLevel(log.Level(conf.Log.Level)); err != nil { - return errors.Wrapf(err, "set log level to %s", conf.Log.Level) + if err := log.SetLevel(log.Level(conf.Telemetry.Log.Level)); err != nil { + return errors.Wrapf(err, "set log level to %s", conf.Telemetry.Log.Level) } return nil @@ -271,6 +282,16 @@ func (a *App) setupFeedStorage() (err error) { return nil } +// setupTelemetryServer initializes the Telemetry server. +func (a *App) setupTelemetryServer() (err error) { + a.telemetry, err = telemetryserver.NewFactory().New(component.Global, a.conf, telemetryserver.Dependencies{}) + if err != nil { + return err + } + + return nil +} + // setupAPI initializes the API service. func (a *App) setupAPI() (err error) { a.api, err = api.NewFactory().New(component.Global, a.conf, api.Dependencies{ @@ -315,6 +336,20 @@ func (a *App) setupMCPServer() (err error) { return nil } +// setupRSSServer initializes the RSS server. +func (a *App) setupRSSServer() (err error) { + a.rss, err = rss.NewFactory().New(component.Global, a.conf, rss.Dependencies{ + API: a.api, + }) + if err != nil { + return err + } + + a.configMgr.Subscribe(a.rss) + + return nil +} + // setupScraper initializes the Scraper manager. func (a *App) setupScraper() (err error) { a.scraperMgr, err = scrape.NewFactory().New(component.Global, a.conf, scrape.Dependencies{ @@ -384,12 +419,12 @@ func (a *App) run(ctx context.Context) error { log.Info(ctx, "starting application components...") if err := component.Run(ctx, component.Group{a.configMgr}, - component.Group{a.llmFactory}, + component.Group{a.llmFactory, a.telemetry}, component.Group{a.rewriter}, component.Group{a.feedStorage}, component.Group{a.kvStorage}, component.Group{a.notifier, a.api}, - component.Group{a.http, a.mcp, a.scraperMgr, a.scheduler}, + component.Group{a.http, a.mcp, a.rss, a.scraperMgr, a.scheduler}, ); err != nil && !errors.Is(err, context.Canceled) { return err } diff --git a/pkg/api/api.go b/pkg/api/api.go index 1150afc..69f231e 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -37,7 +37,6 @@ import ( telemetry "github.com/glidea/zenfeed/pkg/telemetry" telemetrymodel "github.com/glidea/zenfeed/pkg/telemetry/model" jsonschema "github.com/glidea/zenfeed/pkg/util/json_schema" - "github.com/glidea/zenfeed/pkg/util/rpc" ) // --- Interface code block --- @@ -161,11 +160,11 @@ type QueryRequest struct { } func (r *QueryRequest) Validate() error { //nolint:cyclop - if r.Query != "" && utf8.RuneCountInString(r.Query) < 5 { - return errors.New("query must be at least 5 characters") + if r.Query != "" && utf8.RuneCountInString(r.Query) > 64 { + return errors.New("query must be at most 64 characters") } if r.Threshold == 0 { - r.Threshold = 0.55 + r.Threshold = 0.5 } if r.Threshold < 0 || r.Threshold > 1 { return errors.New("threshold must be between 0 and 1") @@ -200,6 +199,28 @@ type QueryResponse struct { Count int `json:"count"` } +type Error struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e Error) Error() string { + return e.Message +} + +func newError(code int, err error) Error { + return Error{ + Code: code, + Message: err.Error(), + } +} + +var ( + ErrBadRequest = func(err error) Error { return newError(http.StatusBadRequest, err) } + ErrNotFound = func(err error) Error { return newError(http.StatusNotFound, err) } + ErrInternal = func(err error) Error { return newError(http.StatusInternalServerError, err) } +) + // --- Factory code block --- type Factory component.Factory[API, config.App, Dependencies] @@ -262,7 +283,7 @@ func (a *api) QueryAppConfigSchema( ) (resp *QueryAppConfigSchemaResponse, err error) { schema, err := jsonschema.ForType(reflect.TypeOf(config.App{})) if err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "query app config schema")) + return nil, ErrInternal(errors.Wrap(err, "query app config schema")) } return (*QueryAppConfigSchemaResponse)(&schema), nil @@ -282,7 +303,7 @@ func (a *api) ApplyAppConfig( req *ApplyAppConfigRequest, ) (resp *ApplyAppConfigResponse, err error) { if err := a.Dependencies().ConfigManager.SaveAppConfig(&req.App); err != nil { - return nil, rpc.ErrBadRequest(errors.Wrap(err, "save app config")) + return nil, ErrBadRequest(errors.Wrap(err, "save app config")) } return &ApplyAppConfigResponse{}, nil @@ -297,20 +318,20 @@ func (a *api) QueryRSSHubCategories( // New request. forwardReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "new request")) + return nil, ErrInternal(errors.Wrap(err, "new request")) } // Do request. forwardRespIO, err := a.hc.Do(forwardReq) if err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "query rss hub websites")) + return nil, ErrInternal(errors.Wrap(err, "query rss hub websites")) } defer func() { _ = forwardRespIO.Body.Close() }() // Parse response. var forwardResp map[string]RSSHubWebsite if err := json.NewDecoder(forwardRespIO.Body).Decode(&forwardResp); err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "parse response")) + return nil, ErrInternal(errors.Wrap(err, "parse response")) } // Convert to response. @@ -333,7 +354,7 @@ func (a *api) QueryRSSHubWebsites( ctx context.Context, req *QueryRSSHubWebsitesRequest, ) (resp *QueryRSSHubWebsitesResponse, err error) { if req.Category == "" { - return nil, rpc.ErrBadRequest(errors.New("category is required")) + return nil, ErrBadRequest(errors.New("category is required")) } url := a.Config().RSSHubEndpoint + "/api/category/" + req.Category @@ -341,29 +362,29 @@ func (a *api) QueryRSSHubWebsites( // New request. forwardReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "new request")) + return nil, ErrInternal(errors.Wrap(err, "new request")) } // Do request. forwardRespIO, err := a.hc.Do(forwardReq) if err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "query rss hub routes")) + return nil, ErrInternal(errors.Wrap(err, "query rss hub routes")) } defer func() { _ = forwardRespIO.Body.Close() }() // Parse response. body, err := io.ReadAll(forwardRespIO.Body) if err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "read response")) + return nil, ErrInternal(errors.Wrap(err, "read response")) } if len(body) == 0 { // Hack for RSSHub... // Consider cache category ids for validate by self to remove this shit code. - return nil, rpc.ErrBadRequest(errors.New("category id is invalid")) + return nil, ErrBadRequest(errors.New("category id is invalid")) } var forwardResp map[string]RSSHubWebsite if err := json.Unmarshal(body, &forwardResp); err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "parse response")) + return nil, ErrInternal(errors.Wrap(err, "parse response")) } // Convert to response. @@ -383,7 +404,7 @@ func (a *api) QueryRSSHubRoutes( req *QueryRSSHubRoutesRequest, ) (resp *QueryRSSHubRoutesResponse, err error) { if req.WebsiteID == "" { - return nil, rpc.ErrBadRequest(errors.New("website id is required")) + return nil, ErrBadRequest(errors.New("website id is required")) } url := a.Config().RSSHubEndpoint + "/api/namespace/" + req.WebsiteID @@ -391,30 +412,30 @@ func (a *api) QueryRSSHubRoutes( // New request. forwardReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "new request")) + return nil, ErrInternal(errors.Wrap(err, "new request")) } // Do request. forwardRespIO, err := a.hc.Do(forwardReq) if err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "query rss hub routes")) + return nil, ErrInternal(errors.Wrap(err, "query rss hub routes")) } defer func() { _ = forwardRespIO.Body.Close() }() // Parse response. body, err := io.ReadAll(forwardRespIO.Body) if err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "read response")) + return nil, ErrInternal(errors.Wrap(err, "read response")) } if len(body) == 0 { - return nil, rpc.ErrBadRequest(errors.New("website id is invalid")) + return nil, ErrBadRequest(errors.New("website id is invalid")) } var forwardResp struct { Routes map[string]RSSHubRoute `json:"routes"` } if err := json.Unmarshal(body, &forwardResp); err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "parse response")) + return nil, ErrInternal(errors.Wrap(err, "parse response")) } // Convert to response. @@ -435,7 +456,7 @@ func (a *api) Write(ctx context.Context, req *WriteRequest) (resp *WriteResponse feed.Labels.Put(model.LabelType, "api", false) } if err := a.Dependencies().FeedStorage.Append(ctx, req.Feeds...); err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "append")) + return nil, ErrInternal(errors.Wrap(err, "append")) } return &WriteResponse{}, nil @@ -447,7 +468,7 @@ func (a *api) Query(ctx context.Context, req *QueryRequest) (resp *QueryResponse // Validate request. if err := req.Validate(); err != nil { - return nil, rpc.ErrBadRequest(errors.Wrap(err, "validate")) + return nil, ErrBadRequest(errors.Wrap(err, "validate")) } // Forward to storage. @@ -460,7 +481,7 @@ func (a *api) Query(ctx context.Context, req *QueryRequest) (resp *QueryResponse End: req.End, }) if err != nil { - return nil, rpc.ErrInternal(errors.Wrap(err, "query")) + return nil, ErrInternal(errors.Wrap(err, "query")) } if len(feeds) == 0 { return &QueryResponse{Feeds: []*block.FeedVO{}}, nil diff --git a/pkg/api/http/http.go b/pkg/api/http/http.go index 8e5b665..419ec18 100644 --- a/pkg/api/http/http.go +++ b/pkg/api/http/http.go @@ -26,9 +26,8 @@ import ( "github.com/glidea/zenfeed/pkg/config" telemetry "github.com/glidea/zenfeed/pkg/telemetry" "github.com/glidea/zenfeed/pkg/telemetry/log" - "github.com/glidea/zenfeed/pkg/telemetry/metric" telemetrymodel "github.com/glidea/zenfeed/pkg/telemetry/model" - "github.com/glidea/zenfeed/pkg/util/rpc" + "github.com/glidea/zenfeed/pkg/util/jsonrpc" ) // --- Interface code block --- @@ -89,18 +88,14 @@ func new(instance string, app *config.App, dependencies Dependencies) (Server, e router := http.NewServeMux() api := dependencies.API - router.Handle("/metrics", metric.Handler()) - router.Handle("/health", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - })) - router.Handle("/write", rpc.API(api.Write)) - router.Handle("/query_config", rpc.API(api.QueryAppConfig)) - router.Handle("/apply_config", rpc.API(api.ApplyAppConfig)) - router.Handle("/query_config_schema", rpc.API(api.QueryAppConfigSchema)) - router.Handle("/query_rsshub_categories", rpc.API(api.QueryRSSHubCategories)) - router.Handle("/query_rsshub_websites", rpc.API(api.QueryRSSHubWebsites)) - router.Handle("/query_rsshub_routes", rpc.API(api.QueryRSSHubRoutes)) - router.Handle("/query", rpc.API(api.Query)) + router.Handle("/write", jsonrpc.API(api.Write)) + router.Handle("/query_config", jsonrpc.API(api.QueryAppConfig)) + router.Handle("/apply_config", jsonrpc.API(api.ApplyAppConfig)) + router.Handle("/query_config_schema", jsonrpc.API(api.QueryAppConfigSchema)) + router.Handle("/query_rsshub_categories", jsonrpc.API(api.QueryRSSHubCategories)) + router.Handle("/query_rsshub_websites", jsonrpc.API(api.QueryRSSHubWebsites)) + router.Handle("/query_rsshub_routes", jsonrpc.API(api.QueryRSSHubRoutes)) + router.Handle("/query", jsonrpc.API(api.Query)) httpServer := &http.Server{Addr: config.Address, Handler: router} return &server{ diff --git a/pkg/api/rss/rss.go b/pkg/api/rss/rss.go new file mode 100644 index 0000000..6f5e2ab --- /dev/null +++ b/pkg/api/rss/rss.go @@ -0,0 +1,231 @@ +// 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 . + +package rss + +import ( + "fmt" + "net" + "net/http" + "text/template" + "time" + + "github.com/benbjohnson/clock" + "github.com/gorilla/feeds" + "github.com/pkg/errors" + + "github.com/glidea/zenfeed/pkg/api" + "github.com/glidea/zenfeed/pkg/component" + "github.com/glidea/zenfeed/pkg/config" + "github.com/glidea/zenfeed/pkg/model" + telemetry "github.com/glidea/zenfeed/pkg/telemetry" + "github.com/glidea/zenfeed/pkg/telemetry/log" + telemetrymodel "github.com/glidea/zenfeed/pkg/telemetry/model" + "github.com/glidea/zenfeed/pkg/util/buffer" +) + +var clk = clock.New() + +// --- Interface code block --- +type Server interface { + component.Component + config.Watcher +} + +type Config struct { + Address string + ContentHTMLTemplate string + contentHTMLTemplate *template.Template +} + +func (c *Config) Validate() error { + if c.Address == "" { + c.Address = ":1302" + } + if _, _, err := net.SplitHostPort(c.Address); err != nil { + return errors.Wrap(err, "invalid address") + } + + if c.ContentHTMLTemplate == "" { + c.ContentHTMLTemplate = "{{ .summary_html_snippet }}" + } + t, err := template.New("").Parse(c.ContentHTMLTemplate) + if err != nil { + return errors.Wrap(err, "parse rss content template") + } + c.contentHTMLTemplate = t + + return nil +} + +func (c *Config) From(app *config.App) *Config { + c.Address = app.API.RSS.Address + c.ContentHTMLTemplate = app.API.RSS.ContentHTMLTemplate + + return c +} + +type Dependencies struct { + API api.API +} + +// --- Factory code block --- +type Factory component.Factory[Server, config.App, Dependencies] + +func NewFactory(mockOn ...component.MockOption) Factory { + if len(mockOn) > 0 { + return component.FactoryFunc[Server, config.App, Dependencies]( + func(instance string, config *config.App, dependencies Dependencies) (Server, error) { + m := &mockServer{} + component.MockOptions(mockOn).Apply(&m.Mock) + + return m, nil + }, + ) + } + + return component.FactoryFunc[Server, config.App, Dependencies](new) +} + +func new(instance string, app *config.App, dependencies Dependencies) (Server, error) { + config := &Config{} + config.From(app) + if err := config.Validate(); err != nil { + return nil, errors.Wrap(err, "validate config") + } + + s := &server{ + Base: component.New(&component.BaseConfig[Config, Dependencies]{ + Name: "RSSServer", + Instance: instance, + Config: config, + Dependencies: dependencies, + }), + } + + router := http.NewServeMux() + router.Handle("/", http.HandlerFunc(s.rss)) + + s.http = &http.Server{Addr: config.Address, Handler: router} + + return s, nil +} + +// --- Implementation code block --- +type server struct { + *component.Base[Config, Dependencies] + http *http.Server +} + +func (s *server) Run() (err error) { + ctx := telemetry.StartWith(s.Context(), append(s.TelemetryLabels(), telemetrymodel.KeyOperation, "Run")...) + defer func() { telemetry.End(ctx, err) }() + + serverErr := make(chan error, 1) + go func() { + serverErr <- s.http.ListenAndServe() + }() + + s.MarkReady() + select { + case <-ctx.Done(): + log.Info(ctx, "shutting down") + + return s.http.Shutdown(ctx) + case err := <-serverErr: + return errors.Wrap(err, "listen and serve") + } +} + +func (s *server) Reload(app *config.App) error { + newConfig := &Config{} + newConfig.From(app) + if err := newConfig.Validate(); err != nil { + return errors.Wrap(err, "validate config") + } + if s.Config().Address != newConfig.Address { + return errors.New("address cannot be reloaded") + } + + s.SetConfig(newConfig) + + return nil +} + +func (s *server) rss(w http.ResponseWriter, r *http.Request) { + var err error + ctx := telemetry.StartWith(r.Context(), append(s.TelemetryLabels(), telemetrymodel.KeyOperation, "rss")...) + defer telemetry.End(ctx, err) + + // Extract parameters. + ps := r.URL.Query() + labelFilters := ps["label_filter"] + query := ps.Get("query") + + // Forward query request to API. + now := clk.Now() + queryResult, err := s.Dependencies().API.Query(ctx, &api.QueryRequest{ + Query: query, + LabelFilters: labelFilters, + Start: now.Add(-24 * time.Hour), + End: now, + Limit: 100, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) // TODO: standardize error handling. + return + } + + // Render and convert to RSS. + rssObj := &feeds.Feed{ + Title: fmt.Sprintf("Zenfeed RSS - %s", ps.Encode()), + Description: "Powered by Github Zenfeed - https://github.com/glidea/zenfeed. If you use Folo, please enable 'Appearance - Content - Render inline styles'", + Items: make([]*feeds.Item, 0, len(queryResult.Feeds)), + } + + buf := buffer.Get() + defer buffer.Put(buf) + + for _, feed := range queryResult.Feeds { + buf.Reset() + + if err = s.Config().contentHTMLTemplate.Execute(buf, feed.Labels.Map()); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + item := &feeds.Item{ + Title: feed.Labels.Get(model.LabelTitle), + Link: &feeds.Link{Href: feed.Labels.Get(model.LabelLink)}, + Created: feed.Time, // NOTE: scrape time, not pub time. + Content: buf.String(), + } + + rssObj.Items = append(rssObj.Items, item) + } + + if err = rssObj.WriteRss(w); err != nil { + log.Error(ctx, errors.Wrap(err, "write rss response")) + return + } +} + +type mockServer struct { + component.Mock +} + +func (m *mockServer) Reload(app *config.App) error { + return m.Called(app).Error(0) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index a853bf9..1e6abe7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -46,10 +46,13 @@ type Config struct { } type App struct { - Timezone string `yaml:"timezone,omitempty" json:"timezone,omitempty" desc:"The timezone of the app. e.g. Asia/Shanghai. Default: server's local timezone"` - Log struct { - Level string `yaml:"level,omitempty" json:"level,omitempty" desc:"Log level, one of debug, info, warn, error. Default: info"` - } `yaml:"log,omitempty" json:"log,omitempty" desc:"The log config."` + Timezone string `yaml:"timezone,omitempty" json:"timezone,omitempty" desc:"The timezone of the app. e.g. Asia/Shanghai. Default: server's local timezone"` + Telemetry struct { + Address string `yaml:"address,omitempty" json:"address,omitempty" desc:"The address ([host]:port) of the telemetry server. e.g. 0.0.0.0:9090. Default: :9090. It can not be changed after the app is running."` + Log struct { + Level string `yaml:"level,omitempty" json:"level,omitempty" desc:"Log level, one of debug, info, warn, error. Default: info"` + } `yaml:"log,omitempty" json:"log,omitempty" desc:"The log config."` + } `yaml:"telemetry,omitempty" json:"telemetry,omitempty" desc:"The telemetry config."` API struct { HTTP struct { Address string `yaml:"address,omitempty" json:"address,omitempty" desc:"The address ([host]:port) of the HTTP API. e.g. 0.0.0.0:1300. Default: :1300. It can not be changed after the app is running."` @@ -57,9 +60,16 @@ type App struct { MCP struct { Address string `yaml:"address,omitempty" json:"address,omitempty" desc:"The address ([host]:port) of the MCP API. e.g. 0.0.0.0:1300. Default: :1301. It can not be changed after the app is running."` } `yaml:"mcp,omitempty" json:"mcp,omitempty" desc:"The MCP API config."` + RSS struct { + Address string `yaml:"address,omitempty" json:"address,omitempty" desc:"The address ([host]:port) of the RSS API. e.g. 0.0.0.0:1300. Default: :1302. It can not be changed after the app is running."` + ContentHTMLTemplate string `yaml:"content_html_template,omitempty" json:"content_html_template,omitempty" desc:"The template to render the RSS content for each item. Default is {{ .summary_html_snippet }}."` + } `yaml:"rss,omitempty" json:"rss,omitempty" desc:"The RSS config."` LLM string `yaml:"llm,omitempty" json:"llm,omitempty" desc:"The LLM name for summarizing feeds. e.g. my-favorite-gemini-king. Default is the default LLM in llms section."` } `yaml:"api,omitempty" json:"api,omitempty" desc:"The API config."` - LLMs []LLM `yaml:"llms,omitempty" json:"llms,omitempty" desc:"The LLMs config. It is required, at least one LLM is needed, refered by other config sections."` + LLMs []LLM `yaml:"llms,omitempty" json:"llms,omitempty" desc:"The LLMs config. It is required, at least one LLM is needed, refered by other config sections."` + Jina struct { + Token string `yaml:"token,omitempty" json:"token,omitempty" desc:"The token of the Jina server."` + } `yaml:"jina,omitempty" json:"jina,omitempty" desc:"The Jina config."` Scrape Scrape `yaml:"scrape,omitempty" json:"scrape,omitempty" desc:"The scrape config."` Storage Storage `yaml:"storage,omitempty" json:"storage,omitempty" desc:"The storage config."` Scheduls struct { @@ -116,6 +126,7 @@ type ScrapeSourceRSS struct { } type RewriteRule struct { + If []string `yaml:"if,omitempty" json:"if,omitempty" desc:"The condition config to match the feed. If not set, that means match all feeds. Like label filters, e.g. [source=github, title!=xxx]"` SourceLabel string `yaml:"source_label,omitempty" json:"source_label,omitempty" desc:"The feed label of the source text to transform. Default is the 'content' label. The feed is essentially a label set (similar to Prometheus metric data). The default labels are type (rss, email (in future), etc), source (the source name), title (feed title), link (feed link), pub_time (feed publish time), and content (feed content)."` SkipTooShortThreshold *int `yaml:"skip_too_short_threshold,omitempty" json:"skip_too_short_threshold,omitempty" desc:"The threshold of the source text length to skip. Default is 300. It helps we to filter out some short feeds."` Transform *RewriteRuleTransform `yaml:"transform,omitempty" json:"transform,omitempty" desc:"The transform config to transform the source text. If not set, that means transform nothing, so the source text is the transformed text."` @@ -130,6 +141,7 @@ type RewriteRuleTransform struct { } type RewriteRuleTransformToText struct { + Type string `yaml:"type,omitempty" json:"type,omitempty" desc:"The type of the transform. It can be one of prompt, crawl, crawl_by_jina. Default is prompt. For crawl, the source text will be as the url to crawl the page, and the page will be converted to markdown. crawl vs crawl_by_jina: crawl is local, more stable; crawl_by_jina is powered by https://jina.ai, more powerful."` LLM string `yaml:"llm,omitempty" json:"llm,omitempty" desc:"The LLM name to use. Default is the default LLM in llms section."` Prompt string `yaml:"prompt,omitempty" json:"prompt,omitempty" desc:"The prompt to transform the source text. The source text will be injected into the prompt above. And you can use go template syntax to refer some built-in prompts, like {{ .summary }}. Available built-in prompts: category, tags, score, comment_confucius, summary, summary_html_snippet."` } @@ -166,15 +178,14 @@ type NotifySubRoute struct { } type NotifyReceiver struct { - Name string `yaml:"name,omitempty" json:"name,omitempty" desc:"The name of the receiver. It is required."` - Email string `yaml:"email,omitempty" json:"email,omitempty" desc:"The email of the receiver."` - // TODO: to reduce copyright risk, we do not support webhook receiver now. - // Webhook *NotifyReceiverWebhook `yaml:"webhook" json:"webhook" desc:"The webhook of the receiver."` + Name string `yaml:"name,omitempty" json:"name,omitempty" desc:"The name of the receiver. It is required."` + Email string `yaml:"email,omitempty" json:"email,omitempty" desc:"The email of the receiver."` + Webhook *NotifyReceiverWebhook `yaml:"webhook" json:"webhook" desc:"The webhook of the receiver."` } -// type NotifyReceiverWebhook struct { -// URL string `yaml:"url"` -// } +type NotifyReceiverWebhook struct { + URL string `yaml:"url"` +} type NotifyChannels struct { Email *NotifyChannelEmail `yaml:"email,omitempty" json:"email,omitempty" desc:"The global email channel config."` diff --git a/pkg/llm/llm.go b/pkg/llm/llm.go index 6a2d102..4b8028f 100644 --- a/pkg/llm/llm.go +++ b/pkg/llm/llm.go @@ -16,6 +16,7 @@ package llm import ( + "bytes" "context" "reflect" "strconv" @@ -33,6 +34,8 @@ import ( "github.com/glidea/zenfeed/pkg/storage/kv" "github.com/glidea/zenfeed/pkg/telemetry/log" telemetrymodel "github.com/glidea/zenfeed/pkg/telemetry/model" + binaryutil "github.com/glidea/zenfeed/pkg/util/binary" + "github.com/glidea/zenfeed/pkg/util/buffer" "github.com/glidea/zenfeed/pkg/util/hash" ) @@ -373,24 +376,94 @@ func newCached(llm LLM, kvStorage kv.Storage) LLM { func (c *cached) String(ctx context.Context, messages []string) (string, error) { key := hash.Sum64s(messages) - keyStr := strconv.FormatUint(key, 10) + keyStr := strconv.FormatUint(key, 10) // for human readable & compatible. - value, err := c.kvStorage.Get(ctx, keyStr) + valueBs, err := c.kvStorage.Get(ctx, []byte(keyStr)) switch { case err == nil: - return value, nil + return string(valueBs), nil case errors.Is(err, kv.ErrNotFound): break default: return "", errors.Wrap(err, "get from kv storage") } - value, err = c.LLM.String(ctx, messages) + value, err := c.LLM.String(ctx, messages) if err != nil { return "", err } - if err = c.kvStorage.Set(ctx, keyStr, value, 65*time.Minute); err != nil { + // TODO: reduce copies. + if err = c.kvStorage.Set(ctx, []byte(keyStr), []byte(value), 65*time.Minute); err != nil { + log.Error(ctx, err, "set to kv storage") + } + + return value, nil +} + +var ( + toBytes = func(v []float32) ([]byte, error) { + buf := buffer.Get() + defer buffer.Put(buf) + + for _, fVal := range v { + if err := binaryutil.WriteFloat32(buf, fVal); err != nil { + return nil, errors.Wrap(err, "write float32") + } + } + + // Must copy data, as the buffer will be reused. + bs := make([]byte, buf.Len()) + copy(bs, buf.Bytes()) + + return bs, nil + } + + toF32s = func(bs []byte) ([]float32, error) { + if len(bs)%4 != 0 { + return nil, errors.New("embedding data is corrupted, length not multiple of 4") + } + + r := bytes.NewReader(bs) + floats := make([]float32, len(bs)/4) + + for i := range floats { + f, err := binaryutil.ReadFloat32(r) + if err != nil { + return nil, errors.Wrap(err, "deserialize float32") + } + floats[i] = f + } + + return floats, nil + } +) + +func (c *cached) Embedding(ctx context.Context, text string) ([]float32, error) { + key := hash.Sum64(text) + keyStr := strconv.FormatUint(key, 10) + + valueBs, err := c.kvStorage.Get(ctx, []byte(keyStr)) + switch { + case err == nil: + return toF32s(valueBs) + case errors.Is(err, kv.ErrNotFound): + break + default: + return nil, errors.Wrap(err, "get from kv storage") + } + + value, err := c.LLM.Embedding(ctx, text) + if err != nil { + return nil, err + } + + valueBs, err = toBytes(value) + if err != nil { + return nil, errors.Wrap(err, "serialize embedding") + } + + if err = c.kvStorage.Set(ctx, []byte(keyStr), valueBs, 65*time.Minute); err != nil { log.Error(ctx, err, "set to kv storage") } diff --git a/pkg/llm/openai.go b/pkg/llm/openai.go index cbe94c9..b458449 100644 --- a/pkg/llm/openai.go +++ b/pkg/llm/openai.go @@ -18,6 +18,7 @@ package llm import ( "context" "encoding/json" + "fmt" "github.com/pkg/errors" oai "github.com/sashabaranov/go-openai" @@ -40,7 +41,7 @@ func newOpenAI(c *Config) LLM { config := oai.DefaultConfig(c.APIKey) config.BaseURL = c.Endpoint client := oai.NewClientWithConfig(config) - embeddingSpliter := newEmbeddingSpliter(2048, 64) + embeddingSpliter := newEmbeddingSpliter(1536, 64) return &openai{ Base: component.New(&component.BaseConfig[Config, struct{}]{ @@ -61,9 +62,9 @@ func (o *openai) String(ctx context.Context, messages []string) (value string, e if config.Model == "" { return "", errors.New("model is not set") } - msg := make([]oai.ChatCompletionMessage, 0, len(messages)) + msgs := make([]oai.ChatCompletionMessage, 0, len(messages)) for _, m := range messages { - msg = append(msg, oai.ChatCompletionMessage{ + msgs = append(msgs, oai.ChatCompletionMessage{ Role: oai.ChatMessageRoleUser, Content: m, }) @@ -71,7 +72,7 @@ func (o *openai) String(ctx context.Context, messages []string) (value string, e req := oai.ChatCompletionRequest{ Model: config.Model, - Messages: msg, + Messages: msgs, Temperature: config.Temperature, } @@ -131,6 +132,7 @@ func (o *openai) Embedding(ctx context.Context, s string) (value []float32, err EncodingFormat: oai.EmbeddingEncodingFormatFloat, }) if err != nil { + fmt.Println(s) return nil, errors.Wrap(err, "create embeddings") } if len(vec.Data) == 0 { @@ -141,6 +143,6 @@ func (o *openai) Embedding(ctx context.Context, s string) (value []float32, err promptTokens.WithLabelValues(lvs...).Add(float64(vec.Usage.PromptTokens)) completionTokens.WithLabelValues(lvs...).Add(float64(vec.Usage.CompletionTokens)) totalTokens.WithLabelValues(lvs...).Add(float64(vec.Usage.TotalTokens)) - + return vec.Data[0].Embedding, nil } diff --git a/pkg/llm/prompt/prompt.go b/pkg/llm/prompt/prompt.go new file mode 100644 index 0000000..c258ea4 --- /dev/null +++ b/pkg/llm/prompt/prompt.go @@ -0,0 +1,156 @@ +package prompt + +var Builtin = map[string]string{ + "category": ` +Analyze the content and categorize it into exactly one of these categories: +Technology, Development, Entertainment, Finance, Health, Politics, Other + +Classification requirements: +- Choose the SINGLE most appropriate category based on: + * Primary topic and main focus of the content + * Key terminology and concepts used + * Target audience and purpose + * Technical depth and complexity level +- For content that could fit multiple categories: + * Identify the dominant theme + * Consider the most specific applicable category + * Use the primary intended purpose +- If content appears ambiguous: + * Focus on the most prominent aspects + * Consider the practical application + * Choose the category that best serves user needs + +Output format: +Return ONLY the category name, no other text or explanation. +Must be one of the provided categories exactly as written. +`, + + "tags": ` +Analyze the content and add appropriate tags based on: +- Main topics and themes +- Key concepts and terminology +- Target audience and purpose +- Technical depth and domain +- 2-4 tags are enough +Output format: +Return a list of tags, separated by commas, no other text or explanation. +e.g. "AI, Technology, Innovation, Future" +`, + + "score": ` +Please give a score between 0 and 10 based on the following content. +Evaluate the content comprehensively considering clarity, accuracy, depth, logical structure, language expression, and completeness. +Note: If the content is an article or a text intended to be detailed, the length is an important factor. Generally, content under 300 words may receive a lower score due to lack of substance, unless its type (such as poetry or summary) is inherently suitable for brevity. +Output format: +Return the score (0-10), no other text or explanation. +E.g. "8", "5", "3", etc. +`, + + "comment_confucius": ` +Please act as Confucius and write a 100-word comment on the article. +Content needs to be in line with the Chinese mainland's regulations. +Output format: +Return the comment only, no other text or explanation. +Reply short and concise, 100 words is enough. +`, + + "summary": ` +Please read the article carefully and summarize its core content in the format of [Choice: Key Point List / Concise Paragraph]. The summary should clearly cover: + +1. What is the main topic/theme of the article? +2. What key arguments/main information did the author put forward? +3. (Optional, if the article contains) What important data, cases, or examples are there? +4. What main conclusions did the article reach or what core information did it ultimately convey? + +Strive for comprehensive, accurate, and concise. +`, + + "summary_html_snippet": ` +You are to act as a professional Content Designer. Your task is to convert the provided article into **visually modern HTML email snippets** that render well in modern email clients like Gmail and QQ Mail. + +**Core Requirements:** + +* **Highlighting and Layout Techniques (Based on the article content, you must actually use the HTML structure templates provided below to generate the content):** + + A. **Stylish Quote Block** (for highlighting important points or direct quotes from the original text): +

+

+ Insert the key point or finding to be highlighted here. +

+
+ + B. **Information Card** (for highlighting key data/metrics): +
+

Metric Name

+

75%

+
+ + C. **Key Points List** (for organizing multiple core points): +
    +
  • + 1 + Description of the first key point +
  • +
  • + 2 + Description of the second key point +
  • +
+ + D. **Emphasized Text** (for highlighting keywords or phrases): + Text to be emphasized + + E. **Comparison Table** (suitable for comparing different solutions or viewpoints): +
+ + + + + + + + + + + + + + + + + + + + +
FeatureOption AOption B
CostHigherModerate
EfficiencyVery HighAverage
+
+ +* **Output Requirements:** + * The design should be **aesthetically pleasing and elegant, with harmonious color schemes**, ensuring sufficient **whitespace and contrast**. + * All article snippets must maintain a **consistent visual style**. + * You **must use multiple visual elements** and avoid mere text listings. **Use at least 2-3 different visual elements** to enhance readability and intuitive understanding. + * **Appropriately quote important original text snippets** to support explanations. + * **Strive to use highlighting styles to mark key points**. + * **Where appropriate, embed original images from the article to aid explanation.** Pay attention to the referrer policy: use referrerpolicy="no-referrer" on the HTML element to ensure images display correctly. + * **Ensure overall reading flow is smooth and natural!!!** Guide the reader's thought process appropriately, minimizing abrupt jumps in logic. + * **Output only the HTML code snippet.** Do not include the full HTML document structure (i.e., no , , or tags). + * **Do not add any explanatory text, extra comments, Markdown formatting, or HTML backticks.** Output the raw HTML code directly. + * **Do not add article titles or sources;** these will be automatically injected by the user later. + * **Do not use any opening remarks or pleasantries** (e.g., "Hi," "Let's talk about..."). Directly present the processed HTML content. + * **Do not refer to "this article," "this piece," "the current text," etc.** The user is aware of this context. + * **Only use inline styles, do not use global styles.** Remember to only generate HTML snippets. + * Do not explain anything, just output the HTML code snippet. + * Use above HTML components & its styles to generate the HTML code snippet, do not customize by yourself, else you will be fired. + +* **Your Personality and Expression Preferences:** + * Focus on the most valuable information, not on every detail. The content should be readable within 3 minutes. + * Communicate **concisely and get straight to the point. + * ** Have a strong aversion to jargon, bureaucratic language, redundant embellishments, and grand narratives. Believe that plain, simple language can best convey truth. + * Be fluent, plain, concise, and not verbose. + * Be **plain, direct, clear, and easy to understand:** Use basic vocabulary and simple sentence structures. Avoid "sophisticated" complex sentences or unnecessary embellishments that increase reading burden. + * Enable readers to quickly grasp: "What is this? What is it generally about? What is its relevance/real-world significance to me (an ordinary person)?" Focus on providing an **overview**, not an accumulation of details. + * Be well-versed in cognitive science; understand how to phrase information so that someone without prior background can quickly understand the core content. + * **Extract key information and core insights,** rather than directly copying the original text. Do not omit crucial information and viewpoints. For example, for forum posts, the main points from comments are also very important! + * Avoid large blocks of text, strive for a combination of pictures and text. +`, +} diff --git a/pkg/model/model.go b/pkg/model/model.go index 8f3a37c..a57ff4f 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -30,6 +30,7 @@ import ( const ( AppName = "zenfeed" + Module = "github.com/glidea/zenfeed" ) // LabelXXX is the metadata label for the feed. @@ -233,6 +234,76 @@ type Label struct { Value string `json:"value"` } +const ( + LabelFilterEqual = "=" + LabelFilterNotEqual = "!=" +) + +type LabelFilter struct { + Label string + Equal bool + Value string +} + +func NewLabelFilter(filter string) (LabelFilter, error) { + eq := false + parts := strings.Split(filter, LabelFilterNotEqual) + if len(parts) != 2 { + parts = strings.Split(filter, LabelFilterEqual) + eq = true + } + if len(parts) != 2 { + return LabelFilter{}, errors.New("invalid label filter") + } + + return LabelFilter{Label: parts[0], Value: parts[1], Equal: eq}, nil +} + +func (f LabelFilter) Match(labels Labels) bool { + lv := labels.Get(f.Label) + if lv == "" { + return false + } + + if f.Equal && lv == f.Value { + return true + } + if !f.Equal && lv != f.Value { + return true + } + + return false +} + +type LabelFilters []LabelFilter + +func (ls LabelFilters) Match(labels Labels) bool { + if len(ls) == 0 { + return true // No filters, always match. + } + + for _, l := range ls { + if !l.Match(labels) { + return false + } + } + + return true +} + +func NewLabelFilters(filters []string) (LabelFilters, error) { + ls := make(LabelFilters, len(filters)) + for i, f := range filters { + lf, err := NewLabelFilter(f) + if err != nil { + return nil, errors.Wrapf(err, "new label filter %q", f) + } + ls[i] = lf + } + + return ls, nil +} + // readExpectedDelim reads the next token and checks if it's the expected delimiter. func readExpectedDelim(dec *json.Decoder, expected json.Delim) error { t, err := dec.Token() diff --git a/pkg/notify/channel/channel.go b/pkg/notify/channel/channel.go index 44704c9..78f639a 100644 --- a/pkg/notify/channel/channel.go +++ b/pkg/notify/channel/channel.go @@ -124,10 +124,9 @@ func (c *aggrChannel) Send(ctx context.Context, receiver Receiver, group *route. if receiver.Email != "" && c.email != nil { return c.send(ctx, receiver, group, c.email, "email") } - // if receiver.Webhook != nil && c.webhook != nil { - // TODO: temporarily disable webhook to reduce copyright risks. - // return c.send(ctx, receiver, group, c.webhook, "webhook") - // } + if receiver.Webhook != nil && c.webhook != nil { + return c.send(ctx, receiver, group, c.webhook, "webhook") + } return nil } diff --git a/pkg/notify/channel/email.go b/pkg/notify/channel/email.go index 2424622..ffb01db 100644 --- a/pkg/notify/channel/email.go +++ b/pkg/notify/channel/email.go @@ -134,53 +134,53 @@ func (e *email) buildEmail(receiver Receiver, group *route.FeedGroup) (*gomail.M if err != nil { return nil, errors.Wrap(err, "build email body HTML") } - m.SetBody("text/html", string(body)) + m.SetBody("text/html", body) return m, nil } -func (e *email) buildBodyHTML(group *route.FeedGroup) ([]byte, error) { +func (e *email) buildBodyHTML(group *route.FeedGroup) (string, error) { bodyBuf := buffer.Get() defer buffer.Put(bodyBuf) // Write HTML header. if err := e.writeHTMLHeader(bodyBuf); err != nil { - return nil, errors.Wrap(err, "write HTML header") + return "", errors.Wrap(err, "write HTML header") } // Write summary. if err := e.writeSummary(bodyBuf, group.Summary); err != nil { - return nil, errors.Wrap(err, "write summary") + return "", errors.Wrap(err, "write summary") } // Write each feed content. if _, err := bodyBuf.WriteString(`

Feeds

`); err != nil { - return nil, errors.Wrap(err, "write feeds header") + return "", errors.Wrap(err, "write feeds header") } for i, feed := range group.Feeds { if err := e.writeFeedContent(bodyBuf, feed); err != nil { - return nil, errors.Wrap(err, "write feed content") + return "", errors.Wrap(err, "write feed content") } // Add separator (except the last feed). if i < len(group.Feeds)-1 { if err := e.writeSeparator(bodyBuf); err != nil { - return nil, errors.Wrap(err, "write separator") + return "", errors.Wrap(err, "write separator") } } } // Write disclaimer and HTML footer. if err := e.writeDisclaimer(bodyBuf); err != nil { - return nil, errors.Wrap(err, "write disclaimer") + return "", errors.Wrap(err, "write disclaimer") } if err := e.writeHTMLFooter(bodyBuf); err != nil { - return nil, errors.Wrap(err, "write HTML footer") + return "", errors.Wrap(err, "write HTML footer") } - return bodyBuf.Bytes(), nil + return bodyBuf.String(), nil } func (e *email) writeHTMLHeader(buf *buffer.Bytes) error { diff --git a/pkg/notify/channel/webhook.go b/pkg/notify/channel/webhook.go index 401a997..2e755f0 100644 --- a/pkg/notify/channel/webhook.go +++ b/pkg/notify/channel/webhook.go @@ -41,9 +41,10 @@ func (r *WebhookReceiver) Validate() error { } type webhookBody struct { - Group string `json:"group"` - Labels model.Labels `json:"labels"` - Feeds []*route.Feed `json:"feeds"` + Group string `json:"group"` + Labels model.Labels `json:"labels"` + Summary string `json:"summary"` + Feeds []*route.Feed `json:"feeds"` } func newWebhook() sender { @@ -59,9 +60,10 @@ type webhook struct { func (w *webhook) Send(ctx context.Context, receiver Receiver, group *route.FeedGroup) error { // Prepare request. body := &webhookBody{ - Group: group.Name, - Labels: group.Labels, - Feeds: group.Feeds, + Group: group.Name, + Labels: group.Labels, + Summary: group.Summary, + Feeds: group.Feeds, } b := runtimeutil.Must1(json.Marshal(body)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, receiver.Webhook.URL, bytes.NewReader(b)) diff --git a/pkg/notify/notify.go b/pkg/notify/notify.go index 5f31ba0..c91a596 100644 --- a/pkg/notify/notify.go +++ b/pkg/notify/notify.go @@ -86,9 +86,9 @@ func (c *Config) From(app *config.App) *Config { if app.Notify.Receivers[i].Email != "" { c.Receivers[i].Email = app.Notify.Receivers[i].Email } - // if app.Notify.Receivers[i].Webhook != nil { - // c.Receivers[i].Webhook = &channel.WebhookReceiver{URL: app.Notify.Receivers[i].Webhook.URL} - // } + if app.Notify.Receivers[i].Webhook != nil { + c.Receivers[i].Webhook = &channel.WebhookReceiver{URL: app.Notify.Receivers[i].Webhook.URL} + } } c.Channels = channel.Config{} @@ -438,8 +438,8 @@ func (n *notifier) send(ctx context.Context, work sendWork) error { return channel.Send(ctx, work.receiver.Receiver, work.group) } -var nlogKey = func(group *route.FeedGroup, receiver Receiver) string { - return fmt.Sprintf("notifier.group.%s.receiver.%s.%d", group.Name, receiver.Name, group.Time.Unix()) +var nlogKey = func(group *route.FeedGroup, receiver Receiver) []byte { + return fmt.Appendf(nil, "notifier.group.%s.receiver.%s.%d", group.Name, receiver.Name, group.Time.Unix()) } func (n *notifier) isSent(ctx context.Context, group *route.FeedGroup, receiver Receiver) bool { @@ -457,7 +457,7 @@ func (n *notifier) isSent(ctx context.Context, group *route.FeedGroup, receiver } func (n *notifier) markSent(ctx context.Context, group *route.FeedGroup, receiver Receiver) error { - return n.Dependencies().KVStorage.Set(ctx, nlogKey(group, receiver), timeutil.Format(time.Now()), timeutil.Day) + return n.Dependencies().KVStorage.Set(ctx, nlogKey(group, receiver), []byte(timeutil.Format(time.Now())), timeutil.Day) } type sendWork struct { diff --git a/pkg/notify/route/route.go b/pkg/notify/route/route.go index 988d4ad..58bad01 100644 --- a/pkg/notify/route/route.go +++ b/pkg/notify/route/route.go @@ -72,56 +72,25 @@ func (s SubRoutes) Match(feed *block.FeedVO) *SubRoute { type SubRoute struct { Route Matchers []string - matchers []matcher + matchers model.LabelFilters } func (r *SubRoute) Match(feed *block.FeedVO) *SubRoute { + // Match sub routes. for _, subRoute := range r.SubRoutes { if matched := subRoute.Match(feed); matched != nil { return matched } } - for _, m := range r.matchers { - fv := feed.Labels.Get(m.key) - switch m.equal { - case true: - if fv != m.value { - return nil - } - default: - if fv == m.value { - return nil - } - } + + // Match self. + if !r.matchers.Match(feed.Labels) { + return nil } return r } -type matcher struct { - key string - value string - equal bool -} - -var ( - matcherEqual = "=" - matcherNotEqual = "!=" - parseMatcher = func(filter string) (matcher, error) { - eq := false - parts := strings.Split(filter, matcherNotEqual) - if len(parts) != 2 { - parts = strings.Split(filter, matcherEqual) - eq = true - } - if len(parts) != 2 { - return matcher{}, errors.New("invalid matcher") - } - - return matcher{key: parts[0], value: parts[1], equal: eq}, nil - } -) - func (r *SubRoute) Validate() error { if len(r.GroupBy) == 0 { r.GroupBy = []string{model.LabelSource} @@ -129,17 +98,16 @@ func (r *SubRoute) Validate() error { if r.CompressByRelatedThreshold == nil { r.CompressByRelatedThreshold = ptr.To(float32(0.85)) } + if len(r.Matchers) == 0 { return errors.New("matchers is required") } - r.matchers = make([]matcher, len(r.Matchers)) - for i, matcher := range r.Matchers { - m, err := parseMatcher(matcher) - if err != nil { - return errors.Wrap(err, "invalid matcher") - } - r.matchers[i] = m + matchers, err := model.NewLabelFilters(r.Matchers) + if err != nil { + return errors.Wrap(err, "invalid matchers") } + r.matchers = matchers + for _, subRoute := range r.SubRoutes { if err := subRoute.Validate(); err != nil { return errors.Wrap(err, "invalid sub_route") @@ -151,7 +119,7 @@ func (r *SubRoute) Validate() error { func (c *Config) Validate() error { if len(c.GroupBy) == 0 { - c.GroupBy = []string{model.LabelSource} + c.GroupBy = []string{model.LabelType} } if c.CompressByRelatedThreshold == nil { c.CompressByRelatedThreshold = ptr.To(float32(0.85)) @@ -179,8 +147,8 @@ type FeedGroup struct { Name string Time time.Time Labels model.Labels - Feeds []*Feed Summary string + Feeds []*Feed } func (g *FeedGroup) ID() string { diff --git a/pkg/rewrite/rewrite.go b/pkg/rewrite/rewrite.go index 6fc5d24..f8cc9a7 100644 --- a/pkg/rewrite/rewrite.go +++ b/pkg/rewrite/rewrite.go @@ -19,8 +19,8 @@ import ( "context" "html/template" "regexp" + "strings" "unicode/utf8" - "unsafe" "github.com/pkg/errors" "k8s.io/utils/ptr" @@ -28,14 +28,15 @@ import ( "github.com/glidea/zenfeed/pkg/component" "github.com/glidea/zenfeed/pkg/config" "github.com/glidea/zenfeed/pkg/llm" + "github.com/glidea/zenfeed/pkg/llm/prompt" "github.com/glidea/zenfeed/pkg/model" "github.com/glidea/zenfeed/pkg/telemetry" telemetrymodel "github.com/glidea/zenfeed/pkg/telemetry/model" "github.com/glidea/zenfeed/pkg/util/buffer" + "github.com/glidea/zenfeed/pkg/util/crawl" ) // --- Interface code block --- - type Rewriter interface { component.Component config.Watcher @@ -71,6 +72,11 @@ type Dependencies struct { } type Rule struct { + // If is the condition to check before applying the rule. + // If not set, the rule will be applied. + If []string + if_ model.LabelFilters + // SourceLabel specifies which label's value to use as source text. // Default is model.LabelContent. SourceLabel string @@ -96,29 +102,51 @@ type Rule struct { } func (r *Rule) Validate() error { //nolint:cyclop + // If. + if len(r.If) > 0 { + if_, err := model.NewLabelFilters(r.If) + if err != nil { + return errors.Wrapf(err, "invalid if %q", r.If) + } + r.if_ = if_ + } + // Source label. if r.SourceLabel == "" { r.SourceLabel = model.LabelContent } if r.SkipTooShortThreshold == nil { - r.SkipTooShortThreshold = ptr.To(300) + r.SkipTooShortThreshold = ptr.To(0) } // Transform. if r.Transform != nil { - if r.Transform.ToText.Prompt == "" { - return errors.New("to text prompt is required") + if r.Transform.ToText == nil { + return errors.New("to_text is required when transform is set") } - tmpl, err := template.New("").Parse(r.Transform.ToText.Prompt) - if err != nil { - return errors.Wrapf(err, "parse prompt template %s", r.Transform.ToText.Prompt) + + switch r.Transform.ToText.Type { + case ToTextTypePrompt: + if r.Transform.ToText.Prompt == "" { + return errors.New("to text prompt is required for prompt type") + } + tmpl, err := template.New("").Parse(r.Transform.ToText.Prompt) + if err != nil { + return errors.Wrapf(err, "parse prompt template %s", r.Transform.ToText.Prompt) + } + + buf := buffer.Get() + defer buffer.Put(buf) + if err := tmpl.Execute(buf, prompt.Builtin); err != nil { + return errors.Wrapf(err, "execute prompt template %s", r.Transform.ToText.Prompt) + } + r.Transform.ToText.promptRendered = buf.String() + + case ToTextTypeCrawl, ToTextTypeCrawlByJina: + // No specific validation for crawl type here, as the source text itself is the URL. + default: + return errors.Errorf("unknown transform type: %s", r.Transform.ToText.Type) } - buf := buffer.Get() - defer buffer.Put(buf) - if err := tmpl.Execute(buf, promptTemplates); err != nil { - return errors.Wrapf(err, "execute prompt template %s", r.Transform.ToText.Prompt) - } - r.Transform.ToText.promptRendered = buf.String() } // Match. @@ -148,15 +176,21 @@ func (r *Rule) Validate() error { //nolint:cyclop } func (r *Rule) From(c *config.RewriteRule) { + r.If = c.If r.SourceLabel = c.SourceLabel r.SkipTooShortThreshold = c.SkipTooShortThreshold if c.Transform != nil { t := &Transform{} if c.Transform.ToText != nil { - t.ToText = &ToText{ + toText := &ToText{ LLM: c.Transform.ToText.LLM, Prompt: c.Transform.ToText.Prompt, } + toText.Type = ToTextType(c.Transform.ToText.Type) + if toText.Type == "" { + toText.Type = ToTextTypePrompt // Default to prompt if not specified. + } + t.ToText = toText } r.Transform = t } @@ -173,15 +207,27 @@ type Transform struct { } type ToText struct { + Type ToTextType + // LLM is the name of the LLM to use. + // Only used when Type is ToTextTypePrompt. LLM string // Prompt is the prompt for LLM completion. // The source text will automatically be injected into the prompt. + // Only used when Type is ToTextTypePrompt. Prompt string promptRendered string } +type ToTextType string + +const ( + ToTextTypePrompt ToTextType = "prompt" + ToTextTypeCrawl ToTextType = "crawl" + ToTextTypeCrawlByJina ToTextType = "crawl_by_jina" +) + type Action string const ( @@ -189,233 +235,7 @@ const ( ActionCreateOrUpdateLabel Action = "create_or_update_label" ) -var promptTemplates = map[string]string{ - "category": ` -Analyze the content and categorize it into exactly one of these categories: -Technology, Development, Entertainment, Finance, Health, Politics, Other - -Classification requirements: -- Choose the SINGLE most appropriate category based on: - * Primary topic and main focus of the content - * Key terminology and concepts used - * Target audience and purpose - * Technical depth and complexity level -- For content that could fit multiple categories: - * Identify the dominant theme - * Consider the most specific applicable category - * Use the primary intended purpose -- If content appears ambiguous: - * Focus on the most prominent aspects - * Consider the practical application - * Choose the category that best serves user needs - -Output format: -Return ONLY the category name, no other text or explanation. -Must be one of the provided categories exactly as written. -`, - - "tags": ` -Analyze the content and add appropriate tags based on: -- Main topics and themes -- Key concepts and terminology -- Target audience and purpose -- Technical depth and domain -- 2-4 tags are enough -Output format: -Return a list of tags, separated by commas, no other text or explanation. -e.g. "AI, Technology, Innovation, Future" -`, - - "score": ` -Please give a score between 0 and 10 based on the following content. -Evaluate the content comprehensively considering clarity, accuracy, depth, logical structure, language expression, and completeness. -Note: If the content is an article or a text intended to be detailed, the length is an important factor. Generally, content under 300 words may receive a lower score due to lack of substance, unless its type (such as poetry or summary) is inherently suitable for brevity. -Output format: -Return the score (0-10), no other text or explanation. -E.g. "8", "5", "3", etc. -`, - - "comment_confucius": ` -Please act as Confucius and write a 100-word comment on the article. -Content needs to be in line with the Chinese mainland's regulations. -Output format: -Return the comment only, no other text or explanation. -Reply short and concise, 100 words is enough. -`, - - "summary": ` -Summarize the article in 100-200 words. -`, - - "summary_html_snippet": ` -# Task: Create Visually Appealing Information Summary Emails - -You are a professional content designer. Please convert the provided articles into **visually modern HTML email segments**, focusing on display effects in modern clients like Gmail and QQ Mail. - -## Key Requirements: - -1. **Output Format**: - - Only output HTML code snippets, **no need for complete HTML document structure** - - Only generate HTML code for a single article, so users can combine multiple pieces into a complete email - - No explanations, additional comments, or markups - - **No need to add titles and sources**, users will inject them automatically - - No use html backticks, output raw html code directly - - Output directly, no explanation, no comments, no markups - -2. **Content Processing**: - - **Don't directly copy the original text**, but extract key information and core insights from each article - - **Each article summary should be 100-200 words**, don't force word count, adjust the word count based on the actual length of the article - - Summarize points in relaxed, natural language, as if chatting with friends, while maintaining depth - - Maintain the original language of the article (e.g., Chinese summary for Chinese articles) - -3. **Visual Design**: - - Design should be aesthetically pleasing with coordinated colors - - Use sufficient whitespace and contrast - - Maintain a consistent visual style across all articles - - **Must use multiple visual elements** (charts, cards, quote blocks, etc.), avoid pure text presentation - - Each article should use at least 2-3 different visual elements to make content more intuitive and readable - -4. **Highlight Techniques**: - - A. **Beautiful Quote Blocks** (for highlighting important viewpoints): -
-

- Here is the key viewpoint or finding that needs to be highlighted. -

-
- - B. **Information Cards** (for highlighting key data): -
-

Metric Name

-

75%

-
- - C. **Key Points List** (for highlighting multiple points): -
    -
  • - 1 - First point description -
  • -
  • - 2 - Second point description -
  • -
- - D. **Emphasis Text** (for highlighting key words or phrases): - Text to emphasize - -5. **Timeline Design** (suitable for event sequences or news developments): -
-

Event Development Timeline

- -
- -
-
-

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.

-
-
-
- -6. **Comparison Table** (for comparing different options or viewpoints): -
- - - - - - - - - - - - - - - - - - - - -
FeatureOption AOption B
CostHigherModerate
EfficiencyVery HighAverage
-
- -7. **Chart Data Processing**: - - Bar Chart/Horizontal Bars: -
-

Data Comparison

- - -
-
- Project A - 65% -
-
-
-
-
- - -
-
- Project B - 42% -
-
-
-
-
-
- -8. **Highlight Box** (for displaying tips or reminders): -
-
-
- ! -
-
-

Tip

-

- Here are some additional tips or suggestions to help readers better understand or apply the article content. -

-
-
-
- -9. **Summary Box**: -
-

In Simple Terms

-

- This is a concise summary of the entire content, highlighting the most critical findings and conclusions. -

-
- -## Notes: -1. **Only generate content for a single article**, not including title and source, and not including HTML head and tail structure -2. Content should be **200-300 words**, don't force word count -3. **Must use multiple visual elements** (at least 2-3 types), avoid monotonous pure text presentation -4. Use relaxed, natural language, as if chatting with friends -5. Create visual charts for important data, rather than just describing with text -6. Use quote blocks to highlight important viewpoints, and lists to organize multiple points -7. Appropriately use emojis and conversational expressions to increase friendliness -8. Note that the article content has been provided in the previous message, please reply directly, no explanation, no comments, no markups -`, -} - // --- Factory code block --- - type Factory component.Factory[Rewriter, config.App, Dependencies] func NewFactory(mockOn ...component.MockOption) Factory { @@ -445,6 +265,8 @@ func new(instance string, app *config.App, dependencies Dependencies) (Rewriter, Config: c, Dependencies: dependencies, }), + crawler: crawl.NewLocal(), + jinaCrawler: crawl.NewJina(app.Jina.Token), }, nil } @@ -452,6 +274,9 @@ func new(instance string, app *config.App, dependencies Dependencies) (Rewriter, type rewriter struct { *component.Base[Config, Dependencies] + + crawler crawl.Crawler + jinaCrawler crawl.Crawler } func (r *rewriter) Reload(app *config.App) error { @@ -462,6 +287,8 @@ func (r *rewriter) Reload(app *config.App) error { } r.SetConfig(newConfig) + r.jinaCrawler = crawl.NewJina(app.Jina.Token) + return nil } @@ -471,6 +298,11 @@ func (r *rewriter) Labels(ctx context.Context, labels model.Labels) (rewritten m rules := *r.Config() for _, rule := range rules { + // If. + if !rule.if_.Match(labels) { + continue + } + // Get source text based on source label. sourceText := labels.Get(rule.SourceLabel) if utf8.RuneCountInString(sourceText) < *rule.SkipTooShortThreshold { @@ -479,7 +311,7 @@ func (r *rewriter) Labels(ctx context.Context, labels model.Labels) (rewritten m // Transform text if configured. text := sourceText - if rule.Transform != nil { + if rule.Transform != nil && rule.Transform.ToText != nil { transformed, err := r.transformText(ctx, rule.Transform, sourceText) if err != nil { return nil, errors.Wrap(err, "transform text") @@ -506,15 +338,37 @@ func (r *rewriter) Labels(ctx context.Context, labels model.Labels) (rewritten m return labels, nil } -// transformText transforms text using configured LLM. +// transformText transforms text using configured LLM or by crawling a URL. func (r *rewriter) transformText(ctx context.Context, transform *Transform, text string) (string, error) { + switch transform.ToText.Type { + case ToTextTypeCrawl: + return r.transformTextCrawl(ctx, r.crawler, text) + case ToTextTypeCrawlByJina: + return r.transformTextCrawl(ctx, r.jinaCrawler, text) + + case ToTextTypePrompt: + return r.transformTextPrompt(ctx, transform, text) + default: + return r.transformTextPrompt(ctx, transform, text) + } +} + +func (r *rewriter) transformTextCrawl(ctx context.Context, crawler crawl.Crawler, url string) (string, error) { + mdBytes, err := crawler.Markdown(ctx, url) + if err != nil { + return "", errors.Wrapf(err, "crawl %s", url) + } + return string(mdBytes), nil +} + +// transformTextPrompt transforms text using configured LLM. +func (r *rewriter) transformTextPrompt(ctx context.Context, transform *Transform, text string) (string, error) { // Get LLM instance. llm := r.Dependencies().LLMFactory.Get(transform.ToText.LLM) // Call completion. result, err := llm.String(ctx, []string{ transform.ToText.promptRendered, - "The content to be processed is below, and the processing requirements are as above", text, // TODO: may place to first line to hit the model cache in different rewrite rules. }) if err != nil { @@ -525,32 +379,11 @@ func (r *rewriter) transformText(ctx context.Context, transform *Transform, text } func (r *rewriter) transformTextHack(text string) string { - bytes := unsafe.Slice(unsafe.StringData(text), len(text)) - start := 0 - end := len(bytes) - - // Remove the last line if it's empty. - // This is a hack to avoid the model output a empty line. - // E.g. category: tech\n - if end > 0 && bytes[end-1] == '\n' { - end-- - } - - // Remove the html backticks. - if end-start >= 7 && string(bytes[start:start+7]) == "```html" { - start += 7 - } - if end-start >= 3 && string(bytes[end-3:end]) == "```" { - end -= 3 - } - - // If no changes, return the original string. - if start == 0 && end == len(bytes) { - return text - } - - // Only copy one time. - return string(bytes[start:end]) + // TODO: optimize this. + text = strings.ReplaceAll(text, "```html", "") + text = strings.ReplaceAll(text, "```markdown", "") + text = strings.ReplaceAll(text, "```", "") + return text } type mockRewriter struct { diff --git a/pkg/rewrite/rewrite_test.go b/pkg/rewrite/rewrite_test.go index 98401ff..636592c 100644 --- a/pkg/rewrite/rewrite_test.go +++ b/pkg/rewrite/rewrite_test.go @@ -44,6 +44,7 @@ func TestLabels(t *testing.T) { SkipTooShortThreshold: ptr.To(10), Transform: &Transform{ ToText: &ToText{ + Type: ToTextTypePrompt, LLM: "mock-llm", Prompt: "{{ .category }}", // Using a simple template for testing }, @@ -79,6 +80,7 @@ func TestLabels(t *testing.T) { SkipTooShortThreshold: ptr.To(10), Transform: &Transform{ ToText: &ToText{ + Type: ToTextTypePrompt, LLM: "mock-llm", Prompt: "{{ .category }}", }, @@ -148,6 +150,7 @@ func TestLabels(t *testing.T) { SkipTooShortThreshold: ptr.To(10), Transform: &Transform{ ToText: &ToText{ + Type: ToTextTypePrompt, LLM: "mock-llm", Prompt: "{{ .category }}", promptRendered: "Analyze the content and categorize it...", @@ -186,6 +189,7 @@ func TestLabels(t *testing.T) { SkipTooShortThreshold: ptr.To(10), Transform: &Transform{ ToText: &ToText{ + Type: ToTextTypePrompt, LLM: "mock-llm", Prompt: "{{ .category }}", promptRendered: "Analyze the content and categorize it...", diff --git a/pkg/schedule/rule/periodic.go b/pkg/schedule/rule/periodic.go index 4625a77..e596bf8 100644 --- a/pkg/schedule/rule/periodic.go +++ b/pkg/schedule/rule/periodic.go @@ -55,7 +55,7 @@ func (r *periodic) Run() (err error) { end := time.Date(today.Year(), today.Month(), today.Day(), config.end.Hour(), config.end.Minute(), 0, 0, today.Location()) - buffer := 20 * time.Minute + buffer := 30 * time.Minute endPlusBuffer := end.Add(buffer) if now.Before(end) || now.After(endPlusBuffer) { return diff --git a/pkg/schedule/rule/rule.go b/pkg/schedule/rule/rule.go index a781283..d2f3b0a 100644 --- a/pkg/schedule/rule/rule.go +++ b/pkg/schedule/rule/rule.go @@ -18,7 +18,6 @@ package rule import ( "strings" "time" - "unicode/utf8" "github.com/pkg/errors" @@ -58,11 +57,8 @@ func (c *Config) Validate() error { //nolint:cyclop,gocognit if c.Name == "" { return errors.New("name is required") } - if c.Query != "" && utf8.RuneCountInString(c.Query) < 5 { - return errors.New("query must be at least 5 characters") - } if c.Threshold == 0 { - c.Threshold = 0.6 + c.Threshold = 0.5 } if c.Threshold < 0 || c.Threshold > 1 { return errors.New("threshold must be between 0 and 1") diff --git a/pkg/scrape/scraper/rss.go b/pkg/scrape/scraper/rss.go index 361b64e..851bc49 100644 --- a/pkg/scrape/scraper/rss.go +++ b/pkg/scrape/scraper/rss.go @@ -65,7 +65,6 @@ func newRSSReader(config *ScrapeSourceRSS) (reader, error) { } // --- Implementation code block --- - type rssReader struct { config *ScrapeSourceRSS client client diff --git a/pkg/scrape/scraper/scraper.go b/pkg/scrape/scraper/scraper.go index 495340b..cc37f80 100644 --- a/pkg/scrape/scraper/scraper.go +++ b/pkg/scrape/scraper/scraper.go @@ -227,7 +227,7 @@ func (s *scraper) filterExists(ctx context.Context, feeds []*model.Feed) (filter appendToResult := func(feed *model.Feed) { key := keyPrefix + strconv.FormatUint(feed.ID, 10) value := timeutil.Format(feed.Time) - if err := s.Dependencies().KVStorage.Set(ctx, key, value, ttl); err != nil { + if err := s.Dependencies().KVStorage.Set(ctx, []byte(key), []byte(value), ttl); err != nil { log.Error(ctx, err, "set last try store time") } filtered = append(filtered, feed) @@ -236,7 +236,7 @@ func (s *scraper) filterExists(ctx context.Context, feeds []*model.Feed) (filter for _, feed := range feeds { key := keyPrefix + strconv.FormatUint(feed.ID, 10) - lastTryStored, err := s.Dependencies().KVStorage.Get(ctx, key) + lastTryStored, err := s.Dependencies().KVStorage.Get(ctx, []byte(key)) switch { default: log.Error(ctx, err, "get last stored time, fallback to continue writing") @@ -246,7 +246,7 @@ func (s *scraper) filterExists(ctx context.Context, feeds []*model.Feed) (filter appendToResult(feed) case err == nil: - t, err := timeutil.Parse(lastTryStored) + t, err := timeutil.Parse(string(lastTryStored)) if err != nil { log.Error(ctx, err, "parse last try stored time, fallback to continue writing") appendToResult(feed) diff --git a/pkg/storage/feed/block/block.go b/pkg/storage/feed/block/block.go index 8b025a0..4d2151a 100644 --- a/pkg/storage/feed/block/block.go +++ b/pkg/storage/feed/block/block.go @@ -26,7 +26,6 @@ import ( "runtime" "slices" "strconv" - "strings" "sync" "sync/atomic" "time" @@ -277,47 +276,20 @@ type QueryOptions struct { Query string Threshold float32 LabelFilters []string - labelFilters []LabelFilter + labelFilters model.LabelFilters Limit int Start, End time.Time } -var ( - LabelFilterEqual = "=" - LabelFilterNotEqual = "!=" - - NewLabelFilter = func(key, value string, eq bool) string { - if eq { - return fmt.Sprintf("%s%s%s", key, LabelFilterEqual, value) - } - - return fmt.Sprintf("%s%s%s", key, LabelFilterNotEqual, value) - } - - ParseLabelFilter = func(filter string) (LabelFilter, error) { - eq := false - parts := strings.Split(filter, LabelFilterNotEqual) - if len(parts) != 2 { - parts = strings.Split(filter, LabelFilterEqual) - eq = true - } - if len(parts) != 2 { - return LabelFilter{}, errors.New("invalid label filter") - } - - return LabelFilter{Label: parts[0], Value: parts[1], Equal: eq}, nil - } -) - func (q *QueryOptions) Validate() error { //nolint:cyclop if q.Threshold < 0 || q.Threshold > 1 { return errors.New("threshold must be between 0 and 1") } - for _, labelFilter := range q.LabelFilters { - if labelFilter == "" { + for _, s := range q.LabelFilters { + if s == "" { return errors.New("label filter is required") } - filter, err := ParseLabelFilter(labelFilter) + filter, err := model.NewLabelFilter(s) if err != nil { return errors.Wrap(err, "parse label filter") } @@ -368,13 +340,6 @@ func (q *QueryOptions) HitTimeRangeCondition(b Block) bool { return queryAsBase || blockAsBase } -// LabelFilter defines the matcher for an item. -type LabelFilter struct { - Label string - Equal bool - Value string -} - // --- Factory code block --- type Factory component.Factory[Block, Config, Dependencies] @@ -1228,14 +1193,14 @@ func (b *block) applyFilters(ctx context.Context, query *QueryOptions) (res filt return b.mergeFilterResults(labelsResult, vectorsResult), nil } -func (b *block) applyLabelFilters(ctx context.Context, filters []LabelFilter) filterResult { +func (b *block) applyLabelFilters(ctx context.Context, filters model.LabelFilters) filterResult { if len(filters) == 0 { return matchedAllFilterResult } var allIDs map[uint64]struct{} for _, filter := range filters { - ids := b.invertedIndex.Search(ctx, filter.Label, filter.Equal, filter.Value) + ids := b.invertedIndex.Search(ctx, filter) if len(ids) == 0 { return matchedNothingFilterResult } @@ -1317,7 +1282,7 @@ func (b *block) mergeFilterResults(x, y filterResult) filterResult { } func (b *block) fillEmbedding(ctx context.Context, feeds []*model.Feed) ([]*chunk.Feed, error) { - embedded := make([]*chunk.Feed, len(feeds)) + embedded := make([]*chunk.Feed, 0, len(feeds)) llm := b.Dependencies().LLMFactory.Get(b.Config().embeddingLLM) var wg sync.WaitGroup var mu sync.Mutex @@ -1336,16 +1301,21 @@ func (b *block) fillEmbedding(ctx context.Context, feeds []*model.Feed) ([]*chun } mu.Lock() - embedded[i] = &chunk.Feed{ + embedded = append(embedded, &chunk.Feed{ Feed: feed, Vectors: vectors, - } + }) mu.Unlock() }(i, feed) } wg.Wait() - if len(errs) > 0 { - return nil, errs[0] + + switch len(errs) { + case 0: + case len(feeds): + return nil, errs[0] // All failed. + default: + log.Error(ctx, errors.Wrap(errs[0], "fill embedding"), "error_count", len(errs)) } return embedded, nil diff --git a/pkg/storage/feed/block/index/inverted/inverted.go b/pkg/storage/feed/block/index/inverted/inverted.go index b633757..790cc99 100644 --- a/pkg/storage/feed/block/index/inverted/inverted.go +++ b/pkg/storage/feed/block/index/inverted/inverted.go @@ -24,7 +24,7 @@ type Index interface { index.Codec // Search returns item IDs matching the given label and value. - Search(ctx context.Context, label string, eq bool, value string) (ids map[uint64]struct{}) + Search(ctx context.Context, matcher model.LabelFilter) (ids map[uint64]struct{}) // Add adds item to the index. // If label or value in labels is empty, it will be ignored. // If value is too long, it will be ignored, @@ -88,17 +88,17 @@ type idx struct { mu sync.RWMutex } -func (idx *idx) Search(ctx context.Context, label string, eq bool, value string) (ids map[uint64]struct{}) { +func (idx *idx) Search(ctx context.Context, matcher model.LabelFilter) (ids map[uint64]struct{}) { ctx = telemetry.StartWith(ctx, append(idx.TelemetryLabels(), telemetrymodel.KeyOperation, "Search")...) defer func() { telemetry.End(ctx, nil) }() idx.mu.RLock() defer idx.mu.RUnlock() - if value == "" { - return idx.searchEmptyValue(label, eq) + if matcher.Value == "" { + return idx.searchEmptyValue(matcher.Label, matcher.Equal) } - return idx.searchNonEmptyValue(label, eq, value) + return idx.searchNonEmptyValue(matcher) } func (idx *idx) Add(ctx context.Context, id uint64, labels model.Labels) { @@ -198,16 +198,16 @@ func (idx *idx) searchEmptyValue(label string, eq bool) map[uint64]struct{} { // searchNonEmptyValue handles the search logic when the target value is not empty. // If eq is true, it returns IDs that have the exact label-value pair. // If eq is false, it returns IDs that *do not* have the exact label-value pair. -func (idx *idx) searchNonEmptyValue(label string, eq bool, value string) map[uint64]struct{} { +func (idx *idx) searchNonEmptyValue(matcher model.LabelFilter) map[uint64]struct{} { // Get the map of values for the given label. - values, labelExists := idx.m[label] + values, labelExists := idx.m[matcher.Label] // If equal (eq), find the exact match. - if eq { + if matcher.Equal { if !labelExists { return make(map[uint64]struct{}) // Label doesn't exist. } - ids, valueExists := values[value] + ids, valueExists := values[matcher.Value] if !valueExists { return make(map[uint64]struct{}) // Value doesn't exist for this label. } @@ -221,7 +221,7 @@ func (idx *idx) searchNonEmptyValue(label string, eq bool, value string) map[uin resultIDs := maps.Clone(idx.ids) if labelExists { // If the specific label-value pair exists, remove its associated IDs. - if matchingIDs, valueExists := values[value]; valueExists { + if matchingIDs, valueExists := values[matcher.Value]; valueExists { for id := range matchingIDs { delete(resultIDs, id) } @@ -413,8 +413,8 @@ type mockIndex struct { component.Mock } -func (m *mockIndex) Search(ctx context.Context, label string, eq bool, value string) (ids map[uint64]struct{}) { - args := m.Called(ctx, label, eq, value) +func (m *mockIndex) Search(ctx context.Context, matcher model.LabelFilter) (ids map[uint64]struct{}) { + args := m.Called(ctx, matcher) return args.Get(0).(map[uint64]struct{}) } diff --git a/pkg/storage/feed/block/index/inverted/inverted_test.go b/pkg/storage/feed/block/index/inverted/inverted_test.go index afc656b..20f0336 100644 --- a/pkg/storage/feed/block/index/inverted/inverted_test.go +++ b/pkg/storage/feed/block/index/inverted/inverted_test.go @@ -118,9 +118,7 @@ func TestSearch(t *testing.T) { setupLabels map[uint64]model.Labels } type whenDetail struct { - searchLabel string - eq bool - searchValue string + matcher model.LabelFilter } type thenExpected struct { want []uint64 @@ -140,9 +138,11 @@ func TestSearch(t *testing.T) { }, }, WhenDetail: whenDetail{ - searchLabel: "category", - searchValue: "tech", - eq: true, + matcher: model.LabelFilter{ + Label: "category", + Value: "tech", + Equal: true, + }, }, ThenExpected: thenExpected{ want: []uint64{1, 2}, @@ -159,9 +159,11 @@ func TestSearch(t *testing.T) { }, }, WhenDetail: whenDetail{ - searchLabel: "invalid", - searchValue: "value", - eq: true, + matcher: model.LabelFilter{ + Label: "invalid", + Value: "value", + Equal: true, + }, }, ThenExpected: thenExpected{ want: nil, @@ -178,9 +180,11 @@ func TestSearch(t *testing.T) { }, }, WhenDetail: whenDetail{ - searchLabel: "category", - searchValue: "invalid", - eq: true, + matcher: model.LabelFilter{ + Label: "category", + Value: "invalid", + Equal: true, + }, }, ThenExpected: thenExpected{ want: nil, @@ -200,9 +204,11 @@ func TestSearch(t *testing.T) { }, }, WhenDetail: whenDetail{ - searchLabel: "category", - searchValue: "tech", - eq: false, + matcher: model.LabelFilter{ + Label: "category", + Value: "tech", + Equal: false, + }, }, ThenExpected: thenExpected{ want: []uint64{2}, @@ -220,9 +226,11 @@ func TestSearch(t *testing.T) { }, }, WhenDetail: whenDetail{ - searchLabel: "invalid", - searchValue: "value", - eq: false, + matcher: model.LabelFilter{ + Label: "invalid", + Value: "value", + Equal: false, + }, }, ThenExpected: thenExpected{ want: []uint64{1, 2}, @@ -240,7 +248,7 @@ func TestSearch(t *testing.T) { } // When. - result := idx.Search(context.Background(), tt.WhenDetail.searchLabel, tt.WhenDetail.eq, tt.WhenDetail.searchValue) + result := idx.Search(context.Background(), tt.WhenDetail.matcher) // Then. if tt.ThenExpected.want == nil { diff --git a/pkg/storage/kv/kv.go b/pkg/storage/kv/kv.go index 93ba024..caefce0 100644 --- a/pkg/storage/kv/kv.go +++ b/pkg/storage/kv/kv.go @@ -32,8 +32,8 @@ import ( // --- Interface code block --- type Storage interface { component.Component - Get(ctx context.Context, key string) (string, error) - Set(ctx context.Context, key string, value string, ttl time.Duration) error + Get(ctx context.Context, key []byte) ([]byte, error) + Set(ctx context.Context, key []byte, value []byte, ttl time.Duration) error } var ErrNotFound = errors.New("not found") @@ -137,7 +137,7 @@ func (k *kv) Close() error { const bucket = "0" -func (k *kv) Get(ctx context.Context, key string) (value string, err error) { +func (k *kv) Get(ctx context.Context, key []byte) (value []byte, err error) { ctx = telemetry.StartWith(ctx, append(k.TelemetryLabels(), telemetrymodel.KeyOperation, "Get")...) defer func() { telemetry.End(ctx, func() error { @@ -157,22 +157,22 @@ func (k *kv) Get(ctx context.Context, key string) (value string, err error) { }) switch { case err == nil: - return string(b), nil + return b, nil case errors.Is(err, nutsdb.ErrNotFoundKey): - return "", ErrNotFound + return nil, ErrNotFound case strings.Contains(err.Error(), "key not found"): - return "", ErrNotFound + return nil, ErrNotFound default: - return "", err + return nil, err } } -func (k *kv) Set(ctx context.Context, key string, value string, ttl time.Duration) (err error) { +func (k *kv) Set(ctx context.Context, key []byte, value []byte, ttl time.Duration) (err error) { ctx = telemetry.StartWith(ctx, append(k.TelemetryLabels(), telemetrymodel.KeyOperation, "Set")...) defer func() { telemetry.End(ctx, err) }() return k.db.Update(func(tx *nutsdb.Tx) error { - return tx.Put(bucket, []byte(key), []byte(value), uint32(ttl.Seconds())) + return tx.Put(bucket, key, value, uint32(ttl.Seconds())) }) } @@ -180,13 +180,13 @@ type mockKV struct { component.Mock } -func (m *mockKV) Get(ctx context.Context, key string) (string, error) { +func (m *mockKV) Get(ctx context.Context, key []byte) ([]byte, error) { args := m.Called(ctx, key) - return args.String(0), args.Error(1) + return args.Get(0).([]byte), args.Error(1) } -func (m *mockKV) Set(ctx context.Context, key string, value string, ttl time.Duration) error { +func (m *mockKV) Set(ctx context.Context, key []byte, value []byte, ttl time.Duration) error { args := m.Called(ctx, key, value, ttl) return args.Error(0) diff --git a/pkg/telemetry/log/log.go b/pkg/telemetry/log/log.go index 3536f98..dbe2e03 100644 --- a/pkg/telemetry/log/log.go +++ b/pkg/telemetry/log/log.go @@ -27,6 +27,8 @@ import ( "github.com/pkg/errors" slogdedup "github.com/veqryn/slog-dedup" + + "github.com/glidea/zenfeed/pkg/model" ) type Level string @@ -187,7 +189,8 @@ func getStack(skip, depth int) string { } first = false - b.WriteString(frame.Function) + fn := strings.TrimPrefix(frame.Function, model.Module) // no module prefix for zenfeed self. + b.WriteString(fn) b.WriteByte(':') b.WriteString(strconv.Itoa(frame.Line)) } diff --git a/pkg/telemetry/server/server.go b/pkg/telemetry/server/server.go new file mode 100644 index 0000000..0722b55 --- /dev/null +++ b/pkg/telemetry/server/server.go @@ -0,0 +1,137 @@ +// 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 . + +package http + +import ( + "net" + "net/http" + "net/http/pprof" + + "github.com/pkg/errors" + + "github.com/glidea/zenfeed/pkg/component" + "github.com/glidea/zenfeed/pkg/config" + telemetry "github.com/glidea/zenfeed/pkg/telemetry" + "github.com/glidea/zenfeed/pkg/telemetry/log" + "github.com/glidea/zenfeed/pkg/telemetry/metric" + telemetrymodel "github.com/glidea/zenfeed/pkg/telemetry/model" +) + +// --- Interface code block --- +type Server interface { + component.Component +} + +type Config struct { + Address string +} + +func (c *Config) Validate() error { + if c.Address == "" { + c.Address = ":9090" + } + if _, _, err := net.SplitHostPort(c.Address); err != nil { + return errors.Wrap(err, "invalid address") + } + + return nil +} + +func (c *Config) From(app *config.App) *Config { + c.Address = app.Telemetry.Address + + return c +} + +type Dependencies struct { +} + +// --- Factory code block --- +type Factory component.Factory[Server, config.App, Dependencies] + +func NewFactory(mockOn ...component.MockOption) Factory { + if len(mockOn) > 0 { + return component.FactoryFunc[Server, config.App, Dependencies]( + func(instance string, config *config.App, dependencies Dependencies) (Server, error) { + m := &mockServer{} + component.MockOptions(mockOn).Apply(&m.Mock) + + return m, nil + }, + ) + } + + return component.FactoryFunc[Server, config.App, Dependencies](new) +} + +func new(instance string, app *config.App, dependencies Dependencies) (Server, error) { + config := &Config{} + config.From(app) + if err := config.Validate(); err != nil { + return nil, errors.Wrap(err, "validate config") + } + + router := http.NewServeMux() + router.Handle("/health", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + router.Handle("/metrics", metric.Handler()) + router.HandleFunc("/pprof", pprof.Index) + router.HandleFunc("/pprof/cmdline", pprof.Cmdline) + router.HandleFunc("/pprof/profile", pprof.Profile) + router.HandleFunc("/pprof/symbol", pprof.Symbol) + router.HandleFunc("/pprof/trace", pprof.Trace) + + return &server{ + Base: component.New(&component.BaseConfig[Config, Dependencies]{ + Name: "TelemetryServer", + Instance: instance, + Config: config, + Dependencies: dependencies, + }), + http: &http.Server{Addr: config.Address, Handler: router}, + }, nil +} + +// --- Implementation code block --- +type server struct { + *component.Base[Config, Dependencies] + http *http.Server +} + +func (s *server) Run() (err error) { + ctx := telemetry.StartWith(s.Context(), append(s.TelemetryLabels(), telemetrymodel.KeyOperation, "Run")...) + defer func() { telemetry.End(ctx, err) }() + + serverErr := make(chan error, 1) + go func() { + serverErr <- s.http.ListenAndServe() + }() + + s.MarkReady() + select { + case <-ctx.Done(): + log.Info(ctx, "shutting down") + + return s.http.Shutdown(ctx) + case err := <-serverErr: + return errors.Wrap(err, "listen and serve") + } +} + +type mockServer struct { + component.Mock +} diff --git a/pkg/util/crawl/crawl.go b/pkg/util/crawl/crawl.go new file mode 100644 index 0000000..700e73d --- /dev/null +++ b/pkg/util/crawl/crawl.go @@ -0,0 +1,176 @@ +package crawl + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "sync" + + "github.com/pkg/errors" + "github.com/temoto/robotstxt" + + "github.com/glidea/zenfeed/pkg/util/text_convert" +) + +type Crawler interface { + Markdown(ctx context.Context, u string) ([]byte, error) +} + +type local struct { + hc *http.Client + + robotsDataCache sync.Map +} + +func NewLocal() Crawler { + return &local{ + hc: &http.Client{}, + } +} + +func (c *local) Markdown(ctx context.Context, u string) ([]byte, error) { + // Check if the page is allowed. + if err := c.checkAllowed(ctx, u); err != nil { + return nil, errors.Wrapf(err, "check robots.txt for %s", u) + } + + // Prepare the request. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, errors.Wrapf(err, "create request for %s", u) + } + req.Header.Set("User-Agent", userAgent) + + // Send the request. + resp, err := c.hc.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "fetch %s", u) + } + defer resp.Body.Close() + + // Parse the response. + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("received non-200 status code %d from %s", resp.StatusCode, u) + } + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrapf(err, "read body from %s", u) + } + + // Convert the body to markdown. + mdBytes, err := textconvert.HTMLToMarkdown(bodyBytes) + if err != nil { + return nil, errors.Wrap(err, "convert html to markdown") + } + + return mdBytes, nil +} + +const userAgent = "ZenFeed" + +func (c *local) checkAllowed(ctx context.Context, u string) error { + parsedURL, err := url.Parse(u) + if err != nil { + return errors.Wrapf(err, "parse url %s", u) + } + + d, err := c.getRobotsData(ctx, parsedURL.Host) + if err != nil { + return errors.Wrapf(err, "check robots.txt for %s", parsedURL.Host) + } + if !d.TestAgent(parsedURL.Path, userAgent) { + return errors.Errorf("disallowed by robots.txt for %s", u) + } + + return nil +} + +// getRobotsData fetches and parses robots.txt for a given host. +func (c *local) getRobotsData(ctx context.Context, host string) (*robotstxt.RobotsData, error) { + // Check the cache. + if data, found := c.robotsDataCache.Load(host); found { + return data.(*robotstxt.RobotsData), nil + } + + // Prepare the request. + robotsURL := fmt.Sprintf("https://%s/robots.txt", host) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, robotsURL, nil) + if err != nil { + return nil, errors.Wrapf(err, "create request for %s", robotsURL) + } + req.Header.Set("User-Agent", userAgent) + + // Send the request. + resp, err := c.hc.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "fetch %s", robotsURL) + } + defer resp.Body.Close() + + // Parse the response. + switch resp.StatusCode { + case http.StatusOK: + data, err := robotstxt.FromResponse(resp) + if err != nil { + return nil, errors.Wrapf(err, "parse robots.txt from %s", robotsURL) + } + c.robotsDataCache.Store(host, data) + return data, nil + case http.StatusNotFound: + data := &robotstxt.RobotsData{} + c.robotsDataCache.Store(host, data) + return data, nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil, errors.Errorf("access to %s denied (status %d)", robotsURL, resp.StatusCode) + default: + return nil, errors.Errorf("unexpected status %d fetching %s", resp.StatusCode, robotsURL) + } +} + +type jina struct { + hc *http.Client + token string +} + +func NewJina(token string) Crawler { + return &jina{ + hc: &http.Client{}, + + // If token is empty, will not affect to use, but rate limit will be lower. + // See https://jina.ai/api-dashboard/rate-limit. + token: token, + } +} + +func (c *jina) Markdown(ctx context.Context, u string) ([]byte, error) { + proxyURL := fmt.Sprintf("https://r.jina.ai/%s", u) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, proxyURL, nil) + if err != nil { + return nil, errors.Wrapf(err, "create request for %s", u) + } + + req.Header.Set("X-Engine", "browser") + req.Header.Set("X-Robots-Txt", userAgent) + if c.token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) + } + + resp, err := c.hc.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "fetch %s", proxyURL) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("received non-200 status code %d from %s", resp.StatusCode, proxyURL) + } + + mdBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrapf(err, "read body from %s", proxyURL) + } + + return mdBytes, nil +} diff --git a/pkg/util/rpc/rpc.go b/pkg/util/jsonrpc/jsonrpc.go similarity index 75% rename from pkg/util/rpc/rpc.go rename to pkg/util/jsonrpc/jsonrpc.go index 98cac88..7e28496 100644 --- a/pkg/util/rpc/rpc.go +++ b/pkg/util/jsonrpc/jsonrpc.go @@ -13,39 +13,19 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package rpc +package jsonrpc import ( "context" "encoding/json" "errors" "net/http" + + "github.com/glidea/zenfeed/pkg/api" ) type Handler[Request any, Response any] func(ctx context.Context, req *Request) (*Response, error) -var ( - ErrBadRequest = func(err error) Error { return newError(http.StatusBadRequest, err) } - ErrNotFound = func(err error) Error { return newError(http.StatusNotFound, err) } - ErrInternal = func(err error) Error { return newError(http.StatusInternalServerError, err) } -) - -type Error struct { - Code int `json:"code"` - Message string `json:"message"` -} - -func (e Error) Error() string { - return e.Message -} - -func newError(code int, err error) Error { - return Error{ - Code: code, - Message: err.Error(), - } -} - func API[Request any, Response any](handler Handler[Request, Response]) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { allowCORS(w) @@ -65,11 +45,11 @@ func API[Request any, Response any](handler Handler[Request, Response]) http.Han resp, err := handler(r.Context(), &req) if err != nil { - var rpcErr Error - if errors.As(err, &rpcErr) { + var apiErr api.Error + if errors.As(err, &apiErr) { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(rpcErr.Code) - _ = json.NewEncoder(w).Encode(rpcErr) + w.WriteHeader(apiErr.Code) + _ = json.NewEncoder(w).Encode(apiErr) return } diff --git a/pkg/util/rpc/rpc_test.go b/pkg/util/jsonrpc/jsonrpc_test.go similarity index 96% rename from pkg/util/rpc/rpc_test.go rename to pkg/util/jsonrpc/jsonrpc_test.go index cc5e02a..e4c0672 100644 --- a/pkg/util/rpc/rpc_test.go +++ b/pkg/util/jsonrpc/jsonrpc_test.go @@ -13,7 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package rpc +package jsonrpc import ( "bytes" @@ -27,6 +27,7 @@ import ( . "github.com/onsi/gomega" + "github.com/glidea/zenfeed/pkg/api" "github.com/glidea/zenfeed/pkg/test" ) @@ -58,15 +59,15 @@ func TestAPI(t *testing.T) { } badRequestHandler := func(ctx context.Context, req *TestRequest) (*TestResponse, error) { - return nil, ErrBadRequest(errors.New("invalid request")) + return nil, api.ErrBadRequest(errors.New("invalid request")) } notFoundHandler := func(ctx context.Context, req *TestRequest) (*TestResponse, error) { - return nil, ErrNotFound(errors.New("resource not found")) + return nil, api.ErrNotFound(errors.New("resource not found")) } internalErrorHandler := func(ctx context.Context, req *TestRequest) (*TestResponse, error) { - return nil, ErrInternal(errors.New("server error")) + return nil, api.ErrInternal(errors.New("server error")) } genericErrorHandler := func(ctx context.Context, req *TestRequest) (*TestResponse, error) {