diff --git a/README.md b/README.md index cc24b0f..d8ded5b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ MusicBrainz. > [the spec](docs/specs/001-webtoons-covers.md). Today's goal is one thing done well: **given a MangaBaka series link and a -Webtoons series link, find every cover the Webtoons series has used, current +WEBTOON or Tapas series link, find every cover that series has used, current and historical, and request them added to the MangaBaka series**. The current poster becomes the main (default) cover for the edition's language. @@ -82,6 +82,11 @@ least 25 approved submissions. | Source | Links | Current covers | Historical covers | |---|---|---|---| | WEBTOON (official & canvas) | `https://www.webtoons.com//…/list?title_no=N`, `m.webtoons.com` | poster, square, banner, foreground | Wayback Machine | +| Tapas (comics & novels) | `https://tapas.io/series/` (or `…/info`, the series number, or an `/episode/N` link) | portrait cover (full size), banner | Wayback Machine | + +Tapas is English only. Mature Tapas series are shown only to signed-in adults: +the page can't read their current covers (it says so and still searches the +archive), but the userscript can, when you are signed in to Tapas in that browser. Each Webtoons language edition has its own `title_no`. Run the tool once per edition; the covers are tagged with that edition's language. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aae6a87..851c840 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -15,6 +15,7 @@ self-hosted Bun server (primary), a userscript, and later a Bun CLI. | `src/core/session.ts` | Review state shared by the page and the userscript: cards, main cover, rehosting (cards remember host, cache key and expiry), manual text, the download bundle, `restore` from history, token flow (preview → confirm → save for review). | | `src/core/zip.ts`, `download.ts` | Store-only ZIP writer (CRC-32, UTF-8 names); the download bundle: file names `NN_role_lang[_YYYY]_WxH.ext` and `covers.txt` (spec 002 §2). | | `src/core/saved.ts` | History model (spec 002 §3): `SavedSearch` (plain data, source by id), `toSaved`/`fromSaved` (drops expired links), `carryEdits` (search again), `pruneIds` (30 entries, 300 MB), `HistoryStore` interface and an in-memory one. | +| `src/core/sources/` | `webtoons/` and `tapas/` (spec 003): each `urls.ts` (links, image URL normalisation, same-series), `parse.ts` (pure HTML parsing, also used on Wayback snapshots), `index.ts` (the `Source`); `registry.ts` lists them and builds the proxy and upload allowlists. | | `src/web/` | Built. The page ("Obi Shelf" design, see `DESIGN.md`): `template.ts` markup shared with the userscript panel, `app.ts` wiring (`mountApp`), `render.ts` shelf and timeline views, `view-model.ts` pure helpers, `transport.ts` (direct to MangaBaka/litterbox, everything else via `/api/fetch`), `styles.css` scoped for a shadow root, `save-file.ts` (Blob download, works in the panel's shadow root), `history-db.ts` (IndexedDB `HistoryStore`; null when storage is refused), `history-keeper.ts` (when the series on screen is saved: after a search, debounced edits, marks; flushes before a new search, an open and page hide; removal/clear stop tracking), `recent.ts` (History list and the start screen's recent shelf; the page only), `dark-reader.ts` (switches to the page's own dark theme and locks Dark Reader out when that extension is darkening the page). | | `src/server/` | Built. `app.ts`: web-standard handler (`createApp(env)`) — static assets, `/api/config`, `/api/fetch` allowlisted proxy (redirects re-checked, 20 MB cap), `/api/upload` (store or catbox; takes a source URL, never bytes), `/covers/*`. `storage.ts` (interface, in-memory) and `storage-dir.ts` (folder, size cap, 30-day sweep). `config.ts` (env, client IP), `rate-limit.ts`, `assets-path.ts`. `bun.ts`: thin `Bun.serve` entry. Gate and Workers entry deferred. | | `src/userscript/` | Built. `gm.ts`: `GM_xmlhttpRequest` transport (no proxy; honest User-Agent for Wayback) and GM storage. `main.ts`: a "Seed covers" launcher on MangaBaka series pages (follows client-side navigation) opening the page's app in a shadow-root panel; optional server URL in its settings. Built by `scripts/build-userscript.ts` into `dist/web/mbseed.user.js`, served at `/mbseed.user.js`. | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1fc644e..d854094 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -16,6 +16,20 @@ the repo every session — measurably slower and more tokens). **Consequences:** Rule changes happen in one file; pointer files must stay thin. Plans are working artefacts: kept after merge they go stale and contradict the code, so the spec is the record. +## 2026-10-15 — Tapas source (spec 003) +**Status:** accepted +**Context:** The user asked for tapas.io next; comics and novels (chosen in chat). +**Decision:** A second `Source` beside Webtoons. The series is read from `/series//info` (the plain +`/series/` is the episode reader); id and episode links resolve to it. Covers: the `.thumb-wrapper` +portrait with its size suffix removed (960×1440 original) and the `og:image` banner. Image URLs are normalised +to `us-a.tapas.io` (old pages name a CloudFront host that serves the same paths). Mature series sit behind an +age check that carries no data: they resolve from the check's `from` link as mature with no current covers and +a warning; we don't fake an age or a session. In the userscript the user's own Tapas cookies go along, so a +signed-in adult gets the real page. The same-series check compares the exact slug or id, which also keeps +`-novel` captures out of the Wayback history. +**Alternatives:** Tapas' episode JSON endpoint (no series images); getting past the age check (not ours to do). +**Consequences:** On the page, mature Tapas series only get archived covers. + ## 2026-10-15 — Download as a ZIP, and a history in IndexedDB (spec 002) **Status:** accepted **Context:** First user review: contributors without a token want the cover files to upload by hand, and a diff --git a/docs/TODO.md b/docs/TODO.md index 63e3a0d..c6a0d96 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -44,6 +44,15 @@ Design: [specs/001-webtoons-covers.md](specs/001-webtoons-covers.md). "rehosted" mark says "rehosted on direct" for no rehosting and outlives expired links; `openHistory` reads `globalThis.indexedDB` outside its `try`. +- [ ] Small Tapas fixes from the spec 003 review (deferred as minor): a non-series tapas.io link gets the generic + "No supported source" error instead of "isn't a series"; an episode link to a mature series says it can't + find the series (check `ageGateTarget` there); a mature series pasted by slug keeps the slug as its id, so a + MangaBaka link by number warns; an all-digit slug is read as an id; accept a quoted `pageMeta` id; archived + history covers fall back to the 400×600 `_z` copy when the original is gone. +- [ ] Verify with a signed-in, age-verified Tapas account (userscript): the mature info page parses. +- [ ] Possible race (unconfirmed): pressing **Find covers** before the page has mounted (it awaits `/api/config` + and IndexedDB first) may submit the form natively and reload. Seen once in a scripted run; not reproduced. + ## Someday - [ ] S3-compatible bucket as a `Storage` option, if a single server's disk stops being enough. - [ ] Password gate (spec §4.11) — only if the open site gets abused or traffic grows too large. diff --git a/docs/specs/003-tapas.md b/docs/specs/003-tapas.md new file mode 100644 index 0000000..86dc58f --- /dev/null +++ b/docs/specs/003-tapas.md @@ -0,0 +1,69 @@ +--- +status: approved +--- + +# 003 — Tapas source + +A second source (spec 001 §4.1): covers of a [Tapas](https://tapas.io) series, comics and novels. Design approved +by the user in chat on 2026-10-15 ("Comics and novels"). Everything else — inspection, dedupe, Wayback history, +hosting, download, history — is the existing pipeline. + +## 1. Verified facts (2026-10-15) + +Checked against *Villains Are Destined to Die* (comic, id 186414) and its novel (id 266599), a mature series +(id 286112) and Wayback captures since 2020-10. Fixtures in `test/fixtures/tapas/` and `test/fixtures/wayback/tapas-*`. + +- **Series page:** `https://tapas.io/series//info` (its own canonical URL). `/series/` serves the + episode reader (canonical `/episode/`). `/series//info` redirects to the slug form. Slugs may contain + capitals and underscores (`/series/RU_Screwed`). +- **Series id:** on the info page `pageMeta : { id: , type: 'series_id' }`; on the reader `seriesId: `. +- **Comic or novel:** the stats row links `/static-landing/genre?category=COMIC` or `…=NOVEL`. `og:type` is + `comicpanda:webcomic_series` for both. +- **Title:** the cover image's `alt` in `.thumb-wrapper`; `og:title` is "Read | Tapas Web Comics". +- **Images** (`us-a.tapas.io/sa/<xx>/<uuid>[_<size>].<ext>`, no token, no Referer needed): + - **Portrait cover:** the `<img>` in `.thumb-wrapper` (both the info page and the 2020 reader page), + `…_z.jpg` = 400×600. Without the suffix: the original, 960×1440. + - **Banner:** `og:image` (also the `js-top-banner` / `header-layer` background), 1280×460, no suffix. + - Lists use `_m` (180 px) of other images; `pc/…` images are signed promotions of other series. Neither is used. + - Older pages use `d30womf5coomej.cloudfront.net/sa/…`; the same paths answer on `us-a.tapas.io`. +- **Mature series:** without a signed-in, age-verified account, `/series/…/info` redirects to + `/account/verify-age?from=https://tapas.io/series/<slug>/info`, a page with no series data. The userscript's + `GM_xmlhttpRequest` sends the user's own tapas.io cookies, so a signed-in adult gets the real page there. +- **Language:** English only (MangaBaka `en`). +- **Wayback:** `tapas.io/series/<slug>` captured about monthly since 2020; the prefix also matches + `<slug>-novel` and other longer slugs. + +## 2. Behaviour + +- **Links accepted:** `tapas.io/series/<slug>`, `…/info`, `…/<id>`, `…/<id>/info`, and `tapas.io/episode/<id>` + (the reader page names its series id). `www.` and `m.` hosts too. Fetched as `https://tapas.io/series/<slug or + id>/info`; an episode link first fetches the episode page for its series id. +- **Series:** `externalId` = the numeric id, `title`, `language: "en"`, `canonicalUrl` = + `https://tapas.io/series/<slug>/info`, `flags.novel`, `archiveUrls` = [`tapas.io/series/<slug>`]. +- **Current covers:** portrait (role `poster`, suffix removed) and banner (role `banner`). The poster is the + suggested main cover as for Webtoons. +- **Mature (age check):** the series is resolved from the `from=` link (slug; title from the slug), `mature: true`, + no current covers, and a warning: Tapas shows this series only to signed-in adults; use the userscript while + signed in, or tick the Wayback history. History still runs. +- **Image URLs:** `normalizeImageUrl` maps both image hosts to `us-a.tapas.io`, drops `_<letters>` size suffixes + after the UUID and any query. +- **Same series:** a link is the same series when it is a tapas.io `/series/<slug>[/info][/]` with the same slug + (case-insensitive) or `/series/<id>[/info]` with the same id. This also filters Wayback rows (`-novel`). +- **Hosts:** fetch `tapas.io`, `www.tapas.io`, `m.tapas.io`, `us-a.tapas.io`, `d30womf5coomej.cloudfront.net`; + images `us-a.tapas.io`, `d30womf5coomej.cloudfront.net`. The userscript `@connect`s `tapas.io` and the + CloudFront host. +- **Chrome:** source-neutral where it named WEBTOON: the link hint ("WEBTOON or Tapas for now"), the + "No rehosting" warning ("the source's own image URL"), the userscript description. + +## 3. Testing + +Unit tests on the captured pages, no network: link parsing (every form, rejects), page parsing (comic, novel, +reader, 2020 archive, age check), URL normalisation, same-series (slug, id, `-novel` rejected), and the source +end to end through the fake transport (info page, episode link, age check, `/series/<id>` redirect). +Then a real run in headless Chrome for one comic and one novel. + +## 4. Risks + +- Tapas markup changes; the parser looks for two stable hooks (`thumb-wrapper`, `og:image`) and the page type link. +- The info page may show a mature badge to signed-in users that we never saw; mature is only known from the + age check. diff --git a/scripts/userscript-banner.ts b/scripts/userscript-banner.ts index 6e9c787..c558370 100644 --- a/scripts/userscript-banner.ts +++ b/scripts/userscript-banner.ts @@ -1,13 +1,13 @@ /** Tampermonkey/Violentmonkey metadata. @connect lists every host GM_xmlhttpRequest may reach. */ export function userscriptBanner(version: string, serverUrl?: string): string { - const connects = ["api.mangabaka.org", "webtoons.com", "pstatic.net", "web.archive.org", "catbox.moe"]; + const connects = ["api.mangabaka.org", "webtoons.com", "pstatic.net", "tapas.io", "d30womf5coomej.cloudfront.net", "web.archive.org", "catbox.moe"]; if (serverUrl) connects.push(new URL(serverUrl).hostname); const lines = [ "// ==UserScript==", "// @name MangaBaka Seeder", "// @namespace https://gitea.lucaswinther.info/lucasw89/mangabaka-seeder", `// @version ${version}`, - "// @description Find every WEBTOON cover of a series and turn them into a MangaBaka cover edit request", + "// @description Find every cover of a WEBTOON or Tapas series and turn them into a MangaBaka cover edit request", "// @match https://mangabaka.org/*", "// @grant GM_xmlhttpRequest", "// @grant GM_getValue", diff --git a/src/core/pipeline.ts b/src/core/pipeline.ts index baa0e8a..05b2915 100644 --- a/src/core/pipeline.ts +++ b/src/core/pipeline.ts @@ -71,6 +71,9 @@ export async function runCovers( if (!linkMatches) { warn(`MangaBaka series ${seriesId} has no link to this ${source.name} series (${series.canonicalUrl}). Check that the two links belong together.`); } + if (series.flags.ageGated) { + warn(`${source.name} shows this series only to signed-in adults, so its current covers couldn't be read. Use the userscript while signed in there, or tick "Old covers from the Wayback Machine".`); + } if (series.language === "unknown") warn(`Couldn't tell the language of ${series.canonicalUrl}; covers will be marked "unknown".`); const current = await source.currentCovers(series, ctx); diff --git a/src/core/sources/registry.ts b/src/core/sources/registry.ts index 27153b6..03d2e7b 100644 --- a/src/core/sources/registry.ts +++ b/src/core/sources/registry.ts @@ -1,8 +1,9 @@ import { WAYBACK_HOST } from "../transport"; import type { Source } from "./source"; +import { tapas } from "./tapas"; import { webtoons } from "./webtoons"; -export const SOURCES: readonly Source[] = [webtoons]; +export const SOURCES: readonly Source[] = [webtoons, tapas]; export function findSource(url: URL, sources: readonly Source[] = SOURCES): Source | undefined { return sources.find((s) => s.matches(url)); diff --git a/src/core/sources/tapas/index.ts b/src/core/sources/tapas/index.ts new file mode 100644 index 0000000..b79f7dc --- /dev/null +++ b/src/core/sources/tapas/index.ts @@ -0,0 +1,91 @@ +import type { CoverCandidate, SourceSeries } from "../../model"; +import { SourceError, type Source, type SourceContext } from "../source"; +import { ageGateTarget, episodeSeriesId, extractCovers, parseInfoPage } from "./parse"; +import { + IMAGE_HOST, IMAGE_HOSTS, LEGACY_IMAGE_HOST, TAPAS_HOSTS, episodeUrl, infoUrl, isSameTapasSeries, isTapasUrl, + normalizeImageUrl, parseTapasUrl, slugOf, +} from "./urls"; + +/** Covers parsed by resolve(), so currentCovers() doesn't fetch the page twice. */ +const currentBySeries = new WeakMap<SourceSeries, CoverCandidate[]>(); + +async function fetchHtml(url: string, ctx: SourceContext): Promise<string> { + const res = await ctx.transport.fetch(url, { signal: ctx.signal }); + if (!res.ok) { + await res.body?.cancel(); + throw new SourceError(`Tapas answered HTTP ${res.status} for ${url}.`); + } + return res.text(); +} + +/** "dog-and-bird-mature" → "Dog and bird mature": all we know of a series behind the age check. */ +const titleFromSlug = (slug: string) => { + const words = slug.replace(/[-_]+/g, " ").trim(); + return words.charAt(0).toUpperCase() + words.slice(1); +}; + +export const tapas: Source = { + id: "tapas", + name: "Tapas", + hosts: [...TAPAS_HOSTS, IMAGE_HOST, LEGACY_IMAGE_HOST], + imageHosts: [...IMAGE_HOSTS], + + matches: (url) => isTapasUrl(url), + + async resolve(url, ctx) { + const ref = parseTapasUrl(url); + let key: string; + if (ref.kind === "episode") { + const id = episodeSeriesId(await fetchHtml(episodeUrl(ref.id), ctx)); + if (!id) throw new SourceError(`Couldn't tell which series Tapas episode ${ref.id} belongs to. Use the series page.`); + key = id; + } else { + key = ref.key; + } + const html = await fetchHtml(infoUrl(key), ctx); + + // Mature series: only signed-in adults see the page (spec 003 §2). History can still find covers. + const gate = ageGateTarget(html); + if (gate) { + const slug = slugOf(gate) ?? key; + const series: SourceSeries = { + source: "tapas", externalId: /^\d+$/.test(key) ? key : slug, title: titleFromSlug(slug), language: "en", + canonicalUrl: infoUrl(slug), archiveUrls: [`tapas.io/series/${slug}`], mature: true, + flags: { novel: false, ageGated: true }, + }; + currentBySeries.set(series, []); + return series; + } + + const page = parseInfoPage(html, new URL(infoUrl(key))); + const canonicalUrl = page.canonicalUrl ?? infoUrl(key); + const slug = slugOf(canonicalUrl) ?? key; + if (/^\d+$/.test(key) && page.id && page.id !== key) { + throw new SourceError(`Tapas sent series ${page.id} when asked for ${key}.`); + } + const series: SourceSeries = { + source: "tapas", + externalId: page.id ?? slug, + title: page.title ?? titleFromSlug(slug), + language: "en", + canonicalUrl, + archiveUrls: [`tapas.io/series/${slug}`], + mature: false, + flags: { novel: page.novel, ageGated: false }, + }; + currentBySeries.set(series, page.covers.map((c) => ({ ...c, current: true }))); + return series; + }, + + async currentCovers(series, ctx) { + const cached = currentBySeries.get(series); + if (cached) return cached; + if (series.flags.ageGated) return []; + const html = await fetchHtml(series.canonicalUrl, ctx); + return extractCovers(html, new URL(series.canonicalUrl)).map((c) => ({ ...c, current: true })); + }, + + extractCovers: (html, pageUrl) => extractCovers(html, pageUrl), + normalizeImageUrl: (url) => normalizeImageUrl(url), + isSameSeries: (linkUrl, series) => isSameTapasSeries(linkUrl, slugOf(series.canonicalUrl), series.externalId), +}; diff --git a/src/core/sources/tapas/parse.ts b/src/core/sources/tapas/parse.ts new file mode 100644 index 0000000..2b79b7f --- /dev/null +++ b/src/core/sources/tapas/parse.ts @@ -0,0 +1,84 @@ +import { decodeEntities, findTags, metaContent } from "../../html"; +import type { CoverCandidate, CoverRole } from "../../model"; +import { normalizeImageUrl } from "./urls"; + +export interface InfoPage { + id: string | null; + title: string | null; + canonicalUrl: string | null; + novel: boolean; + covers: CoverCandidate[]; +} + +export function parseInfoPage(html: string, pageUrl: URL): InfoPage { + return { + id: /pageMeta\s*:\s*\{\s*id\s*:\s*(\d+)/.exec(html)?.[1] ?? episodeSeriesId(html), + title: titleOf(html), + canonicalUrl: canonicalOf(html), + novel: /\/static-landing\/genre\?category=NOVEL\b/.test(html), + covers: extractCovers(html, pageUrl), + }; +} + +/** Spec 003 §1: the portrait in `.thumb-wrapper` (info page and the older reader page) and the banner. */ +export function extractCovers(html: string, pageUrl: URL): CoverCandidate[] { + const found: { role: CoverRole; raw: string | undefined }[] = [ + { role: "poster", raw: portraitImg(html)?.src }, + { role: "banner", raw: metaContent(html, "og:image") ?? bannerBackground(html) }, + ]; + const seen = new Set<string>(); + const out: CoverCandidate[] = []; + for (const { role, raw } of found) { + if (!raw || /^\s*data:/i.test(raw)) continue; + let originalUrl: string; + try { + originalUrl = new URL(raw.trim(), pageUrl).toString(); + } catch { + continue; + } + const url = normalizeImageUrl(originalUrl); + if (seen.has(url)) continue; + seen.add(url); + out.push({ url, role, current: false, originalUrl }); + } + return out; +} + +/** The reader page (`/episode/…`, and `/series/<slug>`) says which series it belongs to. */ +export function episodeSeriesId(html: string): string | null { + return /\bseriesId\s*[:=]\s*['"]?(\d+)/.exec(html)?.[1] ?? null; +} + +/** Mature series redirect to an age check; its form carries the series link (spec 003 §1). */ +export function ageGateTarget(html: string): string | null { + if (!/\/account\/verify-age/.test(metaContent(html, "og:url") ?? "")) return null; + return findTags(html, "input").find((i) => i.name === "from" && i.value)?.value ?? null; +} + +function portraitImg(html: string): Record<string, string> | undefined { + const i = html.search(/class="thumb-wrapper\b/); + return i < 0 ? undefined : findTags(html.slice(i, i + 1500), "img")[0]; +} + +function bannerBackground(html: string): string | undefined { + for (const div of findTags(html, "div")) { + if (!/(^|\s)(js-top-banner|header-layer)(\s|$)/.test(div.class ?? "")) continue; + const m = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)/i.exec(div.style ?? ""); + if (m) return m[1]; + } + return undefined; +} + +function titleOf(html: string): string | null { + const alt = portraitImg(html)?.alt?.trim(); + if (alt) return alt; + const og = metaContent(html, "og:title")?.trim(); + return og ? decodeEntities(og).replace(/^Read\s+/, "").replace(/\s*\|\s*Tapas.*$/, "") || null : null; +} + +function canonicalOf(html: string): string | null { + for (const link of findTags(html, "link")) { + if ((link.rel ?? "").toLowerCase() === "canonical" && link.href) return link.href; + } + return null; +} diff --git a/src/core/sources/tapas/urls.ts b/src/core/sources/tapas/urls.ts new file mode 100644 index 0000000..c9aa2e4 --- /dev/null +++ b/src/core/sources/tapas/urls.ts @@ -0,0 +1,59 @@ +import { LinkError } from "../../links"; + +export const TAPAS_HOSTS: ReadonlySet<string> = new Set(["tapas.io", "www.tapas.io", "m.tapas.io"]); +export const IMAGE_HOST = "us-a.tapas.io"; +/** Older pages name CloudFront; the same paths answer on IMAGE_HOST (spec 003 §1). */ +export const LEGACY_IMAGE_HOST = "d30womf5coomej.cloudfront.net"; +export const IMAGE_HOSTS: ReadonlySet<string> = new Set([IMAGE_HOST, LEGACY_IMAGE_HOST]); + +/** A series by slug or numeric id, or an episode whose page names its series. */ +export type TapasRef = { kind: "series"; key: string } | { kind: "episode"; id: string }; + +const SERIES_PATH = /^\/series\/([A-Za-z0-9_-]+)(?:\/info)?\/?$/; +const EPISODE_PATH = /^\/episode\/(\d+)\/?$/; + +export function isTapasUrl(url: URL): boolean { + return TAPAS_HOSTS.has(url.hostname) && (SERIES_PATH.test(url.pathname) || EPISODE_PATH.test(url.pathname)); +} + +export function parseTapasUrl(url: URL): TapasRef { + if (!TAPAS_HOSTS.has(url.hostname)) throw new LinkError(`Not a Tapas link: ${url.hostname}`); + const series = SERIES_PATH.exec(url.pathname); + if (series) return { kind: "series", key: series[1]! }; + const episode = EPISODE_PATH.exec(url.pathname); + if (episode) return { kind: "episode", id: episode[1]! }; + throw new LinkError("That Tapas link isn't a series. Use the series page (tapas.io/series/…)."); +} + +/** The series page with covers; `/series/<id>/info` redirects to the slug form. */ +export const infoUrl = (key: string): string => `https://tapas.io/series/${key}/info`; +export const episodeUrl = (id: string): string => `https://tapas.io/episode/${id}`; + +export function slugOf(seriesUrl: string): string | null { + try { + const u = new URL(seriesUrl); + return TAPAS_HOSTS.has(u.hostname) ? (SERIES_PATH.exec(u.pathname)?.[1] ?? null) : null; + } catch { + return null; + } +} + +/** The original image: IMAGE_HOST, no `_z`/`_m` size suffix after the UUID, no query. */ +export function normalizeImageUrl(raw: string, base?: string | URL): string { + let u: URL; + try { + u = new URL(raw.trim(), base); + } catch { + return raw.trim(); + } + if (!IMAGE_HOSTS.has(u.hostname)) return u.toString(); + const path = u.pathname.replace(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})_[a-z]{1,2}(\.[a-z]+)$/i, "$1$2"); + return `https://${IMAGE_HOST}${path}`; +} + +/** Same series: the same slug (any case) or the same numeric id. A longer slug (`…-novel`) is another series. */ +export function isSameTapasSeries(linkUrl: string, slug: string | null, id: string): boolean { + const key = slugOf(linkUrl); + if (!key) return false; + return /^\d+$/.test(key) ? key === id : slug !== null && key.toLowerCase() === slug.toLowerCase(); +} diff --git a/src/web/template.ts b/src/web/template.ts index 4fdb760..f434c60 100644 --- a/src/web/template.ts +++ b/src/web/template.ts @@ -11,7 +11,7 @@ export const APP_HTML = /* html */ ` <label class="mbseed-slip"> <span class="mbseed-slip-label">Source series link</span> <input id="source-link" name="source" required autocomplete="off" spellcheck="false" placeholder="https://www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95"> - <span class="mbseed-slip-hint">The series page on the manga, manhwa or webcomic site. WEBTOON for now.</span> + <span class="mbseed-slip-hint">The series page on the manga, manhwa or webcomic site. WEBTOON or Tapas for now.</span> </label> <div class="mbseed-settings"> <label class="mbseed-check"><input id="history-on" type="checkbox"> Old covers from the Wayback Machine</label> diff --git a/src/web/view-model.ts b/src/web/view-model.ts index 5a19cb0..ac2b1ca 100644 --- a/src/web/view-model.ts +++ b/src/web/view-model.ts @@ -40,7 +40,7 @@ export function hostChoices(config: ServerConfig | null, opts: HostOptions, fron }, { id: "direct", label: "No rehosting", lifetime: HOSTS.direct.lifetime(opts), disabled: null, - warning: "Submits WEBTOON's own image URL. Covers only on archive.org can't use this.", + warning: "Submits the source's own image URL. Covers only on archive.org can't use this.", }, ); return choices; diff --git a/test/fixtures/tapas/verify-age-dog-and-bird.html b/test/fixtures/tapas/verify-age-dog-and-bird.html new file mode 100644 index 0000000..081dc6a --- /dev/null +++ b/test/fixtures/tapas/verify-age-dog-and-bird.html @@ -0,0 +1,1740 @@ + + + + + +<!DOCTYPE html> +<html> +<head prefix="og: http://ogp.me/ns# fb: http://ogp.me/ns/fb# comicpanda: http://ogp.me/ns/fb/comicpanda#" itemscope itemtype="http://schema.org/Article"> + <title>Age Verification | Tapas + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ + + + + + + + +
+ Home +
+ + + + + + + + + + +
+ Comics +
+ + + + + + + + + + +
+ Novels +
+ + + + + + + + + + +
+ Community +
+ + + + + + + + + + +
+ Mature +
+ + + + + + + + + + + + + +
+
+ + + + +
+ + +
+ + + +
+
+
+
+ + + + + + + + +
+ Home +
+ + + + + + + + + + +
+ Comics +
+ + + + + + + + + + +
+ Novels +
+ + + + + + + + + + +
+ Community +
+ + + + + + + + + + +
+ Mature +
+ + + + + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ +
+ +
+
+ +
+ + + + + + + + + + + + + + + + +
+
+ +

Verify your age

+

You must be 18 years or older to continue browsing mature content. Please verify your age.

+
+ + + +
+
+
+

MM

+ +
+
    + +
  • January

  • + +
  • February

  • + +
  • March

  • + +
  • April

  • + +
  • May

  • + +
  • June

  • + +
  • July

  • + +
  • August

  • + +
  • September

  • + +
  • October

  • + +
  • November

  • + +
  • December

  • + +
+
+
+
+

DD

+ +
+
    + +
  • 1

  • + +
  • 2

  • + +
  • 3

  • + +
  • 4

  • + +
  • 5

  • + +
  • 6

  • + +
  • 7

  • + +
  • 8

  • + +
  • 9

  • + +
  • 10

  • + +
  • 11

  • + +
  • 12

  • + +
  • 13

  • + +
  • 14

  • + +
  • 15

  • + +
  • 16

  • + +
  • 17

  • + +
  • 18

  • + +
  • 19

  • + +
  • 20

  • + +
  • 21

  • + +
  • 22

  • + +
  • 23

  • + +
  • 24

  • + +
  • 25

  • + +
  • 26

  • + +
  • 27

  • + +
  • 28

  • + +
  • 29

  • + +
  • 30

  • + +
  • 31

  • + +
+
+
+
+

YYYY

+ +
+
    + +
  • 2026

  • + +
  • 2025

  • + +
  • 2024

  • + +
  • 2023

  • + +
  • 2022

  • + +
  • 2021

  • + +
  • 2020

  • + +
  • 2019

  • + +
  • 2018

  • + +
  • 2017

  • + +
  • 2016

  • + +
  • 2015

  • + +
  • 2014

  • + +
  • 2013

  • + +
  • 2012

  • + +
  • 2011

  • + +
  • 2010

  • + +
  • 2009

  • + +
  • 2008

  • + +
  • 2007

  • + +
  • 2006

  • + +
  • 2005

  • + +
  • 2004

  • + +
  • 2003

  • + +
  • 2002

  • + +
  • 2001

  • + +
  • 2000

  • + +
  • 1999

  • + +
  • 1998

  • + +
  • 1997

  • + +
  • 1996

  • + +
  • 1995

  • + +
  • 1994

  • + +
  • 1993

  • + +
  • 1992

  • + +
  • 1991

  • + +
  • 1990

  • + +
  • 1989

  • + +
  • 1988

  • + +
  • 1987

  • + +
  • 1986

  • + +
  • 1985

  • + +
  • 1984

  • + +
  • 1983

  • + +
  • 1982

  • + +
  • 1981

  • + +
  • 1980

  • + +
  • 1979

  • + +
  • 1978

  • + +
  • 1977

  • + +
  • 1976

  • + +
  • 1975

  • + +
  • 1974

  • + +
  • 1973

  • + +
  • 1972

  • + +
  • 1971

  • + +
  • 1970

  • + +
  • 1969

  • + +
  • 1968

  • + +
  • 1967

  • + +
  • 1966

  • + +
  • 1965

  • + +
  • 1964

  • + +
  • 1963

  • + +
  • 1962

  • + +
  • 1961

  • + +
  • 1960

  • + +
  • 1959

  • + +
  • 1958

  • + +
  • 1957

  • + +
  • 1956

  • + +
  • 1955

  • + +
  • 1954

  • + +
  • 1953

  • + +
  • 1952

  • + +
  • 1951

  • + +
  • 1950

  • + +
  • 1949

  • + +
  • 1948

  • + +
  • 1947

  • + +
  • 1946

  • + +
  • 1945

  • + +
  • 1944

  • + +
  • 1943

  • + +
  • 1942

  • + +
  • 1941

  • + +
  • 1940

  • + +
  • 1939

  • + +
  • 1938

  • + +
  • 1937

  • + +
  • 1936

  • + +
  • 1935

  • + +
  • 1934

  • + +
  • 1933

  • + +
  • 1932

  • + +
  • 1931

  • + +
  • 1930

  • + +
  • 1929

  • + +
  • 1928

  • + +
  • 1927

  • + +
  • 1926

  • + +
  • 1925

  • + +
  • 1924

  • + +
  • 1923

  • + +
  • 1922

  • + +
  • 1921

  • + +
  • 1920

  • + +
  • 1919

  • + +
  • 1918

  • + +
  • 1917

  • + +
  • 1916

  • + +
  • 1915

  • + +
  • 1914

  • + +
  • 1913

  • + +
  • 1912

  • + +
  • 1911

  • + +
  • 1910

  • + +
  • 1909

  • + +
  • 1908

  • + +
  • 1907

  • + +
  • 1906

  • + +
  • 1905

  • + +
  • 1904

  • + +
  • 1903

  • + +
  • 1902

  • + +
  • 1901

  • + +
+
+
+
+ Cancel + +
+
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/fixtures/tapas/villains-episode-viewer.html b/test/fixtures/tapas/villains-episode-viewer.html new file mode 100644 index 0000000..8e6e3f8 --- /dev/null +++ b/test/fixtures/tapas/villains-episode-viewer.html @@ -0,0 +1,3308 @@ + + + + + + + + + Read Villains Are Destined to Die | Tapas Web Comics + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/fixtures/tapas/villains-info.html b/test/fixtures/tapas/villains-info.html new file mode 100644 index 0000000..32fbf54 --- /dev/null +++ b/test/fixtures/tapas/villains-info.html @@ -0,0 +1,3785 @@ + + + + + + + + + Read Villains Are Destined to Die | Tapas Web Comics + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ + + + + + + + +
+ Home +
+ + + + + + + + + + +
+ Comics +
+ + + + + + + + + + +
+ Novels +
+ + + + + + + + + + +
+ Community +
+ + + + + + + + + + +
+ Mature +
+ + + + + + + + + + + + + +
+
+ + + + +
+ + +
+ + + +
+
+
+
+ + + + + + + + +
+ Home +
+ + + + + + + + + + +
+ Comics +
+ + + + + + + + + + +
+ Novels +
+ + + + + + + + + + +
+ Community +
+ + + + + + + + + + +
+ Mature +
+ + + + + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ +
+ +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/fixtures/tapas/villains-novel-info.html b/test/fixtures/tapas/villains-novel-info.html new file mode 100644 index 0000000..e42b6a1 --- /dev/null +++ b/test/fixtures/tapas/villains-novel-info.html @@ -0,0 +1,3698 @@ + + + + + + + + + Read Villains Are Destined to Die | Tapas Web Novels + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ + + + + + + + +
+ Home +
+ + + + + + + + + + +
+ Comics +
+ + + + + + + + + + +
+ Novels +
+ + + + + + + + + + +
+ Community +
+ + + + + + + + + + +
+ Mature +
+ + + + + + + + + + + + + +
+
+ + + + +
+ + +
+ + + +
+
+
+
+ + + + + + + + +
+ Home +
+ + + + + + + + + + +
+ Comics +
+ + + + + + + + + + +
+ Novels +
+ + + + + + + + + + +
+ Community +
+ + + + + + + + + + +
+ Mature +
+ + + + + + + + + + + + + +
+
+ + + + +
+
+ + + + + + +
+ +
+ +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/fixtures/wayback/tapas-villains-2020.html b/test/fixtures/wayback/tapas-villains-2020.html new file mode 100644 index 0000000..27b887f --- /dev/null +++ b/test/fixtures/wayback/tapas-villains-2020.html @@ -0,0 +1,2174 @@ + + + + + + + + + Villains Are Destined to Die | Tapas + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/fixtures/wayback/tapas-villains-cdx.txt b/test/fixtures/wayback/tapas-villains-cdx.txt new file mode 100644 index 0000000..fd1524a --- /dev/null +++ b/test/fixtures/wayback/tapas-villains-cdx.txt @@ -0,0 +1,182 @@ +20201031233824 https://tapas.io/series/villains-are-destined-to-die +20201101073743 https://tapas.io/series/villains-are-destined-to-die +20201201230244 https://tapas.io/series/villains-are-destined-to-die +20210115210204 https://tapas.io/series/villains-are-destined-to-die +20210310220234 https://tapas.io/series/villains-are-destined-to-die +20210815193810 https://tapas.io/series/villains-are-destined-to-die +20220207235742 https://tapas.io/series/villains-are-destined-to-die/ +20220314091858 https://tapas.io/series/villains-are-destined-to-die +20221020175350 https://tapas.io/series/villains-are-destined-to-die +20230130104227 https://tapas.io/series/villains-are-destined-to-die +20230216203609 https://tapas.io/series/villains-are-destined-to-die +20230711060200 https://tapas.io/series/villains-are-destined-to-die +20240324214731 https://tapas.io/series/villains-are-destined-to-die +20240722175417 https://tapas.io/series/villains-are-destined-to-die +20240910161454 https://tapas.io/series/villains-are-destined-to-die +20241002071717 https://tapas.io/series/villains-are-destined-to-die +20241122170150 https://tapas.io/series/villains-are-destined-to-die +20241210090231 https://tapas.io/series/villains-are-destined-to-die +20250321012616 https://tapas.io/series/villains-are-destined-to-die +20250401004641 https://tapas.io/series/villains-are-destined-to-die +20250801205333 https://tapas.io/series/villains-are-destined-to-die +20251112024545 https://tapas.io/series/villains-are-destined-to-die +20251201020925 https://tapas.io/series/villains-are-destined-to-die +20260112215610 https://tapas.io/series/villains-are-destined-to-die +20260309000646 https://tapas.io/series/villains-are-destined-to-die +20260527150043 https://tapas.io/series/villains-are-destined-to-die +20260701201337 https://tapas.io/series/villains-are-destined-to-die +20260825092034 https://tapas.io/series/villains-are-destined-to-die +20260908215721 https://tapas.io/series/villains-are-destined-to-die +20261011020749 https://tapas.io/series/villains-are-destined-to-die +20230916222951 https://tapas.io/series/villains-are-destined-to-die-novel +20231207104017 https://tapas.io/series/villains-are-destined-to-die-novel +20240101234523 https://tapas.io/series/villains-are-destined-to-die-novel +20240203072125 https://tapas.io/series/villains-are-destined-to-die-novel +20240302031318 https://tapas.io/series/villains-are-destined-to-die-novel +20240402010836 https://tapas.io/series/villains-are-destined-to-die-novel +20240502022841 https://tapas.io/series/villains-are-destined-to-die-novel +20240605030250 https://tapas.io/series/villains-are-destined-to-die-novel +20240705062104 https://tapas.io/series/villains-are-destined-to-die-novel +20240801210136 https://tapas.io/series/villains-are-destined-to-die-novel +20240902111241 https://tapas.io/series/villains-are-destined-to-die-novel +20241109074723 https://tapas.io/series/villains-are-destined-to-die-novel +20241210034115 https://tapas.io/series/villains-are-destined-to-die-novel +20250115001019 https://tapas.io/series/villains-are-destined-to-die-novel +20250203123143 https://tapas.io/series/villains-are-destined-to-die-novel +20250403074738 https://tapas.io/series/villains-are-destined-to-die-novel +20250711024058 https://tapas.io/series/villains-are-destined-to-die-novel +20251116194411 https://tapas.io/series/villains-are-destined-to-die-novel +20251219041119 https://tapas.io/series/villains-are-destined-to-die-novel +20260214141029 https://tapas.io/series/villains-are-destined-to-die-novel +20260819182839 https://tapas.io/series/villains-are-destined-to-die-novel +20260913185942 https://tapas.io/series/villains-are-destined-to-die-novel +20231221015719 https://tapas.io/series/villains-are-destined-to-die-novel/info +20240322004209 https://tapas.io/series/villains-are-destined-to-die-novel/info +20240506074459 https://tapas.io/series/villains-are-destined-to-die-novel/info +20240623184336 https://tapas.io/series/villains-are-destined-to-die-novel/info +20240712220349 https://tapas.io/series/villains-are-destined-to-die-novel/info +20240905185533 https://tapas.io/series/villains-are-destined-to-die-novel/info +20241203140218 https://tapas.io/series/villains-are-destined-to-die-novel/info +20250124084148 https://tapas.io/series/villains-are-destined-to-die-novel/info +20250228023359 https://tapas.io/series/villains-are-destined-to-die-novel/info +20250319155034 https://tapas.io/series/villains-are-destined-to-die-novel/info +20250426151250 https://tapas.io/series/villains-are-destined-to-die-novel/info +20250729162654 https://tapas.io/series/villains-are-destined-to-die-novel/info +20251008221657 https://tapas.io/series/villains-are-destined-to-die-novel/info +20251208081857 https://tapas.io/series/villains-are-destined-to-die-novel/info +20260215113844 https://tapas.io/series/villains-are-destined-to-die-novel/info +20260913185954 https://tapas.io/series/villains-are-destined-to-die-novel/info +20261003173459 https://tapas.io/series/villains-are-destined-to-die-novel/?_escaped_fragment_=/go-to-top +20260923013556 https://tapas.io/series/villains-are-destined-to-die-novel?c_id=1881&c_title=%F0%9F%8F%86Tapas%20Top%20Titles%F0%9F%8F%86 +20241004093328 https://tapas.io/series/villains-are-destined-to-die-novel?c_id=1888&c_title=%F0%9F%8E%86March%20Series%20Finale%20Sale%F0%9F%8E%87 +20260923025415 https://tapas.io/series/villains-are-destined-to-die-novel?c_id=1888&c_title=%F0%9F%8E%86March%20Series%20Finale%20Sale%F0%9F%8E%87 +20260227125134 https://tapas.io/series/villains-are-destined-to-die/announcement +20201101061815 https://tapas.io/series/villains-are-destined-to-die/info +20201209223441 https://tapas.io/series/villains-are-destined-to-die/info +20210720144012 https://tapas.io/series/villains-are-destined-to-die/info +20220307133159 https://tapas.io/series/villains-are-destined-to-die/info +20220616190944 https://tapas.io/series/villains-are-destined-to-die/info +20221024005722 https://tapas.io/series/villains-are-destined-to-die/info +20221105204538 https://tapas.io/series/villains-are-destined-to-die/info +20230325233316 https://tapas.io/series/villains-are-destined-to-die/info +20230417173531 https://tapas.io/series/villains-are-destined-to-die/info +20241109074750 https://tapas.io/series/villains-are-destined-to-die/info +20241207140447 https://tapas.io/series/villains-are-destined-to-die/info +20250116032916 https://tapas.io/series/villains-are-destined-to-die/info +20250203123325 https://tapas.io/series/villains-are-destined-to-die/info +20250304001719 https://tapas.io/series/villains-are-destined-to-die/info +20250419222427 https://tapas.io/series/villains-are-destined-to-die/info +20250801205419 https://tapas.io/series/villains-are-destined-to-die/info +20251002220415 https://tapas.io/series/villains-are-destined-to-die/info +20251124103547 https://tapas.io/series/villains-are-destined-to-die/info +20251213022322 https://tapas.io/series/villains-are-destined-to-die/info +20260306115123 https://tapas.io/series/villains-are-destined-to-die/info +20260604195453 https://tapas.io/series/villains-are-destined-to-die/info +20260815195119 https://tapas.io/series/villains-are-destined-to-die/info +20260905232522 https://tapas.io/series/villains-are-destined-to-die/info +20261003190724 https://tapas.io/series/villains-are-destined-to-die/info +20260925012911 https://tapas.io/series/villains-are-destined-to-die/?_escaped_fragment_= +20230127171805 https://tapas.io/series/villains-are-destined-to-die?c_id=1002&c_title=Destined%20to%20be%20Royal%20Collection +20250513063636 https://tapas.io/series/villains-are-destined-to-die?c_id=1002&c_title=Destined%20to%20be%20Royal%20Collection +20260923015219 https://tapas.io/series/villains-are-destined-to-die?c_id=1002&c_title=Destined%20to%20be%20Royal%20Collection +20240224192751 https://tapas.io/series/villains-are-destined-to-die?c_id=1011&c_title=Women%27s%20History%20Month +20260923024728 https://tapas.io/series/villains-are-destined-to-die?c_id=1011&c_title=Women%27s%20History%20Month +20230329141002 https://tapas.io/series/villains-are-destined-to-die?c_id=1031&c_title=Fight%20for%20Love +20260923014557 https://tapas.io/series/villains-are-destined-to-die?c_id=1031&c_title=Fight%20for%20Love +20240225201156 https://tapas.io/series/villains-are-destined-to-die?c_id=1055&c_title=Best%20of%20Romance +20260923013434 https://tapas.io/series/villains-are-destined-to-die?c_id=1055&c_title=Best%20of%20Romance +20220521225521 https://tapas.io/series/villains-are-destined-to-die?c_id=1069&c_title=the%20Best%20Romance%20Fantasy%20collection +20230321044352 https://tapas.io/series/villains-are-destined-to-die?c_id=1069&c_title=the%20Best%20Romance%20Fantasy%20collection +20240418234531 https://tapas.io/series/villains-are-destined-to-die?c_id=1069&c_title=the%20Best%20Romance%20Fantasy%20collection +20260923022147 https://tapas.io/series/villains-are-destined-to-die?c_id=1069&c_title=the%20Best%20Romance%20Fantasy%20collection +20230325201634 https://tapas.io/series/villains-are-destined-to-die?c_id=1078&c_title=Most_viewed_comics_romance +20260923021822 https://tapas.io/series/villains-are-destined-to-die?c_id=1078&c_title=Most_viewed_comics_romance +20240302232254 https://tapas.io/series/villains-are-destined-to-die?c_id=1092&c_title=the%20Love%20to%20Remember%20collection +20241208054523 https://tapas.io/series/villains-are-destined-to-die?c_id=1092&c_title=the%20Love%20to%20Remember%20collection +20260923025515 https://tapas.io/series/villains-are-destined-to-die?c_id=1092&c_title=the%20Love%20to%20Remember%20collection +20220523165656 https://tapas.io/series/villains-are-destined-to-die?c_id=1113&c_title=The%20Immersive%20Gaming%20collection +20260923032027 https://tapas.io/series/villains-are-destined-to-die?c_id=1113&c_title=The%20Immersive%20Gaming%20collection +20230131004552 https://tapas.io/series/villains-are-destined-to-die?c_id=1124&c_title=Play%20Now%21 +20250514082332 https://tapas.io/series/villains-are-destined-to-die?c_id=1124&c_title=Play%20Now%21 +20260608085958 https://tapas.io/series/villains-are-destined-to-die?c_id=1124&c_title=Play%20Now%21 +20260923015655 https://tapas.io/series/villains-are-destined-to-die?c_id=1124&c_title=Play%20Now%21 +20230202081423 https://tapas.io/series/villains-are-destined-to-die?c_id=1126&c_title=The%20Ultimate%20Villainess%20Collection +20260423000531 https://tapas.io/series/villains-are-destined-to-die?c_id=1126&c_title=The%20Ultimate%20Villainess%20Collection +20260923020956 https://tapas.io/series/villains-are-destined-to-die?c_id=1126&c_title=The%20Ultimate%20Villainess%20Collection +20250319165522 https://tapas.io/series/villains-are-destined-to-die?c_id=1397&c_title=Tyrants%20in%20love +20260923030759 https://tapas.io/series/villains-are-destined-to-die?c_id=1397&c_title=Tyrants%20in%20love +20250113210223 https://tapas.io/series/villains-are-destined-to-die?c_id=1419&c_title=The%20Deadliest%20of%20Games +20260422152724 https://tapas.io/series/villains-are-destined-to-die?c_id=1419&c_title=The%20Deadliest%20of%20Games +20260923025934 https://tapas.io/series/villains-are-destined-to-die?c_id=1419&c_title=The%20Deadliest%20of%20Games +20240724005926 https://tapas.io/series/villains-are-destined-to-die?c_id=1447&c_title=The%20Villainess%20Collection +20250213134412 https://tapas.io/series/villains-are-destined-to-die?c_id=1447&c_title=The%20Villainess%20Collection +20260923020807 https://tapas.io/series/villains-are-destined-to-die?c_id=1447&c_title=The%20Villainess%20Collection +20240616181138 https://tapas.io/series/villains-are-destined-to-die?c_id=1465&c_title=Top%20Tapas%2010 +20250907141930 https://tapas.io/series/villains-are-destined-to-die?c_id=1465&c_title=Top%20Tapas%2010 +20260923024142 https://tapas.io/series/villains-are-destined-to-die?c_id=1465&c_title=Top%20Tapas%2010 +20250714001404 https://tapas.io/series/villains-are-destined-to-die?c_id=1474&c_title=Villainesses%20are%20here%20to%20Stay +20260923013259 https://tapas.io/series/villains-are-destined-to-die?c_id=1474&c_title=Villainesses%20are%20here%20to%20Stay +20251011045613 https://tapas.io/series/villains-are-destined-to-die?c_id=1479&c_title=March%20Takeover%20Week%203%20Collection +20260923014334 https://tapas.io/series/villains-are-destined-to-die?c_id=1479&c_title=March%20Takeover%20Week%203%20Collection +20250126055929 https://tapas.io/series/villains-are-destined-to-die?c_id=1491&c_title=Best%20Dramatic%20Stories%21 +20260923020334 https://tapas.io/series/villains-are-destined-to-die?c_id=1491&c_title=Best%20Dramatic%20Stories%21 +20250217164440 https://tapas.io/series/villains-are-destined-to-die?c_id=1498&c_title=Top%2010%20Romance%20Stories +20260923023032 https://tapas.io/series/villains-are-destined-to-die?c_id=1498&c_title=Top%2010%20Romance%20Stories +20250206141557 https://tapas.io/series/villains-are-destined-to-die?c_id=1554&c_title=I%20am%20the%20Villainess%3F%21 +20260608151039 https://tapas.io/series/villains-are-destined-to-die?c_id=1554&c_title=I%20am%20the%20Villainess%3F%21 +20260923021013 https://tapas.io/series/villains-are-destined-to-die?c_id=1554&c_title=I%20am%20the%20Villainess%3F%21 +20250616192131 https://tapas.io/series/villains-are-destined-to-die?c_id=1556&c_title=Romance%20with%20Fantasy +20260923030730 https://tapas.io/series/villains-are-destined-to-die?c_id=1556&c_title=Romance%20with%20Fantasy +20230606014551 https://tapas.io/series/villains-are-destined-to-die?c_id=1594&c_title=Devastatingly%20Handsome%21 +20250123124357 https://tapas.io/series/villains-are-destined-to-die?c_id=1594&c_title=Devastatingly%20Handsome%21 +20260923020103 https://tapas.io/series/villains-are-destined-to-die?c_id=1594&c_title=Devastatingly%20Handsome%21 +20240715233146 https://tapas.io/series/villains-are-destined-to-die?c_id=1670&c_title=Strategic%20Leads +20260923012757 https://tapas.io/series/villains-are-destined-to-die?c_id=1670&c_title=Strategic%20Leads +20250122205620 https://tapas.io/series/villains-are-destined-to-die?c_id=1681&c_title=September%202022%3A%20The%20Quintessential%20Sale +20260219001555 https://tapas.io/series/villains-are-destined-to-die?c_id=1681&c_title=September%202022%3A%20The%20Quintessential%20Sale +20260923021113 https://tapas.io/series/villains-are-destined-to-die?c_id=1681&c_title=September%202022%3A%20The%20Quintessential%20Sale +20240811222255 https://tapas.io/series/villains-are-destined-to-die?c_id=1684&c_title=September%20Binge-A-Thon%3A%20Hand-picked%20series%20for%20you%21 +20260923023244 https://tapas.io/series/villains-are-destined-to-die?c_id=1684&c_title=September%20Binge-A-Thon%3A%20Hand-picked%20series%20for%20you%21 +20251010161412 https://tapas.io/series/villains-are-destined-to-die?c_id=1688&c_title=Hand-picked%20series%20for%20you%21 +20260923012845 https://tapas.io/series/villains-are-destined-to-die?c_id=1688&c_title=Hand-picked%20series%20for%20you%21 +20250326011650 https://tapas.io/series/villains-are-destined-to-die?c_id=1832&c_title=%F0%9F%8E%B9A%20Helpful%20Guide%20to%20Tapas%20July%20Deals%F0%9F%8E%B9 +20260923022750 https://tapas.io/series/villains-are-destined-to-die?c_id=1832&c_title=%F0%9F%8E%B9A%20Helpful%20Guide%20to%20Tapas%20July%20Deals%F0%9F%8E%B9 +20250804191603 https://tapas.io/series/villains-are-destined-to-die?c_id=1880&c_title=%F0%9F%8E%89Tapas%2012th%20Birthday%20Bash%F0%9F%8E%82 +20260923020934 https://tapas.io/series/villains-are-destined-to-die?c_id=1880&c_title=%F0%9F%8E%89Tapas%2012th%20Birthday%20Bash%F0%9F%8E%82 +20250521233327 https://tapas.io/series/villains-are-destined-to-die?c_id=1882&c_title=%5BBirthday%20Bash%5D%20Romance +20260923020348 https://tapas.io/series/villains-are-destined-to-die?c_id=1882&c_title=%5BBirthday%20Bash%5D%20Romance +20220129003329 https://tapas.io/series/villains-are-destined-to-die?c_id=400&c_title=Featured%20comics +20221207052610 https://tapas.io/series/villains-are-destined-to-die?c_id=400&c_title=Featured%20comics +20230321162858 https://tapas.io/series/villains-are-destined-to-die?c_id=400&c_title=Featured%20comics +20220309091051 https://tapas.io/series/villains-are-destined-to-die?c_id=400&c_title=Featured+comics +20260923015856 https://tapas.io/series/villains-are-destined-to-die?c_id=911&c_title=Grateful%20for%20Love%F0%9F%92%9D +20240221075429 https://tapas.io/series/villains-are-destined-to-die?c_id=937&c_title=Best%20of%202020 +20260923021202 https://tapas.io/series/villains-are-destined-to-die?c_id=937&c_title=Best%20of%202020 +20250719171504 https://tapas.io/series/villains-are-destined-to-die?c_id=953&c_title=%E2%9D%84%EF%B8%8FLet%20It%20Snow%20Hide%20and%20Seek%20Challenge%E2%9D%84%EF%B8%8F +20260923033236 https://tapas.io/series/villains-are-destined-to-die?c_id=953&c_title=%E2%9D%84%EF%B8%8FLet%20It%20Snow%20Hide%20and%20Seek%20Challenge%E2%9D%84%EF%B8%8F +20230127080232 https://tapas.io/series/villains-are-destined-to-die?c_id=956&c_title=Game%20on +20241011084243 https://tapas.io/series/villains-are-destined-to-die?c_id=956&c_title=Game%20on +20260923015454 https://tapas.io/series/villains-are-destined-to-die?c_id=956&c_title=Game%20on +20220521133810 https://tapas.io/series/villains-are-destined-to-die?c_id=975&c_title=Villainess%20Face-off%21 +20230320232000 https://tapas.io/series/villains-are-destined-to-die?c_id=975&c_title=Villainess%20Face-off%21 +20260923020331 https://tapas.io/series/villains-are-destined-to-die?c_id=975&c_title=Villainess%20Face-off%21 diff --git a/test/gm.test.ts b/test/gm.test.ts index db0c06b..41a439d 100644 --- a/test/gm.test.ts +++ b/test/gm.test.ts @@ -65,7 +65,7 @@ test("banner: matches mangabaka.org, grants GM APIs, connects to every host it n expect(banner).toContain("// @version 0.1.0\n"); expect(banner).toContain("// @match https://mangabaka.org/*\n"); for (const grant of ["GM_xmlhttpRequest", "GM_getValue", "GM_setValue", "GM_deleteValue"]) expect(banner).toContain(`// @grant ${grant}\n`); - for (const host of ["api.mangabaka.org", "webtoons.com", "pstatic.net", "web.archive.org", "catbox.moe", "mbseed.example.org"]) { + for (const host of ["api.mangabaka.org", "webtoons.com", "pstatic.net", "tapas.io", "d30womf5coomej.cloudfront.net", "web.archive.org", "catbox.moe", "mbseed.example.org"]) { expect(banner).toContain(`// @connect ${host}\n`); } expect(banner).toEndWith("// ==/UserScript==\n\n"); diff --git a/test/pipeline.test.ts b/test/pipeline.test.ts index a653b86..100ae6d 100644 --- a/test/pipeline.test.ts +++ b/test/pipeline.test.ts @@ -87,8 +87,8 @@ describe("runCovers on Tower of God", () => { }); test("unsupported source link is a LinkError naming the supported sources", async () => { - await expect(runCovers({ mangabakaLink: "9999", sourceLink: "https://tapas.io/series/x", history: null }, { transport: new FakeTransport() })) - .rejects.toThrow(/Supported: WEBTOON/); + await expect(runCovers({ mangabakaLink: "9999", sourceLink: "https://www.lezhin.com/en/comic/x", history: null }, { transport: new FakeTransport() })) + .rejects.toThrow(/Supported: WEBTOON, Tapas/); await expect(runCovers({ mangabakaLink: "nope", sourceLink: "https://tapas.io/series/x", history: null }, { transport: new FakeTransport() })) .rejects.toBeInstanceOf(LinkError); }); diff --git a/test/tapas-parse.test.ts b/test/tapas-parse.test.ts new file mode 100644 index 0000000..de3c59d --- /dev/null +++ b/test/tapas-parse.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test"; +import { ageGateTarget, episodeSeriesId, extractCovers, parseInfoPage } from "../src/core/sources/tapas/parse"; +import { fixtureText } from "./helpers/fixtures"; + +const INFO = new URL("https://tapas.io/series/villains-are-destined-to-die/info"); + +test("comic info page: id, title, type, the portrait original and the banner", async () => { + const page = parseInfoPage(await fixtureText("tapas/villains-info.html"), INFO); + expect(page).toMatchObject({ + id: "186414", title: "Villains Are Destined to Die", novel: false, + canonicalUrl: "https://tapas.io/series/villains-are-destined-to-die/info", + }); + expect(page.covers.map((c) => [c.role, c.url])).toEqual([ + ["poster", "https://us-a.tapas.io/sa/d1/e57ba991-1815-41f4-99b8-70057b563642.jpg"], + ["banner", "https://us-a.tapas.io/sa/60/4cb8adab-92ea-4b38-b814-94d3d5606bf5.jpg"], + ]); + expect(page.covers[0]!.originalUrl).toBe("https://us-a.tapas.io/sa/d1/e57ba991-1815-41f4-99b8-70057b563642_z.jpg"); +}); + +test("novel info page", async () => { + const page = parseInfoPage(await fixtureText("tapas/villains-novel-info.html"), new URL("https://tapas.io/series/villains-are-destined-to-die-novel/info")); + expect(page).toMatchObject({ id: "266599", title: "Villains Are Destined to Die", novel: true }); + expect(page.covers.map((c) => c.role)).toEqual(["poster", "banner"]); +}); + +test("a 2020 archived page (the reader layout, CloudFront images)", async () => { + const covers = extractCovers(await fixtureText("wayback/tapas-villains-2020.html"), new URL("https://tapas.io/series/villains-are-destined-to-die")); + expect(covers.map((c) => [c.role, c.url])).toEqual([ + ["poster", "https://us-a.tapas.io/sa/c6/8eca19f0-84e2-4d86-bc5e-c2d7ffa6fd1c.jpg"], + ["banner", "https://us-a.tapas.io/sa/b7/3fc9b35a-1dc3-4410-973f-f6286bb41041.jpg"], + ]); + expect(covers[0]!.originalUrl).toBe("https://d30womf5coomej.cloudfront.net/sa/c6/8eca19f0-84e2-4d86-bc5e-c2d7ffa6fd1c_z.jpg"); +}); + +test("the episode reader names its series", async () => { + expect(episodeSeriesId(await fixtureText("tapas/villains-episode-viewer.html"))).toBe("186414"); + expect(episodeSeriesId("")).toBeNull(); +}); + +test("the age check names the series it stands in front of", async () => { + expect(ageGateTarget(await fixtureText("tapas/verify-age-dog-and-bird.html"))).toBe("https://tapas.io/series/dog-and-bird-mature/info"); + expect(ageGateTarget(await fixtureText("tapas/villains-info.html"))).toBeNull(); +}); diff --git a/test/tapas-source.test.ts b/test/tapas-source.test.ts new file mode 100644 index 0000000..d1f0eb7 --- /dev/null +++ b/test/tapas-source.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "bun:test"; +import { waybackHistory } from "../src/core/history/wayback"; +import { runCovers } from "../src/core/pipeline"; +import { allowedFetchHosts, allowedImageHosts, findSource } from "../src/core/sources/registry"; +import { SourceError } from "../src/core/sources/source"; +import { tapas } from "../src/core/sources/tapas"; +import { FakeTransport, json, text } from "./helpers/fake-transport"; +import { fixtureJson, fixtureText } from "./helpers/fixtures"; + +const INFO = "https://tapas.io/series/villains-are-destined-to-die/info"; +const comic = async () => text(await fixtureText("tapas/villains-info.html")); + +test("registered: Tapas links find the Tapas source; its hosts are allowlisted", () => { + expect(findSource(new URL("https://tapas.io/series/villains-are-destined-to-die"))?.id).toBe("tapas"); + expect(findSource(new URL("https://www.webtoons.com/en/fantasy/tower-of-god/list?title_no=95"))?.id).toBe("webtoons"); + for (const host of ["tapas.io", "us-a.tapas.io", "d30womf5coomej.cloudfront.net"]) expect(allowedFetchHosts().has(host)).toBe(true); + for (const host of ["us-a.tapas.io", "d30womf5coomej.cloudfront.net"]) expect(allowedImageHosts().has(host)).toBe(true); +}); + +test("resolve reads the info page; currentCovers reuses it", async () => { + const fake = new FakeTransport().on(INFO, comic); + const series = await tapas.resolve(new URL("https://tapas.io/series/villains-are-destined-to-die"), { transport: fake }); + expect(series).toEqual({ + source: "tapas", externalId: "186414", title: "Villains Are Destined to Die", language: "en", + canonicalUrl: INFO, archiveUrls: ["tapas.io/series/villains-are-destined-to-die"], mature: false, + flags: { novel: false, ageGated: false }, + }); + const covers = await tapas.currentCovers(series, { transport: fake }); + expect(fake.calls.length).toBe(1); + expect(covers.map((c) => [c.role, c.current])).toEqual([["poster", true], ["banner", true]]); +}); + +test("an id link: the page (reached by redirect) must be that series", async () => { + const ok = new FakeTransport().on("https://tapas.io/series/186414/info", comic); + expect((await tapas.resolve(new URL("https://tapas.io/series/186414"), { transport: ok })).canonicalUrl).toBe(INFO); + const wrong = new FakeTransport().on("https://tapas.io/series/999/info", comic); + await expect(tapas.resolve(new URL("https://tapas.io/series/999"), { transport: wrong })).rejects.toBeInstanceOf(SourceError); +}); + +test("an episode link: the reader page names the series, then its info page", async () => { + const fake = new FakeTransport() + .on("https://tapas.io/episode/1901076", async () => text(await fixtureText("tapas/villains-episode-viewer.html"))) + .on("https://tapas.io/series/186414/info", comic); + const series = await tapas.resolve(new URL("https://tapas.io/episode/1901076"), { transport: fake }); + expect(series.externalId).toBe("186414"); + expect(fake.urls()).toEqual(["https://tapas.io/episode/1901076", "https://tapas.io/series/186414/info"]); +}); + +test("novels are series too", async () => { + const fake = new FakeTransport().on("https://tapas.io/series/villains-are-destined-to-die-novel/info", + async () => text(await fixtureText("tapas/villains-novel-info.html"))); + const series = await tapas.resolve(new URL("https://tapas.io/series/villains-are-destined-to-die-novel"), { transport: fake }); + expect(series.flags).toEqual({ novel: true, ageGated: false }); + expect(series.externalId).toBe("266599"); +}); + +test("a series behind the age check: mature, no current covers, history still possible", async () => { + const fake = new FakeTransport().on("https://tapas.io/series/286112/info", async () => text(await fixtureText("tapas/verify-age-dog-and-bird.html"))); + const series = await tapas.resolve(new URL("https://tapas.io/series/286112"), { transport: fake }); + expect(series).toMatchObject({ + externalId: "286112", title: "Dog and bird mature", mature: true, canonicalUrl: "https://tapas.io/series/dog-and-bird-mature/info", + archiveUrls: ["tapas.io/series/dog-and-bird-mature"], flags: { ageGated: true }, + }); + expect(await tapas.currentCovers(series, { transport: fake })).toEqual([]); +}); + +test("a page that isn't there", async () => { + const fake = new FakeTransport().on(() => true, () => text("gone", 404)); + await expect(tapas.resolve(new URL("https://tapas.io/series/nope"), { transport: fake })).rejects.toThrow(/HTTP 404/); +}); + +test("Wayback history keeps the comic's captures, not the novel's", async () => { + const fake = new FakeTransport().on(INFO, comic) + .on((u) => u.startsWith("https://web.archive.org/cdx/"), async () => text(await fixtureText("wayback/tapas-villains-cdx.txt"))) + .on((u) => u.includes("id_/"), async () => text(await fixtureText("wayback/tapas-villains-2020.html"))); + const series = await tapas.resolve(new URL(INFO), { transport: fake }); + const found = await waybackHistory(tapas, series, { every: 12, transport: fake, warn: () => {} }); + const snapshots = fake.urls().filter((u) => u.includes("id_/")); + expect(snapshots.length).toBeGreaterThan(3); + expect(snapshots.some((u) => u.includes("-novel"))).toBe(false); + expect(found.find((c) => c.role === "poster")).toMatchObject({ + url: "https://us-a.tapas.io/sa/c6/8eca19f0-84e2-4d86-bc5e-c2d7ffa6fd1c.jpg", + archivedUrl: expect.stringContaining("id_/https://d30womf5coomej.cloudfront.net/sa/c6/8eca19f0-84e2-4d86-bc5e-c2d7ffa6fd1c_z.jpg"), + }); +}); + +test("the run says why an age-checked series has no current covers", async () => { + const API = "https://api.mangabaka.org"; + const fake = new FakeTransport() + .on(`${API}/v1/series/9999`, async () => json(await fixtureJson("mangabaka/series-9999.json"))) + .on(`${API}/v1/series/9999/links?limit=100`, async () => json(await fixtureJson("mangabaka/links-9999.json"))) + .on((u) => u.startsWith(`${API}/v1/series/9999/images`), () => json({ status: 200, data: [], pagination: { next: null } })) + .on("https://tapas.io/series/286112/info", async () => text(await fixtureText("tapas/verify-age-dog-and-bird.html"))); + const r = await runCovers({ mangabakaLink: "https://mangabaka.org/manhwa/9999/x", sourceLink: "https://tapas.io/series/286112", history: null }, { transport: fake }); + expect(r.fresh).toEqual([]); + expect(r.warnings.some((w) => /signed-in adults/.test(w))).toBe(true); +}); diff --git a/test/tapas-urls.test.ts b/test/tapas-urls.test.ts new file mode 100644 index 0000000..b0b9106 --- /dev/null +++ b/test/tapas-urls.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { LinkError } from "../src/core/links"; +import { infoUrl, isSameTapasSeries, isTapasUrl, normalizeImageUrl, parseTapasUrl, slugOf } from "../src/core/sources/tapas/urls"; + +const u = (s: string) => new URL(s); + +describe("links", () => { + test("series links in every form", () => { + expect(parseTapasUrl(u("https://tapas.io/series/villains-are-destined-to-die"))).toEqual({ kind: "series", key: "villains-are-destined-to-die" }); + expect(parseTapasUrl(u("https://tapas.io/series/villains-are-destined-to-die/info"))).toEqual({ kind: "series", key: "villains-are-destined-to-die" }); + expect(parseTapasUrl(u("https://www.tapas.io/series/RU_Screwed/"))).toEqual({ kind: "series", key: "RU_Screwed" }); + expect(parseTapasUrl(u("https://m.tapas.io/series/186414/info?x=1"))).toEqual({ kind: "series", key: "186414" }); + expect(parseTapasUrl(u("https://tapas.io/episode/1901076"))).toEqual({ kind: "episode", id: "1901076" }); + }); + + test("anything else on tapas.io is not a series link", () => { + for (const s of ["https://tapas.io/comics", "https://tapas.io/series/", "https://tapas.io/episode/abc", "https://example.org/series/x"]) { + expect(isTapasUrl(u(s))).toBe(false); + expect(() => parseTapasUrl(u(s))).toThrow(LinkError); + } + expect(isTapasUrl(u("https://tapas.io/series/x/info"))).toBe(true); + }); + + test("the page we fetch, and the slug of a canonical URL", () => { + expect(infoUrl("villains-are-destined-to-die")).toBe("https://tapas.io/series/villains-are-destined-to-die/info"); + expect(infoUrl("186414")).toBe("https://tapas.io/series/186414/info"); + expect(slugOf("https://tapas.io/series/villains-are-destined-to-die/info")).toBe("villains-are-destined-to-die"); + }); +}); + +describe("image URLs", () => { + test("the original on us-a.tapas.io: no size suffix, no query", () => { + expect(normalizeImageUrl("https://us-a.tapas.io/sa/d1/e57ba991-1815-41f4-99b8-70057b563642_z.jpg")) + .toBe("https://us-a.tapas.io/sa/d1/e57ba991-1815-41f4-99b8-70057b563642.jpg"); + expect(normalizeImageUrl("https://d30womf5coomej.cloudfront.net/sa/c6/8eca19f0-84e2-4d86-bc5e-c2d7ffa6fd1c_z.jpg")) + .toBe("https://us-a.tapas.io/sa/c6/8eca19f0-84e2-4d86-bc5e-c2d7ffa6fd1c.jpg"); + expect(normalizeImageUrl("https://us-a.tapas.io/sa/60/4cb8adab-92ea-4b38-b814-94d3d5606bf5.jpg?x=1")) + .toBe("https://us-a.tapas.io/sa/60/4cb8adab-92ea-4b38-b814-94d3d5606bf5.jpg"); + expect(normalizeImageUrl("https://us-a.tapas.io/sa/cf/7d3083ef-163e-4349-bf05-1aeea3aca97d_m.png")) + .toBe("https://us-a.tapas.io/sa/cf/7d3083ef-163e-4349-bf05-1aeea3aca97d.png"); + }); + + test("other hosts are left alone", () => { + expect(normalizeImageUrl("https://example.org/a_z.jpg?x=1")).toBe("https://example.org/a_z.jpg?x=1"); + }); +}); + +describe("same series", () => { + const slug = "villains-are-destined-to-die"; + test("by slug (any case, /info, trailing slash) or by id", () => { + for (const link of [ + "https://tapas.io/series/villains-are-destined-to-die", + "http://www.tapas.io/series/Villains-Are-Destined-To-Die/info", + "https://tapas.io/series/villains-are-destined-to-die/", + "https://tapas.io/series/186414/info", + ]) expect(isSameTapasSeries(link, slug, "186414")).toBe(true); + }); + + test("a longer slug (the novel) or another id is a different series", () => { + for (const link of [ + "https://tapas.io/series/villains-are-destined-to-die-novel", + "https://tapas.io/series/villains-are-destined-to-die-novel/info", + "https://tapas.io/series/266599", + "https://tapas.io/episode/1901076", + "not a url", + ]) expect(isSameTapasSeries(link, slug, "186414")).toBe(false); + }); +}); diff --git a/test/webtoons-source.test.ts b/test/webtoons-source.test.ts index 24f3067..6b8297a 100644 --- a/test/webtoons-source.test.ts +++ b/test/webtoons-source.test.ts @@ -66,9 +66,10 @@ test("a Webtoons error is a SourceError with the status", async () => { test("registry finds the source and builds the proxy allowlist", () => { expect(findSource(new URL("https://www.webtoons.com/en/x/list?title_no=1"))).toBe(webtoons); - expect(findSource(new URL("https://tapas.io/series/x"))).toBeUndefined(); + expect(findSource(new URL("https://www.lezhin.com/en/comic/x"))).toBeUndefined(); expect([...allowedFetchHosts()].sort()).toEqual( - ["m.webtoons.com", "swebtoon-phinf.pstatic.net", "web.archive.org", "www.webtoons.com"]); + ["d30womf5coomej.cloudfront.net", "m.tapas.io", "m.webtoons.com", "swebtoon-phinf.pstatic.net", "tapas.io", "us-a.tapas.io", + "web.archive.org", "www.tapas.io", "www.webtoons.com"]); }); test("isSameSeries checks title_no", () => {