跳到正文
跟我做
自媒体自动化8 分钟Node.js

Node.js 安装教程:跑通第一个整理脚本

不装依赖,用一个文件把下载文件夹按图片、文档、视频和压缩包自动归类。先预览再移动,同名文件不覆盖,零基础也能跟下来。

约 8 分钟
本页目录
  1. 这篇教程解决什么问题
  2. 你会得到什么
  3. 第一步:装 Node
  4. 第二步:建一个练习文件夹
  5. 第三步:写脚本
  6. 第四步:先预览,不要加 –move
  7. 第五步:确认后移动
  8. 想撤回,或者想改分类
  9. 常见坑
  10. 1. 引号变成了中文引号
  11. 2. 在错误的目录运行
  12. 3. 文件夹名写错
  13. 4. 对不该整理的文件夹用了 –move
  14. 下一步读什么

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 工具页 记了它适合做什么。

终端里输入 node -v 返回 v24.22.1,输入 npm -v 返回 11.19.1
① node 版本 ② npm 版本(版本号本机实测;窗口为示意)

如果提示下面这句:

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。

tidy.mjs 的前 10 行代码,包括 CATEGORIES 分类表;下面是终端运行 node tidy.mjs 待整理 后打出的两行预览
① 脚本文件 ② 跑起来(终端窗口为示意)
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 没有出现在列表里,因为它以 . 开头;子文件夹也不会被处理。

终端预览输出 7 行,每行是原文件名指向新文件夹,例如 a.jpg 指向 图片/a.jpg,最后提示没有移动任何文件
① 原文件名 ② 新文件夹(预览输出为本机实测)

第五步:确认后移动

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

会得到:

没有需要整理的文件。

因为根目录已经没有可移动的文件了。这个「重复执行结果不变」的性质,后面接定时任务时很重要。

终端里 tree 待整理 的输出:图片、文档、视频、压缩包、其他五个文件夹,每个里面是移动过去的文件
① 真跑 –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说「退订」就删掉。详见隐私政策。

同方向继续

全部教程