Expose Greatest source links and safe scraper inspection
This commit is contained in:
1 parent
f493afcc18
commit
d0275223a7
7 files changed
+238
-6
No files matched your search
+5
-2
@@ -12,7 +12,10 @@ Primary-source checks for selected concrete claims in the seed include [Tezuka's
|
||||
- `greatest/catalog.json` is a versioned, read-only editorial seed in the application image. Entries have stable IDs, debut year, cohort, curator rank, rationale, official source lead, and optional AniList cover/chapter/status metadata. It is **not** part of `library/`, scraper queue, or `data/catalog.json` collections.
|
||||
- `greatest/index.js` validates the seed and performs only exact title/alias matching against the existing library index. A match adds the local cover, chapter count, status and **In library** badge; it never moves or downloads a title.
|
||||
- `data/db.json` holds `greatestRatings[userName][entryId]`. Each account can select `flame`, `good`, `neutral`, or `skip`, or click its current choice to clear it. Existing chapter-version votes are separate. Deleting a user removes their ratings. Include `data/db.json` in backups as before.
|
||||
- The only write route is authenticated `POST /api/greatest/rating` with `{id,rating}` (`null` clears). Authenticated `GET /api/greatest` returns the catalog, per-user choice, valid-user community counts, score, and local-library match. Unknown IDs/ratings are rejected. There is no admin-only gate: every account may vote.
|
||||
- The rating write route is authenticated `POST /api/greatest/rating` with `{id,rating}` (`null` clears). Authenticated `GET /api/greatest` returns the catalog, per-user choice, valid-user community counts, score, local-library match, and supported series links from that manga's library metadata. Unknown IDs/ratings are rejected. There is no admin-only gate for voting.
|
||||
- The details popup shows MangaDex and WeebCentral **series** links from the matched library manga's `manga.json`. Admin-only `GET /api/greatest/sources?id=…` checks live scraper queue state by source series ID and conservative title/alias match. Queue-only title matches are visibly marked as needing edition verification; no search result is silently treated as a confirmed match. If the scraper is unavailable, library links remain visible while queue actions are disabled.
|
||||
- Admins can use `GET /api/greatest/mangadex-search?id=…` for a single on-demand, metadata-only title lookup (10 candidates maximum, two-second minimum between uncached lookups, one-hour result cache). The UI shows candidate title and year so an admin can choose the right edition. The WeebCentral search-page link and a pasted series URL cover sources without a safe automatic title lookup. Neither source is bulk-searched when the dashboard opens.
|
||||
- **Add to scraper** uses the existing admin `enqueue` command with `inspect:true`, `scope:{mode:"missing"}`, `existingPolicy:"skip-any"`, and the owned library ID when available. It adds an inspection job; an admin must confirm any download later in Admin → Scraper. Existing jobs are marked with their queue status and cannot be added again from a known link. The daemon remains responsible for URL validation, duplicate prevention, and source protections. Ordinary users can view known links but cannot inspect queue state or add jobs.
|
||||
|
||||
Within each cohort, **Rank by my vote** sorts flame → good → neutral → skull → unrated; **Rank by everyone** uses `3×flames + thumbs-up − 2×skulls`, with curator rank as the tie-breaker. These are preference scores, not independent critical ratings. The quick chips filter by *your* ratings (or in-library status), even when community sorting is active. The popup shows the full rationale, metadata, official source link, community counts, and a library link where available.
|
||||
|
||||
@@ -20,7 +23,7 @@ Within each cohort, **Rank by my vote** sorts flame → good → neutral → sku
|
||||
|
||||
The one-time seed is curated from the research document. All 106 catalog entries now have a cover URL. Most were matched to public AniList manga metadata; the five initial gaps were checked individually. Phoenix (1954) and Nausicaä use matching AniList records; Kitarō uses Drawn & Quarterly's Kitaro edition, Someone Hertz uses Shonen Jump's first-volume cover, and Hitoner uses Shueisha's 2026 first-volume cover. Hitoner's AniList 2024 entry represents an earlier work, so it was **not** used for the 2026 series. The five added URLs returned image content on 2026-10-09, but remote hosts can change them; the UI still shows a title-letter fallback if an image fails.
|
||||
|
||||
`node tools/enrich-greatest-catalog.js` is an **optional manual** public-AniList metadata refresh, paced at one request every 1.5 seconds. It only accepts matching manga title and **original serialization year**, leaving uncertain results untouched rather than using a sequel or remake cover. The server makes **no metadata calls at runtime**. Browser covers are lazy-loaded from their listed source; local library covers take precedence.
|
||||
`node tools/enrich-greatest-catalog.js` is an **optional manual** public-AniList metadata refresh, paced at one request every 1.5 seconds. It only accepts matching manga title and **original serialization year**, leaving uncertain results untouched rather than using a sequel or remake cover. The dashboard makes **no metadata calls when loading**; only an admin pressing **Search MangaDex** triggers a bounded lookup. Browser covers are lazy-loaded from their listed source; local library covers take precedence.
|
||||
|
||||
Public catalog chapter counts are often absent; the UI says “Chapters unknown” rather than inventing one. For owned titles, the local library count wins. Status comes from AniList's releasing/finished flag or local library metadata, and may be unknown. Official source links may be publisher editions, chapter services, or an award/info lead; they do not imply all chapters are available in every country. No unverified hoster coverage is claimed.
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
|
||||
const normalize = value => String(value || '').normalize('NFKD').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
|
||||
function seriesLink(value) {
|
||||
let url;
|
||||
try { url = new URL(String(value || '')); } catch { return null; }
|
||||
if (url.protocol !== 'https:') return null;
|
||||
const weeb = /^\/series\/([A-Z0-9]{26})(?:\/[^/?#]+)?\/?$/i.exec(url.pathname);
|
||||
if (url.hostname === 'weebcentral.com' && weeb) return {
|
||||
source: 'weebcentral', label: 'WeebCentral', seriesId: weeb[1].toUpperCase(),
|
||||
url: `https://weebcentral.com${url.pathname.replace(/\/$/, '')}`,
|
||||
};
|
||||
const dex = /^\/title\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(?:\/[^/?#]+)?\/?$/i.exec(url.pathname);
|
||||
if (['mangadex.org', 'www.mangadex.org'].includes(url.hostname) && dex) return {
|
||||
source: 'mangadex', label: 'MangaDex', seriesId: dex[1].toLowerCase(),
|
||||
url: `https://mangadex.org/title/${dex[1].toLowerCase()}`,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyOf = link => `${link.source}:${link.seriesId}`;
|
||||
|
||||
function hosterLinks(entry, owned, queue = []) {
|
||||
const names = new Set([entry.title, ...(entry.aliases || [])].map(normalize));
|
||||
const known = new Map();
|
||||
for (const source of owned?.meta?.sources || []) {
|
||||
const link = seriesLink(source.url);
|
||||
if (link) known.set(keyOf(link), { ...link, origin: 'library' });
|
||||
}
|
||||
const jobs = [];
|
||||
for (const item of queue) {
|
||||
const link = seriesLink(item.url);
|
||||
if (!link) continue;
|
||||
if (item.libraryMangaId && owned && item.libraryMangaId !== owned.id) continue;
|
||||
if (!(owned && item.libraryMangaId === owned.id) && !names.has(normalize(item.title)) && !known.has(keyOf(link))) continue;
|
||||
if (!known.has(keyOf(link))) known.set(keyOf(link), { ...link, origin: 'queue title match' });
|
||||
jobs.push({ id: item.id, source: link.source, seriesId: link.seriesId, kind: item.kind, status: item.status });
|
||||
}
|
||||
return { links: [...known.values()], jobs };
|
||||
}
|
||||
|
||||
module.exports = { seriesLink, hosterLinks };
|
||||
@@ -37,6 +37,12 @@
|
||||
.greatest-reason-heading { margin: 18px 0 3px; font-size: 15px; color: #e9a47d; }
|
||||
.greatest-dialog-body p { margin: 9px 0; }
|
||||
.greatest-links { display: flex; flex-wrap: wrap; gap: 8px; margin: 17px 0; }
|
||||
.greatest-hoster-panel { margin-top: 15px; border-top: 1px solid var(--line); padding-top: 4px; }
|
||||
.greatest-hoster-row, .greatest-hoster-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
|
||||
.greatest-hoster-row > a { overflow-wrap: anywhere; }
|
||||
.greatest-hoster-row > .btn { margin-left: auto; }
|
||||
.greatest-hoster-tools > input { flex: 1 1 220px; min-width: 0; }
|
||||
.greatest-hoster-results { max-height: 240px; overflow: auto; padding: 0 8px; border: 1px solid var(--line); border-radius: 8px; }
|
||||
@media (max-width: 680px) {
|
||||
.greatest-toolbar { align-items: stretch; flex-direction: column; }
|
||||
.greatest-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 12px; }
|
||||
|
||||
+92
-2
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
/* Curated catalog UI. Rating is the only write; the library and scraper are untouched. */
|
||||
/* Curated catalog UI. Ratings are personal; scraper inspection is admin-only. */
|
||||
(async function () {
|
||||
let me, data;
|
||||
try { [me, data] = await Promise.all([api('api/me'), api('api/greatest')]); } catch (error) { return showError(error); }
|
||||
@@ -24,6 +24,8 @@
|
||||
}
|
||||
const results = h('div', { class: 'greatest-periods' });
|
||||
const dialog = h('dialog', { class: 'greatest-dialog', 'aria-label': 'Manga details' });
|
||||
const sourceCache = new Map();
|
||||
let detailId = null;
|
||||
document.body.append(dialog);
|
||||
mine.onclick = () => { sortMode = 'mine'; store.set('greatest-sort', sortMode); render(); };
|
||||
everyone.onclick = () => { sortMode = 'all'; store.set('greatest-sort', sortMode); render(); };
|
||||
@@ -90,6 +92,7 @@
|
||||
function plainNote(note) { return String(note || '').replace(/\*/g, ''); }
|
||||
|
||||
function openDetails(item) {
|
||||
detailId = item.id;
|
||||
dialog.textContent = '';
|
||||
const close = h('button', { type: 'button', class: 'btn small', onclick: () => dialog.close() }, 'Close');
|
||||
const links = h('div', { class: 'greatest-links' });
|
||||
@@ -97,6 +100,7 @@
|
||||
if (safeHref(item.source?.url)) links.append(h('a', { class: 'btn', href: item.source.url, target: '_blank', rel: 'noopener noreferrer' },
|
||||
`Official ${item.source.kind || 'source'} ↗`));
|
||||
if (safeHref(item.metadata?.url)) links.append(h('a', { class: 'btn', href: item.metadata.url, target: '_blank', rel: 'noopener noreferrer' }, 'AniList metadata ↗'));
|
||||
const hosterPanel = h('section', { class: 'greatest-hoster-panel', 'aria-label': 'Scraper sources' });
|
||||
const votes = item.votes || {};
|
||||
dialog.append(
|
||||
h('div', { class: 'greatest-dialog-head' }, h('span', { class: 'muted small' }, `${item.period} · curator #${item.rank}`), close),
|
||||
@@ -106,9 +110,95 @@
|
||||
h('h3', { class: 'greatest-reason-heading' }, 'Why it matters'),
|
||||
h('p', {}, plainNote(item.note)),
|
||||
h('p', { class: 'muted small' }, `Community votes: 🔥 ${votes.flame || 0} · 👍 ${votes.good || 0} · ● ${votes.neutral || 0} · 💀 ${votes.skip || 0}`),
|
||||
ratingButtons(item), links,
|
||||
ratingButtons(item), links, hosterPanel,
|
||||
h('p', { class: 'muted small' }, 'Chapter counts and status come from public catalog metadata, or from your local library when present. Availability may vary by region.'))));
|
||||
if (!dialog.open) dialog.showModal();
|
||||
renderHosters(item, hosterPanel, sourceCache.get(item.id));
|
||||
if (me.admin && !sourceCache.has(item.id)) refreshHosters(item, hosterPanel);
|
||||
}
|
||||
|
||||
async function refreshHosters(item, panel) {
|
||||
try {
|
||||
const state = await api(`api/greatest/sources?id=${encodeURIComponent(item.id)}`);
|
||||
sourceCache.set(item.id, state);
|
||||
if (detailId === item.id && dialog.open) renderHosters(item, panel, state);
|
||||
} catch (error) {
|
||||
if (detailId === item.id && dialog.open) renderHosters(item, panel, { available: false, error: error.message, links: item.hosters || [] });
|
||||
}
|
||||
}
|
||||
|
||||
function renderHosters(item, panel, state) {
|
||||
panel.textContent = '';
|
||||
panel.append(h('h3', { class: 'greatest-reason-heading' }, 'MangaDex / WeebCentral'));
|
||||
if (me.admin && !state) {
|
||||
panel.append(h('p', { class: 'muted small' }, 'Checking scraper queue…'));
|
||||
return;
|
||||
}
|
||||
const links = state?.links || item.hosters || [];
|
||||
const jobs = state?.jobs || [];
|
||||
if (!links.length) panel.append(h('p', { class: 'muted small' }, 'No confirmed series link yet. A title search may find a different edition; check it before adding.'));
|
||||
for (const link of links) {
|
||||
const job = jobs.find(job => job.source === link.source && job.seriesId === link.seriesId);
|
||||
panel.append(h('div', { class: 'greatest-hoster-row' },
|
||||
h('a', { href: link.url, target: '_blank', rel: 'noopener noreferrer' }, `${link.label} ↗`),
|
||||
link.origin === 'queue title match' ? h('span', { class: 'muted small' }, 'Queue title match · verify edition') : null,
|
||||
me.admin ? h('span', { class: 'muted small' }, job ? `In scraper · ${job.status.replaceAll('_', ' ')}` : state?.available === false ? 'Queue status unavailable' : 'Not in scraper queue') : null,
|
||||
me.admin && state?.available !== false ? queueButton(item, link.url, job) : null));
|
||||
}
|
||||
if (me.admin) {
|
||||
if (state?.available === false) panel.append(h('p', { class: 'small error' }, `Scraper status unavailable: ${state.error || 'unknown error'}. Queue actions are disabled.`));
|
||||
const message = h('span', { class: 'small', role: 'status' });
|
||||
const search = h('button', { class: 'btn small', type: 'button', onclick: async () => {
|
||||
search.disabled = true; message.textContent = 'Looking up MangaDex titles…';
|
||||
try {
|
||||
const found = await api(`api/greatest/mangadex-search?id=${encodeURIComponent(item.id)}`);
|
||||
message.textContent = '';
|
||||
const results = h('div', { class: 'greatest-hoster-results' });
|
||||
for (const candidate of found.results || []) {
|
||||
const existing = jobs.find(job => job.source === 'mangadex' && job.seriesId === candidate.url.split('/').at(-1));
|
||||
results.append(h('div', { class: 'greatest-hoster-row' },
|
||||
h('a', { href: candidate.url, target: '_blank', rel: 'noopener noreferrer' }, `${candidate.title}${candidate.year ? ` (${candidate.year})` : ''} ↗`),
|
||||
existing ? h('span', { class: 'muted small' }, `In scraper · ${existing.status}`) : null,
|
||||
state?.available !== false ? queueButton(item, candidate.url, existing) : null));
|
||||
}
|
||||
if (!found.results?.length) results.append(h('p', { class: 'muted small' }, 'No MangaDex candidates found.'));
|
||||
panel.append(h('p', { class: 'muted small' }, 'Search candidates are not verified matches. Check title, year, and edition before adding.'), results);
|
||||
} catch (error) { message.textContent = error.message || String(error); }
|
||||
finally { search.disabled = false; }
|
||||
} }, 'Search MangaDex');
|
||||
const refresh = h('button', { class: 'btn small', type: 'button', onclick: () => refreshHosters(item, panel) }, 'Refresh queue status');
|
||||
panel.append(h('div', { class: 'greatest-hoster-tools' }, search,
|
||||
h('a', { class: 'btn small', href: 'https://weebcentral.com/search', target: '_blank', rel: 'noopener noreferrer' }, 'Search WeebCentral ↗'),
|
||||
refresh, message));
|
||||
const manual = h('input', { type: 'url', placeholder: 'Paste a MangaDex or WeebCentral series URL', 'aria-label': 'Series URL' });
|
||||
const add = h('button', { class: 'btn small', type: 'button', disabled: state?.available === false, onclick: () => inspectUrl(item, manual.value, add, message, panel) }, 'Add URL to scraper');
|
||||
panel.append(h('div', { class: 'greatest-hoster-tools' }, manual, add));
|
||||
}
|
||||
}
|
||||
|
||||
function queueButton(item, url, job) {
|
||||
return h('button', { class: 'btn small', type: 'button', disabled: !!job,
|
||||
title: job ? `Already in scraper: ${job.status}` : 'Queue an inspection; confirm in Admin → Scraper before downloading',
|
||||
onclick: async event => {
|
||||
const panel = event.currentTarget.closest('.greatest-hoster-panel');
|
||||
await inspectUrl(item, url, event.currentTarget, null, panel);
|
||||
} }, job ? 'Already added' : 'Add to scraper');
|
||||
}
|
||||
|
||||
async function inspectUrl(item, url, button, message, panel) {
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api('api/admin/scraper', { command: 'enqueue', url: String(url || '').trim(), title: item.title,
|
||||
metadataSearch: item.title, libraryMangaId: item.library?.id, scope: { mode: 'missing' },
|
||||
existingPolicy: 'skip-any', inspect: true });
|
||||
if (message) message.textContent = 'Added for inspection. Confirm any download in Admin → Scraper.';
|
||||
sourceCache.delete(item.id);
|
||||
await refreshHosters(item, panel);
|
||||
} catch (error) {
|
||||
if (message) message.textContent = error.message || String(error);
|
||||
else alert(error.message || String(error));
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function matches(item, q) {
|
||||
|
||||
@@ -16,6 +16,7 @@ const { scanLibrary, scanChangedLibrary, libraryStamps, snapshotIndex, restoreIn
|
||||
const { Catalog, CatalogError, collectionViews, TYPES, ROLES } = require('./catalog');
|
||||
const { RATINGS: GREATEST_RATINGS, loadCatalog: loadGreatestCatalog, findOwned: findGreatestOwned,
|
||||
countsFor: greatestCounts, scoreFor: greatestScore } = require('./greatest');
|
||||
const { hosterLinks } = require('./greatest/hosters');
|
||||
const { secret } = require('./runtime/config');
|
||||
const {
|
||||
applyPulse, applyVisit, chapterInsight, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
|
||||
@@ -204,6 +205,28 @@ async function scraperRequest(endpoint, body) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const mangaDexSearchCache = new Map();
|
||||
let lastMangaDexSearch = 0;
|
||||
async function searchGreatestMangaDex(entry) {
|
||||
const cached = mangaDexSearchCache.get(entry.id);
|
||||
if (cached && Date.now() - cached.time < 60 * 60 * 1000) return cached.results;
|
||||
if (Date.now() - lastMangaDexSearch < 2000) bad(429, 'Wait two seconds before another MangaDex lookup');
|
||||
lastMangaDexSearch = Date.now();
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`https://api.mangadex.org/manga?title=${encodeURIComponent(entry.title)}&limit=10`,
|
||||
{ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(7000) });
|
||||
} catch (error) { throw new HttpError(503, `MangaDex lookup unavailable: ${error.message}`); }
|
||||
if (!response.ok) throw new HttpError(response.status === 429 ? 503 : 502, `MangaDex lookup returned HTTP ${response.status}`);
|
||||
const body = await response.json();
|
||||
const results = (body.data || []).filter(row => /^[0-9a-f-]{36}$/i.test(row.id)).map(row => ({
|
||||
title: row.attributes?.title?.en || Object.values(row.attributes?.title || {})[0] || 'Untitled',
|
||||
year: row.attributes?.year || null, url: `https://mangadex.org/title/${row.id}`,
|
||||
}));
|
||||
mangaDexSearchCache.set(entry.id, { time: Date.now(), results });
|
||||
return results;
|
||||
}
|
||||
|
||||
// Versions sorted by the sum of all user votes; ties go to the higher "priority" in chapter.json, then folder order.
|
||||
function ranked(m, ch) {
|
||||
const votes = (db.votes[m.id] && db.votes[m.id][ch.id]) || {};
|
||||
@@ -380,6 +403,7 @@ const routes = {
|
||||
chapters: owned ? owned.chapters.length : entry.chapters,
|
||||
status: owned?.meta?.status || entry.status,
|
||||
library: owned ? { id: owned.id, chapters: owned.chapters.length } : null,
|
||||
hosters: hosterLinks(entry, owned).links,
|
||||
myRating: db.greatestRatings?.[u.name]?.[entry.id] || null,
|
||||
votes: counts, score: greatestScore(counts),
|
||||
};
|
||||
@@ -397,6 +421,24 @@ const routes = {
|
||||
const votes = greatestCounts(id, all, db.users);
|
||||
return { id, myRating: rating, votes, score: greatestScore(votes) };
|
||||
},
|
||||
'GET /api/greatest/sources': async ({ u, q }) => {
|
||||
admin(u);
|
||||
const entry = greatestById.get(String(q.get('id') || ''));
|
||||
if (!entry) bad(404, 'Catalog entry not found');
|
||||
const owned = findGreatestOwned(entry, lib.mangas);
|
||||
try {
|
||||
const state = await scraperRequest('/api/state');
|
||||
return { available: true, ...hosterLinks(entry, owned, state.queue || []) };
|
||||
} catch (error) {
|
||||
return { available: false, error: error.message, ...hosterLinks(entry, owned) };
|
||||
}
|
||||
},
|
||||
'GET /api/greatest/mangadex-search': async ({ u, q }) => {
|
||||
admin(u);
|
||||
const entry = greatestById.get(String(q.get('id') || ''));
|
||||
if (!entry) bad(404, 'Catalog entry not found');
|
||||
return { results: await searchGreatestMangaDex(entry) };
|
||||
},
|
||||
|
||||
'GET /api/manga': ({ u, q }) => {
|
||||
const m = mangaOf(q.get('m')), prog = progressOf(u, m), showStory = settingsOf(u).readingStory;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadCatalog, findOwned, countsFor, scoreFor } = require('../greatest');
|
||||
const { seriesLink, hosterLinks } = require('../greatest/hosters');
|
||||
|
||||
test('curated manga catalog has unique identities and ordered five-year periods', () => {
|
||||
const catalog = loadCatalog();
|
||||
@@ -30,3 +31,21 @@ test('library matching and vote aggregation stay independent of scraper data', (
|
||||
assert.deepEqual(counts, { flame: 1, good: 1, neutral: 0, skip: 0 });
|
||||
assert.equal(scoreFor(counts), 4);
|
||||
});
|
||||
|
||||
test('hoster links use confirmed series URLs and do not mistake unrelated queue entries for matches', () => {
|
||||
const entry = { title: 'BLAME!', aliases: ['Blame'] };
|
||||
const weeb = 'https://weebcentral.com/series/01J76XY8CJ2RJGWMHM69ZS0J3H';
|
||||
const dex = 'https://mangadex.org/title/06a37094-b3ba-4f25-bf2b-54c9751e0e91/bleach';
|
||||
const owned = { id: 'BLAME!', meta: { sources: [{ name: 'WeebCentral', url: weeb }] } };
|
||||
const result = hosterLinks(entry, owned, [
|
||||
{ id: 'match', title: 'blame', url: weeb, status: 'complete', kind: 'download' },
|
||||
{ id: 'other', title: 'Bleach', url: dex, status: 'queued', kind: 'download' },
|
||||
]);
|
||||
assert.equal(result.links.length, 1);
|
||||
assert.equal(result.links[0].source, 'weebcentral');
|
||||
assert.equal(result.jobs.length, 1);
|
||||
assert.equal(result.jobs[0].status, 'complete');
|
||||
assert.equal(seriesLink('https://weebcentral.com/chapters/01J76XY8CJ2RJGWMHM69ZS0J3H'), null);
|
||||
assert.equal(seriesLink('http://weebcentral.com/series/01J76XY8CJ2RJGWMHM69ZS0J3H'), null);
|
||||
assert.equal(seriesLink(dex).seriesId, '06a37094-b3ba-4f25-bf2b-54c9751e0e91');
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs'), os = require('node:os'), path = require('node:path'), net = require('node:net');
|
||||
const { spawn } = require('node:child_process');
|
||||
const http = require('node:http');
|
||||
const { chromium } = require('playwright');
|
||||
const { loadCatalog } = require('../greatest');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
@@ -20,9 +21,26 @@ async function freePort() {
|
||||
const library = path.join(dir, 'library'), data = path.join(dir, 'data');
|
||||
fs.mkdirSync(library); fs.mkdirSync(data);
|
||||
const port = await freePort(), base = `http://127.0.0.1:${port}`;
|
||||
const queue = [{ id: 'existing', title: 'Astro Boy', url: 'https://mangadex.org/title/ca4c84bb-7272-45aa-a22d-dc1282b52372',
|
||||
source: 'mangadex', seriesId: 'ca4c84bb-7272-45aa-a22d-dc1282b52372', kind: 'download', status: 'queued' }];
|
||||
const fakeScraper = http.createServer(async (req, res) => {
|
||||
let result;
|
||||
if (req.method === 'GET' && req.url === '/api/state') result = { queue };
|
||||
else if (req.method === 'POST' && req.url === '/api/queue') {
|
||||
let body = ''; for await (const chunk of req) body += chunk;
|
||||
const input = JSON.parse(body);
|
||||
queue.push({ id: 'added', title: input.title, url: input.url, source: 'weebcentral',
|
||||
seriesId: String(input.url || '').split('/series/')[1]?.split('/')[0] || '',
|
||||
kind: input.inspect ? 'inspection' : 'download', status: 'queued' });
|
||||
result = { item: queue.at(-1) };
|
||||
} else { res.writeHead(404); return res.end(); }
|
||||
res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(result));
|
||||
});
|
||||
await new Promise(resolve => fakeScraper.listen(0, '127.0.0.1', resolve));
|
||||
const scraperPort = fakeScraper.address().port;
|
||||
const child = spawn(process.execPath, ['server.js'], { cwd: root, windowsHide: true,
|
||||
env: { ...process.env, PORT: String(port), LIBRARY: library, DATA: data, SCAN_INTERVAL: '0',
|
||||
FULL_SCAN_INTERVAL: '0', SCRAPER_URL: 'http://127.0.0.1:1', COOKIE_SECURE: '0' }, stdio: 'ignore' });
|
||||
FULL_SCAN_INTERVAL: '0', SCRAPER_URL: `http://127.0.0.1:${scraperPort}`, COOKIE_SECURE: '0' }, stdio: 'ignore' });
|
||||
let browser;
|
||||
try {
|
||||
for (let i = 0; i < 80; i++) {
|
||||
@@ -59,8 +77,18 @@ async function freePort() {
|
||||
await page.getByRole('button', { name: 'Details for Astro Boy' }).click();
|
||||
await page.getByRole('dialog').getByRole('heading', { name: 'Why it matters' }).waitFor();
|
||||
assert.ok((await page.getByRole('dialog').textContent()).includes(astroReason));
|
||||
await page.getByRole('dialog').getByText('In scraper · queued').waitFor();
|
||||
assert.equal(await page.getByRole('dialog').getByRole('button', { name: 'Already added' }).isDisabled(), true);
|
||||
await page.getByRole('dialog').getByText('Community votes: 🔥 1').waitFor();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click();
|
||||
await page.getByRole('button', { name: 'Details for Princess Knight' }).click();
|
||||
await page.getByRole('dialog').getByRole('textbox', { name: 'Series URL' })
|
||||
.fill('https://weebcentral.com/series/01J76XY7E9FNDZ1DBBM6PBJPF0');
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Add URL to scraper' }).click();
|
||||
await page.getByRole('dialog').getByText('In scraper · queued').waitFor();
|
||||
assert.equal(queue.at(-1).kind, 'inspection');
|
||||
assert.equal(queue.at(-1).title, 'Princess Knight');
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
assert.equal(await astro.locator('.greatest-reason').isVisible(), true);
|
||||
assert.equal(await page.locator('.nav-greatest').evaluate(el => getComputedStyle(el, '::after').content), '"Saikō"');
|
||||
@@ -69,10 +97,11 @@ async function freePort() {
|
||||
assert.equal(await page.locator('.greatest-tile').count(), 0);
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
console.log('Greatest of All Time browser QA passed: periods, ratings, filters, modal, mobile layout.');
|
||||
console.log('Greatest of All Time browser QA passed: periods, ratings, filters, source status, modal, mobile layout.');
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); });
|
||||
await new Promise(resolve => fakeScraper.close(resolve));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in new issue
Block a user