/** * MONSTERWIKI — master page content script * Fires on discogs.com/master/* — scrapes reviews, POSTs to ultra. */ (async () => { const { monsterSettings } = await chrome.storage.local.get(['monsterSettings']); if (!monsterSettings?.enabled) return; const masterIdMatch = window.location.pathname.match(/^\/master\/(\d+)/); if (!masterIdMatch) return; const masterId = parseInt(masterIdMatch[1]); // Notify popup we're on a master page and starting chrome.runtime.sendMessage({ action: 'monsterMasterStatus', state: 'syncing', masterId }); // ── 1. Try __NEXT_DATA__ first (fast, structured) ────────────────────── let reviews = []; const nextDataEl = document.getElementById('__NEXT_DATA__'); if (nextDataEl) { try { const data = JSON.parse(nextDataEl.textContent); const pp = data?.props?.pageProps; const raw = pp?.reviews?.items || (Array.isArray(pp?.reviews) ? pp.reviews : null) || pp?.master?.reviews?.items || pp?.initialState?.reviews?.items || []; reviews = raw.map(r => ({ username: r.user?.username || r.username || null, date: (r.submitted_at || r.submittedAt || r.date || '').split('T')[0] || null, rating: r.rating ?? null, text: (r.body || r.text || '').trim() || null, helpful: r.helpful_count || r.helpfulCount || 0, version_ref: r.version?.description || r.versionDescription || null, version_id: r.version?.id || r.versionId || null, replies: (r.replies || []).map(rep => ({ username: rep.user?.username || rep.username || null, date: (rep.submitted_at || rep.date || '').split('T')[0] || null, text: (rep.body || rep.text || '').trim() || null, })), })).filter(r => r.text || r.rating); } catch (e) { console.warn('[MONSTER] __NEXT_DATA__ parse failed:', e.message); } } async function expandAllMasterReviews() { const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); const isVisible = (el) => { if (!el) return false; const style = window.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }; const clickAll = async (matcher, maxRounds = 12) => { for (let round = 0; round < maxRounds; round++) { const els = [...document.querySelectorAll('button, a, div[role="button"]')].filter(isVisible); const targets = els.filter(matcher); if (!targets.length) return; for (const el of targets) { try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch {} try { el.click(); } catch {} await sleep(250); } await sleep(500); } }; for (let i = 0; i < 6; i++) { window.scrollTo(0, document.body.scrollHeight); await sleep(700); } await clickAll((el) => { const t = (el.textContent || '').trim().toLowerCase(); const label = (el.getAttribute('aria-label') || '').trim().toLowerCase(); if (!t && !label) return false; const s = `${t} ${label}`; return s.includes('load more') || s.includes('more reviews') || s.includes('show more'); }); await clickAll((el) => { const t = (el.textContent || '').trim(); return /^see\s+\d+\s+repl/i.test(t); }); } function scrapeDomReviewsWithReplies() { const out = []; const extractRating = (el) => { const ratingLabel = el.querySelector('[class*="rating"], [aria-label*="rated"]')?.getAttribute?.('aria-label') || ''; const match = ratingLabel.match(/rated.*?(\d+)\s*star/i); if (match) return parseInt(match[1]); const stars = el.querySelectorAll('[class*="star"][class*="filled"], [class*="starFilled"]'); return stars.length || null; }; const extractHelpful = (el) => { const txt = [...el.querySelectorAll('button, a')] .map(b => b.textContent || '') .find(t => t.toLowerCase().includes('helpful')) || ''; const n = txt.match(/\d+/)?.[0]; return n ? parseInt(n) : 0; }; // Discogs release-style markup (often reused) const reviewEls = document.querySelectorAll('.review_luKwE:not(.replies_r6FWL .review_luKwE)'); if (reviewEls.length) { reviewEls.forEach(reviewEl => { const username = reviewEl.querySelector('.username_N7O6q')?.innerText?.trim() || null; const date = reviewEl.querySelector('time')?.getAttribute('datetime')?.split('T')[0] || reviewEl.querySelector('time')?.textContent?.trim() || null; const text = reviewEl.querySelector('.markup_Cngxi')?.innerText?.trim() || null; const rating = extractRating(reviewEl); const helpful = extractHelpful(reviewEl); const replies = []; const repliesContainer = reviewEl.nextElementSibling; if (repliesContainer && repliesContainer.classList.contains('replies_r6FWL')) { const replyEls = repliesContainer.querySelectorAll('.review_luKwE'); replyEls.forEach(replyEl => { const ru = replyEl.querySelector('.username_N7O6q')?.innerText?.trim() || null; const rd = replyEl.querySelector('time')?.getAttribute('datetime')?.split('T')[0] || replyEl.querySelector('time')?.textContent?.trim() || null; const rt = replyEl.querySelector('.markup_Cngxi')?.innerText?.trim() || null; if (ru && rt) replies.push({ username: ru, date: rd, text: rt }); }); } if (username && (text || rating)) out.push({ username, date, rating, text, helpful, version_ref: null, version_id: null, replies }); }); } return out; } // ── 2. DOM scrape fallback (reviews loaded client-side) ──────────────── // Note: expandAllMasterReviews() is NOT called here — it scrolls and clicks the page // automatically which interferes with normal browsing. It only runs when triggered // explicitly from the popup. if (!reviews.length || reviews.every(r => !r.replies || !r.replies.length)) { const domWithReplies = scrapeDomReviewsWithReplies(); if (domWithReplies.length) { reviews = domWithReplies; } else if (!reviews.length) { document.querySelectorAll('[class*="review_card"], [class*="reviewCard"], .review').forEach(card => { const username = card.querySelector('[class*="username"], [data-username]')?.textContent?.trim() || card.getAttribute('data-username') || null; const dateEl = card.querySelector('time'); const date = dateEl?.getAttribute('datetime')?.split('T')[0] || dateEl?.textContent?.trim() || null; const text = card.querySelector('[class*="review_body"], [class*="reviewBody"], p') ?.textContent?.trim() || null; const ratingEl = card.querySelectorAll('[class*="star"][class*="filled"], [class*="starFilled"]'); const rating = ratingEl.length || null; const versionEl = [...card.querySelectorAll('a, p')] .find(el => el.textContent.includes('referencing')); const version_ref = versionEl ? versionEl.textContent.replace(/^.*referencing\s*/i, '').trim() || null : null; const helpful = parseInt(card.querySelector('[class*="helpful"]') ?.textContent?.match(/\d+/)?.[0]) || 0; const replies = []; const replyBlocks = card.querySelectorAll('[class*="reply"], [class*="Reply"]'); replyBlocks.forEach(rep => { const ru = rep.querySelector('[class*="username"], [data-username]')?.textContent?.trim() || null; const rd = rep.querySelector('time')?.getAttribute('datetime')?.split('T')[0] || rep.querySelector('time')?.textContent?.trim() || null; const rt = rep.querySelector('p, [class*="body"], [class*="markup"]')?.textContent?.trim() || null; if (ru && rt) replies.push({ username: ru, date: rd, text: rt }); }); if (username && (text || rating)) { reviews.push({ username, date, rating, text, helpful, version_ref, version_id: null, replies }); } }); } } // ── Master metadata ──────────────────────────────────────────────────── const titleEl = document.querySelector('h1[class*="title"], h1'); const artistEl = document.querySelector('[class*="artist"] a, h2 a'); const title = titleEl?.textContent?.trim() || null; const artist = artistEl?.textContent?.trim() || null; console.log(`[MONSTER] master/${masterId} — ${reviews.length} reviews found`); if (!reviews.length) { chrome.runtime.sendMessage({ action: 'monsterMasterStatus', state: 'no_reviews', masterId }); return; } // ── Resolve server URL ───────────────────────────────────────────────── const base = await (async () => { try { const r = await fetch('http://localhost:5002/plice/health', { signal: AbortSignal.timeout(400) }); if (r.ok) return 'http://localhost:5002'; } catch (_) {} return (monsterSettings.serverUrl || 'http://100.91.239.7:5002').replace(/\/$/, ''); })(); // ── POST ─────────────────────────────────────────────────────────────── try { const resp = await fetch(`${base}/plice/master`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ master_id: masterId, title, artist, url: window.location.pathname, reviews }), signal: AbortSignal.timeout(10000), }); const ok = resp.ok; const d = ok ? await resp.json() : {}; console.log(`[MONSTER] master POST ${ok ? 'ok' : 'failed'} — ${d.review_rows ?? '?'} rows`); chrome.runtime.sendMessage({ action: 'monsterMasterStatus', state: ok ? 'done' : 'error', masterId, reviewCount: d.review_rows || reviews.length, }); } catch (e) { console.warn('[MONSTER] master POST failed:', e.message); chrome.runtime.sendMessage({ action: 'monsterMasterStatus', state: 'error', masterId }); } })();