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 +21 -0
- package/README.md +246 -0
- package/dist/index.d.ts +113 -0
- package/dist/index.js +375 -0
- package/dist/index.js.map +1 -0
- package/package.json +30 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|