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:
1 parent
0f87fbe948
commit
613db96355
10 files changed
+264
-57
No files matched your search
@@ -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) |
|
||||
| `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 |
|
||||
| `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/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` |
|
||||
|
||||
+3
-1
@@ -21,7 +21,7 @@ Data on the host, all bind mounts (no named volumes):
|
||||
| Path (`.env`) | Contents | Back up? |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -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 |
|
||||
| `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)) |
|
||||
| `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) |
|
||||
| `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` |
|
||||
|
||||
+85
-29
@@ -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 curated Greatest catalog, already-cached AniList identities/relations, library
|
||||
metadata, each user's Greatest votes, and explicit Discover feedback. It does not
|
||||
call an LLM, fetch new metadata, inspect chapter/image hosters, edit a collection,
|
||||
The reader has a separate **Discover** page. Suggestions come from the curated
|
||||
Greatest catalog, cached AniList relations of Greatest and library titles, and a
|
||||
wider pool that the reader harvests from AniList's public API for the users who
|
||||
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
|
||||
state; no fourth Compose service, credential, or database is required.
|
||||
state; no further Compose service, credential, or database is required.
|
||||
|
||||
## Use
|
||||
|
||||
1. Open `discover.html`. Each account independently enables **Personalized
|
||||
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
|
||||
not a content-safety guarantee.
|
||||
2. An admin presses **Run discovery now** on the same page. Existing suggestions
|
||||
remain visible until the new feed is published. Runs are bounded to 50
|
||||
candidates and 20 suggestions per opted-in user, with no more than four from
|
||||
one period. A second concurrent run is rejected.
|
||||
3. A reader can open **Why this is here · evidence** and record Interested, Maybe,
|
||||
mature-title switch only affects titles AniList marks as adult or with a known
|
||||
mature tag; it is not a content-safety guarantee.
|
||||
2. **Refresh my suggestions** queues a harvest for your own account (at most once an
|
||||
hour). The button shows the queue position, `Fetching…` while it runs, and when
|
||||
the next refresh is possible. Your existing suggestions stay visible until the
|
||||
new feed is published.
|
||||
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
|
||||
titles disappear at once; other feedback influences the next run. The user
|
||||
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
|
||||
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/discovery.json`: atomic, reader-owned candidate and per-user feed
|
||||
snapshots. A previous valid version is retained as `discovery.json.prev`.
|
||||
A corrupt file makes reads/runs fail closed rather than overwrite it.
|
||||
snapshots plus the last harvest record and the admin's harvest settings. A
|
||||
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
|
||||
`discoveryFeedback`. The account-deletion route removes that feedback.
|
||||
- `GET /api/discovery`: caller's own feed, settings, evidence, and feedback only.
|
||||
- `POST /api/discovery/feedback`: caller's candidate choice; requires opt-in.
|
||||
- `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.
|
||||
- Routes: [architecture.md](architecture.md) (`/api/discovery*`,
|
||||
`/api/admin/discovery*`) and §6.6 there for the harvester.
|
||||
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
form, edition-finding action, or explicit purge control for old feed snapshots.
|
||||
Disabling consent stops feed display and future personalized generation, but
|
||||
existing snapshot/feedback is retained until account deletion or a later data
|
||||
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.
|
||||
There is no admin seed form, edition-finding action, LLM rationale, or explicit
|
||||
purge control for old feed snapshots. Disabling consent stops feed display,
|
||||
harvesting and future personalized generation for that account, but existing
|
||||
snapshot/feedback is retained until account deletion.
|
||||
@@ -318,6 +318,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.discovery-card-body details { margin: 10px 0; }
|
||||
.discovery-card-body ul { padding-left: 18px; }
|
||||
.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 > .row { margin-bottom: 16px; }
|
||||
.catalog-workspace input, .catalog-workspace select, .catalog-workspace textarea { min-width: 0; max-width: 100%; }
|
||||
|
||||
+61
-18
@@ -5,13 +5,25 @@
|
||||
topNav(me);
|
||||
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 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() {
|
||||
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) {
|
||||
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) {
|
||||
try {
|
||||
@@ -19,19 +31,27 @@
|
||||
state.feedback[candidateId] = value;
|
||||
note.textContent = value ? 'Preference saved. The next run will use it.' : 'Preference removed.';
|
||||
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 {
|
||||
await api('api/admin/discovery/run', {});
|
||||
note.textContent = 'Discovery running. Existing suggestions remain visible until it finishes.';
|
||||
await api(endpoint, {});
|
||||
note.textContent = message; note.className = 'muted small';
|
||||
} catch (error) { fail(error); }
|
||||
await refresh();
|
||||
if (active) clearInterval(active);
|
||||
active = setInterval(async () => {
|
||||
await refresh();
|
||||
if (adminState?.currentRun?.status !== 'running') { clearInterval(active); active = null; }
|
||||
}, 1500);
|
||||
} catch (error) { note.textContent = error.message; note.className = 'error'; }
|
||||
watch();
|
||||
}
|
||||
const run = () => start('api/admin/discovery/run', 'Discovery running. Existing suggestions remain visible until it finishes.');
|
||||
const refreshMine = () => start('api/discovery/refresh', 'Refresh queued. Your current suggestions stay visible until it finishes.');
|
||||
const harvestNow = () => start('api/admin/discovery/harvest', 'Harvest started. Suggestions are re-scored when it finishes.');
|
||||
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) {
|
||||
return h('label', { class: 'check' }, h('input', { type: 'checkbox', checked: !!state.settings[key],
|
||||
@@ -40,18 +60,37 @@
|
||||
}
|
||||
function 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('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() {
|
||||
if (!me.admin) return null;
|
||||
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'),
|
||||
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,
|
||||
button('Run discovery now', run, 'primary'),
|
||||
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.'));
|
||||
h('div', { class: 'row' }, button('Run discovery now', run, 'primary'), button('Harvest now', harvestNow)),
|
||||
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) {
|
||||
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', {}, item.reason),
|
||||
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,
|
||||
entry.note ? h('span', { class: 'muted' }, ` — ${entry.note}`) : null))),
|
||||
h('ul', {}, (item.reasons || []).map(reason => h('li', {}, reason)),
|
||||
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('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));
|
||||
@@ -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,
|
||||
preferences(), adminPanel());
|
||||
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'
|
||||
? '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`),
|
||||
h('div', { class: 'discovery-grid' }, state.items.filter(item => !['hidden', 'dismissed'].includes(state.feedback[item.candidateId])).map(card)));
|
||||
}
|
||||
await refresh();
|
||||
watch();
|
||||
})();
|
||||
@@ -315,7 +315,11 @@ try {
|
||||
if (harvest?.status === 'running') discoveryStore.patch({ harvest: { ...harvest, status: 'interrupted', finishedAt: new Date().toISOString() } });
|
||||
} catch (error) { console.error('[discovery]', error.message); }
|
||||
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) {
|
||||
readDiscovery(); // A corrupt store fails closed before any AniList call.
|
||||
try { return harvester.request(args); }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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 { spawn } = require('child_process');
|
||||
const { Canvas } = require('../tools/png');
|
||||
|
||||
const FIXTURES = path.join(__dirname, 'fixtures', 'anilist');
|
||||
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 } };
|
||||
|
||||
// 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 library = path.join(root, 'library'), data = path.join(root, 'data');
|
||||
fs.mkdirSync(path.join(library, 'Example Manga'), { recursive: true });
|
||||
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.writeFileSync(path.join(data, 'scraper', 'queue.json'), JSON.stringify({ queue: [] }));
|
||||
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(); }
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const reader = await startReader();
|
||||
try {
|
||||
|
||||
+23
-2
@@ -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';
|
||||
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 readingGateQa = process.argv.includes('--reading-limit-ui');
|
||||
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,
|
||||
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' } } : {}),
|
||||
// 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'] }));
|
||||
}
|
||||
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":[]}',
|
||||
READING_TIMEZONE: 'Europe/Berlin', READING_QUIZ_UNLOCK_MINUTES: '60', READING_QUIZ_CATALOG_PATH: '/data/reading-quiz.json' }
|
||||
: { 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' } : {}) };
|
||||
const args = ['compose', '-p', project, '-f', path.join(root, 'docker-compose.yml'), ...(peerGatewayQa ? ['--profile', 'peering'] : [])];
|
||||
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);
|
||||
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('--discovery-ui')) {
|
||||
if (discoveryQa) {
|
||||
const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' });
|
||||
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')) {
|
||||
const login = await api('/api/login', { name: 'qa', password: 'disposable-local-test' });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use 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 browser = await chromium.launch({ channel: process.env.QA_BROWSER_CHANNEL || 'msedge', headless: true });
|
||||
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 }]);
|
||||
const page = await context.newPage(), errors = [];
|
||||
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 {
|
||||
await page.goto(url + '/discover.html');
|
||||
await page.getByRole('heading', { name: 'Discover', exact: true }).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();
|
||||
// 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();
|
||||
// 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.waitForFunction(() => document.querySelectorAll('.discovery-card').length > 0);
|
||||
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);
|
||||
await page.locator('.discovery-card').first().getByRole('button', { name: '👍 Maybe' }).click();
|
||||
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('; ')}`);
|
||||
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,
|
||||
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)}`) }));
|
||||
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' },
|
||||
body: JSON.stringify({ name: 'discovery-reader-qa', password: 'disposable-reader-test', admin: false }) }).then(response => response.status));
|
||||
assert.equal(create, 200);
|
||||
@@ -36,7 +83,10 @@ module.exports = async function testDiscoveryUI(url, cookie) {
|
||||
await page.reload();
|
||||
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: '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/harvest', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
|
||||
.then(response => response.status)), 403);
|
||||
assert.equal(errors.length, 0, `Browser errors: ${errors.join('; ')}`);
|
||||
} finally { await browser.close(); }
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -41,6 +41,8 @@ Use a two-stage pipeline. **Code** gathers, identifies, filters, and computes re
|
||||
|
||||
### 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.
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user