13 KiB
npm Top200 周榜缓存功能 — 实施方案
Context(背景与目标)
verdaccio-cache-manager 当前只能扫描私服 storage 已缓存的包,缺乏「应该缓存哪些热门包」的外部榜单来源。新功能引入 npm 周下载量 Top200 榜单作为缓存预演依据:
- 按既定文档方案抓取 npm 周下载量 Top200(22 组关键词 × Search API 粗排 350 → Downloads API 复核截 200)
- 榜单与本地 storage 扫描结果(
rawCache)比对,每条标记三态:待拉取 / 需更新 / 已就绪 - 前端展示榜单 + 状态徽章 + 一键拉取待缓存包(经私服代理落盘,与 sync-latest 同机制)
- 每周一 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 rankgetTopListCount()— 计数
不动 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。
实施顺序
- db.js:新增
top_list表 +replaceTopList/getTopListRows等导出函数 - sync-latest.js:导出
triggerVerdaccio供复用 - npm-toplist.js:新建核心模块(fetch + compare + pull + state)
- server.js:新增
/api/toplist*端点 +scheduleWeeklyTopList()+ 启动注册 - router + 顶/底栏菜单:注入路由和菜单项
- store + api:新增 state 字段和 actions
- TopListView.vue:新建主页面
- 端到端验证:按上述 10 步执行