aboutsummaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/proxy/package.json25
-rw-r--r--apps/proxy/src/index.js390
-rw-r--r--apps/proxy/src/mangadex.js392
-rw-r--r--apps/proxy/src/supabase.js217
-rw-r--r--apps/proxy/wrangler.toml3
5 files changed, 926 insertions, 101 deletions
diff --git a/apps/proxy/package.json b/apps/proxy/package.json
index 62c13765..91ee6d7b 100644
--- a/apps/proxy/package.json
+++ b/apps/proxy/package.json
@@ -1,13 +1,14 @@
{
- "name": "due-proxy",
- "version": "0.0.0",
- "private": true,
- "scripts": {
- "deploy": "wrangler deploy",
- "dev": "wrangler dev",
- "start": "wrangler dev"
- },
- "devDependencies": {
- "wrangler": "^3.0.0"
- }
-} \ No newline at end of file
+ "name": "due-proxy",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "deploy": "wrangler deploy",
+ "dev": "wrangler dev",
+ "start": "wrangler dev"
+ },
+ "devDependencies": {
+ "wrangler": "^3.0.0"
+ }
+}
diff --git a/apps/proxy/src/index.js b/apps/proxy/src/index.js
index f2a37111..f90adfa7 100644
--- a/apps/proxy/src/index.js
+++ b/apps/proxy/src/index.js
@@ -1,113 +1,325 @@
-const handleRequest = async (request) => {
+import { bootstrapManga, syncMangadexIndex } from "./mangadex.js";
+import {
+ deleteMangadexFailureRows,
+ getMangadexFailureRowsByAniListIds,
+ getMangadexRowsByAniListIds,
+ hasSupabaseConfig,
+ upsertMangadexRows,
+ upsertMangadexFailureRows,
+} from "./supabase.js";
+
+const DEFAULT_ALLOWED_ORIGIN = "https://due.moe";
+const DEFAULT_BOOTSTRAP_RETRY_MINUTES = 360;
+const DEFAULT_PENDING_RETRY_MS = 750;
+const bootstrapInFlight = new Map();
+
+const isPrivateHostname = (hostname) =>
+ hostname === "localhost" ||
+ hostname === "127.0.0.1" ||
+ hostname.endsWith(".local") ||
+ /^10\./.test(hostname) ||
+ /^192\.168\./.test(hostname) ||
+ /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname);
+
+const accessControlOrigin = (request) => {
+ const origin = request.headers.get("Origin");
+
+ if (!origin) return DEFAULT_ALLOWED_ORIGIN;
+
try {
- const url = new URL(request.url);
- let query;
- let dropHeaders = false;
+ const url = new URL(origin);
- if (url.search.includes('&dh')) {
- url.search = url.search.replace('&dh', '');
- dropHeaders = true;
- }
+ if (
+ url.hostname === "due.moe" ||
+ url.hostname.endsWith(".due.moe") ||
+ isPrivateHostname(url.hostname)
+ )
+ return origin;
+ } catch {}
- if (url.search.includes('?q=')) {
- query = url.search.split('?q=')[1];
- } else if (url.search.includes('?d=')) {
- query = atob(url.search.split('?d=')[1]);
- } else if (url.search.includes('?d2=')) {
- const fullEncodedURL = url.search.split('?d2=')[1];
- const key = parseInt(fullEncodedURL.slice(-2));
-
- query = atob(fullEncodedURL.slice(0, -2))
- .split(':')
- .map((char) => String.fromCharCode(char - key))
- .join('');
- } else {
- return new Response(null, {
- status: 400,
- statusText: 'Bad Request',
- });
- }
+ return DEFAULT_ALLOWED_ORIGIN;
+};
- request = new Request(query, request);
+const appendCorsHeaders = (request, headers = new Headers()) => {
+ headers.set("Access-Control-Allow-Origin", accessControlOrigin(request));
+ headers.set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
+ headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
+ headers.append("Vary", "Origin");
- request.headers.set('Host', new URL(query).origin);
- request.headers.set('Referrer', new URL(query));
- request.headers.set('Origin', new URL(query));
- request.headers.set('Access-Control-Allow-Credentials', 'true');
- request.headers.delete('X-Content-Type-Options');
+ return headers;
+};
- let response = await fetch(request);
+const jsonResponse = (request, body, init = {}) => {
+ const headers = appendCorsHeaders(request, new Headers(init.headers));
- response = new Response(response.body, response);
+ headers.set("Content-Type", "application/json");
- if (dropHeaders) response.headers.forEach((_, key) => response.headers.delete(key));
+ return new Response(JSON.stringify(body), {
+ ...init,
+ headers,
+ });
+};
- response.headers.set('Access-Control-Allow-Origin', 'https://due.moe');
- response.headers.append('Vary', 'Origin');
- response.headers.set('Cache-Control', 'max-age=300');
+const textResponse = (request, body, init = {}) =>
+ new Response(body, {
+ ...init,
+ headers: appendCorsHeaders(request, new Headers(init.headers)),
+ });
- return response;
- } catch {
- return new Response(null, {
- status: 400,
- statusText: 'Bad Request',
- });
+const decodeProxyTarget = (url) => {
+ if (url.search.includes("?q=")) return url.search.split("?q=")[1];
+ if (url.search.includes("?d=")) return atob(url.search.split("?d=")[1]);
+ if (url.search.includes("?d2=")) {
+ const fullEncodedUrl = url.search.split("?d2=")[1];
+ const key = Number.parseInt(fullEncodedUrl.slice(-2), 10);
+
+ return atob(fullEncodedUrl.slice(0, -2))
+ .split(":")
+ .map((char) => String.fromCharCode(Number(char) - key))
+ .join("");
}
+
+ return null;
};
-const handleOptions = async (request) => {
- if (
- request.headers.get('Origin') !== null &&
- request.headers.get('Access-Control-Request-Method') !== null &&
- request.headers.get('Access-Control-Request-Headers') !== null
- ) {
- return new Response(null, {
- headers: {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
- 'Access-Control-Allow-Headers': '*',
- },
+const forwardProxyRequest = async (request) => {
+ const url = new URL(request.url);
+ const dropHeaders = url.search.includes("&dh");
+
+ if (dropHeaders) url.search = url.search.replace("&dh", "");
+
+ const target = decodeProxyTarget(url);
+
+ if (!target)
+ return textResponse(request, null, {
+ status: 400,
+ statusText: "Bad Request",
});
- } else {
- return new Response(null, {
- headers: {
- Allow: 'GET, HEAD, POST, OPTIONS',
+
+ const targetUrl = new URL(target);
+ const proxiedRequest = new Request(target, request);
+
+ proxiedRequest.headers.set("Host", targetUrl.hostname);
+ proxiedRequest.headers.set("Referrer", targetUrl.toString());
+ proxiedRequest.headers.set("Origin", targetUrl.origin);
+ proxiedRequest.headers.delete("X-Content-Type-Options");
+
+ let response = await fetch(proxiedRequest);
+
+ response = new Response(response.body, response);
+
+ if (dropHeaders)
+ for (const key of [...response.headers.keys()])
+ response.headers.delete(key);
+
+ appendCorsHeaders(request, response.headers);
+ response.headers.set("Cache-Control", "max-age=300");
+
+ return response;
+};
+
+const handleOptions = (request) =>
+ new Response(null, {
+ headers: appendCorsHeaders(request),
+ });
+
+const isMangadexIdConstraintConflict = (error) =>
+ error instanceof Error &&
+ error.message.includes("mangadex_manga_index_mangadex_id_key");
+
+const parseMangaPayload = async (request) => {
+ const body = await request.json().catch(() => null);
+ const manga = Array.isArray(body?.manga) ? body.manga : [];
+
+ return manga
+ .map((entry) => ({
+ anilistId: Number(entry?.anilistId),
+ status: String(entry?.status || ""),
+ startYear: entry?.startYear ? Number(entry.startYear) : null,
+ nativeTitle: entry?.nativeTitle || null,
+ englishTitle: entry?.englishTitle || null,
+ romajiTitle: entry?.romajiTitle || null,
+ }))
+ .filter((entry) => Number.isFinite(entry.anilistId));
+};
+
+const bootstrapRetryMinutes = (env) => {
+ const minutes = Number.parseInt(env.MANGADEX_BOOTSTRAP_RETRY_MINUTES || "", 10);
+
+ return Number.isFinite(minutes) && minutes > 0
+ ? minutes
+ : DEFAULT_BOOTSTRAP_RETRY_MINUTES;
+};
+
+const pendingRetryMs = (env) => {
+ const milliseconds = Number.parseInt(env.MANGA_CHAPTER_COUNTS_RETRY_MS || "", 10);
+
+ return Number.isFinite(milliseconds) && milliseconds > 0
+ ? milliseconds
+ : DEFAULT_PENDING_RETRY_MS;
+};
+
+const isRecentFailure = (row, retryMinutes) =>
+ Date.now() - new Date(row.last_attempted_at || row.updated_at).getTime() <
+ retryMinutes * 60 * 1000;
+
+const queueBootstrap = (env, manga) =>
+ manga
+ .map((entry) => {
+ const existing = bootstrapInFlight.get(entry.anilistId);
+
+ if (existing) return existing;
+
+ const promise = (async () => {
+ const row = await bootstrapManga(env, entry);
+
+ if (row) {
+ try {
+ await upsertMangadexRows(env, [row]);
+ } catch (error) {
+ if (!isMangadexIdConstraintConflict(error)) throw error;
+ }
+
+ await deleteMangadexFailureRows(env, [row.anilist_id]);
+
+ return;
+ }
+
+ await upsertMangadexFailureRows(env, [entry.anilistId]);
+ })().finally(() => {
+ bootstrapInFlight.delete(entry.anilistId);
+ });
+
+ bootstrapInFlight.set(entry.anilistId, promise);
+
+ return promise;
+ })
+ .filter(Boolean);
+
+const handleMangaChapterCounts = async (request, env, ctx) => {
+ if (!hasSupabaseConfig(env))
+ return jsonResponse(
+ request,
+ { error: "Supabase is not configured for the proxy worker." },
+ { status: 500 },
+ );
+
+ const manga = await parseMangaPayload(request);
+
+ if (!manga.length) return jsonResponse(request, { data: {} });
+
+ const anilistIds = manga.map((entry) => entry.anilistId);
+ const [existingRows, failureRows] = await Promise.all([
+ getMangadexRowsByAniListIds(env, anilistIds),
+ getMangadexFailureRowsByAniListIds(env, anilistIds),
+ ]);
+ const existingIds = new Set(existingRows.map((row) => row.anilist_id));
+ const recentFailures = new Set(
+ failureRows
+ .filter((row) => isRecentFailure(row, bootstrapRetryMinutes(env)))
+ .map((row) => row.anilist_id),
+ );
+ const missingRows = manga.filter(
+ (entry) =>
+ !existingIds.has(entry.anilistId) && !recentFailures.has(entry.anilistId),
+ );
+ const pendingRows = missingRows.filter((entry) =>
+ bootstrapInFlight.has(entry.anilistId),
+ );
+ const queueableRows = missingRows.filter(
+ (entry) => !bootstrapInFlight.has(entry.anilistId),
+ );
+
+ if (queueableRows.length)
+ ctx.waitUntil(
+ Promise.all(queueBootstrap(env, queueableRows)).catch((error) => {
+ if (!isMangadexIdConstraintConflict(error)) throw error;
+ }),
+ );
+
+ const data = Object.fromEntries(
+ existingRows.map((row) => [
+ String(row.anilist_id),
+ {
+ chapter: row.latest_en_chapter_number,
+ ...(row.latest_en_volume_text === null
+ ? {}
+ : { volumeText: row.latest_en_volume_text }),
},
- });
- }
+ ]),
+ );
+ const pending = [...new Set([...pendingRows, ...queueableRows].map((entry) => entry.anilistId))];
+
+ return jsonResponse(request, {
+ data,
+ ...(pending.length
+ ? {
+ pending,
+ retryAfterMs: pendingRetryMs(env),
+ }
+ : {}),
+ });
};
-addEventListener('fetch', (event) => {
- const request = event.request;
+const isAuthorisedSyncRequest = (request, env) => {
+ const token = env.MANGADEX_SYNC_TOKEN;
- try {
- switch (request.method) {
- case 'OPTIONS':
- event.respondWith(handleOptions(request));
+ if (!token) return isPrivateHostname(new URL(request.url).hostname);
+
+ return request.headers.get("Authorization") === `Bearer ${token}`;
+};
+
+const handleMangaSync = async (request, env) => {
+ if (!hasSupabaseConfig(env))
+ return jsonResponse(
+ request,
+ { error: "Supabase is not configured for the proxy worker." },
+ { status: 500 },
+ );
+
+ if (!isAuthorisedSyncRequest(request, env))
+ return jsonResponse(request, { error: "Forbidden" }, { status: 403 });
+
+ const result = await syncMangadexIndex(env);
+
+ return jsonResponse(request, { data: result });
+};
+
+export default {
+ async fetch(request, env, ctx) {
+ try {
+ const url = new URL(request.url);
- break;
+ if (request.method === "OPTIONS") return handleOptions(request);
- case 'GET':
- case 'HEAD':
- case 'POST':
- event.respondWith(handleRequest(request));
+ if (
+ url.pathname === "/manga/chapter-counts" &&
+ request.method === "POST"
+ )
+ return handleMangaChapterCounts(request, env, ctx);
- break;
+ if (url.pathname === "/manga/sync" && request.method === "POST")
+ return handleMangaSync(request, env);
- default:
- event.respondWith(async () => {
- return new Response(null, {
- status: 405,
- statusText: 'Method Not Allowed',
- });
- });
+ if (["GET", "HEAD", "POST"].includes(request.method))
+ return forwardProxyRequest(request);
- break;
+ return textResponse(request, null, {
+ status: 405,
+ statusText: "Method Not Allowed",
+ });
+ } catch (error) {
+ return jsonResponse(
+ request,
+ { error: error instanceof Error ? error.message : "Bad Request" },
+ { status: 400 },
+ );
}
- } catch {
- return new Response(null, {
- status: 400,
- statusText: 'Bad Request',
- });
- }
-});
+ },
+
+ async scheduled(_controller, env, ctx) {
+ if (!hasSupabaseConfig(env)) return;
+
+ ctx.waitUntil(syncMangadexIndex(env));
+ },
+};
diff --git a/apps/proxy/src/mangadex.js b/apps/proxy/src/mangadex.js
new file mode 100644
index 00000000..d2a42154
--- /dev/null
+++ b/apps/proxy/src/mangadex.js
@@ -0,0 +1,392 @@
+import {
+ getMangadexRowsByMangadexIds,
+ getSyncState,
+ upsertMangadexRows,
+ upsertSyncState,
+} from "./supabase.js";
+
+const MANGADEX_API_ORIGIN = "https://api.mangadex.org";
+const SYNC_STATE_NAME = "chapter_en";
+const ALL_CONTENT_RATINGS = ["safe", "suggestive", "erotica", "pornographic"];
+
+const sleep = (milliseconds) =>
+ new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+const appendQueryValues = (url, query) => {
+ for (const [key, rawValue] of Object.entries(query)) {
+ if (rawValue === undefined || rawValue === null) continue;
+
+ if (Array.isArray(rawValue)) {
+ for (const value of rawValue) url.searchParams.append(key, String(value));
+
+ continue;
+ }
+
+ url.searchParams.set(key, String(rawValue));
+ }
+};
+
+const mangadexUserAgent = (env) =>
+ env?.MANGADEX_USER_AGENT ||
+ "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
+
+const mangadexRequest = async (
+ env,
+ path,
+ { query = {}, retries = 1, retryDelay = 1500 } = {},
+) => {
+ const url = new URL(path, MANGADEX_API_ORIGIN);
+
+ appendQueryValues(url, query);
+
+ const response = await fetch(url, {
+ headers: {
+ Accept: "application/json",
+ "User-Agent": mangadexUserAgent(env),
+ },
+ });
+
+ if (response.status === 429 && retries > 0) {
+ const retryAfterHeader =
+ response.headers.get("Retry-After") ||
+ response.headers.get("X-RateLimit-Retry-After");
+ const retryAfterSeconds = Number.parseInt(retryAfterHeader || "", 10);
+ const retryAt =
+ Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 10_000
+ ? retryAfterSeconds * 1000 - Date.now()
+ : retryAfterSeconds * 1000;
+
+ await sleep(
+ Number.isFinite(retryAt) && retryAt > 0
+ ? Math.min(retryAt, 30_000)
+ : retryDelay,
+ );
+
+ return mangadexRequest(env, path, {
+ query,
+ retries: retries - 1,
+ retryDelay: retryDelay * 2,
+ });
+ }
+
+ if (!response.ok) {
+ const message = await response.text();
+
+ throw new Error(`MangaDex request failed (${response.status}): ${message}`);
+ }
+
+ return response.json();
+};
+
+const statusMap = {
+ FINISHED: "completed",
+ RELEASING: "ongoing",
+ HIATUS: "hiatus",
+ CANCELLED: "cancelled",
+};
+
+const normaliseTitle = (value) =>
+ String(value || "")
+ .trim()
+ .toLowerCase();
+
+const parseOptionalNumber = (value) => {
+ if (value === null || value === undefined || value === "") return null;
+
+ const number = Number.parseFloat(String(value));
+
+ return Number.isFinite(number) ? number : null;
+};
+
+const mangadexDateTime = (isoString) => {
+ const date = new Date(isoString);
+
+ if (Number.isNaN(date.getTime())) return "1970-01-01T00:00:00";
+
+ return date.toISOString().slice(0, 19);
+};
+
+const scoreSearchResult = (manga, title) => {
+ const attributes = manga.attributes || {};
+ const titleMap = attributes.title || {};
+ const altTitles = (attributes.altTitles || []).flatMap((item) =>
+ Object.values(item || {}),
+ );
+ const titles = [...Object.values(titleMap || {}), ...altTitles]
+ .map(normaliseTitle)
+ .filter(Boolean);
+ const candidate = normaliseTitle(title);
+
+ if (!candidate.length) return Number.MAX_SAFE_INTEGER;
+ if (titles.includes(candidate)) return 0;
+ if (titles.some((item) => item.includes(candidate))) return 1;
+ return 2;
+};
+
+const pickBestSearchResult = (results, title) =>
+ [...results].sort(
+ (left, right) =>
+ scoreSearchResult(left, title) - scoreSearchResult(right, title),
+ )[0];
+
+const titleCandidates = (manga) =>
+ [
+ manga.nativeTitle,
+ manga.englishTitle,
+ manga.romajiTitle,
+ manga.nativeTitle === "null" ? null : manga.nativeTitle,
+ manga.englishTitle === "null" ? null : manga.englishTitle,
+ manga.romajiTitle === "null" ? null : manga.romajiTitle,
+ ]
+ .filter(Boolean)
+ .map((title) => String(title).trim())
+ .filter((title, index, array) => array.indexOf(title) === index);
+
+const searchQueries = (manga, title) => {
+ const status = statusMap[manga.status];
+
+ return [
+ {
+ title,
+ ...(manga.startYear ? { year: manga.startYear } : {}),
+ ...(status ? { "status[]": [status] } : {}),
+ limit: 5,
+ },
+ {
+ title,
+ ...(status ? { "status[]": [status] } : {}),
+ limit: 5,
+ },
+ {
+ title,
+ ...(manga.startYear ? { year: manga.startYear } : {}),
+ limit: 5,
+ },
+ {
+ title,
+ limit: 5,
+ },
+ ];
+};
+
+const chooseLatestAggregateChapter = (volumes) => {
+ let latest = null;
+
+ for (const [volumeKey, volumeEntry] of Object.entries(volumes || {})) {
+ const chapters = volumeEntry?.chapters || {};
+
+ for (const chapterEntry of Object.values(chapters)) {
+ const chapterText = chapterEntry?.chapter || null;
+ const chapterNumber = parseOptionalNumber(chapterText);
+ const volumeText =
+ chapterEntry?.volume || volumeEntry?.volume || volumeKey || null;
+ const volumeNumber = parseOptionalNumber(volumeText);
+ const candidate = {
+ chapterText,
+ chapterNumber,
+ volumeText,
+ volumeNumber,
+ chapterId: chapterEntry?.id || null,
+ };
+
+ if (!latest) {
+ latest = candidate;
+
+ continue;
+ }
+
+ if (
+ candidate.chapterNumber !== null &&
+ (latest.chapterNumber === null ||
+ candidate.chapterNumber > latest.chapterNumber ||
+ (candidate.chapterNumber === latest.chapterNumber &&
+ (candidate.volumeNumber || -1) > (latest.volumeNumber || -1)))
+ ) {
+ latest = candidate;
+
+ continue;
+ }
+
+ if (
+ candidate.chapterNumber === null &&
+ latest.chapterNumber === null &&
+ String(candidate.chapterText || "").localeCompare(
+ String(latest.chapterText || ""),
+ undefined,
+ { numeric: true },
+ ) > 0
+ )
+ latest = candidate;
+ }
+ }
+
+ return latest;
+};
+
+const buildIndexedRow = (manga, mangadexId, aggregateResponse) => {
+ const now = new Date().toISOString();
+ const latest = chooseLatestAggregateChapter(aggregateResponse?.volumes);
+
+ return {
+ anilist_id: manga.anilistId,
+ mangadex_id: mangadexId,
+ latest_en_chapter_text: latest?.chapterText || null,
+ latest_en_chapter_number: latest?.chapterNumber || null,
+ latest_en_volume_text: latest?.volumeText || null,
+ latest_en_chapter_id: latest?.chapterId || null,
+ latest_chapter_updated_at: now,
+ last_seen_at: now,
+ last_indexed_at: now,
+ is_releasing: manga.status === "RELEASING",
+ created_at: manga.createdAt || now,
+ updated_at: now,
+ };
+};
+
+export const resolveMangadexId = async (env, manga) => {
+ const candidates = titleCandidates(manga);
+
+ for (const title of candidates) {
+ for (const query of searchQueries(manga, title)) {
+ const payload = await mangadexRequest(env, "/manga", { query });
+ const data = payload?.data || [];
+
+ if (!data.length) continue;
+
+ return pickBestSearchResult(data, title)?.id || data[0]?.id || null;
+ }
+ }
+
+ return null;
+};
+
+export const bootstrapManga = async (env, manga) => {
+ const mangadexId = await resolveMangadexId(env, manga);
+
+ if (!mangadexId) return null;
+
+ const aggregateResponse = await mangadexRequest(
+ env,
+ `/manga/${mangadexId}/aggregate`,
+ {
+ query: {
+ "translatedLanguage[]": ["en"],
+ },
+ },
+ );
+
+ return buildIndexedRow(manga, mangadexId, aggregateResponse);
+};
+
+const extractMangaIdFromChapter = (chapter) =>
+ chapter?.relationships?.find((relationship) => relationship.type === "manga")
+ ?.id || null;
+
+const currentCursor = () => new Date().toISOString();
+
+const rewindCursor = (isoString, seconds = 30) =>
+ new Date(
+ Math.max(new Date(isoString).getTime() - seconds * 1000, 0),
+ ).toISOString();
+
+export const syncMangadexIndex = async (
+ env,
+ { maxPages = 5, aggregateDelayMs = 250 } = {},
+) => {
+ const syncState = (await getSyncState(env, SYNC_STATE_NAME)) || {
+ cursor_updated_at: currentCursor(),
+ };
+ const trackedMangadexIds = new Set();
+ const cursor = syncState.cursor_updated_at;
+ let maxUpdatedAt = cursor;
+ let offset = 0;
+ let pageCount = 0;
+ let scannedChapterCount = 0;
+
+ while (pageCount < maxPages) {
+ const payload = await mangadexRequest(env, "/chapter", {
+ query: {
+ "translatedLanguage[]": ["en"],
+ "contentRating[]": ALL_CONTENT_RATINGS,
+ includeFutureUpdates: 0,
+ includeFuturePublishAt: 0,
+ updatedAtSince: mangadexDateTime(cursor),
+ "order[updatedAt]": "asc",
+ limit: 100,
+ offset,
+ },
+ });
+ const chapters = payload?.data || [];
+
+ if (!chapters.length) break;
+
+ for (const chapter of chapters) {
+ scannedChapterCount += 1;
+
+ const mangaId = extractMangaIdFromChapter(chapter);
+ const updatedAt = chapter?.attributes?.updatedAt;
+
+ if (mangaId) trackedMangadexIds.add(mangaId);
+ if (updatedAt && updatedAt > maxUpdatedAt) maxUpdatedAt = updatedAt;
+ }
+
+ pageCount += 1;
+
+ if (chapters.length < 100) break;
+
+ offset += chapters.length;
+ }
+
+ if (!trackedMangadexIds.size) {
+ await upsertSyncState(env, SYNC_STATE_NAME, maxUpdatedAt);
+
+ return {
+ cursor: maxUpdatedAt,
+ pageCount,
+ scannedChapterCount,
+ refreshedCount: 0,
+ };
+ }
+
+ const trackedRows = await getMangadexRowsByMangadexIds(env, [
+ ...trackedMangadexIds,
+ ]);
+ const refreshedRows = [];
+
+ for (const row of trackedRows) {
+ const aggregateResponse = await mangadexRequest(
+ env,
+ `/manga/${row.mangadex_id}/aggregate`,
+ {
+ query: {
+ "translatedLanguage[]": ["en"],
+ },
+ },
+ );
+
+ refreshedRows.push(
+ buildIndexedRow(
+ {
+ anilistId: row.anilist_id,
+ status: row.is_releasing ? "RELEASING" : "FINISHED",
+ createdAt: row.created_at,
+ },
+ row.mangadex_id,
+ aggregateResponse,
+ ),
+ );
+
+ if (aggregateDelayMs > 0) await sleep(aggregateDelayMs);
+ }
+
+ if (refreshedRows.length) await upsertMangadexRows(env, refreshedRows);
+
+ await upsertSyncState(env, SYNC_STATE_NAME, rewindCursor(maxUpdatedAt));
+
+ return {
+ cursor: rewindCursor(maxUpdatedAt),
+ pageCount,
+ scannedChapterCount,
+ refreshedCount: refreshedRows.length,
+ };
+};
diff --git a/apps/proxy/src/supabase.js b/apps/proxy/src/supabase.js
new file mode 100644
index 00000000..314caf59
--- /dev/null
+++ b/apps/proxy/src/supabase.js
@@ -0,0 +1,217 @@
+const INDEX_COLUMNS = [
+ "anilist_id",
+ "mangadex_id",
+ "latest_en_chapter_text",
+ "latest_en_chapter_number",
+ "latest_en_volume_text",
+ "latest_en_chapter_id",
+ "latest_chapter_updated_at",
+ "last_seen_at",
+ "last_indexed_at",
+ "is_releasing",
+ "created_at",
+ "updated_at",
+].join(",");
+
+const chunk = (items, size) => {
+ const chunks = [];
+
+ for (let index = 0; index < items.length; index += size)
+ chunks.push(items.slice(index, index + size));
+
+ return chunks;
+};
+
+const supabaseUrl = (env, path) =>
+ `${String(env.SUPABASE_URL || "").replace(/\/$/, "")}/rest/v1/${path}`;
+
+const supabaseHeaders = (env, headers = {}) => ({
+ apikey: env.SUPABASE_SERVICE_ROLE_KEY,
+ Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`,
+ ...headers,
+});
+
+const supabaseRequest = async (
+ env,
+ path,
+ {
+ method = "GET",
+ query = {},
+ body,
+ headers = {},
+ prefer = [],
+ returnNullOn404 = false,
+ } = {},
+) => {
+ const url = new URL(supabaseUrl(env, path));
+
+ for (const [key, value] of Object.entries(query))
+ if (value !== undefined && value !== null) url.searchParams.set(key, value);
+
+ const response = await fetch(url, {
+ method,
+ headers: supabaseHeaders(env, {
+ ...(body ? { "Content-Type": "application/json" } : {}),
+ ...(prefer.length ? { Prefer: prefer.join(",") } : {}),
+ ...headers,
+ }),
+ body: body ? JSON.stringify(body) : undefined,
+ });
+
+ if (returnNullOn404 && response.status === 404) return null;
+
+ if (!response.ok) {
+ const message = await response.text();
+
+ throw new Error(`Supabase request failed (${response.status}): ${message}`);
+ }
+
+ if (response.status === 204) return null;
+
+ const text = await response.text();
+
+ if (!text.length) return null;
+
+ return JSON.parse(text);
+};
+
+const buildInFilter = (values) => `in.(${values.join(",")})`;
+
+export const hasSupabaseConfig = (env) =>
+ Boolean(env.SUPABASE_URL && env.SUPABASE_SERVICE_ROLE_KEY);
+
+export const getMangadexRowsByAniListIds = async (env, anilistIds) => {
+ const rows = [];
+
+ for (const ids of chunk([...new Set(anilistIds)], 100))
+ rows.push(
+ ...((await supabaseRequest(env, "mangadex_manga_index", {
+ query: {
+ select: INDEX_COLUMNS,
+ anilist_id: buildInFilter(ids),
+ },
+ })) || []),
+ );
+
+ return rows;
+};
+
+export const getMangadexRowsByMangadexIds = async (env, mangadexIds) => {
+ const rows = [];
+
+ for (const ids of chunk([...new Set(mangadexIds)], 100))
+ rows.push(
+ ...((await supabaseRequest(env, "mangadex_manga_index", {
+ query: {
+ select: INDEX_COLUMNS,
+ mangadex_id: buildInFilter(ids),
+ },
+ })) || []),
+ );
+
+ return rows;
+};
+
+export const getMangadexFailureRowsByAniListIds = async (env, anilistIds) => {
+ const rows = [];
+
+ for (const ids of chunk([...new Set(anilistIds)], 100))
+ rows.push(
+ ...((await supabaseRequest(env, "mangadex_resolution_failures", {
+ query: {
+ select: "anilist_id,last_attempted_at,updated_at",
+ anilist_id: buildInFilter(ids),
+ },
+ returnNullOn404: true,
+ })) || []),
+ );
+
+ return rows;
+};
+
+export const upsertMangadexRows = async (env, rows) => {
+ if (!rows.length) return [];
+
+ return supabaseRequest(env, "mangadex_manga_index", {
+ method: "POST",
+ query: {
+ on_conflict: "anilist_id",
+ },
+ body: rows,
+ prefer: ["resolution=merge-duplicates", "return=representation"],
+ });
+};
+
+export const touchMangadexRows = async (env, rows) => {
+ if (!rows.length) return [];
+
+ return upsertMangadexRows(
+ env,
+ rows.map((row) => ({
+ anilist_id: row.anilist_id,
+ mangadex_id: row.mangadex_id,
+ is_releasing: row.is_releasing,
+ last_seen_at: row.last_seen_at,
+ updated_at: row.updated_at,
+ })),
+ );
+};
+
+export const upsertMangadexFailureRows = async (env, anilistIds) => {
+ if (!anilistIds.length) return null;
+
+ return supabaseRequest(env, "mangadex_resolution_failures", {
+ method: "POST",
+ query: {
+ on_conflict: "anilist_id",
+ },
+ body: [...new Set(anilistIds)].map((anilistId) => ({
+ anilist_id: anilistId,
+ last_attempted_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ })),
+ prefer: ["resolution=merge-duplicates", "return=minimal"],
+ returnNullOn404: true,
+ });
+};
+
+export const deleteMangadexFailureRows = async (env, anilistIds) => {
+ if (!anilistIds.length) return null;
+
+ return supabaseRequest(env, "mangadex_resolution_failures", {
+ method: "DELETE",
+ query: {
+ anilist_id: buildInFilter([...new Set(anilistIds)]),
+ },
+ prefer: ["return=minimal"],
+ returnNullOn404: true,
+ });
+};
+
+export const getSyncState = async (env, name) => {
+ const rows = await supabaseRequest(env, "mangadex_sync_state", {
+ query: {
+ select: "name,cursor_updated_at,updated_at",
+ name: `eq.${name}`,
+ limit: "1",
+ },
+ });
+
+ return rows?.[0] || null;
+};
+
+export const upsertSyncState = async (env, name, cursorUpdatedAt) =>
+ supabaseRequest(env, "mangadex_sync_state", {
+ method: "POST",
+ query: {
+ on_conflict: "name",
+ },
+ body: [
+ {
+ name,
+ cursor_updated_at: cursorUpdatedAt,
+ updated_at: new Date().toISOString(),
+ },
+ ],
+ prefer: ["resolution=merge-duplicates", "return=minimal"],
+ });
diff --git a/apps/proxy/wrangler.toml b/apps/proxy/wrangler.toml
index f65cc413..1115beeb 100644
--- a/apps/proxy/wrangler.toml
+++ b/apps/proxy/wrangler.toml
@@ -2,6 +2,9 @@ name = "due-proxy"
main = "src/index.js"
compatibility_date = "2023-12-18"
+[triggers]
+crons = ["* * * * *"]
+
# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
# Note: Use secrets to store sensitive data.
# Docs: https://developers.cloudflare.com/workers/platform/environment-variables