JavaScript 实战:用 Node.js 写一个命令行工具
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
这一篇把前面几章拼起来:写一个能扫描 Markdown、检查外链是否可访问的命令行工具。要求是——参数清晰、失败有退出码、能装成全局命令、在 CI 里可用。
目标与验收
linkscan <dir> 的行为:
- 递归找出目录下所有
.md文件; - 提取
http:///https://链接,去重; - 并发请求(默认 5 个),记录状态码与耗时;
- 只输出失败项,
--json输出完整结果; - 有失败链接时退出码为
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。