PolyTools Trade Cost 0.1.1 / src/content/page-bridge.js

106 lines · 5,171 bytes · SHA-256 78535d4817e40248d014c16d44e9e0665ed9fe0dcbcd2564fc57ed4453ca916c

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})();