scourgify 1.0.0__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.
scourgify/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """scourgify — normalize a FanFicFare-imported Calibre library."""
2
+
3
+ __version__ = "1.0.0"
scourgify/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from scourgify.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
scourgify/_writer.py ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env python3
2
+ """Internal Calibre write helper — invoked UNDER calibre-debug by wrangle.py / classify.py, never by hand.
3
+
4
+ The standalone (system-python) tools compute everything via read-only sqlite, then shell out to:
5
+ calibre-debug -e _writer.py -- <ops.json>
6
+ to perform the writes through Calibre's API — the only fast batched write path (calibredb's set_metadata is
7
+ one book per process). One generic op list keeps Calibre access in a single ~30-line stub.
8
+
9
+ ops.json = [ {op: ...}, ... ]:
10
+ {"op":"create_column","label":"wrangled","name":"Wrangled","datatype":"datetime","is_multiple":false}
11
+ {"op":"set_field","field":"tags","values":{"<book_id>": ["a","b"] | "scalar"}} # shape coerced to the column
12
+ {"op":"stamp_now","field":"#wrangled","books":[id,...] | null} # null = all books
13
+ {"op":"set_pref","key":"namespaced:...","value":{...}}
14
+ """
15
+ import os, sys, json
16
+ from calibre.library import db as DB
17
+
18
+ LIB = os.path.expanduser(os.environ.get("CALIBRE_LIBRARY", ""))
19
+ if not LIB: raise SystemExit("_writer: CALIBRE_LIBRARY not set")
20
+ ops = json.load(open(sys.argv[-1]))
21
+ legacy = DB(LIB); api = legacy.new_api
22
+
23
+ for op in ops:
24
+ kind = op["op"]
25
+ if kind == "create_column":
26
+ if "#" + op["label"].lstrip("#") not in api.field_metadata.all_field_keys():
27
+ legacy.create_custom_column(op["label"].lstrip("#"), op["name"], op["datatype"], op.get("is_multiple", False))
28
+ legacy = DB(LIB); api = legacy.new_api # reopen so the new column is usable
29
+ print(f" created #{op['label'].lstrip('#')}")
30
+ elif kind == "set_field":
31
+ field = op["field"]
32
+ mult = bool(api.field_metadata.all_metadata().get(field, {}).get("is_multiple"))
33
+ vals = {}
34
+ for b, v in op["values"].items():
35
+ vals[int(b)] = (tuple(v) if isinstance(v, list) else (v,)) if mult else ((v[0] if isinstance(v, list) and v else v) or None)
36
+ api.set_field(field, vals)
37
+ print(f" set {field}: {len(vals)} books")
38
+ elif kind == "stamp_now":
39
+ from calibre.utils.date import now as cal_now
40
+ books = op["books"] if op.get("books") is not None else list(api.all_book_ids())
41
+ ts = cal_now(); api.set_field(op["field"], {int(b): ts for b in books})
42
+ print(f" stamped {op['field']}: {len(books)} books")
43
+ elif kind == "set_pref":
44
+ api.set_pref(op["key"], op["value"]); print(f" set pref {op['key']}")
45
+ print("WROTE.")
scourgify/afm.swift ADDED
@@ -0,0 +1,30 @@
1
+ // Apple Foundation Models bridge for calibre-wrangler (macOS 26+, Apple Intelligence).
2
+ // Persistent stdin loop: each input line is one prompt (literal newlines escaped as );
3
+ // prints one response line per prompt. Used by classify.py --engine apple.
4
+ // build: swiftc -O afm.swift -o afm (or run directly: swift afm.swift)
5
+ import Foundation
6
+ import FoundationModels
7
+
8
+ @available(macOS 26.0, *)
9
+ func loop() async {
10
+ guard case .available = SystemLanguageModel.default.availability else {
11
+ FileHandle.standardError.write(Data("AFM_UNAVAILABLE\n".utf8)); exit(2)
12
+ }
13
+ while let line = readLine(strippingNewline: true) {
14
+ let prompt = line.replacingOccurrences(of: "\u{01}", with: "\n")
15
+ var out = "ERR"
16
+ do {
17
+ let resp = try await LanguageModelSession().respond(to: prompt)
18
+ out = resp.content.replacingOccurrences(of: "\n", with: " ")
19
+ } catch { out = "ERR: \(error)".replacingOccurrences(of: "\n", with: " ") }
20
+ print(out); fflush(stdout)
21
+ }
22
+ }
23
+
24
+ if #available(macOS 26.0, *) {
25
+ let sem = DispatchSemaphore(value: 0)
26
+ Task { await loop(); sem.signal() }
27
+ sem.wait()
28
+ } else {
29
+ FileHandle.standardError.write(Data("needs macOS 26+\n".utf8)); exit(1)
30
+ }
scourgify/classify.py ADDED
@@ -0,0 +1,403 @@
1
+ #!/usr/bin/env python3
2
+ """Content-based tagging from a controlled vocabulary, with TWO outputs per book:
3
+ 1) added_tags — tags chosen from defaults/classify_vocab.txt (the consolidated set); these get APPLIED.
4
+ 2) proposed_new — short reusable tags the model thinks apply but are NOT in the vocab yet; aggregated into
5
+ classify_newtags_ranked.csv for review, so the vocabulary grows cleanly (promote -> vocab).
6
+
7
+ scourgify classify [--engine apple|claude|openai|gemini] [--incremental] [--workers N] [--batch N] [--fresh]
8
+ scourgify classify --apply # apply 'added_tags' + stamp #wrangled (Calibre CLOSED; writes shell to calibre-debug)
9
+
10
+ Engines (--engine): apple = on-device Apple Foundation Models via ./afm (free; macOS 26+).
11
+ claude = Anthropic (ANTHROPIC_API_KEY) | openai = OpenAI (OPENAI_API_KEY) | gemini = Google (GEMINI_API_KEY).
12
+ --model overrides the per-engine default. Only books with < --min-tags tags AND a description are processed.
13
+ Runs are resumable (skip books already in the proposal; --fresh restarts). Dry-run until --apply.
14
+ --incremental = cheap maintenance after new downloads: (re)process only books whose #updated is newer than their own
15
+ #wrangled marker (or never wrangled), plus any still untagged. --apply auto-creates the #wrangled datetime
16
+ column and stamps each tagged book, so the state lives IN the library — no external file. (--since DATE
17
+ forces an explicit cutoff against #updated.) Avoids the ~full-library cost of a --fresh pass."""
18
+ import argparse, os, re, csv, json, subprocess, collections, time
19
+ from concurrent.futures import ThreadPoolExecutor, as_completed
20
+ from scourgify.common import HERE, DATA, ro_connect, read_custom_column, custom_column_id, run_writer, library
21
+ try: # rich is optional: live dashboard/tables in system python3
22
+ from rich.console import Console, Group
23
+ from rich.live import Live
24
+ from rich.panel import Panel
25
+ from rich.progress import Progress, BarColumn, TextColumn, MofNCompleteColumn, TimeRemainingColumn
26
+ from rich.table import Table
27
+ from rich.text import Text
28
+ _con = Console(stderr=True); RICH = True
29
+ except ImportError:
30
+ RICH = False
31
+
32
+ VOCAB = [l.strip() for l in open(f"{HERE}/defaults/classify_vocab.txt") if l.strip() and not l.startswith("#")]
33
+ VLOW = {v.lower(): v for v in VOCAB}
34
+ PROP = f"{DATA}/classify_proposal.csv"
35
+ RANK = f"{DATA}/classify_newtags_ranked.csv"
36
+ FAIL = f"{DATA}/classify_failures.csv"
37
+ SPEND_GATE = 200 # cloud runs above this many books require an explicit yes
38
+
39
+
40
+ def prompt_for(desc, maxtags):
41
+ return ("You are tagging a fanfiction story. Return ONLY a JSON object with two arrays:\n"
42
+ f' "tags": tags from the CONTROLLED LIST below that clearly apply (exact spelling, at most {maxtags}; '
43
+ "be conservative; [] if vague; do NOT echo the whole list).\n"
44
+ ' "new": up to 3 SHORT reusable trope/genre/theme tags (Title Case) that clearly apply but are NOT in the '
45
+ "list and would be worth adding to the vocabulary. No plot specifics, character names, or fandoms; [] if none.\n"
46
+ f"CONTROLLED LIST: {', '.join(VOCAB)}\n\nDESCRIPTION:\n{desc[:1500]}\n\nJSON:")
47
+
48
+ def parse_resp(text, maxtags=6):
49
+ m = re.search(r"\{.*\}", text, re.S)
50
+ if not m: return [], []
51
+ try: obj = json.loads(m.group(0))
52
+ except Exception: return [], []
53
+ vt = [VLOW[str(t).strip().lower()] for t in obj.get("tags", []) if str(t).strip().lower() in VLOW]
54
+ if len(vt) > maxtags * 2: vt = [] # model echoed the list, not selecting
55
+ nt, seen = [], set()
56
+ for t in obj.get("new", []):
57
+ t = str(t).strip()
58
+ # first char must be alphanumeric: also blocks =/+/-/@ spreadsheet-formula injection in the review CSVs
59
+ if t and t[0].isalnum() and 1 < len(t) <= 40 and t.lower() not in VLOW and t.lower() not in seen:
60
+ seen.add(t.lower()); nt.append(t)
61
+ return vt[:maxtags], nt[:3]
62
+
63
+
64
+ # ---- engines ----
65
+ class Apple:
66
+ def __init__(self, model, timeout):
67
+ exe = f"{HERE}/afm" if os.path.exists(f"{HERE}/afm") else None
68
+ cmd = [exe] if exe else ["swift", f"{HERE}/afm.swift"]
69
+ self.p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1)
70
+ def ask(self, prompt):
71
+ self.p.stdin.write(prompt.replace("\n", "") + "\n"); self.p.stdin.flush()
72
+ return self.p.stdout.readline()
73
+
74
+ class Claude:
75
+ def __init__(self, model, timeout):
76
+ self.key = os.environ.get("ANTHROPIC_API_KEY")
77
+ if not self.key: raise SystemExit("claude engine needs ANTHROPIC_API_KEY (or use --engine apple).")
78
+ self.model = model or "claude-haiku-4-5-20251001"; self.timeout = timeout
79
+ def ask(self, prompt):
80
+ import urllib.request
81
+ body = json.dumps({"model": self.model, "max_tokens": 300,
82
+ "messages": [{"role": "user", "content": prompt}]}).encode()
83
+ req = urllib.request.Request("https://api.anthropic.com/v1/messages", data=body,
84
+ headers={"x-api-key": self.key, "anthropic-version": "2023-06-01", "content-type": "application/json"})
85
+ return json.load(urllib.request.urlopen(req, timeout=self.timeout))["content"][0]["text"]
86
+
87
+ class OpenAI:
88
+ def __init__(self, model, timeout):
89
+ self.key = os.environ.get("OPENAI_API_KEY")
90
+ if not self.key: raise SystemExit("openai engine needs OPENAI_API_KEY.")
91
+ self.model = model or "gpt-4o-mini"; self.timeout = timeout
92
+ def ask(self, prompt):
93
+ import urllib.request
94
+ body = json.dumps({"model": self.model, "max_tokens": 300,
95
+ "messages": [{"role": "user", "content": prompt}]}).encode()
96
+ req = urllib.request.Request("https://api.openai.com/v1/chat/completions", data=body,
97
+ headers={"Authorization": f"Bearer {self.key}", "content-type": "application/json"})
98
+ return json.load(urllib.request.urlopen(req, timeout=self.timeout))["choices"][0]["message"]["content"]
99
+
100
+ class Gemini:
101
+ # personal fanfic library: don't let safety filters drop mature/dark stories (the tag list itself lists such terms)
102
+ SAFE = [{"category": c, "threshold": "BLOCK_NONE"} for c in
103
+ ("HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH",
104
+ "HARM_CATEGORY_SEXUALLY_EXPLICIT", "HARM_CATEGORY_DANGEROUS_CONTENT")]
105
+ def __init__(self, model, timeout):
106
+ self.key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
107
+ if not self.key: raise SystemExit("gemini engine needs GEMINI_API_KEY (or GOOGLE_API_KEY).")
108
+ self.model = model or "gemini-2.5-flash"; self.timeout = timeout
109
+ def ask(self, prompt):
110
+ import urllib.request
111
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{self.model}:generateContent"
112
+ body = json.dumps({"contents": [{"parts": [{"text": prompt}]}], "safetySettings": self.SAFE,
113
+ "generationConfig": {"maxOutputTokens": 2048}}).encode() # bound cost; roomy for thinking models
114
+ req = urllib.request.Request(url, data=body,
115
+ headers={"content-type": "application/json", "x-goog-api-key": self.key}) # key in header, never in the URL
116
+ r = json.load(urllib.request.urlopen(req, timeout=self.timeout))
117
+ cands = r.get("candidates")
118
+ if not cands: raise RuntimeError("blocked:" + str(r.get("promptFeedback", {}).get("blockReason")))
119
+ parts = cands[0].get("content", {}).get("parts")
120
+ if not parts: raise RuntimeError("nocontent:" + str(cands[0].get("finishReason")))
121
+ return parts[0]["text"]
122
+
123
+ ENGINES = {"apple": Apple, "claude": Claude, "openai": OpenAI, "gemini": Gemini}
124
+
125
+
126
+ # ---- live run display ----
127
+ def sparkline(vals, width=28):
128
+ """Unicode sparkline of a numeric series (last `width` points), scaled to its max."""
129
+ vals = [v for v in vals][-width:]
130
+ if not vals: return ""
131
+ blocks = "▁▂▃▄▅▆▇█"
132
+ hi = max(vals)
133
+ if hi <= 0: return blocks[0] * len(vals)
134
+ return "".join(blocks[min(7, int(v * 8 / hi))] for v in vals)
135
+
136
+ class _Dashboard:
137
+ """Live display for a classify run: progress bar, running numbers (tagged / failed /
138
+ no-match / rate), a throughput sparkline, and the rising new-tag candidates.
139
+ rich renders it live; without rich it degrades to a checkpoint line every 25 books."""
140
+ BUCKET = 5.0 # seconds per throughput bucket
141
+
142
+ def __init__(self, todo_n, done_before, targets_n):
143
+ self.total, self.done_before, self.targets = todo_n, done_before, targets_n
144
+ self.n = self.tagged = self.fails = 0
145
+ self.newtags = collections.Counter()
146
+ self.t0 = time.monotonic(); self.hist = [0]
147
+ self.live = self.prog = self.task = None
148
+
149
+ def __enter__(self):
150
+ if RICH and self.total:
151
+ self.prog = Progress(TextColumn("[cyan]classifying"), BarColumn(bar_width=None),
152
+ MofNCompleteColumn(), TimeRemainingColumn(), console=_con)
153
+ self.task = self.prog.add_task("", total=self.total)
154
+ self.live = Live(self._render(), console=_con, refresh_per_second=4)
155
+ self.live.__enter__()
156
+ return self
157
+
158
+ def __exit__(self, *exc):
159
+ if self.live: self.live.__exit__(*exc)
160
+ return False
161
+
162
+ def update(self, vt, nt, err):
163
+ self.n += 1
164
+ if err: self.fails += 1
165
+ elif vt: self.tagged += 1
166
+ self.newtags.update(nt)
167
+ b = int((time.monotonic() - self.t0) // self.BUCKET)
168
+ while len(self.hist) <= b: self.hist.append(0)
169
+ self.hist[b] += 1
170
+ if self.live:
171
+ self.prog.update(self.task, advance=1)
172
+ self.live.update(self._render())
173
+ elif self.n % 25 == 0:
174
+ el = time.monotonic() - self.t0
175
+ print(f" +{self.n}/{self.total} … {self.tagged} tagged, {self.fails} failed, {self.n / el:.1f}/s")
176
+
177
+ def _render(self):
178
+ el = time.monotonic() - self.t0
179
+ rate = self.n / el if el > 1 else 0.0
180
+ g = Table.grid(padding=(0, 2))
181
+ g.add_row("[bold]this run[/]", f"{self.n}/{self.total}",
182
+ "[green]tagged[/]", str(self.tagged),
183
+ "[red]failed[/]", str(self.fails),
184
+ "[dim]no match[/]", str(max(0, self.n - self.tagged - self.fails)),
185
+ "[bold]rate[/]", f"{rate:.1f}/s")
186
+ parts = [self.prog, g]
187
+ spark = sparkline(self.hist)
188
+ if spark: parts.append(Text.assemble(("throughput ", "bold"), (spark, "cyan")))
189
+ if self.newtags:
190
+ top = " · ".join(f"{t} ×{c}" for t, c in self.newtags.most_common(5))
191
+ parts.append(Text.assemble(("rising candidates ", "bold"), (top, "magenta")))
192
+ return Panel(Group(*parts), border_style="cyan", padding=(0, 1),
193
+ title=f"classify — {self.done_before + self.n}/{self.targets} total")
194
+
195
+
196
+ # ---- apply: 'added_tags' + stamp #wrangled — standalone, no LLM calls ----
197
+ def apply_proposal():
198
+ if not os.path.exists(PROP):
199
+ raise SystemExit(f"no proposal to apply ({os.path.basename(PROP)} not found — run a classify pass first).")
200
+ con = ro_connect()
201
+ cur = collections.defaultdict(list)
202
+ for b, t in con.execute("SELECT l.book, t.name FROM books_tags_link l JOIN tags t ON t.id=l.tag"): cur[b].append(t)
203
+ have_wrangled = custom_column_id(con, "wrangled") is not None
204
+ chg = {}
205
+ for r in csv.DictReader(open(PROP)):
206
+ b = int(r["book_id"]); tags = [t for t in r.get("added_tags", "").split("; ") if t.strip()]
207
+ if tags: chg[str(b)] = sorted(set(cur.get(b, [])) | set(tags)) # union with current tags
208
+ ops = []
209
+ if not have_wrangled: # first run: create + backfill whole library as wrangled-now
210
+ ops.append({"op": "create_column", "label": "wrangled", "name": "Wrangled", "datatype": "datetime", "is_multiple": False})
211
+ ops.append({"op": "stamp_now", "field": "#wrangled", "books": None})
212
+ ops.append({"op": "set_field", "field": "tags", "values": chg})
213
+ ops.append({"op": "stamp_now", "field": "#wrangled", "books": [int(b) for b in chg]})
214
+ run_writer(ops)
215
+ # archive so a later --apply can't re-add tags you've since hand-removed (stale rows never re-apply)
216
+ arch = PROP.replace(".csv", f"_applied_{time.strftime('%Y%m%d-%H%M%S')}.csv")
217
+ os.rename(PROP, arch)
218
+ print(f"applied tags to {len(chg)} books + stamped #wrangled; proposal archived -> {os.path.basename(arch)}")
219
+
220
+
221
+ # ---- gather books (read-only) ----
222
+ def strip_html(s): return re.sub(r"<[^>]+>", " ", s or "").strip()
223
+
224
+ def book_text(path, limit=6000):
225
+ if not path or not os.path.exists(path): return ""
226
+ if path.lower().endswith(".epub"): # fast path: epub is a zip of XHTML
227
+ import zipfile
228
+ try:
229
+ z = zipfile.ZipFile(path); out = []
230
+ for n in z.namelist():
231
+ if not n.lower().endswith((".xhtml", ".html", ".htm")): continue
232
+ if z.getinfo(n).file_size > 2_000_000: continue # untrusted download: skip zip-bomb members
233
+ t = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", z.read(n).decode("utf-8", "ignore"))).strip()
234
+ if len(t) > 200: out.append(t) # skip nav/title pages
235
+ if sum(len(x) for x in out) > limit: break
236
+ return " ".join(out)[:limit]
237
+ except Exception: return ""
238
+ import tempfile # other formats (MOBI/PDF/DOCX/…): let calibre extract
239
+ try:
240
+ with tempfile.TemporaryDirectory() as td:
241
+ o = os.path.join(td, "o.txt")
242
+ subprocess.run(["ebook-convert", path, o], capture_output=True, timeout=180)
243
+ return re.sub(r"\s+", " ", open(o, errors="ignore").read()).strip()[:limit] if os.path.exists(o) else ""
244
+ except Exception: return ""
245
+
246
+ def gather(a):
247
+ """-> (targets [(book, text)], titles, needs) for books under --min-tags or changed since last wrangle."""
248
+ con = ro_connect(); c = con.cursor()
249
+ tagn = {b: 0 for (b,) in c.execute("SELECT id FROM books")}
250
+ for (b,) in c.execute("SELECT book FROM books_tags_link"): tagn[b] = tagn.get(b, 0) + 1
251
+ desc = {b: t for b, t in c.execute("SELECT book, text FROM comments")}
252
+ updated = wrangled = {}
253
+ if a.since or a.incremental:
254
+ updated = {b: str(v)[:10] for b, v in (read_custom_column(con, "#updated") or {}).items()}
255
+ if a.incremental: wrangled = {b: str(v)[:10] for b, v in (read_custom_column(con, "#wrangled") or {}).items()}
256
+ def needs(b): # changed since we last tagged it, or after an explicit --since date
257
+ if a.incremental and (b not in wrangled or updated.get(b, "") > wrangled.get(b, "")): return True
258
+ if a.since and updated.get(b, "") >= a.since: return True
259
+ return False
260
+ bookfile = {}
261
+ if a.text_fallback: # when the description is thin, sample the book's own text
262
+ bp = {b: p for b, p in c.execute("SELECT id, path FROM books")}
263
+ byb = {}
264
+ for b, fmt, name in c.execute("SELECT book, format, name FROM data"):
265
+ byb.setdefault(b, {})[fmt.upper()] = os.path.join(library(), bp[b], name + "." + fmt.lower())
266
+ for b, fm in byb.items():
267
+ bookfile[b] = fm.get("EPUB") or next(iter(fm.values())) # prefer EPUB, else any available format
268
+ def text_for(b):
269
+ d = strip_html(desc.get(b, ""))
270
+ if len(d) >= 80 or not a.text_fallback: return d
271
+ et = book_text(bookfile.get(b, ""))
272
+ return (d + " " + et).strip() if et else d
273
+ targets = [(b, text_for(b)) for b in tagn if tagn[b] < a.min_tags or needs(b)]
274
+ if a.incremental or a.since: print(f" incremental: {sum(1 for b in tagn if needs(b))} books changed since last wrangle")
275
+ targets = [(b, t) for b, t in targets if t and len(t) >= 40]
276
+ titles = {b: t for b, t in c.execute("SELECT id, title FROM books")}
277
+ if a.limit: targets = targets[:a.limit]
278
+ return targets, titles, needs
279
+
280
+
281
+ def classify_run(a):
282
+ targets, titles, needs = gather(a)
283
+ print(f"engine={a.engine} books to process (< {a.min_tags} tags, has description): {len(targets)}")
284
+
285
+ proposal, done = {}, set() # book -> (vocab_tags, proposed_new_tags)
286
+ if os.path.exists(PROP) and not a.fresh: # resume: skip books already in proposal
287
+ for r in csv.DictReader(open(PROP)):
288
+ bid = int(r["book_id"]); at = [t for t in r.get("added_tags", "").split("; ") if t.strip()]
289
+ proposal[bid] = (at, [t for t in r.get("proposed_new", "").split("; ") if t.strip()])
290
+ if (at or not a.text_fallback) and not needs(bid): done.add(bid) # re-process books changed since last wrangle
291
+ if done: print(f" resuming: {len(done)} already in proposal (pass --fresh to restart)")
292
+ def dump():
293
+ with open(PROP, "w", newline="") as f:
294
+ w = csv.writer(f); w.writerow(["book_id", "title", "added_tags", "proposed_new"])
295
+ for b, (vt, nt) in proposal.items(): w.writerow([b, titles.get(b, ""), "; ".join(vt), "; ".join(nt)])
296
+
297
+ todo = [(b, d) for b, d in targets if b not in done]
298
+ if a.batch: todo = todo[:a.batch]
299
+ if a.engine != "apple" and len(todo) > SPEND_GATE and not a.yes: # spend gate: cloud runs cost real money
300
+ import sys
301
+ msg = f"about to send {len(todo)} books to the {a.engine} API (costs money; --incremental/--batch shrink it)."
302
+ if sys.stdin.isatty() and input(f" {msg} proceed? [y/N] ").strip().lower() not in ("y", "yes"):
303
+ raise SystemExit("aborted (nothing sent).")
304
+ elif not sys.stdin.isatty():
305
+ raise SystemExit(f" {msg}\n non-interactive: re-run with --yes to confirm.")
306
+
307
+ eng = ENGINES[a.engine](a.model, a.timeout)
308
+ def ask_retry(prompt, tries=4):
309
+ err = ""
310
+ for k in range(tries):
311
+ try: return eng.ask(prompt), ""
312
+ except RuntimeError as e: # deterministic content block (no candidates) — retrying is futile
313
+ return "", str(e)[:140]
314
+ except Exception as e: # transient (HTTP 429/503, network) — back off and retry
315
+ err = f"{type(e).__name__}: {e}"[:140]
316
+ if k == tries - 1: return "", err
317
+ time.sleep(2 ** k)
318
+ def work(b, d):
319
+ out, err = ask_retry(prompt_for(d, a.max_tags)); vt, nt = parse_resp(out, a.max_tags); return b, err, vt, nt
320
+
321
+ failures = []
322
+ print(f" {len(todo)} to do this run, {a.workers} concurrent")
323
+ ex = ThreadPoolExecutor(max_workers=a.workers)
324
+ interrupted = False
325
+ try:
326
+ with _Dashboard(len(todo), len(done), len(targets)) as dash:
327
+ futs = [ex.submit(work, b, d) for b, d in todo]
328
+ for fut in as_completed(futs):
329
+ b, err, vt, nt = fut.result()
330
+ if err: failures.append((b, err))
331
+ if vt or nt: proposal[b] = (vt, nt)
332
+ dash.update(vt, nt, err)
333
+ if dash.n % 50 == 0: dump() # checkpoint regardless of UI
334
+ except KeyboardInterrupt:
335
+ # Ctrl+C: never start queued work, don't wait for in-flight requests (they're
336
+ # abandoned; runs are resumable so nothing is lost beyond the requests in the air)
337
+ interrupted = True
338
+ ex.shutdown(wait=False, cancel_futures=True)
339
+ else:
340
+ ex.shutdown()
341
+ dump()
342
+ if interrupted:
343
+ print(f"\n interrupted — {len(proposal)} results saved to the proposal; re-run to resume where you left off.")
344
+ if failures:
345
+ with open(FAIL, "w", newline="") as f:
346
+ w = csv.writer(f); w.writerow(["book_id", "title", "reason"])
347
+ for b, e in failures: w.writerow([b, titles.get(b, ""), e])
348
+ bytype = collections.Counter(e.split(":")[0].split(" ")[0] for _, e in failures)
349
+ print(f"failures: {len(failures)} -> {os.path.basename(FAIL)} by type: {dict(bytype)}")
350
+ print(" (recover blocked books with a no-policy engine: scourgify classify --engine apple)")
351
+
352
+ ranked = collections.Counter()
353
+ for vt, nt in proposal.values():
354
+ for t in nt: ranked[t] += 1
355
+ with open(RANK, "w", newline="") as f:
356
+ w = csv.writer(f); w.writerow(["proposed_tag", "count"])
357
+ for t, cnt in ranked.most_common(): w.writerow([t, cnt])
358
+ print(f"\nOutput 1 (apply): {sum(1 for v in proposal.values() if v[0])} books with vocab tags -> {os.path.basename(PROP)} (col 'added_tags')")
359
+ print(f"Output 2 (grow): {len(ranked)} distinct new-tag candidates -> {os.path.basename(RANK)} (review -> promote into defaults/classify_vocab.txt)")
360
+ if RICH and ranked:
361
+ tbl = Table(title="top new-tag candidates (review → promote into the vocab)")
362
+ tbl.add_column("count", justify="right", style="cyan"); tbl.add_column("proposed tag")
363
+ for tag, cnt in ranked.most_common(25): tbl.add_row(str(cnt), tag)
364
+ _con.print(tbl)
365
+ else:
366
+ print("top new-tag candidates:")
367
+ for t, cnt in ranked.most_common(25): print(f" {cnt:4} {t}")
368
+ print("\nApply vocab tags with: scourgify classify --apply (Calibre closed)")
369
+
370
+
371
+ def build_parser():
372
+ p = argparse.ArgumentParser(description="Content-based tagging from a controlled vocabulary (LLM engines; dry-run until --apply).")
373
+ p.add_argument("--engine", default="apple", choices=sorted(ENGINES), help="apple = on-device, free (default)")
374
+ p.add_argument("--apply", action="store_true", help="apply 'added_tags' from the proposal + stamp #wrangled (Calibre closed)")
375
+ p.add_argument("--incremental", action="store_true", help="only books whose #updated is newer than their #wrangled marker")
376
+ p.add_argument("--since", default="", metavar="DATE", help="(re)process books with #updated >= this ISO date")
377
+ p.add_argument("--fresh", action="store_true", help="ignore the existing proposal and restart (a full cloud pass costs real money)")
378
+ p.add_argument("--batch", type=int, default=0, metavar="N", help="process only N new books this run (re-run resumes)")
379
+ p.add_argument("--limit", type=int, default=0, metavar="N", help="hard cap on candidate books")
380
+ p.add_argument("--workers", type=int, default=8, metavar="N", help="concurrent API requests (cloud engines are I/O-bound)")
381
+ p.add_argument("--min-tags", type=int, default=2, metavar="N", help="process books with fewer than N tags")
382
+ p.add_argument("--max-tags", type=int, default=6, metavar="N", help="max vocab tags per book")
383
+ p.add_argument("--model", default="", help="override the per-engine default model")
384
+ p.add_argument("--timeout", type=int, default=60, metavar="S", help="per-request HTTP timeout")
385
+ p.add_argument("--text-fallback", action="store_true", help="sample the book's own prose when the description is thin")
386
+ p.add_argument("--yes", "-y", action="store_true", help="skip the large-cloud-run confirmation")
387
+ return p
388
+
389
+ def normalize(a):
390
+ """Post-parse invariants shared by the CLI and the wizard."""
391
+ if a.engine == "apple": a.workers = 1 # apple = one subprocess pipe, not thread-safe
392
+ library() # fail fast with a clear message
393
+ os.makedirs(DATA, exist_ok=True)
394
+ return a
395
+
396
+ def main():
397
+ a = normalize(build_parser().parse_args())
398
+ if a.apply: apply_proposal()
399
+ else: classify_run(a)
400
+
401
+
402
+ if __name__ == "__main__":
403
+ main()
scourgify/cli.py ADDED
@@ -0,0 +1,31 @@
1
+ """Single `scourgify` command.
2
+
3
+ Dispatch: bare -> wizard (via wrangle); setup/audit/apply -> wrangle;
4
+ classify -> classify; staleness -> staleness. Each tool keeps its own argparse,
5
+ so we just hand off argv. Imports are lazy so `scourgify --version` stays cheap.
6
+ """
7
+ import sys
8
+
9
+ from scourgify import __version__
10
+
11
+
12
+ def main():
13
+ argv = sys.argv[1:]
14
+ if argv and argv[0] in ("-V", "--version"):
15
+ print(f"scourgify {__version__}")
16
+ return
17
+ if argv and argv[0] == "classify":
18
+ from scourgify import classify
19
+ sys.argv = ["scourgify classify", *argv[1:]]
20
+ return classify.main()
21
+ if argv and argv[0] == "staleness":
22
+ from scourgify import staleness
23
+ sys.argv = ["scourgify staleness", *argv[1:]]
24
+ return staleness.main()
25
+ # setup / audit / apply / (none -> wizard) all live in wrangle's main()
26
+ from scourgify import wrangle
27
+ return wrangle.main()
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
scourgify/common.py ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python3
2
+ """Shared core for the standalone tools (wrangle / classify / staleness).
3
+
4
+ Everything here runs under plain system python3. It owns the four things the tools
5
+ used to each carry a private copy of:
6
+ - library resolution (CALIBRE_LIBRARY) — checked lazily, never at import time
7
+ - read-only sqlite access + link-table-aware custom-column reading
8
+ - normalization helpers (norm, ascii_fold) and the minimal TOML config reader
9
+ - the single write funnel: run_writer() -> calibre-debug -e _writer.py
10
+ (backs up metadata.db to /tmp before every write; refuses to run while Calibre is open)
11
+ """
12
+ import os, re, sys, sqlite3, collections, unicodedata
13
+
14
+ HERE = os.path.dirname(os.path.abspath(__file__)) # the installed package dir (read-only)
15
+ DEFAULTS = os.path.join(HERE, "defaults") # bundled generic maps — ship inside the package
16
+ # Per-run + per-user files live in the working directory, not site-packages: `data/`
17
+ # (proposals/intermediates), config.toml, and overrides/ are all resolved against CWD.
18
+ # When run from the repo via `uv run`, CWD == repo root, so dev layout is unchanged.
19
+ DATA = os.path.join(os.getcwd(), "data") # personal review maps, proposals, intermediates (gitignored)
20
+
21
+
22
+ # ---------------- library resolution (lazy — importing this module never exits) ----------------
23
+ def library():
24
+ lib = os.path.expanduser(os.environ.get("CALIBRE_LIBRARY", ""))
25
+ if not lib:
26
+ raise SystemExit("Set CALIBRE_LIBRARY to your Calibre library folder (the one containing metadata.db).")
27
+ return lib
28
+
29
+ def db_path():
30
+ return os.path.join(library(), "metadata.db")
31
+
32
+ def ro_connect():
33
+ return sqlite3.connect(f"file:{db_path()}?mode=ro", uri=True)
34
+
35
+
36
+ # ---------------- normalization ----------------
37
+ def norm(s):
38
+ s = str(s).strip().lower(); s = re.sub(r"[\[\]\(\)]", "", s); s = s.replace("&", "and")
39
+ s = re.sub(r"[\s_\-/]+", " ", s); s = re.sub(r"[^\w ]", "", s, flags=re.UNICODE); return s.strip()
40
+
41
+ def ascii_fold(s):
42
+ s = s.replace("’", "'").replace("‘", "'").replace("“", '"').replace("”", '"').replace("…", "...").replace("–", "-").replace("—", "-")
43
+ return unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
44
+
45
+
46
+ # ---------------- custom columns (single- and multi-value; link-table aware) ----------------
47
+ def custom_column_id(con, label):
48
+ r = con.execute("SELECT id FROM custom_columns WHERE label=?", (label.lstrip("#"),)).fetchone()
49
+ return r[0] if r else None
50
+
51
+ def read_custom_column(con, label, multi=False):
52
+ """{book: value} (or {book: [values]} with multi=True) for a custom column; None if it doesn't exist.
53
+ Handles both storage shapes: a books_custom_column_N_link table, or an inline `book` column."""
54
+ i = custom_column_id(con, label)
55
+ if i is None: return None
56
+ link = f"books_custom_column_{i}_link"
57
+ has_link = con.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (link,)).fetchone()
58
+ q = (f"SELECT l.book, v.value FROM {link} l JOIN custom_column_{i} v ON v.id=l.value" if has_link
59
+ else f"SELECT book, value FROM custom_column_{i}")
60
+ out = collections.defaultdict(list) if multi else {}
61
+ for b, v in con.execute(q):
62
+ if multi: out[b].append(v)
63
+ else: out[b] = v
64
+ return dict(out)
65
+
66
+
67
+ # ---------------- config (minimal TOML reader; no tomllib dependency) ----------------
68
+ def load_config(path=None):
69
+ cfg = {"columns": {"fandoms": "#fandoms", "characters": "#characters", "relationships": "#relationships",
70
+ "genres": "#genres", "status": "#status", "tags": "tags"},
71
+ "behavior": {"fold_characters": True, "ascii_only_tags": True, "au_as": "genre", "crossover_as": "genre",
72
+ "reincarnation_as": "genre", "time_travel_as": "genre", "fold_ratings": False,
73
+ "keep_categories": True, "tropes_as": "tag"},
74
+ "overrides": {"dir": "overrides"}}
75
+ p = path or os.path.join(os.getcwd(), "config.toml") # user config: CWD, not the package
76
+ if os.path.exists(p):
77
+ sec = None
78
+ for raw in open(p):
79
+ ln = raw.strip()
80
+ if not ln or ln.startswith("#"): continue
81
+ if ln.startswith("["): sec = ln[1:ln.index("]")].strip(); cfg.setdefault(sec, {}); continue
82
+ if "=" in ln and sec:
83
+ k, v = ln.split("=", 1); k = k.strip(); v = v.strip()
84
+ if v[:1] in ("\"", "'"): # quoted string -> value between the quotes (# allowed inside)
85
+ v = v[1:].split(v[0], 1)[0]
86
+ else: # bool/number -> strip any trailing inline comment
87
+ v = v.split("#", 1)[0].strip()
88
+ if v.lower() in ("true", "false"): v = v.lower() == "true"
89
+ cfg[sec][k] = v
90
+ return cfg
91
+
92
+
93
+ # ---------------- the write funnel ----------------
94
+ def calibre_open():
95
+ import subprocess, shutil
96
+ if not shutil.which("pgrep"): return False
97
+ out = subprocess.run(["pgrep", "-fl", "calibre"], capture_output=True, text=True).stdout
98
+ return any("calibre" in l.lower() and not any(x in l for x in ("calibre-debug", "pgrep", "wrangle", "_writer", "classify"))
99
+ for l in out.splitlines())
100
+
101
+ def run_writer(ops):
102
+ """Apply a list of write-ops through Calibre by shelling out to `calibre-debug -e _writer.py`.
103
+ Automatically snapshots metadata.db to /tmp first — every write path gets a rollback point for free."""
104
+ import json, time, tempfile, subprocess, shutil
105
+ ops = [o for o in ops if o.get("op") != "set_field" or o.get("values")]
106
+ if not ops: print(" (nothing to write)"); return
107
+ if calibre_open(): raise SystemExit("Calibre is running — close it first (it locks metadata.db), then re-run.")
108
+ cb = shutil.which("calibre-debug") or "/Applications/calibre.app/Contents/MacOS/calibre-debug"
109
+ if not (shutil.which("calibre-debug") or os.path.exists(cb)): raise SystemExit("calibre-debug not found (install Calibre's CLI tools).")
110
+ bak = f"/tmp/ff_{int(time.time())}.db"
111
+ shutil.copy2(db_path(), bak)
112
+ print(f" backup: {bak}")
113
+ f = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False); json.dump(ops, f); f.close()
114
+ print(" → writing via calibre-debug …")
115
+ rc = subprocess.run([cb, "-e", os.path.join(HERE, "_writer.py"), "--", f.name],
116
+ env={**os.environ, "CALIBRE_LIBRARY": library()}).returncode
117
+ os.unlink(f.name)
118
+ if rc != 0: raise SystemExit(f"writer failed (exit {rc}) — library backup at {bak}")