449 lines
16 KiB
Python
Executable File
449 lines
16 KiB
Python
Executable File
import os
|
||
import re
|
||
import json
|
||
import argparse
|
||
import shutil
|
||
from datetime import datetime
|
||
|
||
LOG_FILE = "./rename_log.txt"
|
||
ERR_REPORT_JSON = "./report_err_only.json"
|
||
|
||
SKIP_DIRS = [
|
||
"@eaDir",
|
||
]
|
||
SKIP_FILES = {
|
||
".DS_Store",
|
||
"Thumbs.db",
|
||
"desktop.ini",
|
||
}
|
||
|
||
FULL_ALLOW_EXT = {".mkv", ".mp4", ".mov", ".srt", ".ass", ".sub", ".strm", ".flv", ".avi", ".wmv", ".ts", ".rmvb", ".webm", ".mp3", ".flac", ".aac", ".wav", ".ogg", ".m4a", ".wma", ".alac", ".rm", ".iso"}
|
||
STRM_ONLY_EXT = {".strm", ".srt", ".ass", ".sub"}
|
||
MAX_FILENAME_LEN = 245
|
||
|
||
def full2half(s: str) -> str:
|
||
res = []
|
||
for ch in s:
|
||
oc = ord(ch)
|
||
if oc == 12288:
|
||
res.append(chr(32))
|
||
elif 65281 <= oc <= 65374:
|
||
res.append(chr(oc - 65248))
|
||
else:
|
||
res.append(ch)
|
||
return "".join(res)
|
||
|
||
def remove_emoji(s: str) -> str:
|
||
emoji_pattern = re.compile("["
|
||
u"\U0001F600-\U0001F64F"
|
||
u"\U0001F300-\U0001F5FF"
|
||
u"\U0001F680-\U0001F6FF"
|
||
u"\U0001F1E0-\U0001F1FF"
|
||
u"\U00002700-\U000027BF"
|
||
u"\U0001F900-\U0001F9FF"
|
||
"]+", flags=re.UNICODE)
|
||
return emoji_pattern.sub(r'', s)
|
||
|
||
def post_clean_dot(name: str, is_dir: bool=False) -> str:
|
||
"""后置净化:清除连续点、首尾点、首尾下划线;区分目录/文件,保护扩展名"""
|
||
if is_dir:
|
||
body = name
|
||
ext = ""
|
||
else:
|
||
body, ext = os.path.splitext(name)
|
||
body = re.sub(r"\.+", ".", body)
|
||
body = body.strip("._")
|
||
return body + ext
|
||
|
||
def stage1_light_clean(name: str, is_dir: bool=False) -> str:
|
||
"""阶段一:轻清洗"""
|
||
n = name
|
||
n = remove_emoji(n)
|
||
n = full2half(n)
|
||
n = re.sub(r"^[A-Za-z] +", "", n)
|
||
n = re.sub(r"\s+", "", n)
|
||
n = post_clean_dot(n, is_dir=is_dir)
|
||
return n.strip()
|
||
|
||
def run_rule_list(orig_name: str, rules: list, is_dir: bool=False):
|
||
hit_ids = []
|
||
cur = orig_name
|
||
safeguard_trigger = False
|
||
|
||
for r in sorted(rules, key=lambda x: x.get("order", 9999)):
|
||
rid = r["rule_id"]
|
||
kind = r["kind"]
|
||
repl = r["repl"]
|
||
safe_guard = r.get("safe_guard_empty", False)
|
||
rule_flags = r.get("flags", "")
|
||
prev = cur
|
||
|
||
re_flags = 0
|
||
if rule_flags == "IGNORECASE":
|
||
re_flags = re.IGNORECASE
|
||
|
||
if kind == "string":
|
||
cur = re.sub(r["pattern"], repl, cur, flags=re_flags)
|
||
elif kind == "bracket_pair":
|
||
o_pat = r["open"]
|
||
c_pat = r["close"]
|
||
# bracket_pair额外开启DOTALL,.可匹配中文、各类符号
|
||
while True:
|
||
new_val = re.sub(o_pat + ".*?" + c_pat, repl, cur, flags=re_flags | re.DOTALL)
|
||
if new_val == cur:
|
||
break
|
||
cur = new_val
|
||
if safe_guard:
|
||
body, ext = (cur, "") if is_dir else os.path.splitext(cur)
|
||
if len(body.strip()) == 0:
|
||
cur = prev
|
||
continue
|
||
|
||
cur = cur.strip()
|
||
if safe_guard:
|
||
body, ext = (cur, "") if is_dir else os.path.splitext(cur)
|
||
if len(body.strip()) == 0:
|
||
cur = prev
|
||
continue
|
||
|
||
if cur != prev:
|
||
hit_ids.append(rid)
|
||
|
||
# 全部规则跑完,全局兜底保护
|
||
body_final, ext_final = (cur, "") if is_dir else os.path.splitext(cur)
|
||
if len(body_final.strip()) == 0:
|
||
safeguard_trigger = True
|
||
cur = orig_name
|
||
|
||
# 符号净化
|
||
cur = post_clean_dot(cur, is_dir=is_dir)
|
||
|
||
# 净化之后再次兜底
|
||
body2, ext2 = (cur, "") if is_dir else os.path.splitext(cur)
|
||
if len(body2.strip()) == 0:
|
||
safeguard_trigger = True
|
||
cur = orig_name
|
||
|
||
return cur, hit_ids, safeguard_trigger
|
||
|
||
def assign_unique_name_group(item_list):
|
||
counter = dict()
|
||
out = []
|
||
for it in item_list:
|
||
cand = it["candidate"]
|
||
cnt = counter.get(cand, 0)
|
||
final = cand if cnt == 0 else f"{cand}({cnt})"
|
||
counter[cand] = cnt + 1
|
||
ni = dict(it)
|
||
ni["final_step1"] = final
|
||
out.append(ni)
|
||
return out
|
||
|
||
def assign_dup_suffix(item_list, name_key: str):
|
||
counter = dict()
|
||
for it in item_list:
|
||
nm = it[name_key]
|
||
c = counter.get(nm, 0)
|
||
if c > 0:
|
||
new_nm = f"{nm}_dup({c})"
|
||
it[name_key] = new_nm
|
||
it["step2_dup_warn"] = True
|
||
else:
|
||
it["step2_dup_warn"] = False
|
||
counter[nm] = c + 1
|
||
|
||
def write_log(msg: str, log_file: str):
|
||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
line = f"[{now}] {msg}"
|
||
print(line)
|
||
try:
|
||
with open(log_file, "a", encoding="utf-8") as f:
|
||
f.write(line + "\n")
|
||
f.flush()
|
||
except Exception:
|
||
print(f"[LOG-WRITE-FAILED] {line}")
|
||
|
||
def scan_full_tree(root_src, skip_dirs, skip_files, allow_ext):
|
||
dir_entries = []
|
||
file_entries = []
|
||
root_abs = os.path.abspath(root_src)
|
||
for src_root, dirs, files in os.walk(root_src, topdown=True):
|
||
remove_idx = []
|
||
for idx, dname in enumerate(dirs):
|
||
if dname in skip_dirs:
|
||
remove_idx.append(idx)
|
||
for idx in reversed(remove_idx):
|
||
del dirs[idx]
|
||
dirs[:] = dirs
|
||
src_root_abs = os.path.abspath(src_root)
|
||
if src_root_abs != root_abs:
|
||
bn = os.path.basename(src_root_abs)
|
||
c1 = stage1_light_clean(bn, is_dir=True)
|
||
dir_entries.append({
|
||
"src_abs": src_root_abs,
|
||
"src_parent_abs": os.path.dirname(src_root_abs),
|
||
"depth": src_root_abs.count(os.sep),
|
||
"orig_basename": bn,
|
||
"candidate": c1
|
||
})
|
||
for fname in files:
|
||
if fname in skip_files:
|
||
continue
|
||
ext = os.path.splitext(fname)[1].lower()
|
||
if ext not in allow_ext:
|
||
continue
|
||
c1 = stage1_light_clean(fname, is_dir=False)
|
||
file_entries.append({
|
||
"src_parent_abs": src_root_abs,
|
||
"orig_basename": fname,
|
||
"candidate": c1
|
||
})
|
||
return root_abs, dir_entries, file_entries
|
||
|
||
def group_by_parent_abs(item_list, key_name):
|
||
groups = dict()
|
||
for it in item_list:
|
||
k = it[key_name]
|
||
if k not in groups:
|
||
groups[k] = []
|
||
groups[k].append(it)
|
||
return groups
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="两阶段媒体清洗【修复括号匹配,DOTALL开启】")
|
||
parser.add_argument("--dir", required=True)
|
||
parser.add_argument("--output", required=True)
|
||
parser.add_argument("--apply", action="store_true")
|
||
parser.add_argument("--dry-run", action="store_true")
|
||
parser.add_argument("--strm-mode", action="store_true")
|
||
parser.add_argument("--rules", default=None, help="外部json规则文件路径")
|
||
parser.add_argument("--log", default=LOG_FILE)
|
||
args = parser.parse_args()
|
||
|
||
target_dir = args.dir
|
||
out_dir = args.output
|
||
log_path = args.log
|
||
rules_path = args.rules
|
||
|
||
DRY_RUN = True
|
||
if args.apply:
|
||
DRY_RUN = False
|
||
if args.dry_run:
|
||
DRY_RUN = True
|
||
|
||
if args.strm_mode:
|
||
ALLOW_EXT = STRM_ONLY_EXT
|
||
mode_info = "STRM模式【仅strm+字幕】"
|
||
else:
|
||
ALLOW_EXT = FULL_ALLOW_EXT
|
||
mode_info = "完整媒体模式"
|
||
|
||
if not os.path.isdir(target_dir):
|
||
print(f"源目录不存在:{target_dir}")
|
||
return
|
||
|
||
try:
|
||
with open(log_path, "w", encoding="utf-8") as f:
|
||
f.flush()
|
||
except Exception as e:
|
||
print(f"日志初始化警告 {e}")
|
||
|
||
file_rules = []
|
||
dir_rules = []
|
||
if rules_path is not None and os.path.exists(rules_path):
|
||
try:
|
||
with open(rules_path, "r", encoding="utf-8") as f:
|
||
jdata = json.load(f)
|
||
file_rules = jdata.get("file_rules", [])
|
||
dir_rules = jdata.get("dir_rules", [])
|
||
write_log(f"成功加载外部规则文件 {rules_path},file_rules={len(file_rules)},dir_rules={len(dir_rules)}", log_path)
|
||
except json.JSONDecodeError as e:
|
||
write_log(f"规则文件JSON格式错误(第{e.lineno}行第{e.colno}列):{e.msg}", log_path)
|
||
write_log("提示:键和字符串值必须用英文双引号,正则中的反斜杠必须写成双反斜杠\\\\", log_path)
|
||
return
|
||
except Exception as e:
|
||
write_log(f"加载规则文件失败:{e}", log_path)
|
||
return
|
||
else:
|
||
write_log("未提供--rules或文件不存在,跳过第二阶段深度清洗", log_path)
|
||
|
||
write_log("===== 两阶段媒体清洗【修复括号匹配DOTALL】 =====", log_path)
|
||
write_log(f"源目录:{target_dir}", log_path)
|
||
write_log(f"输出:{out_dir}", log_path)
|
||
write_log(f"模式:{mode_info} DRY_RUN={DRY_RUN}", log_path)
|
||
|
||
stat = {
|
||
"src_dir_total":0,
|
||
"src_file_total":0,
|
||
"dir_success":0,
|
||
"dir_fail":0,
|
||
"file_success":0,
|
||
"file_fail":0
|
||
}
|
||
report_entries = []
|
||
|
||
root_src_abs, dir_entries, file_entries = scan_full_tree(target_dir, SKIP_DIRS, SKIP_FILES, ALLOW_EXT)
|
||
stat["src_dir_total"] = len(dir_entries)
|
||
stat["src_file_total"] = len(file_entries)
|
||
write_log(f"采集统计:目录节点 {stat['src_dir_total']},媒体文件 {stat['src_file_total']}", log_path)
|
||
|
||
dir_groups = group_by_parent_abs(dir_entries, "src_parent_abs")
|
||
dir_final = []
|
||
for p_abs, items in dir_groups.items():
|
||
assigned = assign_unique_name_group(items)
|
||
for it in assigned:
|
||
step1_name = it["final_step1"]
|
||
if len(dir_rules) > 0:
|
||
step2_name, hit_rids, safe_trig = run_rule_list(step1_name, dir_rules, is_dir=True)
|
||
else:
|
||
step2_name = step1_name
|
||
hit_rids = []
|
||
safe_trig = False
|
||
it["step2_name"] = step2_name
|
||
it["hit_rule_ids"] = hit_rids
|
||
it["safe_guard_trigger"] = safe_trig
|
||
dir_final.append(it)
|
||
dir_g2_groups = group_by_parent_abs(dir_final, "src_parent_abs")
|
||
for _, items in dir_g2_groups.items():
|
||
assign_dup_suffix(items, "step2_name")
|
||
|
||
file_groups = group_by_parent_abs(file_entries, "src_parent_abs")
|
||
file_final = []
|
||
for p_abs, items in file_groups.items():
|
||
assigned = assign_unique_name_group(items)
|
||
for it in assigned:
|
||
step1_name = it["final_step1"]
|
||
if len(file_rules) > 0:
|
||
step2_name, hit_rids, safe_trig = run_rule_list(step1_name, file_rules, is_dir=False)
|
||
else:
|
||
step2_name = step1_name
|
||
hit_rids = []
|
||
safe_trig = False
|
||
it["step2_name"] = step2_name
|
||
it["hit_rule_ids"] = hit_rids
|
||
it["safe_guard_trigger"] = safe_trig
|
||
file_final.append(it)
|
||
file_g2_groups = group_by_parent_abs(file_final, "src_parent_abs")
|
||
for _, items in file_g2_groups.items():
|
||
assign_dup_suffix(items, "step2_name")
|
||
|
||
src_to_out = dict()
|
||
src_to_out[root_src_abs] = out_dir
|
||
dir_final_sorted = sorted(dir_final, key=lambda x: x["depth"])
|
||
for d in dir_final_sorted:
|
||
p_abs = d["src_parent_abs"]
|
||
parent_out = src_to_out.get(p_abs)
|
||
if parent_out is None:
|
||
write_log(f"【警告】父目录映射缺失:{p_abs}", log_path)
|
||
continue
|
||
child_out = os.path.join(parent_out, d["step2_name"])
|
||
src_to_out[d["src_abs"]] = child_out
|
||
|
||
for d in dir_final:
|
||
report_entries.append({
|
||
"type":"dir",
|
||
"src_abs":d["src_abs"],
|
||
"orig_basename":d["orig_basename"],
|
||
"step1_name":d["final_step1"],
|
||
"step2_name":d["step2_name"],
|
||
"hit_rule_ids":d["hit_rule_ids"],
|
||
"safe_guard_trigger":d["safe_guard_trigger"],
|
||
"step2_dup_warn":d.get("step2_dup_warn", False),
|
||
"final_out_path": src_to_out.get(d["src_abs"], "")
|
||
})
|
||
for f in file_final:
|
||
src_file = os.path.join(f["src_parent_abs"], f["orig_basename"])
|
||
parent_out = src_to_out.get(f["src_parent_abs"])
|
||
dst_file = os.path.join(parent_out, f["step2_name"]) if parent_out else ""
|
||
report_entries.append({
|
||
"type":"file",
|
||
"src_abs":src_file,
|
||
"orig_basename":f["orig_basename"],
|
||
"step1_name":f["final_step1"],
|
||
"step2_name":f["step2_name"],
|
||
"hit_rule_ids":f["hit_rule_ids"],
|
||
"safe_guard_trigger":f["safe_guard_trigger"],
|
||
"step2_dup_warn":f.get("step2_dup_warn", False),
|
||
"final_out_path": dst_file
|
||
})
|
||
|
||
# 仅输出异常报告,丢弃完整大JSON
|
||
err_list = []
|
||
for entry in report_entries:
|
||
dup = entry.get("step2_dup_warn", False)
|
||
safe = entry.get("safe_guard_trigger", False)
|
||
if dup or safe:
|
||
err_list.append({
|
||
"type": entry.get("type"),
|
||
"orig_basename": entry.get("orig_basename"),
|
||
"step1_name": entry.get("step1_name"),
|
||
"step2_name": entry.get("step2_name"),
|
||
"step2_dup_warn": dup,
|
||
"safe_guard_trigger": safe,
|
||
"hit_rule_ids": entry.get("hit_rule_ids", [])
|
||
})
|
||
try:
|
||
with open(ERR_REPORT_JSON, "w", encoding="utf-8") as fw:
|
||
json.dump(err_list, fw, ensure_ascii=False, indent=2)
|
||
write_log(f"异常报告已输出:{ERR_REPORT_JSON},异常条目数量:{len(err_list)}", log_path)
|
||
except Exception as e:
|
||
write_log(f"异常报告写入失败 {e}", log_path)
|
||
|
||
for ent in report_entries:
|
||
ot = ent["orig_basename"]
|
||
s1 = ent["step1_name"]
|
||
s2 = ent["step2_name"]
|
||
parts = []
|
||
if ot != s1:
|
||
parts.append(f"原始→step1:{ot} → {s1}")
|
||
if s1 != s2:
|
||
parts.append(f"step1→step2:{s1} → {s2}")
|
||
if ent["step2_dup_warn"]:
|
||
parts.append("[告警:step2重名追加_dup标记]")
|
||
if ent["safe_guard_trigger"]:
|
||
parts.append("[安全保护触发,放弃部分规则]")
|
||
if len(parts) > 0:
|
||
write_log(" | ".join(parts), log_path)
|
||
|
||
del report_entries
|
||
|
||
if DRY_RUN:
|
||
write_log("===== DRY-RUN预览模式结束,未执行磁盘IO =====", log_path)
|
||
return
|
||
|
||
for d in dir_final_sorted:
|
||
full_out = src_to_out.get(d["src_abs"])
|
||
if not full_out:
|
||
continue
|
||
try:
|
||
os.makedirs(full_out, exist_ok=True)
|
||
stat["dir_success"] +=1
|
||
except OSError as e:
|
||
stat["dir_fail"] +=1
|
||
write_log(f"【目录创建失败】{full_out} | {e}", log_path)
|
||
|
||
for f in file_final:
|
||
src_fp = os.path.join(f["src_parent_abs"], f["orig_basename"])
|
||
parent_out = src_to_out.get(f["src_parent_abs"])
|
||
if not parent_out:
|
||
stat["file_fail"] +=1
|
||
write_log(f"【文件复制失败】父目录映射缺失:{src_fp}", log_path)
|
||
continue
|
||
dst_fp = os.path.join(parent_out, f["step2_name"])
|
||
try:
|
||
shutil.copy2(src_fp, dst_fp)
|
||
stat["file_success"] +=1
|
||
except OSError as e:
|
||
stat["file_fail"] +=1
|
||
write_log(f"【文件复制失败】src:{src_fp} dst:{dst_fp} | {e}", log_path)
|
||
|
||
write_log("----------任务统计汇总----------", log_path)
|
||
write_log(f"源采集目录节点总数量:{stat['src_dir_total']},源媒体文件总数量:{stat['src_file_total']}", log_path)
|
||
write_log(f"目录:成功 {stat['dir_success']},失败 {stat['dir_fail']}", log_path)
|
||
write_log(f"文件:成功 {stat['file_success']},失败 {stat['file_fail']}", log_path)
|
||
write_log(f"【校验】预期目录输出={stat['src_dir_total']},实际成功={stat['dir_success']}", log_path)
|
||
write_log(f"【校验】预期文件输出={stat['src_file_total']},实际成功={stat['file_success']}", log_path)
|
||
write_log("============================================================", log_path)
|
||
|
||
if __name__ == "__main__":
|
||
main() |