Files
verdaccio-cache-manager/.trae/documents/npm-top200-cache-plan.md
T
yinjun.chen1 6371c83a2a
CI / Backend tests (push) Canceled after 0s
CI / Frontend build (push) Canceled after 0s
fix(schedule): 每日自动扫描改为 03:00,避开周一 0 点 Top200 抓榜
存档操作日志改造、Top200 榜单、消息通知三个功能实施规划方案
2026-09-22 10:35:46 +08:00

13 KiB
Raw Blame History

npm Top200 周榜缓存功能 — 实施方案

Context(背景与目标)

verdaccio-cache-manager 当前只能扫描私服 storage 已缓存的包,缺乏「应该缓存哪些热门包」的外部榜单来源。新功能引入 npm 周下载量 Top200 榜单作为缓存预演依据:

  1. 按既定文档方案抓取 npm 周下载量 Top200(22 组关键词 × Search API 粗排 350 → Downloads API 复核截 200)
  2. 榜单与本地 storage 扫描结果(rawCache)比对,每条标记三态:待拉取 / 需更新 / 已就绪
  3. 前端展示榜单 + 状态徽章 + 一键拉取待缓存包(经私服代理落盘,与 sync-latest 同机制)
  4. 每周一 00:00(本地时区)自动触发抓榜任务;提供手动触发按钮以便调试和按需刷新

不引入新依赖:网络请求复用 Node 内置 fetch;落盘机制复用 sync-latest 的 triggerVerdaccio;状态机对称 syncState;前端复用 authFetch + 现有 reactive store。

数据库改动 — backend/db.js

新增 top_list 表(在现有 settings/ignored_packages/logs 之后追加 db.exec):

CREATE TABLE IF NOT EXISTS top_list (
  id               INTEGER PRIMARY KEY AUTOINCREMENT,
  rank             INTEGER NOT NULL,          -- 1~200
  pkg_name         TEXT NOT NULL UNIQUE,      -- 含 @scope
  latest_version   TEXT NOT NULL,             -- Search API 返回的最新版本
  weekly_downloads INTEGER NOT NULL DEFAULT 0, -- Downloads API 复核值
  search_weekly    INTEGER DEFAULT 0,         -- Search 快照值(参考)
  keyword          TEXT,                      -- 命中的关键词
  fetched_at       DATETIME                   -- 入榜时间(北京时间,复用 nowIso())
);
CREATE INDEX IF NOT EXISTS idx_top_list_rank ON top_list(rank);

settings 表新增两个键:toplist_last_run(最近一次抓榜完成时间)、toplist_next_run(下次定时执行时间,前端展示用)。

新增导出函数:

  • replaceTopList(rows) — 事务内 DELETE FROM top_list + 批量 INSERT(每轮抓榜完整替换)
  • getTopListRows() — SELECT * FROM top_list ORDER BY rank
  • getTopListCount() — 计数

不动 addLog,复用现有签名,新增 action 值:toplist_fetch / toplist_pull。

核心模块 — backend/npm-toplist.js(新建)

仿照 sync-latest.js 结构:

export const toplistState = {
  running: false, done: false,
  phase: 'idle',           // 'idle' | 'search' | 'recheck' | 'compare' | 'done'
  total: 0, processed: 0,
  success: 0, skipped: 0, failed: 0,
  failedList: [],          // [{name, reason}]
  current: null,           // 当前处理的关键词或包名
  error: null,
  startedAt: null, finishedAt: null,
  // 拉取子状态(与 sync-latest 对称)
  pullRunning: false, pullTotal: 0, pullProcessed: 0,
  pullSuccess: 0, pullSkipped: 0, pullFailed: 0, pullFailedList: [],
  pullCurrent: null, pullStartedAt: null, pullFinishedAt: null,
}

常量

const KEYWORDS = ['javascript','node','react','vue','css','util','web','frontend',
  'angular','typescript','api','test','tool','cli','server','build','http',
  'data','component','style','message','theme']
const SEARCH_API = 'https://registry.npmjs.org/-/v1/search'
const DOWNLOADS_API = 'https://api.npmjs.org/downloads/point/last-week'
const SEARCH_SIZE = 250
const COARSE_TOP = 350
const FINAL_TOP = 200
const REQ_INTERVAL_MS = 1500      // 请求间隔:调宽至 1.5s,避开 npm 匿名隐性限流(用户允许慢)
const RECHECK_CONCURRENCY = 4     // @ 包逐个复核并发:降为 4,避免并发瞬时挤爆
const RECHECK_BATCH = 70          // 非 @ 包批量复核每批大小(保持不变,一次请求复核 70 个,天然省请求)
const PULL_CONCURRENT = 3         // 拉取并发(与 sync-latest 一致)
const RATE_LIMIT_BACKOFF_MAX = 8  // 429 退避最大重试指数(2^n 秒,最高 256s)

限流说明:npm Search/Downloads 是公共匿名接口,无 token 标识,有隐性速率限制(超了返回 429 或临时封 IP)。用户明确"更新榜单不急、接口慢慢调",故:

  • Search 阶段 22 次 × 1.5s ≈ 33s(可接受)
  • Recheck 阶段靠 70 个/批批量复核省请求 + 4 并发控制峰值
  • 任何一步被 429 时按 2^n 秒退避重试(最多 8 次),如仍失败则计入 failedList 不中断 该容忍上限为"单个请求隔 1 分钟",实际按上面更快的间隔跑,若实测仍触发限流再继续放宽。

关键函数

函数 签名 职责
runTopListFetch({addLog, getRawPackages, invalidateScan, setSetting}) async 主流程:search → recheck → merge → compare → persist
runTopListPull({addLog, invalidateScan}) async 拉取所有 cached=false 或 latestCached=false 的包
getTopList() 同步 返回 DB 当前快照 + 实时比对结果(不写 DB)

Search 阶段:

  • 22 个关键词顺序调用 ${SEARCH_API}?text=${kw}&size=${SEARCH_SIZE}
  • 每个响应解析 objects[].package 取 {name, version},objects[].downloads.weekly 取快照值
  • 间隔 ≥400ms,单关键词失败计入 failedList 不中断

Recheck 阶段:

  • 粗排 350 候选 → 拆 @ 包和非 @ 包
  • 非 @ 包 70 个/批:GET ${DOWNLOADS_API}/${names.join(',')} 返回 {[name]: {downloads: N}}
  • @ 包逐个调用,16 并发 worker pool
  • 用 recheck 值覆盖 search 快照值,按降序截取前 200

Compare 阶段(参考 scanner.js 的 versionsDetail 字段):

function compareWithCache(topList, rawPackages) {
  const pkgMap = new Map(rawPackages.map(p => [p.name, p]))
  return topList.map(entry => {
    const pkg = pkgMap.get(entry.name)
    const vs = pkg && Array.isArray(pkg.versionsDetail) ? pkg.versionsDetail : []
    const versionSet = new Set(vs.map(v => v.version))
    return {
      ...entry,
      cached: versionSet.size > 0,
      latestCached: versionSet.has(entry.latestVersion),
      cachedVersions: [...versionSet],
    }
  })
}

三态:cached=false(待拉取) / cached=true && latestCached=false(需更新) / cached=true && latestCached=true(已就绪)

Pull 阶段(复用 sync-latest.js#triggerVerdaccio 的实现思路):

  • 由于 triggerVerdaccio 当前未导出,直接 import 复用:在 sync-latest.js 中添加 export { triggerVerdaccio },npm-toplist.js 中 import { triggerVerdaccio } from './sync-latest.js'
  • 拉取范围:cached=false 的包(待拉取)+ cached=true && latestCached=false 的包(需更新)
  • 并发 3,失败计入 pullFailedList 不中断
  • 完成后调用 invalidateScan() 让下一次请求触发重扫,新版本可见

API 端点 — backend/server.js

在 /api/sync/progress 之后追加:

方法 路径 响应
GET /api/toplist {list: [...], summary: {total, cached, latestCached, missing, outdated}, state: toplistState, lastRun, nextRun}
GET /api/toplist?filter=missing 仅返回 cached=false 的子集
POST /api/toplist/fetch {ok, running, startedAt} — 触发抓榜
GET /api/toplist/progress toplistState 全字段(轮询用)
POST /api/toplist/pull {ok, running, total, startedAt} — 触发拉取
GET /api/toplist/pull/progress {running, done, total, processed, success, skipped, failed, failedList, current, error, startedAt, finishedAt}

GET /api/toplist 内部实时调用 compareWithCache(topList, rawCache || []),前端拿到的 cached/latestCached 永远反映私服实况,不依赖落库字段。

定时调度 — backend/server.js

新增 scheduleWeeklyTopList(),与现有 scheduleDailyScan 模式对称:

function scheduleWeeklyTopList() {
  const now = new Date()
  const next = new Date(now)
  next.setHours(0, 0, 0, 0)
  const dow = next.getDay()           // 0=Sun, 1=Mon
  let daysUntilMonday = (1 - dow + 7) % 7
  if (daysUntilMonday === 0 && now.getTime() > next.getTime()) daysUntilMonday = 7
  next.setDate(next.getDate() + daysUntilMonday)
  setSetting('toplist_next_run', nowIso(next))
  setTimeout(async () => {
    try { await runTopListFetch({ addLog, getRawPackages: getRawPackages, invalidateScan, setSetting }) }
    catch (e) { console.error('[toplist] 定时抓榜失败:', e.message) }
    scheduleWeeklyTopList()
  }, next.getTime() - now.getTime())
}

在 app.listen 回调里 scheduleDailyScan() 之后并列调用一次。

前端改动

路由 — frontend/src/router/index.js

import TopListView from '../views/TopListView.vue'
// routes 数组新增:
{ path: '/toplist', name: 'toplist', component: TopListView },

顶栏与底栏菜单

AppTopbar.vue 的 navs 数组追加 { path: '/toplist', label: 'Top榜单', emo: '🏆' },MobileTabbar.vue 的 tabs 同步追加。

Store — frontend/src/store/index.js

state 新增字段:

toplist: [],
toplistProgress: { running: false, phase: 'idle', total: 0, processed: 0, ... },
toplistPullProgress: { running: false, total: 0, processed: 0, ... },
toplistNextRun: null,
toplistLastRun: null,

新增 actions:refreshTopList(filter)、fetchTopList()、pullTopList()、pollTopListProgress()、pollTopListPullProgress(),仿照 syncLatest + pollSyncProgress 模式实现。

API — frontend/src/api/index.js

新增方法:getTopList(filter)、fetchTopList()、getTopListProgress()、pullTopList()、getTopListPullProgress(),均通过 authFetch 调用对应端点。

主页面 — frontend/src/views/TopListView.vue(新建)

参考 IgnoredView.vue 的表格 + SearchView.vue 的筛选条:

  • 顶部摘要条:共 200 · 已缓存 X · 待拉取 Y · 需更新 Z · 下次抓榜 2026-09-28 00:00
  • 操作区:立即抓榜按钮 / 一键拉取按钮 / 筛选 tabs(全部 · 待拉取 · 需更新 · 已就绪)
  • 进度条:抓榜期间显示 phase(搜索中 → 重校验 → 对比中)+ processed/total;拉取期间显示 success/skipped/failed
  • 表格列:排名 / 包名 / 最新版本 / 周下载(千分位) / 状态徽章 / 已缓存版本 / 操作
  • 状态徽章:复用 StatusTag.vue 风格,三色(红/黄/绿)
  • 空态:DB 无数据时提示「尚无 Top200 数据,点击立即抓榜获取」
  • 轮询:触发后 800ms 拉一次 progress,任务结束后刷新 list

验证步骤

# 1. 启动后端
cd e:\gitea\verdaccio-cache-manager\backend && node server.js

# 2. 启动前端
cd e:\gitea\verdaccio-cache-manager\frontend && npm run dev

# 3. 触发抓榜(登录后)
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/toplist/fetch

# 4. 轮询进度
curl -H "Authorization: Bearer <token>" http://localhost:3000/api/toplist/progress

# 5. 验证 DB
sqlite3 backend/data/manager.db "SELECT COUNT(*) FROM top_list;"  # 应 = 200
sqlite3 backend/data/manager.db "SELECT rank, pkg_name, latest_version, weekly_downloads FROM top_list ORDER BY rank LIMIT 10;"

# 6. 浏览器打开 http://localhost:5173/toplist
#    - 表格 200 行,状态徽章符合实际 storage 状态
#    - 切换筛选 tabs 数量准确

# 7. 触发拉取
curl -X POST -H "Authorization: Bearer <token>" http://localhost:3000/api/toplist/pull

# 8. 拉取完成后查看 storage
ls <VERDACCIO_STORAGE>/<pkg-name>/  # 应有 latest 版本 .tgz

# 9. 刷新页面,对应行变为已就绪
curl -H "Authorization: Bearer <token>" "http://localhost:3000/api/toplist?filter=missing"
# 返回 list 长度应减小

# 10. 验证日志
sqlite3 backend/data/manager.db "SELECT action, deleted_versions, created_at FROM logs WHERE action LIKE 'toplist_%' ORDER BY id DESC LIMIT 5;"

可选验证:把 scheduleWeeklyTopList 内 next 改为 5 秒后,验证自调度 + 下一次正确计算到下周一 00:00。

实施顺序

  1. db.js:新增 top_list 表 + replaceTopList / getTopListRows 等导出函数
  2. sync-latest.js:导出 triggerVerdaccio 供复用
  3. npm-toplist.js:新建核心模块(fetch + compare + pull + state)
  4. server.js:新增 /api/toplist* 端点 + scheduleWeeklyTopList() + 启动注册
  5. router + 顶/底栏菜单:注入路由和菜单项
  6. store + api:新增 state 字段和 actions
  7. TopListView.vue:新建主页面
  8. 端到端验证:按上述 10 步执行