@zfadhli/tokrec 0.7.0 → 0.8.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/README.md +129 -76
- package/dist/api/client.mjs +45 -19
- package/dist/api/client.mjs.map +1 -1
- package/dist/api/rate-limiter.mjs +29 -0
- package/dist/api/rate-limiter.mjs.map +1 -0
- package/dist/api/tiktok.mjs +180 -51
- package/dist/api/tiktok.mjs.map +1 -1
- package/dist/browser-cookies.mjs +8 -11
- package/dist/browser-cookies.mjs.map +1 -1
- package/dist/cli.mjs +10 -2
- package/dist/cli.mjs.map +1 -1
- package/dist/config.d.mts +18 -6
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +7 -3
- package/dist/config.mjs.map +1 -1
- package/dist/index.mjs +23 -8
- package/dist/index.mjs.map +1 -1
- package/dist/monitor.mjs +12 -13
- package/dist/monitor.mjs.map +1 -1
- package/dist/package.mjs +1 -1
- package/dist/recorder/convert.mjs +25 -25
- package/dist/recorder/convert.mjs.map +1 -1
- package/dist/recorder/download-hls.mjs +77 -0
- package/dist/recorder/download-hls.mjs.map +1 -0
- package/dist/recorder/download-stream.mjs +78 -0
- package/dist/recorder/download-stream.mjs.map +1 -0
- package/dist/recorder/ffmpeg-utils.mjs +122 -0
- package/dist/recorder/ffmpeg-utils.mjs.map +1 -0
- package/dist/recorder/index.d.mts.map +1 -1
- package/dist/recorder/index.mjs +82 -178
- package/dist/recorder/index.mjs.map +1 -1
- package/dist/recorder/post-processing.mjs +150 -0
- package/dist/recorder/post-processing.mjs.map +1 -0
- package/dist/recorder/recorder-events.mjs +34 -0
- package/dist/recorder/recorder-events.mjs.map +1 -0
- package/dist/recorder/recorder-state.mjs +38 -0
- package/dist/recorder/recorder-state.mjs.map +1 -0
- package/dist/recorder/stream.mjs +8 -275
- package/dist/recorder/stream.mjs.map +1 -1
- package/dist/ui.mjs +7 -7
- package/dist/ui.mjs.map +1 -1
- package/dist/utils.mjs +19 -3
- package/dist/utils.mjs.map +1 -1
- 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
|
[](https://github.com/zfadhli/tokrec/actions/workflows/ci.yml)
|
|
10
8
|
[](https://www.npmjs.com/package/@zfadhli/tokrec)
|
|
11
9
|
[](https://www.npmjs.com/package/@zfadhli/tokrec)
|
|
12
10
|
[](https://bun.sh)
|
|
13
11
|
[](LICENSE)
|
|
14
12
|
|
|
15
|
-
[
|
|
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
|
|
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
|
|
30
|
-
- **
|
|
31
|
-
- **
|
|
32
|
-
- **
|
|
33
|
-
- **
|
|
34
|
-
- **
|
|
35
|
-
- **
|
|
36
|
-
- **
|
|
37
|
-
- **
|
|
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
|
|
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
|
|
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
|
|
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
|
|
103
|
+
tiktok-live-recorder -u username -o ./videos -p http://127.0.0.1:8080
|
|
101
104
|
|
|
102
|
-
#
|
|
103
|
-
tiktok-live-recorder -u
|
|
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
|
|
114
|
-
| `--interval <minutes>` | `-i` | `3` | Polling interval (
|
|
115
|
-
| `--duration <minutes>` | `-d` | unlimited |
|
|
116
|
-
| `--segment-minutes <minutes>` | `-s` |
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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.
|
|
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
|
|
165
|
-
│ (every Nm) │ │
|
|
166
|
-
│ │ │
|
|
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** —
|
|
172
|
-
- Fast-path from the
|
|
173
|
-
- Brute-force recursive search of the entire JSON (survives key name changes)
|
|
174
|
-
- Webcast API fallback (`/webcast/room/info
|
|
175
|
-
3. **Download** — the
|
|
176
|
-
4. **
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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+.
|
|
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.
|
|
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
|
|
366
|
+
### The process doesn't exit after a `-d` recording
|
|
314
367
|
|
|
315
|
-
|
|
368
|
+
Make sure you're using v0.7.1 or later. Earlier versions continued polling instead of exiting.
|
package/dist/api/client.mjs
CHANGED
|
@@ -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,
|
|
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: "
|
|
24
|
+
browser: "firefox_149",
|
|
10
25
|
os: "windows",
|
|
11
26
|
proxy: config.proxy
|
|
12
27
|
});
|
|
13
|
-
if (config.cookies
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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:
|
|
28
|
-
body,
|
|
29
|
-
signal: AbortSignal.timeout(15e3),
|
|
40
|
+
method: options?.method,
|
|
41
|
+
body: options?.body,
|
|
30
42
|
headers: {
|
|
31
|
-
|
|
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
|
}
|
package/dist/api/client.mjs.map
CHANGED
|
@@ -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
|
|
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"}
|