Snapshot of the working tree exactly as it stood, no edits. This is the predecessor
PRICEGOD was rewritten from ("kept intact, untouched" per pricegod/README.md), and it
is still the only place the DYMO scale is actually implemented —
rfid-daemon/index.js:1068-1265: HID discovery, parseScaleReport, one-shot read, and a
streaming /weight + /scale/start|stop session whose JSON shape PRICEGOD's daemon.js
already speaks.
Preserved verbatim on purpose (hence -og), including the known bug: DYMO_PIDS at
index.js:1082 is [0x8003, 0x8004], so it cannot see the bench M25 (0x8009). Fix that
in whatever daemon inherits the scale, not here.
node_modules stays ignored (22M of the 24M tree). No credentials in the import: the
two PEM markers in utils.js/sheets.js only strip headers off a key read from settings,
and mrpadmin / johnking are an SSH and a Postgres username, both key/trust auth.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
232 lines
12 KiB
JavaScript
232 lines
12 KiB
JavaScript
/**
|
|
* 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 });
|
|
}
|
|
})();
|