PolyTools Trade Cost 0.1.1 / src/content/page-bridge.js
| 1 | /* |
| 2 | * PolyTools — page bridge (runs in the page's MAIN world). |
| 3 | * The isolated content script cannot see React internals, so it asks this bridge which market the order |
| 4 | * ticket is bound to. The request carries the candidate condition ids of the current event; the bridge walks |
| 5 | * up the React fiber tree from the ticket input and returns the first candidate id it meets in props. |
| 6 | * It only ever returns a string that the content script itself supplied — nothing else leaves the page. |
| 7 | */ |
| 8 | (function () { |
| 9 | 'use strict'; |
| 10 | if (window.__pwlBridge) return; |
| 11 | window.__pwlBridge = true; |
| 12 | const TAG = 'pwl-lens-v1'; |
| 13 | |
| 14 | // Only the visible ticket: after client-side navigation the previous page's DOM is still there, hidden. |
| 15 | const visible = (el) => !!el && el.isConnected |
| 16 | && (typeof el.checkVisibility === 'function' ? el.checkVisibility() : el.getClientRects().length > 0); |
| 17 | const firstVisible = (sel) => { for (const el of document.querySelectorAll(sel)) if (visible(el)) return el; return null; }; |
| 18 | |
| 19 | // 1-Tap preset button: two dollar amounts ("$5 win $8.77"), whatever the site language |
| 20 | // textContent glues the spans together ("$5win$9.09"), so no word boundaries here |
| 21 | const PRESET = /^\s*\$\s?[\d.,]+[KkMm]?[^$\d][^$]*\$\s?[\d.,]/; |
| 22 | function firstPreset() { |
| 23 | for (const b of document.querySelectorAll('button')) { |
| 24 | if ((b.textContent || '').indexOf('$') < 0) continue; |
| 25 | if (PRESET.test(b.textContent) && visible(b)) return b; |
| 26 | } |
| 27 | return null; |
| 28 | } |
| 29 | |
| 30 | function ticketInput() { |
| 31 | const root = firstVisible('[data-pwl-ticket]'); |
| 32 | // 1-Tap tickets have no input: start the fiber walk from the ticket card itself |
| 33 | return (root && (root.querySelector('input[inputmode="decimal"]') || root)) |
| 34 | || firstVisible('#market-order-amount-input') |
| 35 | || firstVisible('input[inputmode="decimal"][placeholder*="¢"]') |
| 36 | || firstPreset(); |
| 37 | } |
| 38 | |
| 39 | /** The ticket subtree is hydrated once React has attached a fiber to its input. */ |
| 40 | function hydrated() { |
| 41 | const inp = ticketInput(); |
| 42 | return !!(inp && Object.keys(inp).some((k) => k.startsWith('__reactFiber$'))); |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Which market is the ticket bound to? Walk up the fiber tree from the ticket input and collect every candidate |
| 47 | * conditionId found in props, with its path. The binding is a single `market` object (e.g. the BookContext value: |
| 48 | * `….market.conditionId`); arrays such as `….markets[3].conditionId` list the whole event and their order varies |
| 49 | * by page (sports: moneyline first while a spread is selected). Checked on sports, multi-market and binary pages. |
| 50 | */ |
| 51 | function find(ids) { |
| 52 | const idSet = new Set(ids); |
| 53 | const inp = ticketInput(); |
| 54 | if (!inp) return { conditionId: null, reason: 'noInput' }; |
| 55 | const key = Object.keys(inp).find((k) => k.startsWith('__reactFiber$')); |
| 56 | if (!key) return { conditionId: null, reason: 'noFiber' }; |
| 57 | const hits = []; |
| 58 | const seenHits = new Set(); // the same event-wide array shows up on many fiber levels: count it once |
| 59 | let visited = 0; |
| 60 | let best = null; |
| 61 | const SINGLE = /(^|\.)market(\.value)?\.conditionId$/; |
| 62 | const scan = (v, depth, path, inArray, seen) => { |
| 63 | if (best || visited > 150000) return; |
| 64 | visited++; |
| 65 | if (typeof v === 'string') { |
| 66 | if (idSet.has(v)) { |
| 67 | const h = { id: v, path, inArray }; |
| 68 | if (!inArray && SINGLE.test(path)) { best = h; return; } |
| 69 | const k = v + '|' + path; |
| 70 | if (!seenHits.has(k) && hits.length < 200) { seenHits.add(k); hits.push(h); } |
| 71 | } |
| 72 | return; |
| 73 | } |
| 74 | if (!v || typeof v !== 'object' || depth > 4 || seen.has(v)) return; |
| 75 | seen.add(v); |
| 76 | if (v.$$typeof || (typeof Node !== 'undefined' && v instanceof Node)) return; |
| 77 | if (Array.isArray(v)) { for (let i = 0; i < v.length && i < 60 && !best; i++) scan(v[i], depth + 1, path + '[]', true, seen); return; } |
| 78 | const keys = Object.keys(v); |
| 79 | for (let i = 0; i < keys.length && i < 80 && !best; i++) { |
| 80 | const k = keys[i]; |
| 81 | if (k === 'children' || k.charCodeAt(0) === 95 /* _ */) continue; |
| 82 | scan(v[k], depth + 1, path ? path + '.' + k : k, inArray, seen); |
| 83 | } |
| 84 | }; |
| 85 | let f = inp[key]; |
| 86 | for (let lvl = 0; f && lvl < 150 && !best; lvl++) { |
| 87 | if (f.memoizedProps) scan(f.memoizedProps, 0, '', false, new WeakSet()); |
| 88 | f = f.return; |
| 89 | } |
| 90 | const pick = best || hits.find((h) => !h.inArray) || null; // never trust an event-wide array alone |
| 91 | return { conditionId: pick ? pick.id : null, path: pick ? pick.path : null, visited, hits: hits.length }; |
| 92 | } |
| 93 | |
| 94 | window.addEventListener('message', (ev) => { |
| 95 | const d = ev.data; |
| 96 | if (ev.source !== window || !d || d.tag !== TAG || d.dir !== 'req') return; |
| 97 | let result; |
| 98 | if (d.kind === 'probe') { |
| 99 | result = { hydrated: hydrated() }; |
| 100 | } else if (Array.isArray(d.ids)) { |
| 101 | try { result = find(d.ids.filter((x) => typeof x === 'string').slice(0, 5000)); } catch (e) { result = { conditionId: null, reason: 'error' }; } |
| 102 | result.hydrated = hydrated(); |
| 103 | } else return; |
| 104 | window.postMessage({ tag: TAG, dir: 'res', id: d.id, result }, window.location.origin); |
| 105 | }); |
| 106 | })(); |