Merge branch 'feat/tapas': Tapas source, comics and novels (spec 003)
Continuous Integration / lint-and-test (push) Successful in 7s
Continuous Integration / docker-build-and-push (push) Successful in 25s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-15 12:12:44 +02:00
commit e0bd630bb6
25 files changed
+15443 -11

No files matched your search

+6 -1
View File
@@ -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/<lang>/…/list?title_no=N`, `m.webtoons.com` | poster, square, banner, foreground | Wayback Machine |
| Tapas (comics & novels) | `https://tapas.io/series/<name>` (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.
+1
View File
@@ -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`. |
+14
View File
@@ -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/<slug>/info` (the plain
`/series/<slug>` 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
`<slug>-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
+9
View File
@@ -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.
+69
View File
@@ -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/<slug>/info` (its own canonical URL). `/series/<slug>` serves the
episode reader (canonical `/episode/<id>`). `/series/<id>/info` redirects to the slug form. Slugs may contain
capitals and underscores (`/series/RU_Screwed`).
- **Series id:** on the info page `pageMeta : { id: <id>, type: 'series_id' }`; on the reader `seriesId: <id>`.
- **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 <title> | 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.
+2 -2
View File
@@ -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",
+3
View File
@@ -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);
+2 -1
View File
@@ -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));
+91
View File
@@ -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),
};
+84
View File
@@ -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;
}
+59
View File
@@ -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();
}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+182
View File
@@ -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
+1 -1
View File
@@ -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");
+2 -2
View File
@@ -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);
});
+43
View File
@@ -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("<html></html>")).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();
});
+97
View File
@@ -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);
});
+68
View File
@@ -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);
});
});
+3 -2
View File
@@ -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", () => {