hebits-client 0.2.2 → 0.3.1
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/README.md +10 -13
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -9
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -136,11 +136,6 @@ interface BrowseOptions {
|
|
|
136
136
|
}
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
-
### `hebits.search(options: BrowseOptions): Promise<HebitsTorrent[]>`
|
|
140
|
-
|
|
141
|
-
Same endpoint as `browse`, kept as a separate method because call sites read better as
|
|
142
|
-
"search" when there's a concrete query. Unlike `browse`, `options` is required here.
|
|
143
|
-
|
|
144
139
|
### `hebits.downloadTorrent(id: number): Promise<Uint8Array>`
|
|
145
140
|
|
|
146
141
|
Downloads the `.torrent` file for a torrent id. Hebits serves an HTML page instead of a
|
|
@@ -151,7 +146,7 @@ that looks like a file.
|
|
|
151
146
|
|
|
152
147
|
## The `HebitsTorrent` shape
|
|
153
148
|
|
|
154
|
-
Every torrent `browse
|
|
149
|
+
Every torrent `browse` returns has this shape — the group it belongs to (film or
|
|
155
150
|
show) is folded into each torrent, so you never deal with the tracker's nested
|
|
156
151
|
group/torrent structure directly:
|
|
157
152
|
|
|
@@ -214,11 +209,13 @@ branch on the specific subclass:
|
|
|
214
209
|
This client is deliberately slow and honest, not fast and stealthy:
|
|
215
210
|
|
|
216
211
|
- **One request in flight at a time**, throttled to roughly **one request every two
|
|
217
|
-
seconds** by default (`rateLimit: { limit: 1, interval: 2000 }`).
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
212
|
+
seconds** by default (`rateLimit: { limit: 1, interval: 2000 }`). That default assumes
|
|
213
|
+
background/batch work, where nothing is latency-sensitive; a consumer with a person
|
|
214
|
+
waiting on the result should pass its own, tighter `rateLimit` — left at the default,
|
|
215
|
+
requests serialise and a single screen can take many seconds to fill. This is a single
|
|
216
|
+
shared throttle across every call this client makes — `browse`/`stats`/etc. AND
|
|
217
|
+
`downloadTorrent` AND each retry attempt of any of them — so browsing and downloading
|
|
218
|
+
interleaving, or a burst of retried 5xxs, never doubles the real request rate.
|
|
222
219
|
- **Responses are cached for 10 minutes** by default (`cacheTtlMs`), capped at 200
|
|
223
220
|
distinct query keys by default (`cacheMaxEntries`, an LRU bound), so repeating the same
|
|
224
221
|
`browse`/`stats`/etc. call within that window returns the cached body instead of hitting
|
|
@@ -239,8 +236,8 @@ tracker rather than to turn the throttle up.
|
|
|
239
236
|
|
|
240
237
|
- It never spends freeleech tokens on your behalf — nothing in this client calls a
|
|
241
238
|
token-spending action; that decision stays with you.
|
|
242
|
-
- It never decides what's worth downloading — `browse
|
|
243
|
-
|
|
239
|
+
- It never decides what's worth downloading — `browse` hands you data, you choose what
|
|
240
|
+
to call `downloadTorrent` with.
|
|
244
241
|
- It does not store your cookie anywhere. It's held in memory for the lifetime of the
|
|
245
242
|
`Hebits` instance and sent as a request header; persisting it (env var, secrets
|
|
246
243
|
manager, wherever) is your responsibility.
|
package/dist/index.d.ts
CHANGED
|
@@ -40,7 +40,12 @@ interface TransportOptions {
|
|
|
40
40
|
cookie: string | (() => string);
|
|
41
41
|
baseUrl?: string;
|
|
42
42
|
userAgent?: string;
|
|
43
|
-
/** Default 1 request per 2s.
|
|
43
|
+
/** Default 1 request per 2s. That assumes background/batch work, where nothing is
|
|
44
|
+
* latency-sensitive — fine for something like an account builder. A consumer with a
|
|
45
|
+
* person waiting on the result (listing or streaming to a UI, say) should pass its
|
|
46
|
+
* own, tighter value; left at the default, requests serialise and a single screen
|
|
47
|
+
* can take many seconds to fill. Raising it is a decision to make with the tracker,
|
|
48
|
+
* not a free performance knob — a banned account is not recoverable. */
|
|
44
49
|
rateLimit?: {
|
|
45
50
|
limit: number;
|
|
46
51
|
interval: number;
|
|
@@ -94,8 +99,6 @@ export declare class Hebits {
|
|
|
94
99
|
}>;
|
|
95
100
|
checkLogin(): Promise<void>;
|
|
96
101
|
browse(options?: BrowseOptions): Promise<HebitsTorrent[]>;
|
|
97
|
-
/** The same endpoint as browse; separate because the call sites read differently. */
|
|
98
|
-
search(options: BrowseOptions): Promise<HebitsTorrent[]>;
|
|
99
102
|
/** Hebits serves an HTML page when it refuses a download, so validate before returning. */
|
|
100
103
|
downloadTorrent(id: number): Promise<Uint8Array>;
|
|
101
104
|
}
|
package/dist/index.js
CHANGED
|
@@ -47,7 +47,7 @@ function zoneOffsetMs(at, timeZone) {
|
|
|
47
47
|
second: "2-digit"
|
|
48
48
|
});
|
|
49
49
|
const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value]));
|
|
50
|
-
return Date.UTC(Number(p
|
|
50
|
+
return Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour) % 24, Number(p.minute), Number(p.second)) - at.getTime();
|
|
51
51
|
}
|
|
52
52
|
/** Two-pass offset resolution. A single sample at the naive-as-UTC instant is wrong near
|
|
53
53
|
* a DST transition, because the offset it reads is the one in force AT THAT INSTANT,
|
|
@@ -387,19 +387,15 @@ var Hebits = class {
|
|
|
387
387
|
group_results: 0
|
|
388
388
|
};
|
|
389
389
|
const terms = [options.imdb ?? options.query, options.season ? `S${String(options.season).padStart(2, "0")}` : void 0].filter(Boolean).join(" ");
|
|
390
|
-
if (terms) sp
|
|
391
|
-
if (options.freeleechOnly) sp
|
|
392
|
-
if (options.orderBy) sp
|
|
393
|
-
if (options.orderWay) sp
|
|
390
|
+
if (terms) sp.searchstr = terms;
|
|
391
|
+
if (options.freeleechOnly) sp.freetorrent = 1;
|
|
392
|
+
if (options.orderBy) sp.order_by = options.orderBy;
|
|
393
|
+
if (options.orderWay) sp.order_way = options.orderWay;
|
|
394
394
|
for (const c of options.categories ?? []) sp[`filter_cat[${c}]`] = 1;
|
|
395
395
|
const raw = await this.#transport.json("ajax.php", sp);
|
|
396
396
|
const flat = flattenGroups(parseOrThrow(browseResponseSchema, raw, "ajax.php?action=browse").response.results);
|
|
397
397
|
return options.limit === void 0 ? flat : flat.slice(0, options.limit);
|
|
398
398
|
}
|
|
399
|
-
/** The same endpoint as browse; separate because the call sites read differently. */
|
|
400
|
-
search(options) {
|
|
401
|
-
return this.browse(options);
|
|
402
|
-
}
|
|
403
399
|
/** Hebits serves an HTML page when it refuses a download, so validate before returning. */
|
|
404
400
|
async downloadTorrent(id) {
|
|
405
401
|
const bytes = await this.#transport.bytes("torrents.php", {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/errors.ts","../src/normalise.ts","../src/schemas.ts","../src/scrape.ts","../src/transport.ts","../src/client.ts"],"sourcesContent":["/** Base for everything this package throws. Consumers branch on the subclass. */\nexport class HebitsError extends Error {\n constructor(message: string, options?: { cause?: unknown }) {\n super(message, options);\n this.name = new.target.name;\n }\n}\n\n/** The cookie is dead or was redirected to the login page. NEVER retried: retrying a\n * dead cookie just hammers the tracker. The operator must paste a fresh one. */\nexport class LoginExpiredError extends HebitsError {}\n\n/** The tracker asked us to slow down. */\nexport class RateLimitedError extends HebitsError {}\n\n/** The API answered, but not in a shape we accept: a non-success status, or a response\n * that failed schema validation. A schema failure here means the tracker changed. */\nexport class ApiError extends HebitsError {}\n\n/** A .torrent download returned something that is not bencode — Hebits serves an HTML\n * page when it refuses a download. */\nexport class NotATorrentError extends HebitsError {}\n","import { ApiError } from './errors';\nimport type { RawGroup, RawTorrent } from './schemas';\n\n/** One torrent, with its group's context folded in. This is the package's central type\n * and the only shape consumers see. */\nexport interface HebitsTorrent {\n id: number;\n groupId: number;\n name: string;\n groupName: string;\n categoryId: number;\n imdb?: string;\n cover?: string;\n tags: string[];\n size: number;\n fileCount: number;\n seeders: number;\n leechers: number;\n snatches: number;\n uploadedAt: Date;\n resolution?: string;\n codec?: string;\n audio?: string;\n container?: string;\n downloadFactor: number;\n uploadFactor: number;\n canUseToken: boolean;\n hasSnatched: boolean;\n}\n\nexport function imdbFromCatalogue(url: string | undefined): string | undefined {\n return url?.match(/\\b(tt\\d+)\\b/)?.[1];\n}\n\n/** The API returns an unzoned local timestamp. Israel observes DST, so a fixed +02:00\n * offset is wrong for half the year — Jackett hardcodes it and is an hour out each\n * summer.\n *\n * Resolve the real offset with Intl.formatToParts. Do NOT use the\n * `new Date(d.toLocaleString('en-US', {timeZone}))` trick: it is correct only when the\n * host machine runs in UTC, because the re-parse interprets the formatted string in the\n * MACHINE's zone. Measured: on a host in Asia/Jerusalem it is 2h out in winter and 3h\n * out in summer; in America/New_York, 5h out. That would silently corrupt any caller\n * filtering on \"uploaded in the last N hours\". */\nfunction zoneOffsetMs(at: Date, timeZone: string): number {\n const fmt = new Intl.DateTimeFormat('en-US', {\n timeZone, hour12: false,\n year: 'numeric', month: '2-digit', day: '2-digit',\n hour: '2-digit', minute: '2-digit', second: '2-digit',\n });\n const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value])) as Record<string, string>;\n const asUtc = Date.UTC(\n Number(p['year']), Number(p['month']) - 1, Number(p['day']),\n Number(p['hour']) % 24, Number(p['minute']), Number(p['second']),\n );\n return asUtc - at.getTime();\n}\n\n/** Two-pass offset resolution. A single sample at the naive-as-UTC instant is wrong near\n * a DST transition, because the offset it reads is the one in force AT THAT INSTANT,\n * not the one in force at the wall-clock time the string actually names. Sampling a\n * second time at `naive - o1` — i.e. at our first guess of the real UTC instant —\n * converges on the right offset on both sides of a transition.\n *\n * Two wall-clock windows are genuinely unrecoverable from an unzoned string alone, and\n * two-pass resolves them the same way every other DST-aware parser does rather than\n * producing garbage:\n * - Spring forward (e.g. 03-27 02:00-02:59 in 2026): these wall times never occur.\n * Two-pass maps them forward into 03:xx IDT — the \"compatible\" disambiguation\n * `Temporal` also uses.\n * - Fall back (e.g. 10-25 01:00-01:59 in 2026): these wall times occur twice. Two-pass\n * resolves to the LATER (IST) reading, so a torrent uploaded in the first occurrence\n * of that hour can read up to an hour newer than it really is. Not recoverable — the\n * information needed to pick the earlier one is not in the data. */\nexport function parseHebitsTime(s: string): Date {\n const naive = Date.parse(`${s.replace(' ', 'T')}Z`);\n if (Number.isNaN(naive)) throw new ApiError(`unparseable timestamp from Hebits: ${JSON.stringify(s)}`);\n const o1 = zoneOffsetMs(new Date(naive), 'Asia/Jerusalem');\n const o2 = zoneOffsetMs(new Date(naive - o1), 'Asia/Jerusalem');\n return new Date(naive - o2);\n}\n\ntype Flags = Pick<\n RawTorrent,\n 'isFreeleech' | 'isHalfFreeleech' | 'isQuarterLeech' | 'isNeutralLeech'\n | 'isPersonalFreeleech' | 'isUploadX2' | 'isUploadX3'\n>;\n\n/** Collapse the tracker's seven boolean flags into the two numbers consumers reason\n * about, so nobody has to remember that isQuarterLeech means 0.25.\n *\n * Ordering is deliberate: freeleech beats half- and quarter-leech, and between those\n * two, the cheaper one wins over a torrent somehow flagged both (quarter overrides\n * half). Neutral overrides everything else — it means neither side counts, regardless\n * of what else is set. */\nexport function factorsFor(f: Flags): { downloadFactor: number; uploadFactor: number } {\n let downloadFactor = 1;\n if (f.isHalfFreeleech) downloadFactor = 0.5;\n if (f.isQuarterLeech) downloadFactor = 0.25;\n if (f.isFreeleech || f.isPersonalFreeleech) downloadFactor = 0;\n\n let uploadFactor = 1;\n if (f.isUploadX2) uploadFactor = 2;\n if (f.isUploadX3) uploadFactor = 3;\n\n if (f.isNeutralLeech) return { downloadFactor: 0, uploadFactor: 0 };\n return { downloadFactor, uploadFactor };\n}\n\nexport function flattenGroups(groups: RawGroup[]): HebitsTorrent[] {\n const out: HebitsTorrent[] = [];\n for (const g of groups) {\n const imdb = imdbFromCatalogue(g.catalogue);\n for (const t of g.torrents) {\n out.push({\n id: t.torrentId,\n groupId: g.groupId,\n name: t.release ?? g.groupName,\n groupName: g.groupName,\n categoryId: g.categoryID,\n imdb,\n cover: g.cover,\n tags: g.tags ?? [],\n size: t.size,\n fileCount: t.fileCount,\n seeders: t.seeders,\n leechers: t.leechers,\n snatches: t.snatches,\n uploadedAt: parseHebitsTime(t.time),\n resolution: t.resolution,\n codec: t.codec,\n audio: t.audio,\n container: t.container,\n ...factorsFor(t),\n canUseToken: t.canUseToken,\n hasSnatched: t.hasSnatched,\n });\n }\n }\n return out;\n}\n","import { z } from 'zod';\nimport { ApiError } from './errors';\n\n/** A single torrent inside a group. Field types confirmed against the live API on\n * 2026-09-18: the is* flags really are booleans, and `time` really is an unzoned string.\n * `language` is confirmed against the fixtures to come back as JSON `null` (not omitted)\n * on almost every torrent, so it is nullable as well as optional. */\nexport const rawTorrentSchema = z.object({\n torrentId: z.number(),\n release: z.string().optional(),\n container: z.string().optional(),\n codec: z.string().optional(),\n resolution: z.string().optional(),\n audio: z.string().optional(),\n subbing: z.string().optional(),\n language: z.string().nullable().optional(),\n fileCount: z.number(),\n time: z.string(),\n size: z.number(),\n snatches: z.number(),\n seeders: z.number(),\n leechers: z.number(),\n isFreeleech: z.boolean(),\n isHalfFreeleech: z.boolean(),\n isQuarterLeech: z.boolean(),\n isNeutralLeech: z.boolean(),\n isPersonalFreeleech: z.boolean(),\n isUploadX2: z.boolean(),\n isUploadX3: z.boolean(),\n canUseToken: z.boolean(),\n hasSnatched: z.boolean(),\n});\n\n/** A release group: one film or show, holding several encodes. */\nexport const rawGroupSchema = z.object({\n groupId: z.number(),\n groupName: z.string(),\n groupNameAlt: z.string().optional(),\n categoryID: z.number(),\n categoryName: z.string().optional(),\n cover: z.string().optional(),\n tags: z.array(z.string()).optional(),\n catalogue: z.string().optional(),\n groupYear: z.number().optional(),\n torrents: z.array(rawTorrentSchema),\n});\n\nexport const browseResponseSchema = z.object({\n status: z.literal('success'),\n response: z.object({ results: z.array(rawGroupSchema) }),\n});\n\nexport const rawUserStatsSchema = z.object({\n uploaded: z.number(),\n downloaded: z.number(),\n ratio: z.number(),\n requiredratio: z.number(),\n class: z.string(),\n});\n\nexport const indexResponseSchema = z.object({\n status: z.literal('success'),\n response: z.object({\n id: z.number(),\n username: z.string().optional(),\n userstats: rawUserStatsSchema,\n }),\n});\n\nexport type RawTorrent = z.infer<typeof rawTorrentSchema>;\nexport type RawGroup = z.infer<typeof rawGroupSchema>;\nexport type RawUserStats = z.infer<typeof rawUserStatsSchema>;\n\n/** Parse, or throw an ApiError that names the endpoint and the offending fields.\n * A failure here means the tracker changed its API — that is the signal this package\n * exists to give, in place of the community maintenance Jackett used to provide. */\nexport function parseOrThrow<T extends z.ZodTypeAny>(schema: T, data: unknown, endpoint: string): z.infer<T> {\n const result = schema.safeParse(data);\n if (result.success) return result.data;\n const where = result.error.issues\n .slice(0, 5)\n .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)\n .join('; ');\n throw new ApiError(`${endpoint} response did not match the expected shape — ${where}`);\n}\n","/** The profile page carries the daily download allowance as a Hebrew line:\n * \"הורדות יומיות: 3 / 10\". Strip tags first so markup between the numbers cannot\n * break the match. Returns null rather than guessing — a wrong limit here would let\n * a caller spend downloads it does not have. */\nexport function parseDailyDownloads(html: string): { used: number; limit: number } | null {\n const text = html.replace(/<[^>]+>/g, ' ');\n const m = text.match(/הורדות יומיות:\\s*(\\d+)\\s*\\/\\s*(\\d+)/);\n if (!m) return null;\n return { used: Number(m[1]), limit: Number(m[2]) };\n}\n\n/** A logged-in page carries a logout link with an auth token. This is the same test\n * Jackett's own indexer definition uses. */\nexport function isLoggedIn(html: string): boolean {\n return /logout\\.php\\?auth=/.test(html);\n}\n","import ky, { HTTPError, type KyInstance } from 'ky';\nimport pThrottle from 'p-throttle';\nimport { ApiError, LoginExpiredError, RateLimitedError } from './errors';\n\nexport interface TransportOptions {\n /** Session cookie for hebits.net. A string is sent as-is on every request — the usual\n * case. Pass a function instead when the cookie can change while this client keeps\n * running (an operator pastes a fresh one after the old one expired): it is called\n * fresh before every request, so a new value takes effect on the very next call, with\n * no restart and no code watching for rotation. Called synchronously — read a file or\n * other cached value, don't do I/O inline. If it throws, the throw propagates to the\n * caller of whichever call triggered it, same as any other broken input; an empty\n * string is sent as-is, same as passing `cookie: ''` directly. */\n cookie: string | (() => string);\n baseUrl?: string;\n userAgent?: string;\n /** Default 1 request per 2s. Nothing here is latency-sensitive. */\n rateLimit?: { limit: number; interval: number };\n /** Default 10 minutes. Set 0 to disable. */\n cacheTtlMs?: number;\n /** Caps how many distinct query keys the response cache holds at once — a modest LRU\n * bound, so a long-lived process issuing many distinct queries (e.g. one IMDb id per\n * browse call) doesn't grow the cache without limit. Default 200. */\n cacheMaxEntries?: number;\n retry?: number;\n timeoutMs?: number;\n}\n\nexport interface RequestOptions {\n /** Skip the response cache for this call — read through to the tracker and still\n * write the fresh result back to the cache for everyone else. For calls where a\n * stale answer is actively wrong to act on (a login check, a daily quota count),\n * not just inconvenient. */\n bypassCache?: boolean;\n}\n\nexport interface Transport {\n json(path: string, searchParams?: Record<string, string | number>, opts?: RequestOptions): Promise<unknown>;\n text(path: string, searchParams?: Record<string, string | number>, opts?: RequestOptions): Promise<string>;\n bytes(path: string, searchParams?: Record<string, string | number>): Promise<Uint8Array>;\n}\n\n// Duplicates the version in package.json. Reading package.json from source would need an\n// import attribute and complicate the bundle for a string used in one header — accepted wart.\nconst VERSION = '0.1.0';\nconst LOGIN_MARKERS = [/id=[\"']loginform[\"']/i, /action=[\"']login\\.php/i];\nconst RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);\n// How much of a byte body to decode when sniffing for a login page. Login pages are\n// small; a .torrent's bencode never starts with anything that decodes into this text.\nconst SNIFF_BYTES = 4096;\n\n/** A redirect to login, or a login form served with 200, both mean the cookie is dead.\n * Matches the response URL's PATH only, not the full URL — a search for the literal\n * string \"login.php\" (`browse({ query: 'login.php' })`) must not trip this. */\nfunction assertNotLoginPage(url: string, body: string): void {\n const path = (() => {\n try {\n return new URL(url).pathname;\n } catch {\n return url;\n }\n })();\n if (path.endsWith('login.php') || LOGIN_MARKERS.some((re) => re.test(body))) {\n throw new LoginExpiredError('Hebits returned the login page — the cookie has expired');\n }\n}\n\n/** Decode enough of a byte body to run the same login-page check text responses get.\n * A .torrent file never decodes into anything matching LOGIN_MARKERS. */\nfunction sniff(body: string | Uint8Array): string {\n return typeof body === 'string' ? body : new TextDecoder().decode(body.slice(0, SNIFF_BYTES));\n}\n\nexport function createTransport(opts: TransportOptions): Transport {\n const {\n cookie,\n baseUrl = 'https://hebits.net',\n userAgent = `hebits-client/${VERSION}`,\n rateLimit = { limit: 1, interval: 2000 },\n cacheTtlMs = 10 * 60 * 1000,\n cacheMaxEntries = 200,\n retry = 2,\n timeoutMs = 30_000,\n } = opts;\n\n const client: KyInstance = ky.create({\n baseUrl,\n timeout: timeoutMs,\n redirect: 'manual',\n // ky's own retry is disabled: we retry ourselves, one attempt at a time through the\n // throttle below, so a retry burst is still spaced like any other request. Retrying\n // inside a single throttle slot (ky's default) would let a 5xx burst fire several\n // requests back to back.\n retry: 0,\n // A string cookie is a static header, exactly as before. A function cookie is\n // resolved in beforeRequest instead, once per request, so a rotated value takes\n // effect on the very next call without recreating the client.\n headers: { 'user-agent': userAgent, ...(typeof cookie === 'string' ? { cookie } : {}) },\n ...(typeof cookie === 'function'\n ? { hooks: { beforeRequest: [({ request }) => { request.headers.set('cookie', cookie()); }] } }\n : {}),\n });\n\n // ONE throttle for every request this transport makes — text, JSON, bytes, and each\n // retry attempt of any of them. Two separate throttles (one per response type) would\n // let browsing and downloading interleave at double the configured rate.\n const throttledAttempt = pThrottle(rateLimit)(\n async (path: string, sp: Record<string, string | number> | undefined, responseType: 'text' | 'bytes') => {\n const res = await client.get(path, sp ? { searchParams: sp } : undefined);\n const body = responseType === 'text' ? await res.text() : new Uint8Array(await res.arrayBuffer());\n return { res, body };\n },\n );\n\n async function request(\n path: string,\n sp: Record<string, string | number> | undefined,\n responseType: 'text',\n retriesLeft?: number,\n ): Promise<string>;\n async function request(\n path: string,\n sp: Record<string, string | number> | undefined,\n responseType: 'bytes',\n retriesLeft?: number,\n ): Promise<Uint8Array>;\n async function request(\n path: string,\n sp: Record<string, string | number> | undefined,\n responseType: 'text' | 'bytes',\n retriesLeft: number = retry,\n ): Promise<string | Uint8Array> {\n try {\n const { res, body } = await throttledAttempt(path, sp, responseType);\n assertNotLoginPage(res.url, sniff(body));\n return body;\n } catch (e) {\n if (e instanceof HTTPError) {\n const { status, headers } = e.response;\n if (status === 429) throw new RateLimitedError('Hebits asked us to slow down', { cause: e });\n if (status >= 300 && status < 400 && /login\\.php/.test(headers.get('location') ?? '')) {\n throw new LoginExpiredError('Hebits redirected to login — the cookie has expired', { cause: e });\n }\n if (RETRYABLE_STATUS.has(status) && retriesLeft > 0) {\n return request(path, sp, responseType as 'text', retriesLeft - 1);\n }\n throw new ApiError(`Hebits returned HTTP ${status} for ${path}`, { cause: e });\n }\n throw e;\n }\n }\n\n // key -> settled value with its expiry, and key -> in-flight promise. `cache` is a\n // Map, so iteration order is insertion order; entries are deleted-and-reinserted on\n // every touch (read or write) so the first key is always the least recently used one.\n const cache = new Map<string, { at: number; body: string }>();\n const pending = new Map<string, Promise<string>>();\n\n function pruneCache(): void {\n if (cacheTtlMs > 0) {\n const now = Date.now();\n for (const [k, v] of cache) {\n if (now - v.at >= cacheTtlMs) cache.delete(k);\n }\n }\n while (cache.size > cacheMaxEntries) {\n const oldest = cache.keys().next().value;\n if (oldest === undefined) break;\n cache.delete(oldest);\n }\n }\n\n function cacheGet(key: string): string | undefined {\n const hit = cache.get(key);\n if (!hit || Date.now() - hit.at >= cacheTtlMs) return undefined;\n cache.delete(key);\n cache.set(key, hit); // bump recency\n return hit.body;\n }\n\n function cacheSet(key: string, body: string): void {\n cache.delete(key);\n cache.set(key, { at: Date.now(), body });\n pruneCache();\n }\n\n async function fetchBody(\n path: string,\n sp: Record<string, string | number> | undefined,\n opts?: RequestOptions,\n ): Promise<string> {\n const key = `${path}?${new URLSearchParams(Object.entries(sp ?? {}).map(([k, v]) => [k, String(v)])).toString()}`;\n if (cacheTtlMs > 0 && !opts?.bypassCache) {\n const hit = cacheGet(key);\n if (hit !== undefined) return hit;\n }\n const inFlight = pending.get(key);\n if (inFlight) return inFlight;\n\n // Attach the settle handler in the same statement that creates the promise, so the\n // stored promise always has a handler and a rejection is never unobserved.\n const run = request(path, sp, 'text').then(\n (body) => {\n if (cacheTtlMs > 0) cacheSet(key, body);\n pending.delete(key);\n return body;\n },\n (err) => {\n pending.delete(key); // never cache a failure\n throw err;\n },\n );\n pending.set(key, run);\n return run;\n }\n\n return {\n async json(path, sp, opts) {\n const body = await fetchBody(path, sp, opts);\n try {\n return JSON.parse(body);\n } catch (e) {\n throw new ApiError(`${path} did not return JSON`, { cause: e });\n }\n },\n text: (path, sp, opts) => fetchBody(path, sp, opts),\n async bytes(path, sp) {\n // Binary bodies skip the text CACHE — .torrent files are large and fetched once\n // each — but go through the same throttle, retry and error mapping (including the\n // login-page check) as everything else. A download is a tracker request like any\n // other, and letting it bypass rate limiting or error handling would defeat the\n // point of having them at all.\n return request(path, sp, 'bytes');\n },\n };\n}\n","import { ApiError, LoginExpiredError, NotATorrentError } from './errors';\nimport { flattenGroups, type HebitsTorrent } from './normalise';\nimport { browseResponseSchema, indexResponseSchema, parseOrThrow } from './schemas';\nimport { isLoggedIn, parseDailyDownloads } from './scrape';\nimport { createTransport, type Transport, type TransportOptions } from './transport';\n\nexport interface AccountStats {\n userId: number;\n uploaded: number;\n downloaded: number;\n ratio: number;\n requiredRatio: number;\n userClass: string;\n}\n\nexport interface BrowseOptions {\n /** Free-text search. An IMDb id works here — it is what Jackett sends too. Ignored if\n * `imdb` is also set — see `imdb` below. */\n query?: string;\n /** Convenience: sets `query` to this IMDb id. Takes precedence over `query`: passing\n * both silently discards `query`. */\n imdb?: string;\n /** Appended to the query; Gazelle has no season parameter. */\n season?: number;\n freeleechOnly?: boolean;\n /** 1 Movies, 2 TV, 8 Movie packs — see the tracker's category list. */\n categories?: number[];\n orderBy?: 'time' | 'size' | 'seeders' | 'snatches';\n orderWay?: 'asc' | 'desc';\n /** Cap applied after flattening. The API itself pages at 50 groups. */\n limit?: number;\n}\n\nexport type HebitsOptions = TransportOptions;\n\nexport class Hebits {\n readonly #transport: Transport;\n #userId: number | undefined;\n\n constructor(options: HebitsOptions) {\n this.#transport = createTransport(options);\n }\n\n async stats(): Promise<AccountStats> {\n const raw = await this.#transport.json('ajax.php', { action: 'index' });\n const { response } = parseOrThrow(indexResponseSchema, raw, 'ajax.php?action=index');\n this.#userId = response.id;\n const u = response.userstats;\n return {\n userId: response.id,\n uploaded: u.uploaded,\n downloaded: u.downloaded,\n ratio: u.ratio,\n requiredRatio: u.requiredratio,\n userClass: u.class,\n };\n }\n\n /** The endpoint is user.php?id=N, so an id is needed. Resolves one via stats() when\n * not supplied, so the common call takes no arguments. */\n async dailyDownloads(userId?: number): Promise<{ used: number; limit: number }> {\n const id = userId ?? this.#userId ?? (await this.stats()).userId;\n // Freshness-critical: a stale count could let a caller exceed the tracker's daily\n // download allowance, so this always reads through to the tracker.\n const html = await this.#transport.text('user.php', { id }, { bypassCache: true });\n const parsed = parseDailyDownloads(html);\n if (!parsed) throw new ApiError('could not find the daily download counter on the profile page');\n return parsed;\n }\n\n async checkLogin(): Promise<void> {\n // Freshness-critical: a cached page could report a dead cookie as valid for up to\n // `cacheTtlMs`, defeating the point of a health check.\n const html = await this.#transport.text('', undefined, { bypassCache: true });\n if (!isLoggedIn(html)) throw new LoginExpiredError('no logout link on the front page — the cookie has expired');\n }\n\n async browse(options: BrowseOptions = {}): Promise<HebitsTorrent[]> {\n const sp: Record<string, string | number> = { action: 'browse', group_results: 0 };\n const terms = [options.imdb ?? options.query, options.season ? `S${String(options.season).padStart(2, '0')}` : undefined]\n .filter(Boolean)\n .join(' ');\n if (terms) sp['searchstr'] = terms;\n if (options.freeleechOnly) sp['freetorrent'] = 1;\n if (options.orderBy) sp['order_by'] = options.orderBy;\n if (options.orderWay) sp['order_way'] = options.orderWay;\n for (const c of options.categories ?? []) sp[`filter_cat[${c}]`] = 1;\n\n const raw = await this.#transport.json('ajax.php', sp);\n const parsed = parseOrThrow(browseResponseSchema, raw, 'ajax.php?action=browse');\n const flat = flattenGroups(parsed.response.results);\n return options.limit === undefined ? flat : flat.slice(0, options.limit);\n }\n\n /** The same endpoint as browse; separate because the call sites read differently. */\n search(options: BrowseOptions): Promise<HebitsTorrent[]> {\n return this.browse(options);\n }\n\n /** Hebits serves an HTML page when it refuses a download, so validate before returning. */\n async downloadTorrent(id: number): Promise<Uint8Array> {\n const bytes = await this.#transport.bytes('torrents.php', { action: 'download', id });\n if (bytes[0] !== 0x64 /* 'd' */) {\n const head = new TextDecoder().decode(bytes.slice(0, 200)).replace(/\\s+/g, ' ');\n throw new NotATorrentError(`Hebits refused the download for torrent ${id}: ${head}`);\n }\n return bytes;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/errors.ts","../src/normalise.ts","../src/schemas.ts","../src/scrape.ts","../src/transport.ts","../src/client.ts"],"sourcesContent":["/** Base for everything this package throws. Consumers branch on the subclass. */\nexport class HebitsError extends Error {\n constructor(message: string, options?: { cause?: unknown }) {\n super(message, options);\n this.name = new.target.name;\n }\n}\n\n/** The cookie is dead or was redirected to the login page. NEVER retried: retrying a\n * dead cookie just hammers the tracker. The operator must paste a fresh one. */\nexport class LoginExpiredError extends HebitsError {}\n\n/** The tracker asked us to slow down. */\nexport class RateLimitedError extends HebitsError {}\n\n/** The API answered, but not in a shape we accept: a non-success status, or a response\n * that failed schema validation. A schema failure here means the tracker changed. */\nexport class ApiError extends HebitsError {}\n\n/** A .torrent download returned something that is not bencode — Hebits serves an HTML\n * page when it refuses a download. */\nexport class NotATorrentError extends HebitsError {}\n","import { ApiError } from './errors';\nimport type { RawGroup, RawTorrent } from './schemas';\n\n/** One torrent, with its group's context folded in. This is the package's central type\n * and the only shape consumers see. */\nexport interface HebitsTorrent {\n id: number;\n groupId: number;\n name: string;\n groupName: string;\n categoryId: number;\n imdb?: string;\n cover?: string;\n tags: string[];\n size: number;\n fileCount: number;\n seeders: number;\n leechers: number;\n snatches: number;\n uploadedAt: Date;\n resolution?: string;\n codec?: string;\n audio?: string;\n container?: string;\n downloadFactor: number;\n uploadFactor: number;\n canUseToken: boolean;\n hasSnatched: boolean;\n}\n\nexport function imdbFromCatalogue(url: string | undefined): string | undefined {\n return url?.match(/\\b(tt\\d+)\\b/)?.[1];\n}\n\n/** The API returns an unzoned local timestamp. Israel observes DST, so a fixed +02:00\n * offset is wrong for half the year — Jackett hardcodes it and is an hour out each\n * summer.\n *\n * Resolve the real offset with Intl.formatToParts. Do NOT use the\n * `new Date(d.toLocaleString('en-US', {timeZone}))` trick: it is correct only when the\n * host machine runs in UTC, because the re-parse interprets the formatted string in the\n * MACHINE's zone. Measured: on a host in Asia/Jerusalem it is 2h out in winter and 3h\n * out in summer; in America/New_York, 5h out. That would silently corrupt any caller\n * filtering on \"uploaded in the last N hours\". */\nfunction zoneOffsetMs(at: Date, timeZone: string): number {\n const fmt = new Intl.DateTimeFormat('en-US', {\n timeZone,\n hour12: false,\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n });\n const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value])) as Record<string, string>;\n const asUtc = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour) % 24, Number(p.minute), Number(p.second));\n return asUtc - at.getTime();\n}\n\n/** Two-pass offset resolution. A single sample at the naive-as-UTC instant is wrong near\n * a DST transition, because the offset it reads is the one in force AT THAT INSTANT,\n * not the one in force at the wall-clock time the string actually names. Sampling a\n * second time at `naive - o1` — i.e. at our first guess of the real UTC instant —\n * converges on the right offset on both sides of a transition.\n *\n * Two wall-clock windows are genuinely unrecoverable from an unzoned string alone, and\n * two-pass resolves them the same way every other DST-aware parser does rather than\n * producing garbage:\n * - Spring forward (e.g. 03-27 02:00-02:59 in 2026): these wall times never occur.\n * Two-pass maps them forward into 03:xx IDT — the \"compatible\" disambiguation\n * `Temporal` also uses.\n * - Fall back (e.g. 10-25 01:00-01:59 in 2026): these wall times occur twice. Two-pass\n * resolves to the LATER (IST) reading, so a torrent uploaded in the first occurrence\n * of that hour can read up to an hour newer than it really is. Not recoverable — the\n * information needed to pick the earlier one is not in the data. */\nexport function parseHebitsTime(s: string): Date {\n const naive = Date.parse(`${s.replace(' ', 'T')}Z`);\n if (Number.isNaN(naive)) throw new ApiError(`unparseable timestamp from Hebits: ${JSON.stringify(s)}`);\n const o1 = zoneOffsetMs(new Date(naive), 'Asia/Jerusalem');\n const o2 = zoneOffsetMs(new Date(naive - o1), 'Asia/Jerusalem');\n return new Date(naive - o2);\n}\n\ntype Flags = Pick<\n RawTorrent,\n 'isFreeleech' | 'isHalfFreeleech' | 'isQuarterLeech' | 'isNeutralLeech' | 'isPersonalFreeleech' | 'isUploadX2' | 'isUploadX3'\n>;\n\n/** Collapse the tracker's seven boolean flags into the two numbers consumers reason\n * about, so nobody has to remember that isQuarterLeech means 0.25.\n *\n * Ordering is deliberate: freeleech beats half- and quarter-leech, and between those\n * two, the cheaper one wins over a torrent somehow flagged both (quarter overrides\n * half). Neutral overrides everything else — it means neither side counts, regardless\n * of what else is set. */\nexport function factorsFor(f: Flags): { downloadFactor: number; uploadFactor: number } {\n let downloadFactor = 1;\n if (f.isHalfFreeleech) downloadFactor = 0.5;\n if (f.isQuarterLeech) downloadFactor = 0.25;\n if (f.isFreeleech || f.isPersonalFreeleech) downloadFactor = 0;\n\n let uploadFactor = 1;\n if (f.isUploadX2) uploadFactor = 2;\n if (f.isUploadX3) uploadFactor = 3;\n\n if (f.isNeutralLeech) return { downloadFactor: 0, uploadFactor: 0 };\n return { downloadFactor, uploadFactor };\n}\n\nexport function flattenGroups(groups: RawGroup[]): HebitsTorrent[] {\n const out: HebitsTorrent[] = [];\n for (const g of groups) {\n const imdb = imdbFromCatalogue(g.catalogue);\n for (const t of g.torrents) {\n out.push({\n id: t.torrentId,\n groupId: g.groupId,\n name: t.release ?? g.groupName,\n groupName: g.groupName,\n categoryId: g.categoryID,\n imdb,\n cover: g.cover,\n tags: g.tags ?? [],\n size: t.size,\n fileCount: t.fileCount,\n seeders: t.seeders,\n leechers: t.leechers,\n snatches: t.snatches,\n uploadedAt: parseHebitsTime(t.time),\n resolution: t.resolution,\n codec: t.codec,\n audio: t.audio,\n container: t.container,\n ...factorsFor(t),\n canUseToken: t.canUseToken,\n hasSnatched: t.hasSnatched,\n });\n }\n }\n return out;\n}\n","import { z } from 'zod';\nimport { ApiError } from './errors';\n\n/** A single torrent inside a group. Field types confirmed against the live API on\n * 2026-09-18: the is* flags really are booleans, and `time` really is an unzoned string.\n * `language` is confirmed against the fixtures to come back as JSON `null` (not omitted)\n * on almost every torrent, so it is nullable as well as optional. */\nexport const rawTorrentSchema = z.object({\n torrentId: z.number(),\n release: z.string().optional(),\n container: z.string().optional(),\n codec: z.string().optional(),\n resolution: z.string().optional(),\n audio: z.string().optional(),\n subbing: z.string().optional(),\n language: z.string().nullable().optional(),\n fileCount: z.number(),\n time: z.string(),\n size: z.number(),\n snatches: z.number(),\n seeders: z.number(),\n leechers: z.number(),\n isFreeleech: z.boolean(),\n isHalfFreeleech: z.boolean(),\n isQuarterLeech: z.boolean(),\n isNeutralLeech: z.boolean(),\n isPersonalFreeleech: z.boolean(),\n isUploadX2: z.boolean(),\n isUploadX3: z.boolean(),\n canUseToken: z.boolean(),\n hasSnatched: z.boolean(),\n});\n\n/** A release group: one film or show, holding several encodes. */\nexport const rawGroupSchema = z.object({\n groupId: z.number(),\n groupName: z.string(),\n groupNameAlt: z.string().optional(),\n categoryID: z.number(),\n categoryName: z.string().optional(),\n cover: z.string().optional(),\n tags: z.array(z.string()).optional(),\n catalogue: z.string().optional(),\n groupYear: z.number().optional(),\n torrents: z.array(rawTorrentSchema),\n});\n\nexport const browseResponseSchema = z.object({\n status: z.literal('success'),\n response: z.object({ results: z.array(rawGroupSchema) }),\n});\n\nexport const rawUserStatsSchema = z.object({\n uploaded: z.number(),\n downloaded: z.number(),\n ratio: z.number(),\n requiredratio: z.number(),\n class: z.string(),\n});\n\nexport const indexResponseSchema = z.object({\n status: z.literal('success'),\n response: z.object({\n id: z.number(),\n username: z.string().optional(),\n userstats: rawUserStatsSchema,\n }),\n});\n\nexport type RawTorrent = z.infer<typeof rawTorrentSchema>;\nexport type RawGroup = z.infer<typeof rawGroupSchema>;\nexport type RawUserStats = z.infer<typeof rawUserStatsSchema>;\n\n/** Parse, or throw an ApiError that names the endpoint and the offending fields.\n * A failure here means the tracker changed its API — that is the signal this package\n * exists to give, in place of the community maintenance Jackett used to provide. */\nexport function parseOrThrow<T extends z.ZodTypeAny>(schema: T, data: unknown, endpoint: string): z.infer<T> {\n const result = schema.safeParse(data);\n if (result.success) return result.data;\n const where = result.error.issues\n .slice(0, 5)\n .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)\n .join('; ');\n throw new ApiError(`${endpoint} response did not match the expected shape — ${where}`);\n}\n","/** The profile page carries the daily download allowance as a Hebrew line:\n * \"הורדות יומיות: 3 / 10\". Strip tags first so markup between the numbers cannot\n * break the match. Returns null rather than guessing — a wrong limit here would let\n * a caller spend downloads it does not have. */\nexport function parseDailyDownloads(html: string): { used: number; limit: number } | null {\n const text = html.replace(/<[^>]+>/g, ' ');\n const m = text.match(/הורדות יומיות:\\s*(\\d+)\\s*\\/\\s*(\\d+)/);\n if (!m) return null;\n return { used: Number(m[1]), limit: Number(m[2]) };\n}\n\n/** A logged-in page carries a logout link with an auth token. This is the same test\n * Jackett's own indexer definition uses. */\nexport function isLoggedIn(html: string): boolean {\n return /logout\\.php\\?auth=/.test(html);\n}\n","import ky, { HTTPError, type KyInstance } from 'ky';\nimport pThrottle from 'p-throttle';\nimport { ApiError, LoginExpiredError, RateLimitedError } from './errors';\n\nexport interface TransportOptions {\n /** Session cookie for hebits.net. A string is sent as-is on every request — the usual\n * case. Pass a function instead when the cookie can change while this client keeps\n * running (an operator pastes a fresh one after the old one expired): it is called\n * fresh before every request, so a new value takes effect on the very next call, with\n * no restart and no code watching for rotation. Called synchronously — read a file or\n * other cached value, don't do I/O inline. If it throws, the throw propagates to the\n * caller of whichever call triggered it, same as any other broken input; an empty\n * string is sent as-is, same as passing `cookie: ''` directly. */\n cookie: string | (() => string);\n baseUrl?: string;\n userAgent?: string;\n /** Default 1 request per 2s. That assumes background/batch work, where nothing is\n * latency-sensitive — fine for something like an account builder. A consumer with a\n * person waiting on the result (listing or streaming to a UI, say) should pass its\n * own, tighter value; left at the default, requests serialise and a single screen\n * can take many seconds to fill. Raising it is a decision to make with the tracker,\n * not a free performance knob — a banned account is not recoverable. */\n rateLimit?: { limit: number; interval: number };\n /** Default 10 minutes. Set 0 to disable. */\n cacheTtlMs?: number;\n /** Caps how many distinct query keys the response cache holds at once — a modest LRU\n * bound, so a long-lived process issuing many distinct queries (e.g. one IMDb id per\n * browse call) doesn't grow the cache without limit. Default 200. */\n cacheMaxEntries?: number;\n retry?: number;\n timeoutMs?: number;\n}\n\nexport interface RequestOptions {\n /** Skip the response cache for this call — read through to the tracker and still\n * write the fresh result back to the cache for everyone else. For calls where a\n * stale answer is actively wrong to act on (a login check, a daily quota count),\n * not just inconvenient. */\n bypassCache?: boolean;\n}\n\nexport interface Transport {\n json(path: string, searchParams?: Record<string, string | number>, opts?: RequestOptions): Promise<unknown>;\n text(path: string, searchParams?: Record<string, string | number>, opts?: RequestOptions): Promise<string>;\n bytes(path: string, searchParams?: Record<string, string | number>): Promise<Uint8Array>;\n}\n\n// Duplicates the version in package.json. Reading package.json from source would need an\n// import attribute and complicate the bundle for a string used in one header — accepted wart.\nconst VERSION = '0.1.0';\nconst LOGIN_MARKERS = [/id=[\"']loginform[\"']/i, /action=[\"']login\\.php/i];\nconst RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);\n// How much of a byte body to decode when sniffing for a login page. Login pages are\n// small; a .torrent's bencode never starts with anything that decodes into this text.\nconst SNIFF_BYTES = 4096;\n\n/** A redirect to login, or a login form served with 200, both mean the cookie is dead.\n * Matches the response URL's PATH only, not the full URL — a search for the literal\n * string \"login.php\" (`browse({ query: 'login.php' })`) must not trip this. */\nfunction assertNotLoginPage(url: string, body: string): void {\n const path = (() => {\n try {\n return new URL(url).pathname;\n } catch {\n return url;\n }\n })();\n if (path.endsWith('login.php') || LOGIN_MARKERS.some((re) => re.test(body))) {\n throw new LoginExpiredError('Hebits returned the login page — the cookie has expired');\n }\n}\n\n/** Decode enough of a byte body to run the same login-page check text responses get.\n * A .torrent file never decodes into anything matching LOGIN_MARKERS. */\nfunction sniff(body: string | Uint8Array): string {\n return typeof body === 'string' ? body : new TextDecoder().decode(body.slice(0, SNIFF_BYTES));\n}\n\nexport function createTransport(opts: TransportOptions): Transport {\n const {\n cookie,\n baseUrl = 'https://hebits.net',\n userAgent = `hebits-client/${VERSION}`,\n rateLimit = { limit: 1, interval: 2000 },\n cacheTtlMs = 10 * 60 * 1000,\n cacheMaxEntries = 200,\n retry = 2,\n timeoutMs = 30_000,\n } = opts;\n\n const client: KyInstance = ky.create({\n baseUrl,\n timeout: timeoutMs,\n redirect: 'manual',\n // ky's own retry is disabled: we retry ourselves, one attempt at a time through the\n // throttle below, so a retry burst is still spaced like any other request. Retrying\n // inside a single throttle slot (ky's default) would let a 5xx burst fire several\n // requests back to back.\n retry: 0,\n // A string cookie is a static header, exactly as before. A function cookie is\n // resolved in beforeRequest instead, once per request, so a rotated value takes\n // effect on the very next call without recreating the client.\n headers: { 'user-agent': userAgent, ...(typeof cookie === 'string' ? { cookie } : {}) },\n ...(typeof cookie === 'function'\n ? {\n hooks: {\n beforeRequest: [\n ({ request }) => {\n request.headers.set('cookie', cookie());\n },\n ],\n },\n }\n : {}),\n });\n\n // ONE throttle for every request this transport makes — text, JSON, bytes, and each\n // retry attempt of any of them. Two separate throttles (one per response type) would\n // let browsing and downloading interleave at double the configured rate.\n const throttledAttempt = pThrottle(rateLimit)(\n async (path: string, sp: Record<string, string | number> | undefined, responseType: 'text' | 'bytes') => {\n const res = await client.get(path, sp ? { searchParams: sp } : undefined);\n const body = responseType === 'text' ? await res.text() : new Uint8Array(await res.arrayBuffer());\n return { res, body };\n },\n );\n\n async function request(\n path: string,\n sp: Record<string, string | number> | undefined,\n responseType: 'text',\n retriesLeft?: number,\n ): Promise<string>;\n async function request(\n path: string,\n sp: Record<string, string | number> | undefined,\n responseType: 'bytes',\n retriesLeft?: number,\n ): Promise<Uint8Array>;\n async function request(\n path: string,\n sp: Record<string, string | number> | undefined,\n responseType: 'text' | 'bytes',\n retriesLeft: number = retry,\n ): Promise<string | Uint8Array> {\n try {\n const { res, body } = await throttledAttempt(path, sp, responseType);\n assertNotLoginPage(res.url, sniff(body));\n return body;\n } catch (e) {\n if (e instanceof HTTPError) {\n const { status, headers } = e.response;\n if (status === 429) throw new RateLimitedError('Hebits asked us to slow down', { cause: e });\n if (status >= 300 && status < 400 && /login\\.php/.test(headers.get('location') ?? '')) {\n throw new LoginExpiredError('Hebits redirected to login — the cookie has expired', { cause: e });\n }\n if (RETRYABLE_STATUS.has(status) && retriesLeft > 0) {\n return request(path, sp, responseType as 'text', retriesLeft - 1);\n }\n throw new ApiError(`Hebits returned HTTP ${status} for ${path}`, { cause: e });\n }\n throw e;\n }\n }\n\n // key -> settled value with its expiry, and key -> in-flight promise. `cache` is a\n // Map, so iteration order is insertion order; entries are deleted-and-reinserted on\n // every touch (read or write) so the first key is always the least recently used one.\n const cache = new Map<string, { at: number; body: string }>();\n const pending = new Map<string, Promise<string>>();\n\n function pruneCache(): void {\n if (cacheTtlMs > 0) {\n const now = Date.now();\n for (const [k, v] of cache) {\n if (now - v.at >= cacheTtlMs) cache.delete(k);\n }\n }\n while (cache.size > cacheMaxEntries) {\n const oldest = cache.keys().next().value;\n if (oldest === undefined) break;\n cache.delete(oldest);\n }\n }\n\n function cacheGet(key: string): string | undefined {\n const hit = cache.get(key);\n if (!hit || Date.now() - hit.at >= cacheTtlMs) return undefined;\n cache.delete(key);\n cache.set(key, hit); // bump recency\n return hit.body;\n }\n\n function cacheSet(key: string, body: string): void {\n cache.delete(key);\n cache.set(key, { at: Date.now(), body });\n pruneCache();\n }\n\n async function fetchBody(path: string, sp: Record<string, string | number> | undefined, opts?: RequestOptions): Promise<string> {\n const key = `${path}?${new URLSearchParams(Object.entries(sp ?? {}).map(([k, v]) => [k, String(v)])).toString()}`;\n if (cacheTtlMs > 0 && !opts?.bypassCache) {\n const hit = cacheGet(key);\n if (hit !== undefined) return hit;\n }\n const inFlight = pending.get(key);\n if (inFlight) return inFlight;\n\n // Attach the settle handler in the same statement that creates the promise, so the\n // stored promise always has a handler and a rejection is never unobserved.\n const run = request(path, sp, 'text').then(\n (body) => {\n if (cacheTtlMs > 0) cacheSet(key, body);\n pending.delete(key);\n return body;\n },\n (err) => {\n pending.delete(key); // never cache a failure\n throw err;\n },\n );\n pending.set(key, run);\n return run;\n }\n\n return {\n async json(path, sp, opts) {\n const body = await fetchBody(path, sp, opts);\n try {\n return JSON.parse(body);\n } catch (e) {\n throw new ApiError(`${path} did not return JSON`, { cause: e });\n }\n },\n text: (path, sp, opts) => fetchBody(path, sp, opts),\n async bytes(path, sp) {\n // Binary bodies skip the text CACHE — .torrent files are large and fetched once\n // each — but go through the same throttle, retry and error mapping (including the\n // login-page check) as everything else. A download is a tracker request like any\n // other, and letting it bypass rate limiting or error handling would defeat the\n // point of having them at all.\n return request(path, sp, 'bytes');\n },\n };\n}\n","import { ApiError, LoginExpiredError, NotATorrentError } from './errors';\nimport { flattenGroups, type HebitsTorrent } from './normalise';\nimport { browseResponseSchema, indexResponseSchema, parseOrThrow } from './schemas';\nimport { isLoggedIn, parseDailyDownloads } from './scrape';\nimport { createTransport, type Transport, type TransportOptions } from './transport';\n\nexport interface AccountStats {\n userId: number;\n uploaded: number;\n downloaded: number;\n ratio: number;\n requiredRatio: number;\n userClass: string;\n}\n\nexport interface BrowseOptions {\n /** Free-text search. An IMDb id works here — it is what Jackett sends too. Ignored if\n * `imdb` is also set — see `imdb` below. */\n query?: string;\n /** Convenience: sets `query` to this IMDb id. Takes precedence over `query`: passing\n * both silently discards `query`. */\n imdb?: string;\n /** Appended to the query; Gazelle has no season parameter. */\n season?: number;\n freeleechOnly?: boolean;\n /** 1 Movies, 2 TV, 8 Movie packs — see the tracker's category list. */\n categories?: number[];\n orderBy?: 'time' | 'size' | 'seeders' | 'snatches';\n orderWay?: 'asc' | 'desc';\n /** Cap applied after flattening. The API itself pages at 50 groups. */\n limit?: number;\n}\n\nexport type HebitsOptions = TransportOptions;\n\nexport class Hebits {\n readonly #transport: Transport;\n #userId: number | undefined;\n\n constructor(options: HebitsOptions) {\n this.#transport = createTransport(options);\n }\n\n async stats(): Promise<AccountStats> {\n const raw = await this.#transport.json('ajax.php', { action: 'index' });\n const { response } = parseOrThrow(indexResponseSchema, raw, 'ajax.php?action=index');\n this.#userId = response.id;\n const u = response.userstats;\n return {\n userId: response.id,\n uploaded: u.uploaded,\n downloaded: u.downloaded,\n ratio: u.ratio,\n requiredRatio: u.requiredratio,\n userClass: u.class,\n };\n }\n\n /** The endpoint is user.php?id=N, so an id is needed. Resolves one via stats() when\n * not supplied, so the common call takes no arguments. */\n async dailyDownloads(userId?: number): Promise<{ used: number; limit: number }> {\n const id = userId ?? this.#userId ?? (await this.stats()).userId;\n // Freshness-critical: a stale count could let a caller exceed the tracker's daily\n // download allowance, so this always reads through to the tracker.\n const html = await this.#transport.text('user.php', { id }, { bypassCache: true });\n const parsed = parseDailyDownloads(html);\n if (!parsed) throw new ApiError('could not find the daily download counter on the profile page');\n return parsed;\n }\n\n async checkLogin(): Promise<void> {\n // Freshness-critical: a cached page could report a dead cookie as valid for up to\n // `cacheTtlMs`, defeating the point of a health check.\n const html = await this.#transport.text('', undefined, { bypassCache: true });\n if (!isLoggedIn(html)) throw new LoginExpiredError('no logout link on the front page — the cookie has expired');\n }\n\n async browse(options: BrowseOptions = {}): Promise<HebitsTorrent[]> {\n const sp: Record<string, string | number> = { action: 'browse', group_results: 0 };\n const terms = [options.imdb ?? options.query, options.season ? `S${String(options.season).padStart(2, '0')}` : undefined]\n .filter(Boolean)\n .join(' ');\n if (terms) sp.searchstr = terms;\n if (options.freeleechOnly) sp.freetorrent = 1;\n if (options.orderBy) sp.order_by = options.orderBy;\n if (options.orderWay) sp.order_way = options.orderWay;\n for (const c of options.categories ?? []) sp[`filter_cat[${c}]`] = 1;\n\n const raw = await this.#transport.json('ajax.php', sp);\n const parsed = parseOrThrow(browseResponseSchema, raw, 'ajax.php?action=browse');\n const flat = flattenGroups(parsed.response.results);\n return options.limit === undefined ? flat : flat.slice(0, options.limit);\n }\n\n /** Hebits serves an HTML page when it refuses a download, so validate before returning. */\n async downloadTorrent(id: number): Promise<Uint8Array> {\n const bytes = await this.#transport.bytes('torrents.php', { action: 'download', id });\n if (bytes[0] !== 0x64 /* 'd' */) {\n const head = new TextDecoder().decode(bytes.slice(0, 200)).replace(/\\s+/g, ' ');\n throw new NotATorrentError(`Hebits refused the download for torrent ${id}: ${head}`);\n }\n return bytes;\n }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hebits-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "A client for the Hebits private tracker's JSON API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
"build": "tsdown",
|
|
34
34
|
"typecheck": "tsc",
|
|
35
35
|
"test": "vitest run",
|
|
36
|
+
"format": "biome format --write .",
|
|
37
|
+
"lint": "biome check .",
|
|
36
38
|
"lint:publish": "publint && attw --pack . --profile esm-only",
|
|
37
39
|
"prepublishOnly": "npm run typecheck && npm test && npm run build && npm run lint:publish"
|
|
38
40
|
},
|
|
@@ -43,6 +45,7 @@
|
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
|
45
47
|
"@arethetypeswrong/cli": "^0.18.5",
|
|
48
|
+
"@biomejs/biome": "^2.5.14",
|
|
46
49
|
"@types/node": "^26.6.2",
|
|
47
50
|
"msw": "^2.15.0",
|
|
48
51
|
"publint": "^0.3.24",
|