Node.js 安装教程:跑通第一个整理脚本
不装依赖,用一个文件把下载文件夹按图片、文档、视频和压缩包自动归类。先预览再移动,同名文件不覆盖,零基础也能跟下来。
本页目录
30 秒速查
- 装 Node LTS,重开终端,
node -v能看到版本号- 建
tidy-box/待整理,放几个测试文件- 预览:
node tidy.mjs 待整理- 确认后:
node tidy.mjs 待整理 --move
这篇教程解决什么问题
第一次跑脚本,卡点通常不是代码,是「Node 是什么、命令在哪输、文件放哪」。这篇只做一件事:把「待整理」里的文件按类型分到子文件夹。
你会得到什么
- 一个能跑 Node 的终端环境,知道命令在哪输、报错去哪看。
- 一个先预览、再移动的
tidy.mjs;同名文件自动加-1,不覆盖。 - 一张分类表:想加「电子书」「安装包」,改一行就行。
它不删除文件、不整理子文件夹、不上传任何文件,也不替代系统的文件管理器。
第一步:装 Node
打开 Node.js 官网,下载 LTS 版本:
- Windows:下载
.msi,双击,按默认选项装完。 - macOS:下载
.pkg,双击,按默认选项装完。
装完关掉终端再重新打开。终端只在启动时读一次 PATH(PATH 是电脑找程序时翻的那张名单),不重开就找不到新命令。
然后输入:
node -v
看到 v 开头的版本号就对了(写这篇时是 v24.22.1;Node 22 及以上都能跑这个脚本)。再输入 npm -v 能看到版本号即可;这篇用不到 npm,后面会用到。站内 Node.js 工具页 记了它适合做什么。

如果提示下面这句:
bash: node: command not found
说明终端还没找到 Node。先重开终端;还是不行,检查安装是否真的完成,以及安装时有没有勾选「Add to PATH」(就是让安装程序把 Node 写进那张名单);没有就重新运行安装器补上。
Windows 的 cmd 会提示(PowerShell 的说法不同,意思一样):
'node' 不是内部或外部命令,也不是可运行的程序或批处理文件。
第二步:建一个练习文件夹
不要直接对「下载」或桌面跑。先建一个专用文件夹,里面放几个无关紧要的文件。
macOS / Linux:
mkdir -p ~/tidy-box/待整理
cd ~/tidy-box
touch 待整理/a.jpg 待整理/b.PNG 待整理/c.pdf 待整理/d.mp4 待整理/e.zip 待整理/f.txt 待整理/g.unknown 待整理/.DS_Store
Windows PowerShell:
New-Item -ItemType Directory -Path "$HOME\tidy-box" -Force
New-Item -ItemType Directory -Path "$HOME\tidy-box\待整理" -Force
Set-Location "$HOME\tidy-box"
"a.jpg","b.PNG","c.pdf","d.mp4","e.zip","f.txt","g.unknown",".DS_Store" | ForEach-Object {
New-Item -ItemType File -Force "待整理\$_"
}
目录长这样:
tidy-box/
├── tidy.mjs
└── 待整理/
├── .DS_Store
├── a.jpg
├── b.PNG
├── c.pdf
├── d.mp4
├── e.zip
├── f.txt
└── g.unknown
tidy.mjs 下一步创建。
第三步:写脚本
在 tidy-box 里新建 tidy.mjs,把下面整段复制进去。用系统自带的文本编辑器就行,保存时确认编码是 UTF-8。

import { access, mkdir, readdir, rename } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
// 想改分类,只改这张表:左边是文件夹名,右边是扩展名。
const CATEGORIES = {
图片: ["jpg", "jpeg", "png", "gif", "webp", "heic", "svg"],
文档: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv"],
视频: ["mp4", "mov", "mkv", "avi", "webm"],
音频: ["mp3", "wav", "m4a", "flac", "aac"],
压缩包: ["zip", "rar", "7z", "tar", "gz"],
};
const EXT_TO_CATEGORY = new Map(
Object.entries(CATEGORIES).flatMap(([category, extensions]) =>
extensions.map((extension) => [extension, category]),
),
);
export function categoryOf(filename) {
const extension = path.extname(filename).slice(1).toLowerCase();
return EXT_TO_CATEGORY.get(extension) ?? "其他";
}
async function exists(target) {
try {
await access(target);
return true;
} catch {
return false;
}
}
function addSuffix(filename, index) {
const extension = path.extname(filename);
const base = filename.slice(0, -extension.length);
return `${base}-${index}${extension}`;
}
export async function plan(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile() && !entry.name.startsWith("."))
.sort((a, b) => a.name.localeCompare(b.name));
const claimed = new Set();
const moves = [];
for (const file of files) {
const category = categoryOf(file.name);
let target = path.join(dir, category, file.name);
let suffix = 1;
while (claimed.has(target) || (await exists(target))) {
target = path.join(dir, category, addSuffix(file.name, suffix));
suffix += 1;
}
claimed.add(target);
moves.push({ category, from: path.join(dir, file.name), to: target });
}
return moves;
}
export async function tidy(dir, { move = false } = {}) {
const moves = await plan(dir);
if (move) {
for (const item of moves) {
await mkdir(path.dirname(item.to), { recursive: true });
await rename(item.from, item.to);
}
}
return { moves, moved: move };
}
function printResult(dir, result) {
if (result.moves.length === 0) {
console.log("没有需要整理的文件。");
return;
}
for (const item of result.moves) {
const label = result.moved ? "已移动" : "预览";
const target = path.relative(dir, item.to).split(path.sep).join("/");
console.log(`[${label}] ${path.basename(item.from)} → ${target}`);
}
const counts = new Map();
for (const item of result.moves) {
counts.set(item.category, (counts.get(item.category) ?? 0) + 1);
}
const summary = Object.keys(CATEGORIES)
.concat("其他")
.filter((category) => counts.has(category))
.map((category) => `${category} ${counts.get(category)} 个`)
.join(",");
console.log(`\n共 ${result.moves.length} 个文件:${summary}`);
if (!result.moved) {
console.log("这是预览,没有移动任何文件。确认后加 --move 再跑一次。");
}
}
const isDirectRun =
process.argv[1] &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (isDirectRun) {
const args = process.argv.slice(2);
const move = args.includes("--move");
const dir = args.find((arg) => !arg.startsWith("--")) ?? "待整理";
try {
const result = await tidy(dir, { move });
printResult(dir, result);
} catch (error) {
if (error.code === "ENOENT" || error.code === "ENOTDIR") {
console.error(`打不开文件夹:${dir}`);
console.error("确认路径拼写,或先在脚本旁边新建这个文件夹。");
process.exitCode = 1;
} else {
throw error;
}
}
}
几个地方值得先知道:
CATEGORIES是唯一的分类来源。左边是文件夹名,右边是扩展名数组;没匹配到的进「其他」。tidy默认move: false,只算不动。预览和真正执行走的是同一套计算,所以预览里看到的目标路径就是移动后的路径。plan里维护了一个claimed集合。两个文件撞到同一个目标名时,第二个会自动变成-1、-2,不会覆盖。- 以
.开头的文件(比如.DS_Store)和子文件夹会被跳过。 - 最后那段
isDirectRun让脚本只在直接运行时执行;被别的脚本 import 时不会乱动文件。现在不用理解它,知道别删就行。
第四步:先预览,不要加 –move
在 tidy-box 里运行:
node tidy.mjs 待整理
输出:
[预览] a.jpg → 图片/a.jpg
[预览] b.PNG → 图片/b.PNG
[预览] c.pdf → 文档/c.pdf
[预览] d.mp4 → 视频/d.mp4
[预览] e.zip → 压缩包/e.zip
[预览] f.txt → 文档/f.txt
[预览] g.unknown → 其他/g.unknown
共 7 个文件:图片 2 个,文档 2 个,视频 1 个,压缩包 1 个,其他 1 个
这是预览,没有移动任何文件。确认后加 --move 再跑一次。
.DS_Store 没有出现在列表里,因为它以 . 开头;子文件夹也不会被处理。

第五步:确认后移动
node tidy.mjs 待整理 --move
输出:
[已移动] a.jpg → 图片/a.jpg
[已移动] b.PNG → 图片/b.PNG
[已移动] c.pdf → 文档/c.pdf
[已移动] d.mp4 → 视频/d.mp4
[已移动] e.zip → 压缩包/e.zip
[已移动] f.txt → 文档/f.txt
[已移动] g.unknown → 其他/g.unknown
共 7 个文件:图片 2 个,文档 2 个,视频 1 个,压缩包 1 个,其他 1 个
文件夹变成:
待整理/
├── .DS_Store
├── 图片/
│ ├── a.jpg
│ └── b.PNG
├── 文档/
│ ├── c.pdf
│ └── f.txt
├── 视频/
│ └── d.mp4
├── 压缩包/
│ └── e.zip
└── 其他/
└── g.unknown
再跑一次:
node tidy.mjs 待整理 --move
会得到:
没有需要整理的文件。
因为根目录已经没有可移动的文件了。这个「重复执行结果不变」的性质,后面接定时任务时很重要。

想撤回,或者想改分类
文件没有被删除,也没有被复制。想撤回,把分类文件夹里的文件拖回 待整理/ 根目录即可;练习文件夹里的文件本来就是空的,删掉整个文件夹重来也没损失。
想加自己的分类,改 CATEGORIES 就行。比如把电子书也分出来,在对象里加一行:
电子书: ["epub", "mobi", "azw3"],
没写进表的扩展名会进「其他」,不会报错。改完先跑预览,确认目标文件夹名是你想要的,再加 --move。
常见坑
1. 引号变成了中文引号
从聊天窗口复制代码时,直引号 " 可能变成弯引号 “ ”。Node 会报 SyntaxError: Invalid or unexpected token,指向那一行。在编辑器里重新输入那一行的引号即可。
2. 在错误的目录运行
tidy.mjs 不在当前目录时,会报 Error: Cannot find module。先确认终端提示符是不是 tidy-box,不是就 cd 进去;路径有空格时用引号包住。
3. 文件夹名写错
脚本会直接提示 打不开文件夹:...,不会继续。检查 待整理 是不是建在 tidy-box 里,以及当前目录对不对。
4. 对不该整理的文件夹用了 –move
第一次对任何新文件夹执行,都先不加 --move。整理「下载」或桌面前,先确认里面没有正在下载的文件和要保留的同名文件。
下一步读什么
环境跑通后,最顺的下一步是把「整理文件」换成「整理内容」:选题库和内容日历 同样用 Node 和 CSV,不需要额外依赖;想先练纯脚本,可以看批量生成标题。文件整理完,可以接着做批量改文件名;想让它每天自动跑,看三平台定时配置。
这条路线还会继续补:依赖安装报错、CSV 编码、定时任务。现在先把这一篇跑通,后面每一步都会回到这个 tidy-box 文件夹。完整路线见从 0 开始:第一台自动化工作台。
常见问题
我完全没写过代码,能跟下来吗?
可以。这篇只需要新建一个文件、复制一段代码、输入两条命令。脚本默认只预览,不会动你的文件;确认没问题后再加 --move。卡住时先看终端里最后一行的报错,「常见坑」那节按报错类型写了处理办法。
会不会把文件弄丢或覆盖?
不会删除,也不会覆盖。遇到同名文件会自动加 -1、-2 后缀。第一次务必在练习文件夹里跑,不要直接对「下载」或桌面执行。真要撤回,把分类文件夹里的文件拖回原处即可;它们是移动过去的,不是复制,删掉就真没了。
为什么不用现成的整理软件?
可以先用。但现成软件的分类规则通常改不了,也说不清文件到底去了哪。这个脚本只有一个文件,分类表就在开头,想加「电子书」或「安装包」改一行就行;文件不上传,断网也能跑。
能每天自动跑吗?
能,但要先让脚本稳定跑几次,再交给系统定时任务。这篇只解决「脚本本身能跑」;定时、日志和失败重试放在这条合集的后半段,等环境和文件都熟了再加。
每周一张复习表
每周分享一张实用复习表,不收钱不打扰,想走随时走
邮箱存在 Cloudflare KV 里,只用来发每周更新;任何时候写信到hello@genwozuo.com说「退订」就删掉。详见隐私政策。