Discover page: refresh, harvest admin and seed reasons; docs (BL-0051)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-25 09:28:38 +01:00
1 parent 0f87fbe948
commit 613db96355
10 files changed
+264 -57

No files matched your search

+1 -1
View File
@@ -210,7 +210,7 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js
| `GET /api/discovery` | user | The caller's discovery feed, settings, feedback and `refresh: { state: idle\|queued\|running, position?, nextAllowedAt?, blockedReason? }` (§6.6) | | `GET /api/discovery` | user | The caller's discovery feed, settings, feedback and `refresh: { state: idle\|queued\|running, position?, nextAllowedAt?, blockedReason? }` (§6.6) |
| `POST /api/discovery/feedback` `{candidateId, value}` | user | The caller's choice for a candidate; requires opt-in | | `POST /api/discovery/feedback` `{candidateId, value}` | user | The caller's choice for a candidate; requires opt-in |
| `POST /api/discovery/refresh` | user | Queue a per-user AniList harvest for the caller (opt-in required, else `403`). `{ harvestId, position }`; `409` while one is queued or running for the caller; `429 { nextAllowedAt }` within an hour of the last accepted refresh; `503 { blockedReason }` after AniList pushed back, until a nightly or admin harvest | | `POST /api/discovery/refresh` | user | Queue a per-user AniList harvest for the caller (opt-in required, else `403`). `{ harvestId, position }`; `409` while one is queued or running for the caller; `429 { nextAllowedAt }` within an hour of the last accepted refresh; `503 { blockedReason }` after AniList pushed back, until a nightly or admin harvest |
| `GET /api/admin/discovery` | admin | Last scoring run, opted-in users, candidate count, plus `harvest` (last run record), `currentHarvest`, `cache: { files, bytes }`, `settings: { harvestEnabled, harvestAt }` and `nextNightlyAt` (`null` when off) | | `GET /api/admin/discovery` | admin | Last scoring run, `lastError` (a failed admin run or a failed re-score after a harvest), opted-in users, candidate count, plus `harvest` (last run record), `currentHarvest`, `cache: { files, bytes }`, `settings: { harvestEnabled, harvestAt }` and `nextNightlyAt` (`null` when off) |
| `POST /api/admin/discovery/run` | admin | Re-score every user from local data; `409` while a run is active | | `POST /api/admin/discovery/run` | admin | Re-score every user from local data; `409` while a run is active |
| `POST /api/admin/discovery/harvest` | admin | Start a full harvest for all opted-in users: `{ harvestId, position }`, `409` while any harvest is queued or running | | `POST /api/admin/discovery/harvest` | admin | Start a full harvest for all opted-in users: `{ harvestId, position }`, `409` while any harvest is queued or running |
| `POST /api/admin/discovery/settings` `{harvestEnabled, harvestAt}` | admin | Exactly a boolean and an `HH:MM` time, anything else is `400`; stored in `discovery.json` `settings` | | `POST /api/admin/discovery/settings` `{harvestEnabled, harvestAt}` | admin | Exactly a boolean and an `HH:MM` time, anything else is `400`; stored in `discovery.json` `settings` |
+3 -1
View File
@@ -21,7 +21,7 @@ Data on the host, all bind mounts (no named volumes):
| Path (`.env`) | Contents | Back up? | | Path (`.env`) | Contents | Back up? |
|---|---|---| |---|---|---|
| `LIBRARY_PATH` | The manga: `Manga/Book/Chapter/images` ([library-format.md](library-format.md)) | Yes, it is your collection | | `LIBRARY_PATH` | The manga: `Manga/Book/Chapter/images` ([library-format.md](library-format.md)) | Yes, it is your collection |
| `DATA_PATH` | `db.json` (users, progress, votes), catalog, library index cache | Yes (built-in backup, section 8) | | `DATA_PATH` | `db.json` (users, progress, votes), catalog, discovery feeds, library index cache | Yes (built-in backup, section 8). `discovery-cache/` inside it is safe to delete and may be left out of backups: the next harvest rebuilds it |
| `SCRAPER_DATA_PATH` | Queue, settings, source ledger, metadata cache | Included in the built-in backup | | `SCRAPER_DATA_PATH` | Queue, settings, source ledger, metadata cache | Included in the built-in backup |
| `BACKUP_PATH` | Built-in backup archives | Keep it on another disk or NAS | | `BACKUP_PATH` | Built-in backup archives | Keep it on another disk or NAS |
| `SCRAPER_TOKEN_PATH` | Shared secret between reader and scraper | Regenerate if lost | | `SCRAPER_TOKEN_PATH` | Shared secret between reader and scraper | Regenerate if lost |
@@ -69,6 +69,8 @@ demo manga in an empty library after the build in section 5:
| `TZ` | `UTC` | Time zone of the containers; affects the nightly update time and backup schedule | | `TZ` | `UTC` | Time zone of the containers; affects the nightly update time and backup schedule |
| `COOKIE_SECURE` | `0` | `1` when readers reach the site over HTTPS | | `COOKIE_SECURE` | `0` | `1` when readers reach the site over HTTPS |
| `READING_WINDOWS_JSON`, `READING_TIMEZONE`, `READING_QUIZ_*` | off | Optional server-enforced reading hours ([reading-limiter.md](reading-limiter.md)) | | `READING_WINDOWS_JSON`, `READING_TIMEZONE`, `READING_QUIZ_*` | off | Optional server-enforced reading hours ([reading-limiter.md](reading-limiter.md)) |
| `DISCOVERY_HARVEST_AT` | `03:30` | Daily time (`HH:MM`) of the nightly Discover harvest from AniList, in the container's local time zone (`TZ`). Only users who opted in are harvested; the admin can change the time or switch it off on the Discover page ([discovery.md](discovery.md)) |
| `DISCOVERY_ANILIST_URL` | AniList's public API | Tests only: points the discovery harvest at a fixture server. Leave unset in production |
| `APP_IMAGE`, `VCS_REF` | `mangacircus:local`, `development` | Image to run; set a pinned registry image to deploy prebuilt releases (section 7) | | `APP_IMAGE`, `VCS_REF` | `mangacircus:local`, `development` | Image to run; set a pinned registry image to deploy prebuilt releases (section 7) |
| `COMPOSE_FILE` | `docker-compose.yml` | Extra Compose files, `:`-separated (e.g. `docker-compose.yml:deploy/compose.https.yml`) | | `COMPOSE_FILE` | `docker-compose.yml` | Extra Compose files, `:`-separated (e.g. `docker-compose.yml:deploy/compose.https.yml`) |
| `MANGACIRCUS_DOMAIN` | – | Host name for `deploy/compose.https.yml` | | `MANGACIRCUS_DOMAIN` | – | Host name for `deploy/compose.https.yml` |
+85 -29
View File
@@ -1,23 +1,28 @@
# Discovery Scout (deterministic first release) # Discovery Scout
The reader now has a separate **Discover** page. This first slice deliberately uses The reader has a separate **Discover** page. Suggestions come from the curated
the curated Greatest catalog, already-cached AniList identities/relations, library Greatest catalog, cached AniList relations of Greatest and library titles, and a
metadata, each user's Greatest votes, and explicit Discover feedback. It does not wider pool that the reader harvests from AniList's public API for the users who
call an LLM, fetch new metadata, inspect chapter/image hosters, edit a collection, opted in (design: [the wider-pool spec](superpowers/specs/2026-10-25-discovery-wider-pool-design.md)).
Discovery does not call an LLM, inspect chapter/image hosters, edit a collection,
or enqueue downloads. The reader remains the only writer of account and discovery or enqueue downloads. The reader remains the only writer of account and discovery
state; no fourth Compose service, credential, or database is required. state; no further Compose service, credential, or database is required.
## Use ## Use
1. Open `discover.html`. Each account independently enables **Personalized 1. Open `discover.html`. Each account independently enables **Personalized
discovery** (off by default). Reading activity is a separate opt-in. The discovery** (off by default). Reading activity is a separate opt-in. The
mature-title switch only applies to candidates with a known mature tag; it is mature-title switch only affects titles AniList marks as adult or with a known
not a content-safety guarantee. mature tag; it is not a content-safety guarantee.
2. An admin presses **Run discovery now** on the same page. Existing suggestions 2. **Refresh my suggestions** queues a harvest for your own account (at most once an
remain visible until the new feed is published. Runs are bounded to 50 hour). The button shows the queue position, `Fetching…` while it runs, and when
candidates and 20 suggestions per opted-in user, with no more than four from the next refresh is possible. Your existing suggestions stay visible until the
one period. A second concurrent run is rejected. new feed is published.
3. A reader can open **Why this is here · evidence** and record Interested, Maybe, 3. An admin can press **Run discovery now** (re-score from local data, no network)
or **Harvest now** (fetch from AniList for every opted-in user, then re-score).
A second concurrent run or harvest is rejected. A nightly harvest runs on its
own; the admin panel can switch it off or change its time.
4. A reader can open **Why this is here · evidence** and record Interested, Maybe,
No thanks, or Hide. Feedback is private to the account. Dismissed/hidden No thanks, or Hide. Feedback is private to the account. Dismissed/hidden
titles disappear at once; other feedback influences the next run. The user titles disappear at once; other feedback influences the next run. The user
can undo a choice by clicking its active button again. can undo a choice by clicking its active button again.
@@ -28,17 +33,70 @@ the existing source inspection and confirmation process to download anything.
The title/year/format matching is intentionally conservative but not a verified The title/year/format matching is intentionally conservative but not a verified
identity merge. Missing year, cover, or status remains missing. identity merge. Missing year, cover, or status remains missing.
## Seeds from AniList
A harvest starts from each opted-in user's **anchors**: titles they voted
`flame`/`good` in Greatest, marked Interested/Maybe in Discover, read (only with
reading activity opted in), or own with an AniList ID. The 15 strongest anchors
count. It then asks AniList for four seed kinds:
| Seed | What | Reason shown on the card |
|---|---|---|
| Community recommendations | "readers who liked X recommend Y" for each anchor | AniList readers who liked {anchor} recommend this ({rating}). |
| Same creator | other works by the story/art authors of strong anchors | Also by {author} — you read/liked {anchor}. |
| Top in your tags | well-rated titles in the user's three strongest tags | Top-rated in {tag}, one of your most-liked tags. |
| Fresh | recently started, releasing and trending titles | New: started {year}, ongoing and trending. |
A title found by several seeds keeps every reason and evidence item; the card
shows the strongest, the details list all of them, each with the date it was
fetched. A feed holds at most 20 suggestions, at most 8 from one seed kind, up to
4 fresh titles, and some from outside your strongest tags.
- **Budgets:** a nightly or admin harvest makes at most 300 requests, a
per-user refresh 60. Requests are sequential and limited to 25 a minute. When
the budget is spent the harvest stops; what was fetched is still used.
- **Cache:** `data/discovery-cache/`, one file per response. Titles,
recommendations and creators stay fresh for 30 days, tag lists and the fresh
list for 7 days; at most 5,000 files, oldest pruned first. Scoring reads only
this cache and never the network, so a failed harvest means older candidates,
never an empty feed.
- **Nightly time:** `03:30` by default, or `DISCOVERY_HARVEST_AT`, in the
container's local time zone (`TZ`). The admin panel overrides both and can turn
the nightly harvest off. A night missed while the reader was down runs once
after startup.
- **Privacy:** for opted-in users only, the server sends AniList the IDs of
their anchor titles (and the names of their top tags), without login, cookies or
user names. Feeds and feedback stay private to this server; cache entries are
not tied to a user.
### Stop reasons
The admin panel shows the last harvest as `status · requests/budget · stop
reason · cache files`.
| Stop reason | Meaning | What happens next |
|---|---|---|
| `budget` | The request budget ran out | Normal; the next harvest continues where the cache is stale |
| `rate-limited` | AniList answered 429 | Harvesting pauses; refresh buttons show "Paused by AniList" until the next nightly or admin harvest |
| `forbidden` | AniList answered 401/403 | Same pause |
| `challenge` | AniList answered with an HTML or challenge page | Same pause |
| `upstream-errors` | Five timeouts or server errors in a row | The harvest ends; nothing is paused |
| `error` | A local fault (for example a cache write) | See the reader log |
Discovery never retries with a different identity, user agent or host. A
re-scoring failure after a harvest is shown as the admin panel's error line.
## Data and API ## Data and API
- `data/discovery.json`: atomic, reader-owned candidate and per-user feed - `data/discovery.json`: atomic, reader-owned candidate and per-user feed
snapshots. A previous valid version is retained as `discovery.json.prev`. snapshots plus the last harvest record and the admin's harvest settings. A
A corrupt file makes reads/runs fail closed rather than overwrite it. previous valid version is retained as `discovery.json.prev`. A corrupt file
makes reads/runs/harvests fail closed rather than overwrite it.
- `data/discovery-cache/`: harvested AniList responses; safe to delete.
- `data/db.json`: consent in user settings and explicit feedback under - `data/db.json`: consent in user settings and explicit feedback under
`discoveryFeedback`. The account-deletion route removes that feedback. `discoveryFeedback`. The account-deletion route removes that feedback.
- `GET /api/discovery`: caller's own feed, settings, evidence, and feedback only. - Routes: [architecture.md](architecture.md) (`/api/discovery*`,
- `POST /api/discovery/feedback`: caller's candidate choice; requires opt-in. `/api/admin/discovery*`) and §6.6 there for the harvester.
- `GET /api/admin/discovery` and `POST /api/admin/discovery/run`: admin-only
status and manual generation. A run keeps the last good feed on failure.
The job asks the private scraper state API for queued titles to avoid recommending The job asks the private scraper state API for queued titles to avoid recommending
them. If that API is unavailable, the run still uses only cached sources; the them. If that API is unavailable, the run still uses only cached sources; the
@@ -58,15 +116,13 @@ APP_IMAGE=mangacircus:discovery-test npm run test:discovery-ui
The dry run reads `db.json`, a saved library index if present, Greatest, and The dry run reads `db.json`, a saved library index if present, Greatest, and
cached AniList metadata. It writes nothing and does not contact providers. The cached AniList metadata. It writes nothing and does not contact providers. The
Compose/browser suite uses a disposable data tree and queue, not the live one. Compose/browser suite uses a disposable data tree and queue, not the live one,
and points `DISCOVERY_ANILIST_URL` at a closed local port so no harvest reaches
AniList.
## Deliberate limits and next stage ## Limits
This slice has no scheduled run, external metadata discovery, LLM, admin seed There is no admin seed form, edition-finding action, LLM rationale, or explicit
form, edition-finding action, or explicit purge control for old feed snapshots. purge control for old feed snapshots. Disabling consent stops feed display,
Disabling consent stops feed display and future personalized generation, but harvesting and future personalized generation for that account, but existing
existing snapshot/feedback is retained until account deletion or a later data snapshot/feedback is retained until account deletion.
management feature. Candidate quality is limited by the curated list and local
metadata cache. Add external public metadata feeds and optional LLM only behind
the existing bounded candidate/evidence contract after this deterministic path
has been used and reviewed.
+4
View File
@@ -318,6 +318,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
.discovery-card-body details { margin: 10px 0; } .discovery-card-body details { margin: 10px 0; }
.discovery-card-body ul { padding-left: 18px; } .discovery-card-body ul { padding-left: 18px; }
.discovery-actions { margin: 10px 0; } .discovery-actions { margin: 10px 0; }
.discovery-refresh-row { margin: 0 0 14px; }
/* The paused and cooldown labels are long; let them wrap on phones instead of widening the page. */
.discovery-refresh { max-width: 100%; white-space: normal; text-align: center; }
.discovery-harvest-settings { align-items: center; gap: 12px; margin: 10px 0; }
.catalog-workspace { min-width: 0; margin: 0; padding: 0; border: 0; } .catalog-workspace { min-width: 0; margin: 0; padding: 0; border: 0; }
.catalog-workspace > .row { margin-bottom: 16px; } .catalog-workspace > .row { margin-bottom: 16px; }
.catalog-workspace input, .catalog-workspace select, .catalog-workspace textarea { min-width: 0; max-width: 100%; } .catalog-workspace input, .catalog-workspace select, .catalog-workspace textarea { min-width: 0; max-width: 100%; }
+61 -18
View File
@@ -5,13 +5,25 @@
topNav(me); topNav(me);
const main = $('main'), note = h('p', { role: 'status', 'aria-live': 'polite', class: 'muted small' }); const main = $('main'), note = h('p', { role: 'status', 'aria-live': 'polite', class: 'muted small' });
const button = (label, action, className = '') => h('button', { type: 'button', class: `btn ${className}`, onclick: action }, label); const button = (label, action, className = '') => h('button', { type: 'button', class: `btn ${className}`, onclick: action }, label);
const clock = iso => new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const fail = error => { note.textContent = error.message; note.className = 'error'; };
async function refresh() { async function refresh() {
try { state = await api('api/discovery'); if (me.admin) adminState = await api('api/admin/discovery'); render(); } try { state = await api('api/discovery'); if (me.admin) adminState = await api('api/admin/discovery'); render(); }
catch (error) { note.textContent = error.message; note.className = 'error'; } catch (error) { fail(error); }
}
// Something is still working server-side: a scoring run, a harvest, or this user's refresh.
const busy = () => adminState?.currentRun?.status === 'running' || !!adminState?.currentHarvest
|| ['queued', 'running'].includes(state.refresh?.state);
function watch() {
if (active || !busy()) return;
active = setInterval(async () => {
await refresh();
if (!busy()) { clearInterval(active); active = null; }
}, 1500);
} }
async function preference(key, checked) { async function preference(key, checked) {
try { await api('api/settings', { [key]: checked }); await refresh(); } try { await api('api/settings', { [key]: checked }); await refresh(); }
catch (error) { note.textContent = error.message; note.className = 'error'; } catch (error) { fail(error); }
} }
async function feedback(candidateId, value) { async function feedback(candidateId, value) {
try { try {
@@ -19,19 +31,27 @@
state.feedback[candidateId] = value; state.feedback[candidateId] = value;
note.textContent = value ? 'Preference saved. The next run will use it.' : 'Preference removed.'; note.textContent = value ? 'Preference saved. The next run will use it.' : 'Preference removed.';
render(); render();
} catch (error) { note.textContent = error.message; note.className = 'error'; } } catch (error) { fail(error); }
} }
async function run() { // Start a server job, then poll until it finishes; on refusal re-read the state (cooldown, pause).
async function start(endpoint, message) {
try { try {
await api('api/admin/discovery/run', {}); await api(endpoint, {});
note.textContent = 'Discovery running. Existing suggestions remain visible until it finishes.'; note.textContent = message; note.className = 'muted small';
} catch (error) { fail(error); }
await refresh(); await refresh();
if (active) clearInterval(active); watch();
active = setInterval(async () => { }
await refresh(); const run = () => start('api/admin/discovery/run', 'Discovery running. Existing suggestions remain visible until it finishes.');
if (adminState?.currentRun?.status !== 'running') { clearInterval(active); active = null; } const refreshMine = () => start('api/discovery/refresh', 'Refresh queued. Your current suggestions stay visible until it finishes.');
}, 1500); const harvestNow = () => start('api/admin/discovery/harvest', 'Harvest started. Suggestions are re-scored when it finishes.');
} catch (error) { note.textContent = error.message; note.className = 'error'; } function refreshButton() {
const refreshState = state.refresh || { state: 'idle' };
const label = refreshState.state === 'queued' ? `Queued (position ${refreshState.position})`
: refreshState.state === 'running' ? 'Fetching…'
: refreshState.blockedReason ? `Paused by AniList (${refreshState.blockedReason}) until the next nightly run`
: refreshState.nextAllowedAt ? `Next refresh possible at ${clock(refreshState.nextAllowedAt)}` : null;
return h('button', { type: 'button', class: 'btn discovery-refresh', disabled: !!label, onclick: refreshMine }, label || 'Refresh my suggestions');
} }
function toggle(label, key, hint) { function toggle(label, key, hint) {
return h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!state.settings[key], return h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!state.settings[key],
@@ -40,18 +60,37 @@
} }
function preferences() { function preferences() {
return h('section', { class: 'card discovery-settings' }, h('h2', {}, 'Your discovery preferences'), return h('section', { class: 'card discovery-settings' }, h('h2', {}, 'Your discovery preferences'),
toggle('Personalized discovery', 'discoveryEnabled', 'Off by default. Your feed and feedback stay private to this server.'), toggle('Personalized discovery', 'discoveryEnabled', 'Off by default. When on, the server sends AniList the IDs of titles you liked, without login, to find related work. Your feed and feedback stay private to this server.'),
toggle('Use reading activity', 'discoveryReadingActivity', 'Separately opt in to using completed chapters as a small taste signal.'), toggle('Use reading activity', 'discoveryReadingActivity', 'Separately opt in to using completed chapters as a small taste signal.'),
toggle('Include mature titles', 'discoveryMature', 'Only affects titles with a known mature tag.')); toggle('Include mature titles', 'discoveryMature', 'Only affects titles AniList marks as adult or with a known mature tag.'));
}
function harvestSettings() {
const settings = adminState?.settings || { harvestEnabled: false, harvestAt: '03:30' };
const enabled = h('input', { type: 'checkbox', checked: settings.harvestEnabled, onchange: save });
const time = h('input', { type: 'time', value: settings.harvestAt, 'aria-label': 'Harvest time', required: true, onchange: save });
async function save() {
if (!time.value) return;
try {
const saved = await api('api/admin/discovery/settings', { harvestEnabled: enabled.checked, harvestAt: time.value });
note.textContent = `Harvest settings saved (${saved.settings.harvestEnabled ? 'on' : 'off'}, ${saved.settings.harvestAt})`;
note.className = 'muted small';
await refresh();
} catch (error) { fail(error); }
}
return h('div', { class: 'row discovery-harvest-settings' }, h('label', { class: 'check' }, enabled, h('span', {}, 'Automatic harvest')), time);
} }
function adminPanel() { function adminPanel() {
if (!me.admin) return null; if (!me.admin) return null;
const runState = adminState?.currentRun?.status || adminState?.lastRun?.status || 'never'; const runState = adminState?.currentRun?.status || adminState?.lastRun?.status || 'never';
const harvest = adminState?.harvest, cache = adminState?.cache;
return h('section', { class: 'card discovery-admin' }, h('h2', {}, 'Discovery job'), return h('section', { class: 'card discovery-admin' }, h('h2', {}, 'Discovery job'),
h('p', { class: 'muted small' }, `Status: ${runState} · ${adminState?.candidateCount || 0} candidates · ${adminState?.optedInUsers || 0} opted-in users`), h('p', { class: 'muted small' }, `Status: ${runState} · ${adminState?.candidateCount || 0} candidates · ${adminState?.optedInUsers || 0} opted-in users`),
h('p', { class: 'muted small' }, `Last harvest: ${harvest?.status || 'never'} · ${harvest?.requests ?? 0}/${harvest?.budget ?? 0} requests · ${harvest?.stopReason || '—'} · cache ${cache?.files ?? 0} files`),
h('p', { class: 'muted small' }, adminState?.nextNightlyAt ? `Next nightly harvest: ${new Date(adminState.nextNightlyAt).toLocaleString()}` : 'Automatic harvest is off.'),
adminState?.lastError ? h('p', { class: 'error' }, adminState.lastError) : null, adminState?.lastError ? h('p', { class: 'error' }, adminState.lastError) : null,
button('Run discovery now', run, 'primary'), h('div', { class: 'row' }, button('Run discovery now', run, 'primary'), button('Harvest now', harvestNow)),
h('p', { class: 'muted small' }, 'This first version uses cached public metadata and the curated catalog. It does not ask image hosters, call an LLM, or edit the scraper queue.')); harvestSettings(),
h('p', { class: 'muted small' }, 'Harvests ask AniList for public metadata only. They never ask image hosters, call an LLM, or edit the scraper queue.'));
} }
function card(item) { function card(item) {
const candidate = item.candidate, vote = state.feedback[candidate.id] || null; const candidate = item.candidate, vote = state.feedback[candidate.id] || null;
@@ -64,8 +103,10 @@
h('p', { class: 'muted small' }, [candidate.year || 'Year unknown', candidate.status || 'Status unknown', candidate.period].filter(Boolean).join(' · ')), h('p', { class: 'muted small' }, [candidate.year || 'Year unknown', candidate.status || 'Status unknown', candidate.period].filter(Boolean).join(' · ')),
h('p', {}, item.reason), h('p', {}, item.reason),
h('details', {}, h('summary', {}, 'Why this is here · evidence'), h('details', {}, h('summary', {}, 'Why this is here · evidence'),
h('ul', {}, evidence.map(entry => h('li', {}, entry.url ? h('a', { href: entry.url, target: '_blank', rel: 'noopener noreferrer' }, entry.label) : entry.label, h('ul', {}, (item.reasons || []).map(reason => h('li', {}, reason)),
entry.note ? h('span', { class: 'muted' }, ` — ${entry.note}`) : null))), evidence.map(entry => h('li', {}, entry.url ? h('a', { href: entry.url, target: '_blank', rel: 'noopener noreferrer' }, entry.label) : entry.label,
entry.note ? h('span', { class: 'muted' }, ` — ${entry.note}`) : null,
entry.fetchedAt ? h('span', { class: 'muted' }, ` · fetched ${new Date(entry.fetchedAt).toLocaleDateString()}`) : null))),
h('p', { class: 'muted small' }, 'A recommendation does not verify a particular edition or English chapter availability.')), h('p', { class: 'muted small' }, 'A recommendation does not verify a particular edition or English chapter availability.')),
h('div', { class: 'row discovery-actions' }, votes.map(([value, label]) => button(label, () => feedback(candidate.id, vote === value ? null : value), vote === value ? 'on' : ''))), h('div', { class: 'row discovery-actions' }, votes.map(([value, label]) => button(label, () => feedback(candidate.id, vote === value ? null : value), vote === value ? 'on' : ''))),
candidate.metadataUrl ? h('a', { class: 'small', href: candidate.metadataUrl, target: '_blank', rel: 'noopener noreferrer' }, 'Public metadata ↗') : null)); candidate.metadataUrl ? h('a', { class: 'small', href: candidate.metadataUrl, target: '_blank', rel: 'noopener noreferrer' }, 'Public metadata ↗') : null));
@@ -75,10 +116,12 @@
main.append(h('h1', {}, 'Discover'), h('p', { class: 'muted' }, 'A small, evidence-backed shelf of manga you might like. Suggestions are not downloads.'), note, main.append(h('h1', {}, 'Discover'), h('p', { class: 'muted' }, 'A small, evidence-backed shelf of manga you might like. Suggestions are not downloads.'), note,
preferences(), adminPanel()); preferences(), adminPanel());
if (!state.enabled) return main.append(h('p', { class: 'muted' }, 'Turn on personalized discovery to see your suggestions.')); if (!state.enabled) return main.append(h('p', { class: 'muted' }, 'Turn on personalized discovery to see your suggestions.'));
main.append(h('div', { class: 'row discovery-refresh-row' }, refreshButton()));
if (!state.items.length) return main.append(h('p', { class: 'muted' }, state.lastRun?.status === 'never' if (!state.items.length) return main.append(h('p', { class: 'muted' }, state.lastRun?.status === 'never'
? 'No discovery run yet. An admin can run one above.' : 'No current suggestions passed the filters. Your previous feedback is retained.')); ? 'No discovery run yet. An admin can run one above.' : 'No current suggestions passed the filters. Your previous feedback is retained.'));
main.append(h('p', { class: 'muted small' }, `Last built ${new Date(state.generatedAt).toLocaleString()} · ${state.items.length} suggestions`), main.append(h('p', { class: 'muted small' }, `Last built ${new Date(state.generatedAt).toLocaleString()} · ${state.items.length} suggestions`),
h('div', { class: 'discovery-grid' }, state.items.filter(item => !['hidden', 'dismissed'].includes(state.feedback[item.candidateId])).map(card))); h('div', { class: 'discovery-grid' }, state.items.filter(item => !['hidden', 'dismissed'].includes(state.feedback[item.candidateId])).map(card)));
} }
await refresh(); await refresh();
watch();
})(); })();
+5 -1
View File
@@ -315,7 +315,11 @@ try {
if (harvest?.status === 'running') discoveryStore.patch({ harvest: { ...harvest, status: 'interrupted', finishedAt: new Date().toISOString() } }); if (harvest?.status === 'running') discoveryStore.patch({ harvest: { ...harvest, status: 'interrupted', finishedAt: new Date().toISOString() } });
} catch (error) { console.error('[discovery]', error.message); } } catch (error) { console.error('[discovery]', error.message); }
const harvester = new Harvester({ cache: harvestCache, client: createAniListClient(), store: discoveryStore, const harvester = new Harvester({ cache: harvestCache, client: createAniListClient(), store: discoveryStore,
targetsFor: harvestTargets, onDone: job => rescore(job.kind === 'user' ? { onlyUser: job.user } : {}) }); targetsFor: harvestTargets, onDone: async job => {
// A failed follow-up rescore must reach the admin panel (lastError), not only the log.
try { await rescore(job.kind === 'user' ? { onlyUser: job.user } : {}); }
catch (error) { discoveryError = `Re-scoring after harvest ${job.id} failed: ${String(error.message || error)}`.slice(0, 300); throw error; }
} });
function requestHarvest(args) { function requestHarvest(args) {
readDiscovery(); // A corrupt store fails closed before any AniList call. readDiscovery(); // A corrupt store fails closed before any AniList call.
try { return harvester.request(args); } try { return harvester.request(args); }
+26 -1
View File
@@ -3,6 +3,7 @@
const test = require('node:test'), assert = require('node:assert/strict'); const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net'), http = require('http'), crypto = require('crypto'); const fs = require('fs'), os = require('os'), path = require('path'), net = require('net'), http = require('http'), crypto = require('crypto');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const { Canvas } = require('../tools/png');
const FIXTURES = path.join(__dirname, 'fixtures', 'anilist'); const FIXTURES = path.join(__dirname, 'fixtures', 'anilist');
const fixture = name => JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf8')); const fixture = name => JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.json`), 'utf8'));
@@ -49,11 +50,17 @@ const sha = s => crypto.createHash('sha256').update(s).digest('hex');
const USERS = { boss: { admin: true, discovery: false }, alice: { discovery: true }, bob: { discovery: true }, carol: { discovery: false } }; const USERS = { boss: { admin: true, discovery: false }, alice: { discovery: true }, bob: { discovery: true }, carol: { discovery: false } };
// A disposable reader with ready-made accounts and sessions; nightly harvests are off unless the store says otherwise. // A disposable reader with ready-made accounts and sessions; nightly harvests are off unless the store says otherwise.
async function startReader({ anilist, discovery = {} } = {}) { async function startReader({ anilist, discovery = {}, mangaMeta = null } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-discovery-server-')); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-discovery-server-'));
const library = path.join(root, 'library'), data = path.join(root, 'data'); const library = path.join(root, 'library'), data = path.join(root, 'data');
fs.mkdirSync(path.join(library, 'Example Manga'), { recursive: true }); fs.mkdirSync(path.join(library, 'Example Manga'), { recursive: true });
fs.writeFileSync(path.join(library, 'Example Manga', 'notes.txt'), 'not a chapter'); fs.writeFileSync(path.join(library, 'Example Manga', 'notes.txt'), 'not a chapter');
if (mangaMeta) {
const chapter = path.join(library, mangaMeta.title, 'v01', 'c0001');
fs.mkdirSync(chapter, { recursive: true });
fs.writeFileSync(path.join(chapter, '0001.png'), new Canvas(20, 30, [60, 90, 140]).png());
fs.writeFileSync(path.join(library, mangaMeta.title, 'manga.json'), JSON.stringify(mangaMeta));
}
fs.mkdirSync(path.join(data, 'scraper'), { recursive: true }); fs.mkdirSync(path.join(data, 'scraper'), { recursive: true });
fs.writeFileSync(path.join(data, 'scraper', 'queue.json'), JSON.stringify({ queue: [] })); fs.writeFileSync(path.join(data, 'scraper', 'queue.json'), JSON.stringify({ queue: [] }));
const users = {}, sessions = {}, cookies = {}; const users = {}, sessions = {}, cookies = {};
@@ -209,6 +216,24 @@ test('admin harvest is admin-only and single-flight', { timeout: 60000 }, async
} finally { await reader.stop(); await anilist.close(); } } finally { await reader.stop(); await anilist.close(); }
}); });
test('a failed rescore after a harvest reaches the admin as lastError', { timeout: 60000 }, async () => {
const anilist = await startAniList();
// A manga.json whose tags is not a list makes scoring throw; it stands in for any scoring fault.
const reader = await startReader({ anilist, mangaMeta: { title: 'Broken Tags', tags: 5 } });
try {
await until(async () => (await reader.get('boss', '/api/library')).body?.mangas?.length === 1, 'the library scan');
assert.equal((await reader.post('boss', '/api/admin/discovery/harvest')).status, 200);
const done = await harvestDone(reader);
assert.equal(done.harvest.kind, 'admin');
const admin = await until(async () => {
const { body } = await reader.get('boss', '/api/admin/discovery');
return body.lastError ? body : null;
}, 'the rescore error');
assert.match(admin.lastError, /^Re-scoring after harvest [0-9a-f]{16} failed: /);
assert.equal(admin.currentRun, null);
} finally { await reader.stop(); await anilist.close(); }
});
test('settings validation', { timeout: 30000 }, async () => { test('settings validation', { timeout: 30000 }, async () => {
const reader = await startReader(); const reader = await startReader();
try { try {
+23 -2
View File
@@ -18,6 +18,23 @@ if (peerGatewayQa) {
if (process.platform === 'win32' && !process.env.OPENSSL_BIN) process.env.OPENSSL_BIN = 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe'; if (process.platform === 'win32' && !process.env.OPENSSL_BIN) process.env.OPENSSL_BIN = 'C:\\Program Files\\Git\\usr\\bin\\openssl.exe';
new PeeringState(peerData).generateIdentity('ComposeQA'); new PeeringState(peerData).generateIdentity('ComposeQA');
} }
const discoveryQa = process.argv.includes('--discovery-ui');
// Review focus: an untrusted AniList title in the QA admin's first feed must render as text, never markup.
const XSS_TITLE = '<img src=x onerror=alert(1)>';
if (discoveryQa) {
const fetchedAt = '2026-10-20T10:00:00.000Z';
const evidence = [{ id: 'recommendation:990001', kind: 'recommendation', label: 'AniList recommendation',
note: 'Fixture recommendation', url: 'https://anilist.co/manga/990001', fetchedAt }];
const reasons = ['AniList readers who liked Moon Garden recommend this (42).', 'New: started 2025, ongoing and trending.'];
fs.writeFileSync(path.join(data, 'discovery.json'), JSON.stringify({ schema: 1, revision: 1,
candidates: { 'anilist:990001': { id: 'anilist:990001', title: XSS_TITLE, year: 2025, status: 'RELEASING', cover: null,
seed: 'recs', fetchedAt, evidence, metadataUrl: 'https://anilist.co/manga/990001' } },
feeds: { qa: { generatedAt: fetchedAt, items: [{ candidateId: 'anilist:990001', reason: reasons[0], reasons, evidence,
evidenceIds: evidence.map(entry => entry.id), scoreParts: { communityRec: 3, fresh: 2 }, score: 5, period: 'Related works', group: 'recs' }] } },
lastRun: { status: 'success', startedAt: fetchedAt, finishedAt: fetchedAt, candidateCount: 1, examined: 1, clipped: 0 },
// Nightly off, so only the test's own clicks start harvests.
harvest: null, settings: { harvestEnabled: false, harvestAt: '03:30' }, nightly: { lastDate: null } }));
}
const pausedConfirmQa = process.argv.includes('--paused-confirm'); const pausedConfirmQa = process.argv.includes('--paused-confirm');
const readingGateQa = process.argv.includes('--reading-limit-ui'); const readingGateQa = process.argv.includes('--reading-limit-ui');
if (readingGateQa) fs.writeFileSync(path.join(data, 'reading-quiz.json'), JSON.stringify({ version: 1, manga: { if (readingGateQa) fs.writeFileSync(path.join(data, 'reading-quiz.json'), JSON.stringify({ version: 1, manga: {
@@ -45,6 +62,8 @@ for (const [index, name] of ['Moon Garden', 'Before the Rain', 'Iron Harbor'].en
fs.writeFileSync(path.join(library, name, 'manga.json'), JSON.stringify({ title: name, fs.writeFileSync(path.join(library, name, 'manga.json'), JSON.stringify({ title: name,
direction: directionQa && index === 1 ? 'ltr' : process.argv.includes('--reading-ui') && index === 0 ? 'ttb' : 'rtl', direction: directionQa && index === 1 ? 'ltr' : process.argv.includes('--reading-ui') && index === 0 ? 'ttb' : 'rtl',
...(directionQa && index === 0 ? { 'x-reading-direction': { version: 1, value: 'rtl', source: 'fallback' } } : {}), ...(directionQa && index === 0 ? { 'x-reading-direction': { version: 1, value: 'rtl', source: 'fallback' } } : {}),
// AniList IDs make these owned titles harvest anchors; the unreachable endpoint turns their requests into upstream errors.
...(discoveryQa ? { 'x-anilist': { id: 990101 + index } } : {}),
status: index ? 'completed' : 'ongoing', tags: ['Fantasy', 'Demo'] })); status: index ? 'completed' : 'ongoing', tags: ['Fantasy', 'Demo'] }));
} }
const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'), const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DATA_PATH: path.join(data, 'scraper'),
@@ -53,6 +72,8 @@ const env = { ...process.env, LIBRARY_PATH: library, DATA_PATH: data, SCRAPER_DA
...(readingGateQa ? { READING_WINDOWS_JSON: '{"0":[],"1":[],"2":[],"3":[],"4":[],"5":[],"6":[]}', ...(readingGateQa ? { READING_WINDOWS_JSON: '{"0":[],"1":[],"2":[],"3":[],"4":[],"5":[],"6":[]}',
READING_TIMEZONE: 'Europe/Berlin', READING_QUIZ_UNLOCK_MINUTES: '60', READING_QUIZ_CATALOG_PATH: '/data/reading-quiz.json' } READING_TIMEZONE: 'Europe/Berlin', READING_QUIZ_UNLOCK_MINUTES: '60', READING_QUIZ_CATALOG_PATH: '/data/reading-quiz.json' }
: { READING_WINDOWS_JSON: '', READING_QUIZ_CATALOG_PATH: '' }), : { READING_WINDOWS_JSON: '', READING_QUIZ_CATALOG_PATH: '' }),
// A closed local port: every harvest request fails fast and AniList is never contacted.
...(discoveryQa ? { DISCOVERY_ANILIST_URL: 'http://127.0.0.1:9/' } : {}),
...(peerGatewayQa ? { PEER_DATA_PATH: path.join(data, 'peering'), PEER_USAGE_PATH: path.join(data, 'peering-usage'), PEER_PORT: process.env.QA_PEER_PORT || '8193' } : {}) }; ...(peerGatewayQa ? { PEER_DATA_PATH: path.join(data, 'peering'), PEER_USAGE_PATH: path.join(data, 'peering-usage'), PEER_PORT: process.env.QA_PEER_PORT || '8193' } : {}) };
const args = ['compose', '-p', project, '-f', path.join(root, 'docker-compose.yml'), ...(peerGatewayQa ? ['--profile', 'peering'] : [])]; const args = ['compose', '-p', project, '-f', path.join(root, 'docker-compose.yml'), ...(peerGatewayQa ? ['--profile', 'peering'] : [])];
const docker = (...command) => new Promise((resolve, reject) => { const docker = (...command) => new Promise((resolve, reject) => {
@@ -215,10 +236,10 @@ let keep = false;
const image = await api('/lib/' + encodeURIComponent('Moon Garden') + '/cover.png'); assert.equal(image.status, 200); const image = await api('/lib/' + encodeURIComponent('Moon Garden') + '/cover.png'); assert.equal(image.status, 200);
state = await (await api('/api/admin/scraper')).json(); assert.equal(state.paused, true); state = await (await api('/api/admin/scraper')).json(); assert.equal(state.paused, true);
if (process.argv.includes('--settings-ui')) await require('./test-settings-ui')(url, cookie); if (process.argv.includes('--settings-ui')) await require('./test-settings-ui')(url, cookie);
if (process.argv.includes('--discovery-ui')) { if (discoveryQa) {
const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' }); const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' });
assert.equal(login.status, 200); assert.equal(login.status, 200);
await require('./test-discovery-ui')(url, login.headers.get('set-cookie').split(';')[0]); await require('./test-discovery-ui')(url, login.headers.get('set-cookie').split(';')[0], { xssTitle: XSS_TITLE });
} }
if (process.argv.includes('--peer-ui')) { if (process.argv.includes('--peer-ui')) {
const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' }); const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' });
+53 -3
View File
@@ -1,7 +1,7 @@
'use strict'; 'use strict';
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
module.exports = async function testDiscoveryUI(url, cookie) { module.exports = async function testDiscoveryUI(url, cookie, { xssTitle } = {}) {
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true }); const browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
@@ -9,24 +9,71 @@ module.exports = async function testDiscoveryUI(url, cookie) {
await context.addCookies([{ name, value: value.join('='), url }]); await context.addCookies([{ name, value: value.join('='), url }]);
const page = await context.newPage(), errors = []; const page = await context.newPage(), errors = [];
page.on('pageerror', error => errors.push(error.message)); page.on('pageerror', error => errors.push(error.message));
// An injected title must never run script; any dialog counts as a failure.
page.on('dialog', dialog => { errors.push(`Unexpected dialog: ${dialog.message()}`); dialog.dismiss().catch(() => {}); });
try { try {
await page.goto(url + '/discover.html'); await page.goto(url + '/discover.html');
await page.getByRole('heading', { name: 'Discover', exact: true }).waitFor(); await page.getByRole('heading', { name: 'Discover', exact: true }).waitFor();
await page.getByText('Turn on personalized discovery').waitFor(); await page.getByText('Turn on personalized discovery').waitFor();
await page.getByText('the server sends AniList the IDs of titles you liked, without login').waitFor();
await page.getByLabel('Personalized discovery').check(); await page.getByLabel('Personalized discovery').check();
// Review focus 5: the seeded feed is the first one shown, before any run replaces it.
if (xssTitle) {
const seeded = page.locator('.discovery-card').filter({ hasText: 'AniList readers who liked Moon Garden' });
await seeded.waitFor();
assert.equal(await seeded.locator('h2').textContent(), xssTitle);
assert.equal(await page.locator('.discovery-card h2 img').count(), 0);
await seeded.getByText('Why this is here · evidence').click();
const details = await seeded.locator('details li').allTextContents();
assert.ok(details.some(text => text.includes('New: started 2025')), `All reasons listed: ${details}`);
assert.ok(details.some(text => /AniList recommendation.* · fetched \S+/.test(text)), `Evidence fetch date: ${details}`);
}
await page.getByRole('button', { name: 'Run discovery now' }).click(); await page.getByRole('button', { name: 'Run discovery now' }).click();
// The seeded store already says "success"; wait until the run's own feed replaced the seeded card.
await page.waitForFunction(title => /Status: success/.test(document.querySelector('.discovery-admin')?.textContent || '')
&& ![...document.querySelectorAll('.discovery-card h2')].some(heading => heading.textContent === title), xssTitle, { timeout: 60000 });
await page.getByText(/suggestions/, { exact: false }).first().waitFor(); await page.getByText(/suggestions/, { exact: false }).first().waitFor();
await page.waitForFunction(() => document.querySelectorAll('.discovery-card').length > 0); await page.waitForFunction(() => document.querySelectorAll('.discovery-card').length > 0);
await page.locator('.discovery-card').first().getByText('Why this is here · evidence').click(); await page.locator('.discovery-card').first().getByText('Why this is here · evidence').click();
assert.ok(await page.locator('.discovery-card').first().locator('details li').count() > 0); assert.ok(await page.locator('.discovery-card').first().locator('details li').count() > 0);
await page.locator('.discovery-card').first().getByRole('button', { name: '👍 Maybe' }).click(); await page.locator('.discovery-card').first().getByRole('button', { name: '👍 Maybe' }).click();
await page.getByText('Preference saved').waitFor(); await page.getByText('Preference saved').waitFor();
// Per-user refresh: the unreachable AniList endpoint ends it with upstream errors, then the cooldown shows.
const refresh = page.locator('.discovery-refresh');
await page.getByRole('button', { name: 'Refresh my suggestions' }).click();
await page.waitForFunction(() => /Next refresh possible at \d{1,2}:\d{2}/.test(document.querySelector('.discovery-refresh')?.textContent || ''),
null, { timeout: 90000 });
assert.equal(await refresh.isDisabled(), true);
await page.locator('.discovery-admin').getByText('Last harvest: stopped').waitFor({ timeout: 30000 });
await page.locator('.discovery-admin').getByText('upstream-errors').waitFor();
// Nightly settings save through the settings route and survive a reload.
await page.getByLabel('Automatic harvest').check();
await page.getByText('Harvest settings saved').waitFor();
await page.getByLabel('Harvest time').fill('04:15');
await page.getByText(/Harvest settings saved \(on, 04:15\)/).waitFor();
await page.reload();
await page.getByRole('heading', { name: 'Discovery job' }).waitFor();
assert.equal(await page.getByLabel('Harvest time').inputValue(), '04:15');
assert.equal(await page.getByLabel('Automatic harvest').isChecked(), true);
// Harvest now is single-flight: a second click while one runs shows the server's 409 message.
await page.getByRole('button', { name: 'Harvest now' }).click();
await page.getByText('Harvest started').waitFor();
await page.getByRole('button', { name: 'Harvest now' }).click();
await page.getByText('A harvest is already queued or running').waitFor();
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`); assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
await page.setViewportSize({ width: 390, height: 844 });
await page.setViewportSize({ width: 375, height: 812 });
await page.getByRole('button', { name: /Refresh my suggestions|Next refresh possible|Queued|Fetching|Paused by AniList/ }).waitFor();
assert.ok(await refresh.isVisible());
const box = await refresh.boundingBox();
assert.ok(box && box.x >= 0 && box.x + box.width <= 375, `Refresh button off screen: ${JSON.stringify(box)}`);
const overflow = await page.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth, const overflow = await page.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth,
elements: [...document.querySelectorAll('body *')].filter(el => el.getBoundingClientRect().right > innerWidth + 1) elements: [...document.querySelectorAll('body *')].filter(el => el.getBoundingClientRect().right > innerWidth + 1)
.slice(0, 8).map(el => `${el.tagName}.${el.className}: ${Math.round(el.getBoundingClientRect().right)}`) })); .slice(0, 8).map(el => `${el.tagName}.${el.className}: ${Math.round(el.getBoundingClientRect().right)}`) }));
assert.ok(overflow.scroll <= overflow.width, `Discover page overflows on mobile: ${JSON.stringify(overflow)}`); assert.ok(overflow.scroll <= 375, `Discover page overflows on mobile: ${JSON.stringify(overflow)}`);
const create = await page.evaluate(() => fetch('/api/admin/user', { method: 'POST', headers: { 'content-type': 'application/json' }, const create = await page.evaluate(() => fetch('/api/admin/user', { method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'discovery-reader-qa', password: 'disposable-reader-test', admin: false }) }).then(response => response.status)); body: JSON.stringify({ name: 'discovery-reader-qa', password: 'disposable-reader-test', admin: false }) }).then(response => response.status));
assert.equal(create, 200); assert.equal(create, 200);
@@ -36,7 +83,10 @@ module.exports = async function testDiscoveryUI(url, cookie) {
await page.reload(); await page.reload();
await page.getByRole('heading', { name: 'Discover', exact: true }).waitFor(); await page.getByRole('heading', { name: 'Discover', exact: true }).waitFor();
assert.equal(await page.getByRole('button', { name: 'Run discovery now' }).count(), 0); assert.equal(await page.getByRole('button', { name: 'Run discovery now' }).count(), 0);
assert.equal(await page.getByRole('button', { name: 'Harvest now' }).count(), 0);
assert.equal(await page.evaluate(() => fetch('/api/admin/discovery').then(response => response.status)), 403); assert.equal(await page.evaluate(() => fetch('/api/admin/discovery').then(response => response.status)), 403);
assert.equal(await page.evaluate(() => fetch('/api/admin/discovery/harvest', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
.then(response => response.status)), 403);
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`); assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
} finally { await browser.close(); } } finally { await browser.close(); }
}; };
+3 -1
View File
@@ -1,6 +1,6 @@
# Discovery Scout — personalized manga discovery # Discovery Scout — personalized manga discovery
Status: **deterministic first slice implemented; later phases remain a vision**. Updated 2026-10-09. See [`docs/discovery.md`](../docs/discovery.md) for the current contract and deliberate omissions. Status: **deterministic first slice and the wider AniList candidate pool implemented; later phases remain a vision**. Updated 2026-10-09. See [`docs/discovery.md`](../docs/discovery.md) for the current contract and deliberate omissions.
## 1. Outcome ## 1. Outcome
@@ -41,6 +41,8 @@ Use a two-stage pipeline. **Code** gathers, identifies, filters, and computes re
### Candidate input, in priority order ### Candidate input, in priority order
**Implemented (2026-10-25):** items 1 and 2 — the reader harvests AniList recommendations, same-creator works, top titles in the user's tags and fresh titles into a local cache, with budgets, a nightly run and per-user refresh. See the [wider-pool spec](../docs/superpowers/specs/2026-10-25-discovery-wider-pool-design.md) and [`docs/discovery.md`](../docs/discovery.md). Item 3 (admin seed form) is still open.
1. Reuse public AniList metadata/relations already cached by MangaCircus, plus the read-only Greatest catalog, for cold start and sequel/creator leads. Existing *Greatest* votes are stronger preference signals than curator rank. 1. Reuse public AniList metadata/relations already cached by MangaCircus, plus the read-only Greatest catalog, for cold start and sequel/creator leads. Existing *Greatest* votes are stronger preference signals than curator rank.
2. Add one or two explicit, public metadata feeds only after checking their API terms and stable identity fields. Cache responses with timestamps. Publisher/award lists may seed editorial candidates, but only with source links and dates. 2. Add one or two explicit, public metadata feeds only after checking their API terms and stable identity fields. Cache responses with timestamps. Publisher/award lists may seed editorial candidates, but only with source links and dates.
3. Let an admin seed a candidate by metadata URL or exact provider ID. This is the fallback when public APIs are incomplete. It is **not** a scraper URL and creates no queue item. 3. Let an admin seed a candidate by metadata URL or exact provider ID. This is the fallback when public APIs are incomplete. It is **not** a scraper URL and creates no queue item.