项目目标
本项目把搜索视为核心价值:按标题、网址与标签加权排序,使“输入短语即可打开最佳结果”可靠。书签持久化为一个原子写入的 JSON 文件,打开命令把自由文本解析为最佳匹配,让常用操作只需一次按键。
核心特性
- 保存带标题、网址与多个标签的书签
- 跨标题、网址与标签的大小写不敏感搜索
- 按标签过滤并按最近新增排序
- 按 id 或搜索短语在默认浏览器中打开
架构与分层
命令解析映射 argv 到操作;存储把书签原子写入 JSON;搜索模块以纯函数实现打分与过滤;打开模块调用平台浏览器命令。匹配权重显式化,使标题匹配始终优于仅网址匹配。
实施阶段
六个阶段为:原子存储、书签新增、搜索与排序、浏览器打开、CLI 表面、测试与加固。标题匹配权重最高,URL 的参数以数组传递而非拼接进 shell。
测试与验收
用 vitest 运行。存储测试把书签往返于临时文件并断言原子写入后仍可解析;搜索测试断言标题匹配排序高于仅网址匹配、标签过滤与文本组合、无匹配返回空;打开测试用桩断言按平台选择命令。
延伸方向
从浏览器导出导入、按 URL 去重与标签重命名都围绕纯搜索与存储展开。全文索引可替换搜索函数而不触及持久化。
本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。
代码示例
export function search(bookmarks, query, { tag } = {}) {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
return bookmarks
.map((b) => {
const title = b.title.toLowerCase();
const url = b.url.toLowerCase();
const tags = b.tags.map((t) => t.toLowerCase());
let score = 0;
for (const term of terms) {
if (title.includes(term)) score += 3;
if (tags.some((t) => t.includes(term))) score += 2;
if (url.includes(term)) score += 1;
}
return { ...b, score };
})
.filter((b) => b.score > 0 && (!tag || b.tags.includes(tag)))
.sort((a, b) => b.score - a.score || b.createdAt.localeCompare(a.createdAt));
}
mkdir bookmarks && cd bookmarks
npm init -y
npm install -D vitest@^2.1.0
{
"name": "bookmarks",
"type": "module",
"scripts": {
"bm": "node src/cli.js",
"test": "vitest run"
}
}
import { readFile, writeFile, rename } from 'node:fs/promises';
export async function loadStore(path) {
try { return JSON.parse(await readFile(path, 'utf8')); }
catch (err) { if (err.code === 'ENOENT') return { bookmarks: [], nextId: 1 }; throw err; }
}
export async function saveStore(path, store) {
const tmp = path + '.tmp';
await writeFile(tmp, JSON.stringify(store, null, 2));
await rename(tmp, path);
}
export function addBookmark(store, { title, url, tags = [] }, now = new Date()) {
if (!/^https?:\/\//.test(url)) throw new Error('url must start with http:// or https://');
if (store.bookmarks.some((b) => b.url === url)) throw new Error('bookmark already exists');
const bookmark = {
id: store.nextId,
title: title.trim() || url,
url,
tags: [...new Set(tags)],
createdAt: now.toISOString(),
};
store.bookmarks.push(bookmark);
store.nextId += 1;
return bookmark;
}
export function search(bookmarks, query, { tag } = {}) {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
return bookmarks
.map((b) => {
const title = b.title.toLowerCase();
const url = b.url.toLowerCase();
const tags = b.tags.map((t) => t.toLowerCase());
let score = 0;
for (const term of terms) {
if (title.includes(term)) score += 3;
if (tags.some((t) => t.includes(term))) score += 2;
if (url.includes(term)) score += 1;
}
return { ...b, score };
})
.filter((b) => b.score > 0 && (!tag || b.tags.includes(tag)))
.sort((a, b) => b.score - a.score || b.createdAt.localeCompare(a.createdAt));
}
import { spawn } from 'node:child_process';
export function openUrl(url, { platform = process.platform } = {}) {
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: 'ignore' });
child.on('error', reject);
child.on('exit', (code) => (code === 0 ? resolve(true) : reject(new Error('open failed: ' + code))));
});
}
import { loadStore, saveStore } from './store.js';
import { addBookmark } from './add.js';
import { search } from './search.js';
import { openUrl } from './opener.js';
const path = process.env.BM_FILE ?? './bookmarks.json';
const [command, ...rest] = process.argv.slice(2);
const store = await loadStore(path);
if (command === 'add') {
const [url, ...tagArgs] = rest;
const bookmark = addBookmark(store, { url, tags: tagArgs.filter((t) => t.startsWith('#')).map((t) => t.slice(1)) });
await saveStore(path, store);
console.log('added ' + bookmark.id);
} else if (command === 'search') {
for (const bm of search(store.bookmarks, rest.join(' '))) console.log(bm.id + ' ' + bm.title);
} else if (command === 'open') {
const [best] = search(store.bookmarks, rest.join(' '));
if (!best) { console.error('no match'); process.exit(1); }
await openUrl(best.url);
} else {
console.error('usage: bm <add|list|search|open>');
process.exit(1);
}
import { search } from '../src/search.js';
const data = [
{ id: 1, title: 'Node.js docs', url: 'https://nodejs.org', tags: ['dev'], createdAt: '2026-01-02' },
{ id: 2, title: 'Recipes', url: 'https://nodejs.org/cook', tags: ['food'], createdAt: '2026-01-01' },
];
test('title match outranks url-only match', () => {
const hits = search(data, 'node');
expect(hits[0].id).toBe(1);
});
test('tag filter narrows results', () => {
expect(search(data, 'node', { tag: 'food' }).map((h) => h.id)).toEqual([2]);
});