Compare commits
14 Commits
v0.5.0
...
competitor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46da1fa495 | ||
|
|
7cb8069d60 | ||
|
|
87b84d94ff | ||
|
|
4d29bae67f | ||
|
|
d640e975bd | ||
|
|
e4bd0ca43b | ||
|
|
8b001c4cdf | ||
|
|
6cacb47d3d | ||
|
|
a65d597032 | ||
|
|
151bd5f66f | ||
|
|
69a9545869 | ||
|
|
b01e07e348 | ||
|
|
e92d7e322e | ||
|
|
7b4396067b |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
custom: https://afdian.com/a/glidea
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -5,6 +5,8 @@ on:
|
||||
branches: [ main, dev ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -25,7 +27,7 @@ jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
if: github.event_name == 'push'
|
||||
if: github.event_name == 'release' || (github.event_name == 'push' && github.ref_name == 'dev')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
@@ -36,7 +38,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push Docker image (main)
|
||||
if: github.ref_name == 'main'
|
||||
if: github.event_name == 'release'
|
||||
run: make push
|
||||
- name: Build and push Docker image (dev)
|
||||
if: github.ref_name == 'dev'
|
||||
|
||||
@@ -113,7 +113,7 @@ Give zenfeed a try! **AI + RSS** might be a better way to consume information in
|
||||
|
||||
> [!IMPORTANT]
|
||||
> zenfeed uses model services from [SiliconFlow](https://cloud.siliconflow.cn/en) by default.
|
||||
> * Models: `Qwen/Qwen3-8B` (Free) and `Pro/BAAI/bge-m3`.
|
||||
> * Models: `Qwen/Qwen3-8B` (Free) and `Qwen/Qwen3-Embedding-4B`.
|
||||
> * If you don't have a SiliconFlow account yet, use this [**invitation link**](https://cloud.siliconflow.cn/i/U2VS0Q5A) to get a **¥14** credit.
|
||||
> * If you need to use other providers or models, or for more detailed custom deployments, please refer to the [Configuration Documentation](https://github.com/glidea/zenfeed/blob/main/docs/config.md) to edit `docker-compose.yml`.
|
||||
|
||||
|
||||
33
README.md
33
README.md
@@ -1,3 +1,5 @@
|
||||
[Nano Banana🍌 公益站](https://image-generation.zenfeed.xyz/):集成 Twitter 热门 Prompt,轻松玩转各种姿势
|
||||
---
|
||||
[English](README-en.md)
|
||||
|
||||
---
|
||||
@@ -9,13 +11,7 @@
|
||||
* 面向开发者:一站式接入几乎所有AI应用开发需要用到的模型和API,一站式付费,统一接入。
|
||||
* 面向企业:管理与使用界面分离,一人管理,多人使用,降低中小企业使用AI的门槛和成本。
|
||||
|
||||
> 以下是我的个人感受
|
||||
> * 播客生成效果不错,产品设计也不错,能基于 RSS 内容生成,修改播客脚本... 比市面其它播客产品更全面
|
||||
> * 中转价格大多与官方一致,比如硅基 qwen3 8b 同样免费
|
||||
> * 支持 MCP,Agent 托管
|
||||
> * 拥抱开源,平台上各种 AI 应用都是开源的。可以看他们的 [开源账户](https://github.com/302ai)
|
||||
|
||||
看都看了,GitHub 一键登录 [注册一个](https://share.302.ai/mFS9MS) 试试吧!立即获得 1 美元额度
|
||||
GitHub 一键登录 [注册一个](https://share.302.ai/mFS9MS) 试试吧!立即获得 1 美元额度
|
||||
|
||||
---
|
||||
|
||||
@@ -41,6 +37,8 @@ zenfeed 是你的 <strong>AI 信息中枢</strong>。它既是<strong>智能 RSS
|
||||
<p align="center">
|
||||
<a href="https://zenfeed.xyz"><b>在线体验 (仅 RSS 阅读)</b></a>
|
||||
|
|
||||
<a href="https://github.com/xusonfan/zenfeedApp"><b>安卓版体验 (仅 RSS 阅读)</b></a>
|
||||
|
|
||||
<a href="docs/tech/hld-zh.md"><b>技术文档</b></a>
|
||||
|
|
||||
<a href="#-安装与使用"><b>快速开始</b></a>
|
||||
@@ -52,10 +50,19 @@ zenfeed 是你的 <strong>AI 信息中枢</strong>。它既是<strong>智能 RSS
|
||||
---
|
||||
|
||||
**epub2rss**: 把 epub 电子书转成每日更新一个章节的 RSS Feed,[join waitlist](https://epub2rss.pages.dev/)
|
||||
|
||||
**one-coffee**: 一款类似 syft,万物追踪的日报产品(差异点:支持播客等多模态;高质量信源,主攻 AI 领域)。下方加我微信加入 waitlist
|
||||
|
||||
---
|
||||
|
||||
**赞助项目可以领取 Gemini Key**
|
||||
|
||||
<a href="https://afdian.com/a/glidea"><img src="docs/images/sponsor.png" width="500"></a>
|
||||
<br/>
|
||||
<a href="https://afdian.com/a/glidea">赞助项目,支持发展</a>
|
||||
|
||||
---
|
||||
|
||||
## 💡 前言
|
||||
|
||||
RSS(简易信息聚合)诞生于 Web 1.0 时代,旨在解决信息分散的问题,让用户能在一个地方聚合、追踪多个网站的更新,无需频繁访问。它将网站更新以摘要形式推送给订阅者,便于快速获取信息。
|
||||
@@ -135,7 +142,8 @@ RSS(简易信息聚合)诞生于 Web 1.0 时代,旨在解决信息分散
|
||||
|
||||
> [!IMPORTANT]
|
||||
> zenfeed 默认使用 [硅基流动](https://cloud.siliconflow.cn/) 提供的模型服务。
|
||||
> * 模型: `Qwen/Qwen3-8B` (免费) 和 `Pro/BAAI/bge-m3`。
|
||||
> * 模型: `Qwen/Qwen3-8B` (免费) 和 `Qwen/Qwen3-Embedding-4B`。
|
||||
> * **!!!如果你愿意赞助本项目,将获赠一定额度的 Gemini 2.5 Pro/Flash!!! (见下方)**
|
||||
> * 如果你还没有硅基账号,使用 [**邀请链接**](https://cloud.siliconflow.cn/i/U2VS0Q5A) 可获得 **14 元** 赠送额度。
|
||||
> * 如果需要使用其他厂商或模型,或进行更详细的自定义部署,请参考 [配置文档](https://github.com/glidea/zenfeed/blob/main/docs/config-zh.md) 来编辑 `docker-compose.yml`。
|
||||
|
||||
@@ -171,6 +179,7 @@ $env:API_KEY = "sk-..."; docker-compose -p zenfeed up -d
|
||||
> * **安全提示:** zenfeed 尚无认证机制,将服务暴露到公网可能会泄露您的 `API_KEY`。请务必配置严格的安全组规则,仅对信任的 IP 开放访问。
|
||||
|
||||
### 3. 开始使用
|
||||
> 安卓版:https://github.com/xusonfan/zenfeedApp
|
||||
|
||||
#### 添加 RSS 订阅源
|
||||
|
||||
@@ -206,14 +215,14 @@ $env:API_KEY = "sk-..."; docker-compose -p zenfeed up -d
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="docs/images/wechat.png" alt="Wechat QR Code" width="150">
|
||||
<img src="docs/images/wechat.png" alt="Wechat QR Code" width="300">
|
||||
<br>
|
||||
<strong>加群讨论</strong>
|
||||
<strong>AI 学习交流社群</strong>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="docs/images/sponsor.png" alt="Sponsor QR Code" width="150">
|
||||
<img src="docs/images/sponsor.png" width="500">
|
||||
<br>
|
||||
<strong>请杯咖啡 🧋</strong>
|
||||
<strong><a href="https://afdian.com/a/glidea">请杯奶茶 🧋</a></strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -60,7 +60,7 @@ configs:
|
||||
api_key: ${API_KEY:-your-api-key}
|
||||
- name: embed
|
||||
provider: siliconflow
|
||||
embedding_model: Pro/BAAI/bge-m3
|
||||
embedding_model: Qwen/Qwen3-Embedding-4B
|
||||
api_key: ${API_KEY:-your-api-key}
|
||||
scrape:
|
||||
rsshub_endpoint: http://rsshub:1200
|
||||
|
||||
152
docs/competitor-research-zh.md
Normal file
152
docs/competitor-research-zh.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 竞品调研(Zenfeed)
|
||||
|
||||
更新时间:2025-10(基于公开已知信息与产品体验的综合判断,可能随时间变化)
|
||||
|
||||
目的:梳理 Zenfeed 所在赛道的主要竞品与替代方案,按产品类型与能力维度对比,识别差异化与机会点,指导路线和定位。
|
||||
|
||||
|
||||
一、产品类型与代表选手
|
||||
|
||||
1) AI 加持的 RSS/SaaS 阅读器
|
||||
- Feedly(含 Feedly AI/Leo)
|
||||
- Readwise Reader
|
||||
- Inoreader(规则、搜索较强,近年也引入部分 AI 功能)
|
||||
- NewsBlur(基于训练的过滤偏好)
|
||||
|
||||
2) 自托管 RSS 聚合器(开源)
|
||||
- FreshRSS
|
||||
- Tiny Tiny RSS(TT-RSS)
|
||||
- Miniflux
|
||||
- + 插件生态(例如部分 OpenAI/总结插件)
|
||||
|
||||
3) 信息监控/自动化编排
|
||||
- Huginn(自托管 IFTTT/IF-This-Then-That)
|
||||
- n8n(低代码自动化编排,SaaS/自托管)
|
||||
- changedetection.io(网页变更监控,侧重 DOM 变化)
|
||||
- Distill.io / Visualping(SaaS 页面变更监控)
|
||||
|
||||
4) 情报监测/关键词追踪(论坛/社媒/开发者社区)
|
||||
- Syften(跨社区关键词监控)
|
||||
- 万物追踪(中文市场关注度较高)
|
||||
- TeamWiseFlow/WiseFlow(“AI 首席情报官”思路)
|
||||
|
||||
5) 爬取/采集与任务平台(更多是基础设施/补充)
|
||||
- Scrapy、Apify、Crawlee、Playwright/Chromium 爬取方案
|
||||
- RSSHub(补齐数据源的通用 RSS 生成器,侧重“供给侧”,非直接竞品)
|
||||
|
||||
|
||||
二、核心对比维度
|
||||
|
||||
- 数据源覆盖与可扩展性:
|
||||
- 是否仅限 RSS,是否支持 RSSHub/网页爬取、API、邮件等;是否易于接入新源。
|
||||
- 信息筛选与查询:
|
||||
- 规则引擎(关键词/布尔条件/正则/评分)、保存的搜索、优先级/标签。
|
||||
- AI 能力:
|
||||
- 摘要、打标、主题聚类、意图识别、相似度搜索/去重、长文重写、对话式问答。
|
||||
- 工作流/编排:
|
||||
- 是否可声明式编排(pipeline)、分步处理、多路分发、可插拔 Prompt/算子。
|
||||
- 通知与路由:
|
||||
- 邮件/Slack/Telegram/企业微信/飞书/Webhook/RSS 二次生成,是否支持相似分组/摘要汇总。
|
||||
- 存储与检索:
|
||||
- 结构化索引、倒排索引、向量库、对象存储(音频/附件)、缓存与过期策略。
|
||||
- 部署与成本:
|
||||
- SaaS 订阅 vs 自托管;API Key 成本、并发限速、隐私数据掌控。
|
||||
- 协作与团队能力:
|
||||
- Board/共享、标注/高亮、评论、权限与多用户。
|
||||
- 开放接口:
|
||||
- JSON-RPC/HTTP API、MCP Server、二次开发友好度。
|
||||
- 体验与生态:
|
||||
- Web/移动端、浏览器插件、自动化生态与第三方集成。
|
||||
|
||||
|
||||
三、典型竞品速览
|
||||
|
||||
1) Feedly + Feedly AI(Leo)
|
||||
- 定位:面向专业/团队的信息监测与阅读;AI“Leo”用于优先级、主题、去重与摘要。
|
||||
- 优势:成熟度高,协作/Board 丰富,企业方案完善,数据源与集成较多。
|
||||
- 局限:闭源、订阅价格较高;自定义度受限;自托管不可行;对中文开发者生态与私有化场景不友好。
|
||||
|
||||
2) Readwise Reader
|
||||
- 定位:稍偏“稍后读/知识管理”;支持网页/邮件/订阅,AI 摘要和高亮回顾。
|
||||
- 优势:体验优秀、阅读流程顺滑、与 Readwise 知识库联动。
|
||||
- 局限:闭源、偏个人阅读;自动化编排/告警监控能力有限;难以做灵活的规则路由。
|
||||
|
||||
3) Inoreader / NewsBlur
|
||||
- 定位:传统 RSS 强者;Inoreader 的规则、过滤、搜索很成熟,NewsBlur 有“训练”机制。
|
||||
- 优势:稳定可靠、生态与客户端广、学习成本低。
|
||||
- 局限:AI 能力与向量检索通常非核心;深度个性化编排和二次开发空间有限。
|
||||
|
||||
4) FreshRSS / TT-RSS / Miniflux(自托管)
|
||||
- 定位:轻量的开源 RSS 聚合;可通过插件接 AI 摘要。
|
||||
- 优势:自托管、可控、成本低;社区活跃。
|
||||
- 局限:缺少原生的“语义处理流水线”和“查询/路由/通知”的深度耦合;要实现“AI + 监控 + 通知”的端到端闭环需要较多自定义。
|
||||
|
||||
5) Huginn / n8n(自动化)
|
||||
- 定位:通用自动化与数据流编排。
|
||||
- 优势:灵活、组件多、可拉通任意 API 与存储。
|
||||
- 局限:非“RSS/情报”领域专用;需要自己拼装爬取、解析、去重、总结、相似分组、通知等环节,心智与运维成本较高。
|
||||
|
||||
6) changedetection.io / Distill.io / Visualping(页面变更监控)
|
||||
- 定位:检测网页 DOM/文本变化,并告警。
|
||||
- 优势:上手快、对不提供 RSS 的页面友好。
|
||||
- 局限:语义层理解弱;AI 总结/聚类/多源关联较少;难以形成“知识库 + 查询”。
|
||||
|
||||
7) Syften / 万物追踪 / WiseFlow 等(情报监控)
|
||||
- 定位:跨社区关键词/实体/主题跟踪,生成简报。
|
||||
- 优势:场景契合“情报/监控/简报”;SaaS 即开即用。
|
||||
- 局限:闭源或私有;可编排性有限;数据可控性与隐私诉求难满足;对中文/特定垂直源支持取决于厂商。
|
||||
|
||||
8) RSSHub(重要补充而非直接竞品)
|
||||
- 定位:把各种站点“喂成 RSS”。
|
||||
- 价值:极大丰富数据源;与 Zenfeed 组合可形成“源→处理→查询→路由”的闭环。
|
||||
|
||||
|
||||
四、Zenfeed 的差异化与定位
|
||||
|
||||
- 面向“AI + RSS/情报/监控”的自托管开源引擎:
|
||||
- 声明式 YAML 配置 + 热更新;组件化框架,订阅 Watcher 实时生效。
|
||||
- 处理管道以“标签集”为核心抽象,支持 LLM 驱动的摘要、打标、评分、过滤、聚类、脚本生成、抓取增强等。
|
||||
- 存储侧内置主索引、倒排索引与向量索引;NutsDB 缓存嵌入;可选 MinIO/S3 存储富媒体。
|
||||
- 调度器支持基于语义/规则的查询生成“事件”;通知器做相似合并与 LLM 汇总,路由到 Email/Webhook 等。
|
||||
- 对外提供 JSON-RPC/HTTP、MCP Server、导出 RSS,易于二次集成。
|
||||
|
||||
- 与 SaaS 阅读器相比:更可定制、数据私有、可与私有模型/网络融合;缺点是需要自行部署与维护。
|
||||
- 与通用自动化平台相比:Zenfeed 在“RSS/情报管道”有更强的领域内建语义模型与数据结构;通用性略弱但落地更快。
|
||||
- 与纯 RSS 聚合器相比:原生引入 LLM 语义处理、向量搜索、调度/路由/通知,强调“读写闭环”和“监控/简报”。
|
||||
|
||||
|
||||
五、潜在短板与改进机会(相对竞品)
|
||||
|
||||
- 认证与多用户/权限:当前暴露到公网存在安全顾虑;完善 Auth、分权与审计将有助企业团队采用。
|
||||
- GUI 配置与可视化编排:YAML 心智强,建议提供可视化 Pipeline/路由设计器、规则调试器、Prompt 管理。
|
||||
- 移动端/客户端生态:继续完善官方 Web/移动端体验;浏览器插件“保存到 Zenfeed/一键追踪”。
|
||||
- 第三方通知与协作:拓展 Slack/Telegram/飞书/企业微信等官方集成;通知中的交互(反馈、标注)回写管道。
|
||||
- 数据源扩展:内置“RSSHub 模板化连接”、网页自动提取(Readability/Boilerplate)、邮件源、GitHub/Reddit 等常见垂直源。
|
||||
- 模型与成本:
|
||||
- 多模型策略(廉价 Embedding + 中等 Summarize + 高质少量 Deep Reasoning);
|
||||
- 限速/重试/缓存/去重更精细以优化成本;
|
||||
- 插件式模型提供商抽象,方便企业接私有 LLM。
|
||||
- 团队协作:Board/共享视图、事件指派、评论、标签规范与知识沉淀。
|
||||
|
||||
|
||||
六、目标用户与场景建议
|
||||
|
||||
- 高阶 RSS 用户、研究员/分析师、开发者、投资/BD、开源维护者、增长/品牌观察;
|
||||
- 典型场景:
|
||||
- 行业/竞品/技术情报监控与周报;
|
||||
- 个性化“高质量源 + 语义过滤 + 摘要 + 去重 + 分发”;
|
||||
- 会议/活动/政策/漏洞/版本发布等事件跟踪;
|
||||
- 私有化部署的“团队情报中枢”。
|
||||
|
||||
|
||||
七、结论(定位与策略)
|
||||
|
||||
- 定位:自托管、可编排的 AI 信息中枢(“RSS × 语义处理 × 调度路由 × 通知”)。
|
||||
- 策略:
|
||||
1) 做强“声明式管道 + 语义检索 + 调度路由”的一体化体验;
|
||||
2) 打通 RSSHub 与常见垂直源,降低“连接成本”;
|
||||
3) 完善认证/多用户与 GUI 编排,提升团队与企业可用性;
|
||||
4) 提供一组开箱即用的“行业模板/蓝图”(安全情报、开源监控、投研资讯、竞品监测、政策合规等);
|
||||
5) 控制模型成本,提供可观测性(速率、花费、召回/精确度、摘要质量回馈)。
|
||||
|
||||
备注:以上信息仅用于产品规划参考,不构成对第三方产品的评价承诺。实际功能与定价以各产品官方为准。
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 897 KiB |
@@ -77,7 +77,7 @@ storage:
|
||||
- `speakers`: 定义播客的演讲者。
|
||||
- `name`: 演讲者的名字。
|
||||
- `role`: 演讲者的角色和人设,将影响脚本内容。
|
||||
- `voice`: 演讲者的声音。请参考 [Google Cloud TTS 文档](https://cloud.google.com/text-to-speech/docs/voices) 获取可用的声音名称(例如 `en-US-Standard-C`,`en-US-News-N`)。
|
||||
- `voice`: 演讲者的声音。请参考 [Gemini TTS 文档](https://ai.google.dev/gemini-api/docs/speech-generation#voices)。
|
||||
|
||||
**示例 `config.yaml`:**
|
||||
|
||||
@@ -85,20 +85,23 @@ storage:
|
||||
storage:
|
||||
feed:
|
||||
rewrites:
|
||||
- source_label: "content"
|
||||
label: "podcast_url"
|
||||
- source_label: content # 基于原文
|
||||
transform:
|
||||
to_podcast:
|
||||
llm: "openai-chat"
|
||||
tts_llm: "gemini-tts"
|
||||
transcript_additional_prompt: "使用中文回复"
|
||||
estimate_maximum_duration: 3m0s # 接近 3 分钟
|
||||
transcript_additional_prompt: 对话引人入胜,流畅自然,拒绝 AI 味,使用中文回复 # 脚本内容要求
|
||||
llm: xxxx # 负责生成脚本的 llm
|
||||
tts_llm: gemini-tts # 仅支持 gemini tts,推荐使用 https://github.com/glidea/one-balance 轮询
|
||||
speakers:
|
||||
- name: "主持人小雅"
|
||||
role: "一位经验丰富、声音甜美、风格活泼的科技播客主持人。擅长联系实际生活场景。"
|
||||
voice: "zh-CN-Standard-A" # 女声
|
||||
- name: "技术评论员老王"
|
||||
role: "一位对技术有深入见解、观点犀利的评论员,说话直接,偶尔有些愤世嫉俗。"
|
||||
voice: "zh-CN-Standard-B" # 男声
|
||||
- name: 小雅
|
||||
role: >-
|
||||
一位经验丰富、声音甜美、风格活泼的科技播客主持人。前财经记者、媒体人出身,因为工作原因长期关注科技行业,后来凭着热爱和出色的口才转行做了全职内容创作者。擅长从普通用户视角出发,把复杂的技术概念讲得生动有趣,是她发掘了老王,并把他‘骗’来一起做播客的‘始作俑者’。
|
||||
voice: Autonoe
|
||||
- name: 老王
|
||||
role: >-
|
||||
一位资深科技评论员,互联网老兵。亲身经历过中国互联网从草莽到巨头的全过程,当过程序员,做过产品经理,也创过业。因此他对行业的各种‘风口’和‘概念’有自己独到的、甚至有些刻薄的见解。观点犀利,一针见血,说话直接,热衷于给身边的一切产品挑刺。被‘忽悠’上了‘贼船’,表面上经常吐槽,但内心很享受这种分享观点的感觉。
|
||||
voice: Puck
|
||||
label: podcast_url
|
||||
```
|
||||
|
||||
配置完成后,Zenfeed 将在每次抓取到新文章时,自动执行上述流程。可以在通知模版中使用 podcast_url label,或 Web 中直接收听(Web 固定读取 podcast_url label,若使用别的名称则无法读取)
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -407,6 +408,9 @@ func (c *cached) String(ctx context.Context, messages []string) (string, error)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.Trim(value, " \n\r\t") == "" {
|
||||
return "", errors.New("empty response") // Gemini may occur this.
|
||||
}
|
||||
|
||||
// TODO: reduce copies.
|
||||
if err = c.kvStorage.Set(ctx, []byte(keyStr), []byte(value), 65*time.Minute); err != nil {
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
@@ -47,11 +46,18 @@ type Config struct {
|
||||
Endpoint string
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
Bucket string
|
||||
BucketURL string
|
||||
client *minio.Client
|
||||
|
||||
Bucket string
|
||||
BucketURL string
|
||||
bucketURL *url.URL
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if c.Empty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.Endpoint == "" {
|
||||
return errors.New("endpoint is required")
|
||||
}
|
||||
@@ -64,12 +70,26 @@ func (c *Config) Validate() error {
|
||||
if c.SecretAccessKey == "" {
|
||||
return errors.New("secret access key is required")
|
||||
}
|
||||
client, err := minio.New(c.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(c.AccessKeyID, c.SecretAccessKey, ""),
|
||||
Secure: true,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "new minio client")
|
||||
}
|
||||
c.client = client
|
||||
|
||||
if c.Bucket == "" {
|
||||
return errors.New("bucket is required")
|
||||
}
|
||||
if c.BucketURL == "" {
|
||||
return errors.New("bucket url is required")
|
||||
}
|
||||
u, err := url.Parse(c.BucketURL)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "parse public url")
|
||||
}
|
||||
c.bucketURL = u
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -86,6 +106,10 @@ func (c *Config) From(app *config.App) *Config {
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Config) Empty() bool {
|
||||
return c.Endpoint == "" && c.AccessKeyID == "" && c.SecretAccessKey == "" && c.Bucket == "" && c.BucketURL == ""
|
||||
}
|
||||
|
||||
type Dependencies struct{}
|
||||
|
||||
// --- Factory code block ---
|
||||
@@ -113,19 +137,6 @@ func new(instance string, app *config.App, dependencies Dependencies) (Storage,
|
||||
return nil, errors.Wrap(err, "validate config")
|
||||
}
|
||||
|
||||
client, err := minio.New(config.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(config.AccessKeyID, config.SecretAccessKey, ""),
|
||||
Secure: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "new minio client")
|
||||
}
|
||||
|
||||
u, err := url.Parse(config.BucketURL)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "parse public url")
|
||||
}
|
||||
|
||||
return &s3{
|
||||
Base: component.New(&component.BaseConfig[Config, Dependencies]{
|
||||
Name: "ObjectStorage",
|
||||
@@ -133,39 +144,40 @@ func new(instance string, app *config.App, dependencies Dependencies) (Storage,
|
||||
Config: config,
|
||||
Dependencies: dependencies,
|
||||
}),
|
||||
client: client,
|
||||
bucketURL: u,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Implementation code block ---
|
||||
type s3 struct {
|
||||
*component.Base[Config, Dependencies]
|
||||
|
||||
client *minio.Client
|
||||
bucketURL *url.URL
|
||||
}
|
||||
|
||||
func (s *s3) Put(ctx context.Context, key string, body io.Reader, contentType string) (publicURL string, err error) {
|
||||
ctx = telemetry.StartWith(ctx, append(s.TelemetryLabels(), telemetrymodel.KeyOperation, "Put")...)
|
||||
defer func() { telemetry.End(ctx, err) }()
|
||||
bucket := s.Config().Bucket
|
||||
config := s.Config()
|
||||
if config.Empty() {
|
||||
return "", errors.New("not configured")
|
||||
}
|
||||
|
||||
if _, err := s.client.PutObject(ctx, bucket, key, body, -1, minio.PutObjectOptions{
|
||||
if _, err := config.client.PutObject(ctx, config.Bucket, key, body, -1, minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
}); err != nil {
|
||||
return "", errors.Wrap(err, "put object")
|
||||
}
|
||||
|
||||
return s.bucketURL.JoinPath(key).String(), nil
|
||||
return config.bucketURL.JoinPath(key).String(), nil
|
||||
}
|
||||
|
||||
func (s *s3) Get(ctx context.Context, key string) (publicURL string, err error) {
|
||||
ctx = telemetry.StartWith(ctx, append(s.TelemetryLabels(), telemetrymodel.KeyOperation, "Get")...)
|
||||
defer func() { telemetry.End(ctx, err) }()
|
||||
bucket := s.Config().Bucket
|
||||
config := s.Config()
|
||||
if config.Empty() {
|
||||
return "", errors.New("not configured")
|
||||
}
|
||||
|
||||
if _, err := s.client.StatObject(ctx, bucket, key, minio.StatObjectOptions{}); err != nil {
|
||||
if _, err := config.client.StatObject(ctx, config.Bucket, key, minio.StatObjectOptions{}); err != nil {
|
||||
errResponse := minio.ToErrorResponse(err)
|
||||
if errResponse.Code == minio.NoSuchKey {
|
||||
return "", ErrNotFound
|
||||
@@ -174,7 +186,7 @@ func (s *s3) Get(ctx context.Context, key string) (publicURL string, err error)
|
||||
return "", errors.Wrap(err, "stat object")
|
||||
}
|
||||
|
||||
return s.bucketURL.JoinPath(key).String(), nil
|
||||
return config.bucketURL.JoinPath(key).String(), nil
|
||||
}
|
||||
|
||||
func (s *s3) Reload(app *config.App) (err error) {
|
||||
@@ -187,29 +199,7 @@ func (s *s3) Reload(app *config.App) (err error) {
|
||||
return errors.Wrap(err, "validate config")
|
||||
}
|
||||
|
||||
if reflect.DeepEqual(s.Config(), newConfig) {
|
||||
log.Debug(ctx, "object storage config not changed")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
client, err := minio.New(newConfig.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(newConfig.AccessKeyID, newConfig.SecretAccessKey, ""),
|
||||
Secure: true,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "new minio client")
|
||||
}
|
||||
|
||||
u, err := url.Parse(newConfig.BucketURL)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "parse public url")
|
||||
}
|
||||
|
||||
s.client = client
|
||||
s.bucketURL = u
|
||||
s.SetConfig(newConfig)
|
||||
|
||||
log.Info(ctx, "object storage reloaded")
|
||||
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user