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/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)}