@ashdev/codex-plugin-release-mangaupdates 1.19.2 → 1.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -763,7 +763,7 @@ function dedupePreserveOrder(xs) {
|
|
|
763
763
|
// package.json
|
|
764
764
|
var package_default = {
|
|
765
765
|
name: "@ashdev/codex-plugin-release-mangaupdates",
|
|
766
|
-
version: "1.19.
|
|
766
|
+
version: "1.19.3",
|
|
767
767
|
description: "MangaUpdates RSS release-source plugin for Codex - announces new chapter releases for tracked series in user-configured languages",
|
|
768
768
|
main: "dist/index.js",
|
|
769
769
|
bin: "dist/index.js",
|
|
@@ -802,7 +802,7 @@ var package_default = {
|
|
|
802
802
|
node: ">=22.0.0"
|
|
803
803
|
},
|
|
804
804
|
dependencies: {
|
|
805
|
-
"@ashdev/codex-plugin-sdk": "^1.19.
|
|
805
|
+
"@ashdev/codex-plugin-sdk": "^1.19.3"
|
|
806
806
|
},
|
|
807
807
|
devDependencies: {
|
|
808
808
|
"@biomejs/biome": "^2.4.4",
|
package/dist/index.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../node_modules/@ashdev/codex-plugin-sdk/src/types/rpc.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/errors.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/request-context.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/host-rpc.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/logger.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/server.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/storage.ts", "../node_modules/@ashdev/codex-plugin-sdk/src/types/releases.ts", "../src/fetcher.ts", "../src/parser.ts", "../src/filter.ts", "../package.json", "../src/manifest.ts", "../src/index.ts"],
|
|
4
|
-
"sourcesContent": [null, null, null, null, null, null, null, null, "/**\n * MangaUpdates per-series RSS fetcher.\n *\n * Wraps `fetch` with conditional GET (`If-None-Match` from a stored ETag) and\n * a hard timeout. Returns a discriminated result so the caller can:\n * - act on `200`: parse the body, persist the new ETag.\n * - skip parse on `304`: nothing changed since last poll.\n * - report `429` / `5xx` upstream-status codes back to the host so the\n * per-host backoff layer can react.\n *\n * Network is the only side effect; nothing in here touches storage, the host,\n * or process state. That keeps it trivially testable: pass a mocked `fetch`\n * implementation and assert.\n */\n\n/** Discriminated fetch result. */\nexport type FetchResult =\n | { kind: \"ok\"; body: string; etag: string | null; status: 200 }\n | { kind: \"notModified\"; status: 304 }\n | { kind: \"error\"; status: number; message: string };\n\nexport interface FetcherOptions {\n /** Custom `fetch` impl (for testing). Defaults to global `fetch`. */\n fetchImpl?: typeof fetch;\n /** Per-request timeout. Defaults to 10s. */\n timeoutMs?: number;\n}\n\n/** Public base URL for MangaUpdates' v1 RSS API. */\nexport const MANGAUPDATES_RSS_BASE = \"https://api.mangaupdates.com/v1/series\";\n\n/**\n * Normalize a MangaUpdates series ID to its numeric form for API calls.\n *\n * MangaUpdates uses two interchangeable representations of the same ID:\n *\n * - **Numeric** (e.g. `15180124327`) \u2014 the internal primary key. Every\n * `/v1/series/...` API endpoint requires this form.\n * - **Base36 slug** (e.g. `6z1uqw7`) \u2014 a base36 encoding of the numeric\n * ID, used in public URLs only (`mangaupdates.com/series/6z1uqw7/...`).\n * The API rejects this form with a 405.\n *\n * Metadata sources (MangaBaka, etc.) typically scrape the public URL and\n * store the slug, so the value we receive on `entry.externalIds.mangaupdates`\n * is whatever the source happened to grab. Decode here so callers don't\n * have to know.\n *\n * Returns the input unchanged when it's already an all-digit string;\n * `null` when the input contains characters outside the base36 alphabet\n * (caller should surface as a configuration error).\n */\nexport function normalizeMangaUpdatesId(raw: string): string | null {\n const trimmed = raw.trim();\n if (trimmed.length === 0) return null;\n if (/^\\d+$/.test(trimmed)) return trimmed;\n if (!/^[0-9a-z]+$/i.test(trimmed)) return null;\n // parseInt('6z1uqw7', 36) = 15180124327. JS numbers are precise for\n // integers up to 2^53; MangaUpdates IDs sit well below that.\n const decoded = Number.parseInt(trimmed, 36);\n if (!Number.isFinite(decoded) || decoded <= 0) return null;\n return String(decoded);\n}\n\n/**\n * Build the per-series RSS URL. Accepts either the numeric ID or the\n * base36 slug \u2014 see `normalizeMangaUpdatesId` for the rationale.\n */\nexport function feedUrl(mangaUpdatesId: string): string {\n const normalized = normalizeMangaUpdatesId(mangaUpdatesId) ?? mangaUpdatesId;\n return `${MANGAUPDATES_RSS_BASE}/${normalized}/rss`;\n}\n\n/**\n * Conditional GET against a per-series RSS feed.\n *\n * @param mangaUpdatesId - The MangaUpdates series ID.\n * @param previousEtag - The ETag from the previous successful poll (if any).\n * @param opts - Fetcher options (custom fetch, timeout).\n */\nexport async function fetchSeriesFeed(\n mangaUpdatesId: string,\n previousEtag: string | null,\n opts: FetcherOptions = {},\n): Promise<FetchResult> {\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch;\n const timeoutMs = opts.timeoutMs ?? 10_000;\n\n const url = feedUrl(mangaUpdatesId);\n const headers: Record<string, string> = {\n Accept: \"application/rss+xml, application/xml;q=0.9, */*;q=0.5\",\n \"User-Agent\": \"Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)\",\n };\n if (previousEtag) {\n headers[\"If-None-Match\"] = previousEtag;\n }\n\n // AbortSignal.timeout is the cleanest path. Falling back to a manual\n // controller would add complexity without value (we already require Node\n // 22+).\n const signal = AbortSignal.timeout(timeoutMs);\n\n let resp: Response;\n try {\n resp = await fetchImpl(url, { method: \"GET\", headers, signal });\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"Unknown fetch error\";\n // Treat aborts and other transport-level failures as 0/unavailable so\n // the host's per-host backoff layer can detect \"this domain is sad\n // right now\" without us having to invent a fake HTTP status.\n return { kind: \"error\", status: 0, message: msg };\n }\n\n if (resp.status === 304) {\n return { kind: \"notModified\", status: 304 };\n }\n\n if (resp.status === 200) {\n const body = await resp.text();\n const etag = resp.headers.get(\"etag\");\n return { kind: \"ok\", body, etag, status: 200 };\n }\n\n // Pass through 429 / 5xx so the host's backoff layer sees the real status.\n return {\n kind: \"error\",\n status: resp.status,\n message: `upstream returned ${resp.status} ${resp.statusText}`,\n };\n}\n", "/**\n * RSS parser for MangaUpdates per-series feeds.\n *\n * Per-series feed: `https://api.mangaupdates.com/v1/series/{series_id}/rss`\n *\n * Each `<item>` is one scanlation release. The plugin extracts:\n * - chapter / volume from the title\n * - scanlation group from the title\n * - language tag (parenthesized two-letter code) from the title\n * - link (the MangaUpdates release page) used as `payloadUrl`\n * - pubDate as `observedAt`\n *\n * Implementation note: we do NOT pull in a heavy XML parser. The MangaUpdates\n * RSS format is simple, well-formed, and stable. A small targeted regex\n * pipeline avoids a 100kb dependency and CVE surface for marginal benefit.\n */\n\n/** Parsed item, pre-`ReleaseCandidate`. */\nexport interface ParsedRssItem {\n /** Stable per-source ID. Derived from the release URL or guid. */\n externalReleaseId: string;\n /** Original title string. Useful for debugging / fallback. */\n title: string;\n /** Chapter number (decimals supported, e.g. \"47.5\"). */\n chapter: number | null;\n /** Volume number. */\n volume: number | null;\n /**\n * Language tag (lowercased ISO 639-1). Defaults to `\"en\"` when the title\n * doesn't carry an explicit `(xx)` code, since the MangaUpdates v1 RSS\n * endpoint serves the English release stream. The legacy\n * `UNKNOWN_LANGUAGE` sentinel is still exported for callers that want\n * to surface \"no tag detected\" explicitly, but the parser no longer\n * produces it on its own.\n */\n language: string;\n /** Scanlation group name (best-effort; nullable). */\n group: string | null;\n /** Release page URL on MangaUpdates. Used as `payloadUrl`. */\n link: string;\n /** ISO-8601 string. Falls back to \"now\" when pubDate is missing/invalid. */\n observedAt: string;\n}\n\n/** Sentinel returned when the language tag can't be detected. */\nexport const UNKNOWN_LANGUAGE = \"unknown\" as const;\n\n// -----------------------------------------------------------------------------\n// XML helpers\n// -----------------------------------------------------------------------------\n\n/** Strip CDATA wrapper if present, unescape `&` `<` `>` `"`. */\nfunction decodeXmlText(raw: string): string {\n let s = raw.trim();\n const cdataMatch = s.match(/^<!\\[CDATA\\[([\\s\\S]*?)]]>$/);\n if (cdataMatch?.[1] !== undefined) {\n s = cdataMatch[1];\n }\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/'/g, \"'\");\n}\n\n/** Pull the first `<tag>` text content from an XML fragment, or null. */\nfunction extractTagText(xml: string, tag: string): string | null {\n const re = new RegExp(`<${tag}[^>]*>([\\\\s\\\\S]*?)</${tag}>`, \"i\");\n const m = xml.match(re);\n if (!m?.[1]) return null;\n return decodeXmlText(m[1]);\n}\n\n/** Pull all `<item>...</item>` blocks from a feed. */\nfunction splitItems(xml: string): string[] {\n const out: string[] = [];\n const re = /<item\\b[^>]*>([\\s\\S]*?)<\\/item>/gi;\n for (;;) {\n const match = re.exec(xml);\n if (match === null) break;\n if (match[1] !== undefined) out.push(match[1]);\n }\n return out;\n}\n\n// -----------------------------------------------------------------------------\n// Title parsing\n// -----------------------------------------------------------------------------\n\n/**\n * Extract chapter/volume/group/language from a MangaUpdates RSS title.\n *\n * Observed shapes:\n * \"Vol.2 c.14 by GroupName (en)\"\n * \"v.2 c.14.5 by GroupName (es)\"\n * \"c.143 by GroupName\" (language missing)\n * \"Vol.15 by GroupName (en)\" (volume-only bundle)\n * \"c.143 (en)\" (no group)\n *\n * Volume tokens: `v.N`, `vol.N`, `Vol.N` (case-insensitive).\n * Chapter tokens: `c.N`, `ch.N`, `Ch.N` (decimals allowed).\n * Group: text between `by ` and the next `(` or end-of-string.\n * Language: trailing `(xx)` two-letter code, lowercased.\n */\nexport function parseTitle(title: string): {\n chapter: number | null;\n volume: number | null;\n group: string | null;\n language: string;\n} {\n const trimmed = title.trim();\n\n // Chapter: c.N or ch.N (allow decimals).\n let chapter: number | null = null;\n const chMatch = trimmed.match(/\\bc(?:h)?\\.?\\s*([0-9]+(?:\\.[0-9]+)?)\\b/i);\n if (chMatch?.[1]) {\n const n = Number.parseFloat(chMatch[1]);\n if (Number.isFinite(n)) chapter = n;\n }\n\n // Volume: v.N or vol.N.\n let volume: number | null = null;\n const volMatch = trimmed.match(/\\bv(?:ol)?\\.?\\s*([0-9]+)\\b/i);\n if (volMatch?.[1]) {\n const n = Number.parseInt(volMatch[1], 10);\n if (Number.isFinite(n)) volume = n;\n }\n\n // Group: \"by <Group>\" up to \"(\" or end.\n let group: string | null = null;\n const groupMatch = trimmed.match(/\\bby\\s+(.+?)(?:\\s*\\([a-z]{2,3}\\)\\s*)?$/i);\n if (groupMatch?.[1]) {\n const candidate = groupMatch[1].trim();\n if (candidate.length > 0) group = candidate;\n }\n\n // Language: trailing parenthesized 2-3 letter code (e.g. (en), (es), (id), (por)).\n //\n // The current MangaUpdates v1 RSS endpoint (`/v1/series/{id}/rss`) ships\n // titles without a language tag \u2014 it's the English-localized release\n // stream by design. Default to `\"en\"` so items aren't dropped by the\n // client-side language gate; an explicit `(es)` / `(id)` / etc. still\n // wins when present, and the host's per-series language list remains\n // the authoritative gate downstream. The legacy `UNKNOWN_LANGUAGE`\n // sentinel is kept exported for backwards compatibility but no longer\n // produced by this parser.\n let language = \"en\";\n const langMatch = trimmed.match(/\\(([a-z]{2,3})\\)\\s*$/i);\n if (langMatch?.[1]) {\n language = langMatch[1].toLowerCase();\n }\n\n return { chapter, volume, group, language };\n}\n\n// -----------------------------------------------------------------------------\n// Item parsing\n// -----------------------------------------------------------------------------\n\n/**\n * Best-effort `pubDate` -> ISO-8601 conversion. MangaUpdates uses RFC-2822\n * style dates (`Mon, 04 May 2026 02:31:00 GMT`). Falls back to \"now\" on\n * invalid input \u2014 never throws, since one bad pubDate shouldn't drop the\n * whole feed.\n */\nfunction pubDateToIso(raw: string | null): string {\n if (raw) {\n const d = new Date(raw);\n if (!Number.isNaN(d.getTime())) return d.toISOString();\n }\n return new Date().toISOString();\n}\n\n/**\n * Derive a stable external_release_id. Prefer `<guid>`, then the link URL,\n * otherwise fall back to a deterministic hash of `(title + pubDate)`.\n *\n * Stability is what matters: re-polling the same item must produce the same\n * ID so the host's `(source_id, external_release_id)` dedup catches it.\n */\nfunction deriveExternalReleaseId(\n guid: string | null,\n link: string | null,\n title: string,\n pubDate: string | null,\n): string {\n if (guid && guid.trim().length > 0) return guid.trim();\n if (link && link.trim().length > 0) return link.trim();\n // Deterministic fallback for feeds that omit both. djb2-ish hash keeps the\n // ID short while staying stable across polls.\n const fallback = `${title}|${pubDate ?? \"\"}`;\n let h = 5381;\n for (let i = 0; i < fallback.length; i++) {\n h = ((h << 5) + h + fallback.charCodeAt(i)) | 0;\n }\n return `t:${(h >>> 0).toString(36)}`;\n}\n\n/**\n * Parse a single MangaUpdates `<item>` block into a `ParsedRssItem`. Returns\n * null if the title is missing entirely (truly malformed item).\n */\nexport function parseItem(itemXml: string): ParsedRssItem | null {\n const title = extractTagText(itemXml, \"title\");\n if (!title) return null;\n\n const link = extractTagText(itemXml, \"link\");\n const guid = extractTagText(itemXml, \"guid\");\n const pubDate = extractTagText(itemXml, \"pubDate\");\n\n const { chapter, volume, group, language } = parseTitle(title);\n\n return {\n externalReleaseId: deriveExternalReleaseId(guid, link, title, pubDate),\n title,\n chapter,\n volume,\n group,\n language,\n link: link ?? \"\",\n observedAt: pubDateToIso(pubDate),\n };\n}\n\n/**\n * Parse a full MangaUpdates per-series RSS feed body into items. Bad items\n * (missing title) are dropped silently \u2014 the feed should be best-effort\n * tolerant.\n */\nexport function parseFeed(xml: string): ParsedRssItem[] {\n return splitItems(xml)\n .map(parseItem)\n .filter((i): i is ParsedRssItem => i !== null);\n}\n", "/**\n * Filtering: language allowlist + group blocklist.\n *\n * Filters are applied client-side in the plugin (before recording) for two\n * reasons:\n * 1. Keeps the ledger small. Out-of-language items would be dropped by the\n * host anyway via the latest_known_* gate, but writing them to the\n * ledger pollutes the inbox and wastes write IO.\n * 2. Keeps the inbox clean. Users who configure `[\"en\"]` don't want to see\n * Spanish entries hidden behind a state flag \u2014 they want them gone.\n */\n\nimport { type ParsedRssItem, UNKNOWN_LANGUAGE } from \"./parser.js\";\n\n/**\n * Resolved, normalized filter inputs for a single series. Both lists are\n * lowercased + trimmed. Empty `languages` is interpreted as \"no filter\"\n * (everything passes), but the caller is expected to pass at least the\n * server-wide default to avoid that footgun.\n */\nexport interface ResolvedFilters {\n /** Lowercased ISO 639-1 codes; empty = no filter. */\n languages: string[];\n /** Lowercased group names; case-insensitive exact match against `group`. */\n blockedGroups: Set<string>;\n /**\n * Whether to include items whose language couldn't be detected\n * (`UNKNOWN_LANGUAGE` sentinel). Default false \u2014 be conservative.\n */\n includeUnknownLanguage: boolean;\n}\n\n/**\n * Build resolved filters from raw config strings + lists. Centralizes the\n * normalization so the poll handler doesn't have to care about casing or\n * whitespace.\n */\nexport function resolveFilters(input: {\n languages: string[];\n blockedGroups: string[];\n includeUnknownLanguage?: boolean;\n}): ResolvedFilters {\n const languages = dedupePreserveOrder(\n input.languages.map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0),\n );\n const blockedGroups = new Set(\n input.blockedGroups.map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0),\n );\n return {\n languages,\n blockedGroups,\n includeUnknownLanguage: input.includeUnknownLanguage ?? false,\n };\n}\n\n/**\n * Parse a comma-separated string into a clean list (trim, drop empties).\n * Helper for `blockedGroups` which is admin-config typed as a single string.\n */\nexport function parseCommaList(raw: unknown): string[] {\n if (typeof raw !== \"string\") return [];\n return raw\n .split(\",\")\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n}\n\n/**\n * Returns true if the item should be kept.\n *\n * Language filter:\n * - If `languages` is empty \u2192 pass.\n * - Otherwise, item.language must be in the list (case-insensitive).\n * - `unknown` language is rejected unless `includeUnknownLanguage` is true.\n *\n * Group filter:\n * - If `group` is null \u2192 pass (we have nothing to match against).\n * - Otherwise, group must NOT be in `blockedGroups`.\n */\nexport function passesFilters(item: ParsedRssItem, filters: ResolvedFilters): boolean {\n // Language gate.\n if (item.language === UNKNOWN_LANGUAGE) {\n if (!filters.includeUnknownLanguage) return false;\n } else if (filters.languages.length > 0) {\n if (!filters.languages.includes(item.language.toLowerCase())) return false;\n }\n\n // Group blocklist.\n if (item.group !== null && filters.blockedGroups.size > 0) {\n if (filters.blockedGroups.has(item.group.trim().toLowerCase())) return false;\n }\n\n return true;\n}\n\nfunction dedupePreserveOrder(xs: string[]): string[] {\n const seen = new Set<string>();\n const out: string[] = [];\n for (const x of xs) {\n if (!seen.has(x)) {\n seen.add(x);\n out.push(x);\n }\n }\n return out;\n}\n", "{\n \"name\": \"@ashdev/codex-plugin-release-mangaupdates\",\n \"version\": \"1.19.2\",\n \"description\": \"MangaUpdates RSS release-source plugin for Codex - announces new chapter releases for tracked series in user-configured languages\",\n \"main\": \"dist/index.js\",\n \"bin\": \"dist/index.js\",\n \"type\": \"module\",\n \"files\": [\n \"dist\",\n \"README.md\"\n ],\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/AshDevFr/codex.git\",\n \"directory\": \"plugins/release-mangaupdates\"\n },\n \"scripts\": {\n \"build\": \"esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'\",\n \"dev\": \"npm run build -- --watch\",\n \"clean\": \"rm -rf dist\",\n \"start\": \"node dist/index.js\",\n \"lint\": \"biome check .\",\n \"lint:fix\": \"biome check --write .\",\n \"typecheck\": \"tsc --noEmit\",\n \"test\": \"vitest run --passWithNoTests\",\n \"test:watch\": \"vitest\",\n \"prepublishOnly\": \"npm run lint && npm run build\"\n },\n \"keywords\": [\n \"codex\",\n \"plugin\",\n \"mangaupdates\",\n \"release-source\",\n \"manga\"\n ],\n \"author\": \"Codex\",\n \"license\": \"MIT\",\n \"engines\": {\n \"node\": \">=22.0.0\"\n },\n \"dependencies\": {\n \"@ashdev/codex-plugin-sdk\": \"^1.19.2\"\n },\n \"devDependencies\": {\n \"@biomejs/biome\": \"^2.4.4\",\n \"@types/node\": \"^22.0.0\",\n \"esbuild\": \"^0.27.3\",\n \"typescript\": \"^5.9.3\",\n \"vitest\": \"^4.0.18\"\n }\n}\n", "import type { PluginManifest } from \"@ashdev/codex-plugin-sdk\";\nimport packageJson from \"../package.json\" with { type: \"json\" };\n\n/**\n * External-ID source name for MangaUpdates.\n *\n * MangaUpdates IDs are populated by metadata-provider plugins (e.g.\n * MangaBaka cross-references) or pasted manually by the user via the series\n * tracking panel. The release plugin needs the bare source name (no\n * `plugin:` prefix) here to match the host's external-ID filter.\n */\nexport const EXTERNAL_ID_SOURCE_MANGAUPDATES = \"mangaupdates\" as const;\n\nexport const manifest = {\n name: \"release-mangaupdates\",\n displayName: \"MangaUpdates Releases\",\n version: packageJson.version,\n description:\n \"Announces new chapter releases for tracked series via MangaUpdates per-series RSS feeds. Filters by user-configured languages.\",\n author: \"Codex\",\n homepage: \"https://github.com/AshDevFr/codex\",\n protocolVersion: \"1.1\",\n capabilities: {\n releaseSource: {\n kinds: [\"rss-series\"],\n requiresAliases: false,\n requiresExternalIds: [EXTERNAL_ID_SOURCE_MANGAUPDATES],\n canAnnounceChapters: true,\n canAnnounceVolumes: true,\n },\n },\n configSchema: {\n description:\n \"MangaUpdates plugin configuration. Per-series language preferences live on each series' tracking config; the values here are server-wide defaults applied when a series doesn't override them.\",\n fields: [\n {\n key: \"blockedGroups\",\n label: \"Blocked Scanlation Groups\",\n description:\n \"Comma-separated list of scanlation group names to exclude from announcements (case-insensitive, exact match). Per-series overrides may further extend this list.\",\n type: \"string\" as const,\n required: false,\n default: \"\",\n example: \"LowQualityScans,MTL Group\",\n },\n {\n key: \"requestTimeoutMs\",\n label: \"Request Timeout (ms)\",\n description:\n \"How long to wait for a single RSS fetch before giving up. Defaults to 10000 (10 seconds).\",\n type: \"number\" as const,\n required: false,\n default: 10_000,\n },\n ],\n },\n userDescription:\n \"Announces new chapters for series you've tracked, using their MangaUpdates IDs. Filters releases to languages you can read. Notification-only \u2014 Codex does not download anything.\",\n adminSetupInstructions:\n \"1. No config is required to get started \u2014 saving the plugin is enough. The plugin auto-registers a single source row (`MangaUpdates Releases`) in **Settings \u2192 Release tracking** on first start, where you can disable it, change the poll interval, or hit *Poll now*. 2. To get announcements for a series, edit its tracking panel and either paste a `mangaupdates` external ID or let the metadata-refresh path populate it from MangaBaka cross-references. 3. Optional: set `blockedGroups` (CSV, case-insensitive) to filter noisy scanlators server-wide; per-series language preferences live on each series' tracking config and override the server default (`release_tracking.default_languages`). No credentials are needed; MangaUpdates RSS feeds are public.\",\n} as const satisfies PluginManifest & {\n capabilities: { releaseSource: { kinds: [\"rss-series\"] } };\n};\n", "/**\n * MangaUpdates RSS Release-Source Plugin for Codex.\n *\n * Polls per-series RSS feeds at MangaUpdates and announces new chapter /\n * volume releases for tracked series. The plugin is the first writer of\n * `release_ledger` rows in production \u2014 earlier phases build the\n * infrastructure, this one delivers the first real notification feed.\n *\n * Flow per `releases/poll`:\n * 1. Pull tracked-series scope from the host (`releases/list_tracked`).\n * Filtered server-side to series with a `mangaupdates` external ID.\n * 2. For each series, conditional GET the RSS feed.\n * 3. Parse the response into items, then filter by:\n * - per-series language list (admin / per-series config)\n * - admin-configured group blocklist\n * 4. Build `ReleaseCandidate` rows and stream them via\n * `releases/record`. The host's matcher applies the threshold and\n * ledger dedup.\n * 5. Pass the new ETag back via the poll response so the host updates\n * the source row.\n *\n * **Concurrency note:** The plugin host already serializes RPCs per plugin\n * process, so we don't need to throttle internally beyond an in-poll loop\n * that walks tracked series sequentially.\n */\n\nimport {\n createLogger,\n createReleaseSourcePlugin,\n type HostRpcClient,\n HostRpcError,\n type InitializeParams,\n RELEASES_METHODS,\n type ReleaseCandidate,\n type ReleasePollRequest,\n type ReleasePollResponse,\n type TrackedSeriesEntry,\n} from \"@ashdev/codex-plugin-sdk\";\nimport { fetchSeriesFeed } from \"./fetcher.js\";\nimport { parseCommaList, passesFilters, resolveFilters } from \"./filter.js\";\nimport { EXTERNAL_ID_SOURCE_MANGAUPDATES, manifest } from \"./manifest.js\";\nimport { type ParsedRssItem, parseFeed } from \"./parser.js\";\n\nconst logger = createLogger({ name: manifest.name, level: \"info\" });\n\n// =============================================================================\n// Plugin-level state (set during initialize)\n// =============================================================================\n\ninterface PluginState {\n hostRpc: HostRpcClient | null;\n /** Admin-configured group blocklist (lowercased exact match). */\n blockedGroupsCsv: string;\n /** Hard timeout for upstream fetches. */\n requestTimeoutMs: number;\n}\n\nconst state: PluginState = {\n hostRpc: null,\n blockedGroupsCsv: \"\",\n requestTimeoutMs: 10_000,\n};\n\n/** Reset state. Exported for tests; not part of the plugin contract. */\nexport function _resetState(): void {\n state.hostRpc = null;\n state.blockedGroupsCsv = \"\";\n state.requestTimeoutMs = 10_000;\n}\n\n// =============================================================================\n// Reverse-RPC wrappers (typed shorthands so the poll code reads cleanly)\n// =============================================================================\n\ninterface ListTrackedResponse {\n tracked: TrackedSeriesEntry[];\n nextOffset?: number;\n}\n\ninterface RecordResponse {\n ledgerId: string;\n deduped: boolean;\n}\n\nasync function listTracked(\n rpc: HostRpcClient,\n sourceId: string,\n offset: number,\n limit: number,\n): Promise<ListTrackedResponse> {\n return rpc.call<ListTrackedResponse>(RELEASES_METHODS.LIST_TRACKED, {\n sourceId,\n offset,\n limit,\n });\n}\n\nasync function recordCandidate(\n rpc: HostRpcClient,\n sourceId: string,\n candidate: ReleaseCandidate,\n): Promise<RecordResponse | null> {\n try {\n return await rpc.call<RecordResponse>(RELEASES_METHODS.RECORD, {\n sourceId,\n candidate,\n });\n } catch (err) {\n if (err instanceof HostRpcError) {\n // Threshold rejection / validation error / unknown source. Log and\n // skip; the next poll will retry the still-eligible candidates.\n logger.warn(\n `record failed for ${candidate.externalReleaseId}: ${err.message} (code ${err.code})`,\n );\n } else {\n const msg = err instanceof Error ? err.message : \"unknown error\";\n logger.warn(`record failed for ${candidate.externalReleaseId}: ${msg}`);\n }\n return null;\n }\n}\n\n// =============================================================================\n// Iteration helpers\n// =============================================================================\n\n/**\n * Lazily walk all tracked-series pages from the host. Yields entries one\n * series at a time so the caller can interleave per-series fetches without\n * buffering the whole list (relevant for users tracking hundreds of series).\n */\nasync function* iterateTrackedSeries(\n rpc: HostRpcClient,\n sourceId: string,\n): AsyncGenerator<TrackedSeriesEntry> {\n const pageSize = 200;\n let offset = 0;\n while (true) {\n const page = await listTracked(rpc, sourceId, offset, pageSize);\n for (const entry of page.tracked) {\n yield entry;\n }\n if (page.nextOffset === undefined || page.tracked.length === 0) return;\n offset = page.nextOffset;\n }\n}\n\n/**\n * Per-series effective language list. We use the host's `latestKnown*`\n * exposure plus the `externalIds` map to scope the fetch, but the\n * languages config is owned by the host (set on `series_tracking.languages`\n * with fallback to the server-wide default).\n *\n * However, the current `releases/list_tracked` response shape doesn't\n * expose per-series `languages` \u2014 see plan doc for this design choice.\n * For Phase 6 the plugin reads its admin-level group blocklist and emits\n * candidates with the language tag from the parsed entry; the host's\n * `latest_known_*` advance gate enforces the per-series language list\n * authoritatively (see `services/release/languages.rs`).\n *\n * We *also* want to drop out-of-language candidates client-side to keep the\n * ledger small and the inbox clean. Without per-series languages on the\n * tracked-series payload, the client-side filter degrades to a no-op\n * pass-everything for known languages \u2014 leaving it to the host's gate. The\n * group blocklist still applies.\n *\n * If a future protocol revision exposes `effectiveLanguages` on the\n * tracked-series entry, swap this stub for the real list and the existing\n * `passesFilters` will do the right thing.\n */\nfunction effectiveLanguagesForSeries(_entry: TrackedSeriesEntry): string[] {\n return []; // empty = no client-side language gate; host gate is authoritative\n}\n\n/**\n * Map a `ParsedRssItem` to a `ReleaseCandidate`. Confidence is 1.0 because\n * the match is keyed by external ID \u2014 there's no fuzzy matching.\n */\nfunction toCandidate(entry: TrackedSeriesEntry, item: ParsedRssItem): ReleaseCandidate {\n const candidate: ReleaseCandidate = {\n seriesMatch: {\n codexSeriesId: entry.seriesId,\n confidence: 1.0,\n reason: `mangaupdates_id:${entry.externalIds?.[EXTERNAL_ID_SOURCE_MANGAUPDATES] ?? \"\"}`,\n },\n externalReleaseId: item.externalReleaseId,\n chapter: item.chapter,\n volume: item.volume,\n language: item.language,\n groupOrUploader: item.group,\n payloadUrl: item.link.length > 0 ? item.link : `urn:mu:${item.externalReleaseId}`,\n observedAt: item.observedAt,\n };\n return candidate;\n}\n\n// =============================================================================\n// Per-series poll\n// =============================================================================\n\n/** Outcome of a single per-series fetch+record cycle. */\nexport interface SeriesPollOutcome {\n seriesId: string;\n fetched: boolean;\n notModified: boolean;\n parsed: number;\n /** Of those parsed, how many passed client-side filters and were sent to record. */\n matched: number;\n recorded: number;\n /** Of those sent to record, how many the host deduped onto an existing row. */\n deduped: number;\n upstreamStatus: number;\n /** New ETag returned by upstream (only set when fetched=true). */\n etag: string | null;\n /** Error string if the per-series fetch failed; empty otherwise. */\n error: string;\n}\n\n/**\n * Poll a single series. Internal \u2014 exposed for testing.\n *\n * Aggregates the worst (highest) upstream status across the per-series\n * fetches at the call site so the host's per-host backoff layer sees real\n * 429/5xx signals.\n */\nexport async function pollSeries(\n rpc: HostRpcClient,\n sourceId: string,\n entry: TrackedSeriesEntry,\n options: {\n blockedGroups: string[];\n timeoutMs: number;\n fetchImpl?: typeof fetch;\n },\n): Promise<SeriesPollOutcome> {\n const muId = entry.externalIds?.[EXTERNAL_ID_SOURCE_MANGAUPDATES];\n if (!muId) {\n return {\n seriesId: entry.seriesId,\n fetched: false,\n notModified: false,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: 0,\n etag: null,\n error: \"missing mangaupdates external ID\",\n };\n }\n\n // We don't have per-series ETag here \u2014 that lives on the source row, not\n // the series. For a per-source feed (rss-uploader) ETags align cleanly;\n // for per-series feeds (this plugin) we'd need per-(source, series) state\n // to do conditional GETs per series. That's a future optimization; for\n // now we always do an unconditional GET. Daily polls + small per-series\n // bodies keep the bandwidth cost negligible.\n const result = await fetchSeriesFeed(muId, null, {\n fetchImpl: options.fetchImpl,\n timeoutMs: options.timeoutMs,\n });\n\n if (result.kind === \"notModified\") {\n return {\n seriesId: entry.seriesId,\n fetched: true,\n notModified: true,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: 304,\n etag: null,\n error: \"\",\n };\n }\n\n if (result.kind === \"error\") {\n return {\n seriesId: entry.seriesId,\n fetched: false,\n notModified: false,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: result.status,\n etag: null,\n error: result.message,\n };\n }\n\n // result.kind === \"ok\"\n const items = parseFeed(result.body);\n const filters = resolveFilters({\n languages: effectiveLanguagesForSeries(entry),\n blockedGroups: options.blockedGroups,\n });\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n for (const item of items) {\n if (!passesFilters(item, filters)) continue;\n matched++;\n const candidate = toCandidate(entry, item);\n const outcome = await recordCandidate(rpc, sourceId, candidate);\n if (!outcome) continue;\n if (outcome.deduped) {\n deduped++;\n } else {\n recorded++;\n }\n }\n return {\n seriesId: entry.seriesId,\n fetched: true,\n notModified: false,\n parsed: items.length,\n matched,\n recorded,\n deduped,\n upstreamStatus: 200,\n etag: result.etag,\n error: \"\",\n };\n}\n\n// =============================================================================\n// Top-level poll handler\n// =============================================================================\n\nasync function poll(params: ReleasePollRequest, rpc: HostRpcClient): Promise<ReleasePollResponse> {\n const sourceId = params.sourceId;\n const blockedGroups = parseCommaList(state.blockedGroupsCsv);\n\n let parsed = 0;\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n let worstStatus = 200;\n let lastEtag: string | null = null;\n let seenSeries = 0;\n // Series the host returned that lack a MangaUpdates external ID. A high\n // count here is the most common cause of an \"empty\" poll: the plugin\n // can't fetch a feed without an MU ID, so the user needs to populate\n // those (manual paste or metadata refresh from MangaBaka).\n let skippedNoMuId = 0;\n\n for await (const entry of iterateTrackedSeries(rpc, sourceId)) {\n seenSeries++;\n const outcome = await pollSeries(rpc, sourceId, entry, {\n blockedGroups,\n timeoutMs: state.requestTimeoutMs,\n });\n parsed += outcome.parsed;\n matched += outcome.matched;\n recorded += outcome.recorded;\n deduped += outcome.deduped;\n if (outcome.upstreamStatus > worstStatus) {\n worstStatus = outcome.upstreamStatus;\n }\n if (outcome.etag) lastEtag = outcome.etag;\n\n if (outcome.error === \"missing mangaupdates external ID\") {\n skippedNoMuId++;\n } else if (outcome.error) {\n logger.warn(`series ${entry.seriesId}: ${outcome.error} (status ${outcome.upstreamStatus})`);\n }\n }\n\n if (skippedNoMuId > 0) {\n logger.info(\n `skipped ${skippedNoMuId} of ${seenSeries} tracked series for source=${sourceId}: no mangaupdates external ID. Add one in the Tracking panel or run a metadata refresh.`,\n );\n }\n\n logger.info(\n `poll complete: source=${sourceId} series=${seenSeries} skipped=${skippedNoMuId} parsed=${parsed} matched=${matched} recorded=${recorded} deduped=${deduped} worst_status=${worstStatus}`,\n );\n\n // Report counters back to the host so the source's `last_summary` is\n // accurate. Without these the host only sees the (empty) `candidates`\n // payload \u2014 we record via reverse-RPC mid-poll \u2014 and the badge reads\n // \"Fetched 0 items\" no matter what actually happened.\n // Per-series ETags don't align with the per-source state slot, so we\n // intentionally leave `etag` undefined unless we actually saw one\n // (which today we won't, since we don't pass If-None-Match per series).\n return {\n notModified: false,\n upstreamStatus: worstStatus,\n parsed,\n matched,\n recorded,\n deduped,\n ...(lastEtag !== null ? { etag: lastEtag } : {}),\n };\n}\n\n// =============================================================================\n// Plugin Initialization\n// =============================================================================\n\n/**\n * Register a single static source row representing the MangaUpdates batch\n * feed. Unlike Nyaa (one row per uploader), MangaUpdates polls all tracked\n * series under one logical feed, so we always declare exactly one row keyed\n * `default`. Retries on `METHOD_NOT_FOUND` to handle the brief race where\n * the host has not yet installed the releases reverse-RPC handler.\n */\nexport async function registerSources(\n rpc: HostRpcClient,\n): Promise<{ registered: number; pruned: number } | null> {\n const sources = [\n {\n sourceKey: \"default\",\n displayName: \"MangaUpdates Releases\",\n kind: \"rss-series\" as const,\n config: null,\n },\n ];\n const maxAttempts = 5;\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n return await rpc.call<{ registered: number; pruned: number }>(\n RELEASES_METHODS.REGISTER_SOURCES,\n { sources },\n );\n } catch (err) {\n const isMethodNotFound = err instanceof HostRpcError && err.code === -32601;\n if (isMethodNotFound && attempt < maxAttempts) {\n await new Promise((r) => setTimeout(r, 50 * attempt));\n continue;\n }\n const reason = err instanceof Error ? err.message : String(err);\n logger.error(`register_sources failed: ${reason}`);\n return null;\n }\n }\n return null;\n}\n\ncreateReleaseSourcePlugin({\n manifest,\n provider: {\n async poll(params: ReleasePollRequest): Promise<ReleasePollResponse> {\n if (!state.hostRpc) {\n throw new Error(\"Plugin not initialized: hostRpc client missing\");\n }\n return poll(params, state.hostRpc);\n },\n },\n logLevel: \"info\",\n async onInitialize(params: InitializeParams) {\n state.hostRpc = params.hostRpc;\n const ac = params.adminConfig ?? {};\n if (typeof ac.blockedGroups === \"string\") {\n state.blockedGroupsCsv = ac.blockedGroups;\n }\n if (typeof ac.requestTimeoutMs === \"number\" && Number.isFinite(ac.requestTimeoutMs)) {\n state.requestTimeoutMs = Math.max(1_000, Math.min(ac.requestTimeoutMs, 60_000));\n }\n logger.info(\n `initialized: blockedGroups=${state.blockedGroupsCsv ? \"set\" : \"empty\"} timeoutMs=${state.requestTimeoutMs}`,\n );\n\n // Materialize the single static source row. Deferred to a microtask so\n // we run *after* the host installs the releases reverse-RPC handler.\n queueMicrotask(() => {\n void registerSources(params.hostRpc).then((result) => {\n if (result) {\n logger.info(`register_sources: registered=${result.registered} pruned=${result.pruned}`);\n }\n });\n });\n },\n});\n\nlogger.info(\"MangaUpdates release-source plugin started\");\n"],
|
|
4
|
+
"sourcesContent": [null, null, null, null, null, null, null, null, "/**\n * MangaUpdates per-series RSS fetcher.\n *\n * Wraps `fetch` with conditional GET (`If-None-Match` from a stored ETag) and\n * a hard timeout. Returns a discriminated result so the caller can:\n * - act on `200`: parse the body, persist the new ETag.\n * - skip parse on `304`: nothing changed since last poll.\n * - report `429` / `5xx` upstream-status codes back to the host so the\n * per-host backoff layer can react.\n *\n * Network is the only side effect; nothing in here touches storage, the host,\n * or process state. That keeps it trivially testable: pass a mocked `fetch`\n * implementation and assert.\n */\n\n/** Discriminated fetch result. */\nexport type FetchResult =\n | { kind: \"ok\"; body: string; etag: string | null; status: 200 }\n | { kind: \"notModified\"; status: 304 }\n | { kind: \"error\"; status: number; message: string };\n\nexport interface FetcherOptions {\n /** Custom `fetch` impl (for testing). Defaults to global `fetch`. */\n fetchImpl?: typeof fetch;\n /** Per-request timeout. Defaults to 10s. */\n timeoutMs?: number;\n}\n\n/** Public base URL for MangaUpdates' v1 RSS API. */\nexport const MANGAUPDATES_RSS_BASE = \"https://api.mangaupdates.com/v1/series\";\n\n/**\n * Normalize a MangaUpdates series ID to its numeric form for API calls.\n *\n * MangaUpdates uses two interchangeable representations of the same ID:\n *\n * - **Numeric** (e.g. `15180124327`) \u2014 the internal primary key. Every\n * `/v1/series/...` API endpoint requires this form.\n * - **Base36 slug** (e.g. `6z1uqw7`) \u2014 a base36 encoding of the numeric\n * ID, used in public URLs only (`mangaupdates.com/series/6z1uqw7/...`).\n * The API rejects this form with a 405.\n *\n * Metadata sources (MangaBaka, etc.) typically scrape the public URL and\n * store the slug, so the value we receive on `entry.externalIds.mangaupdates`\n * is whatever the source happened to grab. Decode here so callers don't\n * have to know.\n *\n * Returns the input unchanged when it's already an all-digit string;\n * `null` when the input contains characters outside the base36 alphabet\n * (caller should surface as a configuration error).\n */\nexport function normalizeMangaUpdatesId(raw: string): string | null {\n const trimmed = raw.trim();\n if (trimmed.length === 0) return null;\n if (/^\\d+$/.test(trimmed)) return trimmed;\n if (!/^[0-9a-z]+$/i.test(trimmed)) return null;\n // parseInt('6z1uqw7', 36) = 15180124327. JS numbers are precise for\n // integers up to 2^53; MangaUpdates IDs sit well below that.\n const decoded = Number.parseInt(trimmed, 36);\n if (!Number.isFinite(decoded) || decoded <= 0) return null;\n return String(decoded);\n}\n\n/**\n * Build the per-series RSS URL. Accepts either the numeric ID or the\n * base36 slug \u2014 see `normalizeMangaUpdatesId` for the rationale.\n */\nexport function feedUrl(mangaUpdatesId: string): string {\n const normalized = normalizeMangaUpdatesId(mangaUpdatesId) ?? mangaUpdatesId;\n return `${MANGAUPDATES_RSS_BASE}/${normalized}/rss`;\n}\n\n/**\n * Conditional GET against a per-series RSS feed.\n *\n * @param mangaUpdatesId - The MangaUpdates series ID.\n * @param previousEtag - The ETag from the previous successful poll (if any).\n * @param opts - Fetcher options (custom fetch, timeout).\n */\nexport async function fetchSeriesFeed(\n mangaUpdatesId: string,\n previousEtag: string | null,\n opts: FetcherOptions = {},\n): Promise<FetchResult> {\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch;\n const timeoutMs = opts.timeoutMs ?? 10_000;\n\n const url = feedUrl(mangaUpdatesId);\n const headers: Record<string, string> = {\n Accept: \"application/rss+xml, application/xml;q=0.9, */*;q=0.5\",\n \"User-Agent\": \"Codex-ReleaseTracker/1.0 (+https://github.com/AshDevFr/codex)\",\n };\n if (previousEtag) {\n headers[\"If-None-Match\"] = previousEtag;\n }\n\n // AbortSignal.timeout is the cleanest path. Falling back to a manual\n // controller would add complexity without value (we already require Node\n // 22+).\n const signal = AbortSignal.timeout(timeoutMs);\n\n let resp: Response;\n try {\n resp = await fetchImpl(url, { method: \"GET\", headers, signal });\n } catch (err) {\n const msg = err instanceof Error ? err.message : \"Unknown fetch error\";\n // Treat aborts and other transport-level failures as 0/unavailable so\n // the host's per-host backoff layer can detect \"this domain is sad\n // right now\" without us having to invent a fake HTTP status.\n return { kind: \"error\", status: 0, message: msg };\n }\n\n if (resp.status === 304) {\n return { kind: \"notModified\", status: 304 };\n }\n\n if (resp.status === 200) {\n const body = await resp.text();\n const etag = resp.headers.get(\"etag\");\n return { kind: \"ok\", body, etag, status: 200 };\n }\n\n // Pass through 429 / 5xx so the host's backoff layer sees the real status.\n return {\n kind: \"error\",\n status: resp.status,\n message: `upstream returned ${resp.status} ${resp.statusText}`,\n };\n}\n", "/**\n * RSS parser for MangaUpdates per-series feeds.\n *\n * Per-series feed: `https://api.mangaupdates.com/v1/series/{series_id}/rss`\n *\n * Each `<item>` is one scanlation release. The plugin extracts:\n * - chapter / volume from the title\n * - scanlation group from the title\n * - language tag (parenthesized two-letter code) from the title\n * - link (the MangaUpdates release page) used as `payloadUrl`\n * - pubDate as `observedAt`\n *\n * Implementation note: we do NOT pull in a heavy XML parser. The MangaUpdates\n * RSS format is simple, well-formed, and stable. A small targeted regex\n * pipeline avoids a 100kb dependency and CVE surface for marginal benefit.\n */\n\n/** Parsed item, pre-`ReleaseCandidate`. */\nexport interface ParsedRssItem {\n /** Stable per-source ID. Derived from the release URL or guid. */\n externalReleaseId: string;\n /** Original title string. Useful for debugging / fallback. */\n title: string;\n /** Chapter number (decimals supported, e.g. \"47.5\"). */\n chapter: number | null;\n /** Volume number. */\n volume: number | null;\n /**\n * Language tag (lowercased ISO 639-1). Defaults to `\"en\"` when the title\n * doesn't carry an explicit `(xx)` code, since the MangaUpdates v1 RSS\n * endpoint serves the English release stream. The legacy\n * `UNKNOWN_LANGUAGE` sentinel is still exported for callers that want\n * to surface \"no tag detected\" explicitly, but the parser no longer\n * produces it on its own.\n */\n language: string;\n /** Scanlation group name (best-effort; nullable). */\n group: string | null;\n /** Release page URL on MangaUpdates. Used as `payloadUrl`. */\n link: string;\n /** ISO-8601 string. Falls back to \"now\" when pubDate is missing/invalid. */\n observedAt: string;\n}\n\n/** Sentinel returned when the language tag can't be detected. */\nexport const UNKNOWN_LANGUAGE = \"unknown\" as const;\n\n// -----------------------------------------------------------------------------\n// XML helpers\n// -----------------------------------------------------------------------------\n\n/** Strip CDATA wrapper if present, unescape `&` `<` `>` `"`. */\nfunction decodeXmlText(raw: string): string {\n let s = raw.trim();\n const cdataMatch = s.match(/^<!\\[CDATA\\[([\\s\\S]*?)]]>$/);\n if (cdataMatch?.[1] !== undefined) {\n s = cdataMatch[1];\n }\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/'/g, \"'\");\n}\n\n/** Pull the first `<tag>` text content from an XML fragment, or null. */\nfunction extractTagText(xml: string, tag: string): string | null {\n const re = new RegExp(`<${tag}[^>]*>([\\\\s\\\\S]*?)</${tag}>`, \"i\");\n const m = xml.match(re);\n if (!m?.[1]) return null;\n return decodeXmlText(m[1]);\n}\n\n/** Pull all `<item>...</item>` blocks from a feed. */\nfunction splitItems(xml: string): string[] {\n const out: string[] = [];\n const re = /<item\\b[^>]*>([\\s\\S]*?)<\\/item>/gi;\n for (;;) {\n const match = re.exec(xml);\n if (match === null) break;\n if (match[1] !== undefined) out.push(match[1]);\n }\n return out;\n}\n\n// -----------------------------------------------------------------------------\n// Title parsing\n// -----------------------------------------------------------------------------\n\n/**\n * Extract chapter/volume/group/language from a MangaUpdates RSS title.\n *\n * Observed shapes:\n * \"Vol.2 c.14 by GroupName (en)\"\n * \"v.2 c.14.5 by GroupName (es)\"\n * \"c.143 by GroupName\" (language missing)\n * \"Vol.15 by GroupName (en)\" (volume-only bundle)\n * \"c.143 (en)\" (no group)\n *\n * Volume tokens: `v.N`, `vol.N`, `Vol.N` (case-insensitive).\n * Chapter tokens: `c.N`, `ch.N`, `Ch.N` (decimals allowed).\n * Group: text between `by ` and the next `(` or end-of-string.\n * Language: trailing `(xx)` two-letter code, lowercased.\n */\nexport function parseTitle(title: string): {\n chapter: number | null;\n volume: number | null;\n group: string | null;\n language: string;\n} {\n const trimmed = title.trim();\n\n // Chapter: c.N or ch.N (allow decimals).\n let chapter: number | null = null;\n const chMatch = trimmed.match(/\\bc(?:h)?\\.?\\s*([0-9]+(?:\\.[0-9]+)?)\\b/i);\n if (chMatch?.[1]) {\n const n = Number.parseFloat(chMatch[1]);\n if (Number.isFinite(n)) chapter = n;\n }\n\n // Volume: v.N or vol.N.\n let volume: number | null = null;\n const volMatch = trimmed.match(/\\bv(?:ol)?\\.?\\s*([0-9]+)\\b/i);\n if (volMatch?.[1]) {\n const n = Number.parseInt(volMatch[1], 10);\n if (Number.isFinite(n)) volume = n;\n }\n\n // Group: \"by <Group>\" up to \"(\" or end.\n let group: string | null = null;\n const groupMatch = trimmed.match(/\\bby\\s+(.+?)(?:\\s*\\([a-z]{2,3}\\)\\s*)?$/i);\n if (groupMatch?.[1]) {\n const candidate = groupMatch[1].trim();\n if (candidate.length > 0) group = candidate;\n }\n\n // Language: trailing parenthesized 2-3 letter code (e.g. (en), (es), (id), (por)).\n //\n // The current MangaUpdates v1 RSS endpoint (`/v1/series/{id}/rss`) ships\n // titles without a language tag \u2014 it's the English-localized release\n // stream by design. Default to `\"en\"` so items aren't dropped by the\n // client-side language gate; an explicit `(es)` / `(id)` / etc. still\n // wins when present, and the host's per-series language list remains\n // the authoritative gate downstream. The legacy `UNKNOWN_LANGUAGE`\n // sentinel is kept exported for backwards compatibility but no longer\n // produced by this parser.\n let language = \"en\";\n const langMatch = trimmed.match(/\\(([a-z]{2,3})\\)\\s*$/i);\n if (langMatch?.[1]) {\n language = langMatch[1].toLowerCase();\n }\n\n return { chapter, volume, group, language };\n}\n\n// -----------------------------------------------------------------------------\n// Item parsing\n// -----------------------------------------------------------------------------\n\n/**\n * Best-effort `pubDate` -> ISO-8601 conversion. MangaUpdates uses RFC-2822\n * style dates (`Mon, 04 May 2026 02:31:00 GMT`). Falls back to \"now\" on\n * invalid input \u2014 never throws, since one bad pubDate shouldn't drop the\n * whole feed.\n */\nfunction pubDateToIso(raw: string | null): string {\n if (raw) {\n const d = new Date(raw);\n if (!Number.isNaN(d.getTime())) return d.toISOString();\n }\n return new Date().toISOString();\n}\n\n/**\n * Derive a stable external_release_id. Prefer `<guid>`, then the link URL,\n * otherwise fall back to a deterministic hash of `(title + pubDate)`.\n *\n * Stability is what matters: re-polling the same item must produce the same\n * ID so the host's `(source_id, external_release_id)` dedup catches it.\n */\nfunction deriveExternalReleaseId(\n guid: string | null,\n link: string | null,\n title: string,\n pubDate: string | null,\n): string {\n if (guid && guid.trim().length > 0) return guid.trim();\n if (link && link.trim().length > 0) return link.trim();\n // Deterministic fallback for feeds that omit both. djb2-ish hash keeps the\n // ID short while staying stable across polls.\n const fallback = `${title}|${pubDate ?? \"\"}`;\n let h = 5381;\n for (let i = 0; i < fallback.length; i++) {\n h = ((h << 5) + h + fallback.charCodeAt(i)) | 0;\n }\n return `t:${(h >>> 0).toString(36)}`;\n}\n\n/**\n * Parse a single MangaUpdates `<item>` block into a `ParsedRssItem`. Returns\n * null if the title is missing entirely (truly malformed item).\n */\nexport function parseItem(itemXml: string): ParsedRssItem | null {\n const title = extractTagText(itemXml, \"title\");\n if (!title) return null;\n\n const link = extractTagText(itemXml, \"link\");\n const guid = extractTagText(itemXml, \"guid\");\n const pubDate = extractTagText(itemXml, \"pubDate\");\n\n const { chapter, volume, group, language } = parseTitle(title);\n\n return {\n externalReleaseId: deriveExternalReleaseId(guid, link, title, pubDate),\n title,\n chapter,\n volume,\n group,\n language,\n link: link ?? \"\",\n observedAt: pubDateToIso(pubDate),\n };\n}\n\n/**\n * Parse a full MangaUpdates per-series RSS feed body into items. Bad items\n * (missing title) are dropped silently \u2014 the feed should be best-effort\n * tolerant.\n */\nexport function parseFeed(xml: string): ParsedRssItem[] {\n return splitItems(xml)\n .map(parseItem)\n .filter((i): i is ParsedRssItem => i !== null);\n}\n", "/**\n * Filtering: language allowlist + group blocklist.\n *\n * Filters are applied client-side in the plugin (before recording) for two\n * reasons:\n * 1. Keeps the ledger small. Out-of-language items would be dropped by the\n * host anyway via the latest_known_* gate, but writing them to the\n * ledger pollutes the inbox and wastes write IO.\n * 2. Keeps the inbox clean. Users who configure `[\"en\"]` don't want to see\n * Spanish entries hidden behind a state flag \u2014 they want them gone.\n */\n\nimport { type ParsedRssItem, UNKNOWN_LANGUAGE } from \"./parser.js\";\n\n/**\n * Resolved, normalized filter inputs for a single series. Both lists are\n * lowercased + trimmed. Empty `languages` is interpreted as \"no filter\"\n * (everything passes), but the caller is expected to pass at least the\n * server-wide default to avoid that footgun.\n */\nexport interface ResolvedFilters {\n /** Lowercased ISO 639-1 codes; empty = no filter. */\n languages: string[];\n /** Lowercased group names; case-insensitive exact match against `group`. */\n blockedGroups: Set<string>;\n /**\n * Whether to include items whose language couldn't be detected\n * (`UNKNOWN_LANGUAGE` sentinel). Default false \u2014 be conservative.\n */\n includeUnknownLanguage: boolean;\n}\n\n/**\n * Build resolved filters from raw config strings + lists. Centralizes the\n * normalization so the poll handler doesn't have to care about casing or\n * whitespace.\n */\nexport function resolveFilters(input: {\n languages: string[];\n blockedGroups: string[];\n includeUnknownLanguage?: boolean;\n}): ResolvedFilters {\n const languages = dedupePreserveOrder(\n input.languages.map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0),\n );\n const blockedGroups = new Set(\n input.blockedGroups.map((s) => s.trim().toLowerCase()).filter((s) => s.length > 0),\n );\n return {\n languages,\n blockedGroups,\n includeUnknownLanguage: input.includeUnknownLanguage ?? false,\n };\n}\n\n/**\n * Parse a comma-separated string into a clean list (trim, drop empties).\n * Helper for `blockedGroups` which is admin-config typed as a single string.\n */\nexport function parseCommaList(raw: unknown): string[] {\n if (typeof raw !== \"string\") return [];\n return raw\n .split(\",\")\n .map((s) => s.trim())\n .filter((s) => s.length > 0);\n}\n\n/**\n * Returns true if the item should be kept.\n *\n * Language filter:\n * - If `languages` is empty \u2192 pass.\n * - Otherwise, item.language must be in the list (case-insensitive).\n * - `unknown` language is rejected unless `includeUnknownLanguage` is true.\n *\n * Group filter:\n * - If `group` is null \u2192 pass (we have nothing to match against).\n * - Otherwise, group must NOT be in `blockedGroups`.\n */\nexport function passesFilters(item: ParsedRssItem, filters: ResolvedFilters): boolean {\n // Language gate.\n if (item.language === UNKNOWN_LANGUAGE) {\n if (!filters.includeUnknownLanguage) return false;\n } else if (filters.languages.length > 0) {\n if (!filters.languages.includes(item.language.toLowerCase())) return false;\n }\n\n // Group blocklist.\n if (item.group !== null && filters.blockedGroups.size > 0) {\n if (filters.blockedGroups.has(item.group.trim().toLowerCase())) return false;\n }\n\n return true;\n}\n\nfunction dedupePreserveOrder(xs: string[]): string[] {\n const seen = new Set<string>();\n const out: string[] = [];\n for (const x of xs) {\n if (!seen.has(x)) {\n seen.add(x);\n out.push(x);\n }\n }\n return out;\n}\n", "{\n \"name\": \"@ashdev/codex-plugin-release-mangaupdates\",\n \"version\": \"1.19.3\",\n \"description\": \"MangaUpdates RSS release-source plugin for Codex - announces new chapter releases for tracked series in user-configured languages\",\n \"main\": \"dist/index.js\",\n \"bin\": \"dist/index.js\",\n \"type\": \"module\",\n \"files\": [\n \"dist\",\n \"README.md\"\n ],\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/AshDevFr/codex.git\",\n \"directory\": \"plugins/release-mangaupdates\"\n },\n \"scripts\": {\n \"build\": \"esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --sourcemap --banner:js='#!/usr/bin/env node'\",\n \"dev\": \"npm run build -- --watch\",\n \"clean\": \"rm -rf dist\",\n \"start\": \"node dist/index.js\",\n \"lint\": \"biome check .\",\n \"lint:fix\": \"biome check --write .\",\n \"typecheck\": \"tsc --noEmit\",\n \"test\": \"vitest run --passWithNoTests\",\n \"test:watch\": \"vitest\",\n \"prepublishOnly\": \"npm run lint && npm run build\"\n },\n \"keywords\": [\n \"codex\",\n \"plugin\",\n \"mangaupdates\",\n \"release-source\",\n \"manga\"\n ],\n \"author\": \"Codex\",\n \"license\": \"MIT\",\n \"engines\": {\n \"node\": \">=22.0.0\"\n },\n \"dependencies\": {\n \"@ashdev/codex-plugin-sdk\": \"^1.19.3\"\n },\n \"devDependencies\": {\n \"@biomejs/biome\": \"^2.4.4\",\n \"@types/node\": \"^22.0.0\",\n \"esbuild\": \"^0.27.3\",\n \"typescript\": \"^5.9.3\",\n \"vitest\": \"^4.0.18\"\n }\n}\n", "import type { PluginManifest } from \"@ashdev/codex-plugin-sdk\";\nimport packageJson from \"../package.json\" with { type: \"json\" };\n\n/**\n * External-ID source name for MangaUpdates.\n *\n * MangaUpdates IDs are populated by metadata-provider plugins (e.g.\n * MangaBaka cross-references) or pasted manually by the user via the series\n * tracking panel. The release plugin needs the bare source name (no\n * `plugin:` prefix) here to match the host's external-ID filter.\n */\nexport const EXTERNAL_ID_SOURCE_MANGAUPDATES = \"mangaupdates\" as const;\n\nexport const manifest = {\n name: \"release-mangaupdates\",\n displayName: \"MangaUpdates Releases\",\n version: packageJson.version,\n description:\n \"Announces new chapter releases for tracked series via MangaUpdates per-series RSS feeds. Filters by user-configured languages.\",\n author: \"Codex\",\n homepage: \"https://github.com/AshDevFr/codex\",\n protocolVersion: \"1.1\",\n capabilities: {\n releaseSource: {\n kinds: [\"rss-series\"],\n requiresAliases: false,\n requiresExternalIds: [EXTERNAL_ID_SOURCE_MANGAUPDATES],\n canAnnounceChapters: true,\n canAnnounceVolumes: true,\n },\n },\n configSchema: {\n description:\n \"MangaUpdates plugin configuration. Per-series language preferences live on each series' tracking config; the values here are server-wide defaults applied when a series doesn't override them.\",\n fields: [\n {\n key: \"blockedGroups\",\n label: \"Blocked Scanlation Groups\",\n description:\n \"Comma-separated list of scanlation group names to exclude from announcements (case-insensitive, exact match). Per-series overrides may further extend this list.\",\n type: \"string\" as const,\n required: false,\n default: \"\",\n example: \"LowQualityScans,MTL Group\",\n },\n {\n key: \"requestTimeoutMs\",\n label: \"Request Timeout (ms)\",\n description:\n \"How long to wait for a single RSS fetch before giving up. Defaults to 10000 (10 seconds).\",\n type: \"number\" as const,\n required: false,\n default: 10_000,\n },\n ],\n },\n userDescription:\n \"Announces new chapters for series you've tracked, using their MangaUpdates IDs. Filters releases to languages you can read. Notification-only \u2014 Codex does not download anything.\",\n adminSetupInstructions:\n \"1. No config is required to get started \u2014 saving the plugin is enough. The plugin auto-registers a single source row (`MangaUpdates Releases`) in **Settings \u2192 Release tracking** on first start, where you can disable it, change the poll interval, or hit *Poll now*. 2. To get announcements for a series, edit its tracking panel and either paste a `mangaupdates` external ID or let the metadata-refresh path populate it from MangaBaka cross-references. 3. Optional: set `blockedGroups` (CSV, case-insensitive) to filter noisy scanlators server-wide; per-series language preferences live on each series' tracking config and override the server default (`release_tracking.default_languages`). No credentials are needed; MangaUpdates RSS feeds are public.\",\n} as const satisfies PluginManifest & {\n capabilities: { releaseSource: { kinds: [\"rss-series\"] } };\n};\n", "/**\n * MangaUpdates RSS Release-Source Plugin for Codex.\n *\n * Polls per-series RSS feeds at MangaUpdates and announces new chapter /\n * volume releases for tracked series. The plugin is the first writer of\n * `release_ledger` rows in production \u2014 earlier phases build the\n * infrastructure, this one delivers the first real notification feed.\n *\n * Flow per `releases/poll`:\n * 1. Pull tracked-series scope from the host (`releases/list_tracked`).\n * Filtered server-side to series with a `mangaupdates` external ID.\n * 2. For each series, conditional GET the RSS feed.\n * 3. Parse the response into items, then filter by:\n * - per-series language list (admin / per-series config)\n * - admin-configured group blocklist\n * 4. Build `ReleaseCandidate` rows and stream them via\n * `releases/record`. The host's matcher applies the threshold and\n * ledger dedup.\n * 5. Pass the new ETag back via the poll response so the host updates\n * the source row.\n *\n * **Concurrency note:** The plugin host already serializes RPCs per plugin\n * process, so we don't need to throttle internally beyond an in-poll loop\n * that walks tracked series sequentially.\n */\n\nimport {\n createLogger,\n createReleaseSourcePlugin,\n type HostRpcClient,\n HostRpcError,\n type InitializeParams,\n RELEASES_METHODS,\n type ReleaseCandidate,\n type ReleasePollRequest,\n type ReleasePollResponse,\n type TrackedSeriesEntry,\n} from \"@ashdev/codex-plugin-sdk\";\nimport { fetchSeriesFeed } from \"./fetcher.js\";\nimport { parseCommaList, passesFilters, resolveFilters } from \"./filter.js\";\nimport { EXTERNAL_ID_SOURCE_MANGAUPDATES, manifest } from \"./manifest.js\";\nimport { type ParsedRssItem, parseFeed } from \"./parser.js\";\n\nconst logger = createLogger({ name: manifest.name, level: \"info\" });\n\n// =============================================================================\n// Plugin-level state (set during initialize)\n// =============================================================================\n\ninterface PluginState {\n hostRpc: HostRpcClient | null;\n /** Admin-configured group blocklist (lowercased exact match). */\n blockedGroupsCsv: string;\n /** Hard timeout for upstream fetches. */\n requestTimeoutMs: number;\n}\n\nconst state: PluginState = {\n hostRpc: null,\n blockedGroupsCsv: \"\",\n requestTimeoutMs: 10_000,\n};\n\n/** Reset state. Exported for tests; not part of the plugin contract. */\nexport function _resetState(): void {\n state.hostRpc = null;\n state.blockedGroupsCsv = \"\";\n state.requestTimeoutMs = 10_000;\n}\n\n// =============================================================================\n// Reverse-RPC wrappers (typed shorthands so the poll code reads cleanly)\n// =============================================================================\n\ninterface ListTrackedResponse {\n tracked: TrackedSeriesEntry[];\n nextOffset?: number;\n}\n\ninterface RecordResponse {\n ledgerId: string;\n deduped: boolean;\n}\n\nasync function listTracked(\n rpc: HostRpcClient,\n sourceId: string,\n offset: number,\n limit: number,\n): Promise<ListTrackedResponse> {\n return rpc.call<ListTrackedResponse>(RELEASES_METHODS.LIST_TRACKED, {\n sourceId,\n offset,\n limit,\n });\n}\n\nasync function recordCandidate(\n rpc: HostRpcClient,\n sourceId: string,\n candidate: ReleaseCandidate,\n): Promise<RecordResponse | null> {\n try {\n return await rpc.call<RecordResponse>(RELEASES_METHODS.RECORD, {\n sourceId,\n candidate,\n });\n } catch (err) {\n if (err instanceof HostRpcError) {\n // Threshold rejection / validation error / unknown source. Log and\n // skip; the next poll will retry the still-eligible candidates.\n logger.warn(\n `record failed for ${candidate.externalReleaseId}: ${err.message} (code ${err.code})`,\n );\n } else {\n const msg = err instanceof Error ? err.message : \"unknown error\";\n logger.warn(`record failed for ${candidate.externalReleaseId}: ${msg}`);\n }\n return null;\n }\n}\n\n// =============================================================================\n// Iteration helpers\n// =============================================================================\n\n/**\n * Lazily walk all tracked-series pages from the host. Yields entries one\n * series at a time so the caller can interleave per-series fetches without\n * buffering the whole list (relevant for users tracking hundreds of series).\n */\nasync function* iterateTrackedSeries(\n rpc: HostRpcClient,\n sourceId: string,\n): AsyncGenerator<TrackedSeriesEntry> {\n const pageSize = 200;\n let offset = 0;\n while (true) {\n const page = await listTracked(rpc, sourceId, offset, pageSize);\n for (const entry of page.tracked) {\n yield entry;\n }\n if (page.nextOffset === undefined || page.tracked.length === 0) return;\n offset = page.nextOffset;\n }\n}\n\n/**\n * Per-series effective language list. We use the host's `latestKnown*`\n * exposure plus the `externalIds` map to scope the fetch, but the\n * languages config is owned by the host (set on `series_tracking.languages`\n * with fallback to the server-wide default).\n *\n * However, the current `releases/list_tracked` response shape doesn't\n * expose per-series `languages` \u2014 see plan doc for this design choice.\n * For Phase 6 the plugin reads its admin-level group blocklist and emits\n * candidates with the language tag from the parsed entry; the host's\n * `latest_known_*` advance gate enforces the per-series language list\n * authoritatively (see `services/release/languages.rs`).\n *\n * We *also* want to drop out-of-language candidates client-side to keep the\n * ledger small and the inbox clean. Without per-series languages on the\n * tracked-series payload, the client-side filter degrades to a no-op\n * pass-everything for known languages \u2014 leaving it to the host's gate. The\n * group blocklist still applies.\n *\n * If a future protocol revision exposes `effectiveLanguages` on the\n * tracked-series entry, swap this stub for the real list and the existing\n * `passesFilters` will do the right thing.\n */\nfunction effectiveLanguagesForSeries(_entry: TrackedSeriesEntry): string[] {\n return []; // empty = no client-side language gate; host gate is authoritative\n}\n\n/**\n * Map a `ParsedRssItem` to a `ReleaseCandidate`. Confidence is 1.0 because\n * the match is keyed by external ID \u2014 there's no fuzzy matching.\n */\nfunction toCandidate(entry: TrackedSeriesEntry, item: ParsedRssItem): ReleaseCandidate {\n const candidate: ReleaseCandidate = {\n seriesMatch: {\n codexSeriesId: entry.seriesId,\n confidence: 1.0,\n reason: `mangaupdates_id:${entry.externalIds?.[EXTERNAL_ID_SOURCE_MANGAUPDATES] ?? \"\"}`,\n },\n externalReleaseId: item.externalReleaseId,\n chapter: item.chapter,\n volume: item.volume,\n language: item.language,\n groupOrUploader: item.group,\n payloadUrl: item.link.length > 0 ? item.link : `urn:mu:${item.externalReleaseId}`,\n observedAt: item.observedAt,\n };\n return candidate;\n}\n\n// =============================================================================\n// Per-series poll\n// =============================================================================\n\n/** Outcome of a single per-series fetch+record cycle. */\nexport interface SeriesPollOutcome {\n seriesId: string;\n fetched: boolean;\n notModified: boolean;\n parsed: number;\n /** Of those parsed, how many passed client-side filters and were sent to record. */\n matched: number;\n recorded: number;\n /** Of those sent to record, how many the host deduped onto an existing row. */\n deduped: number;\n upstreamStatus: number;\n /** New ETag returned by upstream (only set when fetched=true). */\n etag: string | null;\n /** Error string if the per-series fetch failed; empty otherwise. */\n error: string;\n}\n\n/**\n * Poll a single series. Internal \u2014 exposed for testing.\n *\n * Aggregates the worst (highest) upstream status across the per-series\n * fetches at the call site so the host's per-host backoff layer sees real\n * 429/5xx signals.\n */\nexport async function pollSeries(\n rpc: HostRpcClient,\n sourceId: string,\n entry: TrackedSeriesEntry,\n options: {\n blockedGroups: string[];\n timeoutMs: number;\n fetchImpl?: typeof fetch;\n },\n): Promise<SeriesPollOutcome> {\n const muId = entry.externalIds?.[EXTERNAL_ID_SOURCE_MANGAUPDATES];\n if (!muId) {\n return {\n seriesId: entry.seriesId,\n fetched: false,\n notModified: false,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: 0,\n etag: null,\n error: \"missing mangaupdates external ID\",\n };\n }\n\n // We don't have per-series ETag here \u2014 that lives on the source row, not\n // the series. For a per-source feed (rss-uploader) ETags align cleanly;\n // for per-series feeds (this plugin) we'd need per-(source, series) state\n // to do conditional GETs per series. That's a future optimization; for\n // now we always do an unconditional GET. Daily polls + small per-series\n // bodies keep the bandwidth cost negligible.\n const result = await fetchSeriesFeed(muId, null, {\n fetchImpl: options.fetchImpl,\n timeoutMs: options.timeoutMs,\n });\n\n if (result.kind === \"notModified\") {\n return {\n seriesId: entry.seriesId,\n fetched: true,\n notModified: true,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: 304,\n etag: null,\n error: \"\",\n };\n }\n\n if (result.kind === \"error\") {\n return {\n seriesId: entry.seriesId,\n fetched: false,\n notModified: false,\n parsed: 0,\n matched: 0,\n recorded: 0,\n deduped: 0,\n upstreamStatus: result.status,\n etag: null,\n error: result.message,\n };\n }\n\n // result.kind === \"ok\"\n const items = parseFeed(result.body);\n const filters = resolveFilters({\n languages: effectiveLanguagesForSeries(entry),\n blockedGroups: options.blockedGroups,\n });\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n for (const item of items) {\n if (!passesFilters(item, filters)) continue;\n matched++;\n const candidate = toCandidate(entry, item);\n const outcome = await recordCandidate(rpc, sourceId, candidate);\n if (!outcome) continue;\n if (outcome.deduped) {\n deduped++;\n } else {\n recorded++;\n }\n }\n return {\n seriesId: entry.seriesId,\n fetched: true,\n notModified: false,\n parsed: items.length,\n matched,\n recorded,\n deduped,\n upstreamStatus: 200,\n etag: result.etag,\n error: \"\",\n };\n}\n\n// =============================================================================\n// Top-level poll handler\n// =============================================================================\n\nasync function poll(params: ReleasePollRequest, rpc: HostRpcClient): Promise<ReleasePollResponse> {\n const sourceId = params.sourceId;\n const blockedGroups = parseCommaList(state.blockedGroupsCsv);\n\n let parsed = 0;\n let matched = 0;\n let recorded = 0;\n let deduped = 0;\n let worstStatus = 200;\n let lastEtag: string | null = null;\n let seenSeries = 0;\n // Series the host returned that lack a MangaUpdates external ID. A high\n // count here is the most common cause of an \"empty\" poll: the plugin\n // can't fetch a feed without an MU ID, so the user needs to populate\n // those (manual paste or metadata refresh from MangaBaka).\n let skippedNoMuId = 0;\n\n for await (const entry of iterateTrackedSeries(rpc, sourceId)) {\n seenSeries++;\n const outcome = await pollSeries(rpc, sourceId, entry, {\n blockedGroups,\n timeoutMs: state.requestTimeoutMs,\n });\n parsed += outcome.parsed;\n matched += outcome.matched;\n recorded += outcome.recorded;\n deduped += outcome.deduped;\n if (outcome.upstreamStatus > worstStatus) {\n worstStatus = outcome.upstreamStatus;\n }\n if (outcome.etag) lastEtag = outcome.etag;\n\n if (outcome.error === \"missing mangaupdates external ID\") {\n skippedNoMuId++;\n } else if (outcome.error) {\n logger.warn(`series ${entry.seriesId}: ${outcome.error} (status ${outcome.upstreamStatus})`);\n }\n }\n\n if (skippedNoMuId > 0) {\n logger.info(\n `skipped ${skippedNoMuId} of ${seenSeries} tracked series for source=${sourceId}: no mangaupdates external ID. Add one in the Tracking panel or run a metadata refresh.`,\n );\n }\n\n logger.info(\n `poll complete: source=${sourceId} series=${seenSeries} skipped=${skippedNoMuId} parsed=${parsed} matched=${matched} recorded=${recorded} deduped=${deduped} worst_status=${worstStatus}`,\n );\n\n // Report counters back to the host so the source's `last_summary` is\n // accurate. Without these the host only sees the (empty) `candidates`\n // payload \u2014 we record via reverse-RPC mid-poll \u2014 and the badge reads\n // \"Fetched 0 items\" no matter what actually happened.\n // Per-series ETags don't align with the per-source state slot, so we\n // intentionally leave `etag` undefined unless we actually saw one\n // (which today we won't, since we don't pass If-None-Match per series).\n return {\n notModified: false,\n upstreamStatus: worstStatus,\n parsed,\n matched,\n recorded,\n deduped,\n ...(lastEtag !== null ? { etag: lastEtag } : {}),\n };\n}\n\n// =============================================================================\n// Plugin Initialization\n// =============================================================================\n\n/**\n * Register a single static source row representing the MangaUpdates batch\n * feed. Unlike Nyaa (one row per uploader), MangaUpdates polls all tracked\n * series under one logical feed, so we always declare exactly one row keyed\n * `default`. Retries on `METHOD_NOT_FOUND` to handle the brief race where\n * the host has not yet installed the releases reverse-RPC handler.\n */\nexport async function registerSources(\n rpc: HostRpcClient,\n): Promise<{ registered: number; pruned: number } | null> {\n const sources = [\n {\n sourceKey: \"default\",\n displayName: \"MangaUpdates Releases\",\n kind: \"rss-series\" as const,\n config: null,\n },\n ];\n const maxAttempts = 5;\n for (let attempt = 1; attempt <= maxAttempts; attempt++) {\n try {\n return await rpc.call<{ registered: number; pruned: number }>(\n RELEASES_METHODS.REGISTER_SOURCES,\n { sources },\n );\n } catch (err) {\n const isMethodNotFound = err instanceof HostRpcError && err.code === -32601;\n if (isMethodNotFound && attempt < maxAttempts) {\n await new Promise((r) => setTimeout(r, 50 * attempt));\n continue;\n }\n const reason = err instanceof Error ? err.message : String(err);\n logger.error(`register_sources failed: ${reason}`);\n return null;\n }\n }\n return null;\n}\n\ncreateReleaseSourcePlugin({\n manifest,\n provider: {\n async poll(params: ReleasePollRequest): Promise<ReleasePollResponse> {\n if (!state.hostRpc) {\n throw new Error(\"Plugin not initialized: hostRpc client missing\");\n }\n return poll(params, state.hostRpc);\n },\n },\n logLevel: \"info\",\n async onInitialize(params: InitializeParams) {\n state.hostRpc = params.hostRpc;\n const ac = params.adminConfig ?? {};\n if (typeof ac.blockedGroups === \"string\") {\n state.blockedGroupsCsv = ac.blockedGroups;\n }\n if (typeof ac.requestTimeoutMs === \"number\" && Number.isFinite(ac.requestTimeoutMs)) {\n state.requestTimeoutMs = Math.max(1_000, Math.min(ac.requestTimeoutMs, 60_000));\n }\n logger.info(\n `initialized: blockedGroups=${state.blockedGroupsCsv ? \"set\" : \"empty\"} timeoutMs=${state.requestTimeoutMs}`,\n );\n\n // Materialize the single static source row. Deferred to a microtask so\n // we run *after* the host installs the releases reverse-RPC handler.\n queueMicrotask(() => {\n void registerSources(params.hostRpc).then((result) => {\n if (result) {\n logger.info(`register_sources: registered=${result.registered} pruned=${result.pruned}`);\n }\n });\n });\n },\n});\n\nlogger.info(\"MangaUpdates release-source plugin started\");\n"],
|
|
5
5
|
"mappings": "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",
|
|
6
6
|
"names": ["manifest", "logger", "response", "manifest"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashdev/codex-plugin-release-mangaupdates",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.3",
|
|
4
4
|
"description": "MangaUpdates RSS release-source plugin for Codex - announces new chapter releases for tracked series in user-configured languages",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": "dist/index.js",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"node": ">=22.0.0"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@ashdev/codex-plugin-sdk": "^1.19.
|
|
42
|
+
"@ashdev/codex-plugin-sdk": "^1.19.3"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@biomejs/biome": "^2.4.4",
|