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.
- scourgify-1.0.0/.github/workflows/publish.yml +61 -0
- scourgify-1.0.0/.gitignore +28 -0
- scourgify-1.0.0/CLAUDE.md +153 -0
- scourgify-1.0.0/LICENSE +21 -0
- scourgify-1.0.0/PKG-INFO +259 -0
- scourgify-1.0.0/README.md +237 -0
- scourgify-1.0.0/attic/README.md +12 -0
- scourgify-1.0.0/attic/apply.py +175 -0
- scourgify-1.0.0/attic/apply_asciitags.py +26 -0
- scourgify-1.0.0/attic/apply_char_fixes.py +22 -0
- scourgify-1.0.0/attic/apply_fff_config.py +38 -0
- scourgify-1.0.0/attic/apply_more.py +92 -0
- scourgify-1.0.0/attic/apply_other.py +40 -0
- scourgify-1.0.0/attic/apply_recents.py +63 -0
- scourgify-1.0.0/attic/apply_relationships.py +63 -0
- scourgify-1.0.0/attic/apply_tropes.py +64 -0
- scourgify-1.0.0/attic/dryrun.py +181 -0
- scourgify-1.0.0/attic/fff_status_writable.py +24 -0
- scourgify-1.0.0/attic/generate_followups.py +123 -0
- scourgify-1.0.0/attic/generate_maps.py +217 -0
- scourgify-1.0.0/attic/recover_xianxia.py +51 -0
- scourgify-1.0.0/build_defaults.py +123 -0
- scourgify-1.0.0/config.toml +26 -0
- scourgify-1.0.0/pyproject.toml +60 -0
- scourgify-1.0.0/src/scourgify/__init__.py +3 -0
- scourgify-1.0.0/src/scourgify/__main__.py +4 -0
- scourgify-1.0.0/src/scourgify/_writer.py +45 -0
- scourgify-1.0.0/src/scourgify/afm.swift +30 -0
- scourgify-1.0.0/src/scourgify/classify.py +403 -0
- scourgify-1.0.0/src/scourgify/cli.py +31 -0
- scourgify-1.0.0/src/scourgify/common.py +118 -0
- scourgify-1.0.0/src/scourgify/defaults/characters.csv +220 -0
- scourgify-1.0.0/src/scourgify/defaults/classify_vocab.txt +120 -0
- scourgify-1.0.0/src/scourgify/defaults/decompose.csv +7 -0
- scourgify-1.0.0/src/scourgify/defaults/fandom_blocklist.txt +57 -0
- scourgify-1.0.0/src/scourgify/defaults/fandoms.csv +277 -0
- scourgify-1.0.0/src/scourgify/defaults/genres_allow.txt +64 -0
- scourgify-1.0.0/src/scourgify/defaults/genres_canon.csv +12 -0
- scourgify-1.0.0/src/scourgify/defaults/genres_split.csv +7 -0
- scourgify-1.0.0/src/scourgify/defaults/junk.txt +280 -0
- scourgify-1.0.0/src/scourgify/defaults/ratings.txt +19 -0
- scourgify-1.0.0/src/scourgify/defaults/tropes.csv +859 -0
- scourgify-1.0.0/src/scourgify/staleness.py +72 -0
- scourgify-1.0.0/src/scourgify/ui.py +67 -0
- scourgify-1.0.0/src/scourgify/wizard.py +245 -0
- scourgify-1.0.0/src/scourgify/wrangle.py +460 -0
- scourgify-1.0.0/tests/test_core.py +147 -0
- 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.
|
scourgify-1.0.0/LICENSE
ADDED
|
@@ -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.
|
scourgify-1.0.0/PKG-INFO
ADDED
|
@@ -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.
|