再提示『您的磁盘几乎已满』算我输

最近每天打开电脑都被提醒:『您的磁盘几乎已满』。我知道,那些过去下载的杂乱文件、传递消息的截图录屏、保存即看过的各种文档,叒叒一次要撑爆我 256g 的磁盘了。但白天太忙,我实在懒得去整理他们,所以习惯去 mute 它。第二天,打开电脑继续被提醒:『您的磁盘几乎已满』。。。

2E4637AD-BB37-4E9E-9C88-7E792B89480D.png

截屏2021-05-03 上午11.46.06.png

好难受,还是处理下吧,写个程序自动处理下吧,如果你也遇到这种问题,我们一起来看下吧。

确定整理文件的策略

在动手之前我们需要分析一下机器的文件目录(比如我的这台工作电脑),制定一些处理策略。显然我需要处理那些体积大的目录,但常用的工作目录、应用程序、系统文件目录,现在不该去碰它们,应该等到空余时间多的时候专门处理。 70F14F88-EF28-437C-B36C-FC865FF7EB96.png 除去这些目录之后,体积最大的 『下载 download』 就是这台工作电脑上我们可以动的首要目录,此外 『文稿document』 和 『桌面 desktop』 也是我们应该去清理的常用目录。我们姑且确定就清理这三个目录。 35728B12-F1C0-41A2-8CCE-65C4DF82B315.png 对于这三个目录里的各种文件,应该有几种处理:

  • 删除。比如图片、视频(我清楚的知道他们都来自日常工作消息传递中留存的,现在和未来不会再用,这台工作电脑本地无需留存,你也可以自己决定)、下载包、压缩包等。
  • 保留。有用的文档、资料、或者从程序角度目前无法判断的文件。

所以我决定先检测一下这三个目录下到底有哪些文件。新建一个 js 文件,我们定义好需要检测的目录 checkingDirectories, 主函数是一个 checkFolders ,他会遍历 checkingDirectories 下的每一个目录。

Javascript
const checkingDirectories = [
  '/Users/albertaz/Downloads',
  '/Users/albertaz/Documents',
  '/Users/albertaz/Desktop',
];

async function checkFolders() {
  for ( const dir of checkingDirectories) {
    await checkDir(dir);
  }
}

checkFolders();

接下来我们需要写 checkDir 检查被检测目录下的所有文件并标记文件类型。如果这个文件是一个目录,我会标记成 dir,如果是文件则标记文件类型,如果最后依然识别不出,就标记成 unknown

那么我们如何标记文件类型呢?最简单方法当然是通过后缀名,但这也是最粗糙的方法,毕竟后缀名可以随便改。

更好的方法也是我们这篇文章涉及的一个知识点,通过 Magic Number 判断文件类型。对于某一些类型的文件,起始的几个字节内容都是固定的,会有一个数字标识文件类型,这个数字可以是任意值、也可以占据任何字节(通常是4或8个字节)。计算机可以根据这几个字节的内容就可以判断文件类型,这些特定字节也被称为魔数 Magic Number。 通过 文件的 Magic Number 列表,我可以查到几乎所有文件的 Magic Number,注意是几乎所有,因为这个表也在动态更新,也可能有没覆盖到的特殊格式。

This is a dynamic list and may never be able to satisfy particular standards for completeness.

但,我想应该可以覆盖我这个工作电脑上的文件了,所以我们先找一个基于 Magic Number 的文件类型判断库,比如 file-type。所以 checkDir 实现如下:

Javascript
const fs = require('fs');
const path = require('path');
const FileType = require('file-type');

async function checkDir(dirPath) {
  const resultTable = {};
  try {
    const files = await fs.promises.readdir(dirPath);
    for await (const filename of files) {
      const filePath = path.join(dirPath, filename);
      try {
        const stats = await fs.promises.stat(filePath);
        const isFile = stats.isFile(); //是文件
        const isDir = stats.isDirectory(); //是文件夹
        if (isFile) {
          const fileType = await FileType.fromFile(filePath);
          const ext = fileType ? fileType.ext : 'unknown';
          const mime = fileType ? fileType.mime : 'unknown';
          if (resultTable[ext]) {
            resultTable[ext] += 1;
          } else {
            resultTable[ext] = 1;
          }
        }
        if (isDir) {
          if (resultTable['dir']) {
            resultTable['dir'] += 1;
          } else {
            resultTable['dir'] = 1;
          }
        }

      } catch (err) {
        console.error(err);
      }
    }
    console.table(resultTable);
  } catch (err) {
    console.error(err);
  }
}

最后我们用 console.table 展示一下统计数据:

Bash
┌─────────┬────────┐
│ (index) │ Values │
├─────────┼────────┤
│ unknown │   31   │
│   zip   │   63   │
│   pdf   │  223   │
│  pptx   │   37   │
│   png   │   87   │
│   dir   │   77   │
│   jpg   │   11   │
│   cfb   │   4    │
│   mp4   │   7    │
│   xml   │   5    │
│   bz2   │   1    │
│   stl   │   1    │
│  xlsx   │   3    │
│   exe   │   1    │
│   psd   │   2    │
│   gz    │   3    │
│  docx   │   7    │
│   mov   │   6    │
│   gif   │   2    │
└─────────┴────────┘
┌─────────┬────────┐
│ (index) │ Values │
├─────────┼────────┤
│ unknown │   2    │
│   dir   │   10   │
│   xml   │   1    │
│   zip   │   2    │
│   png   │   6    │
└─────────┴────────┘
┌─────────┬────────┐
│ (index) │ Values │
├─────────┼────────┤
│ unknown │   4    │
│   zip   │   9    │
│   png   │   45   │
│   pdf   │   10   │
│   dir   │   5    │
│   jpg   │   7    │
│  pptx   │   4    │
│   xml   │   1    │
│   mov   │   10   │
└─────────┴────────┘

根据这个数据,我又更新了一次处理思路:

  • 毫不犹豫删除:比如图片、视频、下载包(通常已经被解压成文件夹)、压缩包,这些我确定需要删除的文件。
  • 判断访问时间再删除:比如一年多我都没有碰过的文件,大概率我已经忘了他,不再需要,可以删除。
  • 保留并且分类:比如会把 pptx、pdf 之类的文件移到 doc 目录下,把 psd、stl 等文件移到 workfile 目录下。
  • 跳过不处理:文件夹(大部分文件夹是有用的)和未识别类型文件(保险起见)。

因此,我们增加一些常量配置来定义要做的处理:

Javascript
const ACTIONS = {
  DELETE: 'DELETE',
  MOVE: 'MOVE',
  SKIP: 'SKIP',
};

const DELETE_FILE_TYPES = [
  'png', 'jpeg', 'jpg',
  'gz', 'zip', 'exe',
  'mov', 'mp4', 'gif',
  'xml',
];

const SKIP_FILE_TYPES = [
  'unknown', 'dir'
];

// 以下文件会保留但分门别类移动目录
const MOVE_FILE_TYPES = {
  'doc': [ // 移动到 doc 目录
    'pptx', 'ppt',
    'docx', 'doc',
    'xlsx', 'xls',
    'pdf',
  ],
  'workfile': ['psd', 'cfb', 'bz2', 'stl'] // 移动到 workfile 目录
};

按策略处理文件

首先改造一下主函数 checkFolders

Javascript
async function checkFolders() {
  makeFileTypeActions();
  for ( const dir of CHECKING_DIRECTORIES) {
    await checkDir(dir);
  }
  await actionLog();
}

可以看到我们增加一个 makeFileTypeActions 用来把配置常量转换成方便处理的 map 结构(这里我期望这个 map 的 key 是文件类型,value 是要做的操作),同时保证每个文件类型只有一种操作(即使之前有重复的操作配置,或者配置错了)。所以我们会按顺序遍历 SKIP_FILE_TYPESDELETE_FILE_TYPESMOVE_FILE_TYPES

Javascript
const FILETYP_ACTIONS_MAP = new Map();

function makeFileTypeActions() {
    for (const type of SKIP_FILE_TYPES) {
      FILETYPE_ACTIONS.set(type, {
        type: ACTIONS.SKIP
      });
    }
    for (const type of DELETE_FILE_TYPES) {
      // 如果这个文件类型有重复的操作配置,跳过暂不处理
      if (FILETYPE_ACTIONS.has(type) && FILETYPE_ACTIONS.get(type) !== ACTIONS.SKIP) {
        FILETYPE_ACTIONS.delete(type);
        FILETYPE_ACTIONS.set(type, {
          type: ACTIONS.SKIP
        });
      } else {
        FILETYPE_ACTIONS.set(type, {
          type: ACTIONS.DELETE
        });
      }
    }
    for (const categoryName in MOVE_FILE_TYPES) {
      const categoryTypes = MOVE_FILE_TYPES[categoryName];
      for (const type of categoryTypes) {
     	  // 如果这个文件类型有重复的操作配置,跳过暂不处理
        if (FILETYPE_ACTIONS.has(type) && FILETYPE_ACTIONS.get(type) !== ACTIONS.SKIP) {
          FILETYPE_ACTIONS.delete(type);
          FILETYPE_ACTIONS.set(type, {
            type: ACTIONS.SKIP
          });
        } else {
          FILETYPE_ACTIONS.set(type, {
            type: ACTIONS.MOVE,
            categoryName,
          });
        }

      }
    }
  }

我们还增加了一个 actionLog用来记录日志,会把日志存到 /logs 目录下并用时间戳作为文件名:

Javascript
const logFilePath = 'logs'  
async actionLog() {
  const data = `
\n --------- skip result
${this.skipResult.map(r => `${JSON.stringify(r)} \n`)}
\n --------- move result
${this.moveResult.map(r => `${JSON.stringify(r)} \n`)}
\n --------- delete result
${this.deleteResult.map(r => `${JSON.stringify(r)} \n`)}
`;
  try {
    const logTime = new Date().getTime();
    if (!fs.existsSync(path.join(process.cwd(), logFilePath))) {
      await fs.promises.mkdir(path.join(process.cwd(), logFilePath));
    }
    const logFileName = path.join(process.cwd(), logFilePath, `clean-files-${logTime}.log`);
    await fs.promises.writeFile(logFileName, data);
  } catch (err) {
    console.error(err);
  }
}

接着我们需要改造一下 checkDir。为了判断一个文件是否很久没被访问,我们从 fs.stat 中取出文件的上一次被访问时间 atimeMs,并且增加一个 doFileActions 操作方法。

Javascript
async function checkDir(dirPath) {
	...
  const stats = await fs.promises.stat(filePath);
  const isFile = stats.isFile(); //是文件
  const isDir = stats.isDirectory(); //是文件夹
  const { size, atimeMs, birthtimeMs } = stats;
	let ext = 'unknown';
  let mime = 'unknown';
  if (isFile) {
    const fileType = await FileType.fromFile(filePath);
    ext = fileType ? fileType.ext : 'unknown';
    mime = fileType ? fileType.mime : 'unknown';
  }
  if (isDir) {
    ext = 'dir'; 
    mime = 'dir';
  }
  const fileInfo = { ext, mime, size, atimeMs, birthtimeMs, filename, filePath };
  await doFileActions(fileInfo, dirPath);
	...
}

doFileActions 的实现也很简单,即实现三种操作。其中我们增加了一个可以接受的最久的访问天数 THRESHOLD_DAY (期望他是可配置的),并将他转换成距离现在的毫秒数 THRESHOLD 用来跟文件的
atimeMs 作比较,超出也会删除文件:

Javascript
const THRESHOLD_DAY = null;
const THRESHOLD = const THRESHOLD_DAY ? new Date().getTime() - THRESHOLD_DAY * 24 * 60 * 60 : 0;

async doFileActions (fileInfo, dirPath) {
  const { ext, size, atimeMs, birthtimeMs, filename, filePath } = fileInfo;
  if (!FILETYPE_ACTIONS.get(ext) || FILETYPE_ACTIONS.get(ext).type === ACTIONS.SKIP) {
    skipResult.push({
      ...fileInfo,
      action: ACTIONS.SKIP,
    });
    return;
  }

  if (FILETYPE_ACTIONS.get(ext).type === ACTIONS.MOVE) {
    const categoryName = FILETYPE_ACTIONS.get(ext).categoryName;
    moveResult.push({
      ...fileInfo,
      action: ACTIONS.MOVE,
      categoryName,
    });
    // 暂不执行移动操作
    // try {
    //   if (!fs.existsSync(path.join(dirPath, categoryName))) {
    //     await fs.promises.mkdir(path.join(dirPath, categoryName));
    //   }
    //   await fs.promises.rename(filePath, path.join(dirPath, categoryName, filename));
    // } catch (err) {
    //   console.error(err);
    // }
    return;
  }
  if (FILETYPE_ACTIONS.get(ext).type === ACTIONS.DELETE || (THRESHOLD && atimeMs < THRESHOLD)) {
    deleteResult.push({
      ...fileInfo,
      action: 'delete',
    });
    // 暂不执行删除操作
    // try {
    //   await fs.promises.unlink(filePath);
    // } catch (err) {
    //   console.error(err);
    // }
    return;
  }
}

看起来为了保险起见我们先不执行真正的文件操作,而是仅记录日志。

定时自动整理

到这里我们的程序看起来基本写完了,但你可能不想每次都手动执行,希望它可以每周或者每个月跑一次,所以接下来我们再增加一个定时任务,让我们可以配置执行周期。

为了更容易配置,首先我们把所有配置抽成一个文件并完善一下它 .cf-config.js,这里的每个配置项你都可以为不填置空。注意我们增加了一个 scheduleCron 支持配置 Cron 格式的定时任务配置(比如这里设置成了每个月20号2点触发 0 0 2 20 * ?):

Javascript
// .cf-config.js
'use strict';

module.exports = {
  "checkingDirectories": [
    "/Users/albertaz/Downloads",
    "/Users/albertaz/Documents",
    "/Users/albertaz/Desktop"
  ],
  "deleteFileTypes": [
    "png", "jpeg", "jpg",
    "gz", "zip", "exe", "dmg", "pkg",
    "mov", "mp4", "gif",
    "xml", "html", "svg",
    "sketch",
    "json",
    "xlsx", "xls", "csv", "numbers"
  ],
  "moveFileTypes": {
    "doc": [
      "pptx", "ppt", ".key",
      "docx", "doc",
      "pdf"
    ],
    "workfile": ["psd", "cfb", "bz2", "stl"]
  },
  "logFilePath": "logs",
  "thresholdDay": 60,
  "scheduleCron": "0 0 2 20 * ?" // 每个月20号2点
};

接着把主代码改成一个模块 lib/cleanfiles.js,方便读取配置和调用:

Javascript
// lib/cleanfiles.js
const fs = require('fs');
const path = require('path');
const FileType = require('file-type');

class CleanFiles {
  constructor(config) {
    this.CHECKING_DIRECTORIES = config.checkingDirectories || [];
    this.ACTIONS = {
      DELETE: 'DELETE',
      MOVE: 'MOVE',
      SKIP: 'SKIP',
    };
    this.SKIP_FILE_TYPES = [ 'unknown', 'dir'];
    this.FILETYPE_ACTIONS = new Map();
    this.DELETE_FILE_TYPES = config.deleteFileTypes || [];
    this.MOVE_FILE_TYPES = config.moveFileTypes || {};
    this.logFilePath = config.logFilePath;
    this.THRESHOLD_DAY = config.thresholdDay;
    this.THRESHOLD = null;

    this.skipResult = [];
    this.deleteResult = [];
    this.moveResult = [];
    this.makeFileTypeActions();
  }

  resetActionState() {
    this.THRESHOLD = this.THRESHOLD_DAY ? new Date().getTime() - this.THRESHOLD_DAY * 24 * 60 * 60 : 0;
    this.skipResult = [];
    this.deleteResult = [];
    this.moveResult = [];
  }

  makeFileTypeActions() {...}

  async doFileActions (fileInfo, dirPath) {...}

  async actionLog() {...}

  async checkDir(dirPath) {...}

  async start() {
    this.resetActionState();
    for ( const dir of this.CHECKING_DIRECTORIES) {
      await this.checkDir(dir);
    }
    await this.actionLog();
  }
};

module.exports = CleanFiles;

同时为了让我们可以定时调用,增加了一个 resetActionState 方法在每次定时任务执行时重置一些状态。

之后我们就可以新建一个入口 index.js 定时调用 CleanFiles 模块了:

Javascript
// index.js

const schedule = require('node-schedule');
const config = require('./.cf-config.js');
const Cleanfiles = require('./lib/cleanfiles');

const cleanfiles = new Cleanfiles(config);

if (config.scheduleCron) {
  schedule.scheduleJob(config.scheduleCron, () => {
    console.log(`Starting a clean files task at ${new Date().toLocaleString()}`);
    cleanfiles.start();
  });
} else {
  console.log(`Starting a clean files task at ${new Date().toLocaleString()}`);
  cleanfiles.start();
}

最后 npm init一下并配置到 package.json, 让它看起来更像一个可以用的模块包:

JSON
{
  "name": "clean-files",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "file-type": "^16.3.0",
    "node-schedule": "^2.0.0"
  }
}

而我们整个项目也变成了这样:

Javascript
.
├── index.js
├── lib
│   └── cleanfiles.js
├── logs
└── package.json

自定义文件类型判断

看起来我们前面已经做完了所有事,只需要把 doFileActions 中真实的文件操作注释放开就可以。 但在这之前,我又检查了一遍日志,发现了一些文件判断有问题,比如:

JSON
 --------- delete result
{"ext":"zip","filename":"xxxxxx.key","size":865959,"atimeMs":1609949237000,"birthtimeMs":1588729777441.4695,"filePath":"/Users/albertaz/Downloads/xxxxxx.key","action":"delete"} 
,{"ext":"zip","filename":"xxx.key","size":11901492,"atimeMs":1609943460000,"birthtimeMs":1601282100922.4902,"filePath":"/Users/albertaz/Downloads/xxx.key","action":"delete"} 
 
--------- skip result
{"ext":"unknown","filename":"AntMan-0.4.dmg","size":44728286,"atimeMs":1620618364938.3296,"birthtimeMs":1620618351017.468,"filePath":"/Users/albertaz/Downloads/AntMan-0.4.dmg","action":"SKIP"} 

key 文件因为被识别成了 zip 而被标记成删除,dmggeojson文件因为被识别成了 unknown 而被标记成跳过。显然是因为之前提到的现有的 Magic Number 列表无法覆盖所文件格式。

所以我们去看下使用的 file-type 是如何判断的,如果有误判我们得自己补充。比如对 zip 的判断,可以看到知检测了前四个字节:

Javascript
...	
// Zip-based file formats
// Need to be before the `zip` check
if (check([0x50, 0x4B, 0x3, 0x4])) { // Local file header signature
...

我们再简单加一端代码测试一下 key 文件的头部二进制:

Javascript
async customFileType(filePath, filename) {
  if (filename === 'xxxxxx.key' || filename === 'xxx.key') {
    console.log(filePath)
    const fileBuffer = await fs.promises.readFile(filePath);
    console.log(fileBuffer);
  }
}

可以看到头部二进制前十个字节都是一样的,也就是说我们应该提取前十个字节 0x50 0x4b 0x03 0x04 0x14 0x00 0x00 0x00 0x00 0x00作为 Magic Number:

Bash
/Users/albertaz/Downloads/xxxxxx.key
<Buffer 50 4b 03 04 14 00 00 00 00 00 00 8e 89 4a d5 45 d4 f2 68 09 11 00 68 09 11 00 1a 00 00 00 44 61 74 61 2f 70 61 73 74 65 64 2d 69 6d 61 67 65 2d 38 30 ... 11901442 more bytes>
/Users/albertaz/Downloads/xxx.key
<Buffer 50 4b 03 04 14 00 00 00 00 00 76 5c a1 50 c8 d8 b8 35 71 8a 00 00 71 8a 00 00 34 00 00 00 44 61 74 61 2f 6d 74 2d 37 33 30 34 45 34 44 32 2d 37 33 30 ... 65478916 more bytes>

同样的方法我们可以得到 dmg 文件的 Magic Number 是 0x78 0xda 0x63 0x60 0x18 0x05 0x43 0x18 0xfc 0xfb 0xff 0xff 0x1d 0x10 0x33 0x02 0x99

这类无法识别的文件未来可能还会有,为了以后可以灵活补充 Magic Number,我们应该支持通过配置文件添加 Magic Number :

Javascript
// .cf-config.js
'use strict';

module.exports = {
  ...
  "customMagicNumberMap": {
    "sketch": [0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00],
    "dmg": [0x78, 0xda, 0x63, 0x60, 0x18, 0x05, 0x43, 0x18, 0xfc, 0xfb, 0xff, 0xff, 0x1d, 0x10, 0x33, 0x02, 0x99]
  },
};

然后我们增加一个 CustomFileChecker 类专门用来做读取配置的 Magic Number 做自定义文件检测处理:

Javascript
class CustomFileChecker {
  constructor(customFileCheckMap) {
    this.FIlE_CHECKERS = new Map();
    this.initFileCheckers(customFileCheckMap);
  }

  initFileCheckers(customFileCheckMap = {}) {
    for (const ext in customFileCheckMap) {
      ext && this.FIlE_CHECKERS.set(ext, customFileCheckMap[ext]);
    }
  }

  checkeFileType(fileBufferHeader) {
    for (let [ext, magicNumber] of this.FIlE_CHECKERS) {
      const match = magicNumber.every((number, index) => number === fileBufferHeader[index]);
      if (match) {
        return { ext };
      }
    }
    return { ext: 'unknown' };
  }
}

我们再把 customFileType改造成调用 customFileChecker.checkeFileType 。注意我们发现的特殊 Magic Number 长度都是不固定的,不再是 4 或 8个字节,所以需要比较的文件头长度也不固定。但如果直接拿整个文件去比较显然也没有必要,所以我们暂定截取前 20 个字节作为文件头(如果以后不够长再补充吧)。

Javascript
async customFileType(filePath, filename) {
  const start = 0;
  const end = 20;
  const fileBuffer = await fs.promises.readFile(filePath);
  const fileHeader = fileBuffer.slice(start, end);
  const { ext } = this.customFileChecker.checkeFileType(fileHeader);
  return ext;
}

随后我们检测文件类型时增加一步,先执行自定义的判断 customFileType , 如果没有匹配上,再调用原来的逻辑 用 file-type 库判断:

Javascript
// lib/cleanfiles.js
constructor(config) {
  ...
  this.customFileChecker = new CustomFileChecker(config.customMagicNumberMap);
	...
}

async checkDir(dirPath) {
 ...
 if (isFile) {
   let ext = await this.customFileType(filePath, filename);
   if (!ext || ext === 'unknown') {
     const fileType = await FileType.fromFile(filePath, filename);
     ext = fileType ? fileType.ext : 'unknown';
   }
   const fileInfo = { ext, size, atimeMs, birthtimeMs, filename, filePath };

   await this.doFileActions(fileInfo, dirPath);
 ...
}

更多优化

到此,我们基本完成了目前能想到的优化处理。打开处理文件的注释,执行了一遍任务,相信我短期内不会再看到『您的磁盘几乎已满』的推送了。

总结一下: 我们通过 Magic Number 判断文件类型、通过文件上一次被访问时间找出了长期不访问的文件、通过 nodejs fs api 完成了各种文件查找、遍历、移动和删除处理,并从零到一开发了一个可以灵活配置的定时整理文件的工具。

我把这个简陋的模块叫做 clean-files,如果你感兴趣,欢迎查看源码: https://github.com/AlbertAZ1992/clean-files

事实上这个模块还有很多优化可以做,比如怎样把这个模块改造成一个 cli,添加进程守护常驻后台;怎样让文件头的 Magic Number 检查效率更高;等等。如果有时间,后续会持续优化。