scourgify 1.0.0__tar.gz

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.
Files changed (48) hide show
  1. scourgify-1.0.0/.github/workflows/publish.yml +61 -0
  2. scourgify-1.0.0/.gitignore +28 -0
  3. scourgify-1.0.0/CLAUDE.md +153 -0
  4. scourgify-1.0.0/LICENSE +21 -0
  5. scourgify-1.0.0/PKG-INFO +259 -0
  6. scourgify-1.0.0/README.md +237 -0
  7. scourgify-1.0.0/attic/README.md +12 -0
  8. scourgify-1.0.0/attic/apply.py +175 -0
  9. scourgify-1.0.0/attic/apply_asciitags.py +26 -0
  10. scourgify-1.0.0/attic/apply_char_fixes.py +22 -0
  11. scourgify-1.0.0/attic/apply_fff_config.py +38 -0
  12. scourgify-1.0.0/attic/apply_more.py +92 -0
  13. scourgify-1.0.0/attic/apply_other.py +40 -0
  14. scourgify-1.0.0/attic/apply_recents.py +63 -0
  15. scourgify-1.0.0/attic/apply_relationships.py +63 -0
  16. scourgify-1.0.0/attic/apply_tropes.py +64 -0
  17. scourgify-1.0.0/attic/dryrun.py +181 -0
  18. scourgify-1.0.0/attic/fff_status_writable.py +24 -0
  19. scourgify-1.0.0/attic/generate_followups.py +123 -0
  20. scourgify-1.0.0/attic/generate_maps.py +217 -0
  21. scourgify-1.0.0/attic/recover_xianxia.py +51 -0
  22. scourgify-1.0.0/build_defaults.py +123 -0
  23. scourgify-1.0.0/config.toml +26 -0
  24. scourgify-1.0.0/pyproject.toml +60 -0
  25. scourgify-1.0.0/src/scourgify/__init__.py +3 -0
  26. scourgify-1.0.0/src/scourgify/__main__.py +4 -0
  27. scourgify-1.0.0/src/scourgify/_writer.py +45 -0
  28. scourgify-1.0.0/src/scourgify/afm.swift +30 -0
  29. scourgify-1.0.0/src/scourgify/classify.py +403 -0
  30. scourgify-1.0.0/src/scourgify/cli.py +31 -0
  31. scourgify-1.0.0/src/scourgify/common.py +118 -0
  32. scourgify-1.0.0/src/scourgify/defaults/characters.csv +220 -0
  33. scourgify-1.0.0/src/scourgify/defaults/classify_vocab.txt +120 -0
  34. scourgify-1.0.0/src/scourgify/defaults/decompose.csv +7 -0
  35. scourgify-1.0.0/src/scourgify/defaults/fandom_blocklist.txt +57 -0
  36. scourgify-1.0.0/src/scourgify/defaults/fandoms.csv +277 -0
  37. scourgify-1.0.0/src/scourgify/defaults/genres_allow.txt +64 -0
  38. scourgify-1.0.0/src/scourgify/defaults/genres_canon.csv +12 -0
  39. scourgify-1.0.0/src/scourgify/defaults/genres_split.csv +7 -0
  40. scourgify-1.0.0/src/scourgify/defaults/junk.txt +280 -0
  41. scourgify-1.0.0/src/scourgify/defaults/ratings.txt +19 -0
  42. scourgify-1.0.0/src/scourgify/defaults/tropes.csv +859 -0
  43. scourgify-1.0.0/src/scourgify/staleness.py +72 -0
  44. scourgify-1.0.0/src/scourgify/ui.py +67 -0
  45. scourgify-1.0.0/src/scourgify/wizard.py +245 -0
  46. scourgify-1.0.0/src/scourgify/wrangle.py +460 -0
  47. scourgify-1.0.0/tests/test_core.py +147 -0
  48. scourgify-1.0.0/uv.lock +56 -0
@@ -0,0 +1,61 @@
1
+ name: Publish
2
+
3
+ # Two triggers, one workflow:
4
+ # - push to main -> auto-publish to TestPyPI (every release commit)
5
+ # - workflow_dispatch -> pick target manually (testpypi or pypi)
6
+ #
7
+ # Production PyPI stays gated behind manual dispatch — uploads are permanent, so
8
+ # target=pypi should be a deliberate human action, never an accidental push.
9
+ #
10
+ # Trusted Publishing (OIDC) authenticates both indexes — no API tokens stored.
11
+ # Requires a matching trusted publisher registered on PyPI *and* TestPyPI:
12
+ # owner=elfensky, repo=scourgify, workflow=publish.yml, environment=pypi
13
+ # (if any of those differ in your PyPI registration, update them here to match).
14
+
15
+ on:
16
+ push:
17
+ branches: [main]
18
+ workflow_dispatch:
19
+ inputs:
20
+ target:
21
+ description: "Where to publish"
22
+ type: choice
23
+ options:
24
+ - testpypi
25
+ - pypi
26
+ default: testpypi
27
+
28
+ jobs:
29
+ publish:
30
+ runs-on: ubuntu-latest
31
+ environment:
32
+ name: pypi
33
+ url: ${{ inputs.target == 'pypi' && 'https://pypi.org/project/scourgify/' || 'https://test.pypi.org/project/scourgify/' }}
34
+ permissions:
35
+ id-token: write # required for Trusted Publishing (OIDC)
36
+ steps:
37
+ - uses: actions/checkout@v6
38
+
39
+ - name: Install uv
40
+ uses: astral-sh/setup-uv@v8.1.0
41
+ with:
42
+ python-version: "3.13"
43
+
44
+ - name: Verify before publishing
45
+ run: |
46
+ uv sync
47
+ uv run tests/test_core.py
48
+
49
+ - name: Build sdist and wheel
50
+ run: uv build
51
+
52
+ # Push to main (inputs.target unset) and dispatch target=testpypi land here.
53
+ - name: Publish to TestPyPI
54
+ if: github.event_name == 'push' || inputs.target == 'testpypi'
55
+ run: uv publish --index testpypi --trusted-publishing always
56
+
57
+ # PyPI only on explicit dispatch target=pypi — a push to main can never
58
+ # reach this step.
59
+ - name: Publish to PyPI
60
+ if: github.event_name == 'workflow_dispatch' && inputs.target == 'pypi'
61
+ run: uv publish --trusted-publishing always
@@ -0,0 +1,28 @@
1
+ # Personal library data — review maps, proposals, intermediates (derived from YOUR Calibre library)
2
+ data/
3
+
4
+ # Ignore stray CSVs anywhere; the bundled generic defaults/ are the only tracked CSVs
5
+ *.csv
6
+ !src/scourgify/defaults/*.csv
7
+
8
+ # Calibre DB snapshots / backups
9
+ *.db
10
+
11
+ # Generated per-user config is fine to ship as a default, but ignore local overrides
12
+ overrides/
13
+
14
+ # Python / OS cruft
15
+ __pycache__/
16
+ *.pyc
17
+ .venv/
18
+ .DS_Store
19
+
20
+ # compiled Apple FM bridge binary (build from afm.swift; lives next to it in the package)
21
+ /afm
22
+ src/scourgify/afm
23
+ *.o
24
+ classify_state.json
25
+
26
+ # build artifacts
27
+ /dist/
28
+ *.egg-info/
@@ -0,0 +1,153 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ scourgify normalizes a [FanFicFare](https://github.com/JimmXinu/FanFicFare)-imported
6
+ [Calibre](https://calibre-ebook.com) library — consolidating tags, fandoms, characters, relationships,
7
+ genres, and status. It is data-driven (bundled `defaults/` + per-user `overrides/` + `config.toml`),
8
+ audit-first, and reversible. Python stdlib + Calibre's own CLI + `rich`; tests in `tests/` (plain asserts,
9
+ no framework needed). **rich dependency rules by surface:** `wizard.py`/`ui.py` may hard-import rich (the
10
+ wizard is rich-first; `ui.py` raises a friendly install hint if missing). The core tools
11
+ (`wrangle`/`classify`/`staleness`) import rich under `try/except` and every rich use there needs a plain
12
+ fallback (scripting/CI without rich must keep working). `_writer.py` runs under `calibre-debug` (Calibre's
13
+ bundled Python has empty site-packages) — never import rich (or `ui`/`wizard`) there.
14
+
15
+ ## Running it
16
+
17
+ Everything keys off `CALIBRE_LIBRARY` (the folder containing `metadata.db`):
18
+
19
+ ```bash
20
+ export CALIBRE_LIBRARY="$HOME/Calibre/fanfiction"
21
+ uv run scourgify # no args = the interactive wizard (rich required; TTY only)
22
+ uv run scourgify setup # interactive health check + setup (FanFicFare, columns, config)
23
+ uv run scourgify audit # read-only dry-run of every pass
24
+ uv run scourgify apply --apply # write changes (Calibre CLOSED for the write step)
25
+ ```
26
+
27
+ (`uv run scourgify` from a checkout; an installed copy — `pipx install scourgify` — drops the `uv run`.)
28
+
29
+ **`wizard.py`** (launched by bare `scourgify`) is a guiding menu wizard: on launch it
30
+ detects an un-set-up library (missing columns / no config.toml) and routes to setup; the header shows
31
+ books, column health, new/changed-since-last-run count, pending proposal, Calibre-open warning; the
32
+ menu default adapts via `recommend()` (setup → review-pending → maintenance → audit). Item 0
33
+ (`act_flow`) is a guided maintenance run sequencing wrangle → staleness → classify → review with
34
+ per-step explanations and skips. It calls the same engine functions the subcommands do (previews →
35
+ confirm → write), so guardrails and auto-backup apply identically; guardrail `SystemExit`s return to
36
+ the menu. `ui.py` holds the shared rich Console + prompt helpers (lintle `term.py` pattern). classify
37
+ runs render a live dashboard (`classify._Dashboard`: progress, tagged/failed/rate, throughput
38
+ sparkline, rising candidates).
39
+
40
+ **Packaging.** The code is a proper installable package under `src/scourgify/` (hatchling; on PyPI as
41
+ `scourgify`). The single `scourgify` console command (`cli.py`) dispatches argv to the tools: bare → wizard,
42
+ `setup`/`audit`/`apply` → wrangle, `classify`, `staleness`. Bundled `defaults/` (and `_writer.py`, `afm.swift`)
43
+ ship **inside** the package (read-only at runtime); per-user `config.toml`, `overrides/`, and `data/` resolve
44
+ against the **current working directory** — so `uv run` from the repo (CWD = repo root) behaves exactly as
45
+ before, while an installed copy writes proposals under wherever it's invoked. `common.HERE` is the package
46
+ dir (use it only for shipped read-only files); anything user-writable keys off `os.getcwd()`.
47
+
48
+ **Everything runs under normal CPython** — the installed `scourgify` command, `uv run scourgify`, or plain
49
+ `python3` with rich installed. The core operating rule is about *reads vs writes*, not which interpreter:
50
+ - **Reads** (audit, classify proposal, setup health check) — read-only `sqlite3 ... mode=ro`; fine while Calibre is open.
51
+ - **Writes** — the standalone tool computes the change-set, serializes it to JSON, and shells out **once** to
52
+ `calibre-debug -e _writer.py -- ops.json` (Calibre's API is the only fast batch-write path; `calibredb set_metadata`
53
+ is one book per process). `run_writer()` (in **common.py**; imported by wrangle/classify/staleness) does this,
54
+ **automatically snapshots metadata.db to `/tmp/ff_<ts>.db` first** (prints the path — that's the rollback), and
55
+ **refuses to run while Calibre is open** (it locks the DB). The user never types `calibre-debug`. Master rollback =
56
+ the full "Export all Calibre data" backup.
57
+ - **`_writer.py`** is the only file that imports Calibre — a generic ops executor (`create_column` / `set_field` /
58
+ `stamp_now` / `set_pref`).
59
+ - **`common.py`** is the shared core: lazy `CALIBRE_LIBRARY` resolution (importing any module never exits),
60
+ `ro_connect()`, link-table-aware `read_custom_column()`, `norm`/`ascii_fold`, the minimal TOML `load_config()`,
61
+ and `run_writer()`. Don't re-implement any of these in a tool script.
62
+
63
+ Verification: `uv run tests/test_core.py` (plain asserts, pytest-compatible, no library/network needed) pins the
64
+ pure core — `transform`, trope-chain resolution, `parse_resp`, the TOML reader. `scourgify audit` remains the
65
+ against-your-library check: full new state, before/after counts, and SAFETY lines asserting **no book loses its
66
+ last fandom or character** plus a **tag mass-deletion guardrail** (`apply` aborts if tags would shrink >25% and
67
+ >200 assignments — the signature of an over-broad junk rule; `--force` overrides).
68
+
69
+ ## Maintenance loop (after new FanFicFare downloads)
70
+
71
+ **Order matters: deterministic cleanup (wrangle) FIRST, content tagging (classify) second** — raw
72
+ junk tags inflate a book's tag count and would hide it from the classifier's sparse-book targeting.
73
+
74
+ ```
75
+ FFF fetch → uv run scourgify apply --apply # 1. junk-drop/canonicalize the new raw tags (idempotent)
76
+ → uv run scourgify staleness --apply # 2. free; re-derive #status from #updated age
77
+ → uv run scourgify classify --incremental # 3. cheap; only books changed since last wrangle
78
+ → review data/classify_proposal.csv # 4.
79
+ → uv run scourgify classify --apply # 5. Calibre closed (writes shell to calibre-debug)
80
+ ```
81
+
82
+ (Or the wizard: `uv run scourgify` → menu 3 → 4 → 5 → 6.)
83
+
84
+ **⚠️ Cost:** a full Gemini `classify --fresh` pass over the library ≈ **€50** in tokens. Never run `--fresh`
85
+ casually — use `--incremental` (only changed/new books), `--batch N`, or `--engine apple` (free, on-device).
86
+ Confirm with the user before any full cloud run (classify itself gates cloud runs >200 books behind a
87
+ confirmation / `--yes`). **Do NOT bulk re-fetch FFF metadata** — it re-pollutes columns not protected by
88
+ `custom_cols_newonly`.
89
+
90
+ ## Architecture
91
+
92
+ **`wrangle.py` — the unified engine.** Subcommands `audit` / `apply` / `setup`. Loads three layers:
93
+ `defaults/` (generic, shipped) ← `config.toml` (column map + behavior toggles) ← `overrides/` (per-user,
94
+ **gitignored**, same file formats, wins on conflict). `load_maps()` builds the in-memory maps; `transform()`
95
+ is the per-book core: fandom alias→canonical, character folding (global + fandom-scoped), genre
96
+ split→canon→route, tag junk-drop / trope-route / redundancy-strip. Strips a redundant tag only when the
97
+ concept already lives in that book's structured column (**backfill-before-strip**).
98
+
99
+ **The FFF→Calibre column model** (see README "FanFicFare → Calibre columns"): `category`→`#fandoms`,
100
+ `characters`→`#characters`, `ships`→`#relationships`, `genre`→`#genres`, `status`→`#status`, real
101
+ `series`→builtin Series. Two gotchas the tool exists to fix: `include_in_series:category` stuffing fandoms
102
+ into the numbered Series field, and aggressive franchise unification (e.g. all Fate/Nasuverse → `Type-Moon`).
103
+
104
+ **`classify.py` — content-based tagging** (separate from the deterministic engine; uses an LLM). Two outputs
105
+ per book: `added_tags` (chosen from the controlled vocab `defaults/classify_vocab.txt` → applied) and
106
+ `proposed_new` (novel candidates → aggregated to `classify_newtags_ranked.csv` for review→promotion, so the
107
+ vocab grows without freeform noise). Engines `--engine apple|claude|openai|gemini` (keys via env:
108
+ `ANTHROPIC_/OPENAI_/GEMINI_API_KEY`); `apple` = on-device, free, single-threaded. Concurrency via
109
+ `ThreadPoolExecutor` (`--workers`), retry/backoff, incremental save + resume. `--text-fallback` samples the
110
+ book's own prose (EPUB via zipfile, other formats via `ebook-convert`) when the `#comments` description is
111
+ too thin. `--incremental` re-tags only books whose `#updated` is newer than their per-book **`#wrangled`**
112
+ datetime marker (auto-created and stamped on `--apply`) — state lives in the library, no external file.
113
+ Proposals/outputs live in `data/` (gitignored); `--apply` archives the proposal to
114
+ `classify_proposal_applied_<ts>.csv` so stale rows never re-add hand-removed tags.
115
+
116
+ **`staleness.py`** — re-derives `#status` for the activity family {In-Progress, Hiatus, Abandoned} from
117
+ `#updated` age (`<2y`→In-Progress, `2–5y`→Hiatus, `≥5y`→Abandoned); idempotent + self-correcting on re-run.
118
+ Completed/Dropped/Rewritten and date-less books are never touched.
119
+
120
+ **`build_defaults.py`** — maintainer tool: regenerates `defaults/` from the source library's gitignored
121
+ review-map CSVs (in `data/`). Curated cross-library knowledge (e.g. franchise unification) lives in its `CURATED_FAN`.
122
+
123
+ ## Gotchas worth knowing before editing
124
+
125
+ - **Column creation needs the legacy DB object**, then a reopen: `DB(LIB).create_custom_column(...)` →
126
+ re-instantiate `DB(LIB).new_api` before the new column is usable in the same process. `Cache` has no
127
+ `all_field_keys` — use `api.field_metadata.all_field_keys()`.
128
+ - **Single-value columns may use a link table.** Read a custom column by detecting
129
+ `books_custom_column_{id}_link`; fall back to the `book` column in `custom_column_{id}` if absent.
130
+ - **`tropes.csv` is parsed leniently** (`read_tropes` + `resolve_trope_chains` in `wrangle.py`):
131
+ delimiter-sniffed (`,` or `;`), positional columns, unknown route → `tag` (so freeform notes don't crash),
132
+ and variant→canonical chains/cycles are resolved to a terminal at load. Hand-editing it is expected.
133
+ - **Gemini hard-blocks ~1% of extreme content** as `PROHIBITED_CONTENT` (non-configurable; `safetySettings`
134
+ only relaxes the 4 HARM categories). It's deterministic — recover those books with `--engine openai` or
135
+ `--engine apple`. `classify.py` logs failures to `classify_failures.csv`.
136
+ - **No `tomllib`** under `calibre-debug`'s Python — `common.py` ships a minimal TOML reader (quote-aware so
137
+ values can contain `#`; tolerates trailing comments on section headers).
138
+
139
+ ## Repo conventions
140
+
141
+ - **Personal library data is gitignored and lives in `data/`** (review maps, proposals, cluster
142
+ intermediates); `.gitignore` also ignores stray `*.csv` **except** `!src/scourgify/defaults/*.csv`, plus
143
+ `*.db`, `overrides/`, the compiled `afm` binary (`/afm` and `src/scourgify/afm`), and build artifacts
144
+ (`/dist/`, `*.egg-info/`). Only the generic `defaults/` ship (bundled inside the package).
145
+ - **`attic/`** holds the original single-purpose pipeline (`apply_*.py`, `generate_*.py`, `dryrun.py`,
146
+ `recover_xianxia.py`), kept as provenance — see `attic/README.md`. `scourgify` supersedes it; the attic
147
+ scripts read CSVs from their own directory and predate the auto-backup, so prefer the live tools.
148
+ - `src/scourgify/afm.swift` is the Apple Foundation Models bridge for `scourgify classify --engine apple`;
149
+ it ships in the package (a `swift` toolchain runs it as-is), or build the faster binary with
150
+ `swiftc -O src/scourgify/afm.swift -o src/scourgify/afm` (requires macOS 26+ / Apple Intelligence).
151
+ - **Publishing:** `uv build` → `uv publish --index testpypi` (dry-run) → `uv publish` (PyPI). The wheel must
152
+ contain `scourgify/defaults/*`, `_writer.py`, `afm.swift` and **not** `data/`, `overrides/`, or the `afm`
153
+ binary — verify with `unzip -l dist/*.whl` after a layout change.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 elfensky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,259 @@
1
+ Metadata-Version: 2.4
2
+ Name: scourgify
3
+ Version: 1.0.0
4
+ Summary: Normalize and consolidate tags/fandoms/characters/genres in a FanFicFare-imported Calibre library
5
+ Project-URL: Homepage, https://github.com/elfensky/scourgify
6
+ Project-URL: Repository, https://github.com/elfensky/scourgify
7
+ Project-URL: Issues, https://github.com/elfensky/scourgify/issues
8
+ Author-email: Andrei Lavrenov <andrei@lav.ren>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: calibre,ebook,epub,fanficfare,fanfiction,metadata,normalization,tags
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: rich>=13
21
+ Description-Content-Type: text/markdown
22
+
23
+ # scourgify
24
+
25
+ The tag-wrangler / canonizer for your fanfiction library — normalize and consolidate **tags,
26
+ fandoms, characters, relationships and genres** in a
27
+ [FanFicFare](https://github.com/JimmXinu/FanFicFare)-imported [Calibre](https://calibre-ebook.com)
28
+ library. Data-driven from **~1,700 bundled generic defaults**, fully customizable, audit-first and
29
+ reversible.
30
+
31
+ ```bash
32
+ pipx install scourgify # or: uv tool install scourgify (one dependency: rich)
33
+
34
+ export CALIBRE_LIBRARY="$HOME/Calibre/fanfiction" # folder containing metadata.db
35
+ scourgify # ← the wizard: everything below, menu-driven
36
+ ```
37
+
38
+ Requires **Calibre installed** — the tool reads via read-only sqlite and shells out to Calibre's own
39
+ `calibre-debug` for writes. From a checkout, `uv run scourgify` (or `uvx --from . scourgify`) runs it
40
+ without installing; [uv](https://docs.astral.sh/uv/) handles the environment (one dependency: rich).
41
+
42
+ **The wizard** (no arguments) is the intended way in — and it steers you: on a fresh library it
43
+ detects missing columns/config and offers to run **setup** immediately; afterwards the status header
44
+ shows book count, column health, how many books are **new/changed since the last run**, and any
45
+ pending proposal, and the menu **defaults to whatever the library needs next**. Menu item **0 —
46
+ maintenance** walks the whole loop in the right order (wrangle → staleness → classify → review),
47
+ each step explained, previewed, and skippable. Classify runs show a **live dashboard** (progress +
48
+ tagged/failed/rate + throughput sparkline + rising tag candidates). Every write previews first,
49
+ asks for confirmation, and auto-backs-up `metadata.db`.
50
+
51
+ Each step is also a plain scriptable subcommand:
52
+
53
+ ```bash
54
+ scourgify setup # interactive health check + setup (FanFicFare, columns, config)
55
+ scourgify audit # read-only dry-run report of every pass
56
+ scourgify apply --apply # write changes (Calibre CLOSED for this step)
57
+ ```
58
+
59
+ Everything runs under plain `python3`. The tool reads via read-only sqlite and, for the actual writes,
60
+ shells out **once** to `calibre-debug -e _writer.py` (Calibre's API is the only fast batch-write path) —
61
+ so any command that writes (`apply --apply`, `setup` creating columns, `classify.py --apply`) needs
62
+ Calibre **closed**, and refuses to run while it's open. You never invoke `calibre-debug` yourself.
63
+
64
+ **`setup` is the first-run wizard + re-runnable health check.** It verifies, with `✓/⚠/✗` status and
65
+ `Y/n` prompts (default-yes; `--yes` to auto-accept): the library; that the **FanFicFare plugin is
66
+ installed and configured**, flagging + offering to fix the known gotchas (fandom-vs-series mapping,
67
+ `include_in_series:category`, unprotected `#genres`); that every needed column exists (`#fandoms`,
68
+ `#characters`, `#relationships`, `#genres`, `#status`, plus `#updated` and `#wrangled` for staleness /
69
+ incremental classification), creating any that are missing; and writes `config.toml` (preserving your
70
+ behavior toggles). Safe to re-run anytime.
71
+
72
+ **rich** is required for the wizard and powers the live dashboards/tables everywhere else; the plain
73
+ subcommands still degrade to text without it (rich is `try/except`-imported in the core tools, so
74
+ scripting/CI without rich keeps working, and `_writer.py` under Calibre's bundled Python needs none).
75
+
76
+ The engine reads:
77
+ - **`defaults/`** — bundled, generic fanfic knowledge (FFN `Harry P.`→`Harry Potter`, JP→English
78
+ fandom titles, `no beta we die like…` = junk, …). Ships with the tool; not specific to anyone.
79
+ - **`config.toml`** — your column mapping + opinionated behavior toggles (generated by `setup`).
80
+ - **`overrides/`** *(optional)* — your own files (same formats as `defaults/`) that **extend and win
81
+ over** the defaults.
82
+
83
+ ---
84
+
85
+ ## FanFicFare → Calibre columns (how the linking works)
86
+
87
+ FanFicFare scrapes metadata fields from each story and writes them into Calibre columns. The mapping
88
+ lives in the FFF Calibre-plugin config (stored per-library in the `metadata.db` preference
89
+ `namespaced:FanFicFarePlugin:settings` → key `custom_cols`). scourgify's `setup` reads that
90
+ mapping, and creates any recommended columns you're missing.
91
+
92
+ **Recommended mapping** (FFF metadata field → Calibre column):
93
+
94
+ | FanFicFare field | Calibre column | Type | Holds |
95
+ |---|---|---|---|
96
+ | `category` | `#fandoms` | text, multiple | fandom(s) — **map this to `category`, not `series`** (see gotcha) |
97
+ | `characters` | `#characters` | text, multiple | characters |
98
+ | `ships` | `#relationships` | text, multiple | pairings |
99
+ | `genre` | `#genres` | text, multiple | genres |
100
+ | `status` | `#status` | text | In-Progress / Completed / … |
101
+ | `series` | **Series** (built-in) | series | the real site/AO3 series |
102
+ | `numWords` | `#words` | int | word count |
103
+ | `numChapters`| `#chapters` | int | chapter count |
104
+ | `dateUpdated`| `#updated` | datetime | last-updated date |
105
+ | `storyUrl` | `#storyurl` | text | source URL (also stored as the `url` identifier) |
106
+ | *subject tags* | `tags` (built-in) | — | freeform tags (what this tool normalizes most) |
107
+
108
+ ### ⚠️ The fandom-vs-series gotcha
109
+ FanFicFare's `personal.ini` setting **`include_in_series:category`** stuffs the *fandom* into FFF's
110
+ `series` field. If your `custom_cols` then maps **`#fandoms ← series`**, two things break: your
111
+ **Series** column fills with fandom names (not real series), and **#fandoms** is fed from that
112
+ fandom-stuffed series field. Fix:
113
+ 1. Remove `include_in_series:category` from `personal.ini` → `series` becomes the real (e.g. AO3) series.
114
+ 2. Map `#fandoms ← category` (the true fandom field).
115
+
116
+ `wrangle.py setup` detects and offers to fix both, plus the protection below (the original
117
+ `attic/apply_fff_config.py` does the same standalone). After that, real series fills in going
118
+ forward and fandoms come from `category`.
119
+
120
+ **Why fandom-as-series is especially bad:** Calibre's **Series** is a *numbered* field — every book
121
+ gets a `series_index` (`A Fandom Name [1]`, `[2]`, …). So fandom-as-series doesn't just duplicate the
122
+ fandom, it invents a bogus **ordered hierarchy**: dozens of unrelated stories become "book 1, book 2…
123
+ of Harry Potter," a sequence that reflects nothing real. Clearing it (see `attic/apply_other.py`) and
124
+ mapping `#fandoms ← category` removes the fake ordering; real series (where the index is meaningful,
125
+ e.g. a genuine 3-part AO3 series) then populate correctly.
126
+
127
+ ### Franchise unification (fandom granularity)
128
+ Related works in one universe (e.g. `Fate/stay night`, `Fate/Zero`, `Fate/Grand Order`) are distinct
129
+ titles but one fandom. The bundled `defaults/fandoms.csv` unifies the obvious franchises to a single
130
+ canonical — **the Fate/Nasuverse works all map to `Type-Moon`** (the studio/umbrella name the
131
+ Nasuverse fandom uses). Prefer an **English title** as canonical wherever one exists (e.g.
132
+ `The Saga of Tanya the Evil`, not `Youjo Senki`; `Puella Magi Madoka Magica`, not the romaji). This
133
+ is a granularity *preference*: if you'd rather keep `Fate/Zero` separate from `Fate/stay night`,
134
+ remove those rows from your `overrides/fandoms.csv` (or leave them unmapped). Note some franchises
135
+ should **stay split** — Disney works are mostly standalone worlds (keep `DuckTales`, don't fold to a
136
+ `Disney` mega-fandom), and `Overlord (Game)` vs `Overlord (Anime)` are unrelated. Curated
137
+ unifications live in `build_defaults.py`'s `CURATED_FAN`.
138
+
139
+ ### Protecting your cleanup from re-pollution
140
+ FFF's **`custom_cols_newonly`** (`{column: bool}`) controls overwrite-on-update: when `true`, FFF
141
+ only writes that column **if it's empty**, so a metadata refresh won't clobber your normalized
142
+ values. Recommended: `newonly:true` for `#genres`; leave `#status` **writable** so FanFicFare refreshes it on fetch (`staleness.py` re-derives the activity inference). The **built-in `tags` column is
143
+ never protected** by this — so new downloads/updates re-add raw tag junk, and you re-run
144
+ `wrangle.py` to clean it (see *Maintenance*).
145
+
146
+ ---
147
+
148
+ ## Customizing
149
+
150
+ **`config.toml`** — column map + behavior toggles (all have sane, opinionated defaults):
151
+
152
+ | Toggle | Default | Effect |
153
+ |---|---|---|
154
+ | `fold_characters` | `true` | apply abbreviation→full-name defaults |
155
+ | `ascii_only_tags` | `true` | transliterate non-ASCII tags to plain ASCII |
156
+ | `au_as` / `crossover_as` / `reincarnation_as` / `time_travel_as` | `genre` | put these tropes in `#genres` (`tag` to keep in tags) |
157
+ | `fold_ratings` | `false` | fold `Erotica`→`Smut`, `Adult`→`Mature` |
158
+ | `keep_categories` | `true` | keep `Multi`/`Gen`/`F/M` tags (`false` drops them) |
159
+
160
+ **`overrides/`** — drop in `characters.csv`, `fandoms.csv`, `tropes.csv`, `junk.txt`,
161
+ `genres_allow.txt`, … (same formats as `defaults/`). Anything here is merged on top of the bundled
162
+ defaults and wins on conflicts. This is where *your* preferences live — the code stays generic.
163
+
164
+ **`defaults/` formats:**
165
+ - `characters.csv` — `variant,canonical,fandom` (blank fandom = global; set = homonym-scoped, e.g. `Luke C.` differs in Marvel vs PJO)
166
+ - `fandoms.csv` — `alias,canonical`
167
+ - `tropes.csv` — `variant,canonical,route` (route = `tag`|`genre`|`character`|`fandom`)
168
+ - `genres_split.csv` (`combined,atoms`), `genres_canon.csv` (`variant,canonical`), `genres_allow.txt` (the genre vocabulary)
169
+ - `junk.txt` — drop list (plain line = case-insensitive exact; `re:<regex>` = regex)
170
+ - `ratings.txt` — content-rating/warning vocabulary
171
+
172
+ ---
173
+
174
+ ## Safety model
175
+ `audit` and `apply` compute the full new state in memory and assert **no book loses its last fandom
176
+ or character** (backfill-before-strip), aborting without writing if that fails. A second guardrail
177
+ aborts if tags would **mass-shrink** (>25% of assignments and >200 lost — the signature of an
178
+ over-broad `junk.txt` rule; `--force` overrides after you've checked). A redundant tag is only
179
+ stripped when the concept already lives in that book's structured column. `audit` is read-only
180
+ (plain `python3`, fine with Calibre open); `apply`/`setup` use the Calibre API (Calibre **closed**).
181
+ **Every write automatically snapshots `metadata.db` to `/tmp/ff_<timestamp>.db` first** and prints
182
+ the path — that's your instant rollback (master rollback = a full "Export all Calibre data" backup).
183
+
184
+ ## Maintenance — after new downloads / updates
185
+ New stories arrive **raw** (junky subject tags, unfolded names). **Order matters — deterministic
186
+ cleanup first, content tagging second**, because raw junk tags inflate a book's tag count and would
187
+ hide it from the classifier's "sparsely tagged" targeting:
188
+
189
+ ```bash
190
+ scourgify apply --apply # 1. wrangle FIRST: junk-drop/canonicalize the new raw tags (idempotent)
191
+ scourgify staleness --apply # 2. free: re-derive #status from #updated age (independent, any time)
192
+ scourgify classify --incremental # 3. cheap: content-tag only new/changed books -> proposal
193
+ # 4. review data/classify_proposal.csv
194
+ scourgify classify --apply # 5. apply the reviewed tags + stamp #wrangled
195
+ ```
196
+
197
+ Or just `scourgify` and walk the wizard menu in order: **3 wrangle → 4 staleness →
198
+ 5 classify → 6 review**. Re-running wrangle is always safe — it's idempotent and won't regress
199
+ curated genres (it uses the full `genres_allow.txt`).
200
+
201
+ ---
202
+
203
+ ## Content-based tagging — `scourgify classify`
204
+ Reads each book's description (`#comments`) and produces **two outputs**: (1) `added_tags` — tags chosen
205
+ from the **controlled vocabulary** (`defaults/classify_vocab.txt`), which get applied; and (2) `proposed_new`
206
+ — short novel tags *not* in the vocab, aggregated by frequency into `classify_newtags_ranked.csv` so you can
207
+ review and **promote** the recurring ones into the vocab. Grows the tag set deliberately, without freeform noise.
208
+
209
+ ```bash
210
+ scourgify classify --engine apple --limit 50 # propose -> data/classify_proposal.csv (dry-run, read-only)
211
+ scourgify classify --apply # add the proposed tags (Calibre CLOSED)
212
+ ```
213
+ - `--engine apple` — on-device **Apple Foundation Models** via `afm.swift` (free, private; macOS 26+,
214
+ Apple Intelligence). Ships as source; a `swift` toolchain runs it as-is, or from a checkout build the
215
+ faster binary once: `swiftc -O src/scourgify/afm.swift -o src/scourgify/afm`. Lower quality — prone to over-tagging,
216
+ so the prompt caps at `--max-tags 6` and dumps (>2× cap) are rejected.
217
+ - `--engine claude|openai|gemini` — cloud APIs (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY`);
218
+ defaults `claude-haiku-4-5` / `gpt-4o-mini` / `gemini-2.5-flash`, override with `--model`. Sharper; cheap.
219
+ - Only books with `< --min-tags` (default 2) tags **and** a description are touched. Always dry-run
220
+ until `--apply`. Edit `defaults/classify_vocab.txt` to shape the allowed tag set.
221
+ - Long runs **save incrementally and resume** on re-run (skip books already in the proposal; `--fresh`
222
+ to restart). `--batch N` processes only N new books per run — handy for pacing API spend/rate limits.
223
+ A **spend gate** asks for confirmation (or `--yes`) before sending more than 200 books to a cloud engine.
224
+ - Proposals/outputs live in `data/` (gitignored): `classify_proposal.csv`, `classify_newtags_ranked.csv`,
225
+ `classify_failures.csv`. On `--apply` the proposal is **archived** to `classify_proposal_applied_<ts>.csv`,
226
+ so a later apply can never re-add tags you've since hand-removed in Calibre.
227
+ - **Incremental maintenance (`--incremental`):** after new FanFicFare downloads, `classify.py --incremental` (re)tags
228
+ only books whose `#updated` is newer than their own **`#wrangled`** marker (a datetime column auto-created and
229
+ stamped on `--apply`), plus any still untagged — cents instead of a full pass. State lives *in the library*
230
+ (travels with `metadata.db`, no external file). A full cloud `--fresh` run is expensive; reserve it for vocab changes.
231
+
232
+ ## Custom maps from your library (`overrides/`)
233
+ The bundled `defaults/` are generic. Two helper workflows mined library-specific maps into `overrides/`
234
+ (gitignored): AO3-style tag clustering (`overrides/tropes.csv`) and fandom **universe-unification**
235
+ (`overrides/fandoms.csv`, e.g. `Avengers`/`Captain America (Movies)` → `Marvel`, `Game of Thrones (TV)`
236
+ → `A Song of Ice and Fire`). The engine loads these on top of the defaults automatically.
237
+
238
+ ## Repo layout
239
+ The package lives in **`src/scourgify/`**; the single `scourgify` command (`cli.py`) dispatches
240
+ bare → wizard, `setup`/`audit`/`apply` → wrangle, `classify`, and `staleness`.
241
+ - **`cli.py`** — the `scourgify` entry point (argv dispatcher over the tools below)
242
+ - **`wrangle.py`** — the engine: `setup` / `audit` / `apply`; with no command it launches the wizard
243
+ - **`wizard.py` + `ui.py`** — the interactive wizard and its rich terminal helpers (the one
244
+ rich-required surface)
245
+ - **`classify.py`** — content-based tagging (LLM engines) · **`staleness.py`** — `#status` re-derivation
246
+ - **`common.py`** — shared core: library resolution, read-only sqlite + custom-column reading,
247
+ config, and `run_writer()` (the single write funnel, with automatic backup)
248
+ - **`_writer.py`** — the only file that imports Calibre; a generic ops executor invoked under
249
+ `calibre-debug` by `run_writer()`, never by hand
250
+ - **`defaults/`** — bundled generic maps, shipped inside the package (read-only at runtime)
251
+ - Per-user files resolve against the **working directory**: `config.toml`, `overrides/` (your maps,
252
+ gitignored), and `data/` (proposals/intermediates, gitignored)
253
+ - **`build_defaults.py`** (repo root) — maintainer tool: regenerates `defaults/` from the review maps in `data/`
254
+ - **`tests/`** — `uv run tests/test_core.py`, no library needed
255
+ - **`attic/`** — the original single-purpose pipeline, kept as provenance (see `attic/README.md`);
256
+ `scourgify` supersedes it.
257
+
258
+ The per-library review-map CSVs in `data/` are gitignored (they contain your library's actual data);
259
+ only the generic `defaults/` ship with the repo.