JavaScript 实战:用 Node.js 写一个命令行工具

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

Node.js 22 LTS

这一篇把前面几章拼起来:写一个能扫描 Markdown、检查外链是否可访问的命令行工具。要求是——参数清晰、失败有退出码、能装成全局命令、在 CI 里可用。

目标与验收

linkscan <dir> 的行为:

  1. 递归找出目录下所有 .md 文件;
  2. 提取 http:// / https:// 链接,去重;
  3. 并发请求(默认 5 个),记录状态码与耗时;
  4. 只输出失败项,--json 输出完整结果;
  5. 有失败链接时退出码为 1,参数错误为 2。

初始结构

linkscan/
  package.json
  bin/linkscan.js
  src/scan.js
  src/links.js
{
  "name": "linkscan",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "bin": { "linkscan": "./bin/linkscan.js" },
  "scripts": {
    "start": "node bin/linkscan.js"
  },
  "engines": { "node": ">=22" }
}

bin 字段让 npm link 或 npm i -g 之后可以直接输入 linkscan。入口文件第一行必须是 shebang:

#!/usr/bin/env node

解析参数

Node 内置 node:util 的 parseArgs,不用装依赖:

// bin/linkscan.js
#!/usr/bin/env node
import { parseArgs } from "node:util";
import { scan } from "../src/scan.js";

const USAGE = `用法:linkscan <dir> [选项]

选项:
  --concurrency <n>  并发数,默认 5
  --json             输出 JSON
  --timeout <ms>     单链接超时,默认 8000
  -h, --help         显示帮助`;

function parseCli(argv) {
  const { values, positionals } = parseArgs({
    args: argv,
    allowPositionals: true,
    options: {
      concurrency: { type: "string", short: "c", default: "5" },
      timeout: { type: "string", default: "8000" },
      json: { type: "boolean", default: false },
      help: { type: "boolean", short: "h", default: false },
    },
  });

  const concurrency = Number(values.concurrency);
  const timeout = Number(values.timeout);

  if (!Number.isInteger(concurrency) || concurrency < 1) {
    throw new UsageError("--concurrency 必须是正整数");
  }
  if (!Number.isFinite(timeout) || timeout < 1) {
    throw new UsageError("--timeout 必须是正数(毫秒)");
  }
  if (positionals.length !== 1) {
    throw new UsageError("需要一个目录参数");
  }

  return { dir: positionals[0], concurrency, timeout, json: values.json, help: values.help };
}

自定义一个错误类型,好在顶层区分「用法错误」和「运行失败」:

class UsageError extends Error {}

提取链接

// src/links.js
const URL_PATTERN = /https?:\/\/[^\s<>"'`)\]]+/g;

export function extractLinks(markdown) {
  const withoutCode = markdown.replace(/```[\s\S]*?```/g, "");
  const matches = withoutCode.match(URL_PATTERN) ?? [];
  return matches.map((url) => url.replace(/[.,;:!?]+$/, ""));
}

先剔除代码块,避免把示例里的假链接当真;再去掉句末标点。正则掏 HTML 不严谨,但对付 Markdown 足够,真正的解析器可以后续换 remark。

遍历文件

// src/scan.js
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { extractLinks } from "./links.js";

async function* walk(dir) {
  const entries = await readdir(dir, { withFileTypes: true });
  for (const entry of entries) {
    if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) yield* walk(full);
    else if (entry.isFile() && entry.name.endsWith(".md")) yield full;
  }
}

withFileTypes 省掉一次 stat;跳过隐藏目录和 node_modules 是必要的默认值。

并发探测

async function checkLink(url, timeout) {
  const started = performance.now();
  try {
    const res = await fetch(url, {
      method: "GET",
      redirect: "follow",
      signal: AbortSignal.timeout(timeout),
      headers: { "user-agent": "linkscan/0.1 (+https://example.com)" },
    });
    return { url, status: res.status, ok: res.ok, ms: Math.round(performance.now() - started) };
  } catch (err) {
    const status = err.name === "TimeoutError" ? "timeout" : "error";
    return { url, status, ok: false, ms: Math.round(performance.now() - started), message: err.message };
  }
}

async function mapLimit(items, limit, worker) {
  const results = new Array(items.length);
  let cursor = 0;
  async function run() {
    while (cursor < items.length) {
      const index = cursor++;
      results[index] = await worker(items[index]);
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
  return results;
}

有些站点拒绝 HEAD,所以统一用 GET;如果只关心可达性,HEAD 更省流量,可以加参数切换。

组装主流程

export async function scan({ dir, concurrency, timeout }) {
  const files = [];
  for await (const file of walk(dir)) files.push(file);

  const byUrl = new Map(); // url -> 出现过的文件
  for (const file of files) {
    const content = await readFile(file, "utf8");
    for (const url of extractLinks(content)) {
      const list = byUrl.get(url) ?? [];
      list.push(path.relative(process.cwd(), file));
      byUrl.set(url, list);
    }
  }

  const urls = [...byUrl.keys()];
  const checked = await mapLimit(urls, concurrency, (url) => checkLink(url, timeout));

  return {
    files: files.length,
    links: urls.length,
    results: checked.map((item) => ({ ...item, files: byUrl.get(item.url) })),
  };
}

输出与退出码

function report(summary, asJson) {
  if (asJson) {
    process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
    return;
  }

  const failed = summary.results.filter((r) => !r.ok);
  for (const item of failed) {
    const reason = item.status === "timeout" ? "超时" : (item.status === "error" ? item.message : `HTTP ${item.status}`);
    console.error(`✗ [${reason}] ${item.url}`);
    for (const file of item.files) console.error(`    ${file}`);
  }
  console.log(`\n扫描 ${summary.files} 个文件、${summary.links} 个链接,失败 ${failed.length} 个`);
}

async function main() {
  let options;
  try {
    options = parseCli(process.argv.slice(2));
  } catch (err) {
    console.error(err.message);
    console.error(USAGE);
    process.exitCode = 2;
    return;
  }

  if (options.help) {
    console.log(USAGE);
    return;
  }

  const summary = await scan(options);
  report(summary, options.json);
  process.exitCode = summary.results.some((r) => !r.ok) ? 1 : 0;
}

main().catch((err) => {
  console.error("运行失败:", err);
  process.exitCode = 1;
});

要点:

  • 用 process.exitCode 而不是 process.exit(),让 stdout 有机会冲刷完。
  • 人读的信息走 stdout,失败细节走 stderr,--json 时保持 stdout 只有 JSON——CI 才能直接解析。
  • 参数错误用 2,与「检查出问题」的 1 区分开。

安装与调用

npm link          # 本地调试,直接敲 linkscan
node bin/linkscan.js ./docs --json

发布到 npm 后 npm i -g linkscan 即可全局使用;团队内也可以只写进 package.json 的 scripts 里用 npm run check:links。

CI 里跑:

linkscan ./src/content --concurrency 8 || echo "存在失效链接"

常见错误与注意事项

1. 忘了 shebang 或没加可执行权限

npm link 后提示 Permission denied。shebang 必须在第一行,且文件不能有 BOM。

2. 相对路径解析错位

用 path.join 而不是字符串拼接;process.cwd() 是执行命令时的目录,不是你期望的项目根。需要固定根目录就用 import.meta.dirname 推算。

3. 并发太高被限流

默认 5 是保守值。被目标站点返回 429 时,读 retry-after 退避,别继续加压。

4. 把超时当成链接失效

TimeoutError 和 404 是两回事,输出里要区分,否则用户分不清是站点慢还是页面没了。

5. 读完文件不关

readFile 会自动关闭;用 createReadStream 时记得处理 error 和 close。

6. 用 console.log 打 JSON

前面混了别的输出,管道给 jq 就解析失败。结构化输出只走一条通道。

7. 没有 --help

CLI 的第一份文档就是它自己的帮助文本,别省。

小结

  • parseArgs + 自定义 UsageError 把参数问题挡在入口。
  • 递归遍历用 withFileTypes,跳过隐藏目录与 node_modules。
  • 并发用 worker 池限流,超时用 AbortSignal.timeout。
  • 退出码约定:0 成功、1 检查失败、2 用法错误。
  • 人类可读信息与机器可解析的 JSON 分流到 stderr/stdout。

下一步