From 9a6740c3c667a312b5db61e74411ab5a1bf4e6dc Mon Sep 17 00:00:00 2001 From: Patrick Dundas Date: Sat, 8 Aug 2026 22:18:59 -0600 Subject: [PATCH] feat(tracker-adapters): add TBDev adapter and DocsPedia registry entry TBDev (tbdev.org) is the classic PHP tracker codebase and exposes no API, so stats are scraped from the logged-in /userdetails.php page. Auth is by session cookie rather than username/password, and that is forced rather than chosen: TBDev's login form carries a CAPTCHA, so the login-and- collect-Set-Cookie flow the TorrentLeech adapter uses cannot work. The cookies are long-lived, so capturing them once from a browser is the practical way in. The uid is derived from whichever cookie name ends in `uid`, since the prefix is site-specific (DocsPedia uses doccook_uid, stock TBDev uses uid). Byte units get their own parser. TBDev's mksize() divides by 1024 at every step but labels the result kB/MB/GB/TB, so a TBDev "1.00 GB" is one GiB. Routing that through parseBytes unchanged would read it against the decimal table and under-report by ~7% at GB and ~10% at TB - and parseBytes has no lowercase "kB" entry at all, so the form TBDev actually emits would have thrown. hitAndRuns is null, not 0: stock TBDev has no hit-and-run accounting, and 0 would assert a clean record the tracker cannot vouch for. Verified end-to-end against a live DocsPedia account: HTTP 200, username, class and karma parsed correctly. --- package.json | 2 +- src/data/trackers/docspedia.ts | 59 ++++++ src/data/trackers/index.ts | 3 + src/lib/adapters/constants.ts | 2 + src/lib/adapters/index.ts | 2 + src/lib/adapters/tbdev.test.ts | 160 ++++++++++++++++ src/lib/adapters/tbdev.ts | 330 +++++++++++++++++++++++++++++++++ 7 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 src/data/trackers/docspedia.ts create mode 100644 src/lib/adapters/tbdev.test.ts create mode 100644 src/lib/adapters/tbdev.ts diff --git a/package.json b/package.json index 24dbcea6..3dc925e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "private-tracker-tracker", - "version": "2.8.9-homelab.5", + "version": "2.8.9-homelab.6", "description": "Self-hosted dashboard for monitoring private tracker stats over time", "license": "GPL-3.0", "repository": { diff --git a/src/data/trackers/docspedia.ts b/src/data/trackers/docspedia.ts new file mode 100644 index 00000000..9455ff75 --- /dev/null +++ b/src/data/trackers/docspedia.ts @@ -0,0 +1,59 @@ +// src/data/trackers/docspedia.ts + +import type { TrackerRegistryEntry } from "@/data/tracker-registry" + +export const docspedia: TrackerRegistryEntry = { + // ── Identity ──────────────────────────────────────────────────────── + slug: "docspedia", + name: "DocsPedia", + abbreviation: "DP", + url: "https://www.docspedia.world", + description: + "Invite-only tracker for documents, ebooks and educational material. Runs TBDev with an XBT tracker backend.", + + // ── Platform & API ────────────────────────────────────────────────── + platform: "tbdev", + apiPath: "/userdetails.php", + + // ── Content ───────────────────────────────────────────────────────── + specialty: "Books", + contentCategories: ["Books"], + language: "English", + + // ── Visual ────────────────────────────────────────────────────────── + color: "#8b6f47", + logo: "", + + // ── External Links ────────────────────────────────────────────────── + trackerHubSlug: "", + statusPageUrl: "", + + // ── Community ─────────────────────────────────────────────────────── + userClasses: [], + releaseGroups: [], + bannedGroups: [], + notableMembers: [], + + // ── Rules ─────────────────────────────────────────────────────────── + rules: { + // "Low ratio may result in severe consequences, including banning in extreme + // cases (low ratio <0.4 and more than 25GB downloads)." The 0.4 figure is the + // ban threshold, not a target — the site also asks for 1:1 on every torrent. + minimumRatio: 0.4, + // "Torrents must be seeded at least 48 hours or until ratio 1:1!" + seedTimeHours: 48, + // "Accounts without activity in the first 28 days will be deleted automatically + // by the system." Stated for the first 28 days; treated as a standing interval + // because the downside of logging in too often is nil. + loginIntervalDays: 28, + }, + + // ── Status ────────────────────────────────────────────────────────── + warning: false, + warningNote: "", + + // ── Flags ─────────────────────────────────────────────────────────── + draft: false, + supportsTransitPapers: false, + profileUrlPattern: "/userdetails.php?id={userId}", +} diff --git a/src/data/trackers/index.ts b/src/data/trackers/index.ts index 41a3df1e..7625e27d 100644 --- a/src/data/trackers/index.ts +++ b/src/data/trackers/index.ts @@ -21,6 +21,7 @@ export * from "./cinemaz" export * from "./concertos" export * from "./darkpeers" export * from "./digitalcore" +export * from "./docspedia" export * from "./empornium" export * from "./exoticaz" export * from "./fearnopeer" @@ -74,6 +75,7 @@ import { cinemaz } from "./cinemaz" import { concertos } from "./concertos" import { darkpeers } from "./darkpeers" import { digitalcore } from "./digitalcore" +import { docspedia } from "./docspedia" import { empornium } from "./empornium" import { exoticaz } from "./exoticaz" import { fearnopeer } from "./fearnopeer" @@ -156,6 +158,7 @@ export const ALL_TRACKERS: TrackerRegistryEntry[] = [ seedpool, skipthecommercials, sportscult, + docspedia, torrentleech, tvvault, uhdbits, diff --git a/src/lib/adapters/constants.ts b/src/lib/adapters/constants.ts index a40875a2..3838e854 100644 --- a/src/lib/adapters/constants.ts +++ b/src/lib/adapters/constants.ts @@ -12,6 +12,7 @@ export const VALID_PLATFORM_TYPES = [ "btn", "iptorrents", "torrentleech", + "tbdev", ] as const export type PlatformType = (typeof VALID_PLATFORM_TYPES)[number] @@ -28,4 +29,5 @@ export const DEFAULT_API_PATHS: Record = { btn: "https://api.broadcasthe.net/", iptorrents: "/profile", torrentleech: "/profile", + tbdev: "/userdetails.php", } diff --git a/src/lib/adapters/index.ts b/src/lib/adapters/index.ts index 97843f35..a7dd0ff1 100644 --- a/src/lib/adapters/index.ts +++ b/src/lib/adapters/index.ts @@ -10,6 +10,7 @@ import { GGnAdapter } from "./ggn" import { IptorrentsAdapter } from "./iptorrents" import { MamAdapter } from "./mam" import { NebulanceAdapter } from "./nebulance" +import { TbdevAdapter } from "./tbdev" import { TorrentleechAdapter } from "./torrentleech" import type { FetchOptions, TrackerAdapter } from "./types" import { Unit3dAdapter } from "./unit3d" @@ -26,6 +27,7 @@ const adapters: Record = { iptorrents: new IptorrentsAdapter(), mam: new MamAdapter(), nebulance: new NebulanceAdapter(), + tbdev: new TbdevAdapter(), torrentleech: new TorrentleechAdapter(), unit3d: new Unit3dAdapter(), } diff --git a/src/lib/adapters/tbdev.test.ts b/src/lib/adapters/tbdev.test.ts new file mode 100644 index 00000000..9dbace94 --- /dev/null +++ b/src/lib/adapters/tbdev.test.ts @@ -0,0 +1,160 @@ +// src/lib/adapters/tbdev.test.ts + +import { describe, expect, it } from "vitest" +import { parseTbdevBytes, parseTbdevCredentials, parseTbdevProfile } from "./tbdev" + +// Trimmed from a real DocsPedia.world /userdetails.php response (TBDev), 2026-08-08. +// Secrets scrubbed; the Passkey row is empty on the real page too. +const PROFILE_HTML = ` + +DocsPedia.world :: Details for evergreen99 + +
+
Welcome back evergreen99 [User]
+
200.0 + Invites 1
+
+ Ratio 1.25 + Seeding: 12 + Leeching: 3 +
+
+

evergreen99

+ + + + + + + + + +
Join dateJul 28 2026, 02:40 AM
Last seen< 1 minute ago
Passkey
Uploaded12.50 GB
Downloaded10.00 GB
Buffer2.50 GB
ClassPower User
Karma points200
` + +// The account as it actually stood on 2026-08-08: brand new, everything zero. +const FRESH_HTML = PROFILE_HTML.replace("12.50 GB", "0.00 kB") + .replace("10.00 GB", "0.00 kB") + .replace("2.50 GB", "0.00 kB") + .replace(" 1.25", " 0.00") + .replace("Seeding: 12", "Seeding: 0") + .replace("Leeching: 3", "Leeching: 0") + .replace("Power User", "User") + +describe("parseTbdevBytes", () => { + it("treats TBDev's decimal labels as binary units", () => { + // TBDev's mksize() divides by 1024 but writes "GB", so 1.00 GB is one GiB. + expect(parseTbdevBytes("1.00 GB")).toBe(1073741824n) + expect(parseTbdevBytes("1.00 MB")).toBe(1048576n) + expect(parseTbdevBytes("1.00 TB")).toBe(1099511627776n) + }) + + it("accepts the lowercase kB that parseBytes rejects outright", () => { + expect(parseTbdevBytes("1.00 kB")).toBe(1024n) + expect(parseTbdevBytes("0.00 kB")).toBe(0n) + }) + + it("handles nbsp separators, thousands commas and plain bytes", () => { + expect(parseTbdevBytes("512 B")).toBe(512n) + expect(parseTbdevBytes("1,024.00 kB")).toBe(1048576n) + }) + + it("returns zero for empty input and throws on garbage", () => { + expect(parseTbdevBytes("")).toBe(0n) + expect(() => parseTbdevBytes("lots")).toThrow(/Invalid TBDev byte format/) + expect(() => parseTbdevBytes("5 parsecs")).toThrow(/Unknown TBDev unit/) + }) +}) + +describe("parseTbdevCredentials", () => { + it("derives the user id from a site-prefixed uid cookie", () => { + const creds = parseTbdevCredentials( + JSON.stringify({ cookie: "doccook_uid=25971; doccook_pass=abc; PHPSESSID=xyz" }) + ) + expect(creds.userId).toBe("25971") + expect(creds.cookie).toContain("PHPSESSID=xyz") + }) + + it("also handles a bare uid cookie from stock TBDev", () => { + expect(parseTbdevCredentials(JSON.stringify({ cookie: "uid=42; pass=abc" })).userId).toBe("42") + }) + + it("prefers an explicit userId over the cookie", () => { + const creds = parseTbdevCredentials( + JSON.stringify({ cookie: "doccook_uid=25971; pass=abc", userId: "999" }) + ) + expect(creds.userId).toBe("999") + }) + + it("does not mistake other cookies for the uid", () => { + // `doccook_hash` ends in neither `uid` nor `_uid`; a loose match would grab it. + expect(() => + parseTbdevCredentials(JSON.stringify({ cookie: "doccook_hash=deadbeef; PHPSESSID=xyz" })) + ).toThrow(/could not determine userId/) + }) + + it("rejects empty, malformed and non-numeric input", () => { + expect(() => parseTbdevCredentials("not json")).toThrow(/must be a JSON object/) + expect(() => parseTbdevCredentials(JSON.stringify({ cookie: " " }))).toThrow( + /cookie cannot be empty/ + ) + expect(() => + parseTbdevCredentials(JSON.stringify({ cookie: "uid=1", userId: "abc" })) + ).toThrow(/must be numeric/) + }) +}) + +describe("parseTbdevProfile", () => { + it("reads the full profile", () => { + const stats = parseTbdevProfile(PROFILE_HTML) + expect(stats.username).toBe("evergreen99") + expect(stats.group).toBe("Power User") + expect(stats.uploadedBytes).toBe(13421772800n) // 12.50 GiB + expect(stats.downloadedBytes).toBe(10737418240n) // 10.00 GiB + expect(stats.bufferBytes).toBe(2684354560n) + expect(stats.ratio).toBeCloseTo(1.25) + expect(stats.seedingCount).toBe(12) + expect(stats.leechingCount).toBe(3) + expect(stats.seedbonus).toBe(200) + }) + + it("reports hitAndRuns as unknown, not zero", () => { + // Stock TBDev has no HnR accounting; 0 would falsely assert a clean record. + expect(parseTbdevProfile(PROFILE_HTML).hitAndRuns).toBeNull() + }) + + it("parses the real day-one account without throwing on 0.00 kB", () => { + const stats = parseTbdevProfile(FRESH_HTML) + expect(stats.uploadedBytes).toBe(0n) + expect(stats.downloadedBytes).toBe(0n) + expect(stats.ratio).toBe(0) + expect(stats.seedingCount).toBe(0) + expect(stats.group).toBe("User") + expect(stats.seedbonus).toBe(200) + }) + + it("falls back to the page title when the h1 is missing", () => { + const noH1 = PROFILE_HTML.replace( + "

evergreen99

", + "evergreen99" + ) + expect(parseTbdevProfile(noH1).username).toBe("evergreen99") + }) + + it("computes ratio from bytes when the header status bar is absent", () => { + const noHeader = PROFILE_HTML.replace(/
[\s\S]*?<\/div>\n { + const login = `DocsPedia.world :: Login + ` + expect(() => parseTbdevProfile(login)).toThrow(/Session expired/) + }) + + it("throws when the details table is missing entirely", () => { + expect(() => parseTbdevProfile("

nothing here

")).toThrow( + /Could not find profile stats/ + ) + }) +}) diff --git a/src/lib/adapters/tbdev.ts b/src/lib/adapters/tbdev.ts new file mode 100644 index 00000000..adbd4082 --- /dev/null +++ b/src/lib/adapters/tbdev.ts @@ -0,0 +1,330 @@ +// src/lib/adapters/tbdev.ts +// +// Functions: parseTbdevCredentials, parseTbdevBytes, parseTbdevProfile, TbdevAdapter +// +// TBDev is the classic PHP tracker codebase (tbdev.org). It exposes NO API, so stats +// come from scraping the logged-in /userdetails.php page. +// +// Auth is by session cookie rather than username/password, and that is deliberate: +// TBDev's login form carries a CAPTCHA, so a programmatic login is not possible. The +// cookies (TBDev sets a `*_uid` / `*_pass` / `*_hash` trio, prefix varies per site, +// plus PHPSESSID) are long-lived, so capturing them once from a browser is the +// practical way in. + +import { type HTMLElement as ParsedElement, parse as parseHtml } from "node-html-parser" +import { computeBufferBytes } from "@/lib/data-transforms" +import { classifyFetchError, sanitizeNetworkError } from "@/lib/error-utils" +import { ADAPTER_FETCH_TIMEOUT_MS } from "@/lib/limits" +import { parseBytes } from "@/lib/parser" +import type { DebugApiCall, FetchOptions, TrackerAdapter, TrackerStats } from "./types" + +// --------------------------------------------------------------------------- +// Credential handling +// --------------------------------------------------------------------------- + +export interface TbdevCredentials { + /** Raw Cookie header value, copied verbatim from a logged-in browser session. */ + cookie: string + /** Numeric user id for /userdetails.php?id=… */ + userId: string +} + +/** + * Extract the user id from a TBDev cookie string. + * + * The cookie NAME is site-specific — DocsPedia uses `doccook_uid`, stock TBDev uses + * `uid`, others prefix differently — so match on the suffix rather than a fixed name. + */ +function userIdFromCookie(cookie: string): string | null { + for (const pair of cookie.split(";")) { + const eq = pair.indexOf("=") + if (eq === -1) continue + const name = pair.slice(0, eq).trim() + const value = pair.slice(eq + 1).trim() + if (/(^|_)uid$/i.test(name) && /^\d+$/.test(value)) return value + } + return null +} + +export function parseTbdevCredentials(apiToken: string): TbdevCredentials { + let parsed: unknown + try { + parsed = JSON.parse(apiToken) + } catch { + throw new Error( + 'TBDev credentials must be a JSON object, e.g. {"cookie": "uid=123; pass=abc; PHPSESSID=xyz"}' + ) + } + + if (typeof parsed !== "object" || parsed === null) { + throw new Error("TBDev credentials must be a JSON object with a cookie field") + } + + const raw = parsed as Record + const cookie = typeof raw.cookie === "string" ? raw.cookie.trim() : "" + if (!cookie) { + throw new Error("TBDev credentials: cookie cannot be empty") + } + + const explicitId = + typeof raw.userId === "string" + ? raw.userId.trim() + : typeof raw.userId === "number" + ? String(raw.userId) + : "" + + const userId = explicitId || userIdFromCookie(cookie) || "" + if (!userId) { + throw new Error( + "TBDev credentials: could not determine userId — add it explicitly, " + + 'e.g. {"cookie": "…", "userId": "12345"}' + ) + } + if (!/^\d+$/.test(userId)) { + throw new Error(`TBDev credentials: userId must be numeric (got "${userId}")`) + } + + return { cookie, userId } +} + +// --------------------------------------------------------------------------- +// Byte parsing — TBDev's units are binary despite decimal labels +// --------------------------------------------------------------------------- + +/** + * TBDev's mksize() divides by 1024 at every step but labels the result kB/MB/GB/TB: + * + * $bytes/1024 -> " kB" + * $bytes/1048576 -> " MB" + * + * So a TBDev "1.00 GB" is 1 GiB, not 10^9 bytes. Routing that through parseBytes() + * unchanged would read it against the DECIMAL table and under-report by ~7% at GB and + * ~10% at TB. Remap to the binary units before parsing. + * + * (parseBytes is also case-sensitive and has no "kB" entry at all, so the lowercase-k + * form TBDev emits would otherwise throw outright.) + */ +export function parseTbdevBytes(text: string): bigint { + const trimmed = text.replace(/ /g, " ").trim() + if (!trimmed) return 0n + + const match = trimmed.match(/^([\d.,]+)\s*([A-Za-z]+)$/) + if (!match) throw new Error(`Invalid TBDev byte format: "${text}"`) + + const value = match[1].replace(/,/g, "") + const unit = match[2].toLowerCase() + + const BINARY: Record = { + b: "B", + kb: "KiB", + mb: "MiB", + gb: "GiB", + tb: "TiB", + kib: "KiB", + mib: "MiB", + gib: "GiB", + tib: "TiB", + } + + const mapped = BINARY[unit] + if (!mapped) throw new Error(`Unknown TBDev unit: "${match[2]}"`) + + return parseBytes(`${value} ${mapped}`) +} + +// --------------------------------------------------------------------------- +// Profile page parser +// --------------------------------------------------------------------------- + +/** Collapse   and runs of whitespace so label matching is stable. */ +function norm(s: string | undefined): string { + return (s ?? "").replace(/ /g, " ").replace(/\s+/g, " ").trim() +} + +/** + * TBDev's userdetails.php body is a two-column table of `` + * followed by the value cell. Labels contain   ("Join date"), hence norm(). + */ +function buildRowMap(doc: ParsedElement): Map { + const rows = new Map() + for (const tr of doc.querySelectorAll("tr")) { + const cells = tr.querySelectorAll("td") + if (cells.length < 2) continue + const label = norm(cells[0].textContent).toLowerCase() + if (!label) continue + if (!rows.has(label)) rows.set(label, norm(cells[1].textContent)) + } + return rows +} + +export function parseTbdevProfile(html: string): TrackerStats { + // Not authenticated: TBDev bounces to login.php, and the details table never renders. + if (/[^<]*login/i.test(html) || /name=['"]password['"]/i.test(html)) { + throw new Error("Session expired — TBDev cookies need to be refreshed") + } + + const doc = parseHtml(html) + const rows = buildRowMap(doc) + + if (!rows.has("uploaded") && !rows.has("downloaded")) { + throw new Error( + "Could not find profile stats on TBDev page — the page may not be authenticated" + ) + } + + // Username: the <h1> above the table. Fall back to the page title, which reads + // "<site> :: Details for <username>". + let username = norm(doc.querySelector("h1")?.textContent) + if (!username) { + const title = norm(doc.querySelector("title")?.textContent) + username = title.match(/details for\s+(.+)$/i)?.[1]?.trim() ?? "" + } + + const uploadedBytes = rows.get("uploaded") ? parseTbdevBytes(rows.get("uploaded") as string) : 0n + const downloadedBytes = rows.get("downloaded") + ? parseTbdevBytes(rows.get("downloaded") as string) + : 0n + + // The details table has no ratio row — it lives in the header status bar. Compute + // from uploaded/downloaded when the header is absent or unparseable. + const bodyText = norm(doc.textContent) + let ratio = 0 + const ratioMatch = bodyText.match(/Ratio\s*([\d.]+)/i) + if (ratioMatch) { + ratio = parseFloat(ratioMatch[1]) || 0 + } else if (downloadedBytes > 0n) { + ratio = Number(uploadedBytes) / Number(downloadedBytes) + } + + // Seeding/leeching also come from the header status bar. + const seedingCount = parseInt(bodyText.match(/Seeding:\s*(\d[\d,]*)/i)?.[1]?.replace(/,/g, "") ?? "0", 10) + const leechingCount = parseInt(bodyText.match(/Leeching:\s*(\d[\d,]*)/i)?.[1]?.replace(/,/g, "") ?? "0", 10) + + // TBDev's bonus-point system is "karma points" (mybonus.php). + const karma = rows.get("karma points") ?? "" + const seedbonus = karma ? parseFloat(karma.replace(/,/g, "")) || 0 : 0 + + const group = rows.get("class") || "User" + + return { + username, + group, + uploadedBytes, + downloadedBytes, + ratio, + bufferBytes: computeBufferBytes(uploadedBytes, downloadedBytes), + seedingCount: Number.isFinite(seedingCount) ? seedingCount : 0, + leechingCount: Number.isFinite(leechingCount) ? leechingCount : 0, + seedbonus, + // Stock TBDev has no hit-and-run accounting at all — there is no counter on the + // profile page to read. null (not 0) so the dashboard shows "unknown" rather than + // asserting a clean record this tracker cannot actually vouch for. + hitAndRuns: null, + requiredRatio: null, + warned: null, + freeleechTokens: null, + } +} + +// --------------------------------------------------------------------------- +// HTML fetcher +// --------------------------------------------------------------------------- + +async function fetchHtml( + url: string, + cookies: string, + proxyAgent?: FetchOptions["proxyAgent"] +): Promise<string> { + const headers: Record<string, string> = { + Cookie: cookies, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + } + + if (proxyAgent) { + const { proxyFetch } = await import("@/lib/tunnel") + const result = await proxyFetch(url, proxyAgent, { headers }) + if (!result.ok) { + throw new Error( + sanitizeNetworkError( + `${result.status} ${result.statusText}`, + `TBDev page fetch failed: ${result.status}` + ) + ) + } + return (await result.buffer()).toString("utf8") + } + + let response: Response + try { + response = await fetch(url, { + headers, + signal: AbortSignal.timeout(ADAPTER_FETCH_TIMEOUT_MS), + redirect: "manual", + }) + } catch (err) { + throw classifyFetchError(err, new URL(url).hostname) + } + + // TBDev answers an unauthenticated request with a redirect to login.php. + if (response.status >= 300 && response.status < 400) { + throw new Error("Session expired — TBDev cookies need to be refreshed") + } + + if (!response.ok) { + throw new Error( + sanitizeNetworkError( + `${response.status} ${response.statusText}`, + `TBDev page fetch failed: ${response.status}` + ) + ) + } + + return response.text() +} + +// --------------------------------------------------------------------------- +// Adapter class +// --------------------------------------------------------------------------- + +export class TbdevAdapter implements TrackerAdapter { + async fetchStats( + baseUrl: string, + apiToken: string, + apiPath: string, + options?: FetchOptions + ): Promise<TrackerStats> { + const creds = parseTbdevCredentials(apiToken) + const path = apiPath || "/userdetails.php" + const url = `${baseUrl}${path}?id=${encodeURIComponent(creds.userId)}` + const html = await fetchHtml(url, creds.cookie, options?.proxyAgent) + return parseTbdevProfile(html) + } + + async fetchRaw( + baseUrl: string, + apiToken: string, + apiPath: string, + options?: FetchOptions + ): Promise<DebugApiCall[]> { + const calls: DebugApiCall[] = [] + let endpoint = apiPath || "/userdetails.php" + + try { + const creds = parseTbdevCredentials(apiToken) + endpoint = `${endpoint}?id=${creds.userId}` + const html = await fetchHtml(`${baseUrl}${endpoint}`, creds.cookie, options?.proxyAgent) + const stats = parseTbdevProfile(html) + calls.push({ label: "User Details", endpoint, data: stats, error: null }) + } catch (err) { + calls.push({ + label: "User Details", + endpoint, + data: null, + error: err instanceof Error ? err.message : "Request failed", + }) + } + + return calls + } +}
Label