Featured image of post Hardlink Upload Mirror:自动生成合法文件名镜像目录的硬链接脚本

Hardlink Upload Mirror:自动生成合法文件名镜像目录的硬链接脚本

Hardlink Upload Mirror 是一个面向自动化文件整理与上传场景的脚本工具。它会扫描原始文件目录,并在目标位置创建一个镜像目录。这个镜像目录会尽量保留原始目录层级,同时对目录名和文件名进行合法化处理,以便用于同步、上传、归档或备份流程。

介绍

在Cloudsync或Webdav等方式备份到云端的时候,由于云端上传到文件名要求,导致部分文件上传不成功的问题,文件多的时候一个个改写又十分麻烦,或者像做种的时候不方便改写文件名的情况,为此写的一个通用的自动化脚本,用于为原始文件目录生成一个可上传镜像目录,脚本通过硬链接方式保留原始文件内容不变,同时自动处理目录名和文件名,使其更适合上传到对路径和文件名有限制的云端服务或同步工具。

下载

底部

必须更改的配置

日志文件位置

LOG_FILE=

状态文件位置

STATE_DIR=

Perl文件位置

PERL_BIN= 由于部分系统的位置不同,需要使用command -v perl来查找位置并填入该脚本处

需要进行镜像的文件位置

填写格式为源文件位置|目标文件地址 FOLDER_PAIRS=

可选更改的配置

哈希后缀追加

USE_HASH_SUFFIX= 该功能是控制目标文件名后面要不要自动加一个短哈希后缀, 如果是 1,文件名会变成: 清洗后的主文件名__短哈希.扩展名 如果是 0,文件名会变成 清洗后的主文件名.扩展名 主要是为了避免不同原文件在清洗名字后发生重名冲突。

日志滚动大小

LOG_ROTATE_SIZE=

日志保存数量

LOG_ROTATE_COUNT=

核心功能

  • 使用硬链接创建镜像,减少额外空间占用
  • 保留原始文件不变
  • 自动生成合法文件名和目录名
  • 保留目录层级结构
  • 支持多组目录映射
  • 支持状态跟踪
  • 支持源文件删除后的目标清理
  • 支持日志滚动
  • 支持常见分卷压缩文件识别
  • 支持中文、英文、数字及日文字符保留

适用场景

这个脚本适合以下场景:

  • 文件名不适合直接上传到云端
  • 希望保留原始文件不动
  • 希望自动生成一份更适合上传或同步的镜像目录
  • 需要节省空间,不想复制整份数据
  • 需要在 NAS、Linux 主机或自动化任务环境中定时执行
  • 需要处理媒体素材、文档、压缩包和分卷文件

工作原理

脚本会读取原始目录中的文件和子目录,排除系统目录和不需要处理的路径,然后在目标目录中创建对应的镜像结构。文件通过硬链接方式加入目标目录,目录名和文件名则根据规则进行转换,以提升兼容性和可上传性。

文件名处理能力

默认支持以下处理方式:

  • 保留中文
  • 保留英文
  • 保留数字
  • 保留日文平假名和片假名
  • 删除 emoji 与 pictograph
  • 替换不兼容字符
  • 保留扩展名
  • 支持部分分卷压缩格式识别

支持识别的分卷和复合扩展名包括:

  • .part01.rar
  • .7z.001
  • .zip.001
  • .r00
  • .z01
  • .tar.gz
  • .tar.xz
  • .tar.bz2
  • .tar.zst

删除功能是做什么的

在很多自动化镜像场景里,只创建新文件是不够的。 如果原始目录中的文件后来被删除,而镜像目录中的旧文件一直保留下来,那么时间一长,镜像目录就会越来越不准确,最终变成一份“历史残留集合”,而不是一份真实可用的当前镜像。

Hardlink Upload Mirror 的删除功能,就是为了解决这个问题。

它会在每次运行时判断:

  • 某个目标文件是否仍然有对应的源文件
  • 如果源文件已经不存在,目标文件是否应该被安全删除

这样,镜像目录就能保持与原始目录更接近的一致性,而不会无限堆积过期文件。

State 的作用

什么是 state

state 可以理解为脚本的“映射记录层”或“同步状态索引”。

它并不是文件内容数据库,也不是完整的校验系统,而是一份轻量级的记录文件,用来保存:

上一次运行时,哪些源文件对应到了哪些目标文件。

脚本里专门有一个状态目录用于保存这些映射文件。每组“源目录 → 目标目录”配置,都会对应生成一个单独的 state 文件。


State 里保存什么

state 文件不是只保存目标文件路径,而是保存一一对应的映射关系,例如:

/source/path/fileA.txt /mirror/path/fileA__abc123.txt, /source/path/sub/fileB.md /mirror/path/sub/fileB__def456.md

也就是说,每一行都能回答两个问题:

  • 这个镜像文件来自哪个原始文件
  • 这个原始文件当前对应的目标文件路径是什么

删除是如何判断的

脚本不是简单地“扫描目标目录后随便删文件”,而是通过一份状态记录来判断每个目标文件是否仍然有效。

每次运行时,脚本都会记录:

源文件路径 → 目标文件路径

也就是说,状态不是只保存“目标目录里有哪些文件”,而是明确保存每一个镜像文件对应的原始来源。

这使删除逻辑可以做到:

  • 如果源文件还存在,则绝不删除对应目标文件
  • 如果源文件已经不存在,并且这条映射不再出现在本轮扫描结果中,才会删除目标镜像文件

脚本获取

可以选择:GithubRaw里复制内容,或打开Github地址进行下载,也可以直接在这里复制并保存成.sh文件

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
#!/bin/bash
set -eu
unset LD_PRELOAD

########################################
# 项目地址:https://github.com/yuzi-ska/Hardlink-Upload-Mirror
########################################

########################################
# 配置区:按需修改
########################################

LOG_FILE="/volume/log/build_upload_mirror/mirror.log"
LOG_ROTATE_SIZE=$((5 * 1024 * 1024))
LOG_ROTATE_COUNT=5

# 状态目录:保存每组映射的“源文件 -> 目标文件”映射
STATE_DIR="/volume/log/build_upload_mirror/state"
# 是否进行短哈希后缀追加,如:"清洗后的主文件名__短哈希.扩展名"
USE_HASH_SUFFIX=1

# 计划任务环境里可能找不到 perl,这里写绝对路径
PERL_BIN="/opt/bin/perl"
# 填写格式为`源文件位置|目标文件地址`
FOLDER_PAIRS=(
  "/volume/Project|/volume/Project/backup"
  "/files/Project|/files/Project/backup"
)

########################################
# 初始化
########################################

mkdir -p "$(dirname "$LOG_FILE")"
mkdir -p "$STATE_DIR"
touch "$LOG_FILE"

rotate_log_if_needed() {
  [ -f "$LOG_FILE" ] || return 0

  local size
  size="$(stat -c '%s' "$LOG_FILE" 2>/dev/null || echo 0)"
  [ "$size" -lt "$LOG_ROTATE_SIZE" ] && return 0

  local i
  for ((i=LOG_ROTATE_COUNT-1; i>=1; i--)); do
    if [ -f "${LOG_FILE}.${i}" ]; then
      mv -f "${LOG_FILE}.${i}" "${LOG_FILE}.$((i+1))"
    fi
  done

  mv -f "$LOG_FILE" "${LOG_FILE}.1"
  : > "$LOG_FILE"
}

log() {
  rotate_log_if_needed
  echo "[$(date '+%F %T')] $*" >> "$LOG_FILE"
}

# 提前检查 perl 是否存在,避免计划任务 silently 出错
if [ ! -x "$PERL_BIN" ]; then
  log "错误:PERL_BIN 不存在或不可执行:$PERL_BIN"
  exit 1
fi

########################################
# 名称清洗规则
# 保留:中文、平假名、片假名、英文、数字
########################################

sanitize_name() {
  "$PERL_BIN" -CSDA -e '
    use utf8;
    my $s = join("", <>);

    $s =~ s/[\x{1F000}-\x{1FAFF}\x{2600}-\x{27BF}\x{FE0F}\x{200D}\x{20E3}]//g;
    $s =~ s/[^\p{Han}\p{Hiragana}\p{Katakana}\p{Latin}\p{Nd}._-]+/_/g;
    $s =~ s/_+/_/g;
    $s =~ s/^_+//;
    $s =~ s/_+$//;
    $s = "_" if $s eq "";
    print $s;
  ' <<< "$1"
}

########################################
# 目录相对路径合法化
########################################

build_sanitized_rel_path() {
  local rel="$1"
  local out=""
  local part

  if [ -z "$rel" ] || [ "$rel" = "." ]; then
    printf '%s' ""
    return
  fi

  IFS='/' read -r -a parts <<< "$rel"
  for part in "${parts[@]}"; do
    [ -n "$part" ] || continue
    out="$out/$(sanitize_name "$part")"
  done

  printf '%s' "$out"
}

########################################
# 识别分卷压缩 / 复合扩展名
########################################

detect_archive_suffix() {
  local base="$1"

  if [[ "$base" =~ ^(.+)(\.part[0-9]+\.rar)$ ]]; then
    printf '%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
    return 0
  fi

  if [[ "$base" =~ ^(.+)(\.(7z|zip|tar)\.[0-9]{3})$ ]]; then
    printf '%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
    return 0
  fi

  if [[ "$base" =~ ^(.+)(\.z[0-9]{2})$ ]]; then
    printf '%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
    return 0
  fi

  if [[ "$base" =~ ^(.+)(\.r[0-9]{2})$ ]]; then
    printf '%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
    return 0
  fi

  if [[ "$base" =~ ^(.+)(\.tar\.(gz|xz|bz2|zst))$ ]]; then
    printf '%s\n%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}"
    return 0
  fi

  if [[ "$base" == *.* && "$base" != .* ]]; then
    printf '%s\n.%s\n' "${base%.*}" "${base##*.}"
    return 0
  fi

  printf '%s\n\n' "$base"
}

########################################
# 文件名合法化
########################################

safe_target_name() {
  local base="$1"
  local parsed
  local stem
  local ext
  local clean_stem

  parsed="$(detect_archive_suffix "$base")"
  stem="$(printf '%s\n' "$parsed" | sed -n '1p')"
  ext="$(printf '%s\n' "$parsed" | sed -n '2p')"

  clean_stem="$(sanitize_name "$stem")"

  if [ -z "$clean_stem" ] || [ "$clean_stem" = "_" ]; then
    clean_stem="$(printf '%s' "$stem" | sed 's/[^[:alnum:][:space:]._-]/_/g; s/[[:space:]]\+/_/g; s/_\+/_/g; s/^_//; s/_$//')"
    [ -n "$clean_stem" ] || clean_stem="file"
  fi

  if [ "$USE_HASH_SUFFIX" = "1" ]; then
    local short_hash
    short_hash="$(printf '%s' "$base" | md5sum | awk '{print substr($1,1,6)}')"
    printf '%s__%s%s' "$clean_stem" "$short_hash" "$ext"
  else
    printf '%s%s' "$clean_stem" "$ext"
  fi
}

########################################
# inode / 文件系统 / 路径判断
########################################

same_inode() {
  local f1="$1"
  local f2="$2"

  local i1 i2
  i1="$(stat -c '%d:%i' "$f1" 2>/dev/null || true)"
  i2="$(stat -c '%d:%i' "$f2" 2>/dev/null || true)"

  [ -n "$i1" ] && [ "$i1" = "$i2" ]
}

same_filesystem() {
  local p1="$1"
  local p2="$2"

  mkdir -p "$p1" "$p2"

  local d1 d2
  d1="$(stat -c '%d' "$p1" 2>/dev/null || true)"
  d2="$(stat -c '%d' "$p2" 2>/dev/null || true)"

  [ -n "$d1" ] && [ "$d1" = "$d2" ]
}

is_subpath() {
  local child="$1"
  local parent="$2"

  case "$child" in
    "$parent"|"${parent}/"*)
      return 0
      ;;
    *)
      return 1
      ;;
  esac
}

same_value() {
  local f1="$1"
  local f2="$2"

  if same_inode "$f1" "$f2"; then
    return 0
  fi

  local s1 s2
  s1="$(stat -c '%s' "$f1" 2>/dev/null || echo -1)"
  s2="$(stat -c '%s' "$f2" 2>/dev/null || echo -1)"

  [ "$s1" = "$s2" ] || return 1
  cmp -s "$f1" "$f2"
}

########################################
# 状态文件
########################################

pair_state_file() {
  local src="$1"
  local dst="$2"
  local key
  key="$(printf '%s|%s' "$src" "$dst" | md5sum | awk '{print $1}')"
  printf '%s/%s.list' "$STATE_DIR" "$key"
}

########################################
# 安全删除目标文件
########################################

safe_remove_target_file() {
  local target="$1"
  local dst_root="$2"
  local src_root="$3"

  [ -n "$target" ] || return
  [ -n "$dst_root" ] || return
  [ -n "$src_root" ] || return

  dst_root="${dst_root%/}"
  src_root="${src_root%/}"

  case "$dst_root" in
    ""|"/"|"/volume1"|"/volume2"|"/volume3"|"/volume4")
      log "安全检查未通过,拒绝删除:危险目标目录 $dst_root"
      return
      ;;
  esac

  if [ "$src_root" = "$dst_root" ]; then
    log "安全检查未通过,拒绝删除:源目录与目标目录相同 src=$src_root dst=$dst_root"
    return
  fi

  [ -e "$target" ] || return

  if ! is_subpath "$target" "$dst_root"; then
    log "安全检查未通过,拒绝删除(不在目标目录内):$target"
    return
  fi

  if is_subpath "$target" "$src_root"; then
    log "安全检查未通过,拒绝删除(目标路径落入源目录):$target"
    return
  fi

  rm -f -- "$target"
  log "删除镜像文件:$target"
}

cleanup_deleted_targets() {
  local prev_list="$1"
  local curr_list="$2"
  local dst_root="$3"
  local src_root="$4"

  [ -f "$prev_list" ] || return

  while IFS=$'\t' read -r old_src old_target; do
    [ -n "${old_src:-}" ] || continue
    [ -n "${old_target:-}" ] || continue

    if [ -e "$old_src" ]; then
      continue
    fi

    if grep -Fq -- "$old_src"$'\t'"$old_target" "$curr_list"; then
      continue
    fi

    safe_remove_target_file "$old_target" "$dst_root" "$src_root"
  done < "$prev_list"

  find "$dst_root" -depth -type d -empty -delete 2>/dev/null || true
}

########################################
# 处理一组目录映射
########################################

process_pair() {
  local src="$1"
  local dst="$2"
  local log_dir
  local state_file
  local state_file_tmp
  local current_list
  local prev_tmp

  log "开始处理:SRC=$src DST=$dst"

  if [ ! -d "$src" ]; then
    log "跳过,不存在的源目录:$src"
    return
  fi

  mkdir -p "$dst"

  if ! same_filesystem "$src" "$dst"; then
    log "跳过,源目录与镜像目录不在同一文件系统,无法创建硬链接:SRC=$src DST=$dst"
    return
  fi

  log_dir="$(dirname "$LOG_FILE")"
  state_file="$(pair_state_file "$src" "$dst")"
  state_file_tmp="${state_file}.tmp"
  current_list="$(mktemp)"
  prev_tmp="$(mktemp)"

  [ -f "$state_file" ] && cp -f "$state_file" "$prev_tmp" || : > "$prev_tmp"

  # 1) 创建镜像目录结构
  while IFS= read -r -d '' src_dir; do
    local rel_dir sanitized_rel dst_dir

    case "$src_dir" in
      */#recycle|*/#recycle/*|*/@eaDir|*/@eaDir/*)
        continue
        ;;
    esac

    if is_subpath "$src_dir" "$dst"; then
      continue
    fi

    if is_subpath "$src_dir" "$log_dir"; then
      continue
    fi

    if [ "$src_dir" = "$src" ]; then
      continue
    fi

    rel_dir="${src_dir#"$src"/}"

    if [ -z "$rel_dir" ] || [ "$rel_dir" = "$src_dir" ]; then
      continue
    fi

    sanitized_rel="$(build_sanitized_rel_path "$rel_dir")"
    dst_dir="$dst$sanitized_rel"
    mkdir -p "$dst_dir"
  done < <(find "$src" -mindepth 1 -type d -print0)

  # 2) 文件处理
  while IFS= read -r -d '' src_file; do
    local src_dirname rel_dir base sanitized_rel_dir dst_dir dst_file

    case "$src_file" in
      */#recycle/*|*/@eaDir/*)
        continue
        ;;
    esac

    if is_subpath "$src_file" "$dst"; then
      continue
    fi

    if is_subpath "$src_file" "$log_dir"; then
      continue
    fi

    base="${src_file##*/}"
    src_dirname="${src_file%/*}"

    if [ "$src_dirname" = "$src" ]; then
      rel_dir=""
    else
      rel_dir="${src_dirname#"$src"/}"
      if [ "$rel_dir" = "$src_dirname" ]; then
        rel_dir=""
      fi
    fi

    if [ -n "$rel_dir" ]; then
      sanitized_rel_dir="$(build_sanitized_rel_path "$rel_dir")"
      dst_dir="$dst$sanitized_rel_dir"
    else
      dst_dir="$dst"
    fi

    mkdir -p "$dst_dir"
    dst_file="$dst_dir/$(safe_target_name "$base")"

    if [ -e "$dst_file" ]; then
      if same_inode "$src_file" "$dst_file"; then
        log "已存在且同 inode,跳过:$src_file -> $dst_file"
      elif same_value "$src_file" "$dst_file"; then
        log "内容一致,跳过:$src_file -> $dst_file"
      else
        rm -f -- "$dst_file"
        if ln "$src_file" "$dst_file" 2>>"$LOG_FILE"; then
          log "内容变化,重建硬链接:$src_file -> $dst_file"
        else
          log "重建硬链接失败:$src_file -> $dst_file"
        fi
      fi
    else
      if ln "$src_file" "$dst_file" 2>>"$LOG_FILE"; then
        log "新建硬链接:$src_file -> $dst_file"
      else
        log "创建硬链接失败:$src_file -> $dst_file"
      fi
    fi

    printf '%s\t%s\n' "$src_file" "$dst_file" >> "$current_list"
  done < <(find "$src" -type f -print0)

  sort -u "$current_list" -o "$current_list"

  # 关键修正:先写 state,再做清理
  cp -f "$current_list" "$state_file_tmp"
  mv -f "$state_file_tmp" "$state_file"

  cleanup_deleted_targets "$prev_tmp" "$current_list" "$dst" "$src"

  rm -f "$current_list" "$prev_tmp"

  log "处理完成:SRC=$src DST=$dst"
}

########################################
# 主流程
########################################

log "========== 脚本开始 =========="

for pair in "${FOLDER_PAIRS[@]}"; do
  SRC_DIR="${pair%%|*}"
  DST_DIR="${pair#*|}"

  SRC_DIR="${SRC_DIR%/}"
  DST_DIR="${DST_DIR%/}"

  case "$DST_DIR" in
    ""|"/"|"/volume1"|"/volume2"|"/volume3"|"/volume4")
      log "跳过危险目标目录配置:$pair"
      continue
      ;;
  esac

  if [ -z "$SRC_DIR" ] || [ -z "$DST_DIR" ] || [ "$SRC_DIR" = "$DST_DIR" ]; then
    log "跳过无效配置:$pair"
    continue
  fi

  process_pair "$SRC_DIR" "$DST_DIR"
done

log "========== 脚本结束 =========="
原来博客不止我一个人看呀
本博客已稳定运行
发表了11篇文章 · 总计14.72k字
总阅读次 您是第个小伙伴 萌ICP备20250969号