← 中文资源

简体中文版本

构建带标签的书签管理命令行工具

保存带标签的网址、跨标题/网址/标签搜索、按标签过滤并按匹配度打开最佳结果。

阅读英文原文

项目目标

本项目把搜索视为核心价值:按标题、网址与标签加权排序,使“输入短语即可打开最佳结果”可靠。书签持久化为一个原子写入的 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]);
});