@zfadhli/tokrec 0.13.0 → 0.15.2
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 +99 -403
- package/dist/app/abort.mjs +14 -0
- package/dist/app/abort.mjs.map +1 -0
- package/dist/{lib → app}/config.d.mts +73 -71
- package/dist/app/config.d.mts.map +1 -0
- package/dist/app/config.mjs +26 -0
- package/dist/app/config.mjs.map +1 -0
- package/dist/app/events.mjs +36 -0
- package/dist/app/events.mjs.map +1 -0
- package/dist/app/job.mjs +190 -0
- package/dist/app/job.mjs.map +1 -0
- package/dist/{lib → app}/logger.mjs +1 -1
- package/dist/app/logger.mjs.map +1 -0
- package/dist/{core/monitor.mjs → app/loop.mjs} +19 -9
- package/dist/app/loop.mjs.map +1 -0
- package/dist/app/recorder.d.mts +7 -0
- package/dist/app/recorder.d.mts.map +1 -0
- package/dist/app/recorder.mjs +233 -0
- package/dist/app/recorder.mjs.map +1 -0
- package/dist/core/errors.d.mts +16 -0
- package/dist/core/errors.d.mts.map +1 -0
- package/dist/core/errors.mjs +15 -0
- package/dist/core/errors.mjs.map +1 -0
- package/dist/core/format.d.mts +12 -0
- package/dist/core/format.d.mts.map +1 -0
- package/dist/{lib/utils.mjs → core/format.mjs} +6 -41
- package/dist/core/format.mjs.map +1 -0
- package/dist/core/plan.mjs +52 -0
- package/dist/core/plan.mjs.map +1 -0
- package/dist/core/verdict.d.mts +44 -0
- package/dist/core/verdict.d.mts.map +1 -0
- package/dist/core/verdict.mjs +47 -0
- package/dist/core/verdict.mjs.map +1 -0
- package/dist/index.d.mts +6 -3
- package/dist/index.mjs +5 -2
- package/dist/{core/api/client.mjs → io/http.mjs} +38 -10
- package/dist/io/http.mjs.map +1 -0
- package/dist/io/media/convert.mjs +60 -0
- package/dist/io/media/convert.mjs.map +1 -0
- package/dist/{core/recorder → io/media}/download.mjs +8 -7
- package/dist/io/media/download.mjs.map +1 -0
- package/dist/{core/recorder/ffmpeg-utils.mjs → io/media/ffmpeg.mjs} +45 -5
- package/dist/io/media/ffmpeg.mjs.map +1 -0
- package/dist/{core/recorder → io/media}/normalize.mjs +1 -1
- package/dist/io/media/normalize.mjs.map +1 -0
- package/dist/io/tiktok/client.mjs +77 -0
- package/dist/io/tiktok/client.mjs.map +1 -0
- package/dist/io/tiktok/discovery/chain.mjs +117 -0
- package/dist/io/tiktok/discovery/chain.mjs.map +1 -0
- package/dist/io/tiktok/discovery/fallback.mjs +64 -0
- package/dist/io/tiktok/discovery/fallback.mjs.map +1 -0
- package/dist/io/tiktok/discovery/page.mjs +102 -0
- package/dist/io/tiktok/discovery/page.mjs.map +1 -0
- package/dist/io/tiktok/page.mjs +34 -0
- package/dist/io/tiktok/page.mjs.map +1 -0
- package/dist/io/tiktok/parse.mjs +110 -0
- package/dist/io/tiktok/parse.mjs.map +1 -0
- package/dist/io/tiktok/webcast.mjs +132 -0
- package/dist/io/tiktok/webcast.mjs.map +1 -0
- package/package.json +5 -26
- package/src/app/abort.ts +18 -0
- package/src/app/config.ts +178 -0
- package/src/app/events.ts +46 -0
- package/src/app/job.ts +228 -0
- package/src/app/logger.ts +133 -0
- package/src/app/loop.ts +84 -0
- package/src/app/recorder.ts +288 -0
- package/src/core/errors.ts +24 -0
- package/src/core/format.ts +50 -0
- package/src/core/log.ts +13 -0
- package/src/core/plan.ts +49 -0
- package/src/core/verdict.ts +79 -0
- package/src/index.ts +38 -0
- package/src/io/http.ts +167 -0
- package/src/io/media/convert.ts +69 -0
- package/src/io/media/download.ts +120 -0
- package/src/io/media/ffmpeg.ts +201 -0
- package/src/io/media/normalize.ts +64 -0
- package/src/io/tiktok/client.ts +114 -0
- package/src/io/tiktok/discovery/chain.ts +146 -0
- package/src/io/tiktok/discovery/fallback.ts +88 -0
- package/src/io/tiktok/discovery/page.ts +142 -0
- package/src/io/tiktok/page.ts +42 -0
- package/src/io/tiktok/parse.ts +171 -0
- package/src/io/tiktok/webcast.ts +162 -0
- package/dist/bin.d.mts +0 -1
- package/dist/bin.mjs +0 -215
- package/dist/bin.mjs.map +0 -1
- package/dist/core/api/client.mjs.map +0 -1
- package/dist/core/api/rate-limiter.mjs +0 -29
- package/dist/core/api/rate-limiter.mjs.map +0 -1
- package/dist/core/api/tiktok.mjs +0 -394
- package/dist/core/api/tiktok.mjs.map +0 -1
- package/dist/core/monitor.mjs.map +0 -1
- package/dist/core/recorder/convert.mjs +0 -55
- package/dist/core/recorder/convert.mjs.map +0 -1
- package/dist/core/recorder/download.mjs.map +0 -1
- package/dist/core/recorder/ffmpeg-utils.mjs.map +0 -1
- package/dist/core/recorder/index.d.mts +0 -7
- package/dist/core/recorder/index.d.mts.map +0 -1
- package/dist/core/recorder/index.mjs +0 -205
- package/dist/core/recorder/index.mjs.map +0 -1
- package/dist/core/recorder/normalize.mjs.map +0 -1
- package/dist/core/recorder/post-processing.mjs +0 -154
- package/dist/core/recorder/post-processing.mjs.map +0 -1
- package/dist/core/recorder/recorder-events.mjs +0 -34
- package/dist/core/recorder/recorder-events.mjs.map +0 -1
- package/dist/core/recorder/recorder-state.mjs +0 -38
- package/dist/core/recorder/recorder-state.mjs.map +0 -1
- package/dist/core/recorder/stream.mjs +0 -25
- package/dist/core/recorder/stream.mjs.map +0 -1
- package/dist/lib/browser-cookies.mjs +0 -113
- package/dist/lib/browser-cookies.mjs.map +0 -1
- package/dist/lib/cli.mjs +0 -137
- package/dist/lib/cli.mjs.map +0 -1
- package/dist/lib/config.d.mts.map +0 -1
- package/dist/lib/config.mjs +0 -47
- package/dist/lib/config.mjs.map +0 -1
- package/dist/lib/logger.mjs.map +0 -1
- package/dist/lib/tmux.mjs +0 -84
- package/dist/lib/tmux.mjs.map +0 -1
- package/dist/lib/ui.mjs +0 -136
- package/dist/lib/ui.mjs.map +0 -1
- package/dist/lib/utils.mjs.map +0 -1
- package/dist/package.mjs +0 -6
- package/dist/package.mjs.map +0 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export interface SigiState {
|
|
2
|
+
LiveRoom?: {
|
|
3
|
+
liveRoomStatus?: number
|
|
4
|
+
liveRoomUserInfo?: {
|
|
5
|
+
user?: {
|
|
6
|
+
id?: string | number
|
|
7
|
+
roomId?: string | number
|
|
8
|
+
}
|
|
9
|
+
liveRoom?: {
|
|
10
|
+
status?: number // 2 = live, 4 = offline
|
|
11
|
+
title?: string
|
|
12
|
+
startTime?: number
|
|
13
|
+
streamData?: {
|
|
14
|
+
pull_data?: {
|
|
15
|
+
stream_data?: string // JSON string with FLV/HLS URLs
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
UserModule?: {
|
|
22
|
+
users?: {
|
|
23
|
+
[username: string]: {
|
|
24
|
+
id?: string // numeric user ID
|
|
25
|
+
uniqueId?: string
|
|
26
|
+
roomId?: string | { id: string }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── HTML parsing ──────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @public Exported for testing.
|
|
36
|
+
*/
|
|
37
|
+
export function extractSigiState(html: string): SigiState | null {
|
|
38
|
+
const match = html.match(/<script\s+id="SIGI_STATE"\s+type="application\/json">(.*?)<\/script>/)
|
|
39
|
+
if (!match?.[1]) return null
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(match[1]) as SigiState
|
|
42
|
+
} catch {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract the numeric user ID and optional room ID from TikTok's new
|
|
49
|
+
* page structure (__UNIVERSAL_DATA_FOR_REHYDRATION__).
|
|
50
|
+
*
|
|
51
|
+
* The profile page (/@user) includes "webapp.user-detail" which
|
|
52
|
+
* contains the user's numeric ID. When the user is live, the roomId
|
|
53
|
+
* is also embedded here.
|
|
54
|
+
*
|
|
55
|
+
* Returns { userId, roomId } where userId is always set when the data
|
|
56
|
+
* is found, and roomId is set only when the user is currently live.
|
|
57
|
+
*
|
|
58
|
+
* @public Exported for testing and fixture capture.
|
|
59
|
+
*/
|
|
60
|
+
export function extractUserFromUniversalData(html: string): {
|
|
61
|
+
userId: string | null
|
|
62
|
+
roomId: string | null
|
|
63
|
+
} {
|
|
64
|
+
const match = html.match(
|
|
65
|
+
/<script\s+id="__UNIVERSAL_DATA_FOR_REHYDRATION__"\s+type="application\/json">(.*?)<\/script>/,
|
|
66
|
+
)
|
|
67
|
+
if (!match?.[1]) return { userId: null, roomId: null }
|
|
68
|
+
try {
|
|
69
|
+
const data = JSON.parse(match[1])
|
|
70
|
+
const scope = (data as Record<string, unknown>).__DEFAULT_SCOPE__ ?? data
|
|
71
|
+
const userDetail = scope?.["webapp.user-detail"]
|
|
72
|
+
const user = userDetail?.userInfo?.user
|
|
73
|
+
const userId = user?.id ? String(user.id) : null
|
|
74
|
+
const roomId = user?.roomId ? String(user.roomId) : null
|
|
75
|
+
return { userId, roomId }
|
|
76
|
+
} catch {
|
|
77
|
+
return { userId: null, roomId: null }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Stream URL extraction ─────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fast-path: extract stream URL from the known SIGI_STATE location.
|
|
85
|
+
* This covers ~95% of cases without needing a full recursive search.
|
|
86
|
+
*
|
|
87
|
+
* Prefers FLV (lower latency) over HLS/other protocols.
|
|
88
|
+
*/
|
|
89
|
+
export function extractStreamUrlFast(
|
|
90
|
+
liveRoom: NonNullable<NonNullable<SigiState["LiveRoom"]>["liveRoomUserInfo"]>["liveRoom"],
|
|
91
|
+
): string | null {
|
|
92
|
+
try {
|
|
93
|
+
const raw = liveRoom?.streamData?.pull_data?.stream_data
|
|
94
|
+
if (!raw) return null
|
|
95
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>
|
|
96
|
+
const data = (parsed as Record<string, unknown>)?.data as Record<string, unknown> | undefined
|
|
97
|
+
if (!data) return null
|
|
98
|
+
|
|
99
|
+
// Try all known quality keys, preferring higher quality.
|
|
100
|
+
// FLV is preferred (lower latency); HLS is the fallback.
|
|
101
|
+
const qualities = ["origin", "fhd", "uhd", "hd", "sd", "ld"]
|
|
102
|
+
for (const q of qualities) {
|
|
103
|
+
const entry = data[q] as { main?: { flv?: string; hls?: string } } | undefined
|
|
104
|
+
// Prefer FLV over HLS when both are available
|
|
105
|
+
const url = entry?.main?.flv ?? entry?.main?.hls
|
|
106
|
+
if (url) return url
|
|
107
|
+
}
|
|
108
|
+
return null
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Brute-force recursive search for a stream URL anywhere in a JSON value.
|
|
116
|
+
* Survives TikTok changing key names or nesting structure.
|
|
117
|
+
*
|
|
118
|
+
* Prefers FLV over HLS when both are present in the response.
|
|
119
|
+
*
|
|
120
|
+
* Inspired by PR #430 (Michele0303/tiktok-live-recorder).
|
|
121
|
+
*/
|
|
122
|
+
export function findStreamUrlRecursively(obj: unknown): string | null {
|
|
123
|
+
// Collect all candidate URLs with their preference
|
|
124
|
+
const flvUrls: string[] = []
|
|
125
|
+
const m3u8Urls: string[] = []
|
|
126
|
+
|
|
127
|
+
function walk(value: unknown): void {
|
|
128
|
+
if (typeof value === "string") {
|
|
129
|
+
// Direct URL match
|
|
130
|
+
if (
|
|
131
|
+
(value.startsWith("http://") || value.startsWith("https://")) &&
|
|
132
|
+
(value.includes(".flv") || value.includes(".m3u8"))
|
|
133
|
+
) {
|
|
134
|
+
if (value.includes(".flv")) {
|
|
135
|
+
flvUrls.push(value)
|
|
136
|
+
} else {
|
|
137
|
+
m3u8Urls.push(value)
|
|
138
|
+
}
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
// JSON-encoded string — parse and recurse
|
|
142
|
+
if (value.startsWith("{") || value.startsWith("[")) {
|
|
143
|
+
try {
|
|
144
|
+
walk(JSON.parse(value))
|
|
145
|
+
} catch {
|
|
146
|
+
// skip
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (value && typeof value === "object") {
|
|
153
|
+
if (Array.isArray(value)) {
|
|
154
|
+
for (const item of value) {
|
|
155
|
+
walk(item)
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
for (const val of Object.values(value as Record<string, unknown>)) {
|
|
159
|
+
walk(val)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
walk(obj)
|
|
166
|
+
|
|
167
|
+
// Prefer FLV (lower latency) over HLS
|
|
168
|
+
if (flvUrls.length > 0) return flvUrls[0] as string
|
|
169
|
+
if (m3u8Urls.length > 0) return m3u8Urls[0] as string
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { RoomRef, StreamRef } from "../../core/verdict"
|
|
2
|
+
import type { HttpClient } from "../http"
|
|
3
|
+
import { findStreamUrlRecursively } from "./parse"
|
|
4
|
+
|
|
5
|
+
const WEBCAST_BASE = "https://webcast.tiktok.com"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Discriminated room/info result: refusals (notably status_code 4003110)
|
|
9
|
+
* and transport failures surface as `{ ok: false }` instead of null.
|
|
10
|
+
* `statusCode: 0` means the request threw before a status was read.
|
|
11
|
+
*/
|
|
12
|
+
export type RoomApiResult<T> =
|
|
13
|
+
| { ok: true; value: T | null }
|
|
14
|
+
| { ok: false; statusCode: number; body: string }
|
|
15
|
+
|
|
16
|
+
/** Authoritative room/info facts: the room plus its stream URL when present. */
|
|
17
|
+
export interface RoomFacts {
|
|
18
|
+
readonly room: RoomRef
|
|
19
|
+
readonly stream: StreamRef | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What discovery sources consume. `createWebcastApi` is the only implementation. */
|
|
23
|
+
export interface WebcastReader {
|
|
24
|
+
fetchFromRoomApi: <T>(
|
|
25
|
+
roomId: string,
|
|
26
|
+
extract: (data: Record<string, unknown>) => T | null,
|
|
27
|
+
) => Promise<RoomApiResult<T>>
|
|
28
|
+
fetchCheckAlive: (roomId: string) => Promise<boolean>
|
|
29
|
+
fetchStreamUrlFromApi: (roomId: string) => Promise<RoomApiResult<string>>
|
|
30
|
+
/** room/info as domain facts; 4003110 comes back as `{ ok: false }`. */
|
|
31
|
+
roomInfo: (roomId: string) => Promise<RoomApiResult<RoomFacts>>
|
|
32
|
+
/** Resolve a 4003110 refusal: alive → restricted candidate, else offline. */
|
|
33
|
+
refusedRoom: (roomId: string, title: string | null) => Promise<RoomRef>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createWebcastApi(
|
|
37
|
+
http: HttpClient,
|
|
38
|
+
debug: (msg: string) => void,
|
|
39
|
+
signal?: AbortSignal,
|
|
40
|
+
): WebcastReader {
|
|
41
|
+
/**
|
|
42
|
+
* Shared boilerplate for Webcast room/info API calls.
|
|
43
|
+
* Handles URL construction, HTTP GET, JSON parsing, and error handling.
|
|
44
|
+
* Room-gate refusals (status_code 4003110) are reported to the caller
|
|
45
|
+
* instead of being collapsed into null; callers map failures.
|
|
46
|
+
* The caller provides an extractor for the response data.
|
|
47
|
+
*/
|
|
48
|
+
async function fetchFromRoomApi<T>(
|
|
49
|
+
roomId: string,
|
|
50
|
+
extract: (data: Record<string, unknown>) => T | null,
|
|
51
|
+
): Promise<RoomApiResult<T>> {
|
|
52
|
+
try {
|
|
53
|
+
const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`
|
|
54
|
+
const res = await http.get(url, signal)
|
|
55
|
+
debug(`fetchFromRoomApi: roomId=${roomId} status=${res.status}, ok=${res.ok}`)
|
|
56
|
+
if (!res.ok) return { ok: false, statusCode: res.status, body: "" }
|
|
57
|
+
|
|
58
|
+
const text = await res.text()
|
|
59
|
+
debug(`fetchFromRoomApi: roomId=${roomId} returned ${text.length} bytes`)
|
|
60
|
+
if (text.length === 0) return { ok: true, value: null }
|
|
61
|
+
|
|
62
|
+
const data = JSON.parse(text) as Record<string, unknown>
|
|
63
|
+
|
|
64
|
+
// status_code 4003110 = age-protected/restricted room: report it
|
|
65
|
+
if ((data as { status_code?: unknown }).status_code === 4003110) {
|
|
66
|
+
return { ok: false, statusCode: 4003110, body: text }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { ok: true, value: extract(data) }
|
|
70
|
+
} catch {
|
|
71
|
+
// statusCode 0 = transport/parse failure before a status was available.
|
|
72
|
+
return { ok: false, statusCode: 0, body: "" }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if a room is currently alive using the lightweight
|
|
78
|
+
* /webcast/room/check_alive/ endpoint.
|
|
79
|
+
*
|
|
80
|
+
* This is faster and cheaper than fetching the full room/info response.
|
|
81
|
+
* Returns `true` if the room is live, `false` otherwise (including errors).
|
|
82
|
+
*
|
|
83
|
+
* Endpoint: /webcast/room/check_alive/?aid=1988®ion=CH&room_ids={id}&user_is_login=true
|
|
84
|
+
* Response: { "data": [{ "alive": true/false, "room_id": "..." }] }
|
|
85
|
+
*/
|
|
86
|
+
async function fetchCheckAlive(roomId: string): Promise<boolean> {
|
|
87
|
+
try {
|
|
88
|
+
const url = `${WEBCAST_BASE}/webcast/room/check_alive/?aid=1988®ion=CH&room_ids=${roomId}&user_is_login=true`
|
|
89
|
+
const res = await http.get(url, signal)
|
|
90
|
+
debug(`fetchCheckAlive: roomId=${roomId} status=${res.status}, ok=${res.ok}`)
|
|
91
|
+
if (!res.ok) return false
|
|
92
|
+
|
|
93
|
+
const text = await res.text()
|
|
94
|
+
debug(`fetchCheckAlive: roomId=${roomId} returned ${text.length} bytes`)
|
|
95
|
+
if (text.length === 0) return false
|
|
96
|
+
|
|
97
|
+
const data = JSON.parse(text) as Record<string, unknown>
|
|
98
|
+
const items = data?.data as Array<Record<string, unknown>> | undefined
|
|
99
|
+
const alive = items?.[0]?.alive === true
|
|
100
|
+
debug(`fetchCheckAlive: roomId=${roomId} alive=${alive}`)
|
|
101
|
+
return alive
|
|
102
|
+
} catch (err) {
|
|
103
|
+
debug(
|
|
104
|
+
`fetchCheckAlive: roomId=${roomId} error — ${err instanceof Error ? err.message : String(err)}`,
|
|
105
|
+
)
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve a 4003110 room-gate refusal via check_alive: the room is live
|
|
112
|
+
* (restricted) when alive, offline otherwise. Single home for this mapping.
|
|
113
|
+
*/
|
|
114
|
+
async function refusedRoom(roomId: string, title: string | null): Promise<RoomRef> {
|
|
115
|
+
debug(`refusedRoom: room/info refused roomId=${roomId} (status_code 4003110)`)
|
|
116
|
+
const live = await fetchCheckAlive(roomId)
|
|
117
|
+
return { id: roomId, title, live }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fetch room/info as domain facts. Requires a known roomId. A 4003110
|
|
122
|
+
* refusal stays `{ ok: false, statusCode: 4003110 }` for the caller to map
|
|
123
|
+
* via `refusedRoom`.
|
|
124
|
+
*/
|
|
125
|
+
async function roomInfo(roomId: string): Promise<RoomApiResult<RoomFacts>> {
|
|
126
|
+
const res = await fetchFromRoomApi<Record<string, unknown>>(
|
|
127
|
+
roomId,
|
|
128
|
+
(data) => (data as { data?: Record<string, unknown> }).data ?? null,
|
|
129
|
+
)
|
|
130
|
+
if (!res.ok) return res
|
|
131
|
+
const roomData = res.value
|
|
132
|
+
if (!roomData) return { ok: true, value: null }
|
|
133
|
+
|
|
134
|
+
const title = (roomData.title as string | undefined) ?? null
|
|
135
|
+
const streamUrl = findStreamUrlRecursively(roomData)
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
value: {
|
|
139
|
+
room: { id: roomId, title, live: (roomData.status ?? 4) === 2 },
|
|
140
|
+
stream: streamUrl ? { url: streamUrl } : null,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fetch stream URL from the Webcast API.
|
|
147
|
+
* Covers cases where SIGI_STATE doesn't include stream data.
|
|
148
|
+
* Refusals (4003110) and transport failures are reported so callers
|
|
149
|
+
* can distinguish restricted rooms from missing URLs.
|
|
150
|
+
*/
|
|
151
|
+
async function fetchStreamUrlFromApi(roomId: string): Promise<RoomApiResult<string>> {
|
|
152
|
+
return fetchFromRoomApi(roomId, (data) => findStreamUrlRecursively(data))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
fetchFromRoomApi,
|
|
157
|
+
fetchCheckAlive,
|
|
158
|
+
fetchStreamUrlFromApi,
|
|
159
|
+
roomInfo,
|
|
160
|
+
refusedRoom,
|
|
161
|
+
}
|
|
162
|
+
}
|
package/dist/bin.d.mts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { };
|
package/dist/bin.mjs
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
import { TikTokError } from "./lib/config.mjs";
|
|
3
|
-
import { bytesToHuman, relativeTime } from "./lib/utils.mjs";
|
|
4
|
-
import { createRecorder } from "./core/recorder/index.mjs";
|
|
5
|
-
import "./index.mjs";
|
|
6
|
-
import { extractTikTokCookiesFromFirefox } from "./lib/browser-cookies.mjs";
|
|
7
|
-
import { parseArgs } from "./lib/cli.mjs";
|
|
8
|
-
import { tmuxStart, tmuxStatus, tmuxStop } from "./lib/tmux.mjs";
|
|
9
|
-
import { createDisplay } from "./lib/ui.mjs";
|
|
10
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { join } from "node:path";
|
|
12
|
-
import { color } from "@zfadhli/koko-cli";
|
|
13
|
-
//#region src/bin.ts
|
|
14
|
-
/**
|
|
15
|
-
* Entry point — parses CLI args, creates the recorder, and handles signals.
|
|
16
|
-
* Uses the terminal Display manager for beautiful console output.
|
|
17
|
-
*/
|
|
18
|
-
async function loadCookies(cookiesPath) {
|
|
19
|
-
const path = cookiesPath ?? join(process.cwd(), "cookies.json");
|
|
20
|
-
if (!existsSync(path)) return;
|
|
21
|
-
try {
|
|
22
|
-
const content = readFileSync(path, "utf-8");
|
|
23
|
-
const data = JSON.parse(content);
|
|
24
|
-
if (data.sessionid_ss && data.sessionid_ss.length > 0) return data;
|
|
25
|
-
return;
|
|
26
|
-
} catch {
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
async function main() {
|
|
31
|
-
let result;
|
|
32
|
-
try {
|
|
33
|
-
result = parseArgs();
|
|
34
|
-
} catch (err) {
|
|
35
|
-
if (err instanceof TikTokError && err.kind === "config-error") {
|
|
36
|
-
console.error(`${color.red("Error:")} ${err.message}`);
|
|
37
|
-
process.exit(1);
|
|
38
|
-
}
|
|
39
|
-
throw err;
|
|
40
|
-
}
|
|
41
|
-
if (result.command === "start") {
|
|
42
|
-
const userMatch = result.args.find((_, i, a) => a[i - 1] === "--user" || a[i - 1] === "-u");
|
|
43
|
-
if (!userMatch) {
|
|
44
|
-
console.error(`${color.red("Error:")} --user is required for "start" command`);
|
|
45
|
-
process.exit(1);
|
|
46
|
-
}
|
|
47
|
-
const cmd = `tokrec ${result.args.join(" ")}`;
|
|
48
|
-
try {
|
|
49
|
-
await tmuxStart(userMatch, cmd);
|
|
50
|
-
console.log(`${color.green("✓")} Session "tokrec-${userMatch}" started in background`);
|
|
51
|
-
console.log(` tmux attach -t tokrec-${userMatch} — view live output`);
|
|
52
|
-
console.log(` tokrec stop -u ${userMatch} — stop recording`);
|
|
53
|
-
} catch (err) {
|
|
54
|
-
console.error(`${color.red("Error:")} ${err instanceof Error ? err.message : String(err)}`);
|
|
55
|
-
process.exit(1);
|
|
56
|
-
}
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
if (result.command === "stop") {
|
|
60
|
-
try {
|
|
61
|
-
await tmuxStop(result.user);
|
|
62
|
-
if (result.user) console.log(`${color.green("✓")} Session "tokrec-${result.user}" stopped`);
|
|
63
|
-
else console.log(`${color.green("✓")} All tokrec sessions stopped`);
|
|
64
|
-
} catch (err) {
|
|
65
|
-
console.error(`${color.red("Error:")} ${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
-
process.exit(1);
|
|
67
|
-
}
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
if (result.command === "status") {
|
|
71
|
-
const sessions = await tmuxStatus(result.user);
|
|
72
|
-
if (sessions.length === 0) console.log(`${color.gray("○")} No active session${result.user ? ` for "${result.user}"` : ""}`);
|
|
73
|
-
else for (const s of sessions) {
|
|
74
|
-
console.log(`${color.green("●")} tokrec-${s.user}`);
|
|
75
|
-
console.log(` tmux attach -t ${s.session} — view live output`);
|
|
76
|
-
}
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
if (result.command === "setup") {
|
|
80
|
-
const firefoxCookies = extractTikTokCookiesFromFirefox();
|
|
81
|
-
if (firefoxCookies) {
|
|
82
|
-
const names = Object.keys(firefoxCookies);
|
|
83
|
-
const detail = names.length > 1 ? ` (${names.length} cookies)` : "";
|
|
84
|
-
console.log(`${color.green("✓")} Firefox cookies detected${detail}`);
|
|
85
|
-
console.log(` No setup needed — tokrec auto-detects Firefox cookies.`);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
const cookiesPath = join(process.cwd(), "cookies.json");
|
|
89
|
-
if (existsSync(cookiesPath)) try {
|
|
90
|
-
if (JSON.parse(readFileSync(cookiesPath, "utf-8")).sessionid_ss) {
|
|
91
|
-
console.log(`${color.green("✓")} cookies.json found at ./cookies.json`);
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
} catch {}
|
|
95
|
-
console.log(`${color.yellow("⚠")} No TikTok cookies found.`);
|
|
96
|
-
console.log();
|
|
97
|
-
console.log(`You need a valid sessionid_ss cookie to bypass TikTok's WAF.`);
|
|
98
|
-
console.log(`Either log in to TikTok in Firefox (auto-detected), or enter manually:`);
|
|
99
|
-
console.log();
|
|
100
|
-
const sessionid = prompt(" sessionid_ss cookie value:");
|
|
101
|
-
if (!sessionid || sessionid.trim().length === 0) {
|
|
102
|
-
console.log(`${color.red("✗")} No cookie provided. Setup cancelled.`);
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
const cookies = { sessionid_ss: sessionid.trim() };
|
|
106
|
-
const idc = prompt(" tt-target-idc (optional, press Enter to skip):");
|
|
107
|
-
if (idc && idc.trim().length > 0) cookies["tt-target-idc"] = idc.trim();
|
|
108
|
-
writeFileSync(cookiesPath, `${JSON.stringify(cookies, null, 2)}\n`);
|
|
109
|
-
console.log();
|
|
110
|
-
console.log(`${color.green("✓")} cookies.json created at ./cookies.json`);
|
|
111
|
-
console.log(` You can now run: tokrec -u <username>`);
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
const config = result.config;
|
|
115
|
-
const firefoxCookies = extractTikTokCookiesFromFirefox();
|
|
116
|
-
config.cookies = firefoxCookies ?? await loadCookies(config.cookiesPath);
|
|
117
|
-
config.logConsole = false;
|
|
118
|
-
const display = createDisplay();
|
|
119
|
-
if (config.cookies) {
|
|
120
|
-
const source = firefoxCookies ? "Firefox" : "cookies.json";
|
|
121
|
-
const names = Object.keys(config.cookies);
|
|
122
|
-
const detail = names.length > 1 ? ` (${names.length} cookies)` : "";
|
|
123
|
-
display.showInfo(`${source} cookies loaded${detail}`);
|
|
124
|
-
} else display.showWarning("No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json");
|
|
125
|
-
const recorder = createRecorder(config);
|
|
126
|
-
let lastWasLive = null;
|
|
127
|
-
let firstOfflineTime = 0;
|
|
128
|
-
recorder.on("checking", (info) => {
|
|
129
|
-
display.checkingUser(info.user);
|
|
130
|
-
});
|
|
131
|
-
recorder.on("tick", (info) => {
|
|
132
|
-
if (info.isLive) display.userLive(info.user, info.roomId ?? "?");
|
|
133
|
-
else if (lastWasLive === null || lastWasLive === true) {
|
|
134
|
-
display.userOffline(info.user);
|
|
135
|
-
firstOfflineTime = Date.now();
|
|
136
|
-
} else {
|
|
137
|
-
const elapsed = Date.now() - firstOfflineTime;
|
|
138
|
-
display.userOfflineRepeat(info.user, relativeTime(elapsed));
|
|
139
|
-
}
|
|
140
|
-
lastWasLive = info.isLive;
|
|
141
|
-
});
|
|
142
|
-
recorder.on("recording:start", () => {
|
|
143
|
-
display.startRecording();
|
|
144
|
-
});
|
|
145
|
-
recorder.on("download:end", (info) => {
|
|
146
|
-
const parsed = info.file.split("/").pop() ?? info.file;
|
|
147
|
-
const sizeStr = bytesToHuman(info.size);
|
|
148
|
-
display.finishRecording(parsed, info.duration, sizeStr);
|
|
149
|
-
});
|
|
150
|
-
recorder.on("segmenting:start", () => {
|
|
151
|
-
display.startSegmenting();
|
|
152
|
-
});
|
|
153
|
-
recorder.on("segmenting:end", (info) => {
|
|
154
|
-
display.segmentsCreated(info.segments);
|
|
155
|
-
});
|
|
156
|
-
recorder.on("converting:start", () => {
|
|
157
|
-
display.startConverting();
|
|
158
|
-
});
|
|
159
|
-
recorder.on("converted", (info) => {
|
|
160
|
-
const parsed = info.output.split("/").pop() ?? info.output;
|
|
161
|
-
display.conversionDone(parsed);
|
|
162
|
-
});
|
|
163
|
-
recorder.on("recording:end", () => {});
|
|
164
|
-
recorder.on("error", (err) => {
|
|
165
|
-
display.showError(`[${err.kind}] ${err.message}`);
|
|
166
|
-
});
|
|
167
|
-
recorder.on("normalize:start", () => {
|
|
168
|
-
display.normalizeStart();
|
|
169
|
-
});
|
|
170
|
-
recorder.on("normalize:progress", (info) => {
|
|
171
|
-
display.normalizeProgress(info.percent, info.phase);
|
|
172
|
-
});
|
|
173
|
-
recorder.on("normalize:end", () => {
|
|
174
|
-
display.normalizeComplete();
|
|
175
|
-
});
|
|
176
|
-
recorder.on("normalize:error", (info) => {
|
|
177
|
-
display.normalizeError(info.error);
|
|
178
|
-
});
|
|
179
|
-
let stopping = false;
|
|
180
|
-
const handleSignal = async () => {
|
|
181
|
-
if (stopping) return;
|
|
182
|
-
stopping = true;
|
|
183
|
-
process.off("SIGINT", handleSignal);
|
|
184
|
-
process.off("SIGTERM", handleSignal);
|
|
185
|
-
process.off("SIGHUP", handleSignal);
|
|
186
|
-
display.showInfo("Shutting down gracefully...");
|
|
187
|
-
await recorder.stop();
|
|
188
|
-
display.stop();
|
|
189
|
-
process.exit(0);
|
|
190
|
-
};
|
|
191
|
-
process.on("SIGINT", handleSignal);
|
|
192
|
-
process.on("SIGTERM", handleSignal);
|
|
193
|
-
process.on("SIGHUP", handleSignal);
|
|
194
|
-
display.pollingStarted(config.interval ?? 3);
|
|
195
|
-
try {
|
|
196
|
-
await recorder.start();
|
|
197
|
-
} catch (err) {
|
|
198
|
-
if (err instanceof TikTokError) display.showError(`[${err.kind}] ${err.message}`);
|
|
199
|
-
else display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
|
|
200
|
-
process.off("SIGINT", handleSignal);
|
|
201
|
-
process.off("SIGTERM", handleSignal);
|
|
202
|
-
process.off("SIGHUP", handleSignal);
|
|
203
|
-
await recorder.stop();
|
|
204
|
-
display.stop();
|
|
205
|
-
process.exit(1);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
main().catch((err) => {
|
|
209
|
-
console.error("Fatal:", err instanceof Error ? err.message : String(err));
|
|
210
|
-
process.exit(1);
|
|
211
|
-
});
|
|
212
|
-
//#endregion
|
|
213
|
-
export {};
|
|
214
|
-
|
|
215
|
-
//# sourceMappingURL=bin.mjs.map
|
package/dist/bin.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"bin.mjs","names":[],"sources":["../src/bin.ts"],"sourcesContent":["#!/usr/bin/env bun\n/**\n * Entry point — parses CLI args, creates the recorder, and handles signals.\n * Uses the terminal Display manager for beautiful console output.\n */\n\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { color } from \"@zfadhli/koko-cli\"\nimport { createRecorder } from \"./index\"\nimport { extractTikTokCookiesFromFirefox } from \"./lib/browser-cookies\"\nimport { type CLIResult, parseArgs } from \"./lib/cli\"\nimport type { RecorderConfig } from \"./lib/config\"\nimport { TikTokError } from \"./lib/config\"\nimport { tmuxStart, tmuxStatus, tmuxStop } from \"./lib/tmux\"\nimport { createDisplay } from \"./lib/ui\"\nimport { bytesToHuman, relativeTime } from \"./lib/utils\"\n\nasync function loadCookies(cookiesPath?: string): Promise<Record<string, string> | undefined> {\n const path = cookiesPath ?? join(process.cwd(), \"cookies.json\")\n if (!existsSync(path)) {\n return undefined\n }\n try {\n const content = readFileSync(path, \"utf-8\")\n const data = JSON.parse(content) as Record<string, string | undefined>\n if (data.sessionid_ss && data.sessionid_ss.length > 0) {\n return data as Record<string, string>\n }\n return undefined\n } catch {\n return undefined\n }\n}\n\nasync function main(): Promise<void> {\n let result: CLIResult\n\n try {\n result = parseArgs()\n } catch (err) {\n if (err instanceof TikTokError && err.kind === \"config-error\") {\n console.error(`${color.red(\"Error:\")} ${err.message}`)\n process.exit(1)\n }\n throw err\n }\n\n // Handle tmux subcommands\n if (result.command === \"start\") {\n // Extract user from args to build session name\n const userMatch = result.args.find((_, i, a) => a[i - 1] === \"--user\" || a[i - 1] === \"-u\")\n if (!userMatch) {\n console.error(`${color.red(\"Error:\")} --user is required for \"start\" command`)\n process.exit(1)\n }\n const cmd = `tokrec ${result.args.join(\" \")}`\n try {\n await tmuxStart(userMatch, cmd)\n console.log(`${color.green(\"✓\")} Session \"tokrec-${userMatch}\" started in background`)\n console.log(` tmux attach -t tokrec-${userMatch} — view live output`)\n console.log(` tokrec stop -u ${userMatch} — stop recording`)\n } catch (err) {\n console.error(`${color.red(\"Error:\")} ${err instanceof Error ? err.message : String(err)}`)\n process.exit(1)\n }\n return\n }\n\n if (result.command === \"stop\") {\n try {\n await tmuxStop(result.user)\n if (result.user) {\n console.log(`${color.green(\"✓\")} Session \"tokrec-${result.user}\" stopped`)\n } else {\n console.log(`${color.green(\"✓\")} All tokrec sessions stopped`)\n }\n } catch (err) {\n console.error(`${color.red(\"Error:\")} ${err instanceof Error ? err.message : String(err)}`)\n process.exit(1)\n }\n return\n }\n\n if (result.command === \"status\") {\n const sessions = await tmuxStatus(result.user)\n if (sessions.length === 0) {\n console.log(\n `${color.gray(\"○\")} No active session${result.user ? ` for \"${result.user}\"` : \"\"}`,\n )\n } else {\n for (const s of sessions) {\n console.log(`${color.green(\"●\")} tokrec-${s.user}`)\n console.log(` tmux attach -t ${s.session} — view live output`)\n }\n }\n return\n }\n\n if (result.command === \"setup\") {\n // 1. Check Firefox cookies\n const firefoxCookies = extractTikTokCookiesFromFirefox()\n if (firefoxCookies) {\n const names = Object.keys(firefoxCookies)\n const detail = names.length > 1 ? ` (${names.length} cookies)` : \"\"\n console.log(`${color.green(\"✓\")} Firefox cookies detected${detail}`)\n console.log(` No setup needed — tokrec auto-detects Firefox cookies.`)\n return\n }\n\n // 2. Check cookies.json\n const cookiesPath = join(process.cwd(), \"cookies.json\")\n if (existsSync(cookiesPath)) {\n try {\n const data = JSON.parse(readFileSync(cookiesPath, \"utf-8\"))\n if (data.sessionid_ss) {\n console.log(`${color.green(\"✓\")} cookies.json found at ./cookies.json`)\n return\n }\n } catch {\n // invalid JSON, fall through to setup\n }\n }\n\n // 3. No cookies found — interactive setup\n console.log(`${color.yellow(\"⚠\")} No TikTok cookies found.`)\n console.log()\n console.log(`You need a valid sessionid_ss cookie to bypass TikTok's WAF.`)\n console.log(`Either log in to TikTok in Firefox (auto-detected), or enter manually:`)\n console.log()\n\n const sessionid = prompt(\" sessionid_ss cookie value:\")\n if (!sessionid || sessionid.trim().length === 0) {\n console.log(`${color.red(\"✗\")} No cookie provided. Setup cancelled.`)\n return\n }\n\n const cookies: Record<string, string> = { sessionid_ss: sessionid.trim() }\n\n const idc = prompt(\" tt-target-idc (optional, press Enter to skip):\")\n if (idc && idc.trim().length > 0) {\n cookies[\"tt-target-idc\"] = idc.trim()\n }\n\n writeFileSync(cookiesPath, `${JSON.stringify(cookies, null, 2)}\\n`)\n console.log()\n console.log(`${color.green(\"✓\")} cookies.json created at ./cookies.json`)\n console.log(` You can now run: tokrec -u <username>`)\n return\n }\n\n // Record command — full recorder flow\n const config: RecorderConfig = result.config\n\n // Load cookies: try Firefox browser first, then fall back to cookies.json\n const firefoxCookies = extractTikTokCookiesFromFirefox()\n config.cookies = firefoxCookies ?? (await loadCookies(config.cookiesPath))\n\n // Suppress internal logger's console output — the Display owns the terminal\n config.logConsole = false\n\n // Beautiful terminal display\n const display = createDisplay()\n\n if (config.cookies) {\n const source = firefoxCookies ? \"Firefox\" : \"cookies.json\"\n const names = Object.keys(config.cookies)\n const detail = names.length > 1 ? ` (${names.length} cookies)` : \"\"\n display.showInfo(`${source} cookies loaded${detail}`)\n } else {\n display.showWarning(\n \"No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json\",\n )\n }\n\n const recorder = createRecorder(config)\n\n let lastWasLive: boolean | null = null\n let firstOfflineTime = 0\n\n // Subscribe to events for beautiful console output\n recorder.on(\"checking\", (info) => {\n display.checkingUser(info.user)\n })\n\n recorder.on(\"tick\", (info) => {\n if (info.isLive) {\n display.userLive(info.user, info.roomId ?? \"?\")\n } else if (lastWasLive === null || lastWasLive === true) {\n display.userOffline(info.user)\n firstOfflineTime = Date.now()\n } else {\n const elapsed = Date.now() - firstOfflineTime\n display.userOfflineRepeat(info.user, relativeTime(elapsed))\n }\n lastWasLive = info.isLive\n })\n\n recorder.on(\"recording:start\", () => {\n display.startRecording()\n })\n\n recorder.on(\"download:end\", (info) => {\n const parsed = info.file.split(\"/\").pop() ?? info.file\n const sizeStr = bytesToHuman(info.size)\n display.finishRecording(parsed, info.duration, sizeStr)\n })\n\n recorder.on(\"segmenting:start\", () => {\n display.startSegmenting()\n })\n\n recorder.on(\"segmenting:end\", (info) => {\n display.segmentsCreated(info.segments)\n })\n\n recorder.on(\"converting:start\", () => {\n display.startConverting()\n })\n\n recorder.on(\"converted\", (info) => {\n const parsed = info.output.split(\"/\").pop() ?? info.output\n display.conversionDone(parsed)\n })\n\n recorder.on(\"recording:end\", () => {\n // For simple conversion (no segmenting), recording:end fires once.\n // For segmenting path, segmenting:end already acknowledges the result.\n // This is a no-op since we handle the terminal output through download:end\n // and segmenting:end / converted events.\n })\n\n recorder.on(\"error\", (err) => {\n display.showError(`[${err.kind}] ${err.message}`)\n })\n\n recorder.on(\"normalize:start\", () => {\n display.normalizeStart()\n })\n\n recorder.on(\"normalize:progress\", (info) => {\n display.normalizeProgress(info.percent, info.phase)\n })\n\n recorder.on(\"normalize:end\", () => {\n display.normalizeComplete()\n })\n\n recorder.on(\"normalize:error\", (info) => {\n display.normalizeError(info.error)\n })\n\n // Signal handling\n let stopping = false\n const handleSignal = async () => {\n if (stopping) return // ignore duplicate signals (e.g. SIGINT + SIGTERM from one Ctrl-C)\n stopping = true\n // Remove signal handlers so they don't accumulate if main() is called again\n process.off(\"SIGINT\", handleSignal)\n process.off(\"SIGTERM\", handleSignal)\n process.off(\"SIGHUP\", handleSignal)\n display.showInfo(\"Shutting down gracefully...\")\n await recorder.stop()\n display.stop()\n process.exit(0)\n }\n\n process.on(\"SIGINT\", handleSignal)\n process.on(\"SIGTERM\", handleSignal)\n process.on(\"SIGHUP\", handleSignal)\n\n // Show initial status and start the recorder\n display.pollingStarted(config.interval ?? 3)\n try {\n await recorder.start()\n } catch (err) {\n if (err instanceof TikTokError) {\n display.showError(`[${err.kind}] ${err.message}`)\n } else {\n display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`)\n }\n process.off(\"SIGINT\", handleSignal)\n process.off(\"SIGTERM\", handleSignal)\n process.off(\"SIGHUP\", handleSignal)\n await recorder.stop()\n display.stop()\n process.exit(1)\n }\n}\n\nmain().catch((err) => {\n console.error(\"Fatal:\", err instanceof Error ? err.message : String(err))\n process.exit(1)\n})\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"client.mjs","names":[],"sources":["../../../src/core/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 \"../../lib/config\"\nimport { createRateLimiter } from \"./rate-limiter\"\n\n// BodyInit is only available inside declare module \"bun\" in bun-types, not as a global\n// under the ES2022 lib. We explicitly import it to keep lib focused on ES2022.\ntype _BodyInit = import(\"bun\").BodyInit\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 never,\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 never,\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":";;;;;;;;AAoBA,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,MAAkB,YAC1C,iBAAiB,KAAK;GACpB,QAAQ;GACF;GACN,SAAS;IACP,gBAAgB;IAChB,GAAG;GACL;EACF,CAAC;EACH,OAAO,YAAY;GACjB,MAAM,QAAQ,MAAM;EACtB;CACF;AACF"}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
//#region src/core/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
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"rate-limiter.mjs","names":[],"sources":["../../../src/core/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"}
|