PolyTools Trade Cost 0.1.1 / src/content/ticket-reader.js
| 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); |