@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,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
|
+
}
|