hebits-client 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Asaf (lacherogwu)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,246 @@
1
+ # hebits-client
2
+
3
+ A TypeScript client for [Hebits](https://hebits.net)'s JSON API — a private, invite-only
4
+ Gazelle-based BitTorrent tracker. It wraps `ajax.php`, the browse/search endpoint, account
5
+ stats, the daily download counter, and `.torrent` downloads, and validates every response
6
+ with [zod](https://zod.dev) so a change on the tracker's side surfaces as a typed error
7
+ instead of silent `undefined`.
8
+
9
+ ## Before you start: you need a session cookie, not a password
10
+
11
+ Hebits has no API key and no username/password login you can automate: the login form is
12
+ behind a captcha. The only way to get a valid session is to log in with a real browser and
13
+ copy the session cookie out of it (DevTools → Application/Storage → Cookies, or your
14
+ browser's cookie export). Pass that cookie string to the client:
15
+
16
+ ```ts
17
+ new Hebits({ cookie: 'session=abcdef...' });
18
+ ```
19
+
20
+ The cookie eventually expires or gets invalidated server-side. When that happens, every
21
+ call throws `LoginExpiredError` — see [Errors](#errors) below. There is no way to refresh
22
+ it from inside this package; you have to log in again in a browser and supply a new one.
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ npm install hebits-client
28
+ ```
29
+
30
+ Requires Node.js 22+. The package ships as ESM only (no CommonJS build) with bundled
31
+ TypeScript types.
32
+
33
+ ## Quick example
34
+
35
+ ```ts
36
+ import { Hebits, LoginExpiredError, NotATorrentError } from 'hebits-client';
37
+
38
+ const hebits = new Hebits({ cookie: process.env.HEBITS_COOKIE! });
39
+
40
+ const account = await hebits.stats();
41
+ console.log(`${account.userClass}, ratio ${account.ratio}`);
42
+
43
+ const results = await hebits.browse({ imdb: 'tt0944947', season: 3, freeleechOnly: true });
44
+ for (const torrent of results) {
45
+ console.log(torrent.name, torrent.size, torrent.seeders, torrent.downloadFactor);
46
+ }
47
+
48
+ try {
49
+ const bytes = await hebits.downloadTorrent(results[0]!.id);
50
+ // write `bytes` (a Uint8Array) to a .torrent file, or hand it to your client
51
+ } catch (e) {
52
+ if (e instanceof NotATorrentError) {
53
+ // Hebits served an HTML refusal page instead of a torrent file
54
+ }
55
+ if (e instanceof LoginExpiredError) {
56
+ // the cookie is dead — go get a new one from the browser
57
+ }
58
+ throw e;
59
+ }
60
+ ```
61
+
62
+ ## API
63
+
64
+ ### `new Hebits(options)`
65
+
66
+ | option | type | default |
67
+ | ------------- | ------------------------------------- | --------------------------- |
68
+ | `cookie` | `string` | required |
69
+ | `baseUrl` | `string` | `https://hebits.net` |
70
+ | `userAgent` | `string` | `hebits-client/<version>` |
71
+ | `rateLimit` | `{ limit: number; interval: number }` | `{ limit: 1, interval: 2000 }` |
72
+ | `cacheTtlMs` | `number` | `600000` (10 minutes); `0` disables |
73
+ | `cacheMaxEntries` | `number` | `200` — an LRU cap on how many distinct query keys the response cache holds at once |
74
+ | `retry` | `number` | `2` |
75
+ | `timeoutMs` | `number` | `30000` |
76
+
77
+ `userAgent` exists for the rare case you need a different string; the default is honest on
78
+ purpose (see [Rate limiting and identification](#rate-limiting-and-identification)), and
79
+ you shouldn't change it to make requests look like they came from a browser.
80
+
81
+ ### `hebits.stats(): Promise<AccountStats>`
82
+
83
+ Calls `ajax.php?action=index`. Returns:
84
+
85
+ ```ts
86
+ interface AccountStats {
87
+ userId: number;
88
+ uploaded: number;
89
+ downloaded: number;
90
+ ratio: number;
91
+ requiredRatio: number;
92
+ userClass: string;
93
+ }
94
+ ```
95
+
96
+ ### `hebits.dailyDownloads(userId?: number): Promise<{ used: number; limit: number }>`
97
+
98
+ Reads the per-day download counter off the user's profile page (`user.php?id=N`). If you
99
+ don't pass a `userId`, it resolves one for you by calling `stats()` first, so the common
100
+ case is just `await hebits.dailyDownloads()`. This call always bypasses the response
101
+ cache — a stale counter could let you exceed the tracker's daily allowance.
102
+
103
+ ### `hebits.checkLogin(): Promise<void>`
104
+
105
+ Fetches the front page and checks for a logout link. Resolves silently if the cookie is
106
+ still valid; throws `LoginExpiredError` if not. Useful as a cheap health check before a
107
+ batch of other calls. This call always bypasses the response cache, so it never reports a
108
+ dead cookie as valid just because a cached page is still within its TTL.
109
+
110
+ ### `hebits.browse(options?: BrowseOptions): Promise<HebitsTorrent[]>`
111
+
112
+ Calls `ajax.php?action=browse` and flattens the tracker's group/torrent nesting into a
113
+ single array of `HebitsTorrent` (see below) — one entry per torrent, not per release
114
+ group.
115
+
116
+ ```ts
117
+ interface BrowseOptions {
118
+ query?: string; // free-text search; an IMDb id works here too — ignored if `imdb` is also set
119
+ imdb?: string; // convenience: sets `query` to this IMDb id; takes precedence, silently discarding `query`
120
+ season?: number; // appended to the query — Gazelle has no season parameter
121
+ freeleechOnly?: boolean;
122
+ categories?: number[]; // 1 Movies, 2 TV, 8 Movie packs — see the tracker's own category list
123
+ orderBy?: 'time' | 'size' | 'seeders' | 'snatches';
124
+ orderWay?: 'asc' | 'desc';
125
+ limit?: number; // caps the flattened array; the API itself pages at 50 groups
126
+ }
127
+ ```
128
+
129
+ ### `hebits.search(options: BrowseOptions): Promise<HebitsTorrent[]>`
130
+
131
+ Same endpoint as `browse`, kept as a separate method because call sites read better as
132
+ "search" when there's a concrete query. Unlike `browse`, `options` is required here.
133
+
134
+ ### `hebits.downloadTorrent(id: number): Promise<Uint8Array>`
135
+
136
+ Downloads the `.torrent` file for a torrent id. Hebits serves an HTML page instead of a
137
+ torrent file when it refuses a download (e.g. insufficient ratio, wrong class); this
138
+ method checks the first byte for bencode's leading `d` and throws `NotATorrentError` with
139
+ a snippet of the refusal page if the check fails, rather than handing you an HTML blob
140
+ that looks like a file.
141
+
142
+ ## The `HebitsTorrent` shape
143
+
144
+ Every torrent `browse`/`search` returns has this shape — the group it belongs to (film or
145
+ show) is folded into each torrent, so you never deal with the tracker's nested
146
+ group/torrent structure directly:
147
+
148
+ ```ts
149
+ interface HebitsTorrent {
150
+ id: number;
151
+ groupId: number;
152
+ name: string;
153
+ groupName: string;
154
+ categoryId: number;
155
+ imdb?: string;
156
+ cover?: string;
157
+ tags: string[];
158
+ size: number;
159
+ fileCount: number;
160
+ seeders: number;
161
+ leechers: number;
162
+ snatches: number;
163
+ uploadedAt: Date; // corrected to real UTC — see note below
164
+ resolution?: string;
165
+ codec?: string;
166
+ audio?: string;
167
+ container?: string;
168
+ downloadFactor: number; // 1 = full cost, 0.5 = half, 0.25 = quarter, 0 = free/neutral
169
+ uploadFactor: number; // 1 = normal, 2 / 3 = upload bonus, 0 = neutral
170
+ canUseToken: boolean;
171
+ hasSnatched: boolean;
172
+ }
173
+ ```
174
+
175
+ `uploadedAt` is computed from the tracker's unzoned local timestamp, corrected for
176
+ Israel's actual DST offset on that date (not a fixed offset), so filtering on "uploaded in
177
+ the last N hours" is correct year-round — including right around the DST transitions
178
+ themselves, which need a two-pass offset resolution to get right. Two specific wall-clock
179
+ windows per year are genuinely unrecoverable from an unzoned string alone (the tracker
180
+ doesn't say which side of the transition it means): the hour that doesn't exist during
181
+ spring-forward resolves forward past the gap, and the hour that occurs twice during
182
+ fall-back resolves to the later occurrence, so a torrent uploaded in the first occurrence
183
+ of that hour can read up to an hour newer than it really is. Both cases are documented in
184
+ `parseHebitsTime`'s source comment.
185
+
186
+ `downloadFactor` and `uploadFactor` collapse the tracker's seven separate freeleech/upload
187
+ boolean flags into two numbers: freeleech beats half- and quarter-leech, and a
188
+ neutral-leech flag overrides everything else to `0`/`0`.
189
+
190
+ ## Responses are validated, not trusted
191
+
192
+ > Responses are validated with zod at the boundary. If Hebits changes its API, you get a
193
+ > clear `ApiError` naming the field rather than `undefined` propagating into your own
194
+ > logic.
195
+
196
+ ## Errors
197
+
198
+ All errors extend `HebitsError` (itself an `Error`), so you can catch that base class or
199
+ branch on the specific subclass:
200
+
201
+ | class | when it fires |
202
+ | --------------------- | ------------- |
203
+ | `LoginExpiredError` | The response is a redirect to `login.php`, or a login form was served with a 200 — either way, the cookie is dead. This applies uniformly to every call this client makes, including `downloadTorrent`: a dead cookie on the download endpoint raises this too, not a generic error. Never retried automatically: retrying a dead cookie just hammers the tracker for no gain. |
204
+ | `RateLimitedError` | The tracker answered with HTTP 429 — on any endpoint, including downloads. |
205
+ | `ApiError` | Any other non-success HTTP status, a non-JSON body where JSON was expected, or a JSON body that fails the zod schema (the tracker's API shape changed). The message names the offending field(s). |
206
+ | `NotATorrentError` | `downloadTorrent` got a body that isn't bencode and isn't a login page either — Hebits served some other HTML refusal instead (e.g. insufficient ratio, wrong class). |
207
+
208
+ ## Rate limiting and identification
209
+
210
+ This client is deliberately slow and honest, not fast and stealthy:
211
+
212
+ - **One request in flight at a time**, throttled to roughly **one request every two
213
+ seconds** by default (`rateLimit: { limit: 1, interval: 2000 }`). Nothing this package
214
+ does is latency-sensitive. This is a single shared throttle across every call this
215
+ client makes — `browse`/`stats`/etc. AND `downloadTorrent` AND each retry attempt of
216
+ any of them — so browsing and downloading interleaving, or a burst of retried 5xxs,
217
+ never doubles the real request rate.
218
+ - **Responses are cached for 10 minutes** by default (`cacheTtlMs`), capped at 200
219
+ distinct query keys by default (`cacheMaxEntries`, an LRU bound), so repeating the same
220
+ `browse`/`stats`/etc. call within that window returns the cached body instead of hitting
221
+ the tracker again. `checkLogin` and `dailyDownloads` always bypass this cache, since a
222
+ stale answer from either is actively wrong to act on.
223
+ - **Concurrent identical requests are collapsed into one.** If two calls for the exact
224
+ same path and parameters overlap in flight, the second one just awaits the first's
225
+ in-flight promise instead of firing its own request.
226
+ - **Requests identify themselves honestly.** The default User-Agent is
227
+ `hebits-client/<version>` — this client does not pretend to be a browser. A tracker
228
+ operator should be able to see a scripted client for what it is and rate-limit it
229
+ fairly, rather than have it blend into ordinary browser traffic.
230
+
231
+ If your integration needs results faster than this, that's a sign to raise it with the
232
+ tracker rather than to turn the throttle up.
233
+
234
+ ## What this package deliberately does not do
235
+
236
+ - It never spends freeleech tokens on your behalf — nothing in this client calls a
237
+ token-spending action; that decision stays with you.
238
+ - It never decides what's worth downloading — `browse`/`search` hand you data, you choose
239
+ what to call `downloadTorrent` with.
240
+ - It does not store your cookie anywhere. It's held in memory for the lifetime of the
241
+ `Hebits` instance and sent as a request header; persisting it (env var, secrets
242
+ manager, wherever) is your responsibility.
243
+
244
+ ## License
245
+
246
+ MIT
@@ -0,0 +1,113 @@
1
+ /** One torrent, with its group's context folded in. This is the package's central type
2
+ * and the only shape consumers see. */
3
+ interface HebitsTorrent {
4
+ id: number;
5
+ groupId: number;
6
+ name: string;
7
+ groupName: string;
8
+ categoryId: number;
9
+ imdb?: string;
10
+ cover?: string;
11
+ tags: string[];
12
+ size: number;
13
+ fileCount: number;
14
+ seeders: number;
15
+ leechers: number;
16
+ snatches: number;
17
+ uploadedAt: Date;
18
+ resolution?: string;
19
+ codec?: string;
20
+ audio?: string;
21
+ container?: string;
22
+ downloadFactor: number;
23
+ uploadFactor: number;
24
+ canUseToken: boolean;
25
+ hasSnatched: boolean;
26
+ }
27
+
28
+ interface TransportOptions {
29
+ cookie: string;
30
+ baseUrl?: string;
31
+ userAgent?: string;
32
+ /** Default 1 request per 2s. Nothing here is latency-sensitive. */
33
+ rateLimit?: {
34
+ limit: number;
35
+ interval: number;
36
+ };
37
+ /** Default 10 minutes. Set 0 to disable. */
38
+ cacheTtlMs?: number;
39
+ /** Caps how many distinct query keys the response cache holds at once — a modest LRU
40
+ * bound, so a long-lived process issuing many distinct queries (e.g. one IMDb id per
41
+ * browse call) doesn't grow the cache without limit. Default 200. */
42
+ cacheMaxEntries?: number;
43
+ retry?: number;
44
+ timeoutMs?: number;
45
+ }
46
+
47
+ interface AccountStats {
48
+ userId: number;
49
+ uploaded: number;
50
+ downloaded: number;
51
+ ratio: number;
52
+ requiredRatio: number;
53
+ userClass: string;
54
+ }
55
+ interface BrowseOptions {
56
+ /** Free-text search. An IMDb id works here — it is what Jackett sends too. Ignored if
57
+ * `imdb` is also set — see `imdb` below. */
58
+ query?: string;
59
+ /** Convenience: sets `query` to this IMDb id. Takes precedence over `query`: passing
60
+ * both silently discards `query`. */
61
+ imdb?: string;
62
+ /** Appended to the query; Gazelle has no season parameter. */
63
+ season?: number;
64
+ freeleechOnly?: boolean;
65
+ /** 1 Movies, 2 TV, 8 Movie packs — see the tracker's category list. */
66
+ categories?: number[];
67
+ orderBy?: 'time' | 'size' | 'seeders' | 'snatches';
68
+ orderWay?: 'asc' | 'desc';
69
+ /** Cap applied after flattening. The API itself pages at 50 groups. */
70
+ limit?: number;
71
+ }
72
+ type HebitsOptions = TransportOptions;
73
+ declare class Hebits {
74
+ #private;
75
+ constructor(options: HebitsOptions);
76
+ stats(): Promise<AccountStats>;
77
+ /** The endpoint is user.php?id=N, so an id is needed. Resolves one via stats() when
78
+ * not supplied, so the common call takes no arguments. */
79
+ dailyDownloads(userId?: number): Promise<{
80
+ used: number;
81
+ limit: number;
82
+ }>;
83
+ checkLogin(): Promise<void>;
84
+ browse(options?: BrowseOptions): Promise<HebitsTorrent[]>;
85
+ /** The same endpoint as browse; separate because the call sites read differently. */
86
+ search(options: BrowseOptions): Promise<HebitsTorrent[]>;
87
+ /** Hebits serves an HTML page when it refuses a download, so validate before returning. */
88
+ downloadTorrent(id: number): Promise<Uint8Array>;
89
+ }
90
+
91
+ /** Base for everything this package throws. Consumers branch on the subclass. */
92
+ declare class HebitsError extends Error {
93
+ constructor(message: string, options?: {
94
+ cause?: unknown;
95
+ });
96
+ }
97
+ /** The cookie is dead or was redirected to the login page. NEVER retried: retrying a
98
+ * dead cookie just hammers the tracker. The operator must paste a fresh one. */
99
+ declare class LoginExpiredError extends HebitsError {
100
+ }
101
+ /** The tracker asked us to slow down. */
102
+ declare class RateLimitedError extends HebitsError {
103
+ }
104
+ /** The API answered, but not in a shape we accept: a non-success status, or a response
105
+ * that failed schema validation. A schema failure here means the tracker changed. */
106
+ declare class ApiError extends HebitsError {
107
+ }
108
+ /** A .torrent download returned something that is not bencode — Hebits serves an HTML
109
+ * page when it refuses a download. */
110
+ declare class NotATorrentError extends HebitsError {
111
+ }
112
+
113
+ export { type AccountStats, ApiError, type BrowseOptions, Hebits, HebitsError, type HebitsOptions, type HebitsTorrent, LoginExpiredError, NotATorrentError, RateLimitedError };
package/dist/index.js ADDED
@@ -0,0 +1,375 @@
1
+ // src/errors.ts
2
+ var HebitsError = class extends Error {
3
+ constructor(message, options) {
4
+ super(message, options);
5
+ this.name = new.target.name;
6
+ }
7
+ };
8
+ var LoginExpiredError = class extends HebitsError {
9
+ };
10
+ var RateLimitedError = class extends HebitsError {
11
+ };
12
+ var ApiError = class extends HebitsError {
13
+ };
14
+ var NotATorrentError = class extends HebitsError {
15
+ };
16
+
17
+ // src/normalise.ts
18
+ function imdbFromCatalogue(url) {
19
+ return url?.match(/\b(tt\d+)\b/)?.[1];
20
+ }
21
+ function zoneOffsetMs(at, timeZone) {
22
+ const fmt = new Intl.DateTimeFormat("en-US", {
23
+ timeZone,
24
+ hour12: false,
25
+ year: "numeric",
26
+ month: "2-digit",
27
+ day: "2-digit",
28
+ hour: "2-digit",
29
+ minute: "2-digit",
30
+ second: "2-digit"
31
+ });
32
+ const p = Object.fromEntries(fmt.formatToParts(at).map((x) => [x.type, x.value]));
33
+ const asUtc = Date.UTC(
34
+ Number(p["year"]),
35
+ Number(p["month"]) - 1,
36
+ Number(p["day"]),
37
+ Number(p["hour"]) % 24,
38
+ Number(p["minute"]),
39
+ Number(p["second"])
40
+ );
41
+ return asUtc - at.getTime();
42
+ }
43
+ function parseHebitsTime(s) {
44
+ const naive = Date.parse(`${s.replace(" ", "T")}Z`);
45
+ if (Number.isNaN(naive)) throw new ApiError(`unparseable timestamp from Hebits: ${JSON.stringify(s)}`);
46
+ const o1 = zoneOffsetMs(new Date(naive), "Asia/Jerusalem");
47
+ const o2 = zoneOffsetMs(new Date(naive - o1), "Asia/Jerusalem");
48
+ return new Date(naive - o2);
49
+ }
50
+ function factorsFor(f) {
51
+ let downloadFactor = 1;
52
+ if (f.isHalfFreeleech) downloadFactor = 0.5;
53
+ if (f.isQuarterLeech) downloadFactor = 0.25;
54
+ if (f.isFreeleech || f.isPersonalFreeleech) downloadFactor = 0;
55
+ let uploadFactor = 1;
56
+ if (f.isUploadX2) uploadFactor = 2;
57
+ if (f.isUploadX3) uploadFactor = 3;
58
+ if (f.isNeutralLeech) return { downloadFactor: 0, uploadFactor: 0 };
59
+ return { downloadFactor, uploadFactor };
60
+ }
61
+ function flattenGroups(groups) {
62
+ const out = [];
63
+ for (const g of groups) {
64
+ const imdb = imdbFromCatalogue(g.catalogue);
65
+ for (const t of g.torrents) {
66
+ out.push({
67
+ id: t.torrentId,
68
+ groupId: g.groupId,
69
+ name: t.release ?? g.groupName,
70
+ groupName: g.groupName,
71
+ categoryId: g.categoryID,
72
+ imdb,
73
+ cover: g.cover,
74
+ tags: g.tags ?? [],
75
+ size: t.size,
76
+ fileCount: t.fileCount,
77
+ seeders: t.seeders,
78
+ leechers: t.leechers,
79
+ snatches: t.snatches,
80
+ uploadedAt: parseHebitsTime(t.time),
81
+ resolution: t.resolution,
82
+ codec: t.codec,
83
+ audio: t.audio,
84
+ container: t.container,
85
+ ...factorsFor(t),
86
+ canUseToken: t.canUseToken,
87
+ hasSnatched: t.hasSnatched
88
+ });
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ // src/schemas.ts
95
+ import { z } from "zod";
96
+ var rawTorrentSchema = z.object({
97
+ torrentId: z.number(),
98
+ release: z.string().optional(),
99
+ container: z.string().optional(),
100
+ codec: z.string().optional(),
101
+ resolution: z.string().optional(),
102
+ audio: z.string().optional(),
103
+ subbing: z.string().optional(),
104
+ language: z.string().nullable().optional(),
105
+ fileCount: z.number(),
106
+ time: z.string(),
107
+ size: z.number(),
108
+ snatches: z.number(),
109
+ seeders: z.number(),
110
+ leechers: z.number(),
111
+ isFreeleech: z.boolean(),
112
+ isHalfFreeleech: z.boolean(),
113
+ isQuarterLeech: z.boolean(),
114
+ isNeutralLeech: z.boolean(),
115
+ isPersonalFreeleech: z.boolean(),
116
+ isUploadX2: z.boolean(),
117
+ isUploadX3: z.boolean(),
118
+ canUseToken: z.boolean(),
119
+ hasSnatched: z.boolean()
120
+ });
121
+ var rawGroupSchema = z.object({
122
+ groupId: z.number(),
123
+ groupName: z.string(),
124
+ groupNameAlt: z.string().optional(),
125
+ categoryID: z.number(),
126
+ categoryName: z.string().optional(),
127
+ cover: z.string().optional(),
128
+ tags: z.array(z.string()).optional(),
129
+ catalogue: z.string().optional(),
130
+ groupYear: z.number().optional(),
131
+ torrents: z.array(rawTorrentSchema)
132
+ });
133
+ var browseResponseSchema = z.object({
134
+ status: z.literal("success"),
135
+ response: z.object({ results: z.array(rawGroupSchema) })
136
+ });
137
+ var rawUserStatsSchema = z.object({
138
+ uploaded: z.number(),
139
+ downloaded: z.number(),
140
+ ratio: z.number(),
141
+ requiredratio: z.number(),
142
+ class: z.string()
143
+ });
144
+ var indexResponseSchema = z.object({
145
+ status: z.literal("success"),
146
+ response: z.object({
147
+ id: z.number(),
148
+ username: z.string().optional(),
149
+ userstats: rawUserStatsSchema
150
+ })
151
+ });
152
+ function parseOrThrow(schema, data, endpoint) {
153
+ const result = schema.safeParse(data);
154
+ if (result.success) return result.data;
155
+ const where = result.error.issues.slice(0, 5).map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
156
+ throw new ApiError(`${endpoint} response did not match the expected shape \u2014 ${where}`);
157
+ }
158
+
159
+ // src/scrape.ts
160
+ function parseDailyDownloads(html) {
161
+ const text = html.replace(/<[^>]+>/g, " ");
162
+ const m = text.match(/הורדות יומיות:\s*(\d+)\s*\/\s*(\d+)/);
163
+ if (!m) return null;
164
+ return { used: Number(m[1]), limit: Number(m[2]) };
165
+ }
166
+ function isLoggedIn(html) {
167
+ return /logout\.php\?auth=/.test(html);
168
+ }
169
+
170
+ // src/transport.ts
171
+ import ky, { HTTPError } from "ky";
172
+ import pThrottle from "p-throttle";
173
+ var VERSION = "0.1.0";
174
+ var LOGIN_MARKERS = [/id=["']loginform["']/i, /action=["']login\.php/i];
175
+ var RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 500, 502, 503, 504]);
176
+ var SNIFF_BYTES = 4096;
177
+ function assertNotLoginPage(url, body) {
178
+ const path = (() => {
179
+ try {
180
+ return new URL(url).pathname;
181
+ } catch {
182
+ return url;
183
+ }
184
+ })();
185
+ if (path.endsWith("login.php") || LOGIN_MARKERS.some((re) => re.test(body))) {
186
+ throw new LoginExpiredError("Hebits returned the login page \u2014 the cookie has expired");
187
+ }
188
+ }
189
+ function sniff(body) {
190
+ return typeof body === "string" ? body : new TextDecoder().decode(body.slice(0, SNIFF_BYTES));
191
+ }
192
+ function createTransport(opts) {
193
+ const {
194
+ cookie,
195
+ baseUrl = "https://hebits.net",
196
+ userAgent = `hebits-client/${VERSION}`,
197
+ rateLimit = { limit: 1, interval: 2e3 },
198
+ cacheTtlMs = 10 * 60 * 1e3,
199
+ cacheMaxEntries = 200,
200
+ retry = 2,
201
+ timeoutMs = 3e4
202
+ } = opts;
203
+ const client = ky.create({
204
+ baseUrl,
205
+ timeout: timeoutMs,
206
+ redirect: "manual",
207
+ // ky's own retry is disabled: we retry ourselves, one attempt at a time through the
208
+ // throttle below, so a retry burst is still spaced like any other request. Retrying
209
+ // inside a single throttle slot (ky's default) would let a 5xx burst fire several
210
+ // requests back to back.
211
+ retry: 0,
212
+ headers: { cookie, "user-agent": userAgent }
213
+ });
214
+ const throttledAttempt = pThrottle(rateLimit)(
215
+ async (path, sp, responseType) => {
216
+ const res = await client.get(path, sp ? { searchParams: sp } : void 0);
217
+ const body = responseType === "text" ? await res.text() : new Uint8Array(await res.arrayBuffer());
218
+ return { res, body };
219
+ }
220
+ );
221
+ async function request(path, sp, responseType, retriesLeft = retry) {
222
+ try {
223
+ const { res, body } = await throttledAttempt(path, sp, responseType);
224
+ assertNotLoginPage(res.url, sniff(body));
225
+ return body;
226
+ } catch (e) {
227
+ if (e instanceof HTTPError) {
228
+ const { status, headers } = e.response;
229
+ if (status === 429) throw new RateLimitedError("Hebits asked us to slow down", { cause: e });
230
+ if (status >= 300 && status < 400 && /login\.php/.test(headers.get("location") ?? "")) {
231
+ throw new LoginExpiredError("Hebits redirected to login \u2014 the cookie has expired", { cause: e });
232
+ }
233
+ if (RETRYABLE_STATUS.has(status) && retriesLeft > 0) {
234
+ return request(path, sp, responseType, retriesLeft - 1);
235
+ }
236
+ throw new ApiError(`Hebits returned HTTP ${status} for ${path}`, { cause: e });
237
+ }
238
+ throw e;
239
+ }
240
+ }
241
+ const cache = /* @__PURE__ */ new Map();
242
+ const pending = /* @__PURE__ */ new Map();
243
+ function pruneCache() {
244
+ if (cacheTtlMs > 0) {
245
+ const now = Date.now();
246
+ for (const [k, v] of cache) {
247
+ if (now - v.at >= cacheTtlMs) cache.delete(k);
248
+ }
249
+ }
250
+ while (cache.size > cacheMaxEntries) {
251
+ const oldest = cache.keys().next().value;
252
+ if (oldest === void 0) break;
253
+ cache.delete(oldest);
254
+ }
255
+ }
256
+ function cacheGet(key) {
257
+ const hit = cache.get(key);
258
+ if (!hit || Date.now() - hit.at >= cacheTtlMs) return void 0;
259
+ cache.delete(key);
260
+ cache.set(key, hit);
261
+ return hit.body;
262
+ }
263
+ function cacheSet(key, body) {
264
+ cache.delete(key);
265
+ cache.set(key, { at: Date.now(), body });
266
+ pruneCache();
267
+ }
268
+ async function fetchBody(path, sp, opts2) {
269
+ const key = `${path}?${new URLSearchParams(Object.entries(sp ?? {}).map(([k, v]) => [k, String(v)])).toString()}`;
270
+ if (cacheTtlMs > 0 && !opts2?.bypassCache) {
271
+ const hit = cacheGet(key);
272
+ if (hit !== void 0) return hit;
273
+ }
274
+ const inFlight = pending.get(key);
275
+ if (inFlight) return inFlight;
276
+ const run = request(path, sp, "text").then(
277
+ (body) => {
278
+ if (cacheTtlMs > 0) cacheSet(key, body);
279
+ pending.delete(key);
280
+ return body;
281
+ },
282
+ (err) => {
283
+ pending.delete(key);
284
+ throw err;
285
+ }
286
+ );
287
+ pending.set(key, run);
288
+ return run;
289
+ }
290
+ return {
291
+ async json(path, sp, opts2) {
292
+ const body = await fetchBody(path, sp, opts2);
293
+ try {
294
+ return JSON.parse(body);
295
+ } catch (e) {
296
+ throw new ApiError(`${path} did not return JSON`, { cause: e });
297
+ }
298
+ },
299
+ text: (path, sp, opts2) => fetchBody(path, sp, opts2),
300
+ async bytes(path, sp) {
301
+ return request(path, sp, "bytes");
302
+ }
303
+ };
304
+ }
305
+
306
+ // src/client.ts
307
+ var Hebits = class {
308
+ #transport;
309
+ #userId;
310
+ constructor(options) {
311
+ this.#transport = createTransport(options);
312
+ }
313
+ async stats() {
314
+ const raw = await this.#transport.json("ajax.php", { action: "index" });
315
+ const { response } = parseOrThrow(indexResponseSchema, raw, "ajax.php?action=index");
316
+ this.#userId = response.id;
317
+ const u = response.userstats;
318
+ return {
319
+ userId: response.id,
320
+ uploaded: u.uploaded,
321
+ downloaded: u.downloaded,
322
+ ratio: u.ratio,
323
+ requiredRatio: u.requiredratio,
324
+ userClass: u.class
325
+ };
326
+ }
327
+ /** The endpoint is user.php?id=N, so an id is needed. Resolves one via stats() when
328
+ * not supplied, so the common call takes no arguments. */
329
+ async dailyDownloads(userId) {
330
+ const id = userId ?? this.#userId ?? (await this.stats()).userId;
331
+ const html = await this.#transport.text("user.php", { id }, { bypassCache: true });
332
+ const parsed = parseDailyDownloads(html);
333
+ if (!parsed) throw new ApiError("could not find the daily download counter on the profile page");
334
+ return parsed;
335
+ }
336
+ async checkLogin() {
337
+ const html = await this.#transport.text("", void 0, { bypassCache: true });
338
+ if (!isLoggedIn(html)) throw new LoginExpiredError("no logout link on the front page \u2014 the cookie has expired");
339
+ }
340
+ async browse(options = {}) {
341
+ const sp = { action: "browse", group_results: 0 };
342
+ const terms = [options.imdb ?? options.query, options.season ? `S${String(options.season).padStart(2, "0")}` : void 0].filter(Boolean).join(" ");
343
+ if (terms) sp["searchstr"] = terms;
344
+ if (options.freeleechOnly) sp["freetorrent"] = 1;
345
+ if (options.orderBy) sp["order_by"] = options.orderBy;
346
+ if (options.orderWay) sp["order_way"] = options.orderWay;
347
+ for (const c of options.categories ?? []) sp[`filter_cat[${c}]`] = 1;
348
+ const raw = await this.#transport.json("ajax.php", sp);
349
+ const parsed = parseOrThrow(browseResponseSchema, raw, "ajax.php?action=browse");
350
+ const flat = flattenGroups(parsed.response.results);
351
+ return options.limit === void 0 ? flat : flat.slice(0, options.limit);
352
+ }
353
+ /** The same endpoint as browse; separate because the call sites read differently. */
354
+ search(options) {
355
+ return this.browse(options);
356
+ }
357
+ /** Hebits serves an HTML page when it refuses a download, so validate before returning. */
358
+ async downloadTorrent(id) {
359
+ const bytes = await this.#transport.bytes("torrents.php", { action: "download", id });
360
+ if (bytes[0] !== 100) {
361
+ const head = new TextDecoder().decode(bytes.slice(0, 200)).replace(/\s+/g, " ");
362
+ throw new NotATorrentError(`Hebits refused the download for torrent ${id}: ${head}`);
363
+ }
364
+ return bytes;
365
+ }
366
+ };
367
+ export {
368
+ ApiError,
369
+ Hebits,
370
+ HebitsError,
371
+ LoginExpiredError,
372
+ NotATorrentError,
373
+ RateLimitedError
374
+ };
375
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"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.js';\nimport type { RawGroup, RawTorrent } from './schemas.js';\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.js';\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.js';\n\nexport interface TransportOptions {\n cookie: 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 headers: { cookie, 'user-agent': userAgent },\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.js';\nimport { flattenGroups, type HebitsTorrent } from './normalise.js';\nimport { browseResponseSchema, indexResponseSchema, parseOrThrow } from './schemas.js';\nimport { isLoggedIn, parseDailyDownloads } from './scrape.js';\nimport { createTransport, type Transport, type TransportOptions } from './transport.js';\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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","names":["opts"]}
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "hebits-client",
3
+ "version": "0.1.0",
4
+ "description": "A client for the Hebits private tracker's JSON API.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": { "type": "git", "url": "git+https://github.com/lacherogwu/hebits-client.git" },
8
+ "keywords": ["hebits", "gazelle", "torrent", "tracker", "api-client"],
9
+ "author": "Asaf (lacherogwu)",
10
+ "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
11
+ "files": ["dist"],
12
+ "engines": { "node": ">=22" },
13
+ "scripts": {
14
+ "build": "tsup",
15
+ "typecheck": "tsc --noEmit",
16
+ "test": "vitest run",
17
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
18
+ },
19
+ "dependencies": {
20
+ "ky": "^2.1.0",
21
+ "p-throttle": "^8.1.1",
22
+ "zod": "^4.6.5"
23
+ },
24
+ "devDependencies": {
25
+ "msw": "^2.15.0",
26
+ "tsup": "^8.5.1",
27
+ "typescript": "^5.9.3",
28
+ "vitest": "^5.0.1"
29
+ }
30
+ }