@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.
Files changed (126) hide show
  1. package/README.md +99 -403
  2. package/dist/app/abort.mjs +14 -0
  3. package/dist/app/abort.mjs.map +1 -0
  4. package/dist/{lib → app}/config.d.mts +73 -71
  5. package/dist/app/config.d.mts.map +1 -0
  6. package/dist/app/config.mjs +26 -0
  7. package/dist/app/config.mjs.map +1 -0
  8. package/dist/app/events.mjs +36 -0
  9. package/dist/app/events.mjs.map +1 -0
  10. package/dist/app/job.mjs +190 -0
  11. package/dist/app/job.mjs.map +1 -0
  12. package/dist/{lib → app}/logger.mjs +1 -1
  13. package/dist/app/logger.mjs.map +1 -0
  14. package/dist/{core/monitor.mjs → app/loop.mjs} +19 -9
  15. package/dist/app/loop.mjs.map +1 -0
  16. package/dist/app/recorder.d.mts +7 -0
  17. package/dist/app/recorder.d.mts.map +1 -0
  18. package/dist/app/recorder.mjs +233 -0
  19. package/dist/app/recorder.mjs.map +1 -0
  20. package/dist/core/errors.d.mts +16 -0
  21. package/dist/core/errors.d.mts.map +1 -0
  22. package/dist/core/errors.mjs +15 -0
  23. package/dist/core/errors.mjs.map +1 -0
  24. package/dist/core/format.d.mts +12 -0
  25. package/dist/core/format.d.mts.map +1 -0
  26. package/dist/{lib/utils.mjs → core/format.mjs} +6 -41
  27. package/dist/core/format.mjs.map +1 -0
  28. package/dist/core/plan.mjs +52 -0
  29. package/dist/core/plan.mjs.map +1 -0
  30. package/dist/core/verdict.d.mts +44 -0
  31. package/dist/core/verdict.d.mts.map +1 -0
  32. package/dist/core/verdict.mjs +47 -0
  33. package/dist/core/verdict.mjs.map +1 -0
  34. package/dist/index.d.mts +6 -3
  35. package/dist/index.mjs +5 -2
  36. package/dist/{core/api/client.mjs → io/http.mjs} +38 -10
  37. package/dist/io/http.mjs.map +1 -0
  38. package/dist/io/media/convert.mjs +60 -0
  39. package/dist/io/media/convert.mjs.map +1 -0
  40. package/dist/{core/recorder → io/media}/download.mjs +8 -7
  41. package/dist/io/media/download.mjs.map +1 -0
  42. package/dist/{core/recorder/ffmpeg-utils.mjs → io/media/ffmpeg.mjs} +45 -5
  43. package/dist/io/media/ffmpeg.mjs.map +1 -0
  44. package/dist/{core/recorder → io/media}/normalize.mjs +1 -1
  45. package/dist/io/media/normalize.mjs.map +1 -0
  46. package/dist/io/tiktok/client.mjs +77 -0
  47. package/dist/io/tiktok/client.mjs.map +1 -0
  48. package/dist/io/tiktok/discovery/chain.mjs +117 -0
  49. package/dist/io/tiktok/discovery/chain.mjs.map +1 -0
  50. package/dist/io/tiktok/discovery/fallback.mjs +64 -0
  51. package/dist/io/tiktok/discovery/fallback.mjs.map +1 -0
  52. package/dist/io/tiktok/discovery/page.mjs +102 -0
  53. package/dist/io/tiktok/discovery/page.mjs.map +1 -0
  54. package/dist/io/tiktok/page.mjs +34 -0
  55. package/dist/io/tiktok/page.mjs.map +1 -0
  56. package/dist/io/tiktok/parse.mjs +110 -0
  57. package/dist/io/tiktok/parse.mjs.map +1 -0
  58. package/dist/io/tiktok/webcast.mjs +132 -0
  59. package/dist/io/tiktok/webcast.mjs.map +1 -0
  60. package/package.json +5 -26
  61. package/src/app/abort.ts +18 -0
  62. package/src/app/config.ts +178 -0
  63. package/src/app/events.ts +46 -0
  64. package/src/app/job.ts +228 -0
  65. package/src/app/logger.ts +133 -0
  66. package/src/app/loop.ts +84 -0
  67. package/src/app/recorder.ts +288 -0
  68. package/src/core/errors.ts +24 -0
  69. package/src/core/format.ts +50 -0
  70. package/src/core/log.ts +13 -0
  71. package/src/core/plan.ts +49 -0
  72. package/src/core/verdict.ts +79 -0
  73. package/src/index.ts +38 -0
  74. package/src/io/http.ts +167 -0
  75. package/src/io/media/convert.ts +69 -0
  76. package/src/io/media/download.ts +120 -0
  77. package/src/io/media/ffmpeg.ts +201 -0
  78. package/src/io/media/normalize.ts +64 -0
  79. package/src/io/tiktok/client.ts +114 -0
  80. package/src/io/tiktok/discovery/chain.ts +146 -0
  81. package/src/io/tiktok/discovery/fallback.ts +88 -0
  82. package/src/io/tiktok/discovery/page.ts +142 -0
  83. package/src/io/tiktok/page.ts +42 -0
  84. package/src/io/tiktok/parse.ts +171 -0
  85. package/src/io/tiktok/webcast.ts +162 -0
  86. package/dist/bin.d.mts +0 -1
  87. package/dist/bin.mjs +0 -215
  88. package/dist/bin.mjs.map +0 -1
  89. package/dist/core/api/client.mjs.map +0 -1
  90. package/dist/core/api/rate-limiter.mjs +0 -29
  91. package/dist/core/api/rate-limiter.mjs.map +0 -1
  92. package/dist/core/api/tiktok.mjs +0 -394
  93. package/dist/core/api/tiktok.mjs.map +0 -1
  94. package/dist/core/monitor.mjs.map +0 -1
  95. package/dist/core/recorder/convert.mjs +0 -55
  96. package/dist/core/recorder/convert.mjs.map +0 -1
  97. package/dist/core/recorder/download.mjs.map +0 -1
  98. package/dist/core/recorder/ffmpeg-utils.mjs.map +0 -1
  99. package/dist/core/recorder/index.d.mts +0 -7
  100. package/dist/core/recorder/index.d.mts.map +0 -1
  101. package/dist/core/recorder/index.mjs +0 -205
  102. package/dist/core/recorder/index.mjs.map +0 -1
  103. package/dist/core/recorder/normalize.mjs.map +0 -1
  104. package/dist/core/recorder/post-processing.mjs +0 -154
  105. package/dist/core/recorder/post-processing.mjs.map +0 -1
  106. package/dist/core/recorder/recorder-events.mjs +0 -34
  107. package/dist/core/recorder/recorder-events.mjs.map +0 -1
  108. package/dist/core/recorder/recorder-state.mjs +0 -38
  109. package/dist/core/recorder/recorder-state.mjs.map +0 -1
  110. package/dist/core/recorder/stream.mjs +0 -25
  111. package/dist/core/recorder/stream.mjs.map +0 -1
  112. package/dist/lib/browser-cookies.mjs +0 -113
  113. package/dist/lib/browser-cookies.mjs.map +0 -1
  114. package/dist/lib/cli.mjs +0 -137
  115. package/dist/lib/cli.mjs.map +0 -1
  116. package/dist/lib/config.d.mts.map +0 -1
  117. package/dist/lib/config.mjs +0 -47
  118. package/dist/lib/config.mjs.map +0 -1
  119. package/dist/lib/logger.mjs.map +0 -1
  120. package/dist/lib/tmux.mjs +0 -84
  121. package/dist/lib/tmux.mjs.map +0 -1
  122. package/dist/lib/ui.mjs +0 -136
  123. package/dist/lib/ui.mjs.map +0 -1
  124. package/dist/lib/utils.mjs.map +0 -1
  125. package/dist/package.mjs +0 -6
  126. package/dist/package.mjs.map +0 -1
@@ -0,0 +1,114 @@
1
+ /**
2
+ * TikTok API — resolves room IDs, live status, and stream URLs.
3
+ *
4
+ * `createTikTokClient` is a thin orchestrator: per-user cache, cache invalidation,
5
+ * and `discover()` over sources returning `Candidate`s. The chain turns
6
+ * failures into evidence instead of aborting. `getVerdict` is the only
7
+ * public verdict surface.
8
+ *
9
+ * Architecture:
10
+ * createTikTokClient(httpClient) → { isRoomAlive, getLiveUrl, getVerdict }
11
+ *
12
+ * All public methods share a per-tick cache so the page is fetched at most once
13
+ * per poll cycle. The optional `signal` aborts in-flight HTTP requests.
14
+ *
15
+ * Note: The room/enter Webcast endpoint has been deprecated due to frequent
16
+ * 403 errors. All lookups go through room/info (reliable for live + offline).
17
+ */
18
+
19
+ import type { Logger } from "../../core/log"
20
+ import { isLiveReason, type Verdict, verdictOf } from "../../core/verdict"
21
+ import type { HttpClient } from "../http"
22
+ import { type DiscoverySource, discover, type ResolveContext } from "./discovery/chain"
23
+ import { createApiLiveSource, createProfileSource } from "./discovery/fallback"
24
+ import { createPageSource } from "./discovery/page"
25
+ import { createPageFetcher } from "./page"
26
+ import { createWebcastApi } from "./webcast"
27
+
28
+ export interface TikTokClient {
29
+ /** Check if a room is currently live. */
30
+ isRoomAlive(roomId: string): Promise<boolean>
31
+ /** Get the best-quality FLV stream URL for a room. Returns null on failure. */
32
+ getLiveUrl(roomId: string): Promise<string | null>
33
+ /** Full per-tick verdict with reason, refs, evidence trail, and failure. */
34
+ getVerdict(user: string): Promise<Verdict>
35
+ /** Invalidate per-tick cache (call at start of each poll tick). */
36
+ invalidateCache(): void
37
+ }
38
+
39
+ export function createTikTokClient(
40
+ http: HttpClient,
41
+ logger?: Logger,
42
+ showDebug?: boolean,
43
+ signal?: AbortSignal,
44
+ ): TikTokClient {
45
+ // Debug logging helper — writes to stderr only when --debug is passed.
46
+ const debug = (msg: string) => {
47
+ logger?.debug(msg)
48
+ if (showDebug) process.stderr.write(`[API_DEBUG] ${msg}\n`)
49
+ }
50
+ let cached: Verdict | null = null
51
+ let cachedUser = ""
52
+ const webcast = createWebcastApi(http, debug, signal)
53
+ const tryFetchPage = createPageFetcher(http, debug, signal)
54
+ const sources: DiscoverySource[] = [
55
+ createPageSource(),
56
+ createApiLiveSource(),
57
+ createProfileSource(),
58
+ ]
59
+
60
+ async function ensureCache(user: string): Promise<Verdict> {
61
+ if (cached && cachedUser === user) return cached
62
+ cached = await resolveVerdict(user)
63
+ cachedUser = user
64
+ return cached
65
+ }
66
+
67
+ function invalidateCache(): void {
68
+ cached = null
69
+ cachedUser = ""
70
+ }
71
+
72
+ return {
73
+ invalidateCache,
74
+
75
+ async getVerdict(user: string): Promise<Verdict> {
76
+ return ensureCache(user)
77
+ },
78
+
79
+ async isRoomAlive(roomId: string): Promise<boolean> {
80
+ if (cached?.room && String(cached.room.id) === String(roomId)) {
81
+ return isLiveReason(cached.reason)
82
+ }
83
+ // Use the lightweight check_alive endpoint instead of re-fetching
84
+ // the full room/info response.
85
+ return webcast.fetchCheckAlive(roomId)
86
+ },
87
+
88
+ async getLiveUrl(roomId: string): Promise<string | null> {
89
+ if (cached?.room && String(cached.room.id) === String(roomId) && cached.stream) {
90
+ return cached.stream.url
91
+ }
92
+ // Room ID, not a username: never route this through ensureCache.
93
+ const res = await webcast.fetchStreamUrlFromApi(roomId)
94
+ return res.ok ? res.value : null
95
+ },
96
+ }
97
+
98
+ // ─── Internal: discovery chain → one verdict ───
99
+
100
+ async function resolveVerdict(user: string): Promise<Verdict> {
101
+ const ctx: ResolveContext = { user, http, debug, fetchPage: tryFetchPage, webcast }
102
+ const { candidate, evidence } = await discover(sources, ctx)
103
+ switch (candidate.kind) {
104
+ case "room":
105
+ return verdictOf({ room: candidate.room, stream: candidate.stream, evidence })
106
+ case "denied":
107
+ return verdictOf({ room: candidate.room, denied: true, evidence })
108
+ case "failed":
109
+ return verdictOf({ failure: candidate.error, evidence })
110
+ case "miss":
111
+ return verdictOf({ evidence })
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Discovery chain — one idea in one file: a source reports facts, the chain
3
+ * turns every outcome (including a throw) into evidence, and the first room or
4
+ * gate refusal ends it.
5
+ *
6
+ * Types, candidate constructors, and the runner live together because they are
7
+ * a single contract; splitting them across `types.ts` and `candidates.ts` only
8
+ * added import edges.
9
+ */
10
+
11
+ import type { Evidence, EvidenceOutcome, RoomRef, SourceId, StreamRef } from "../../../core/verdict"
12
+ import type { HttpClient } from "../../http"
13
+ import type { WebcastReader } from "../webcast"
14
+
15
+ /**
16
+ * What one discovery source found. `room`/`denied` end the chain; `miss` and
17
+ * `failed` let the next source try — a throw is evidence, never a dead end.
18
+ */
19
+ export type Candidate =
20
+ | { kind: "room"; room: RoomRef; stream: StreamRef | null; source: SourceId }
21
+ | { kind: "denied"; room: RoomRef; code: number; source: SourceId }
22
+ | { kind: "miss"; note: string }
23
+ | { kind: "failed"; error: Error }
24
+
25
+ export interface ResolveContext {
26
+ user: string
27
+ http: HttpClient
28
+ debug: (msg: string) => void
29
+ fetchPage: (user: string, path: string) => Promise<{ html: string; baseUrl: string } | null>
30
+ webcast: WebcastReader
31
+ }
32
+
33
+ export interface DiscoverySource {
34
+ readonly id: "page" | "api-live" | "profile"
35
+ find(ctx: ResolveContext): Promise<Candidate>
36
+ }
37
+
38
+ // ─── candidate constructors ────────────────────────────────
39
+
40
+ /** Nothing found here — let the next source try. */
41
+ export function miss(note: string): Candidate {
42
+ return { kind: "miss", note }
43
+ }
44
+
45
+ /** A transport/parse failure. The chain keeps going; it only wins over a miss. */
46
+ export function failed(error: unknown): Candidate {
47
+ return { kind: "failed", error: error instanceof Error ? error : new Error(String(error)) }
48
+ }
49
+
50
+ /**
51
+ * Room live with a stream URL. Uses `knownUrl` when the page already carried
52
+ * it; otherwise asks the Webcast API. A 4003110 refusal becomes `denied`.
53
+ */
54
+ export async function withStream(
55
+ ctx: ResolveContext,
56
+ room: RoomRef,
57
+ source: SourceId,
58
+ knownUrl: string | null,
59
+ ): Promise<Candidate> {
60
+ if (knownUrl) return { kind: "room", room, stream: { url: knownUrl }, source }
61
+ const res = await ctx.webcast.fetchStreamUrlFromApi(room.id)
62
+ if (res.ok) {
63
+ return { kind: "room", room, stream: res.value ? { url: res.value } : null, source }
64
+ }
65
+ if (res.statusCode === 4003110) return refused(ctx, room.id, room.title, source)
66
+ return { kind: "room", room, stream: null, source }
67
+ }
68
+
69
+ /** Authoritative room/info lookup: the single 4003110 consumer for room IDs. */
70
+ export async function fromRoomInfo(
71
+ ctx: ResolveContext,
72
+ roomId: string,
73
+ source: SourceId,
74
+ ): Promise<Candidate> {
75
+ const res = await ctx.webcast.roomInfo(roomId)
76
+ if (res.ok) {
77
+ if (!res.value) return miss(`room/info returned no data for ${roomId}`)
78
+ return { kind: "room", room: res.value.room, stream: res.value.stream, source }
79
+ }
80
+ if (res.statusCode === 4003110) return refused(ctx, roomId, null, source)
81
+ if (res.statusCode === 0) return failed(new Error(`room/info transport failure for ${roomId}`))
82
+ return miss(`room/info HTTP ${res.statusCode} for ${roomId}`)
83
+ }
84
+
85
+ /** Gate refusal: check_alive decides whether the room is live (restricted) or offline. */
86
+ export async function refused(
87
+ ctx: ResolveContext,
88
+ roomId: string,
89
+ title: string | null,
90
+ source: SourceId,
91
+ ): Promise<Candidate> {
92
+ const room = await ctx.webcast.refusedRoom(roomId, title)
93
+ return { kind: "denied", room, code: 4003110, source }
94
+ }
95
+
96
+ // ─── runner ────────────────────────────────────────────────
97
+
98
+ function outcomeOf(candidate: Candidate): EvidenceOutcome {
99
+ switch (candidate.kind) {
100
+ case "room":
101
+ return "hit"
102
+ case "denied":
103
+ return "denied"
104
+ case "failed":
105
+ return "error"
106
+ case "miss":
107
+ return "miss"
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Run discovery sources in order. A throw — sync or async — becomes a
113
+ * `failed` candidate and the chain continues: the `probe.ts:154` dead end,
114
+ * inverted. A room or a gate refusal ends the chain; the first failure
115
+ * outranks a later miss.
116
+ */
117
+ export async function discover(
118
+ sources: readonly DiscoverySource[],
119
+ ctx: ResolveContext,
120
+ ): Promise<{ candidate: Candidate; evidence: Evidence[] }> {
121
+ const evidence: Evidence[] = []
122
+ let best: Candidate = { kind: "miss", note: "no sources configured" }
123
+
124
+ for (const source of sources) {
125
+ const candidate = await (async (): Promise<Candidate> => {
126
+ try {
127
+ return await source.find(ctx)
128
+ } catch (error) {
129
+ ctx.debug(
130
+ `discover: ${source.id} threw — ${error instanceof Error ? error.message : error}`,
131
+ )
132
+ return { kind: "failed", error: error instanceof Error ? error : new Error(String(error)) }
133
+ }
134
+ })()
135
+ evidence.push({
136
+ source:
137
+ candidate.kind === "room" || candidate.kind === "denied" ? candidate.source : source.id,
138
+ outcome: outcomeOf(candidate),
139
+ })
140
+ if (candidate.kind === "room" || candidate.kind === "denied") return { candidate, evidence }
141
+ // First failure is the root cause; a later failure must not overwrite it.
142
+ if (candidate.kind === "failed" && best.kind !== "failed") best = candidate
143
+ }
144
+
145
+ return { candidate: best, evidence }
146
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Fallback sources tried after the page: TikTok's own `/api-live/` endpoint,
3
+ * then the profile page's universal data. Both are thin, both fail into
4
+ * `miss`/`failed` so the chain keeps its evidence trail.
5
+ */
6
+
7
+ import type { RoomRef } from "../../../core/verdict"
8
+ import { extractUserFromUniversalData } from "../parse"
9
+ import {
10
+ type Candidate,
11
+ type DiscoverySource,
12
+ failed,
13
+ fromRoomInfo,
14
+ miss,
15
+ type ResolveContext,
16
+ withStream,
17
+ } from "./chain"
18
+
19
+ export function createApiLiveSource(): DiscoverySource {
20
+ return {
21
+ id: "api-live",
22
+ async find(ctx: ResolveContext): Promise<Candidate> {
23
+ try {
24
+ const url = `https://www.tiktok.com/api-live/user/room/?aid=1988&uniqueId=${encodeURIComponent(ctx.user)}&sourceType=54`
25
+ const res = await ctx.http.get(url)
26
+ ctx.debug(`fetchLiveInfoFromApi: ${url} status=${res.status}`)
27
+ if (!res.ok) return miss(`api-live HTTP ${res.status}`)
28
+
29
+ const text = await res.text()
30
+ ctx.debug(`fetchLiveInfoFromApi: returned ${text.length} bytes`)
31
+ if (text.length === 0) return miss("api-live: empty body")
32
+
33
+ const data = JSON.parse(text) as Record<string, unknown>
34
+ const userData = (data?.data as Record<string, unknown> | undefined)?.user as
35
+ | { roomId?: string; status?: number; uniqueId?: string }
36
+ | undefined
37
+
38
+ if (!userData?.roomId) {
39
+ ctx.debug("fetchLiveInfoFromApi: no roomId in API response")
40
+ return miss("api-live: no roomId")
41
+ }
42
+
43
+ // status: 2 = live, 4 = offline, 1 = ...
44
+ const room: RoomRef = {
45
+ id: String(userData.roomId),
46
+ title: null,
47
+ live: userData.status === 2,
48
+ }
49
+ ctx.debug(
50
+ `fetchLiveInfoFromApi: roomId=${room.id}, status=${userData.status}, live=${room.live}`,
51
+ )
52
+
53
+ if (!room.live) return { kind: "room", room, stream: null, source: "api-live" }
54
+ return withStream(ctx, room, "api-live", null)
55
+ } catch (error) {
56
+ return failed(error)
57
+ }
58
+ },
59
+ }
60
+ }
61
+
62
+ export function createProfileSource(): DiscoverySource {
63
+ return {
64
+ id: "profile",
65
+ async find(ctx: ResolveContext): Promise<Candidate> {
66
+ try {
67
+ const page = await ctx.fetchPage(ctx.user, "")
68
+ if (!page) {
69
+ ctx.debug("fetchLiveInfoFromProfile: all domains blocked for profile page")
70
+ return miss("profile: page blocked")
71
+ }
72
+ ctx.debug(
73
+ `fetchLiveInfoFromProfile: returned ${page.html.length} bytes (${page.baseUrl}), first 300: ${page.html.slice(0, 300).replace(/\n/g, "\\n")}`,
74
+ )
75
+ const { userId, roomId } = extractUserFromUniversalData(page.html)
76
+ if (!userId) return miss("profile: no universal data")
77
+ // No roomId means the user is offline (room/enter was unreliable).
78
+ if (!roomId) return miss("profile: user offline")
79
+
80
+ // If roomId is embedded in the profile page, the user is live.
81
+ // Use room/info which works reliably for both live and offline rooms.
82
+ return fromRoomInfo(ctx, roomId, "room-info")
83
+ } catch (error) {
84
+ return failed(error)
85
+ }
86
+ },
87
+ }
88
+ }
@@ -0,0 +1,142 @@
1
+ import type { RoomRef } from "../../../core/verdict"
2
+ import type { SigiState } from "../parse"
3
+ import {
4
+ extractSigiState,
5
+ extractStreamUrlFast,
6
+ extractUserFromUniversalData,
7
+ findStreamUrlRecursively,
8
+ } from "../parse"
9
+ import {
10
+ type Candidate,
11
+ type DiscoverySource,
12
+ fromRoomInfo,
13
+ miss,
14
+ type ResolveContext,
15
+ withStream,
16
+ } from "./chain"
17
+
18
+ /**
19
+ * Page source: server-rendered SIGI_STATE first, then the async-loaded legacy
20
+ * module, then `__UNIVERSAL_DATA_FOR_REHYDRATION__`. Throws propagate to the
21
+ * chain, which records them and moves on — never a dead end.
22
+ */
23
+ export function createPageSource(): DiscoverySource {
24
+ return {
25
+ id: "page",
26
+ async find(ctx: ResolveContext): Promise<Candidate> {
27
+ let sigi: SigiState | null = null
28
+ let html = ""
29
+ // A `failed` sub-step is not terminal: legacy + universal-data fallbacks
30
+ // still get their chance. It is returned only if nothing else hit.
31
+ let failure: Extract<Candidate, { kind: "failed" }> | null = null
32
+ const take = (candidate: Candidate): Candidate | null => {
33
+ if (candidate.kind === "room" || candidate.kind === "denied") return candidate
34
+ if (candidate.kind === "failed") failure ??= candidate
35
+ return null
36
+ }
37
+
38
+ const page = await ctx.fetchPage(ctx.user, "/live")
39
+ if (!page) {
40
+ ctx.debug("fetchLiveInfo: all domains blocked for /live page")
41
+ // Fall through to universal data / profile page fallback
42
+ html = ""
43
+ } else {
44
+ html = page.html
45
+ ctx.debug(
46
+ `fetchLiveInfo: /live returned ${html.length} bytes (${page.baseUrl}), first 300: ${html.slice(0, 300).replace(/\n/g, "\\n")}`,
47
+ )
48
+
49
+ sigi = extractSigiState(html)
50
+ ctx.debug(`fetchLiveInfo: SIGI_STATE ${sigi ? "found" : "not found"}`)
51
+
52
+ if (sigi) {
53
+ const liveRoom = sigi.LiveRoom?.liveRoomUserInfo?.liveRoom
54
+ const userInfo = sigi.LiveRoom?.liveRoomUserInfo?.user
55
+
56
+ ctx.debug(
57
+ `fetchLiveInfo: liveRoom=${!!liveRoom}, userInfo=${!!userInfo}, userInfo.roomId=${userInfo?.roomId ?? "null"}`,
58
+ )
59
+
60
+ // ── Primary path: liveRoom is populated (server-rendered) ──
61
+ if (liveRoom && userInfo?.roomId) {
62
+ const room: RoomRef = {
63
+ id: String(userInfo.roomId),
64
+ title: liveRoom.title ?? null,
65
+ live: liveRoom.status === 2, // 2 = live, 4 = offline
66
+ }
67
+ ctx.debug(
68
+ `fetchLiveInfo: primary path — roomId=${room.id}, status=${liveRoom.status}, live=${room.live}`,
69
+ )
70
+ if (!room.live) return { kind: "room", room, stream: null, source: "sigi" }
71
+
72
+ // Extract stream URL: try SIGI_STATE first, then Webcast API fallback
73
+ const knownUrl = extractStreamUrlFast(liveRoom) ?? findStreamUrlRecursively(sigi)
74
+ return withStream(ctx, room, "sigi", knownUrl)
75
+ }
76
+
77
+ // ── Fallback: userInfo has roomId, but liveRoom is async-loaded ──
78
+ // TikTok may omit liveRoom from the server-rendered page and load it
79
+ // via JavaScript. Query the Webcast API directly — it's authoritative.
80
+ if (userInfo?.roomId) {
81
+ const roomId = String(userInfo.roomId)
82
+ ctx.debug(`fetchLiveInfo: userInfo fallback — roomId=${roomId}, querying Webcast API`)
83
+ const found = take(await fromRoomInfo(ctx, roomId, "room-info"))
84
+ ctx.debug(
85
+ `fetchLiveInfo: Webcast API ${found ? "returned room info" : "returned null"}`,
86
+ )
87
+ if (found) return found
88
+ }
89
+
90
+ // ── Legacy fallback: async-loaded SIGI_STATE ──
91
+ ctx.debug("fetchLiveInfo: trying legacy fallback (UserModule)")
92
+ const legacy = take(await sigiLegacy(sigi, ctx))
93
+ ctx.debug(`fetchLiveInfo: legacy fallback ${legacy ? "succeeded" : "returned null"}`)
94
+ if (legacy) return legacy
95
+ } // end if (sigi)
96
+ } // end else (page loaded)
97
+
98
+ // ── Universal data from the already-fetched /live html ──
99
+ // TikTok migrated from SIGI_STATE to __UNIVERSAL_DATA_FOR_REHYDRATION__.
100
+ // The /live page may not have it, so try the main profile page.
101
+ ctx.debug(`fetchLiveInfo: trying universal data fallback (html length ${html.length})`)
102
+ const { roomId } = extractUserFromUniversalData(html)
103
+ ctx.debug(`fetchLiveInfo: universal data roomId=${roomId ?? "null"}`)
104
+ if (roomId) {
105
+ const found = take(await fromRoomInfo(ctx, roomId, "room-info"))
106
+ ctx.debug(
107
+ `fetchLiveInfo: universal data Webcast API ${found ? "succeeded" : "returned null"}`,
108
+ )
109
+ if (found) return found
110
+ }
111
+ return failure ?? miss("page: no room id")
112
+ },
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Secondary live-info extraction when SIGI_STATE's LiveRoom data is
118
+ * async-loaded. Uses LiveRoom.liveRoomStatus for the live flag and
119
+ * UserModule.users[user].roomId for the room ID. Stream URL comes from the
120
+ * Webcast API (room/info/) as a final fallback.
121
+ */
122
+ async function sigiLegacy(sigi: SigiState, ctx: ResolveContext): Promise<Candidate> {
123
+ const isLive = sigi.LiveRoom?.liveRoomStatus === 2 // 2 = live
124
+
125
+ // Try to get roomId from UserModule (usually populated even when LiveRoom is not)
126
+ const userModule = sigi.UserModule?.users?.[ctx.user]
127
+ let roomId: string | null = null
128
+
129
+ if (userModule?.roomId) {
130
+ roomId =
131
+ typeof userModule.roomId === "object"
132
+ ? String((userModule.roomId as { id: string }).id ?? "")
133
+ : String(userModule.roomId)
134
+ }
135
+
136
+ // No roomId available — treat as offline (room/enter was unreliable).
137
+ if (!roomId) return miss("sigi-legacy: no roomId")
138
+
139
+ const room: RoomRef = { id: roomId, title: null, live: isLive }
140
+ if (!isLive) return { kind: "room", room, stream: null, source: "sigi" }
141
+ return withStream(ctx, room, "sigi", null)
142
+ }
@@ -0,0 +1,42 @@
1
+ import type { HttpClient } from "../http"
2
+
3
+ const TIKTOK_BASES = ["https://www.tiktok.com", "https://m.tiktok.com"]
4
+
5
+ export function createPageFetcher(
6
+ http: HttpClient,
7
+ debug: (msg: string) => void,
8
+ signal?: AbortSignal,
9
+ ): (user: string, path: string) => Promise<{ html: string; baseUrl: string } | null> {
10
+ /**
11
+ * Try to fetch a TikTok user page from multiple domains.
12
+ * www.tiktok.com is increasingly blocked by Slardar WAF; m.tiktok.com
13
+ * often has different bot protection and may succeed.
14
+ * Returns the HTML and the base URL that worked, or null if all fail.
15
+ */
16
+ async function tryFetchPage(
17
+ user: string,
18
+ path: string,
19
+ ): Promise<{ html: string; baseUrl: string } | null> {
20
+ for (const base of TIKTOK_BASES) {
21
+ const url = `${base}/@${user}${path}`
22
+ const res = await http.get(url, signal)
23
+ debug(`tryFetchPage: ${url} status=${res.status}, ok=${res.ok}`)
24
+ if (!res.ok) continue
25
+
26
+ const html = await res.text()
27
+ // Reject Slardar WAF challenge pages (small + keyword)
28
+ if (html.length < 5000 || html.includes("SlardarWAF")) {
29
+ debug(
30
+ `tryFetchPage: ${url} WAF blocked (${html.length} bytes, slardar=${html.includes("SlardarWAF")})`,
31
+ )
32
+ continue
33
+ }
34
+
35
+ debug(`tryFetchPage: ${url} succeeded (${html.length} bytes)`)
36
+ return { html, baseUrl: base }
37
+ }
38
+ return null
39
+ }
40
+
41
+ return tryFetchPage
42
+ }