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/fingerprint.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Read-only fingerprint: metadata + hash, no content leaves the machine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import links as link_rules
|
|
11
|
+
from . import tags as tag_rules
|
|
12
|
+
from . import util
|
|
13
|
+
from .extract import read_manifest
|
|
14
|
+
from .jobs import jobs_root, write_state
|
|
15
|
+
|
|
16
|
+
TEXT_CACHE_DIR = "text"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _extract_text(p: Path, rel: str, ext: str, extract_dir: Path | None) -> tuple[str, str]:
|
|
20
|
+
"""Return (text, text_status).
|
|
21
|
+
|
|
22
|
+
text_status is one of: text | extracted | no_text | failed | not_enabled | unsupported.
|
|
23
|
+
The tri-state matters for honesty: "not extracted" is not an extraction failure.
|
|
24
|
+
"""
|
|
25
|
+
if ext in util.TEXT_EXTS:
|
|
26
|
+
try:
|
|
27
|
+
return util.read_text(p), "text"
|
|
28
|
+
except OSError:
|
|
29
|
+
return "", "failed"
|
|
30
|
+
if ext in util.UNSUPPORTED_TEXT_EXTS:
|
|
31
|
+
return "", "unsupported"
|
|
32
|
+
if extract_dir is None:
|
|
33
|
+
return "", "not_enabled"
|
|
34
|
+
ep = Path(extract_dir) / rel
|
|
35
|
+
ep = Path(str(ep) + ".txt")
|
|
36
|
+
if not ep.exists():
|
|
37
|
+
return "", "failed"
|
|
38
|
+
try:
|
|
39
|
+
text = util.read_text(ep)
|
|
40
|
+
except OSError:
|
|
41
|
+
return "", "failed"
|
|
42
|
+
if not text.strip():
|
|
43
|
+
return text, "no_text"
|
|
44
|
+
return text, "extracted"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def purge_text(job: Path, older_than_days: int | None = None) -> int:
|
|
48
|
+
"""Delete cached body text under job/text/.
|
|
49
|
+
|
|
50
|
+
older_than_days=None purges everything; otherwise only files older than N days.
|
|
51
|
+
Returns the number of removed cache files.
|
|
52
|
+
"""
|
|
53
|
+
text_dir = Path(job) / TEXT_CACHE_DIR
|
|
54
|
+
if not text_dir.is_dir():
|
|
55
|
+
return 0
|
|
56
|
+
cutoff = None if older_than_days is None else time.time() - max(0, int(older_than_days)) * 86400
|
|
57
|
+
removed = 0
|
|
58
|
+
for p in text_dir.rglob("*"):
|
|
59
|
+
if not p.is_file():
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
if cutoff is not None and p.stat().st_mtime >= cutoff:
|
|
63
|
+
continue
|
|
64
|
+
p.unlink()
|
|
65
|
+
removed += 1
|
|
66
|
+
except OSError:
|
|
67
|
+
continue
|
|
68
|
+
for d in sorted((d for d in text_dir.rglob("*") if d.is_dir()),
|
|
69
|
+
key=lambda d: len(d.parts), reverse=True):
|
|
70
|
+
try:
|
|
71
|
+
d.rmdir()
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
try:
|
|
75
|
+
if not any(text_dir.iterdir()):
|
|
76
|
+
text_dir.rmdir()
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
return removed
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sweep_expired_text(ttl_days: int | None = None, skip_job: str | None = None) -> dict:
|
|
83
|
+
"""Apply the text-cache TTL to all jobs (except skip_job)."""
|
|
84
|
+
ttl = util.text_ttl_days() if ttl_days is None else max(0, int(ttl_days))
|
|
85
|
+
removed = 0
|
|
86
|
+
hit_jobs = 0
|
|
87
|
+
for p in jobs_root().iterdir():
|
|
88
|
+
if not p.is_dir() or (skip_job and p.name == skip_job):
|
|
89
|
+
continue
|
|
90
|
+
n = purge_text(p, older_than_days=ttl)
|
|
91
|
+
if n:
|
|
92
|
+
removed += n
|
|
93
|
+
hit_jobs += 1
|
|
94
|
+
try:
|
|
95
|
+
write_state(p, text_cache_purged_at=util.now_iso(),
|
|
96
|
+
text_cache_purged_files=n, text_ttl_days=ttl)
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
return {"files": removed, "jobs": hit_jobs, "ttl_days": ttl}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _describe(p: Path, root: Path, extract_dir: Path | None, redact: bool,
|
|
103
|
+
extract_manifest: dict | None = None):
|
|
104
|
+
rel = util.rel_posix(p, root)
|
|
105
|
+
st = p.stat()
|
|
106
|
+
ext = p.suffix.lower()
|
|
107
|
+
text, text_status = _extract_text(p, rel, ext, extract_dir)
|
|
108
|
+
meta = (extract_manifest or {}).get(rel) or {}
|
|
109
|
+
if text_status == "failed" and meta.get("status") in ("no_text", "failed", "unsupported"):
|
|
110
|
+
text_status = meta["status"]
|
|
111
|
+
fm, body = util.parse_frontmatter(text) if ext in util.TEXT_EXTS else ({}, text)
|
|
112
|
+
title = fm.get("title") if isinstance(fm.get("title"), str) else ""
|
|
113
|
+
title_src = "frontmatter" if title else ""
|
|
114
|
+
if not title:
|
|
115
|
+
for line in (body or "").splitlines():
|
|
116
|
+
if line.startswith("# "):
|
|
117
|
+
title = line[2:].strip()
|
|
118
|
+
title_src = "heading"
|
|
119
|
+
break
|
|
120
|
+
if not title:
|
|
121
|
+
title = p.stem
|
|
122
|
+
title_src = "stem"
|
|
123
|
+
fm_tags = fm.get("topicTags", []) or []
|
|
124
|
+
if isinstance(fm_tags, str):
|
|
125
|
+
fm_tags = [t.strip().strip("'\"") for t in fm_tags.strip("[]").split(",") if t.strip()]
|
|
126
|
+
fm_tags = [str(t) for t in fm_tags] if isinstance(fm_tags, list) else []
|
|
127
|
+
fm_domain = fm.get("domain", "") if isinstance(fm.get("domain", ""), str) else ""
|
|
128
|
+
tags, tag_sources, inferred_tags, domain, domain_source = tag_rules.infer_metadata(
|
|
129
|
+
rel, p.name, body, fm_tags, fm_domain
|
|
130
|
+
)
|
|
131
|
+
owner = fm.get("owner") or fm.get("负责人") or fm.get("Owner") or ""
|
|
132
|
+
owner = str(owner).strip()[:120] if isinstance(owner, (str, int)) else ""
|
|
133
|
+
links_total, broken = ((link_rules.scan_links(body or "", p.parent)
|
|
134
|
+
if ext in util.TEXT_EXTS else (0, []))
|
|
135
|
+
if body else (0, []))
|
|
136
|
+
key = util.safe_name(rel) if redact else rel
|
|
137
|
+
name = util.safe_name(rel) + ext if redact else p.name
|
|
138
|
+
return {
|
|
139
|
+
"p": key,
|
|
140
|
+
"name": name,
|
|
141
|
+
"rel": "" if redact else rel,
|
|
142
|
+
"ext": ext,
|
|
143
|
+
"size": st.st_size,
|
|
144
|
+
"sha256": "",
|
|
145
|
+
"mtime": st.st_mtime,
|
|
146
|
+
"depth": rel.count("/"),
|
|
147
|
+
"chars": len(body) if body else 0,
|
|
148
|
+
"has_text": bool((body or "").strip()),
|
|
149
|
+
"text_status": text_status,
|
|
150
|
+
"text_error": str(meta.get("error") or "")[:300],
|
|
151
|
+
"xlsx_fp": meta.get("xlsx_fp") if isinstance(meta.get("xlsx_fp"), dict) else None,
|
|
152
|
+
"version_stage": util.version_stage(p.name, rel),
|
|
153
|
+
"version_number": util.version_number(p.name, rel),
|
|
154
|
+
"title": title[:200],
|
|
155
|
+
"title_source": title_src,
|
|
156
|
+
"tags": tags,
|
|
157
|
+
"tags_frontmatter": fm_tags,
|
|
158
|
+
"tags_inferred": inferred_tags,
|
|
159
|
+
"tag_sources": tag_sources,
|
|
160
|
+
"domain": domain,
|
|
161
|
+
"domain_source": domain_source,
|
|
162
|
+
"owner": owner,
|
|
163
|
+
"links_total": links_total,
|
|
164
|
+
"broken_links": len(broken),
|
|
165
|
+
"broken_examples": broken[:3],
|
|
166
|
+
"reviewed": bool(fm.get("reviewed")) if fm else False,
|
|
167
|
+
}, (body or "")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def scan(
|
|
171
|
+
root: Path,
|
|
172
|
+
job: Path,
|
|
173
|
+
exclude=None,
|
|
174
|
+
max_file_mb: int = 200,
|
|
175
|
+
extract_dir: Path | None = None,
|
|
176
|
+
redact: bool = False,
|
|
177
|
+
progress=None,
|
|
178
|
+
text_ttl_days: int | None = None,
|
|
179
|
+
) -> dict:
|
|
180
|
+
root = Path(root).resolve()
|
|
181
|
+
job = Path(job)
|
|
182
|
+
job.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
extract_dir = Path(extract_dir).resolve() if extract_dir else None
|
|
184
|
+
extract_manifest = read_manifest(extract_dir) if extract_dir else {}
|
|
185
|
+
if extract_manifest:
|
|
186
|
+
# Redacted fingerprints use safe_name(rel) as p; index both.
|
|
187
|
+
aliased = {}
|
|
188
|
+
for rel, entry in extract_manifest.items():
|
|
189
|
+
aliased[rel] = entry
|
|
190
|
+
aliased[util.safe_name(rel)] = entry
|
|
191
|
+
extract_manifest = aliased
|
|
192
|
+
ttl = util.text_ttl_days() if text_ttl_days is None else max(0, int(text_ttl_days))
|
|
193
|
+
|
|
194
|
+
files = list(util.iter_files(root, exclude=exclude))
|
|
195
|
+
write_state(job, status="running", stage="fingerprint", root=str(root),
|
|
196
|
+
total=len(files), done=0, started_at=util.now_iso())
|
|
197
|
+
|
|
198
|
+
jsonl = job / "files.jsonl"
|
|
199
|
+
done_keys: set[str] = set()
|
|
200
|
+
done_records: list[dict] = []
|
|
201
|
+
if jsonl.exists():
|
|
202
|
+
for line in jsonl.read_text(encoding="utf-8").splitlines():
|
|
203
|
+
try:
|
|
204
|
+
rec = json.loads(line)
|
|
205
|
+
done_keys.add(rec["p"])
|
|
206
|
+
done_records.append(rec)
|
|
207
|
+
except (ValueError, KeyError):
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
max_bytes = max_file_mb * 1024 * 1024
|
|
211
|
+
text_dir = job / "text"
|
|
212
|
+
text_dir.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
|
|
214
|
+
# If the body cache was purged, resuming would silently lose text. Force a clean rescan.
|
|
215
|
+
cache_missing = any(
|
|
216
|
+
rec.get("has_text") and not (text_dir / (util.safe_name(rec["p"]) + ".txt")).exists()
|
|
217
|
+
for rec in done_records
|
|
218
|
+
)
|
|
219
|
+
# A job scanned before `kba extract` must not silently keep "not extracted" records.
|
|
220
|
+
needs_extract_rescan = any(
|
|
221
|
+
(extract_manifest.get(rec.get("p")) or {}).get("status") in ("extracted", "no_text")
|
|
222
|
+
and rec.get("text_status") not in ("extracted", "no_text")
|
|
223
|
+
for rec in done_records
|
|
224
|
+
)
|
|
225
|
+
if cache_missing or needs_extract_rescan:
|
|
226
|
+
try:
|
|
227
|
+
jsonl.unlink()
|
|
228
|
+
except OSError:
|
|
229
|
+
pass
|
|
230
|
+
else:
|
|
231
|
+
done_keys.clear()
|
|
232
|
+
|
|
233
|
+
def work(p: Path):
|
|
234
|
+
rec, text = _describe(p, root, extract_dir, redact, extract_manifest)
|
|
235
|
+
if rec["size"] > max_bytes:
|
|
236
|
+
rec["skipped"] = "too_large"
|
|
237
|
+
rec["sha256"] = ""
|
|
238
|
+
else:
|
|
239
|
+
rec["sha256"] = util.sha256_file(p)
|
|
240
|
+
return rec, text
|
|
241
|
+
|
|
242
|
+
todo = []
|
|
243
|
+
for p in files:
|
|
244
|
+
rel_key = util.safe_name(util.rel_posix(p, root)) if redact else util.rel_posix(p, root)
|
|
245
|
+
if rel_key not in done_keys:
|
|
246
|
+
todo.append(p)
|
|
247
|
+
|
|
248
|
+
done = len(done_keys)
|
|
249
|
+
errors = 0
|
|
250
|
+
with open(jsonl, "a", encoding="utf-8") as f, ThreadPoolExecutor(max_workers=8) as ex:
|
|
251
|
+
futures = {ex.submit(work, p): p for p in todo}
|
|
252
|
+
for fut in as_completed(futures):
|
|
253
|
+
p = futures[fut]
|
|
254
|
+
try:
|
|
255
|
+
rec, text = fut.result()
|
|
256
|
+
except Exception as exc: # noqa: BLE001
|
|
257
|
+
errors += 1
|
|
258
|
+
rec, text = {"p": util.rel_posix(p, root), "name": p.name, "ext": p.suffix.lower(),
|
|
259
|
+
"size": 0, "sha256": "", "mtime": 0, "depth": 0, "chars": 0,
|
|
260
|
+
"has_text": False, "text_status": "failed",
|
|
261
|
+
"text_error": str(exc)[:300], "xlsx_fp": None,
|
|
262
|
+
"version_stage": util.version_stage(p.name, util.rel_posix(p, root)),
|
|
263
|
+
"version_number": "", "title": p.stem, "title_source": "stem",
|
|
264
|
+
"tags": [], "tags_frontmatter": [], "tags_inferred": [],
|
|
265
|
+
"tag_sources": [], "domain": "", "domain_source": "",
|
|
266
|
+
"owner": "", "links_total": 0, "broken_links": 0,
|
|
267
|
+
"broken_examples": [], "reviewed": False, "error": str(exc)}, ""
|
|
268
|
+
if text and text.strip():
|
|
269
|
+
try:
|
|
270
|
+
(text_dir / (util.safe_name(rec["p"]) + ".txt")).write_text(text, encoding="utf-8")
|
|
271
|
+
except OSError:
|
|
272
|
+
pass
|
|
273
|
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
274
|
+
f.flush()
|
|
275
|
+
done += 1
|
|
276
|
+
if progress and done % 20 == 0:
|
|
277
|
+
progress(done, len(files))
|
|
278
|
+
if done % 20 == 0:
|
|
279
|
+
write_state(job, done=done)
|
|
280
|
+
|
|
281
|
+
records = []
|
|
282
|
+
for line in jsonl.read_text(encoding="utf-8").splitlines():
|
|
283
|
+
try:
|
|
284
|
+
records.append(json.loads(line))
|
|
285
|
+
except ValueError:
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
by_ext: dict[str, int] = {}
|
|
289
|
+
unsupported_by_ext: dict[str, int] = {}
|
|
290
|
+
status_counts: Counter = Counter()
|
|
291
|
+
total_bytes = 0
|
|
292
|
+
max_depth = 0
|
|
293
|
+
for r in records:
|
|
294
|
+
by_ext[r["ext"]] = by_ext.get(r["ext"], 0) + 1
|
|
295
|
+
if r.get("ext") in util.UNSUPPORTED_TEXT_EXTS:
|
|
296
|
+
unsupported_by_ext[r["ext"]] = unsupported_by_ext.get(r["ext"], 0) + 1
|
|
297
|
+
status_counts[util.text_status_of(r, extract_dir is not None)] += 1
|
|
298
|
+
total_bytes += r.get("size", 0)
|
|
299
|
+
max_depth = max(max_depth, r.get("depth", 0))
|
|
300
|
+
|
|
301
|
+
fp = {
|
|
302
|
+
"schema": "kba.fingerprint/1",
|
|
303
|
+
"job_id": job.name,
|
|
304
|
+
"created_at": util.now_iso(),
|
|
305
|
+
"tool": {"name": "kba", "version": __import__("kba").__version__},
|
|
306
|
+
"root": {"redacted": redact, "name": root.name, "files": len(records)},
|
|
307
|
+
"params": {
|
|
308
|
+
"max_file_mb": max_file_mb,
|
|
309
|
+
"exclude": sorted(exclude or util.DEFAULT_EXCLUDE),
|
|
310
|
+
"extract_enabled": extract_dir is not None,
|
|
311
|
+
"extract_dir": str(extract_dir) if (extract_dir and not redact) else None,
|
|
312
|
+
"text_ttl_days": ttl,
|
|
313
|
+
},
|
|
314
|
+
"summary": {
|
|
315
|
+
"files": len(records),
|
|
316
|
+
"bytes": total_bytes,
|
|
317
|
+
"by_ext": dict(sorted(by_ext.items(), key=lambda kv: -kv[1])),
|
|
318
|
+
"max_depth": max_depth,
|
|
319
|
+
"errors": errors,
|
|
320
|
+
"skipped": sum(1 for r in records if r.get("skipped")),
|
|
321
|
+
"with_text": sum(1 for r in records if r.get("has_text")),
|
|
322
|
+
"unsupported": sum(unsupported_by_ext.values()),
|
|
323
|
+
"unsupported_by_ext": dict(sorted(unsupported_by_ext.items(), key=lambda kv: -kv[1])),
|
|
324
|
+
"text_status": dict(sorted(status_counts.items())),
|
|
325
|
+
"xlsx_with_fp": sum(1 for r in records if isinstance(r.get("xlsx_fp"), dict)),
|
|
326
|
+
"text_ttl_days": ttl,
|
|
327
|
+
},
|
|
328
|
+
"files": records,
|
|
329
|
+
}
|
|
330
|
+
util.write_json(job / "fp.json", fp)
|
|
331
|
+
write_state(job, status="done", stage="fingerprint", done=len(records), total=len(files),
|
|
332
|
+
finished_at=util.now_iso())
|
|
333
|
+
return fp
|
kba/jobs.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Job storage under ~/.kba/jobs/<job_id>/."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def kba_home() -> Path:
|
|
13
|
+
return Path(os.environ.get("KBA_HOME", Path.home() / ".kba"))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def jobs_root() -> Path:
|
|
17
|
+
d = kba_home() / "jobs"
|
|
18
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
return d
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def new_job_id() -> str:
|
|
23
|
+
return datetime.now().strftime("%Y%m%d-%H%M%S-") + secrets.token_hex(2)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def job_dir(job_id: str) -> Path:
|
|
27
|
+
return jobs_root() / job_id
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def state_path(j: Path) -> Path:
|
|
31
|
+
return Path(j) / "state.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_state(j: Path, **fields) -> dict:
|
|
35
|
+
j = Path(j)
|
|
36
|
+
j.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
p = state_path(j)
|
|
38
|
+
try:
|
|
39
|
+
st = json.loads(p.read_text(encoding="utf-8"))
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
st = {}
|
|
42
|
+
st.update(fields)
|
|
43
|
+
st["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
|
|
44
|
+
tmp = p.with_name(f".{p.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp")
|
|
45
|
+
tmp.write_text(json.dumps(st, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
46
|
+
# Windows can briefly lock state.json (AV/indexer); retry the atomic replace.
|
|
47
|
+
for attempt in range(8):
|
|
48
|
+
try:
|
|
49
|
+
tmp.replace(p)
|
|
50
|
+
break
|
|
51
|
+
except PermissionError:
|
|
52
|
+
if attempt == 7:
|
|
53
|
+
raise
|
|
54
|
+
time.sleep(0.02)
|
|
55
|
+
return st
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_state(j: Path) -> dict:
|
|
59
|
+
try:
|
|
60
|
+
return json.loads(state_path(j).read_text(encoding="utf-8"))
|
|
61
|
+
except (OSError, ValueError):
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def latest_job() -> str | None:
|
|
66
|
+
jobs = [p for p in jobs_root().iterdir() if p.is_dir()]
|
|
67
|
+
if not jobs:
|
|
68
|
+
return None
|
|
69
|
+
return max(jobs, key=lambda p: p.stat().st_mtime).name
|
kba/links.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Local Markdown link checks (never fetches external URLs)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import unquote
|
|
7
|
+
|
|
8
|
+
LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)(?:\s+[\"'][^\"']*[\"'])?\)")
|
|
9
|
+
SKIP_PREFIXES = ("#", "http://", "https://", "mailto:", "tel:", "data:", "javascript:", "//")
|
|
10
|
+
SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def scan_links(text: str, base_dir: Path, cap: int = 200) -> tuple[int, list[str]]:
|
|
14
|
+
"""Return (local_link_count, broken_targets)."""
|
|
15
|
+
total = 0
|
|
16
|
+
broken: list[str] = []
|
|
17
|
+
for match in LINK_RE.finditer(text or ""):
|
|
18
|
+
target = match.group(1).strip()
|
|
19
|
+
if not target or target.startswith(SKIP_PREFIXES) or target.startswith("/"):
|
|
20
|
+
continue
|
|
21
|
+
# Skip URI schemes (file:, obsidian:, javascript\:void(...)) and @placeholders.
|
|
22
|
+
if target.startswith("@") or SCHEME_RE.match(target.replace("\\:", ":")):
|
|
23
|
+
continue
|
|
24
|
+
if "{{" in target or "}}" in target:
|
|
25
|
+
continue
|
|
26
|
+
total += 1
|
|
27
|
+
clean = unquote(target.split("#", 1)[0].split("?", 1)[0]).strip()
|
|
28
|
+
if not clean:
|
|
29
|
+
continue
|
|
30
|
+
try:
|
|
31
|
+
candidate = Path(base_dir) / clean
|
|
32
|
+
if not candidate.exists():
|
|
33
|
+
broken.append(clean[:200])
|
|
34
|
+
except OSError:
|
|
35
|
+
broken.append(clean[:200])
|
|
36
|
+
if len(broken) >= cap:
|
|
37
|
+
break
|
|
38
|
+
return total, broken
|
kba/pii.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""PII candidate detection: counts only, never returns or stores matched text."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
PATTERNS = (
|
|
7
|
+
("email", re.compile(r"(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?![A-Za-z0-9.-])")),
|
|
8
|
+
("id_cn", re.compile(r"(?<!\d)\d{17}[\dXx](?!\d)")),
|
|
9
|
+
("phone_cn", re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")),
|
|
10
|
+
("bank_card", re.compile(r"(?<!\d)(?:\d[ -]?){15,18}\d(?!\d)")),
|
|
11
|
+
("secret", re.compile(r"(?:AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{20,})")),
|
|
12
|
+
("contract_no", re.compile(
|
|
13
|
+
r"(?:合同|协议|订单|contract)\s*(?:编号|号|no\.?)?\s*[::]?\s*([A-Z]{2,}[A-Z0-9-]{4,})",
|
|
14
|
+
re.I,
|
|
15
|
+
)),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
LABELS = {
|
|
19
|
+
"email": "邮箱",
|
|
20
|
+
"id_cn": "身份证号",
|
|
21
|
+
"phone_cn": "手机号",
|
|
22
|
+
"bank_card": "银行卡号",
|
|
23
|
+
"secret": "密钥/Token",
|
|
24
|
+
"contract_no": "合同/协议编号",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def scan_text(text: str, cap: int = 200_000) -> dict[str, int]:
|
|
29
|
+
"""Return {type: occurrences}; matched spans are blanked to avoid double counting."""
|
|
30
|
+
text = (text or "")[:cap]
|
|
31
|
+
counts: dict[str, int] = {}
|
|
32
|
+
for name, pattern in PATTERNS:
|
|
33
|
+
found = list(pattern.finditer(text))
|
|
34
|
+
if found:
|
|
35
|
+
counts[name] = len(found)
|
|
36
|
+
text = pattern.sub(lambda m: " " * len(m.group(0)), text)
|
|
37
|
+
return counts
|