@zfadhli/tokrec 0.7.1 → 0.9.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.
Files changed (44) hide show
  1. package/README.md +129 -76
  2. package/dist/api/client.mjs +45 -19
  3. package/dist/api/client.mjs.map +1 -1
  4. package/dist/api/rate-limiter.mjs +29 -0
  5. package/dist/api/rate-limiter.mjs.map +1 -0
  6. package/dist/api/tiktok.mjs +180 -51
  7. package/dist/api/tiktok.mjs.map +1 -1
  8. package/dist/browser-cookies.mjs +8 -11
  9. package/dist/browser-cookies.mjs.map +1 -1
  10. package/dist/cli.mjs +10 -2
  11. package/dist/cli.mjs.map +1 -1
  12. package/dist/config.d.mts +18 -6
  13. package/dist/config.d.mts.map +1 -1
  14. package/dist/config.mjs +8 -4
  15. package/dist/config.mjs.map +1 -1
  16. package/dist/index.mjs +22 -4
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/monitor.mjs +12 -13
  19. package/dist/monitor.mjs.map +1 -1
  20. package/dist/package.mjs +1 -1
  21. package/dist/recorder/convert.mjs +25 -25
  22. package/dist/recorder/convert.mjs.map +1 -1
  23. package/dist/recorder/download-hls.mjs +77 -0
  24. package/dist/recorder/download-hls.mjs.map +1 -0
  25. package/dist/recorder/download-stream.mjs +78 -0
  26. package/dist/recorder/download-stream.mjs.map +1 -0
  27. package/dist/recorder/ffmpeg-utils.mjs +122 -0
  28. package/dist/recorder/ffmpeg-utils.mjs.map +1 -0
  29. package/dist/recorder/index.d.mts.map +1 -1
  30. package/dist/recorder/index.mjs +88 -176
  31. package/dist/recorder/index.mjs.map +1 -1
  32. package/dist/recorder/post-processing.mjs +150 -0
  33. package/dist/recorder/post-processing.mjs.map +1 -0
  34. package/dist/recorder/recorder-events.mjs +34 -0
  35. package/dist/recorder/recorder-events.mjs.map +1 -0
  36. package/dist/recorder/recorder-state.mjs +38 -0
  37. package/dist/recorder/recorder-state.mjs.map +1 -0
  38. package/dist/recorder/stream.mjs +5 -293
  39. package/dist/recorder/stream.mjs.map +1 -1
  40. package/dist/ui.mjs +16 -25
  41. package/dist/ui.mjs.map +1 -1
  42. package/dist/utils.mjs +19 -3
  43. package/dist/utils.mjs.map +1 -1
  44. package/package.json +6 -6
package/README.md CHANGED
@@ -4,19 +4,17 @@
4
4
 
5
5
  **Minimal TikTok live stream recorder — Bun + TypeScript**
6
6
 
7
- <br>
8
-
9
7
  [![CI Status](https://img.shields.io/github/actions/workflow/status/zfadhli/tokrec/ci.yml?style=flat-square&label=CI)](https://github.com/zfadhli/tokrec/actions/workflows/ci.yml)
10
8
  [![npm version](https://img.shields.io/npm/v/@zfadhli/tokrec?style=flat-square)](https://www.npmjs.com/package/@zfadhli/tokrec)
11
9
  [![npm downloads](https://img.shields.io/npm/dw/@zfadhli/tokrec?style=flat-square)](https://www.npmjs.com/package/@zfadhli/tokrec)
12
10
  [![Bun](https://img.shields.io/badge/Bun-%3E%3D1.2-14151a?style=flat-square&logo=bun)](https://bun.sh)
13
11
  [![License](https://img.shields.io/npm/l/@zfadhli/tokrec?style=flat-square)](LICENSE)
14
12
 
15
- [CLI options](#cli-options) · [Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Authentication](#authentication) · [How it works](#how-it-works) · [Library API](#library-api) · [FAQ](#faq)
13
+ [Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [CLI options](#cli-options) · [Authentication](#authentication) · [How it works](#how-it-works) · [Library API](#library-api) · [FAQ](#faq)
16
14
 
17
15
  </div>
18
16
 
19
- `tokrec` polls a TikTok user's live status, detects when they go live, downloads the FLV stream, and post-processes it with FFmpeg into playable MP4 segments. It is available as both a CLI tool and a TypeScript library.
17
+ `tokrec` polls a TikTok user's live status, detects when they go live, downloads the stream, and post-processes it with FFmpeg into playable MP4 segments. Available as both a CLI tool and a TypeScript library.
20
18
 
21
19
  > [!NOTE]
22
20
  > This is **not** an all-in-one TikTok downloader. It focuses on **live stream recording** with automatic reconnection, graceful shutdown, and clean terminal output. For downloading uploaded videos, see other tools.
@@ -25,16 +23,28 @@
25
23
 
26
24
  ## Features
27
25
 
28
- - **Automatic polling** — checks every N minutes (default 3) and starts recording as soon as the user goes live.
29
- - **Stream reconnection** — TikTok stream segments are short-lived (30–60s). When one ends, `tokrec` transparently fetches a fresh URL and continues writing to the same file.
30
- - **Time-aligned MP4 segments** — post-recording, FFmpeg splits the raw FLV into configurable-length MP4 segments (default 20 minutes), each independently playable.
31
- - **Graceful shutdown** — responds to `SIGINT`, `SIGTERM`, and `SIGHUP` (terminal close). Aborts the current download, flushes buffered data, runs FFmpeg, and exits cleanly. A second signal force-kills.
32
- - **Live progress display** — animated spinner with real-time byte count, elapsed time, and download speed. Built with [@zfadhli/koko-cli](https://github.com/zfadhli/koko-cli).
33
- - **Cookie-based auth** — seeds a `sessionid_ss` cookie into the HTTP session for authenticated requests, bypassing TikTok's Slardar WAF.
34
- - **Duration limit** — record for a fixed time (`--duration`) or unlimited (default).
35
- - **Proxy support** — route traffic through an HTTP proxy for regional access.
36
- - **Dual entry** — use as a CLI tool or embed `createRecorder()` in your own application.
37
- - **45+ passing tests** — unit-tested CLI parsing, config validation, stream URL detection, and logger.
26
+ - **Automatic polling** — checks every N minutes (default 3) and starts recording as soon as the user goes live
27
+ - **Stream reconnection** — TikTok stream segments are short-lived (30-60s). When one ends, `tokrec` transparently fetches a fresh URL and continues writing to the same file
28
+ - **Duration-limited recording** — record for a fixed time (`-d`) and auto-exit, or record indefinitely (default)
29
+ - **Time-aligned MP4 segments** — post-recording, optionally split the stream into configurable-length MP4 segments, each independently playable
30
+ - **Audio normalization** — EBU R128 two-pass loudness normalization via [peaknorm](https://github.com/sinedied/peaknorm)
31
+ - **Graceful shutdown** — responds to `SIGINT`/`SIGTERM`/`SIGHUP`. Aborts the download, flushes buffered data, converts to MP4, and exits cleanly
32
+ - **Firefox cookie auto-detection** — reads `sessionid_ss` from Firefox's SQLite cookie store automatically (no config file needed)
33
+ - **Live progress display** — animated spinner with real-time byte count, elapsed time, and download speed
34
+ - **Typed event system** — 15 events with typed payloads for lifecycle tracking
35
+ - **Proxy support** — route traffic through an HTTP proxy for regional access
36
+ - **FLV + HLS support** — automatically detects and handles both stream formats
37
+ - **65+ passing tests** — unit tests with real TikTok HTML/API fixtures
38
+
39
+ ---
40
+
41
+ ## Requirements
42
+
43
+ - **FFmpeg** — must be on your `$PATH` for MP4 conversion and segmenting
44
+ - Linux: `apt install ffmpeg`
45
+ - macOS: `brew install ffmpeg`
46
+ - Windows: `choco install ffmpeg`
47
+ - **Bun** (optional, for development) — Node.js 20+ can run the built package
38
48
 
39
49
  ---
40
50
 
@@ -46,7 +56,7 @@
46
56
  npm install -g @zfadhli/tokrec
47
57
  ```
48
58
 
49
- Then run with:
59
+ Then run:
50
60
 
51
61
  ```bash
52
62
  tiktok-live-recorder -u username
@@ -77,49 +87,48 @@ await recorder.start()
77
87
 
78
88
  ---
79
89
 
80
- ## Requirements
81
-
82
- - **FFmpeg** — must be on your `$PATH` for FLV→MP4 conversion and segmenting.
83
- - Linux: `apt install ffmpeg`
84
- - macOS: `brew install ffmpeg`
85
- - Windows: `choco install ffmpeg`
86
- - **Bun** (optional, for development) — Node.js 20+ can run the built package.
87
-
88
- ---
89
-
90
90
  ## Quick start
91
91
 
92
92
  ```bash
93
93
  # Record a live stream (Ctrl+C to stop)
94
- tiktok-live-recorder -u vierstinrovve
94
+ tiktok-live-recorder -u username
95
+
96
+ # Record for 10 minutes, then exit
97
+ tiktok-live-recorder -u username -d 10
95
98
 
96
99
  # Record for 10 minutes, split into 5-minute segments
97
- tiktok-live-recorder -u vierstinrovve -d 10 -s 5
100
+ tiktok-live-recorder -u username -d 10 -s 5
98
101
 
99
102
  # Use a proxy and custom output directory
100
- tiktok-live-recorder -u vierstinrovve -o ./videos -p http://127.0.0.1:8080
103
+ tiktok-live-recorder -u username -o ./videos -p http://127.0.0.1:8080
101
104
 
102
- # Specify a cookies file (see Authentication section)
103
- tiktok-live-recorder -u vierstinrovve -c ./my-cookies.json
105
+ # Enable audio normalization
106
+ tiktok-live-recorder -u username --normalize
104
107
  ```
105
108
 
109
+ > [!TIP]
110
+ > Both `-d` and `-s` accept values in **minutes** (the tool converts them internally).
111
+
106
112
  ---
107
113
 
108
114
  ## CLI options
109
115
 
110
116
  | Option | Shorthand | Default | Description |
111
117
  |--------|-----------|---------|-------------|
112
- | `--user <name>` | `-u` | _(required)_ | TikTok username (without `@`) |
113
- | `--output <dir>` | `-o` | `./recordings` | Output directory for recordings |
114
- | `--interval <minutes>` | `-i` | `3` | Polling interval (minimum 1) |
115
- | `--duration <minutes>` | `-d` | unlimited | Maximum recording duration |
116
- | `--segment-minutes <minutes>` | `-s` | `20` | Length of each output MP4 segment |
118
+ | `--user <name>` | `-u` | _(required)_ | TikTok username (with or without `@`) |
119
+ | `--output <dir>` | `-o` | `./recordings` | Output directory |
120
+ | `--interval <minutes>` | `-i` | `3` | Polling interval (min 1) |
121
+ | `--duration <minutes>` | `-d` | unlimited | Max recording duration. Implies one-shot (exit after recording) |
122
+ | `--segment-minutes <minutes>` | `-s` | disabled | Split recording into MP4 segments of this length |
117
123
  | `--proxy <url>` | `-p` | none | HTTP proxy URL |
118
124
  | `--cookies <path>` | `-c` | `./cookies.json` | Path to cookies JSON file |
119
125
  | `--log-level <level>` | `-l` | `info` | One of: `debug`, `info`, `warn`, `error` |
120
-
121
- > [!TIP]
122
- > `--duration` and `--segment-minutes` both accept values in **minutes**. The tool converts them internally.
126
+ | `--normalize` | _(none)_ | off | Enable EBU R128 audio normalization |
127
+ | `--normalize-loudness <num>` | _(none)_ | `-14` | Target loudness in LUFS |
128
+ | `--normalize-codec <name>` | _(none)_ | `aac` | Audio codec for normalized output |
129
+ | `--normalize-bitrate <str>` | _(none)_ | `128k` | Audio bitrate for normalized output |
130
+ | `--rate <n>` | _(none)_ | `5` | Max API requests/sec (0 = unlimited). Prevents WAF triggering |
131
+ | `--debug` | _(none)_ | off | Show API debug logging on stderr |
123
132
 
124
133
  ---
125
134
 
@@ -127,9 +136,19 @@ tiktok-live-recorder -u vierstinrovve -c ./my-cookies.json
127
136
 
128
137
  TikTok's Slardar WAF blocks unauthenticated requests. You must provide a valid `sessionid_ss` cookie to bypass it.
129
138
 
130
- ### Setup
139
+ ### Option 1: Firefox (auto-detected)
140
+
141
+ If you're logged into TikTok in Firefox, `tokrec` automatically reads the cookie from Firefox's SQLite store. No config file needed.
142
+
143
+ ```bash
144
+ tiktok-live-recorder -u username
145
+ ```
146
+
147
+ You will see: `ℹ Firefox cookies loaded (30 cookies)`
131
148
 
132
- 1. Open TikTok in your browser and log in.
149
+ ### Option 2: cookies.json
150
+
151
+ 1. Open TikTok in any browser and log in.
133
152
  2. Open DevTools → **Application** → **Cookies** → `www.tiktok.com`.
134
153
  3. Copy the value of `sessionid_ss` and create a `cookies.json` file:
135
154
 
@@ -148,7 +167,7 @@ You can also include the `tt-target-idc` cookie (optional, for regional routing)
148
167
  }
149
168
  ```
150
169
 
151
- 4. Run the tool with `--cookies ./path/to/cookies.json`, or place `cookies.json` in the working directory (auto-detected).
170
+ 4. Place `cookies.json` in the working directory (auto-detected) or pass `--cookies ./path/to/cookies.json`.
152
171
 
153
172
  > [!CAUTION]
154
173
  > Without valid cookies, the tool will report every user as **offline** because the WAF challenge page won't contain live-stream JSON data.
@@ -157,23 +176,56 @@ You can also include the `tt-target-idc` cookie (optional, for regional routing)
157
176
 
158
177
  ## How it works
159
178
 
179
+ ### Detection flow
180
+
181
+ ```
182
+ fetchLiveInfo(user)
183
+ ├─ tryFetchPage (www.tiktok.com → m.tiktok.com)
184
+ │ └─ WAF blocks both → API fallback
185
+ ├─ /api-live/user/room/ (bypasses WAF)
186
+ │ └─ Returns roomId + status (2=live, 4=offline)
187
+ └─ If live → /webcast/room/info/ for stream URL
188
+
189
+ isRoomAlive(roomId) [during recording]
190
+ ├─ Cache hit → return cached.isLive
191
+ └─ Cache miss → /webcast/room/check_alive/ → boolean (~100 bytes)
192
+
193
+ getNextUrl() [during recording]
194
+ ├─ isRoomAlive(roomId) → if dead, stop
195
+ ├─ Invalidate cache
196
+ └─ getLiveUrl(roomId) → fresh stream URL
197
+ ```
198
+
160
199
  ### Pipeline
161
200
 
162
201
  ```
163
- ┌────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
164
- │ Poll tick │ → │ Detect live │ → │ Download FLV │ → │ FFmpeg post │
165
- │ (every Nm) │ │ via SIGI_ │ │ (auto-reconn.)│ │ (segment/ │
166
- │ │ │ STATE scrape │ │ │ │ convert) │
167
- └────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
202
+ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐
203
+ │ Poll tick │ → │ Detect live │ → │ Download TS │ → │ FFmpeg post │
204
+ │ (every Nm) │ │ 3-tier │ │ (auto-reconn.)│ │ (convert/ │
205
+ │ │ │ fallback │ │ │ │ segment) │
206
+ └────────────┘ └──────────────┘ └──────────────┘ └─────────────────┘
168
207
  ```
169
208
 
170
209
  1. **Poll tick** — every N minutes, the monitor calls the check function.
171
- 2. **Live detection** — the TikTok profile page is fetched and the `SIGI_STATE` JSON blob is extracted. Stream URL extraction follows a three-tier fallback:
172
- - Fast-path from the known `pull_data.stream_data` location (~95% of cases).
173
- - Brute-force recursive search of the entire JSON (survives key name changes).
174
- - Webcast API fallback (`/webcast/room/info/?aid=1988`).
175
- 3. **Download** — the raw FLV is streamed to disk with a 512 KB buffer and 60-second read timeout. If the stream segment ends, the downloader fetches a fresh URL and continues (up to 100 reconnects per session). The `--duration` limit is checked against total elapsed time across reconnections.
176
- 4. **FFmpeg post-processing** — the FLV is split into time-aligned MP4 segments using `ffmpeg -c copy -f segment -segment_time N -reset_timestamps 1`. If segmenting fails, it falls back to a simple FLV→MP4 conversion.
210
+ 2. **Live detection** — uses a three-tier fallback:
211
+ - Fast-path from `SIGI_STATE` on the `/live` page (~95% of cases)
212
+ - Brute-force recursive search of the entire JSON (survives key name changes)
213
+ - Webcast API fallback (`/webcast/room/info/`)
214
+ 3. **Download** — the stream is downloaded via FFmpeg stdout pipe to a `.ts` file. TikTok's short-lived URLs are handled transparently: when a URL expires, `getNextUrl()` fetches a fresh one. Duration limit is enforced **inside** FFmpeg via the `-t` flag for precise cutoff.
215
+ 4. **Post-processing** — the `.ts` file is remuxed to MP4 via `ffmpeg -c copy` (no re-encode, fast). If segmenting is enabled, FFmpeg's segment muxer splits into `_partN.mp4` files. Audio normalization runs last if enabled.
216
+
217
+ ### Output structure
218
+
219
+ ```
220
+ ./recordings/
221
+ username=20260614_143000.ts # raw download (deleted after conversion)
222
+ username=20260614_143000.mp4 # remuxed MP4 (no segmenting)
223
+ username=20260614_143000_part1.mp4 # segment 1 (with -s)
224
+ username=20260614_143000_part2.mp4 # segment 2
225
+ ...
226
+ ```
227
+
228
+ File naming: `{username}={YYYYMMDD}_{HHMMSS}[_partN].mp4`
177
229
 
178
230
  ### Event system
179
231
 
@@ -186,26 +238,17 @@ The recorder emits typed events that you can subscribe to via `recorder.on()`:
186
238
  | `recording:start` | `{ user, file }` | When recording begins |
187
239
  | `download:progress` | `{ bytes, elapsed, speed }` | During download (~1s intervals) |
188
240
  | `download:end` | `{ file, duration, size }` | When download completes |
241
+ | `recording:end` | `{ file, duration, size }` | Per segment/file after conversion |
189
242
  | `segmenting:start` | `{ input, outputPattern }` | Before FFmpeg segmenting |
190
243
  | `segmenting:end` | `{ segments }` | After segmenting |
191
244
  | `converting:start` | `{ input }` | Before simple conversion |
192
- | `converted` | `{ input, output }` | After each segment/file |
245
+ | `converted` | `{ input, output }` | After each converted file |
246
+ | `normalize:start` | `{ file }` | Before audio normalization |
247
+ | `normalize:progress` | `{ file, percent, phase }` | During normalization |
248
+ | `normalize:end` | `{ input, output }` | After normalization |
249
+ | `normalize:error` | `{ input, error }` | On normalization error |
193
250
  | `error` | `err: TikTokError` | On non-fatal errors |
194
251
 
195
- ### Output structure
196
-
197
- Recordings are saved as:
198
-
199
- ```
200
- ./recordings/
201
- vierstinrovve=20260609_143000.flv # raw download (deleted after conversion)
202
- vierstinrovve=20260609_143000_part1.mp4 # segment 1
203
- vierstinrovve=20260609_143000_part2.mp4 # segment 2
204
- ...
205
- ```
206
-
207
- File naming: `{username}={YYYYMMDD}_{HHMMSS}[_partN].mp4`
208
-
209
252
  ---
210
253
 
211
254
  ## Library API
@@ -237,6 +280,7 @@ interface RecorderController {
237
280
  stop(): Promise<void>
238
281
  getStatus(): RecorderStatus
239
282
  on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void
283
+ off<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void
240
284
  }
241
285
  ```
242
286
 
@@ -253,7 +297,7 @@ interface RecorderStatus {
253
297
  }
254
298
  ```
255
299
 
256
- ### Example: watch for live status
300
+ ### Example: build a notification bot
257
301
 
258
302
  ```ts
259
303
  import { createRecorder } from '@zfadhli/tokrec'
@@ -262,9 +306,14 @@ const recorder = createRecorder({ user: 'username' })
262
306
 
263
307
  recorder.on('checking', ({ user }) => console.log(`Checking @${user}...`))
264
308
  recorder.on('tick', ({ isLive, roomId, user }) => {
265
- console.log(isLive ? `@${user} is LIVE!` : `@${user} is offline`)
309
+ if (isLive) {
310
+ console.log(`@${user} is LIVE! (room: ${roomId})`)
311
+ // Send a Discord/Telegram notification here
312
+ }
313
+ })
314
+ recorder.on('recording:end', ({ file, duration }) => {
315
+ console.log(`Recorded ${file} (${Math.round(duration)}s)`)
266
316
  })
267
-
268
317
  recorder.on('error', (err) => console.error(`[${err.kind}] ${err.message}`))
269
318
 
270
319
  await recorder.start()
@@ -280,19 +329,23 @@ You likely don't have a valid `sessionid_ss` cookie. See the [Authentication](#a
280
329
 
281
330
  ### Why does the download stop after 30 seconds?
282
331
 
283
- TikTok serves live streams as short FLV segments (~30–60s). The tool now reconnects automatically, but older versions stopped after one segment. Update to the latest version.
332
+ TikTok serves live streams as short FLV segments (~30-60s). `tokrec` handles reconnection transparently — this is expected behavior and the download continues with a fresh URL.
284
333
 
285
- ### Can I record multiple users at the same time?
334
+ ### Can I record multiple users at once?
286
335
 
287
336
  Run multiple instances of the CLI in separate terminals, each with a different `--user` and `--output`.
288
337
 
289
338
  ### Can I use this with Node.js instead of Bun?
290
339
 
291
- The package is built to ESM for Node.js 20+. The runtime dependency is `@zfadhli/koko-cli` and `wreq-js`, both of which work in Node. Bun is optional for development.
340
+ Yes. The package is built to ESM for Node.js 20+. Bun is optional for development.
292
341
 
293
342
  ### Where are the log files?
294
343
 
295
- `tiktok-recorder.log` in the working directory, with rotating backups. File logs include full timestamps and levels for debugging.
344
+ `tiktok-recorder.log` in the working directory, with rotating backups. Console output is also available via `--log-level`.
345
+
346
+ ### Does it work if the user goes live while I'm not running it?
347
+
348
+ No. `tokrec` only polls while it's running. If you want 24/7 monitoring, leave it running in a background process (e.g., tmux, systemd, or a Docker container).
296
349
 
297
350
  ---
298
351
 
@@ -310,6 +363,6 @@ which ffmpeg # should print a path
310
363
 
311
364
  Your `sessionid_ss` cookie has expired or is invalid. Generate a fresh one from your browser's DevTools.
312
365
 
313
- ### The spinner freezes during download
366
+ ### The process doesn't exit after a `-d` recording
314
367
 
315
- The download has a 60-second read timeout. If TikTok stops sending data (e.g., network drop), the tool waits 60 seconds before attempting reconnection. If reconnection fails, the download ends with whatever data was buffered.
368
+ Make sure you're using v0.7.1 or later. Earlier versions continued polling instead of exiting.
@@ -1,38 +1,64 @@
1
+ import { createRateLimiter } from "./rate-limiter.mjs";
1
2
  import { createSession } from "wreq-js";
2
3
  //#region src/api/client.ts
3
4
  /**
4
5
  * HTTP client — thin wrapper around wreq-js createSession.
5
- * Provides TLS fingerprint impersonation, cookie jar, and proxy support.
6
+ * Provides TLS fingerprint impersonation, cookie jar, proxy support,
7
+ * and configurable rate limiting to prevent WAF triggering.
6
8
  */
9
+ const REQUEST_TIMEOUT_MS = 15e3;
10
+ /** Standard browser headers to avoid TikTok WAF detection. */
11
+ const BROWSER_HEADERS = {
12
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
13
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
14
+ "Accept-Language": "en-US,en;q=0.5",
15
+ "Accept-Encoding": "gzip, deflate, br",
16
+ "Upgrade-Insecure-Requests": "1",
17
+ "Sec-Fetch-Dest": "document",
18
+ "Sec-Fetch-Mode": "navigate",
19
+ "Sec-Fetch-Site": "none",
20
+ "Sec-Fetch-User": "?1"
21
+ };
7
22
  async function createHttpClient(config) {
8
23
  const session = await createSession({
9
- browser: "chrome_142",
24
+ browser: "firefox_149",
10
25
  os: "windows",
11
26
  proxy: config.proxy
12
27
  });
13
- if (config.cookies?.sessionid_ss) for (const domain of [
28
+ if (config.cookies && "sessionid_ss" in config.cookies) for (const domain of [
14
29
  "https://www.tiktok.com",
15
30
  "https://webcast.tiktok.com",
16
31
  "https://m.tiktok.com"
17
- ]) {
18
- session.setCookie("sessionid_ss", config.cookies.sessionid_ss, domain);
19
- if (config.cookies["tt-target-idc"]) session.setCookie("tt-target-idc", config.cookies["tt-target-idc"], domain);
20
- }
21
- return {
22
- get: async (url) => {
23
- return await session.fetch(url, { signal: AbortSignal.timeout(15e3) });
24
- },
25
- post: async (url, body, headers) => {
32
+ ]) for (const [name, value] of Object.entries(config.cookies)) session.setCookie(name, value, domain);
33
+ const rateLimiter = createRateLimiter(config.ratePerSecond ?? 5);
34
+ async function rateLimitedFetch(url, options) {
35
+ const controller = new AbortController();
36
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
37
+ try {
38
+ await rateLimiter.acquire(controller.signal);
26
39
  return await session.fetch(url, {
27
- method: "POST",
28
- body,
29
- signal: AbortSignal.timeout(15e3),
40
+ method: options?.method,
41
+ body: options?.body,
30
42
  headers: {
31
- "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
32
- ...headers
33
- }
43
+ ...BROWSER_HEADERS,
44
+ ...options?.headers
45
+ },
46
+ signal: controller.signal
34
47
  });
35
- },
48
+ } finally {
49
+ clearTimeout(timeout);
50
+ }
51
+ }
52
+ return {
53
+ get: async (url) => rateLimitedFetch(url),
54
+ post: async (url, body, headers) => rateLimitedFetch(url, {
55
+ method: "POST",
56
+ body,
57
+ headers: {
58
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
59
+ ...headers
60
+ }
61
+ }),
36
62
  close: async () => {
37
63
  await session.close();
38
64
  }
@@ -1 +1 @@
1
- {"version":3,"file":"client.mjs","names":[],"sources":["../../src/api/client.ts"],"sourcesContent":["/**\n * HTTP client — thin wrapper around wreq-js createSession.\n * Provides TLS fingerprint impersonation, cookie jar, and proxy support.\n */\n\nimport { createSession, type Session } from \"wreq-js\"\nimport type { RecorderConfig } from \"../config\"\n\nexport interface HttpClient {\n get: (url: string) => Promise<Response>\n post: (url: string, body?: BodyInit, headers?: Record<string, string>) => Promise<Response>\n close: () => Promise<void>\n}\n\nexport async function createHttpClient(config: RecorderConfig): Promise<HttpClient> {\n const session: Session = await createSession({\n browser: \"chrome_142\",\n os: \"windows\",\n proxy: config.proxy,\n } as any)\n\n // Seed cookies into the session jar so all subsequent requests are authenticated.\n // NOTE: Using a Cookie header does NOT populate wreq-js's cookie jar —\n // we must use session.setCookie() instead.\n // Seed for all TikTok domains since cookies may not propagate across\n // subdomains automatically in wreq-js's cookie jar.\n if (config.cookies?.sessionid_ss) {\n for (const domain of [\n \"https://www.tiktok.com\",\n \"https://webcast.tiktok.com\",\n \"https://m.tiktok.com\",\n ]) {\n session.setCookie(\"sessionid_ss\", config.cookies.sessionid_ss, domain)\n if (config.cookies[\"tt-target-idc\"]) {\n session.setCookie(\"tt-target-idc\", config.cookies[\"tt-target-idc\"], domain)\n }\n }\n }\n\n return {\n get: async (url: string) => {\n const res = await session.fetch(url, {\n signal: AbortSignal.timeout(15000),\n } as any)\n return res as unknown as Response\n },\n post: async (url: string, body?: BodyInit, headers?: Record<string, string>) => {\n const res = await session.fetch(url, {\n method: \"POST\",\n body,\n signal: AbortSignal.timeout(15000),\n headers: {\n \"Content-Type\": \"application/x-www-form-urlencoded; charset=UTF-8\",\n ...headers,\n },\n } as any)\n return res as unknown as Response\n },\n close: async () => {\n await session.close()\n },\n }\n}\n"],"mappings":";;;;;;AAcA,eAAsB,iBAAiB,QAA6C;CAClF,MAAM,UAAmB,MAAM,cAAc;EAC3C,SAAS;EACT,IAAI;EACJ,OAAO,OAAO;CAChB,CAAQ;CAOR,IAAI,OAAO,SAAS,cAClB,KAAK,MAAM,UAAU;EACnB;EACA;EACA;CACF,GAAG;EACD,QAAQ,UAAU,gBAAgB,OAAO,QAAQ,cAAc,MAAM;EACrE,IAAI,OAAO,QAAQ,kBACjB,QAAQ,UAAU,iBAAiB,OAAO,QAAQ,kBAAkB,MAAM;CAE9E;CAGF,OAAO;EACL,KAAK,OAAO,QAAgB;GAI1B,OAAO,MAHW,QAAQ,MAAM,KAAK,EACnC,QAAQ,YAAY,QAAQ,IAAK,EACnC,CAAQ;EAEV;EACA,MAAM,OAAO,KAAa,MAAiB,YAAqC;GAU9E,OAAO,MATW,QAAQ,MAAM,KAAK;IACnC,QAAQ;IACR;IACA,QAAQ,YAAY,QAAQ,IAAK;IACjC,SAAS;KACP,gBAAgB;KAChB,GAAG;IACL;GACF,CAAQ;EAEV;EACA,OAAO,YAAY;GACjB,MAAM,QAAQ,MAAM;EACtB;CACF;AACF"}
1
+ {"version":3,"file":"client.mjs","names":[],"sources":["../../src/api/client.ts"],"sourcesContent":["/**\n * HTTP client — thin wrapper around wreq-js createSession.\n * Provides TLS fingerprint impersonation, cookie jar, proxy support,\n * and configurable rate limiting to prevent WAF triggering.\n */\n\nimport { createSession, type Session } from \"wreq-js\"\nimport type { RecorderConfig } from \"../config\"\nimport { createRateLimiter } from \"./rate-limiter\"\n\nexport interface HttpClient {\n get: (url: string) => Promise<Response>\n post: (url: string, body?: BodyInit, headers?: Record<string, string>) => Promise<Response>\n close: () => Promise<void>\n}\n\nconst REQUEST_TIMEOUT_MS = 15_000\n\n/** Standard browser headers to avoid TikTok WAF detection. */\nconst BROWSER_HEADERS: Record<string, string> = {\n \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0\",\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8\",\n \"Accept-Language\": \"en-US,en;q=0.5\",\n \"Accept-Encoding\": \"gzip, deflate, br\",\n \"Upgrade-Insecure-Requests\": \"1\",\n \"Sec-Fetch-Dest\": \"document\",\n \"Sec-Fetch-Mode\": \"navigate\",\n \"Sec-Fetch-Site\": \"none\",\n \"Sec-Fetch-User\": \"?1\",\n}\n\nexport async function createHttpClient(config: RecorderConfig): Promise<HttpClient> {\n const session: Session = await createSession({\n browser: \"firefox_149\",\n os: \"windows\",\n proxy: config.proxy,\n })\n\n // Seed cookies into the session jar so all subsequent requests are authenticated.\n // NOTE: Using a Cookie header does NOT populate wreq-js's cookie jar —\n // we must use session.setCookie() instead.\n // Seed for all TikTok domains since cookies may not propagate across\n // subdomains automatically in wreq-js's cookie jar.\n if (config.cookies && \"sessionid_ss\" in config.cookies) {\n const domains = [\"https://www.tiktok.com\", \"https://webcast.tiktok.com\", \"https://m.tiktok.com\"]\n for (const domain of domains) {\n for (const [name, value] of Object.entries(config.cookies)) {\n session.setCookie(name, value, domain)\n }\n }\n }\n\n const rateLimiter = createRateLimiter(config.ratePerSecond ?? 5)\n\n async function rateLimitedFetch(\n url: string,\n options?: { method?: string; body?: unknown; headers?: Record<string, string> },\n ): Promise<Response> {\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)\n try {\n await rateLimiter.acquire(controller.signal)\n const res = await session.fetch(url, {\n method: options?.method,\n body: options?.body as any,\n headers: {\n ...BROWSER_HEADERS,\n ...options?.headers,\n },\n signal: controller.signal,\n })\n return res as unknown as Response\n } finally {\n clearTimeout(timeout)\n }\n }\n\n return {\n get: async (url: string) => rateLimitedFetch(url),\n post: async (url: string, body?: BodyInit, headers?: Record<string, string>) =>\n rateLimitedFetch(url, {\n method: \"POST\",\n body: body as any,\n headers: {\n \"Content-Type\": \"application/x-www-form-urlencoded; charset=UTF-8\",\n ...headers,\n },\n }),\n close: async () => {\n await session.close()\n },\n }\n}\n"],"mappings":";;;;;;;;AAgBA,MAAM,qBAAqB;;AAG3B,MAAM,kBAA0C;CAC9C,cAAc;CACd,QAAQ;CACR,mBAAmB;CACnB,mBAAmB;CACnB,6BAA6B;CAC7B,kBAAkB;CAClB,kBAAkB;CAClB,kBAAkB;CAClB,kBAAkB;AACpB;AAEA,eAAsB,iBAAiB,QAA6C;CAClF,MAAM,UAAmB,MAAM,cAAc;EAC3C,SAAS;EACT,IAAI;EACJ,OAAO,OAAO;CAChB,CAAC;CAOD,IAAI,OAAO,WAAW,kBAAkB,OAAO,SAE7C,KAAK,MAAM,UAAU;EADJ;EAA0B;EAA8B;CAC9C,GACzB,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,OAAO,OAAO,GACvD,QAAQ,UAAU,MAAM,OAAO,MAAM;CAK3C,MAAM,cAAc,kBAAkB,OAAO,iBAAiB,CAAC;CAE/D,eAAe,iBACb,KACA,SACmB;EACnB,MAAM,aAAa,IAAI,gBAAgB;EACvC,MAAM,UAAU,iBAAiB,WAAW,MAAM,GAAG,kBAAkB;EACvE,IAAI;GACF,MAAM,YAAY,QAAQ,WAAW,MAAM;GAU3C,OAAO,MATW,QAAQ,MAAM,KAAK;IACnC,QAAQ,SAAS;IACjB,MAAM,SAAS;IACf,SAAS;KACP,GAAG;KACH,GAAG,SAAS;IACd;IACA,QAAQ,WAAW;GACrB,CAAC;EAEH,UAAU;GACR,aAAa,OAAO;EACtB;CACF;CAEA,OAAO;EACL,KAAK,OAAO,QAAgB,iBAAiB,GAAG;EAChD,MAAM,OAAO,KAAa,MAAiB,YACzC,iBAAiB,KAAK;GACpB,QAAQ;GACF;GACN,SAAS;IACP,gBAAgB;IAChB,GAAG;GACL;EACF,CAAC;EACH,OAAO,YAAY;GACjB,MAAM,QAAQ,MAAM;EACtB;CACF;AACF"}
@@ -0,0 +1,29 @@
1
+ //#region src/api/rate-limiter.ts
2
+ /**
3
+ * Create a token-bucket rate limiter.
4
+ *
5
+ * @param tokensPerSecond Max sustained requests per second.
6
+ * 0 or negative = unlimited (no waiting).
7
+ */
8
+ function createRateLimiter(tokensPerSecond) {
9
+ if (tokensPerSecond <= 0) return { acquire: async () => {} };
10
+ const MIN_INTERVAL_MS = 1e3 / tokensPerSecond;
11
+ let lastTokenTime = 0;
12
+ return { async acquire(signal) {
13
+ if (signal?.aborted) return;
14
+ const now = Date.now();
15
+ const waitTime = Math.max(0, lastTokenTime + MIN_INTERVAL_MS - now);
16
+ if (waitTime > 0) await new Promise((resolve) => {
17
+ const timer = setTimeout(resolve, waitTime);
18
+ if (signal) signal.addEventListener("abort", () => {
19
+ clearTimeout(timer);
20
+ resolve();
21
+ }, { once: true });
22
+ });
23
+ lastTokenTime = Date.now();
24
+ } };
25
+ }
26
+ //#endregion
27
+ export { createRateLimiter };
28
+
29
+ //# sourceMappingURL=rate-limiter.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limiter.mjs","names":[],"sources":["../../src/api/rate-limiter.ts"],"sourcesContent":["/**\n * Token-bucket rate limiter — limits API request frequency to prevent\n * TikTok WAF triggering on rapid polling or URL refresh loops.\n *\n * Design:\n * - Token bucket with configurable refill rate (tokens/sec)\n * - AbortSignal-aware wait so cancellation works through the queue\n * - Thread-safe for cooperative JS concurrency (all awaiters share\n * the same monotonically-increasing last-token timestamp)\n */\n\nexport interface RateLimiter {\n /** Wait until a rate-limit token is available, then consume it. */\n acquire(signal?: AbortSignal): Promise<void>\n}\n\n/**\n * Create a token-bucket rate limiter.\n *\n * @param tokensPerSecond Max sustained requests per second.\n * 0 or negative = unlimited (no waiting).\n */\nexport function createRateLimiter(tokensPerSecond: number): RateLimiter {\n // Unlimited mode — no-op acquire\n if (tokensPerSecond <= 0) {\n return { acquire: async () => {} }\n }\n\n const MIN_INTERVAL_MS = 1000 / tokensPerSecond\n let lastTokenTime = 0\n\n return {\n async acquire(signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) return\n\n const now = Date.now()\n const waitTime = Math.max(0, lastTokenTime + MIN_INTERVAL_MS - now)\n\n if (waitTime > 0) {\n await new Promise<void>((resolve) => {\n const timer = setTimeout(resolve, waitTime)\n if (signal) {\n signal.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer)\n resolve()\n },\n { once: true },\n )\n }\n })\n }\n\n lastTokenTime = Date.now()\n },\n }\n}\n"],"mappings":";;;;;;;AAsBA,SAAgB,kBAAkB,iBAAsC;CAEtE,IAAI,mBAAmB,GACrB,OAAO,EAAE,SAAS,YAAY,CAAC,EAAE;CAGnC,MAAM,kBAAkB,MAAO;CAC/B,IAAI,gBAAgB;CAEpB,OAAO,EACL,MAAM,QAAQ,QAAqC;EACjD,IAAI,QAAQ,SAAS;EAErB,MAAM,MAAM,KAAK,IAAI;EACrB,MAAM,WAAW,KAAK,IAAI,GAAG,gBAAgB,kBAAkB,GAAG;EAElE,IAAI,WAAW,GACb,MAAM,IAAI,SAAe,YAAY;GACnC,MAAM,QAAQ,WAAW,SAAS,QAAQ;GAC1C,IAAI,QACF,OAAO,iBACL,eACM;IACJ,aAAa,KAAK;IAClB,QAAQ;GACV,GACA,EAAE,MAAM,KAAK,CACf;EAEJ,CAAC;EAGH,gBAAgB,KAAK,IAAI;CAC3B,EACF;AACF"}