kba-cli 0.3.0rc4__py3-none-any.whl
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.
- kba/__init__.py +3 -0
- kba/__main__.py +6 -0
- kba/analyze.py +908 -0
- kba/cli.py +552 -0
- kba/doctor.py +73 -0
- kba/eval.py +332 -0
- kba/extract.py +511 -0
- kba/fingerprint.py +333 -0
- kba/jobs.py +69 -0
- kba/links.py +38 -0
- kba/pii.py +37 -0
- kba/report.py +565 -0
- kba/summary.py +55 -0
- kba/tags.py +108 -0
- kba/telemetry.py +78 -0
- kba/util.py +204 -0
- kba_cli-0.3.0rc4.dist-info/METADATA +135 -0
- kba_cli-0.3.0rc4.dist-info/RECORD +22 -0
- kba_cli-0.3.0rc4.dist-info/WHEEL +5 -0
- kba_cli-0.3.0rc4.dist-info/entry_points.txt +2 -0
- kba_cli-0.3.0rc4.dist-info/licenses/LICENSE +21 -0
- kba_cli-0.3.0rc4.dist-info/top_level.txt +1 -0
kba/extract.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Read-only text extraction for PDF/Office into .txt sidecars.
|
|
2
|
+
|
|
3
|
+
Sources are never modified. Sidecars are UTF-8 text at ``<out>/<rel>.txt`` so a
|
|
4
|
+
normal ``kba audit start --extract-dir <out>`` picks them up without changes.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import importlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from collections import Counter
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import util
|
|
20
|
+
|
|
21
|
+
logging.getLogger("pypdf").setLevel(logging.ERROR)
|
|
22
|
+
|
|
23
|
+
SCHEMA = "kba.extract/1"
|
|
24
|
+
EXTRACT_EXTS = util.EXTRACTABLE_EXTS
|
|
25
|
+
LEGACY_UNSUPPORTED = util.UNSUPPORTED_TEXT_EXTS
|
|
26
|
+
|
|
27
|
+
_FORMULA_STR = re.compile(r'"(?:[^"]|"")*"')
|
|
28
|
+
_FORMULA_CELL = re.compile(r"\$?[A-Za-z]{1,3}\$?\d+")
|
|
29
|
+
_FORMULA_NUM = re.compile(r"(?<![A-Za-z_])\d+(?:\.\d+)?")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load(name: str):
|
|
33
|
+
try:
|
|
34
|
+
return importlib.import_module(name)
|
|
35
|
+
except ImportError:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _missing(dep: str) -> dict:
|
|
40
|
+
return {"status": "failed", "text": "", "error": f"missing_dependency:{dep}", "xlsx_fp": None}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _failed(exc: Exception | str) -> dict:
|
|
44
|
+
reason = exc if isinstance(exc, str) else f"{type(exc).__name__}: {exc}"
|
|
45
|
+
return {"status": "failed", "text": "", "error": reason[:300], "xlsx_fp": None}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _finish(text: str, xlsx_fp: dict | None = None) -> dict:
|
|
49
|
+
text = (text or "").replace("\x00", "").strip()
|
|
50
|
+
if text:
|
|
51
|
+
return {"status": "extracted", "text": text, "error": "", "xlsx_fp": xlsx_fp}
|
|
52
|
+
return {"status": "no_text", "text": "", "error": "", "xlsx_fp": xlsx_fp}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _normalize_formula(formula: str) -> str:
|
|
56
|
+
s = _FORMULA_STR.sub('"S"', formula)
|
|
57
|
+
s = _FORMULA_CELL.sub("R", s)
|
|
58
|
+
s = _FORMULA_NUM.sub("N", s)
|
|
59
|
+
return re.sub(r"\s+", "", s)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _defined_names(wb) -> list[str]:
|
|
63
|
+
try:
|
|
64
|
+
dn = wb.defined_names
|
|
65
|
+
if hasattr(dn, "keys"):
|
|
66
|
+
return sorted(str(k) for k in dn.keys())
|
|
67
|
+
return sorted(str(x) for x in dn)
|
|
68
|
+
except Exception: # noqa: BLE001
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _compact_structure(sheets: list[dict], patterns: Counter, defined: list[str]) -> dict:
|
|
73
|
+
payload = {
|
|
74
|
+
"sheets": sheets,
|
|
75
|
+
"formula_cells": sum(patterns.values()),
|
|
76
|
+
"formula_patterns": dict(patterns.most_common(100)),
|
|
77
|
+
"defined_names": defined,
|
|
78
|
+
}
|
|
79
|
+
digest = hashlib.sha256(
|
|
80
|
+
json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
81
|
+
).hexdigest()
|
|
82
|
+
return {
|
|
83
|
+
"sha256": digest,
|
|
84
|
+
"sheets": [s["name"] for s in sheets],
|
|
85
|
+
"formula_cells": payload["formula_cells"],
|
|
86
|
+
"pattern_count": len(patterns),
|
|
87
|
+
"defined_names": defined,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def xlsx_structure(wb) -> dict:
|
|
92
|
+
"""Compact structural fingerprint: sheets, formula patterns, defined names."""
|
|
93
|
+
sheets = []
|
|
94
|
+
patterns: Counter = Counter()
|
|
95
|
+
try:
|
|
96
|
+
for ws in wb.worksheets:
|
|
97
|
+
pats: Counter = Counter()
|
|
98
|
+
for row in ws.iter_rows():
|
|
99
|
+
for cell in row:
|
|
100
|
+
value = cell.value
|
|
101
|
+
if isinstance(value, str) and value.startswith("="):
|
|
102
|
+
pats[_normalize_formula(value)] += 1
|
|
103
|
+
patterns.update(pats)
|
|
104
|
+
sheets.append({
|
|
105
|
+
"name": ws.title,
|
|
106
|
+
"max_row": int(ws.max_row or 0),
|
|
107
|
+
"max_column": int(ws.max_column or 0),
|
|
108
|
+
"formula_patterns": dict(pats.most_common(50)),
|
|
109
|
+
})
|
|
110
|
+
except Exception: # noqa: BLE001 - partial workbook is still useful
|
|
111
|
+
pass
|
|
112
|
+
return _compact_structure(sheets, patterns, _defined_names(wb))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _norm_edge(line: str) -> str:
|
|
116
|
+
s = re.sub(r"\d+", "#", line.strip().lower())
|
|
117
|
+
return re.sub(r"\s+", " ", s)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _strip_pdf_boilerplate(pages: list[str]) -> list[str]:
|
|
121
|
+
"""Remove repeated page headers/footers (only edge lines of each page)."""
|
|
122
|
+
pages = [p or "" for p in pages]
|
|
123
|
+
if len(pages) < 3:
|
|
124
|
+
return pages
|
|
125
|
+
edge_counts: Counter = Counter()
|
|
126
|
+
for page in pages:
|
|
127
|
+
lines = [ln.strip() for ln in page.splitlines() if ln.strip()]
|
|
128
|
+
# Count each edge line once per page; duplicated top/bottom lines are one vote.
|
|
129
|
+
edge_lines = list(dict.fromkeys(lines[:3] + lines[-3:]))
|
|
130
|
+
for ln in edge_lines:
|
|
131
|
+
edge_counts[_norm_edge(ln)] += 1
|
|
132
|
+
threshold = max(2, int(len(pages) * 0.6 + 0.999))
|
|
133
|
+
boiler = {line for line, count in edge_counts.items()
|
|
134
|
+
if count >= threshold and len(line) >= 3}
|
|
135
|
+
if not boiler:
|
|
136
|
+
return pages
|
|
137
|
+
cleaned = []
|
|
138
|
+
for page in pages:
|
|
139
|
+
lines = page.splitlines()
|
|
140
|
+
nonempty = [i for i, ln in enumerate(lines) if ln.strip()]
|
|
141
|
+
edge = set(nonempty[:3] + nonempty[-3:])
|
|
142
|
+
keep = [ln for i, ln in enumerate(lines)
|
|
143
|
+
if not (i in edge and _norm_edge(ln) in boiler)]
|
|
144
|
+
cleaned.append("\n".join(keep).strip())
|
|
145
|
+
return cleaned
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _extract_pdf(path: Path) -> dict:
|
|
149
|
+
pypdf = _load("pypdf")
|
|
150
|
+
if pypdf is None:
|
|
151
|
+
return _missing("pypdf")
|
|
152
|
+
try:
|
|
153
|
+
reader = pypdf.PdfReader(str(path))
|
|
154
|
+
if getattr(reader, "is_encrypted", False):
|
|
155
|
+
try:
|
|
156
|
+
if reader.decrypt("") == 0:
|
|
157
|
+
return _failed("encrypted")
|
|
158
|
+
except Exception: # noqa: BLE001
|
|
159
|
+
return _failed("encrypted")
|
|
160
|
+
pages = []
|
|
161
|
+
for page in reader.pages:
|
|
162
|
+
try:
|
|
163
|
+
pages.append(page.extract_text() or "")
|
|
164
|
+
except Exception: # noqa: BLE001 - keep other pages
|
|
165
|
+
pages.append("")
|
|
166
|
+
cleaned = [p.strip() for p in _strip_pdf_boilerplate(pages) if p.strip()]
|
|
167
|
+
return _finish("\f".join(cleaned))
|
|
168
|
+
except Exception as exc: # noqa: BLE001
|
|
169
|
+
return _failed(exc)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _extract_docx(path: Path) -> dict:
|
|
173
|
+
docx = _load("docx")
|
|
174
|
+
if docx is None:
|
|
175
|
+
return _missing("python-docx")
|
|
176
|
+
try:
|
|
177
|
+
doc = docx.Document(str(path))
|
|
178
|
+
parts: list[str] = []
|
|
179
|
+
for p in doc.paragraphs:
|
|
180
|
+
t = (p.text or "").strip()
|
|
181
|
+
if t:
|
|
182
|
+
parts.append(t)
|
|
183
|
+
for table in doc.tables:
|
|
184
|
+
for row in table.rows:
|
|
185
|
+
cells = [(c.text or "").strip().replace("\n", " ") for c in row.cells]
|
|
186
|
+
if any(cells):
|
|
187
|
+
parts.append("\t".join(cells))
|
|
188
|
+
for section in doc.sections:
|
|
189
|
+
for hf in (section.header, section.footer):
|
|
190
|
+
for p in hf.paragraphs:
|
|
191
|
+
t = (p.text or "").strip()
|
|
192
|
+
if t:
|
|
193
|
+
parts.append(t)
|
|
194
|
+
return _finish("\n\n".join(parts))
|
|
195
|
+
except Exception as exc: # noqa: BLE001
|
|
196
|
+
return _failed(exc)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _extract_pptx(path: Path) -> dict:
|
|
200
|
+
pptx = _load("pptx")
|
|
201
|
+
if pptx is None:
|
|
202
|
+
return _missing("python-pptx")
|
|
203
|
+
try:
|
|
204
|
+
prs = pptx.Presentation(str(path))
|
|
205
|
+
slides: list[str] = []
|
|
206
|
+
for slide in prs.slides:
|
|
207
|
+
parts: list[str] = []
|
|
208
|
+
for shape in slide.shapes:
|
|
209
|
+
if getattr(shape, "has_text_frame", False):
|
|
210
|
+
t = (shape.text_frame.text or "").strip()
|
|
211
|
+
if t:
|
|
212
|
+
parts.append(t)
|
|
213
|
+
if getattr(shape, "has_table", False):
|
|
214
|
+
for row in shape.table.rows:
|
|
215
|
+
cells = [(c.text or "").strip().replace("\n", " ") for c in row.cells]
|
|
216
|
+
if any(cells):
|
|
217
|
+
parts.append("\t".join(cells))
|
|
218
|
+
try:
|
|
219
|
+
notes = slide.notes_slide.notes_text_frame.text.strip()
|
|
220
|
+
if notes:
|
|
221
|
+
parts.append(notes)
|
|
222
|
+
except Exception: # noqa: BLE001 - notes are optional
|
|
223
|
+
pass
|
|
224
|
+
slides.append("\n\n".join(parts))
|
|
225
|
+
return _finish("\f".join(s for s in slides if s.strip()))
|
|
226
|
+
except Exception as exc: # noqa: BLE001
|
|
227
|
+
return _failed(exc)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _extract_xlsx(path: Path) -> dict:
|
|
231
|
+
openpyxl = _load("openpyxl")
|
|
232
|
+
if openpyxl is None:
|
|
233
|
+
return _missing("openpyxl")
|
|
234
|
+
try:
|
|
235
|
+
wb = openpyxl.load_workbook(str(path), read_only=True, data_only=False)
|
|
236
|
+
except Exception as exc: # noqa: BLE001
|
|
237
|
+
return _failed(exc)
|
|
238
|
+
try:
|
|
239
|
+
parts: list[str] = []
|
|
240
|
+
sheets: list[dict] = []
|
|
241
|
+
patterns: Counter = Counter()
|
|
242
|
+
for ws in wb.worksheets:
|
|
243
|
+
parts.append(f"# sheet: {ws.title}")
|
|
244
|
+
pats: Counter = Counter()
|
|
245
|
+
for row in ws.iter_rows(values_only=True):
|
|
246
|
+
cells = ["" if v is None else str(v) for v in row]
|
|
247
|
+
for value in row:
|
|
248
|
+
if isinstance(value, str) and value.startswith("="):
|
|
249
|
+
pats[_normalize_formula(value)] += 1
|
|
250
|
+
while cells and cells[-1] == "":
|
|
251
|
+
cells.pop()
|
|
252
|
+
if any(cells):
|
|
253
|
+
parts.append("\t".join(cells))
|
|
254
|
+
patterns.update(pats)
|
|
255
|
+
sheets.append({
|
|
256
|
+
"name": ws.title,
|
|
257
|
+
"max_row": int(ws.max_row or 0),
|
|
258
|
+
"max_column": int(ws.max_column or 0),
|
|
259
|
+
"formula_patterns": dict(pats.most_common(50)),
|
|
260
|
+
})
|
|
261
|
+
xlsx_fp = _compact_structure(sheets, patterns, _defined_names(wb))
|
|
262
|
+
return _finish("\n".join(parts), xlsx_fp=xlsx_fp)
|
|
263
|
+
except Exception as exc: # noqa: BLE001
|
|
264
|
+
return _failed(exc)
|
|
265
|
+
finally:
|
|
266
|
+
try:
|
|
267
|
+
wb.close()
|
|
268
|
+
except Exception: # noqa: BLE001
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def extract_one(path: Path) -> dict:
|
|
273
|
+
"""Extract one file. Never raises; returns status/text/error/xlsx_fp."""
|
|
274
|
+
ext = Path(path).suffix.lower()
|
|
275
|
+
if ext in LEGACY_UNSUPPORTED:
|
|
276
|
+
return {"status": "unsupported", "text": "", "error": "legacy_office_unsupported", "xlsx_fp": None}
|
|
277
|
+
if ext == ".pdf":
|
|
278
|
+
return _extract_pdf(Path(path))
|
|
279
|
+
if ext == ".docx":
|
|
280
|
+
return _extract_docx(Path(path))
|
|
281
|
+
if ext == ".pptx":
|
|
282
|
+
return _extract_pptx(Path(path))
|
|
283
|
+
if ext == ".xlsx":
|
|
284
|
+
return _extract_xlsx(Path(path))
|
|
285
|
+
return {"status": "unsupported", "text": "", "error": "unsupported_ext", "xlsx_fp": None}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _out_guard(root: Path, out: Path) -> None:
|
|
289
|
+
if out == root:
|
|
290
|
+
raise ValueError("输出目录不能等于扫描根目录")
|
|
291
|
+
try:
|
|
292
|
+
rel = out.relative_to(root)
|
|
293
|
+
except ValueError:
|
|
294
|
+
return
|
|
295
|
+
if not rel.parts or rel.parts[0] not in util.DEFAULT_EXCLUDE:
|
|
296
|
+
raise ValueError(
|
|
297
|
+
"输出目录不能位于扫描根目录内(除非放在 _extract 等默认排除目录),"
|
|
298
|
+
"否则下次扫描会把 sidecar 当作文档。"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _process_one(p: Path, root: Path, out: Path, prior: dict,
|
|
303
|
+
overwrite: bool, max_bytes: int) -> tuple[dict, str]:
|
|
304
|
+
ext = p.suffix.lower()
|
|
305
|
+
rel = util.rel_posix(p, root)
|
|
306
|
+
target = Path(str(out / rel) + ".txt")
|
|
307
|
+
status = ""
|
|
308
|
+
error = ""
|
|
309
|
+
xlsx_fp = None
|
|
310
|
+
text = ""
|
|
311
|
+
chars = 0
|
|
312
|
+
if ext in LEGACY_UNSUPPORTED:
|
|
313
|
+
status = "unsupported"
|
|
314
|
+
error = "legacy_office_unsupported"
|
|
315
|
+
elif target.exists() and not overwrite:
|
|
316
|
+
status = "skipped_existing"
|
|
317
|
+
old = prior.get(rel) or {}
|
|
318
|
+
if isinstance(old.get("xlsx_fp"), dict):
|
|
319
|
+
xlsx_fp = old["xlsx_fp"]
|
|
320
|
+
chars = int(old.get("chars") or 0)
|
|
321
|
+
else:
|
|
322
|
+
try:
|
|
323
|
+
too_large = p.stat().st_size > max_bytes
|
|
324
|
+
except OSError as exc:
|
|
325
|
+
too_large = False
|
|
326
|
+
status = "failed"
|
|
327
|
+
error = f"{type(exc).__name__}: {exc}"[:300]
|
|
328
|
+
if not status and too_large:
|
|
329
|
+
status = "failed"
|
|
330
|
+
error = "too_large"
|
|
331
|
+
if not status:
|
|
332
|
+
try:
|
|
333
|
+
result = extract_one(p)
|
|
334
|
+
except Exception as exc: # noqa: BLE001
|
|
335
|
+
result = {"status": "failed", "text": "", "error": str(exc), "xlsx_fp": None}
|
|
336
|
+
status = result["status"]
|
|
337
|
+
error = result.get("error") or ""
|
|
338
|
+
xlsx_fp = result.get("xlsx_fp")
|
|
339
|
+
text = result.get("text") or ""
|
|
340
|
+
chars = len(text) if status == "extracted" else 0
|
|
341
|
+
if status in ("extracted", "no_text"):
|
|
342
|
+
try:
|
|
343
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
target.write_text(text if status == "extracted" else "", encoding="utf-8")
|
|
345
|
+
except OSError as exc:
|
|
346
|
+
status = "failed"
|
|
347
|
+
error = f"write_error: {exc}"[:300]
|
|
348
|
+
chars = 0
|
|
349
|
+
else:
|
|
350
|
+
try:
|
|
351
|
+
if target.exists():
|
|
352
|
+
target.unlink()
|
|
353
|
+
except OSError:
|
|
354
|
+
pass
|
|
355
|
+
entry = {
|
|
356
|
+
"p": rel,
|
|
357
|
+
"ext": ext,
|
|
358
|
+
"status": status,
|
|
359
|
+
"chars": chars,
|
|
360
|
+
"error": error,
|
|
361
|
+
"xlsx_fp": xlsx_fp,
|
|
362
|
+
}
|
|
363
|
+
missing = error.split(":", 1)[1] if status == "failed" and error.startswith("missing_dependency:") else ""
|
|
364
|
+
return entry, missing
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def extract_root(
|
|
368
|
+
root: Path,
|
|
369
|
+
out: Path,
|
|
370
|
+
exclude=None,
|
|
371
|
+
overwrite: bool = False,
|
|
372
|
+
max_file_mb: int = 200,
|
|
373
|
+
progress=None,
|
|
374
|
+
workers: int = 1,
|
|
375
|
+
ttl_days: int | None = None,
|
|
376
|
+
) -> dict:
|
|
377
|
+
root = Path(root).resolve()
|
|
378
|
+
out = Path(out).resolve()
|
|
379
|
+
if not root.is_dir():
|
|
380
|
+
raise NotADirectoryError(str(root))
|
|
381
|
+
_out_guard(root, out)
|
|
382
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
383
|
+
if ttl_days is not None:
|
|
384
|
+
purge_sidecars(out, older_than_days=ttl_days)
|
|
385
|
+
prior = read_manifest(out)
|
|
386
|
+
|
|
387
|
+
max_bytes = max_file_mb * 1024 * 1024
|
|
388
|
+
files = [p for p in util.iter_files(root, exclude=exclude)
|
|
389
|
+
if p.suffix.lower() in (EXTRACT_EXTS | LEGACY_UNSUPPORTED)]
|
|
390
|
+
counts: Counter = Counter()
|
|
391
|
+
missing: Counter = Counter()
|
|
392
|
+
entries: list[dict] = []
|
|
393
|
+
workers = max(1, min(int(workers or 1), 16))
|
|
394
|
+
|
|
395
|
+
def consume(index: int, entry: dict, missing_dep: str):
|
|
396
|
+
counts[entry["status"]] += 1
|
|
397
|
+
if missing_dep:
|
|
398
|
+
missing[missing_dep] += 1
|
|
399
|
+
entries.append(entry)
|
|
400
|
+
if progress and index % 20 == 0:
|
|
401
|
+
progress(index, len(files))
|
|
402
|
+
|
|
403
|
+
if workers > 1 and len(files) > 1:
|
|
404
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
405
|
+
futures = [pool.submit(_process_one, p, root, out, prior, overwrite, max_bytes)
|
|
406
|
+
for p in files]
|
|
407
|
+
for i, future in enumerate(futures, 1):
|
|
408
|
+
entry, missing_dep = future.result()
|
|
409
|
+
consume(i, entry, missing_dep)
|
|
410
|
+
else:
|
|
411
|
+
for i, p in enumerate(files, 1):
|
|
412
|
+
entry, missing_dep = _process_one(p, root, out, prior, overwrite, max_bytes)
|
|
413
|
+
consume(i, entry, missing_dep)
|
|
414
|
+
|
|
415
|
+
manifest = {
|
|
416
|
+
"schema": SCHEMA,
|
|
417
|
+
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
418
|
+
"root": {"name": root.name},
|
|
419
|
+
"params": {"overwrite": bool(overwrite), "max_file_mb": max_file_mb,
|
|
420
|
+
"workers": workers, "ttl_days": ttl_days},
|
|
421
|
+
"summary": {
|
|
422
|
+
"files": len(files),
|
|
423
|
+
"extracted": counts.get("extracted", 0),
|
|
424
|
+
"no_text": counts.get("no_text", 0),
|
|
425
|
+
"failed": counts.get("failed", 0),
|
|
426
|
+
"unsupported": counts.get("unsupported", 0),
|
|
427
|
+
"skipped_existing": counts.get("skipped_existing", 0),
|
|
428
|
+
"missing_dependencies": dict(sorted(missing.items())),
|
|
429
|
+
},
|
|
430
|
+
"files": entries,
|
|
431
|
+
}
|
|
432
|
+
util.write_json(out / "extract.json", manifest)
|
|
433
|
+
return manifest
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def read_manifest(extract_dir: Path) -> dict:
|
|
437
|
+
"""Return {rel: entry} from <extract_dir>/extract.json (tolerates absence)."""
|
|
438
|
+
data = util.read_json(Path(extract_dir) / "extract.json", {}) or {}
|
|
439
|
+
entries = data.get("files") if isinstance(data, dict) else None
|
|
440
|
+
result = {}
|
|
441
|
+
if isinstance(entries, list):
|
|
442
|
+
for entry in entries:
|
|
443
|
+
if isinstance(entry, dict) and entry.get("p"):
|
|
444
|
+
result[str(entry["p"])] = entry
|
|
445
|
+
return result
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def purge_sidecars(extract_dir: Path, older_than_days: int | None = None) -> dict:
|
|
449
|
+
"""Delete sidecars listed in extract.json, never touching source files.
|
|
450
|
+
|
|
451
|
+
older_than_days=None purges all; otherwise only files older than N days.
|
|
452
|
+
"""
|
|
453
|
+
out = Path(extract_dir).resolve()
|
|
454
|
+
manifest_path = out / "extract.json"
|
|
455
|
+
data = util.read_json(manifest_path, {}) or {}
|
|
456
|
+
entries = data.get("files") if isinstance(data, dict) else None
|
|
457
|
+
if not isinstance(entries, list):
|
|
458
|
+
return {"files": 0, "remaining": 0, "manifest": False}
|
|
459
|
+
|
|
460
|
+
cutoff = None if older_than_days is None else time.time() - max(0, int(older_than_days)) * 86400
|
|
461
|
+
remaining: list[dict] = []
|
|
462
|
+
purged = 0
|
|
463
|
+
for entry in entries:
|
|
464
|
+
if not isinstance(entry, dict) or not entry.get("p"):
|
|
465
|
+
continue
|
|
466
|
+
target = (out / (str(entry["p"]) + ".txt")).resolve()
|
|
467
|
+
try:
|
|
468
|
+
target.relative_to(out)
|
|
469
|
+
except ValueError:
|
|
470
|
+
continue
|
|
471
|
+
try:
|
|
472
|
+
if not target.exists():
|
|
473
|
+
continue
|
|
474
|
+
if cutoff is not None and target.stat().st_mtime >= cutoff:
|
|
475
|
+
remaining.append(entry)
|
|
476
|
+
continue
|
|
477
|
+
target.unlink()
|
|
478
|
+
purged += 1
|
|
479
|
+
except OSError:
|
|
480
|
+
remaining.append(entry)
|
|
481
|
+
|
|
482
|
+
for d in sorted((d for d in out.rglob("*") if d.is_dir()),
|
|
483
|
+
key=lambda d: len(d.parts), reverse=True):
|
|
484
|
+
try:
|
|
485
|
+
if not any(d.iterdir()):
|
|
486
|
+
d.rmdir()
|
|
487
|
+
except OSError:
|
|
488
|
+
pass
|
|
489
|
+
|
|
490
|
+
counts = Counter(str(e.get("status") or "") for e in remaining)
|
|
491
|
+
summary = data.get("summary") if isinstance(data.get("summary"), dict) else {}
|
|
492
|
+
summary.update({
|
|
493
|
+
"files": len(remaining),
|
|
494
|
+
"extracted": counts.get("extracted", 0),
|
|
495
|
+
"no_text": counts.get("no_text", 0),
|
|
496
|
+
"failed": counts.get("failed", 0),
|
|
497
|
+
"unsupported": counts.get("unsupported", 0),
|
|
498
|
+
"skipped_existing": counts.get("skipped_existing", 0),
|
|
499
|
+
"purged": int(summary.get("purged", 0) or 0) + purged,
|
|
500
|
+
})
|
|
501
|
+
data["files"] = remaining
|
|
502
|
+
data["summary"] = summary
|
|
503
|
+
data["purged_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
504
|
+
if remaining:
|
|
505
|
+
util.write_json(manifest_path, data)
|
|
506
|
+
else:
|
|
507
|
+
try:
|
|
508
|
+
manifest_path.unlink()
|
|
509
|
+
except OSError:
|
|
510
|
+
pass
|
|
511
|
+
return {"files": purged, "remaining": len(remaining), "manifest": bool(remaining)}
|