PolyTools Trade Cost 0.1.1 / src/content/ticket-reader.js

176 lines · 8,524 bytes · SHA-256 44bb0cb35cfe04233fc2914c970612e1268a5da85c3c826d118379f2d431e354

1/*
2 * PolyTools — reads the Polymarket order ticket from the DOM.
3 * Relies on structure, not on English labels (the site is localized):
4 * market mode: input#market-order-amount-input — USD for Buy, shares for Sell;
5 * limit mode: two decimal inputs — the one whose placeholder has "¢" is the price, the other is shares;
6 * [role=radio] inside the ticket: first two = Buy / Sell, the rest = outcomes in market order.
7 * Checked for polymarket.com on 2026-10-26 (event, multi-market and sports pages).
8 */
9(function (root) {
10 'use strict';
11
12 const isChecked = (el) => el && (el.getAttribute('aria-checked') === 'true' || el.getAttribute('data-state') === 'checked');
13
14 /**
15 * "1,234.5" → 1234.5 (the site's own format). With decimalComma (our manual input on ru/es/fr keyboards) a single
16 * comma followed by 1-2 digits and no dot is a decimal separator: "1,5" → 1.5.
17 */
18 function parseNum(s, opts) {
19 if (s == null) return null;
20 let str = String(s).replace(/[\s\u00a0\u202f$¢€]/g, '');
21 if (!str) return null;
22 if (opts && opts.decimalComma) {
23 // ru/es/fr keyboards: "1,5" / "12,5 $" / "1 000,5" / "1.000,50" (dot as thousands, comma as decimal)
24 if (/^\d{1,3}(\.\d{3})+,\d+$/.test(str)) str = str.replace(/\./g, '').replace(',', '.');
25 else if (/^\d+,\d{1,2}$/.test(str)) str = str.replace(',', '.');
26 }
27 const n = Number(str.replace(/,/g, ''));
28 return Number.isFinite(n) ? n : null;
29 }
30
31
32 /** Next.js keeps the previous page's DOM hidden after client-side navigation: only visible tickets count. */
33 function isVisible(el) {
34 if (!el || !el.isConnected) return false;
35 if (typeof el.checkVisibility === 'function') return el.checkVisibility();
36 return el.getClientRects().length > 0;
37 }
38
39 function anchorInput() {
40 for (const i of document.querySelectorAll('#market-order-amount-input')) if (isVisible(i)) return i;
41 for (const i of document.querySelectorAll('input[inputmode="decimal"]')) if (/¢/.test(i.placeholder || '') && isVisible(i)) return i;
42 return null;
43 }
44
45 /**
46 * 1-Tap mode (short crypto up/down markets): no amount input, preset buttons "$5 win $8.77", "$25 win $44", …
47 * The "win" label is amount / best ask, before the fee (checked 2026-10-29 on a 7% market: $5 at 57¢ → $8.77).
48 */
49 // Structural: a button holding two dollar amounts (amount, then payout), whatever word sits between them.
50 const PRESET_RE = /^\$\s?([\d,]+(?:\.\d+)?)\s?([KkMm])?\b[^$]*\$\s?([\d,]+(?:\.\d+)?)\s?([KkMm])?/;
51 const MULT = { k: 1e3, m: 1e6 };
52 function presetAmounts(text) {
53 const m = PRESET_RE.exec(String(text || '').replace(/\s+/g, ' ').trim());
54 if (!m) return null;
55 const val = (n, suf) => { const v = parseNum(n); return v == null ? null : v * (suf ? MULT[suf.toLowerCase()] : 1); };
56 return { usd: val(m[1], m[2]), win: val(m[3], m[4]) };
57 }
58 function presetButtons(scope) {
59 const out = [];
60 for (const b of (scope || document).querySelectorAll('button')) {
61 const tc = b.textContent || '';
62 if (tc.indexOf('$') < 0 || tc.indexOf('$') === tc.lastIndexOf('$')) continue; // cheap filter before layout reads
63 if (!isVisible(b) || !presetAmounts(b.innerText)) continue;
64 out.push(b);
65 if (out.length >= 8) break;
66 }
67 return out;
68 }
69
70 /** The ticket card: nearest ancestor of the amount input (or 1-Tap presets) holding ≥ 4 radios (Buy, Sell, 2+ outcomes). */
71 function findTicket() {
72 const input = anchorInput() || presetButtons()[0];
73 if (!input) return null;
74 let el = input.parentElement;
75 for (let i = 0; i < 18 && el; i++) {
76 if (el.querySelectorAll('[role="radio"]').length >= 4) return el;
77 el = el.parentElement;
78 }
79 return null;
80 }
81
82 /** "No 96.4¢", "MIA -7.5 52¢", "Yes 3.6¢ 9 8 7 … ¢" (animated digits) → {label, price} */
83 function parseOutcome(text) {
84 const s = String(text || '').replace(/\s+/g, ' ').trim();
85 const m = /^(.*?)\s+(\d+(?:\.\d+)?)\s*¢/.exec(s);
86 if (m) return { label: m[1].trim(), price: Number(m[2]) / 100 };
87 return { label: s.replace(/\s+--$/, '').trim(), price: null };
88 }
89
90 function readTicket(card) {
91 if (!card) return null;
92 const radios = [...card.querySelectorAll('[role="radio"]')];
93 if (radios.length < 4) return null;
94 const sideRadios = radios.slice(0, 2);
95 const outcomeRadios = radios.slice(2);
96 const side = isChecked(sideRadios[1]) ? 'sell' : 'buy';
97 const outcomes = outcomeRadios.map((r) => parseOutcome(r.innerText));
98 const outcomeIdx = outcomeRadios.findIndex(isChecked);
99
100 const marketInput = card.querySelector('#market-order-amount-input');
101 const presets = marketInput ? [] : presetButtons(card).map((b) => presetAmounts(b.innerText)).filter((p) => p && p.usd > 0);
102 const hasDecimal = !!card.querySelector('input[inputmode="decimal"]');
103 const state = {
104 side, outcomes, outcomeIdx, mode: marketInput ? 'market' : (!hasDecimal && presets.length ? 'onetap' : 'limit'),
105 amount: null, shares: null, limitPrice: null, presets,
106 };
107 if (state.mode === 'onetap') {
108 /* presets carry the amounts */
109 } else if (marketInput) {
110 const v = parseNum(marketInput.value);
111 if (side === 'buy') state.amount = v; else state.shares = v;
112 } else {
113 const decimals = [...card.querySelectorAll('input[inputmode="decimal"]')];
114 const priceInput = decimals.find((i) => /¢/.test(i.placeholder || ''));
115 const sharesInput = decimals.find((i) => i !== priceInput);
116 const p = priceInput ? parseNum(priceInput.value) : null;
117 state.limitPrice = p != null ? p / 100 : null;
118 state.shares = sharesInput ? parseNum(sharesInput.value) : null;
119 }
120 // Header text (event title + market label) sits above the Buy/Sell radios.
121 const headerText = [];
122 const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT);
123 for (let n = walker.nextNode(); n; n = walker.nextNode()) {
124 if (sideRadios[0].contains(n) || (sideRadios[0].compareDocumentPosition(n) & Node.DOCUMENT_POSITION_FOLLOWING)) break;
125 const t = n.nodeValue.trim();
126 if (t && t !== '·') headerText.push(t);
127 }
128 state.header = headerText.slice(0, 6);
129 state.signature = [side, state.mode, outcomeIdx, outcomes.map((o) => o.label).join('|'), state.header.join('|'),
130 presets.map((p) => p.usd).join(',')].join('#');
131 return state;
132 }
133
134 /** Best-effort market match by the ticket header when the page bridge is unavailable. */
135 function guessMarket(event, ticket) {
136 if (!event || !event.markets.length) return null;
137 const live = event.markets.filter((m) => m.tokenIds.length >= 2);
138 if (live.length === 1) return live[0];
139 const header = (ticket && ticket.header ? ticket.header : []).map((s) => s.toLowerCase());
140 const norm = (s) => String(s || '').toLowerCase().replace(/\s+/g, ' ').trim();
141 let best = null, bestScore = 0;
142 for (const m of live) {
143 let score = 0;
144 const g = norm(m.groupItemTitle), q = norm(m.question);
145 for (const h of header) {
146 if (g && h === g) score += 10;
147 else if (q && h === q) score += 8;
148 else if (g && (h.includes(g) || g.includes(h)) && h.length > 2) score += 3;
149 }
150 if (ticket && ticket.outcomes && ticket.outcomes.length === m.outcomes.length) {
151 const labels = ticket.outcomes.map((o) => norm(o.label));
152 if (labels.every((l, i) => l === norm(m.outcomes[i]))) score += 2;
153 }
154 if (score > bestScore) { best = m; bestScore = score; }
155 }
156 return bestScore >= 8 ? best : null;
157 }
158
159 /** Map the ticket's checked outcome button to the market's outcome index. */
160 function outcomeIndex(market, ticket) {
161 if (!market || !ticket) return null;
162 const norm = (s) => String(s || '').toLowerCase().trim();
163 if (ticket.outcomeIdx >= 0) {
164 const label = norm(ticket.outcomes[ticket.outcomeIdx] && ticket.outcomes[ticket.outcomeIdx].label);
165 const exact = market.outcomes.findIndex((o) => norm(o) === label);
166 if (exact >= 0) return exact;
167 if (ticket.outcomeIdx < market.outcomes.length) return ticket.outcomeIdx;
168 }
169 return null;
170 }
171
172 const reader = { findTicket, readTicket, parseOutcome, parseNum, guessMarket, outcomeIndex, anchorInput, isVisible, presetButtons, presetAmounts };
173 root.PWL = root.PWL || {};
174 root.PWL.ticket = reader;
175 if (typeof module !== 'undefined' && module.exports) module.exports = reader;
176})(typeof globalThis !== 'undefined' ? globalThis : this);