UNPKG

@animegarden/client

Version:

Anime Garden API client and utils

708 lines (693 loc) 22 kB
import { z } from 'zod'; import { serialize } from 'ohash'; import { fullToHalf, tradToSimple } from 'simptrad'; const DefaultBaseURL = "https://api.animes.garden/"; const SupportProviders = ["dmhy", "moe", "ani"]; const CollectionSchema = z.object({ hash: z.string().optional(), name: z.coerce.string().default(""), authorization: z.string(), filters: z.array( z.object({ name: z.coerce.string().default(""), searchParams: z.string(), // filters provider: z.enum(SupportProviders).optional(), duplicate: z.boolean().optional(), types: z.array(z.string()).optional(), after: z.coerce.date().optional(), before: z.coerce.date().optional(), fansubs: z.array(z.string()).optional(), publishers: z.array(z.string()).optional(), subjects: z.array(z.number()).optional(), search: z.array(z.string()).optional(), include: z.array(z.string()).optional(), keywords: z.array(z.string()).optional(), exclude: z.array(z.string()).optional() }).passthrough() ).min(1).max(50) }); function parseCollection(collection) { const parsed = CollectionSchema.safeParse(collection); if (parsed.success) { return parsed.data; } else { return void 0; } } async function hashCollection(collection) { const sorted = [...collection.filters]; sorted.sort((lhs, rhs) => lhs.searchParams.localeCompare(rhs.searchParams)); const filters = sorted.map((f) => { const r = { ...f }; delete r.name; delete r.searchParams; delete r.resources; delete r.complete; return r; }); const body = serialize(filters); const encoder = new TextEncoder(); const data = encoder.encode(body); const digest = await crypto.subtle.digest("SHA-1", data); const hashArray = Array.from(new Uint8Array(digest)); const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); return hashHex; } function normalizeTitle(title) { return fullToHalf(tradToSimple(title), { punctuation: true }); } function makeResourcesFilter(filter) { const conds = []; const { provider, include, keywords, exclude, subjects, search, publishers, fansubs, types, before, after } = filter; if (provider) { conds.push((r) => r.provider === provider); } if (include && include.length > 0 || keywords && keywords.length > 0 || exclude && exclude.length > 0) { conds.push((r) => { const title = normalizeTitle(r.title).toLowerCase(); return (include?.map((i) => normalizeTitle(i).toLocaleLowerCase()).some((i) => title.indexOf(i.toLocaleLowerCase()) !== -1) ?? true) && (keywords?.map((i) => normalizeTitle(i).toLocaleLowerCase()).every((i) => title.indexOf(i) !== -1) ?? true) && (exclude?.map((i) => normalizeTitle(i).toLocaleLowerCase()).every((i) => title.indexOf(i) === -1) ?? true); }); } if (subjects && subjects.length > 0) { conds.push((r) => subjects.some((s) => r.subjectId === s)); } if (search && search.length > 0) ; if (publishers && publishers.length > 0 || fansubs && fansubs.length > 0) { conds.push( (r) => (publishers?.some((p) => r.publisher.name === p) ?? false) || (fansubs?.some((p) => r.fansub?.name === p) ?? false) ); } if (types && types.length > 0) { conds.push((r) => types.some((t) => r.type === t)); } if (before) { const t = before.getTime(); conds.push((r) => r.createdAt.getTime() <= t); } if (after) { const t = after.getTime(); conds.push((r) => r.createdAt.getTime() >= t); } return (res) => { return conds.every((c) => c(res)); }; } const dateLike = z.union([ z.null(), z.undefined(), z.coerce.number().transform((n) => new Date(n)), z.coerce.date() ]); const stringArray = z.union([z.string().transform((s) => [s]), z.array(z.string())]); const providerEnum = z.enum(SupportProviders); const UrlSearchSchema = { provider: providerEnum.optional(), duplicate: z.union([z.null(), z.undefined(), z.coerce.boolean()]).optional(), page: z.union([z.null(), z.undefined(), z.coerce.number()]).optional(), pageSize: z.union([z.null(), z.undefined(), z.coerce.number()]).optional(), fansub: z.string().array().optional(), publisher: z.string().array().optional(), type: z.string().array().optional(), before: dateLike.optional(), after: dateLike.optional(), subject: z.coerce.number().array().optional(), search: z.string().array().optional(), include: z.string().array().optional(), keyword: z.string().array().optional(), exclude: z.string().array().optional() }; const BodySchema = { provider: providerEnum.optional(), duplicate: z.union([z.null(), z.undefined(), z.coerce.boolean()]).optional(), page: z.coerce.number().optional(), pageSize: z.coerce.number().optional(), fansub: z.string().optional(), fansubs: z.string().array().optional(), publisher: z.string().optional(), publishers: z.string().array().optional(), type: z.string().optional(), types: z.string().array().optional(), before: dateLike.optional(), after: dateLike.optional(), subject: z.coerce.number().optional(), subjects: z.coerce.number().array().optional(), search: stringArray.optional(), include: stringArray.optional(), keywords: stringArray.optional(), exclude: stringArray.optional() }; function parseURLSearch(params, body) { const res1 = params ? { provider: UrlSearchSchema.provider.safeParse(params.get("provider")).data, duplicate: UrlSearchSchema.duplicate.safeParse(params.get("duplicate")).data, page: UrlSearchSchema.page.safeParse(params.get("page")).data, pageSize: UrlSearchSchema.pageSize.safeParse(params.get("pageSize")).data, fansub: UrlSearchSchema.fansub.safeParse(params.getAll("fansub")).data, publisher: UrlSearchSchema.publisher.safeParse(params.getAll("publisher")).data, type: UrlSearchSchema.type.safeParse(params.getAll("type")).data, before: UrlSearchSchema.before.safeParse(params.get("before")).data, after: UrlSearchSchema.after.safeParse(params.get("after")).data, subject: UrlSearchSchema.subject.safeParse(params.getAll("subject")).data, search: UrlSearchSchema.search.safeParse(params.getAll("search")).data, include: UrlSearchSchema.include.safeParse(params.getAll("include")).data, keyword: UrlSearchSchema.keyword.safeParse(params.getAll("keyword")).data, exclude: UrlSearchSchema.exclude.safeParse(params.getAll("exclude")).data } : void 0; const res2 = body ? { provider: BodySchema.provider.safeParse(body.provider).data, duplicate: BodySchema.duplicate.safeParse(body.duplicate).data, page: BodySchema.page.safeParse(body.page).data, pageSize: BodySchema.pageSize.safeParse(body.pageSize).data, fansub: BodySchema.fansub.safeParse(body.fansub).data, fansubs: BodySchema.fansubs.safeParse(body.fansubs).data, publisher: BodySchema.publisher.safeParse(body.publisher).data, publishers: BodySchema.publishers.safeParse(body.publishers).data, type: BodySchema.type.safeParse(body.type).data, types: BodySchema.types.safeParse(body.types).data, before: BodySchema.before.safeParse(body.before).data, after: BodySchema.after.safeParse(body.after).data, subject: BodySchema.subject.safeParse(body.subject).data, subjects: BodySchema.subjects.safeParse(body.subjects).data, search: BodySchema.search.safeParse(body.search).data, include: BodySchema.include.safeParse(body.include).data, keywords: BodySchema.keywords.safeParse(body.keywords).data, exclude: BodySchema.exclude.safeParse(body.exclude).data } : void 0; const filter = { page: res1?.page ?? res2?.page ?? 1, pageSize: res1?.pageSize ?? res2?.pageSize ?? 100 }; const isNaN = (d) => d === void 0 || d === null || Number.isNaN(d); if (isNaN(filter.page) || filter.page < 1) { filter.page = 1; } else { filter.page = Math.round(filter.page); } if (isNaN(filter.pageSize) || filter.pageSize < 1 || filter.pageSize > 1e3) { filter.pageSize = 100; } else { filter.pageSize = Math.round(filter.pageSize); } if (res2?.provider) { filter.duplicate = res1?.duplicate ?? res2?.duplicate ?? true; filter.provider = res2.provider; } else if (res1?.provider) { filter.provider = res1.provider; filter.duplicate = res2?.duplicate ?? res1?.duplicate ?? true; } if (res2?.fansub) { filter.fansubs = [res2.fansub]; } else if (res2?.fansubs && res2.fansubs.length > 0) { filter.fansubs = res2.fansubs; } else if (res1?.fansub && res1.fansub.length > 0) { filter.fansubs = res1.fansub; } if (filter.fansubs) { filter.fansubs = [...new Set(filter.fansubs)]; } if (res2?.publisher) { filter.publishers = [res2.publisher]; } else if (res2?.publishers && res2.publishers.length > 0) { filter.publishers = res2.publishers; } else if (res1?.publisher && res1.publisher.length > 0) { filter.publishers = res1.publisher; } if (res2?.type) { filter.types = [res2.type]; } else if (res2?.types && res2.types.length > 0) { filter.types = res2.types; } else if (res1?.type && res1.type.length > 0) { filter.types = res1.type; } if (filter.types) { filter.types = [...new Set(filter.types)]; } if (res2?.before || res1?.before) { filter.before = res2?.before || res1?.before || void 0; } if (res2?.after || res1?.after) { filter.after = res2?.after || res1?.after || void 0; } if (res2?.subject) { filter.subjects = [res2.subject]; } else if (res2?.subjects && res2?.subjects.length > 0) { filter.subjects = res2.subjects; } else if (res1?.subject && res1.subject.length > 0) { filter.subjects = res1.subject; } if (res2?.search && res2.search.length > 0) { filter.search = res2.search; } else if (res1?.search && res1.search.length > 0) { filter.search = res1.search; } if (filter.search) { filter.search = [...new Set(filter.search)]; } if (res2?.include && res2.include.length > 0) { filter.include = res2.include; } else if (res1?.include && res1.include.length > 0) { filter.include = res1.include; } if (filter.include) { filter.include = [...new Set(filter.include)]; } if (res2?.keywords && res2.keywords.length > 0) { filter.keywords = res2.keywords; } else if (res1?.keyword && res1.keyword.length > 0) { filter.keywords = res1.keyword; } if (filter.keywords) { filter.keywords = [...new Set(filter.keywords)]; } if (res2?.exclude && res2.exclude.length > 0) { filter.exclude = res2.exclude; } else if (res1?.exclude && res1.exclude.length > 0) { filter.exclude = res1.exclude; } if (filter.exclude) { filter.exclude = [...new Set(filter.exclude)]; } if (filter.search) { delete filter.include; } return filter; } function stringifyURLSearch(options) { const params = new URLSearchParams(); const { page, pageSize, duplicate, after, before } = options; if (page) { params.set("page", "" + page); } if (pageSize) { params.set("pageSize", "" + pageSize); } if (duplicate) { params.set("duplicate", "true"); } if (after) { params.set("after", "" + after.getTime()); } if (before) { params.set("before", "" + before.getTime()); } const { provider } = options; if (provider) { params.set("provider", provider); } const { search, include, keywords, exclude, subject, subjects } = options; if (subject) { params.set("subject", "" + subject); } else if (subjects) { for (const subject2 of new Set(subjects)) { params.append("subject", "" + subject2); } } if (search && search.length > 0) { for (const word of new Set(search)) { params.append("search", word); } for (const word of keywords ? new Set(keywords) : []) { params.append("keyword", word); } for (const word of exclude ? new Set(exclude) : []) { params.append("exclude", word); } } else if (include && include.length > 0) { for (const word of include ? new Set(include) : []) { params.append("include", word); } for (const word of keywords ? new Set(keywords) : []) { params.append("keyword", word); } for (const word of exclude ? new Set(exclude) : []) { params.append("exclude", word); } } else { for (const word of keywords ? new Set(keywords) : []) { params.append("keyword", word); } for (const word of exclude ? new Set(exclude) : []) { params.append("exclude", word); } } const { type, types } = options; if (type) { params.set("type", type); } else if (types) { for (const type2 of new Set(types)) { params.append("type", type2); } } const { fansub, fansubs } = options; if (fansub) { params.set("fansub", fansub); } else if (fansubs) { for (const fansub2 of new Set(fansubs)) { params.append("fansub", fansub2); } } const { publisher, publishers } = options; if (publisher) { params.set("publisher", publisher); } else if (publishers) { for (const publisher2 of new Set(publishers)) { params.append("publisher", publisher2); } } params.sort(); return params; } function transformResourceHref(provider, href) { if (!href) return void 0; switch (provider) { case "dmhy": return `https://share.dmhy.org/topics/view/${href}`; case "moe": return `https://bangumi.moe/torrent/${href}`; case "ani": return href; default: return void 0; } } function transformPublisherHref(provider, publisherId) { if (!publisherId) return void 0; switch (provider) { case "dmhy": return `https://share.dmhy.org/topics/list/user_id/${publisherId}`; case "moe": return `https://bangumi.moe/tag/${publisherId}`; case "ani": return "https://aniopen.an-i.workers.dev/"; default: return void 0; } } function transformFansubHref(provider, fansubId) { if (!fansubId) return void 0; switch (provider) { case "dmhy": return `https://share.dmhy.org/topics/list/team_id/${fansubId}`; case "moe": return `https://bangumi.moe/tag/${fansubId}`; case "ani": return "https://aniopen.an-i.workers.dev/"; default: return void 0; } } async function retryFn(fn, count, signal) { if (count < 0) { count = Number.MAX_SAFE_INTEGER; } let e; for (let i = 0; i <= count; i++) { try { return await fn(); } catch (err) { e = err; if (signal?.aborted) { break; } } } throw e; } function sleep(timeout) { return new Promise((res) => { setTimeout(() => res(), timeout); }); } const version = "0.5.2"; async function fetchAPI(path, init = void 0, options = {}) { const { fetch = globalThis.fetch, baseURL = DefaultBaseURL, retry = 0 } = options; const url = new URL(path.replace(/^\/+/g, ""), baseURL); const headers = new Headers(options.headers); headers.set(`x-trace-id`, crypto.randomUUID()); if (!headers.get("user-agent")) { headers.set(`user-agent`, `animegarden@${version}`); } return await retryFn( async () => { const signal = options.timeout && options.timeout > 0 ? options.signal ? AbortSignal.any([AbortSignal.timeout(options.timeout), options.signal]) : AbortSignal.timeout(options.timeout) : options.signal; const payload = { ...init, headers, signal }; if (options?.hooks?.prefetch) { await options.hooks.prefetch(url.toString(), payload); } let error; const resp = await fetch(url.toString(), payload).catch((_error) => { error = _error; return void 0; }); if (resp) { if (options?.hooks?.postfetch) { await options.hooks.postfetch(url.toString(), payload, resp); } if (resp.ok) { return await resp.json(); } else { if (resp.status === 429) { await sleep(16 * 1e3); } throw new Error(`${resp.status} ${resp.statusText} ${url.toString()}`, { cause: resp }); } } else { if (error?.name === "AbortError") { throw error; } if (error?.name === "TimeoutError") { await (options.hooks?.timeout ? options.hooks?.timeout() : sleep(100)); throw error; } if (error instanceof Error) { throw error; } else { throw new Error(error); } } }, retry, options.signal ); } async function fetchStatus(options = {}) { const resp = await fetchAPI("/", void 0, options).catch(() => void 0); if (resp) { return { ok: true, timestamp: resp.timestamp, providers: resp.providers }; } else { return { ok: false, timestamp: void 0, providers: void 0 }; } } async function generateCollection(collection, options = {}) { const body = JSON.stringify({ ...collection, filters: collection.filters.map((f) => ({ ...f, resources: void 0, complete: void 0 })) }); const resp = await fetchAPI( "collection", { method: "PUT", body }, options ).catch((_err) => { return void 0; }); if (resp) { return { ok: true, ...collection, createdAt: resp.createdAt, hash: resp.hash, timestamp: new Date(resp.timestamp) }; } return void 0; } async function fetchCollection(hash, options = {}) { const resp = await fetchAPI( `collection/${hash}`, { method: "GET" }, options ).catch((_err) => { return void 0; }); if (resp) { return { ok: true, ...resp, timestamp: new Date(resp.timestamp) }; } return void 0; } async function fetchResourceDetail(provider, href, options = {}) { const resp = await fetchAPI(`detail/${provider}/${href}`, void 0, options).catch( (_err) => { return void 0; } ); return { ok: resp && resp.resource !== void 0 && resp.timestamp !== void 0, resource: resp?.resource, detail: resp?.detail, timestamp: resp?.timestamp ? new Date(resp.timestamp) : void 0 }; } async function fetchResources(options = {}) { const searchParams = stringifyURLSearch(options); if (options.tracker) { searchParams.set("tracker", "true"); } if (options.metadata) { searchParams.set("metadata", "true"); } if (options.count !== void 0 && options.count !== null) { searchParams.set("pageSize", "1000"); const count = options.count < 0 ? Number.MAX_SAFE_INTEGER : options.count; const map = /* @__PURE__ */ new Map(); let aborted = false; let timestamp; let complete = false; let filter = void 0; for (let page = 1; map.size < count && !complete; page++) { try { if (options.signal?.aborted) { aborted = true; break; } const resp = await fetchPage(page); if (!resp) { aborted = true; break; } complete = resp.complete; if (!timestamp) { timestamp = resp.timestamp; } if (resp.filter) { filter = resp.filter; } if (resp.resources.length === 0) { break; } const newRes = []; for (const r of resp.resources) { if (!map.has(r.href)) { map.set(r.href, r); newRes.push(r); } } await options.progress?.(newRes, { url: searchParams.toString(), searchParams, page }); } catch (error) { if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) { aborted = true; break; } else { throw error; } } } if (filter) { delete filter["page"]; delete filter["pageSize"]; } return { ok: !aborted, resources: uniq([...map.values()]), complete: aborted ? false : complete, filter, timestamp }; } else { const resp = await fetchPage(options.page ?? 1); if (!resp) { return { ok: false, resources: [], complete: false, filter: void 0, timestamp: void 0 }; } const resources = uniq(resp.resources); await options.progress?.(resources, { url: searchParams.toString(), searchParams, page: 1 }); return { ok: true, resources, complete: resp.complete ?? false, filter: resp.filter, timestamp: resp.timestamp }; } async function fetchPage(page) { searchParams.set("page", "" + page); const r = await fetchAPI("resources?" + searchParams.toString(), void 0, options); const timestamp = new Date(r.timestamp); if (!isNaN(timestamp.getTime())) { for (const res of r.resources) { res.createdAt = new Date(res.createdAt); res.fetchedAt = new Date(res.fetchedAt); } if (r.filter.before) { r.filter.before = new Date(r.filter.before); } if (r.filter.after) { r.filter.after = new Date(r.filter.after); } return { resources: r.resources, complete: r.complete, filter: r.filter, timestamp }; } else { throw new Error(`Invalid response /resource?${searchParams.toString()}`); } } function uniq(resources) { const map = /* @__PURE__ */ new Map(); for (const r of resources) { if (!map.has(r.href)) { map.set(r.href, r); } } return [...map.values()].sort((lhs, rhs) => rhs.createdAt.getTime() - lhs.createdAt.getTime()); } } export { DefaultBaseURL, SupportProviders, fetchAPI, fetchCollection, fetchResourceDetail, fetchResources, fetchStatus, generateCollection, hashCollection, makeResourcesFilter, normalizeTitle, parseCollection, parseURLSearch, stringifyURLSearch, transformFansubHref, transformPublisherHref, transformResourceHref };