@zfadhli/tokrec 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +315 -0
- package/dist/api/tiktok.mjs +64 -11
- package/dist/api/tiktok.mjs.map +1 -1
- package/dist/cli.mjs +25 -4
- package/dist/cli.mjs.map +1 -1
- package/dist/package.mjs +6 -0
- package/dist/package.mjs.map +1 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# tokrec
|
|
4
|
+
|
|
5
|
+
**Minimal TikTok live stream recorder — Bun + TypeScript**
|
|
6
|
+
|
|
7
|
+
<br>
|
|
8
|
+
|
|
9
|
+
[](https://github.com/zfadhli/tokrec/actions/workflows/ci.yml)
|
|
10
|
+
[](https://www.npmjs.com/package/@zfadhli/tokrec)
|
|
11
|
+
[](https://www.npmjs.com/package/@zfadhli/tokrec)
|
|
12
|
+
[](https://bun.sh)
|
|
13
|
+
[](LICENSE)
|
|
14
|
+
|
|
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)
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
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.
|
|
20
|
+
|
|
21
|
+
> [!NOTE]
|
|
22
|
+
> 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.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
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.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
### CLI tool (global)
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm install -g @zfadhli/tokrec
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then run with:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
tiktok-live-recorder -u username
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### One-off use (npx)
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npx @zfadhli/tokrec -u username
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### As a library
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install @zfadhli/tokrec
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { createRecorder } from '@zfadhli/tokrec'
|
|
69
|
+
|
|
70
|
+
const recorder = createRecorder({ user: 'username' })
|
|
71
|
+
recorder.on('recording:end', (info) => console.log('Done:', info.file))
|
|
72
|
+
await recorder.start()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
> [!TIP]
|
|
76
|
+
> When using as a library, logs print to the console by default. To suppress console output (e.g., if you run your own UI), set `logConsole: false` in the config.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
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
|
+
## Quick start
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# Record a live stream (Ctrl+C to stop)
|
|
94
|
+
tiktok-live-recorder -u vierstinrovve
|
|
95
|
+
|
|
96
|
+
# Record for 10 minutes, split into 5-minute segments
|
|
97
|
+
tiktok-live-recorder -u vierstinrovve -d 10 -s 5
|
|
98
|
+
|
|
99
|
+
# Use a proxy and custom output directory
|
|
100
|
+
tiktok-live-recorder -u vierstinrovve -o ./videos -p http://127.0.0.1:8080
|
|
101
|
+
|
|
102
|
+
# Specify a cookies file (see Authentication section)
|
|
103
|
+
tiktok-live-recorder -u vierstinrovve -c ./my-cookies.json
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## CLI options
|
|
109
|
+
|
|
110
|
+
| Option | Shorthand | Default | Description |
|
|
111
|
+
|--------|-----------|---------|-------------|
|
|
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 |
|
|
117
|
+
| `--proxy <url>` | `-p` | none | HTTP proxy URL |
|
|
118
|
+
| `--cookies <path>` | `-c` | `./cookies.json` | Path to cookies JSON file |
|
|
119
|
+
| `--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.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Authentication
|
|
127
|
+
|
|
128
|
+
TikTok's Slardar WAF blocks unauthenticated requests. You must provide a valid `sessionid_ss` cookie to bypass it.
|
|
129
|
+
|
|
130
|
+
### Setup
|
|
131
|
+
|
|
132
|
+
1. Open TikTok in your browser and log in.
|
|
133
|
+
2. Open DevTools → **Application** → **Cookies** → `www.tiktok.com`.
|
|
134
|
+
3. Copy the value of `sessionid_ss` and create a `cookies.json` file:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"sessionid_ss": "6c74daa215a8f34eeddf5162ca091668"
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
You can also include the `tt-target-idc` cookie (optional, for regional routing):
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"sessionid_ss": "6c74daa215a8f34eeddf5162ca091668",
|
|
147
|
+
"tt-target-idc": "useast2a"
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
4. Run the tool with `--cookies ./path/to/cookies.json`, or place `cookies.json` in the working directory (auto-detected).
|
|
152
|
+
|
|
153
|
+
> [!CAUTION]
|
|
154
|
+
> Without valid cookies, the tool will report every user as **offline** because the WAF challenge page won't contain live-stream JSON data.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## How it works
|
|
159
|
+
|
|
160
|
+
### Pipeline
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
┌────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
164
|
+
│ Poll tick │ → │ Detect live │ → │ Download FLV │ → │ FFmpeg post │
|
|
165
|
+
│ (every Nm) │ │ via SIGI_ │ │ (auto-reconn.)│ │ (segment/ │
|
|
166
|
+
│ │ │ STATE scrape │ │ │ │ convert) │
|
|
167
|
+
└────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
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.
|
|
177
|
+
|
|
178
|
+
### Event system
|
|
179
|
+
|
|
180
|
+
The recorder emits typed events that you can subscribe to via `recorder.on()`:
|
|
181
|
+
|
|
182
|
+
| Event | Payload | When |
|
|
183
|
+
|-------|---------|------|
|
|
184
|
+
| `checking` | `{ user }` | Before each poll tick |
|
|
185
|
+
| `tick` | `{ user, isLive, roomId? }` | After checking live status |
|
|
186
|
+
| `recording:start` | `{ user, file }` | When recording begins |
|
|
187
|
+
| `download:progress` | `{ bytes, elapsed, speed }` | During download (~1s intervals) |
|
|
188
|
+
| `download:end` | `{ file, duration, size }` | When download completes |
|
|
189
|
+
| `segmenting:start` | `{ input, outputPattern }` | Before FFmpeg segmenting |
|
|
190
|
+
| `segmenting:end` | `{ segments }` | After segmenting |
|
|
191
|
+
| `converting:start` | `{ input }` | Before simple conversion |
|
|
192
|
+
| `converted` | `{ input, output }` | After each segment/file |
|
|
193
|
+
| `error` | `err: TikTokError` | On non-fatal errors |
|
|
194
|
+
|
|
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
|
+
---
|
|
210
|
+
|
|
211
|
+
## Library API
|
|
212
|
+
|
|
213
|
+
### `createRecorder(config)`
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
import { createRecorder } from '@zfadhli/tokrec'
|
|
217
|
+
import type { RecorderConfig, RecorderController, RecorderEvent } from '@zfadhli/tokrec'
|
|
218
|
+
|
|
219
|
+
const recorder: RecorderController = createRecorder({
|
|
220
|
+
user: 'username', // required
|
|
221
|
+
outputDir: './videos', // default: './recordings'
|
|
222
|
+
interval: 3, // polling interval in minutes
|
|
223
|
+
duration: 600, // max recording duration in seconds
|
|
224
|
+
segmentMinutes: 20, // segment length in minutes
|
|
225
|
+
proxy: 'http://127.0.0.1:8080',
|
|
226
|
+
cookies: { sessionid_ss: '...' },
|
|
227
|
+
logLevel: 'info',
|
|
228
|
+
logConsole: true,
|
|
229
|
+
})
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### `RecorderController`
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
interface RecorderController {
|
|
236
|
+
start(): Promise<void>
|
|
237
|
+
stop(): Promise<void>
|
|
238
|
+
getStatus(): RecorderStatus
|
|
239
|
+
on<E extends RecorderEvent>(event: E, handler: RecorderEventHandler[E]): void
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### `RecorderStatus`
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
interface RecorderStatus {
|
|
247
|
+
state: 'idle' | 'polling' | 'recording' | 'converting' | 'stopped'
|
|
248
|
+
user: string
|
|
249
|
+
currentFile?: string
|
|
250
|
+
sessionDuration?: number
|
|
251
|
+
lastPollTime?: string
|
|
252
|
+
lastError?: string
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Example: watch for live status
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import { createRecorder } from '@zfadhli/tokrec'
|
|
260
|
+
|
|
261
|
+
const recorder = createRecorder({ user: 'username' })
|
|
262
|
+
|
|
263
|
+
recorder.on('checking', ({ user }) => console.log(`Checking @${user}...`))
|
|
264
|
+
recorder.on('tick', ({ isLive, roomId, user }) => {
|
|
265
|
+
console.log(isLive ? `@${user} is LIVE!` : `@${user} is offline`)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
recorder.on('error', (err) => console.error(`[${err.kind}] ${err.message}`))
|
|
269
|
+
|
|
270
|
+
await recorder.start()
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## FAQ
|
|
276
|
+
|
|
277
|
+
### Why does the tool say "offline" for every user?
|
|
278
|
+
|
|
279
|
+
You likely don't have a valid `sessionid_ss` cookie. See the [Authentication](#authentication) section.
|
|
280
|
+
|
|
281
|
+
### Why does the download stop after 30 seconds?
|
|
282
|
+
|
|
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.
|
|
284
|
+
|
|
285
|
+
### Can I record multiple users at the same time?
|
|
286
|
+
|
|
287
|
+
Run multiple instances of the CLI in separate terminals, each with a different `--user` and `--output`.
|
|
288
|
+
|
|
289
|
+
### Can I use this with Node.js instead of Bun?
|
|
290
|
+
|
|
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.
|
|
292
|
+
|
|
293
|
+
### Where are the log files?
|
|
294
|
+
|
|
295
|
+
`tiktok-recorder.log` in the working directory, with rotating backups. File logs include full timestamps and levels for debugging.
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## Troubleshooting
|
|
300
|
+
|
|
301
|
+
### FFmpeg not found
|
|
302
|
+
|
|
303
|
+
Install FFmpeg and ensure it is on your `$PATH`:
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
which ffmpeg # should print a path
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### "WAF blocked" errors
|
|
310
|
+
|
|
311
|
+
Your `sessionid_ss` cookie has expired or is invalid. Generate a fresh one from your browser's DevTools.
|
|
312
|
+
|
|
313
|
+
### The spinner freezes during download
|
|
314
|
+
|
|
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.
|
package/dist/api/tiktok.mjs
CHANGED
|
@@ -28,29 +28,82 @@ function createTikTokApi(http) {
|
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
30
|
async function fetchLiveInfo(user) {
|
|
31
|
+
let sigi = null;
|
|
31
32
|
try {
|
|
32
33
|
const res = await http.get(`${TIKTOK_BASE}/@${user}/live`);
|
|
33
34
|
if (!res.ok) return null;
|
|
34
|
-
|
|
35
|
+
sigi = extractSigiState(await res.text());
|
|
35
36
|
if (!sigi) return null;
|
|
36
37
|
const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom;
|
|
37
38
|
const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user;
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
if (liveRoom && userInfo?.roomId) {
|
|
40
|
+
const roomId = String(userInfo.roomId);
|
|
41
|
+
const isLive = liveRoom.status === 2;
|
|
42
|
+
let streamUrl = null;
|
|
43
|
+
if (isLive) {
|
|
44
|
+
streamUrl = extractStreamUrlFast(liveRoom);
|
|
45
|
+
if (!streamUrl) streamUrl = findStreamUrlRecursively(sigi);
|
|
46
|
+
if (!streamUrl) streamUrl = await fetchStreamUrlFromApi(roomId);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
roomId,
|
|
50
|
+
isLive,
|
|
51
|
+
streamUrl,
|
|
52
|
+
title: liveRoom.title ?? null
|
|
53
|
+
};
|
|
46
54
|
}
|
|
55
|
+
} catch (_err) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return fetchLiveInfoFallback(sigi, user);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Secondary live-info extraction when SIGI_STATE's LiveRoom data is
|
|
62
|
+
* async-loaded. Uses LiveRoom.liveRoomStatus for the live flag and
|
|
63
|
+
* UserModule.users[user].roomId for the room ID. Stream URL is obtained
|
|
64
|
+
* from the Webcast API (room/info/) as a final fallback.
|
|
65
|
+
*/
|
|
66
|
+
async function fetchLiveInfoFallback(sigi, user) {
|
|
67
|
+
const isLive = sigi.LiveRoom?.liveRoomStatus === 2;
|
|
68
|
+
const userModule = sigi.UserModule?.users?.[user];
|
|
69
|
+
let roomId = null;
|
|
70
|
+
if (userModule?.roomId) roomId = typeof userModule.roomId === "object" ? String(userModule.roomId.id ?? "") : String(userModule.roomId);
|
|
71
|
+
if (roomId) {
|
|
72
|
+
const streamUrl = isLive ? await fetchStreamUrlFromApi(roomId) : null;
|
|
47
73
|
return {
|
|
48
74
|
roomId,
|
|
49
75
|
isLive,
|
|
50
76
|
streamUrl,
|
|
51
|
-
title:
|
|
77
|
+
title: null
|
|
52
78
|
};
|
|
53
|
-
}
|
|
79
|
+
}
|
|
80
|
+
if (userModule?.id && isLive) return fetchRoomInfoFromApi(userModule.id);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Fallback: fetch room info + stream URL from the Webcast API.
|
|
85
|
+
* This covers cases where SIGI_STATE has no room data at all
|
|
86
|
+
* (fully async-loaded page).
|
|
87
|
+
*/
|
|
88
|
+
async function fetchRoomInfoFromApi(userId) {
|
|
89
|
+
try {
|
|
90
|
+
const url = `${WEBCAST_BASE}/webcast/room/enter/?aid=1988&user_id=${userId}`;
|
|
91
|
+
const res = await http.get(url);
|
|
92
|
+
if (!res.ok) return null;
|
|
93
|
+
const text = await res.text();
|
|
94
|
+
if (text.length === 0) return null;
|
|
95
|
+
const roomData = JSON.parse(text)?.data?.room;
|
|
96
|
+
if (!roomData) return null;
|
|
97
|
+
const roomId = String(roomData.roomId ?? "");
|
|
98
|
+
if (!roomId) return null;
|
|
99
|
+
const isLive = roomData.status === 2;
|
|
100
|
+
return {
|
|
101
|
+
roomId,
|
|
102
|
+
isLive,
|
|
103
|
+
streamUrl: isLive ? findStreamUrlRecursively(roomData) : null,
|
|
104
|
+
title: roomData.title ?? null
|
|
105
|
+
};
|
|
106
|
+
} catch {
|
|
54
107
|
return null;
|
|
55
108
|
}
|
|
56
109
|
}
|
package/dist/api/tiktok.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tiktok.mjs","names":[],"sources":["../../src/api/tiktok.ts"],"sourcesContent":["/**\n * TikTok API — resolves room IDs, live status, and stream URLs from a single\n * SIGI_STATE HTML scrape. No webcast API calls needed.\n *\n * Architecture:\n * createTikTokApi(httpClient) → { getRoomId, isRoomAlive, getLiveUrl }\n *\n * All public methods share a per-tick cache so the page is fetched at most once\n * per poll cycle.\n */\n\nimport type { HttpClient } from './client'\n\nexport interface TikTokApi {\n /** Resolve a username to a room ID. Returns null if not found. */\n getRoomId(user: string): Promise<string | null>\n /** Check if a room is currently live. */\n isRoomAlive(roomId: string): Promise<boolean>\n /** Get the best-quality FLV stream URL for a room. Returns null on failure. */\n getLiveUrl(roomId: string): Promise<string | null>\n /** Invalidate per-tick cache (call at start of each poll tick). */\n invalidateCache(): void\n}\n\nconst TIKTOK_BASE = 'https://www.tiktok.com'\nconst WEBCAST_BASE = 'https://webcast.tiktok.com'\n\nexport function createTikTokApi(http: HttpClient): TikTokApi {\n // Per-tick cache — stores the last fetched live info so the three public\n // methods don't each fetch the page separately.\n let cached: LiveInfo | null = null\n let cachedUser = ''\n\n async function ensureCache(user: string): Promise<LiveInfo | null> {\n if (cached && cachedUser === user) return cached\n cached = await fetchLiveInfo(user)\n cachedUser = user\n return cached\n }\n\n function invalidateCache(): void {\n cached = null\n cachedUser = ''\n }\n\n return {\n invalidateCache,\n\n async getRoomId(user: string): Promise<string | null> {\n const info = await ensureCache(user)\n return info?.roomId ?? null\n },\n\n async isRoomAlive(roomId: string): Promise<boolean> {\n if (cached && String(cached.roomId) === String(roomId)) {\n return cached.isLive\n }\n const info = await fetchLiveInfo(cachedUser || roomId)\n return info?.isLive ?? false\n },\n\n async getLiveUrl(roomId: string): Promise<string | null> {\n const info = await ensureCache(cachedUser || roomId)\n return info?.streamUrl ?? null\n },\n }\n\n // ─── Internal: fetch and parse SIGI_STATE ─────────────────\n\n async function fetchLiveInfo(user: string): Promise<LiveInfo | null> {\n try {\n const res = await http.get(`${TIKTOK_BASE}/@${user}/live`)\n if (!res.ok) return null\n\n const html = await res.text()\n const sigi = extractSigiState(html)\n if (!sigi) return null\n\n const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom\n const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user\n\n if (!liveRoom || !userInfo?.roomId) return null\n\n const roomId = String(userInfo.roomId)\n const isLive = liveRoom.status === 2 // 2 = live, 4 = offline\n\n // Extract stream URL: try SIGI_STATE first, then Webcast API fallback\n let streamUrl: string | null = null\n if (isLive) {\n streamUrl = extractStreamUrlFast(liveRoom)\n if (!streamUrl) {\n streamUrl = findStreamUrlRecursively(sigi)\n }\n if (!streamUrl) {\n streamUrl = await fetchStreamUrlFromApi(roomId)\n }\n }\n\n return { roomId, isLive, streamUrl, title: liveRoom.title ?? null }\n } catch (_err) {\n // Timeout (15s) or network error → treat as offline\n return null\n }\n }\n\n /**\n * Fallback: fetch stream URL from the Webcast API.\n * This covers cases where SIGI_STATE doesn't include stream data\n * (e.g. async-loaded room info).\n */\n async function fetchStreamUrlFromApi(roomId: string): Promise<string | null> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`\n const res = await http.get(url)\n if (!res.ok) return null\n\n const text = await res.text()\n if (text.length === 0) return null\n\n const data = JSON.parse(text) as Record<string, unknown>\n\n // status_code 4003110 = live restriction / room not found\n if ((data as any).status_code === 4003110) return null\n\n return findStreamUrlRecursively(data)\n } catch {\n return null\n }\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────\n\ninterface LiveInfo {\n roomId: string\n isLive: boolean\n streamUrl: string | null\n title: string | null\n}\n\ninterface SigiState {\n LiveRoom?: {\n liveRoomStatus?: number\n liveRoomUserInfo?: {\n user?: {\n roomId?: string | number\n }\n liveRoom?: {\n status?: number // 2 = live, 4 = offline\n title?: string\n startTime?: number\n streamData?: {\n pull_data?: {\n stream_data?: string // JSON string with FLV/HLS URLs\n }\n }\n }\n }\n }\n}\n\n// ─── HTML parsing ──────────────────────────────────────────\n\nfunction extractSigiState(html: string): SigiState | null {\n const match = html.match(/<script\\s+id=\"SIGI_STATE\"\\s+type=\"application\\/json\">(.*?)<\\/script>/)\n if (!match?.[1]) return null\n try {\n return JSON.parse(match[1]) as SigiState\n } catch {\n return null\n }\n}\n\n// ─── Stream URL extraction ─────────────────────────────────\n\n/**\n * Fast-path: extract stream URL from the known SIGI_STATE location.\n * This covers ~95% of cases without needing a full recursive search.\n */\nfunction extractStreamUrlFast(\n liveRoom: NonNullable<NonNullable<SigiState['LiveRoom']>['liveRoomUserInfo']>['liveRoom'],\n): string | null {\n try {\n const raw = liveRoom?.streamData?.pull_data?.stream_data\n if (!raw) return null\n const parsed = JSON.parse(raw) as Record<string, unknown>\n const data = (parsed as Record<string, unknown>)?.data as Record<string, unknown> | undefined\n if (!data) return null\n\n // Try all known quality keys, preferring higher quality\n const qualities = ['origin', 'fhd', 'uhd', 'hd', 'sd', 'ld']\n for (const q of qualities) {\n const entry = data[q] as { main?: { flv?: string } } | undefined\n const url = entry?.main?.flv\n if (url) return url\n }\n return null\n } catch {\n return null\n }\n}\n\n/**\n * Brute-force recursive search for a stream URL anywhere in a JSON value.\n * Survives TikTok changing key names or nesting structure.\n *\n * Inspired by PR #430 (Michele0303/tiktok-live-recorder).\n */\nexport function findStreamUrlRecursively(obj: unknown): string | null {\n // Base case: a string\n if (typeof obj === 'string') {\n // Direct URL match\n if (\n (obj.startsWith('http://') || obj.startsWith('https://')) &&\n (obj.includes('.flv') || obj.includes('.m3u8'))\n ) {\n return obj\n }\n // JSON-encoded string — parse and recurse (covers stream_data, etc.)\n if (obj.startsWith('{') || obj.startsWith('[')) {\n try {\n return findStreamUrlRecursively(JSON.parse(obj))\n } catch {\n return null\n }\n }\n return null\n }\n\n // Dict: recurse into all values\n if (obj && typeof obj === 'object' && !Array.isArray(obj)) {\n for (const val of Object.values(obj as Record<string, unknown>)) {\n const found = findStreamUrlRecursively(val)\n if (found) return found\n }\n return null\n }\n\n // Array: recurse into all elements\n if (Array.isArray(obj)) {\n for (const item of obj) {\n const found = findStreamUrlRecursively(item)\n if (found) return found\n }\n }\n\n return null\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tiktok.mjs","names":[],"sources":["../../src/api/tiktok.ts"],"sourcesContent":["/**\n * TikTok API — resolves room IDs, live status, and stream URLs.\n *\n * Primary path: parse SIGI_STATE from the user's /live page (fast, ~95% of\n * cases). Fallback paths handle async-loaded room info by reading\n * UserModule / LiveRoom.liveRoomStatus from SIGI_STATE and, when needed,\n * calling the Webcast API (room/info/ or room/enter/).\n *\n * Architecture:\n * createTikTokApi(httpClient) → { getRoomId, isRoomAlive, getLiveUrl }\n *\n * All public methods share a per-tick cache so the page is fetched at most once\n * per poll cycle.\n */\n\nimport type { HttpClient } from \"./client\";\n\nexport interface TikTokApi {\n /** Resolve a username to a room ID. Returns null if not found. */\n getRoomId(user: string): Promise<string | null>;\n /** Check if a room is currently live. */\n isRoomAlive(roomId: string): Promise<boolean>;\n /** Get the best-quality FLV stream URL for a room. Returns null on failure. */\n getLiveUrl(roomId: string): Promise<string | null>;\n /** Invalidate per-tick cache (call at start of each poll tick). */\n invalidateCache(): void;\n}\n\nconst TIKTOK_BASE = \"https://www.tiktok.com\";\nconst WEBCAST_BASE = \"https://webcast.tiktok.com\";\n\nexport function createTikTokApi(http: HttpClient): TikTokApi {\n // Per-tick cache — stores the last fetched live info so the three public\n // methods don't each fetch the page separately.\n let cached: LiveInfo | null = null;\n let cachedUser = \"\";\n\n async function ensureCache(user: string): Promise<LiveInfo | null> {\n if (cached && cachedUser === user) return cached;\n cached = await fetchLiveInfo(user);\n cachedUser = user;\n return cached;\n }\n\n function invalidateCache(): void {\n cached = null;\n cachedUser = \"\";\n }\n\n return {\n invalidateCache,\n\n async getRoomId(user: string): Promise<string | null> {\n const info = await ensureCache(user);\n return info?.roomId ?? null;\n },\n\n async isRoomAlive(roomId: string): Promise<boolean> {\n if (cached && String(cached.roomId) === String(roomId)) {\n return cached.isLive;\n }\n const info = await fetchLiveInfo(cachedUser || roomId);\n return info?.isLive ?? false;\n },\n\n async getLiveUrl(roomId: string): Promise<string | null> {\n const info = await ensureCache(cachedUser || roomId);\n return info?.streamUrl ?? null;\n },\n };\n\n // ─── Internal: fetch and parse SIGI_STATE ─────────────────\n\n async function fetchLiveInfo(user: string): Promise<LiveInfo | null> {\n // Track whether SIGI_STATE actually existed (for retry decisions)\n let sigi: SigiState | null = null;\n\n try {\n const res = await http.get(`${TIKTOK_BASE}/@${user}/live`);\n if (!res.ok) return null;\n\n const html = await res.text();\n sigi = extractSigiState(html);\n if (!sigi) return null;\n\n // ── Primary path: room info in LiveRoom.liveRoomUserInfo ──\n const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom;\n const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user;\n\n if (liveRoom && userInfo?.roomId) {\n const roomId = String(userInfo.roomId);\n const isLive = liveRoom.status === 2; // 2 = live, 4 = offline\n\n // Extract stream URL: try SIGI_STATE first, then Webcast API fallback\n let streamUrl: string | null = null;\n if (isLive) {\n streamUrl = extractStreamUrlFast(liveRoom);\n if (!streamUrl) streamUrl = findStreamUrlRecursively(sigi);\n if (!streamUrl) streamUrl = await fetchStreamUrlFromApi(roomId);\n }\n\n return { roomId, isLive, streamUrl, title: liveRoom.title ?? null };\n }\n } catch (_err) {\n // Timeout (15s) or network error → treat as offline\n return null;\n }\n\n // ── Fallback path: async-loaded room info ──\n // When LiveRoom.liveRoomUserInfo is absent, check LiveRoom.liveRoomStatus\n // and UserModule.users[user].roomId, then use the Webcast API for stream URL.\n return fetchLiveInfoFallback(sigi, user);\n }\n\n /**\n * Secondary live-info extraction when SIGI_STATE's LiveRoom data is\n * async-loaded. Uses LiveRoom.liveRoomStatus for the live flag and\n * UserModule.users[user].roomId for the room ID. Stream URL is obtained\n * from the Webcast API (room/info/) as a final fallback.\n */\n async function fetchLiveInfoFallback(\n sigi: SigiState,\n user: string,\n ): Promise<LiveInfo | null> {\n const liveRoomStatus = sigi.LiveRoom?.liveRoomStatus;\n const isLive = liveRoomStatus === 2; // 2 = live\n\n // Try to get roomId from UserModule (usually populated even when LiveRoom is not)\n const userModule = sigi.UserModule?.users?.[user];\n let roomId: string | null = null;\n\n if (userModule?.roomId) {\n roomId =\n typeof userModule.roomId === \"object\"\n ? String((userModule.roomId as { id: string }).id ?? \"\")\n : String(userModule.roomId);\n }\n\n // If we have a roomId, use Webcast room/info for the stream URL\n if (roomId) {\n const streamUrl = isLive ? await fetchStreamUrlFromApi(roomId) : null;\n return { roomId, isLive, streamUrl, title: null };\n }\n\n // Final fallback: Webcast room/enter API using the numeric user ID\n if (userModule?.id && isLive) {\n return fetchRoomInfoFromApi(userModule.id);\n }\n\n return null; // truly offline / unknown\n }\n\n /**\n * Fallback: fetch room info + stream URL from the Webcast API.\n * This covers cases where SIGI_STATE has no room data at all\n * (fully async-loaded page).\n */\n async function fetchRoomInfoFromApi(\n userId: string,\n ): Promise<LiveInfo | null> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/enter/?aid=1988&user_id=${userId}`;\n const res = await http.get(url);\n if (!res.ok) return null;\n\n const text = await res.text();\n if (text.length === 0) return null;\n\n const data = JSON.parse(text) as Record<string, unknown>;\n const roomData = (data as any)?.data?.room;\n if (!roomData) return null;\n\n const roomId = String(roomData.roomId ?? \"\");\n if (!roomId) return null;\n\n const roomStatus = roomData.status; // 2 = live\n const isLive = roomStatus === 2;\n const streamUrl = isLive ? findStreamUrlRecursively(roomData) : null;\n\n return { roomId, isLive, streamUrl, title: roomData.title ?? null };\n } catch {\n return null;\n }\n }\n\n /**\n * Fallback: fetch stream URL from the Webcast API.\n * This covers cases where SIGI_STATE doesn't include stream data\n * (e.g. async-loaded room info).\n */\n async function fetchStreamUrlFromApi(roomId: string): Promise<string | null> {\n try {\n const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`;\n const res = await http.get(url);\n if (!res.ok) return null;\n\n const text = await res.text();\n if (text.length === 0) return null;\n\n const data = JSON.parse(text) as Record<string, unknown>;\n\n // status_code 4003110 = live restriction / room not found\n if ((data as any).status_code === 4003110) return null;\n\n return findStreamUrlRecursively(data);\n } catch {\n return null;\n }\n }\n}\n\n// ─── Types ──────────────────────────────────────────────────\n\ninterface LiveInfo {\n roomId: string;\n isLive: boolean;\n streamUrl: string | null;\n title: string | null;\n}\n\ninterface SigiState {\n LiveRoom?: {\n liveRoomStatus?: number;\n liveRoomUserInfo?: {\n user?: {\n id?: string | number;\n roomId?: string | number;\n };\n liveRoom?: {\n status?: number; // 2 = live, 4 = offline\n title?: string;\n startTime?: number;\n streamData?: {\n pull_data?: {\n stream_data?: string; // JSON string with FLV/HLS URLs\n };\n };\n };\n };\n };\n UserModule?: {\n users?: {\n [username: string]: {\n id?: string; // numeric user ID\n uniqueId?: string;\n roomId?: string | { id: string };\n };\n };\n };\n}\n\n// ─── HTML parsing ──────────────────────────────────────────\n\nfunction extractSigiState(html: string): SigiState | null {\n const match = html.match(\n /<script\\s+id=\"SIGI_STATE\"\\s+type=\"application\\/json\">(.*?)<\\/script>/,\n );\n if (!match?.[1]) return null;\n try {\n return JSON.parse(match[1]) as SigiState;\n } catch {\n return null;\n }\n}\n\n// ─── Stream URL extraction ─────────────────────────────────\n\n/**\n * Fast-path: extract stream URL from the known SIGI_STATE location.\n * This covers ~95% of cases without needing a full recursive search.\n */\nfunction extractStreamUrlFast(\n liveRoom: NonNullable<\n NonNullable<SigiState[\"LiveRoom\"]>[\"liveRoomUserInfo\"]\n >[\"liveRoom\"],\n): string | null {\n try {\n const raw = liveRoom?.streamData?.pull_data?.stream_data;\n if (!raw) return null;\n const parsed = JSON.parse(raw) as Record<string, unknown>;\n const data = (parsed as Record<string, unknown>)?.data as\n | Record<string, unknown>\n | undefined;\n if (!data) return null;\n\n // Try all known quality keys, preferring higher quality\n const qualities = [\"origin\", \"fhd\", \"uhd\", \"hd\", \"sd\", \"ld\"];\n for (const q of qualities) {\n const entry = data[q] as { main?: { flv?: string } } | undefined;\n const url = entry?.main?.flv;\n if (url) return url;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/**\n * Brute-force recursive search for a stream URL anywhere in a JSON value.\n * Survives TikTok changing key names or nesting structure.\n *\n * Inspired by PR #430 (Michele0303/tiktok-live-recorder).\n */\nexport function findStreamUrlRecursively(obj: unknown): string | null {\n // Base case: a string\n if (typeof obj === \"string\") {\n // Direct URL match\n if (\n (obj.startsWith(\"http://\") || obj.startsWith(\"https://\")) &&\n (obj.includes(\".flv\") || obj.includes(\".m3u8\"))\n ) {\n return obj;\n }\n // JSON-encoded string — parse and recurse (covers stream_data, etc.)\n if (obj.startsWith(\"{\") || obj.startsWith(\"[\")) {\n try {\n return findStreamUrlRecursively(JSON.parse(obj));\n } catch {\n return null;\n }\n }\n return null;\n }\n\n // Dict: recurse into all values\n if (obj && typeof obj === \"object\" && !Array.isArray(obj)) {\n for (const val of Object.values(obj as Record<string, unknown>)) {\n const found = findStreamUrlRecursively(val);\n if (found) return found;\n }\n return null;\n }\n\n // Array: recurse into all elements\n if (Array.isArray(obj)) {\n for (const item of obj) {\n const found = findStreamUrlRecursively(item);\n if (found) return found;\n }\n }\n\n return null;\n}\n"],"mappings":"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"}
|
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TikTokError } from "./config.mjs";
|
|
2
2
|
import { sanitizeUser } from "./utils.mjs";
|
|
3
|
+
import { version } from "./package.mjs";
|
|
3
4
|
import { createCLI } from "@zfadhli/koko-cli";
|
|
4
5
|
//#region src/cli.ts
|
|
5
6
|
/**
|
|
@@ -9,9 +10,9 @@ import { createCLI } from "@zfadhli/koko-cli";
|
|
|
9
10
|
function parseArgs(argv = process.argv) {
|
|
10
11
|
let config;
|
|
11
12
|
let error;
|
|
12
|
-
const cli = createCLI("tiktok-live-recorder",
|
|
13
|
+
const cli = createCLI("tiktok-live-recorder", version).description("Minimal TikTok live stream recorder — Bun + TypeScript");
|
|
13
14
|
cli.command("", "Record a TikTok live stream", (cmd) => {
|
|
14
|
-
cmd.option("-u, --user <name>", "TikTok username (
|
|
15
|
+
cmd.option("-u, --user <name>", "TikTok username (or pass as first argument)");
|
|
15
16
|
cmd.option("-o, --output <dir>", "Output directory", { default: "./recordings" });
|
|
16
17
|
cmd.option("-i, --interval <minutes>", "Polling interval in minutes", { default: "3" });
|
|
17
18
|
cmd.option("-d, --duration <minutes>", "Max recording duration in minutes");
|
|
@@ -22,7 +23,7 @@ function parseArgs(argv = process.argv) {
|
|
|
22
23
|
cmd.action((opts) => {
|
|
23
24
|
try {
|
|
24
25
|
const user = opts.user;
|
|
25
|
-
if (!user) throw new TikTokError("config-error", "
|
|
26
|
+
if (!user) throw new TikTokError("config-error", "<user> is required (positional or --user)");
|
|
26
27
|
const parsed = { user: sanitizeUser(user) };
|
|
27
28
|
if (opts.output) parsed.outputDir = opts.output;
|
|
28
29
|
if (opts.interval !== void 0) {
|
|
@@ -58,11 +59,31 @@ function parseArgs(argv = process.argv) {
|
|
|
58
59
|
}
|
|
59
60
|
});
|
|
60
61
|
});
|
|
62
|
+
const positionalUser = extractPositional(argv);
|
|
63
|
+
if (positionalUser) argv.push("--user", positionalUser);
|
|
61
64
|
cli.parse(argv);
|
|
62
65
|
if (error) throw new TikTokError("config-error", error);
|
|
63
|
-
if (!config) throw new TikTokError("config-error", "
|
|
66
|
+
if (!config) throw new TikTokError("config-error", "<user> is required (positional or --user). Use --help for usage.");
|
|
64
67
|
return config;
|
|
65
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Scan argv for the first bare argument that is neither a flag nor a flag's value.
|
|
71
|
+
* Returns the value and removes it from the array, or null if none found.
|
|
72
|
+
*/
|
|
73
|
+
function extractPositional(argv) {
|
|
74
|
+
for (let i = 2; i < argv.length; i++) {
|
|
75
|
+
const arg = argv[i];
|
|
76
|
+
if (arg === "--") return argv.splice(i, 1)[0];
|
|
77
|
+
if (arg.startsWith("-")) {
|
|
78
|
+
const next = argv[i + 1];
|
|
79
|
+
if (next && !next.startsWith("-") && arg !== "--help" && arg !== "--version") i++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
argv.splice(i, 1);
|
|
83
|
+
return arg;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
66
87
|
//#endregion
|
|
67
88
|
export { parseArgs };
|
|
68
89
|
|
package/dist/cli.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.mjs","names":[],"sources":["../src/cli.ts"],"sourcesContent":["/**\n * CLI argument parser — uses koko-cli's createCLI (wraps cac).\n * Exports a single parseArgs() function.\n */\n\nimport { createCLI } from '@zfadhli/koko-cli'\nimport type { LogLevel, RecorderConfig } from './config'\nimport { TikTokError } from './config'\nimport { sanitizeUser } from './utils'\n\nexport function parseArgs(argv: string[] = process.argv): RecorderConfig {\n let config: RecorderConfig | undefined\n let error: string | undefined\n\n const cli = createCLI('tiktok-live-recorder',
|
|
1
|
+
{"version":3,"file":"cli.mjs","names":["pkg.version"],"sources":["../src/cli.ts"],"sourcesContent":["/**\n * CLI argument parser — uses koko-cli's createCLI (wraps cac).\n * Exports a single parseArgs() function.\n */\n\nimport { createCLI } from '@zfadhli/koko-cli'\nimport pkg from '../package.json'\nimport type { LogLevel, RecorderConfig } from './config'\nimport { TikTokError } from './config'\nimport { sanitizeUser } from './utils'\n\nexport function parseArgs(argv: string[] = process.argv): RecorderConfig {\n let config: RecorderConfig | undefined\n let error: string | undefined\n\n const cli = createCLI('tiktok-live-recorder', pkg.version).description(\n 'Minimal TikTok live stream recorder — Bun + TypeScript',\n )\n\n cli.command('', 'Record a TikTok live stream', (cmd) => {\n cmd.option('-u, --user <name>', 'TikTok username (or pass as first argument)')\n cmd.option('-o, --output <dir>', 'Output directory', { default: './recordings' })\n cmd.option('-i, --interval <minutes>', 'Polling interval in minutes', { default: '3' })\n cmd.option('-d, --duration <minutes>', 'Max recording duration in minutes')\n cmd.option('-p, --proxy <url>', 'HTTP proxy (e.g. http://127.0.0.1:8080)')\n cmd.option('-l, --log-level <level>', 'Log level: debug | info | warn | error')\n cmd.option('-c, --cookies <path>', 'Path to cookies.json')\n cmd.option('-s, --segment-minutes <minutes>', 'Segment duration in minutes', { default: '20' })\n\n cmd.action((opts: Record<string, unknown>) => {\n try {\n const user = opts.user as string | undefined\n if (!user) {\n throw new TikTokError('config-error', '<user> is required (positional or --user)')\n }\n\n const parsed: RecorderConfig = {\n user: sanitizeUser(user),\n }\n\n if (opts.output) parsed.outputDir = opts.output as string\n if (opts.interval !== undefined) {\n const n = Number(opts.interval)\n if (!Number.isFinite(n) || n < 1) {\n throw new TikTokError('config-error', '--interval must be a number >= 1')\n }\n parsed.interval = n\n }\n if (opts.duration !== undefined) {\n const n = Number(opts.duration)\n if (!Number.isFinite(n) || n < 0) {\n throw new TikTokError('config-error', '--duration must be a number >= 0 (minutes)')\n }\n parsed.duration = n * 60\n }\n if (opts.proxy) parsed.proxy = opts.proxy as string\n if (opts.logLevel) {\n const levels = ['debug', 'info', 'warn', 'error']\n if (!levels.includes(opts.logLevel as string)) {\n throw new TikTokError(\n 'config-error',\n `--log-level must be one of: ${levels.join(', ')}`,\n )\n }\n parsed.logLevel = opts.logLevel as LogLevel\n }\n if (opts.cookies) parsed.cookiesPath = opts.cookies as string\n if (opts.segmentMinutes !== undefined) {\n const n = Number(opts.segmentMinutes)\n if (!Number.isFinite(n) || n < 1) {\n throw new TikTokError('config-error', '--segment-minutes must be a number >= 1')\n }\n parsed.segmentMinutes = n\n }\n\n config = parsed\n } catch (err) {\n error = err instanceof Error ? err.message : String(err)\n }\n })\n })\n\n // If the first bare argument (not a flag or flag value) looks like a username,\n // convert it to --user for cac (which only handles options on the default command).\n const positionalUser = extractPositional(argv)\n if (positionalUser) {\n argv.push('--user', positionalUser)\n }\n\n cli.parse(argv)\n\n if (error) {\n throw new TikTokError('config-error', error)\n }\n\n if (!config) {\n throw new TikTokError(\n 'config-error',\n '<user> is required (positional or --user). Use --help for usage.',\n )\n }\n\n return config\n}\n\n/**\n * Scan argv for the first bare argument that is neither a flag nor a flag's value.\n * Returns the value and removes it from the array, or null if none found.\n */\nfunction extractPositional(argv: string[]): string | null {\n for (let i = 2; i < argv.length; i++) {\n const arg = argv[i]!\n // -- signals end of options; anything after is a positional\n if (arg === '--') {\n const pos = argv.splice(i, 1)[0]!\n return pos\n }\n // If this arg is a flag (-*) the next arg is its value\n if (arg.startsWith('-')) {\n // Skip the value if this flag takes one (not a boolean flag like --help/--version)\n const next = argv[i + 1]\n if (next && !next.startsWith('-') && arg !== '--help' && arg !== '--version') {\n i++ // skip the value\n }\n continue\n }\n // Found a bare positional — remove it and return\n argv.splice(i, 1)\n return arg\n }\n return null\n}\n"],"mappings":";;;;;;;;;AAWA,SAAgB,UAAU,OAAiB,QAAQ,MAAsB;CACvE,IAAI;CACJ,IAAI;CAEJ,MAAM,MAAM,UAAU,wBAAwBA,OAAW,CAAC,CAAC,YACzD,wDACF;CAEA,IAAI,QAAQ,IAAI,gCAAgC,QAAQ;EACtD,IAAI,OAAO,qBAAqB,6CAA6C;EAC7E,IAAI,OAAO,sBAAsB,oBAAoB,EAAE,SAAS,eAAe,CAAC;EAChF,IAAI,OAAO,4BAA4B,+BAA+B,EAAE,SAAS,IAAI,CAAC;EACtF,IAAI,OAAO,4BAA4B,mCAAmC;EAC1E,IAAI,OAAO,qBAAqB,yCAAyC;EACzE,IAAI,OAAO,2BAA2B,wCAAwC;EAC9E,IAAI,OAAO,wBAAwB,sBAAsB;EACzD,IAAI,OAAO,mCAAmC,+BAA+B,EAAE,SAAS,KAAK,CAAC;EAE9F,IAAI,QAAQ,SAAkC;GAC5C,IAAI;IACF,MAAM,OAAO,KAAK;IAClB,IAAI,CAAC,MACH,MAAM,IAAI,YAAY,gBAAgB,2CAA2C;IAGnF,MAAM,SAAyB,EAC7B,MAAM,aAAa,IAAI,EACzB;IAEA,IAAI,KAAK,QAAQ,OAAO,YAAY,KAAK;IACzC,IAAI,KAAK,aAAa,KAAA,GAAW;KAC/B,MAAM,IAAI,OAAO,KAAK,QAAQ;KAC9B,IAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,GAC7B,MAAM,IAAI,YAAY,gBAAgB,kCAAkC;KAE1E,OAAO,WAAW;IACpB;IACA,IAAI,KAAK,aAAa,KAAA,GAAW;KAC/B,MAAM,IAAI,OAAO,KAAK,QAAQ;KAC9B,IAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,GAC7B,MAAM,IAAI,YAAY,gBAAgB,4CAA4C;KAEpF,OAAO,WAAW,IAAI;IACxB;IACA,IAAI,KAAK,OAAO,OAAO,QAAQ,KAAK;IACpC,IAAI,KAAK,UAAU;KACjB,MAAM,SAAS;MAAC;MAAS;MAAQ;MAAQ;KAAO;KAChD,IAAI,CAAC,OAAO,SAAS,KAAK,QAAkB,GAC1C,MAAM,IAAI,YACR,gBACA,+BAA+B,OAAO,KAAK,IAAI,GACjD;KAEF,OAAO,WAAW,KAAK;IACzB;IACA,IAAI,KAAK,SAAS,OAAO,cAAc,KAAK;IAC5C,IAAI,KAAK,mBAAmB,KAAA,GAAW;KACrC,MAAM,IAAI,OAAO,KAAK,cAAc;KACpC,IAAI,CAAC,OAAO,SAAS,CAAC,KAAK,IAAI,GAC7B,MAAM,IAAI,YAAY,gBAAgB,yCAAyC;KAEjF,OAAO,iBAAiB;IAC1B;IAEA,SAAS;GACX,SAAS,KAAK;IACZ,QAAQ,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GACzD;EACF,CAAC;CACH,CAAC;CAID,MAAM,iBAAiB,kBAAkB,IAAI;CAC7C,IAAI,gBACF,KAAK,KAAK,UAAU,cAAc;CAGpC,IAAI,MAAM,IAAI;CAEd,IAAI,OACF,MAAM,IAAI,YAAY,gBAAgB,KAAK;CAG7C,IAAI,CAAC,QACH,MAAM,IAAI,YACR,gBACA,kEACF;CAGF,OAAO;AACT;;;;;AAMA,SAAS,kBAAkB,MAA+B;CACxD,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;EACpC,MAAM,MAAM,KAAK;EAEjB,IAAI,QAAQ,MAEV,OADY,KAAK,OAAO,GAAG,CAAC,CAAC,CAAC;EAIhC,IAAI,IAAI,WAAW,GAAG,GAAG;GAEvB,MAAM,OAAO,KAAK,IAAI;GACtB,IAAI,QAAQ,CAAC,KAAK,WAAW,GAAG,KAAK,QAAQ,YAAY,QAAQ,aAC/D;GAEF;EACF;EAEA,KAAK,OAAO,GAAG,CAAC;EAChB,OAAO;CACT;CACA,OAAO;AACT"}
|
package/dist/package.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"package.mjs","names":[],"sources":["../package.json"],"sourcesContent":[""],"mappings":""}
|