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 并调用,自身无业务
④ 表现 PresentationCLI + 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:articleListreadingNotes:viewer)。表现层据此经 Registry 解析 module.components[routeId] 完成路由。删除冗余的路由类型联合(原 Screen 联合)。

模块清单(平级)

模块领域职责onRefresh
newsrefresh/topnews/bookmark/cleanup/source 管理
reading-notesnote-picker/quiz(GLM 问答)
未来tasks/finance/…按需

扩展:加新模块

  1. src/modules/<x>/(视图组件 + 领域逻辑 + 数据 schema + 可选 onRefresh)。
  2. index.tsregister(mod) + 填 components 映射 + 导出领域函数。
  3. 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==falsebookmarked==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)任意模块读 JSONpathunknown|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?) / writeTopNewsTopNews 视图 / --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)QuizModeGLM 问答

5.5 调度层 Scheduling(模块无关)

接口使用场景说明
runAllRefresh(opts?)systemd timer / bf refresh / TUI r枚举 registry 中带 onRefresh 的模块并调用;汇总 stats
bf refresh [--module <id>]:缺省=全部模块;可限定单模块。

5.6 表现层(CLI)

命令路由退出码
bf sourcesnews: registry.getAllSources()0
bf news <src> / bf news today_top_news [--date]news: readArticles / readTopNews0/3
bf article <url>news: getExtractor().extract(live)0/1/3
bf refresh [--module <id>]调度层 runAllRefresh(不再 news 专属)0/1/3
bf source list|enable|disablenews: registry0/2
退出码:0 成功 | 1 运行错 | 2 用法/未知 | 3 抓取为空。

6. 部署与测试治理

部署:定时调度

  • deploy/bf-refresh.{service,timer}OnBootSec=5min + OnUnitActiveSec=12h + Persistent=true
  • ExecStart=node dist/index.js refreshrunAllRefresh(模块无关)
  • After=network-online.target mihomo.service;日志 journalctl

测试分层

  • UT:vitest,mock 外部依赖
  • 黑盒 E2E:spawn 真进程;每对外能力 功能+性能+退出码
  • TUI 接线 UT:ink-testing-library
  • CItsc --noEmit + UT + build
宪法:每个对外能力背后必须是模块导出的共享函数 + 黑盒 E2E;改动不得让已通过 E2E 退化。

严格 4 层(Storage→Module→Scheduling/Presentation);news 为平级模块之一。配套:docs/design/architecture.md · api.md · plan.md