bf_tool — 架构与 API 设计
严格分层(Storage → Module → Scheduling / Presentation)· 数据存储结构 · 时序图 · 接口定义。bf_tool 是可扩展个人助手,news 仅为其中一个模块。本页零外部依赖(离线可用)。
4 层严格分层
模块平级、可插拔
存储层仅基础读写
调度/前端 模块无关
1. 严格分层架构(Strict Layered Architecture)
依赖方向严格单向向下;上层不感知下层内部;存储层仅提供基础读写原语,不含任何领域或模块语义。
④ 表现层Presentation · 模块无关 · 经 Registry 路由
CLI · cli.ts
TUI · App + tui/*
↓ 依赖方向(上层 → 下层)
③ 调度层Scheduling · 模块无关 · 枚举 onRefresh
Scheduler · runAllRefresh()
systemd timer · bf refresh · TUI r
↓
② 模块层Module · 平级、可插拔 · 自管领域与数据 schema
news
refresh · topnews · bookmark · cleanup
reading-notes
note-picker · quiz
未来模块 …
tasks · finance
↓
① 存储层Storage · 基础读写原语 · content-agnostic
read · write · exists · delete · list(按路径,零领域语义)
横切公共基础设施(所有模块共享):Fetcher 工厂 · Config · Paths · ModuleRegistry
四层职责(自底向上)
| 层 | 职责 | 模块无关? |
| ① 存储 Storage | 仅基础读写原语。零领域语义、零模块知识 | 是 |
| ② 模块 Module | 每模块自包含领域逻辑、视图组件、数据 schema、可选 onRefresh。news 与 reading-notes 平级 | 否 |
| ③ 调度 Scheduling | 后台任务编排:枚举模块 onRefresh 并调用,自身无业务 | 是 |
| ④ 表现 Presentation | CLI + TUI;经 ModuleRegistry 发现/路由,通用外壳 | 是 |
分层原则
- 依赖方向单向向下;存储层不反向依赖上层。
- 存储层去领域化:领域 schema 归属各自模块(见 §3),存储只提供通用读写。
- news 非中心:其 refresh/topnews/bookmark/cleanup 是 news 模块内部逻辑。
- 调度/表现 模块无关:经 ModuleRegistry 枚举/路由,加模块不改它们。
- 工厂 + Fail-Fast:未知类型即抛错,禁止静默降级。
- 共享函数 + 单点 E2E:每个对外能力 = 模块导出函数;CLI/TUI 调同一函数。
2. 模块层(Module Layer)
Module 接口(interface)重构命名
interface Module {
id: string;
name: string;
icon: string;
description: string;
entryRoute: RouteId; // 模块入口路由
routes: Route[]; // 模块声明的全部路由
components: Record<RouteId, React.FC>; // 路由 → 组件 映射
onRefresh?: () => Promise<ModuleRefreshStats>; // 可选定时任务
init?: () => void | Promise<void>;
}
interface Route { id: RouteId; label: string; }
type RouteId = string; // 规范:moduleId:routeName
路由标识规范(RouteId)
统一
moduleId:routeName 格式(如
news:articleList、
readingNotes:viewer)。表现层据此经 Registry 解析
module.components[routeId] 完成路由。
删除冗余的路由类型联合(原
Screen 联合)。
模块清单(平级)
| 模块 | 领域职责 | onRefresh |
| news | refresh/topnews/bookmark/cleanup/source 管理 | 是 |
| reading-notes | note-picker/quiz(GLM 问答) | 否 |
| 未来 | tasks/finance/… | 按需 |
扩展:加新模块
- 建
src/modules/<x>/(视图组件 + 领域逻辑 + 数据 schema + 可选 onRefresh)。
index.ts:register(mod) + 填 components 映射 + 导出领域函数。
src/index.tsx 加一行 side-effect import。
菜单 / 路由 / 调度自动生效,无需改表现层或调度层。
3. 数据存储结构(Data Storage Schema)
每模块在 data/modules/<module>/ 下自管数据;存储层提供通用读写,路径与 schema 由各模块自行定义。下表为 news 模块 schema(reading-notes 等同理自管)。
3.1 物理布局
data/
├── config.json # 全局:jina_api_key / glm_api_key / proxy_url / notes_dirs
├── modules/
│ ├── news/ # news 模块命名空间
│ │ ├── sources/ sources.json # 源配置与偏好(disabled_sources)
│ │ └── cache/
│ │ ├── <source>/ # 每源一个目录(如 ainews_com/)
│ │ │ ├── articles.json # 文章元数据列表
│ │ │ └── content/ # 正文文件
│ │ │ └── <title>__<url-hash>.json
│ │ ├── today_top_news/
│ │ │ └── <YYYY-MM-DD>.json # 当日 Top5
│ │ └── bookmarks/
│ │ └── articles.json # 收藏引用列表
│ └── reading-notes/ # reading-notes 模块命名空间(自管)
│ └── …
└── (saved/ 旧散文式文件作废删除)
3.2 各文件 Schema(全字段)
| 文件 | 类型定义 | 字段语义 |
<source>/articles.json | { updated_at: ISO; articles: ArticleMeta[] }
ArticleMeta = { title; url; summary?; author?; publishedDate?; fetched_at: ISO } | url=去重主键;fetched_at=首次入缓存时间(不可变),保留策略判定依据;updated_at=文件最后合并时间 |
<source>/content/<title>__<hash>.json | { url; title; fetched_at: ISO; content: string(markdown); word_count: int; char_count: int; bookmarked: boolean } | 正文(统一 markdown);文件名=净化标题__url短哈希(哈希用于按 url 检索);bookmarked 决定清理豁免 |
today_top_news/<date>.json | { date: YYYY-MM-DD; fetched_at: ISO; articles: TopNewsItem[] }
TopNewsItem = { title; url; source; sourceName; score: number; reason: string } | 跨源聚合 + GLM 打分当日 Top5;source=源 key(解析归属),sourceName=显示名 |
bookmarks/articles.json | { updated_at: ISO; articles: BookmarkItem[] }
BookmarkItem = { title; url; source; sourceName; saved_at: ISO } | 收藏引用列表;URL 去重;持久保留,清理策略永不触碰 |
3.3 接口 ↔ 存储路径映射
| 接口(news 模块) | 存储路径 | 操作 |
readArticles(source) | modules/news/cache/<source>/articles.json | 读 ArticleMeta[] |
upsertArticles(source, batch) | 同上 | 按 url 去重增量 upsert |
getContent(source, url) | .../<source>/content/<title>__<hash>.json | 读正文对象 |
setContent(source, url, body) | 同上 | 写正文(bookmarked 缺省 false,已存在则保留) |
readTopNews(date?) / writeTopNews(date, items) | .../today_top_news/<date>.json | 读/写当日 Top5 |
bookmarkArticle / removeBookmark / listBookmarks | .../bookmarks/articles.json + 对应 content 文件 | 双写:引用列表 + content 的 bookmarked 标记 |
3.4 保留策略(Retention)
清理规则(cleanup() 在 onRefresh 末尾执行):① articles.json 删除 fetched_at > 7 天 的条目;② content/ 删除 fetched_at > 7 天 且 bookmarked==false(bookmarked==true 永久保留);③ today_top_news/<date> 删除日期 > 7 天的文件;④ bookmarks/ 永不触碰。无数量上限(7 天自然封顶:~5篇/天×7天)。
4. 时序图(Sequence Diagrams)— 工作原理
4.1 启动 · 零网络
4.2 读时 · 纯缓存(Cache-Only Read)
4.3 后台刷新 · 调度层 → 各模块 onRefresh(模块无关)
4.4 收藏 · 双写一致性(news 模块内)
4.5 清理 · 保留策略(news 模块内)
5. 接口参考(按层 · 签名/使用场景/参数)
5.1 存储层 Storage(content-agnostic)
| 接口 | 使用场景 | 参数/返回 |
Storage.read(path) | 任意模块读 JSON | path → unknown|null |
Storage.write(path, data) | 任意模块写(自动 mkdir) | path,data → void |
Storage.exists/delete/list(dir) | 存在性/删除/列目录 | 路径原语 |
约束:Storage 不接受领域参数(无 source/url/date);路径由调用模块构造。
5.2 横切基础设施(所有模块共享)
| 接口 | 使用场景 | 说明 |
getFetcher(type, options?) | 任何模块抓网页 | http/zai/jina,未知抛错;jina 走代理;src/shared/fetch |
Fetcher.fetch(url) | 取整页内容 | → Promise<string|null> |
getExtractor(fetcherType)改 | 正文抽取(按类型分派) | jina→reader(代理)/http→defuddle/zai→reader;未知抛错 |
getConfig() / getModuleRegistry() | 配置 / 模块发现 | 跨模块共享 |
已移除:fetchBody(正文改由 Extractor 工厂按类型分派)、getContentType(production 零调用)。
5.3 模块层 — news 接口(领域,仅 news)
| 接口 | 使用场景 | 参数/行为 |
news.onRefresh()新 | 调度层调用 | 取5篇→upsert→正文→打分→写Top5→cleanup;失败隔离 |
readArticles(source) | ArticleList / bf news | 纯读 → ArticleMeta[]|null |
upsertArticles(source, batch)改名 | onRefresh 内部 | 按 url 去重增量 upsert(新篇 fetched_at=now) |
getContent(source,url)改名 | ArticleDetail | → {content,word_count,char_count,bookmarked}|null |
setContent(source,url,body)改名 | onRefresh / miss 定向写 | 写正文(bookmarked 缺省 false,已存在则保留) |
readTopNews(date?) / writeTopNews | TopNews 视图 / --date / onRefresh | 当日 Top5 |
bookmarkArticle(article)新 | ArticleDetail 收藏键 | 追加 bookmarks.json(url去重) + 置对应 content 的 bookmarked=true |
removeBookmark(url)新 | 收藏视图取消 | 逆向双写回滚(bookmarked=false) |
listBookmarks()新 | 收藏视图渲染 | → BookmarkItem[] |
cleanup()新 | onRefresh 末尾 | 7 天保留 + bookmarked 豁免(见 §3.4) |
注:setBookmarked 不作为公开接口暴露——它是 bookmarkArticle/removeBookmark 的内部实现(操作 content 文件的 bookmarked 字段)。
5.4 模块层 — reading-notes 接口(领域,仅 reading-notes)
| 接口 | 使用场景 | 说明 |
pickRandomNote(dirs) | ReadingNotes 视图 | 从 notes_dirs 随机选笔记 |
generateQuiz(notePath) | QuizMode | GLM 问答 |
5.5 调度层 Scheduling(模块无关)
| 接口 | 使用场景 | 说明 |
runAllRefresh(opts?)新 | systemd timer / bf refresh / TUI r | 枚举 registry 中带 onRefresh 的模块并调用;汇总 stats |
bf refresh [--module <id>]:缺省=全部模块;可限定单模块。
5.6 表现层(CLI)
| 命令 | 路由 | 退出码 |
bf sources | news: registry.getAllSources() | 0 |
bf news <src> / bf news today_top_news [--date] | news: readArticles / readTopNews | 0/3 |
bf article <url> | news: getExtractor().extract(live) | 0/1/3 |
bf refresh [--module <id>]改 | 调度层 runAllRefresh(不再 news 专属) | 0/1/3 |
bf source list|enable|disable | news: registry | 0/2 |
退出码:0 成功 | 1 运行错 | 2 用法/未知 | 3 抓取为空。
6. 部署与测试治理
部署:定时调度
deploy/bf-refresh.{service,timer}:OnBootSec=5min + OnUnitActiveSec=12h + Persistent=true
ExecStart=node dist/index.js refresh → runAllRefresh(模块无关)
After=network-online.target mihomo.service;日志 journalctl
测试分层
- UT:vitest,mock 外部依赖
- 黑盒 E2E:spawn 真进程;每对外能力 功能+性能+退出码
- TUI 接线 UT:ink-testing-library
- CI:
tsc --noEmit + UT + build
宪法:每个对外能力背后必须是模块导出的共享函数 + 黑盒 E2E;改动不得让已通过 E2E 退化。
严格 4 层(Storage→Module→Scheduling/Presentation);news 为平级模块之一。配套:docs/design/architecture.md · api.md · plan.md