Merge branch 'feat/tapas': Tapas source, comics and novels (spec 003)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e0bd630bb6
25 files changed
+15443
-11
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1740
File diff suppressed because it is too large.
Load diff
+3308
File diff suppressed because it is too large.
Load diff
+3785
File diff suppressed because it is too large.
Load diff
+3698
File diff suppressed because it is too large.
Load diff
+2174
File diff suppressed because it is too large.
Load diff
+182
@@ -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
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user