nx_cam_chaturbate.nx source
↩ module page · 485 lines · 21340 B
1// nx_cam_chaturbate.nx -- /compare/mediaingest R9 (lr_watch) SITE ADAPTER: Chaturbate.
2//
3// WHY THIS EXISTS, MEASURED 2026-09-01 -- not recalled, and it corrects the record.
4// The retired Python detector whose output sits in ~/.ctbrec/logs tried 8 regex patterns, 4
5// JS-object patterns and one API endpoint against the ROOM PAGE, then reported:
6// "Room Status: PRIVATE (found 'private show') / Streams Found: 0 / DETECTION FAILED"
7// "3. JavaScript rendering required ... Consider using Selenium/WebDriver"
8// Re-reading the HTML that same run SAVED (page_dj_woman_20251029_110425.html, 184,898 B)
9// refutes every part of that conclusion:
10// - the body carries next="/?next=/dj_woman/" plus roomlist / endless_page markers, i.e. the
11// request was BOUNCED TO THE LANDING PAGE and the room page was never retrieved at all;
12// - the page <title> is the generic Chaturbate homepage title, not a room title;
13// - the literal "private show" it reports matching is homepage boilerplate (3 hits, none a room
14// status field), so the PRIVATE verdict was read off the wrong document.
15// ALL THIRTEEN "pattern misses" therefore have ONE cause, and it is not JavaScript. A check that
16// ran on the wrong subject produced a confident, complete-looking false negative, and its remedy
17// ("render JavaScript") would have bought a browser engine to fix a fetch that never landed.
18// INDEPENDENT SECOND WITNESS, found in our own tree: nx_http_client.nx's build_request_cookie
19// comment already says "chaturbate's /?next= age-gate hands back csrftoken/sbr/AG_Key that must be
20// replayed to reach the room -- without a jar we land on the gate page forever."
21//
22// THE REAL MECHANISM needs no room page and no browser. Two endpoints:
23// 1. STATUS IN BULK -- GET /affiliates/api/onlinerooms/?format=json returns EVERY online room in
24// ONE request. This is the rate-limit answer: N watched performers cost 1 call, not N. Paying
25// one ajax per performer across a large fleet is precisely what flags an exit IP into a
26// site-wide 403, so bulk is not an optimisation here, it is the correctness path.
27// 2. URL RESOLVE -- POST /get_edge_hls_url_ajax/ (application/x-www-form-urlencoded,
28// room_slug=<name>&bandwidth=high, X-Requested-With: XMLHttpRequest) -> JSON carrying
29// room_status, url and cmaf_edge.
30//
31// *** THE EDGE TOKEN IN THAT url IS SINGLE-USE / CONNECTION-BOUND. ***
32// ANY fetch of the tokenised playlist -- even a HEAD, even a "quick validity check" -- CONSUMES it,
33// and the next consumer gets HTTP 403. So this organ RESOLVES AND RETURNS; it never probes, never
34// validates and never pre-fetches the URL it hands back. That is not style: our own nx_hls_get
35// fetches the MASTER playlist and THEN fetches the variant media playlist, which spends the token
36// on the first fetch and 403s on the second. cb_capture_contract() states the handoff rule in the
37// return value rather than in a comment, because a rule that lives only in a comment is invisible
38// to every caller -- the same lesson nx_https_client.nx's NOT_IMPLEMENTED verdict records.
39//
40// EGRESS ETHICS (standing estate bound, /compare/mediaingest R12): sovereign-owned egress only.
41// The incumbent recorder routes its gated ajax through a residential proxy pool; we deliberately do
42// NOT, and answer exit-IP pressure with the bulk endpoint plus the R0 pacer instead of with
43// someone else's household connection.
44//
45// Every network call goes through nx_paced_fetch, so this adapter cannot itself become the thing
46// that gets the estate rate-limited.
47//
48// module: nishi-core.media.cam.chaturbate
49// contract: /compare/mediaingest R9 lr_watch (site half)
50
51import "nx_syscalls.nx"
52import "nx_paced_fetch.nx"
53
54// ---- room states. UNKNOWN is its own bucket: an unrecognised room_status must NEVER fall into a
55// known one, because the bucket it lands in becomes the number somebody schedules against.
56const CB_UNKNOWN: i64 = 0
57const CB_PUBLIC: i64 = 1
58const CB_PRIVATE: i64 = 2
59const CB_HIDDEN: i64 = 3
60const CB_OFFLINE: i64 = 4
61const CB_RESTRICTED: i64 = 5 // room says PUBLIC but the ajax returned an EMPTY url -> geo/age-gated for this exit
62const CB_RATELIMIT: i64 = 6 // 429 / 5xx / 403-challenge -> BACK OFF, never fast-retry
63const CB_NOTEXIST: i64 = 7 // 404
64const CB_WALL: i64 = 8 // challenge body, classified by nx_antibot in the capture path (R13)
65const CB_STATE_N: i64 = 9
66
67const CB_HTTP_OK: i64 = 200
68const CB_HTTP_FORBIDDEN: i64 = 403
69const CB_HTTP_NOTFOUND: i64 = 404
70const CB_HTTP_TOOMANY: i64 = 429
71const CB_HTTP_SERVER_MIN: i64 = 500
72
73// the capture-handoff contract cb_capture_contract() returns
74const CB_HANDOFF_SINGLE_CONSUMER: i64 = 1
75
76const CB_CH_QUOTE: i64 = 34
77const CB_CH_COLON: i64 = 58
78const CB_CH_SPACE: i64 = 32
79const CB_CH_TAB: i64 = 9
80const CB_CH_DIGIT0: i64 = 48
81const CB_CH_UPPER_A: i64 = 65
82const CB_CH_UPPER_Z: i64 = 90
83const CB_CH_CASE_DELTA: i64 = 32
84
85const CB_NUMBUF_BYTES: i64 = 32
86
87func cb_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
88func cb_w(s: *u8) -> i64 { sys_write(1, s, cb_slen(s)); return 0 }
89func cb_wb(b: *u8, n: i64) -> i64 { sys_write(1, b, n); return 0 }
90func cb_wn(v: i64) -> i64 {
91 let t: *u8 = sys_mmap(CB_NUMBUF_BYTES)
92 var m: i64 = v
93 var k: i64 = 0
94 if m < 0 { sys_write(1, "-" as *u8, 1); m = 0 - m }
95 if m == 0 { t[0] = CB_CH_DIGIT0 as u8; k = 1 }
96 while m > 0 { t[k] = (CB_CH_DIGIT0 + (m % 10)) as u8; m = m / 10; k = k + 1 }
97 var i: i64 = k - 1
98 while i >= 0 { sys_write(1, ((t as i64) + i) as *u8, 1); i = i - 1 }
99 return 0
100}
101
102func cb_state_name(st: i64) -> *u8 {
103 if st == CB_PUBLIC { return "PUBLIC" as *u8 }
104 if st == CB_PRIVATE { return "PRIVATE" as *u8 }
105 if st == CB_HIDDEN { return "HIDDEN" as *u8 }
106 if st == CB_OFFLINE { return "OFFLINE" as *u8 }
107 if st == CB_RESTRICTED { return "RESTRICTED" as *u8 }
108 if st == CB_RATELIMIT { return "RATELIMIT" as *u8 }
109 if st == CB_NOTEXIST { return "NOTEXIST" as *u8 }
110 if st == CB_WALL { return "WALL" as *u8 }
111 return "UNKNOWN" as *u8
112}
113
114func cb_state_is_valid(st: i64) -> i64 {
115 if st < 0 { return 0 }
116 if st >= CB_STATE_N { return 0 }
117 return 1
118}
119
120// A recording may only START from a state that means "a public stream exists right now".
121// RESTRICTED deliberately does NOT qualify: it means the ajax handed back an EMPTY url, and
122// starting a capture on an empty input is what produced the incumbent's retry loop.
123func cb_is_recordable(st: i64) -> i64 { if st == CB_PUBLIC { return 1 } return 0 }
124
125// States that mean BACK OFF on the slow cadence rather than retry promptly. Getting 403 into this
126// set is the whole point: treating a Cloudflare challenge as a fast-retryable ERROR is what turns a
127// transient block into a long one.
128func cb_should_backoff(st: i64) -> i64 {
129 if st == CB_RATELIMIT { return 1 }
130 if st == CB_WALL { return 1 }
131 return 0
132}
133
134// THE HANDOFF CONTRACT, returned rather than commented. A resolved chaturbate playlist URL carries
135// a single-use, connection-bound edge token: the capture path must be its FIRST AND ONLY consumer.
136// Callers assert on this so the rule is checkable from outside instead of trusted.
137func cb_capture_contract() -> i64 { return CB_HANDOFF_SINGLE_CONSUMER }
138
139func cb_lc(c: i64) -> i64 {
140 if c >= CB_CH_UPPER_A { if c <= CB_CH_UPPER_Z { return c + CB_CH_CASE_DELTA } }
141 return c
142}
143
144// case-insensitive compare of buf[off..off+n) against a NUL-terminated literal
145func cb_ieq(buf: *u8, off: i64, n: i64, lit: *u8) -> i64 {
146 let ll: i64 = cb_slen(lit)
147 if n != ll { return 0 }
148 var i: i64 = 0
149 var same: i64 = 1
150 while i < n {
151 if cb_lc(buf[off + i] as i64) != cb_lc(lit[i] as i64) { same = 0 }
152 i = i + 1
153 }
154 return same
155}
156
157// ---- THE PURE DECISION -------------------------------------------------------------------------
158// Every input is a number or a buffer the caller already holds, so a gate can plant all of them and
159// prove every state with NO NETWORK. Transport verdicts are decided BEFORE the body is trusted at
160// all, and a tooth asserts that order.
161func cb_decide(http_status: i64, rs: *u8, rs_off: i64, rs_len: i64, url_len: i64) -> i64 {
162 if http_status == CB_HTTP_TOOMANY { return CB_RATELIMIT }
163 if http_status >= CB_HTTP_SERVER_MIN { return CB_RATELIMIT }
164 // 403 here is a Cloudflare challenge / IP-reputation block, NOT a permanent error.
165 if http_status == CB_HTTP_FORBIDDEN { return CB_RATELIMIT }
166 if http_status == CB_HTTP_NOTFOUND { return CB_NOTEXIST }
167 if http_status != CB_HTTP_OK { return CB_UNKNOWN }
168 if rs_len <= 0 { return CB_UNKNOWN }
169 // ONE vocabulary table, shared with the bulk feed (cb_state_of_show). Two copies of this mapping
170 // would drift the first time either endpoint added a word, and the drift would be silent.
171 let v: i64 = cb_state_of_show(rs, rs_off, rs_len)
172 // A PUBLIC room with an EMPTY url is geo/age-restricted for THIS exit IP. It is neither an
173 // error nor recordable; calling it PUBLIC hands an empty input to the capture path.
174 if v == CB_PUBLIC { if url_len <= 0 { return CB_RESTRICTED } }
175 return v
176}
177
178// ---- minimal JSON reader ----------------------------------------------------------------------
179// Finds "key" : "value" and returns the VALUE length, setting off_out[0] to its offset.
180// Returns -1 when the key is absent or the value is not a string.
181// One cursor, one flag, no loop-exit-by-clobbering-the-cursor (the estate's measured 4-in-one-day
182// defect: a sentinel written into the search cursor erases the answer).
183func cb_field_from(buf: *u8, n: i64, key: *u8, from: i64, off_out: *i64) -> i64 {
184 off_out[0] = 0 - 1
185 let kl: i64 = cb_slen(key)
186 var i: i64 = from
187 while i + kl + 2 < n {
188 var hit: i64 = 0
189 if buf[i] == (CB_CH_QUOTE as u8) {
190 var same: i64 = 1
191 var j: i64 = 0
192 while j < kl {
193 if buf[i + 1 + j] != key[j] { same = 0 }
194 j = j + 1
195 }
196 if same == 1 { if buf[i + 1 + kl] == (CB_CH_QUOTE as u8) { hit = 1 } }
197 }
198 if hit == 1 {
199 var p: i64 = i + kl + 2
200 var scan: i64 = 1
201 while scan == 1 {
202 if p >= n { scan = 0 }
203 else {
204 if buf[p] == (CB_CH_SPACE as u8) { p = p + 1 }
205 else { if buf[p] == (CB_CH_TAB as u8) { p = p + 1 } else { scan = 0 } }
206 }
207 }
208 if p < n {
209 if buf[p] == (CB_CH_COLON as u8) {
210 p = p + 1
211 scan = 1
212 while scan == 1 {
213 if p >= n { scan = 0 }
214 else {
215 if buf[p] == (CB_CH_SPACE as u8) { p = p + 1 }
216 else { if buf[p] == (CB_CH_TAB as u8) { p = p + 1 } else { scan = 0 } }
217 }
218 }
219 if p < n {
220 if buf[p] == (CB_CH_QUOTE as u8) {
221 p = p + 1
222 let start: i64 = p
223 var e: i64 = p
224 var end: i64 = 0 - 1
225 var done: i64 = 0
226 while done == 0 {
227 if e >= n { done = 1 }
228 else {
229 if buf[e] == (CB_CH_QUOTE as u8) { end = e; done = 1 }
230 else { e = e + 1 }
231 }
232 }
233 if end >= 0 { off_out[0] = start; return end - start }
234 return 0 - 1
235 }
236 }
237 }
238 }
239 }
240 i = i + 1
241 }
242 return 0 - 1
243}
244
245// "key": true -> 1 ; absent or false -> 0. Used for cmaf_edge.
246func cb_flag(buf: *u8, n: i64, key: *u8) -> i64 {
247 let kl: i64 = cb_slen(key)
248 var i: i64 = 0
249 while i + kl + 2 < n {
250 var hit: i64 = 0
251 if buf[i] == (CB_CH_QUOTE as u8) {
252 var same: i64 = 1
253 var j: i64 = 0
254 while j < kl {
255 if buf[i + 1 + j] != key[j] { same = 0 }
256 j = j + 1
257 }
258 if same == 1 { if buf[i + 1 + kl] == (CB_CH_QUOTE as u8) { hit = 1 } }
259 }
260 if hit == 1 {
261 var p: i64 = i + kl + 2
262 var scan: i64 = 1
263 while scan == 1 {
264 if p >= n { scan = 0 }
265 else {
266 if buf[p] == (CB_CH_SPACE as u8) { p = p + 1 }
267 else {
268 if buf[p] == (CB_CH_COLON as u8) { p = p + 1 }
269 else { if buf[p] == (CB_CH_TAB as u8) { p = p + 1 } else { scan = 0 } }
270 }
271 }
272 }
273 if p + 3 < n {
274 if buf[p] == (116 as u8) {
275 if buf[p+1] == (114 as u8) {
276 if buf[p+2] == (117 as u8) { if buf[p+3] == (101 as u8) { return 1 } }
277 }
278 }
279 }
280 return 0
281 }
282 i = i + 1
283 }
284 return 0
285}
286
287// ---- substring helpers -------------------------------------------------------------------------
288func cb_find(buf: *u8, n: i64, lit: *u8, from: i64) -> i64 {
289 let ll: i64 = cb_slen(lit)
290 if ll <= 0 { return 0 - 1 }
291 var i: i64 = from
292 while i + ll <= n {
293 var same: i64 = 1
294 var j: i64 = 0
295 while j < ll {
296 if buf[i + j] != lit[j] { same = 0 }
297 j = j + 1
298 }
299 if same == 1 { return i }
300 i = i + 1
301 }
302 return 0 - 1
303}
304
305func cb_rfind(buf: *u8, n: i64, lit: *u8) -> i64 {
306 let ll: i64 = cb_slen(lit)
307 if ll <= 0 { return 0 - 1 }
308 var best: i64 = 0 - 1
309 var i: i64 = 0
310 while i + ll <= n {
311 var same: i64 = 1
312 var j: i64 = 0
313 while j < ll {
314 if buf[i + j] != lit[j] { same = 0 }
315 j = j + 1
316 }
317 if same == 1 { best = i }
318 i = i + 1
319 }
320 return best
321}
322
323func cb_apps(dst: *u8, k0: i64, src: *u8) -> i64 {
324 var k: i64 = k0
325 var i: i64 = 0
326 let n: i64 = cb_slen(src)
327 while i < n { dst[k] = src[i]; k = k + 1; i = i + 1 }
328 return k
329}
330
331func cb_appn(dst: *u8, k0: i64, src: *u8, n: i64) -> i64 {
332 var k: i64 = k0
333 var i: i64 = 0
334 while i < n { dst[k] = src[i]; k = k + 1; i = i + 1 }
335 return k
336}
337
338// ---- CMAF rewrite ------------------------------------------------------------------------------
339// When the ajax reports cmaf_edge, the low-latency fragmented-MP4 variant is available and is the
340// one to record. Two substitutions, in the incumbent's order:
341// playlist.m3u8 -> playlist_sfm4s.m3u8
342// live-<anything>amlst -> live-c-fhls/amlst (first "live-" through the LAST "amlst")
343// Returns the rewritten length, or -1 if it would not fit. A URL with neither token is copied
344// unchanged, so calling this on a non-CMAF URL is safe and a tooth proves that.
345func cb_cmaf_rewrite(src: *u8, srclen: i64, out: *u8, outcap: i64) -> i64 {
346 let tmp: *u8 = sys_mmap(outcap)
347 var t: i64 = 0
348 let ppos: i64 = cb_find(src, srclen, "playlist.m3u8" as *u8, 0)
349 if ppos < 0 {
350 t = cb_appn(tmp, 0, src, srclen)
351 } else {
352 let plen: i64 = cb_slen("playlist.m3u8" as *u8)
353 t = cb_appn(tmp, 0, src, ppos)
354 t = cb_apps(tmp, t, "playlist_sfm4s.m3u8" as *u8)
355 t = cb_appn(tmp, t, ((src as i64) + ppos + plen) as *u8, srclen - ppos - plen)
356 }
357 if t > outcap { return 0 - 1 }
358
359 let lpos: i64 = cb_find(tmp, t, "live-" as *u8, 0)
360 let apos: i64 = cb_rfind(tmp, t, "amlst" as *u8)
361 let alen: i64 = cb_slen("amlst" as *u8)
362 if lpos < 0 { var k0: i64 = cb_appn(out, 0, tmp, t); return k0 }
363 if apos < 0 { var k1: i64 = cb_appn(out, 0, tmp, t); return k1 }
364 if apos <= lpos { var k2: i64 = cb_appn(out, 0, tmp, t); return k2 }
365 var k: i64 = cb_appn(out, 0, tmp, lpos)
366 k = cb_apps(out, k, "live-c-fhls/amlst" as *u8)
367 k = cb_appn(out, k, ((tmp as i64) + apos + alen) as *u8, t - apos - alen)
368 if k > outcap { return 0 - 1 }
369 return k
370}
371
372// ---- the form body -----------------------------------------------------------------------------
373/// cb_field is cb_field_from anchored at the start of the buffer -- ONE reader, two entry points.
374func cb_field(buf: *u8, n: i64, key: *u8, off_out: *i64) -> i64 {
375 return cb_field_from(buf, n, key, 0, off_out)
376}
377
378// ---- THE STATUS VOCABULARY, IN ONE PLACE -------------------------------------------------------
379// Both endpoints report the same words with different key names: the ajax calls it room_status, the
380// bulk feed calls it current_show. They MUST agree on what each word means, so there is exactly one
381// mapping and both callers reach it. Two copies of this table would be a duplicate ruler that drifts
382// silently the first time either endpoint adds a word.
383func cb_state_of_show(rs: *u8, off: i64, len: i64) -> i64 {
384 if len <= 0 { return CB_UNKNOWN }
385 if cb_ieq(rs, off, len, "public" as *u8) == 1 { return CB_PUBLIC }
386 if cb_ieq(rs, off, len, "private" as *u8) == 1 { return CB_PRIVATE }
387 if cb_ieq(rs, off, len, "hidden" as *u8) == 1 { return CB_HIDDEN }
388 if cb_ieq(rs, off, len, "offline" as *u8) == 1 { return CB_OFFLINE }
389 if cb_ieq(rs, off, len, "away" as *u8) == 1 { return CB_OFFLINE }
390 return CB_UNKNOWN
391}
392
393// ---- THE BULK FEED -----------------------------------------------------------------------------
394// GET /affiliates/api/onlinerooms/?format=json is a JSON ARRAY of per-room objects carrying at least
395// username and current_show. ONE request covers every online room, which is why the fleet path polls
396// here and spends a per-room ajax only at the moment a recording actually starts.
397//
398// cb_field finds the FIRST match in whatever buffer it is handed, so reading a per-room field out of
399// an ARRAY requires scoping the read to ONE record first -- otherwise every lookup would return
400// record zero's value and the whole fleet would be told it has the first room's status. The record
401// bounds below exist for exactly that reason.
402
403// nearest enclosing object start at or before `at`
404func cb_record_start(buf: *u8, n: i64, at: i64) -> i64 {
405 var i: i64 = at
406 while i >= 0 {
407 if buf[i] == (123 as u8) { return i }
408 i = i - 1
409 }
410 return 0
411}
412
413// nearest object end at or after `at`
414func cb_record_end(buf: *u8, n: i64, at: i64) -> i64 {
415 var i: i64 = at
416 while i < n {
417 if buf[i] == (125 as u8) { return i }
418 i = i + 1
419 }
420 return n
421}
422
423// How many room records did the feed carry? Counted by the username key, one per record.
424func cb_bulk_count(buf: *u8, n: i64) -> i64 {
425 let off: *i64 = sys_mmap(16) as *i64
426 var count: i64 = 0
427 var from: i64 = 0
428 var scanning: i64 = 1
429 while scanning == 1 {
430 let vl: i64 = cb_field_from(buf, n, "username" as *u8, from, off)
431 if vl < 0 { scanning = 0 }
432 else {
433 count = count + 1
434 from = off[0] + vl
435 if from >= n { scanning = 0 }
436 }
437 }
438 return count
439}
440
441// Find one room in the bulk feed and return its state.
442// ABSENT FROM THE FEED IS OFFLINE, not UNKNOWN: this endpoint enumerates the rooms that are ONLINE,
443// so "not listed" is a positive statement about the room, and that is the feed's own semantics.
444// off_out[0] receives the record offset on a hit, or -1 when the room was not listed.
445func cb_bulk_lookup(buf: *u8, n: i64, user: *u8, off_out: *i64) -> i64 {
446 off_out[0] = 0 - 1
447 let ulen: i64 = cb_slen(user)
448 let off: *i64 = sys_mmap(16) as *i64
449 var from: i64 = 0
450 var scanning: i64 = 1
451 while scanning == 1 {
452 let vl: i64 = cb_field_from(buf, n, "username" as *u8, from, off)
453 if vl < 0 { scanning = 0 }
454 else {
455 let voff: i64 = off[0]
456 if vl == ulen {
457 if cb_ieq(buf, voff, vl, user) == 1 {
458 // scope the current_show read to THIS record
459 let rs: i64 = cb_record_start(buf, n, voff)
460 let re: i64 = cb_record_end(buf, n, voff)
461 let rlen: i64 = re - rs
462 let soff: *i64 = sys_mmap(16) as *i64
463 let sl: i64 = cb_field(((buf as i64) + rs) as *u8, rlen, "current_show" as *u8, soff)
464 off_out[0] = rs
465 if sl < 0 { return CB_UNKNOWN }
466 return cb_state_of_show(((buf as i64) + rs) as *u8, soff[0], sl)
467 }
468 }
469 from = voff + vl
470 if from >= n { scanning = 0 }
471 }
472 }
473 return CB_OFFLINE
474}
475
476// room_slug=<user>&bandwidth=high -- built, never templated from untrusted bytes. Returns length.
477
478func cb_build_body(user: *u8, out: *u8, outcap: i64) -> i64 {
479 let need: i64 = cb_slen("room_slug=" as *u8) + cb_slen(user) + cb_slen("&bandwidth=high" as *u8)
480 if need >= outcap { return 0 - 1 }
481 var k: i64 = cb_apps(out, 0, "room_slug=" as *u8)
482 k = cb_apps(out, k, user)
483 k = cb_apps(out, k, "&bandwidth=high" as *u8)
484 return k
485}