deepclean-mydisk 0.0.0-stage → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +354 -2
- package/assets/icon-180.png +0 -0
- package/cordis.patch.yml +5 -0
- package/docs/plugin/DETAIL.en.md +58 -0
- package/docs/plugin/DETAIL.zh.md +58 -0
- package/docs/plugin/shot-clean.jpg +0 -0
- package/docs/plugin/shot-home.jpg +0 -0
- package/docs/plugin/shot-move.jpg +0 -0
- package/docs/plugin/shot-space.jpg +0 -0
- package/lib/clean.js +537 -0
- package/lib/client.js +2625 -0
- package/lib/index.js +1944 -0
- package/lib/platform.js +29 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +67 -4
- package/resources/LICENSE-phosphor.txt +21 -0
- package/resources/images/disk.png +0 -0
- package/resources/rules.json +139 -0
- package/resources/scripts/deepclean.py +109 -0
- package/resources/scripts/photos-windows.ps1 +77 -0
- package/resources/scripts/photos.py +268 -0
- package/resources/scripts/platforms/windows.py +940 -0
- package/resources/scripts/scan.py +949 -0
- package/resources/scripts/tree.py +218 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""相似照片扫描(dHash)——只读分析,供插件「相似照片」页面展示与清理。
|
|
3
|
+
|
|
4
|
+
实现:系统解码器将图片缩成 9x8 → 灰度像素 → dHash64 → 汉明距离分组。
|
|
5
|
+
不依赖第三方库;macOS 用 sips,Windows 用 PowerShell/.NET 系统图片解码器。
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import struct
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
EXTS = {".jpg", ".jpeg", ".png", ".heic", ".heif", ".gif", ".webp", ".bmp", ".tiff"}
|
|
18
|
+
SKIP_DIRS = {"node_modules", ".git", ".Trash", "Library", "Photos Library.photoslibrary"}
|
|
19
|
+
PACKAGE_SUFFIXES = (
|
|
20
|
+
".photoslibrary", ".photolibrary", ".sparsebundle", ".fcpbundle", ".logicx",
|
|
21
|
+
".band", ".imovielibrary", ".tvlibrary", ".aplibrary",
|
|
22
|
+
)
|
|
23
|
+
CLOUD_ATTRS = 0x400000 | 0x40000 | 0x1000 # RECALL_ON_DATA_ACCESS | RECALL_ON_OPEN | OFFLINE
|
|
24
|
+
CLOUD_DIR_NAMES = ("onedrive", "dropbox", "google drive", "icloud drive")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cloud_roots():
|
|
28
|
+
"""云盘根:照搬 tree.py 的规则(平台固定位置 + 环境变量 + DEEPCLEAN_CLOUD_ROOTS)。"""
|
|
29
|
+
roots = []
|
|
30
|
+
home = Path.home()
|
|
31
|
+
if sys.platform == "darwin":
|
|
32
|
+
roots += [home / "Library" / "CloudStorage", home / "Library" / "Mobile Documents"]
|
|
33
|
+
else:
|
|
34
|
+
for name in ("OneDrive", "OneDriveConsumer", "OneDriveCommercial"):
|
|
35
|
+
value = os.environ.get(name)
|
|
36
|
+
if value:
|
|
37
|
+
roots.append(Path(value))
|
|
38
|
+
try:
|
|
39
|
+
for child in home.iterdir():
|
|
40
|
+
if child.name.lower().startswith(CLOUD_DIR_NAMES):
|
|
41
|
+
roots.append(child)
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
extra = os.environ.get("DEEPCLEAN_CLOUD_ROOTS")
|
|
45
|
+
if extra:
|
|
46
|
+
try:
|
|
47
|
+
roots += [Path(p) for p in json.loads(extra) if isinstance(p, str)]
|
|
48
|
+
except (ValueError, TypeError):
|
|
49
|
+
pass
|
|
50
|
+
out = []
|
|
51
|
+
for root in roots:
|
|
52
|
+
try:
|
|
53
|
+
out.append(str(root.resolve()))
|
|
54
|
+
except OSError:
|
|
55
|
+
continue
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_cloud_path(path, roots):
|
|
60
|
+
try:
|
|
61
|
+
target = os.path.normcase(str(Path(path).resolve()))
|
|
62
|
+
except OSError:
|
|
63
|
+
return False
|
|
64
|
+
for root in roots:
|
|
65
|
+
base = os.path.normcase(str(Path(root).resolve()))
|
|
66
|
+
if target == base or target.startswith(base + os.sep):
|
|
67
|
+
return True
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_package_dir(name):
|
|
72
|
+
low = name.lower()
|
|
73
|
+
return any(low.endswith(suffix) for suffix in PACKAGE_SUFFIXES)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _progress(pct, label):
|
|
77
|
+
try:
|
|
78
|
+
print(f"PROGRESS {pct} {label}", file=sys.stderr, flush=True)
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def bmp_gray(path):
|
|
84
|
+
"""读 sips 产出的 9x8 BMP,返回 9x8 灰度(0-255)。"""
|
|
85
|
+
b = open(path, "rb").read()
|
|
86
|
+
if b[:2] != b"BM":
|
|
87
|
+
raise ValueError("not BMP")
|
|
88
|
+
off = struct.unpack_from("<I", b, 10)[0]
|
|
89
|
+
w, h = struct.unpack_from("<ii", b, 18)
|
|
90
|
+
bpp = struct.unpack_from("<H", b, 28)[0]
|
|
91
|
+
if w < 2 or abs(h) < 1 or bpp not in (24, 32):
|
|
92
|
+
raise ValueError(f"bad BMP w={w} h={h} bpp={bpp}")
|
|
93
|
+
top_down = h < 0
|
|
94
|
+
rows = abs(h)
|
|
95
|
+
step = bpp // 8
|
|
96
|
+
stride = ((w * bpp + 31) // 32) * 4
|
|
97
|
+
gray = []
|
|
98
|
+
for y in range(rows):
|
|
99
|
+
row = y if top_down else rows - 1 - y
|
|
100
|
+
base = off + row * stride
|
|
101
|
+
line = []
|
|
102
|
+
for x in range(w):
|
|
103
|
+
i = base + x * step
|
|
104
|
+
blue, green, red = b[i], b[i + 1], b[i + 2]
|
|
105
|
+
line.append(int(0.299 * red + 0.587 * green + 0.114 * blue))
|
|
106
|
+
gray.append(line)
|
|
107
|
+
return gray
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def windows_command(mode):
|
|
111
|
+
if mode not in ("hash", "thumbnail"):
|
|
112
|
+
raise ValueError("Unknown image operation")
|
|
113
|
+
helper = Path(__file__).with_name("photos-windows.ps1")
|
|
114
|
+
command = "& {\n" + helper.read_text(encoding="utf-8") + "\n} -Mode " + mode
|
|
115
|
+
import base64
|
|
116
|
+
encoded = base64.b64encode(command.encode("utf-16-le")).decode("ascii")
|
|
117
|
+
return ["powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def windows_hashes(files):
|
|
121
|
+
"""Decode the batch in one PowerShell process; paths travel as UTF-8 JSON, not shell code."""
|
|
122
|
+
result = subprocess.run(
|
|
123
|
+
windows_command("hash"),
|
|
124
|
+
input=json.dumps([f["path"] for f in files], ensure_ascii=False),
|
|
125
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=540)
|
|
126
|
+
if result.returncode:
|
|
127
|
+
raise RuntimeError("Windows image decoder failed: " + result.stderr.strip()[:500])
|
|
128
|
+
rows = json.loads(result.stdout.lstrip("\ufeff"))
|
|
129
|
+
return {r["path"]: int(r["hash"], 16) for r in rows if r.get("hash")}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def dhash(path):
|
|
133
|
+
with tempfile.TemporaryDirectory(prefix="dc-photo-") as td:
|
|
134
|
+
out = os.path.join(td, "x.bmp")
|
|
135
|
+
subprocess.run(["sips", "-z", "8", "9", "-s", "format", "bmp", str(path), "--out", out],
|
|
136
|
+
capture_output=True, timeout=30, check=True)
|
|
137
|
+
g = bmp_gray(out)
|
|
138
|
+
bits = 0
|
|
139
|
+
for y in range(8):
|
|
140
|
+
for x in range(8):
|
|
141
|
+
bits = (bits << 1) | (1 if g[y][x] > g[y][x + 1] else 0)
|
|
142
|
+
return bits
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def hamming(a, b):
|
|
146
|
+
return bin(a ^ b).count("1")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def collect(dirs, min_kb, limit, stats=None):
|
|
150
|
+
"""收集候选图片;云盘目录与 App 包目录跳过(云端去重=删云端文件,包目录移动会破坏 App)。"""
|
|
151
|
+
stats = stats if stats is not None else {"cloud": 0, "package": 0}
|
|
152
|
+
clouds = cloud_roots()
|
|
153
|
+
files = []
|
|
154
|
+
for root in dirs:
|
|
155
|
+
root = Path(root)
|
|
156
|
+
if not root.is_dir():
|
|
157
|
+
continue
|
|
158
|
+
if is_cloud_path(root, clouds):
|
|
159
|
+
stats["cloud"] += 1
|
|
160
|
+
continue
|
|
161
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
162
|
+
kept = []
|
|
163
|
+
for d in dirnames:
|
|
164
|
+
if d in SKIP_DIRS or d.startswith("."):
|
|
165
|
+
continue
|
|
166
|
+
child = Path(dirpath) / d
|
|
167
|
+
if is_cloud_path(child, clouds):
|
|
168
|
+
stats["cloud"] += 1
|
|
169
|
+
continue
|
|
170
|
+
if is_package_dir(d):
|
|
171
|
+
stats["package"] += 1
|
|
172
|
+
continue
|
|
173
|
+
kept.append(d)
|
|
174
|
+
dirnames[:] = kept
|
|
175
|
+
for name in filenames:
|
|
176
|
+
if Path(name).suffix.lower() not in EXTS:
|
|
177
|
+
continue
|
|
178
|
+
p = Path(dirpath) / name
|
|
179
|
+
try:
|
|
180
|
+
st = p.stat()
|
|
181
|
+
except OSError:
|
|
182
|
+
continue
|
|
183
|
+
if getattr(st, "st_file_attributes", 0) & CLOUD_ATTRS:
|
|
184
|
+
stats["cloud"] += 1
|
|
185
|
+
continue
|
|
186
|
+
if st.st_size < min_kb * 1024:
|
|
187
|
+
continue
|
|
188
|
+
files.append({"path": str(p), "kb": st.st_size // 1024, "mtime": int(st.st_mtime), "size": st.st_size})
|
|
189
|
+
files.sort(key=lambda f: -f["size"])
|
|
190
|
+
return files[:limit], stats
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main():
|
|
194
|
+
ap = argparse.ArgumentParser()
|
|
195
|
+
ap.add_argument("--json", required=True)
|
|
196
|
+
ap.add_argument("--limit", type=int, default=600)
|
|
197
|
+
ap.add_argument("--min-kb", type=int, default=200)
|
|
198
|
+
ap.add_argument("--home")
|
|
199
|
+
args = ap.parse_args()
|
|
200
|
+
home = Path(args.home).expanduser() if args.home else Path.home()
|
|
201
|
+
dirs = [home / "Pictures", home / "Downloads", home / "Desktop", home / "Documents"]
|
|
202
|
+
t0 = time.time()
|
|
203
|
+
_progress(5, "查找图片")
|
|
204
|
+
files, stats = collect(dirs, args.min_kb, args.limit)
|
|
205
|
+
total = len(files)
|
|
206
|
+
_progress(15, f"计算指纹(0/{total})")
|
|
207
|
+
if sys.platform == "win32" and files:
|
|
208
|
+
hashes = windows_hashes(files)
|
|
209
|
+
for f in files:
|
|
210
|
+
f["hash"] = hashes.get(f["path"])
|
|
211
|
+
else:
|
|
212
|
+
for i, f in enumerate(files):
|
|
213
|
+
try:
|
|
214
|
+
f["hash"] = dhash(f["path"])
|
|
215
|
+
except (FileNotFoundError, PermissionError):
|
|
216
|
+
raise RuntimeError("Image decoder unavailable: sips")
|
|
217
|
+
except (OSError, ValueError, struct.error, subprocess.SubprocessError):
|
|
218
|
+
f["hash"] = None
|
|
219
|
+
if i % 25 == 0:
|
|
220
|
+
_progress(15 + int(70 * i / max(1, total)), f"计算指纹({i}/{total})")
|
|
221
|
+
good = [f for f in files if f["hash"] is not None]
|
|
222
|
+
if files and not good:
|
|
223
|
+
raise RuntimeError("No images could be decoded; check image formats and the system decoder")
|
|
224
|
+
_progress(88, "分组相似照片")
|
|
225
|
+
parent = list(range(len(good)))
|
|
226
|
+
|
|
227
|
+
def find(x):
|
|
228
|
+
while parent[x] != x:
|
|
229
|
+
parent[x] = parent[parent[x]]
|
|
230
|
+
x = parent[x]
|
|
231
|
+
return x
|
|
232
|
+
|
|
233
|
+
def union(a, b):
|
|
234
|
+
ra, rb = find(a), find(b)
|
|
235
|
+
if ra != rb:
|
|
236
|
+
parent[rb] = ra
|
|
237
|
+
|
|
238
|
+
for i in range(len(good)):
|
|
239
|
+
for j in range(i + 1, len(good)):
|
|
240
|
+
if hamming(good[i]["hash"], good[j]["hash"]) <= 6:
|
|
241
|
+
union(i, j)
|
|
242
|
+
groups = {}
|
|
243
|
+
for i, f in enumerate(good):
|
|
244
|
+
groups.setdefault(find(i), []).append(f)
|
|
245
|
+
result = []
|
|
246
|
+
for gi, items in groups.items():
|
|
247
|
+
if len(items) < 2:
|
|
248
|
+
continue
|
|
249
|
+
items.sort(key=lambda x: -x["size"])
|
|
250
|
+
total_kb = sum(x["kb"] for x in items)
|
|
251
|
+
waste = total_kb - items[0]["kb"]
|
|
252
|
+
result.append({
|
|
253
|
+
"id": f"g{gi}",
|
|
254
|
+
"items": [{k: it[k] for k in ("path", "kb", "mtime", "size")} for it in items],
|
|
255
|
+
"totalKb": total_kb,
|
|
256
|
+
"wastedKb": waste,
|
|
257
|
+
})
|
|
258
|
+
result.sort(key=lambda g: -g["wastedKb"])
|
|
259
|
+
out = {"generatedAt": int(time.time() * 1000), "scanned": total,
|
|
260
|
+
"compared": len(good), "skipped": total - len(good), "groups": result,
|
|
261
|
+
"skippedCloud": stats.get("cloud", 0), "skippedPackages": stats.get("package", 0),
|
|
262
|
+
"elapsed": round(time.time() - t0, 1)}
|
|
263
|
+
Path(args.json).write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
264
|
+
_progress(100, "完成")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
main()
|