code wiki / (root) / nx_cam_chaturbate.nx

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}