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