pliceclogs-og/monster_capture.js
type-two e5b8504bc6 Import pliceclogs as-is — the original Discogs seller extension
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>
2026-07-24 13:47:09 +10:00

263 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Shared MONSTERWIKI capture helpers — pure functions, no DOM / popup deps.
// Loaded by popup.html (<script>) and background.js (importScripts) so the
// passive auto-capture path and the popup path use identical parsing/shaping.
//
// ponytail: these were moved verbatim out of popup.js. One home, two callers.
function parseSalesHistoryHtml(html) {
const statRe = /([A-Z]?\$[\d,.]+)\s*<small>(Average|Median|High|Low)<\/small>/g;
const stats = {};
let m;
while ((m = statRe.exec(html)) !== null) {
stats[m[2].toLowerCase()] = parseFloat(m[1].replace(/[^0-9.]/g, ''));
}
const lastSold = html.match(/Last sold on ([^<]+)/)?.[1]?.trim() || null;
const currency = html.match(/([A-Z]{0,2}\$)[\d,.]+\s*<small>Average/)?.[1] || null;
const sales = [];
const allTr = [...html.matchAll(/<tr class="([^"]*)">([\s\S]*?)<\/tr>/g)];
let i = 0;
while (i < allTr.length) {
const [, cls, body] = allTr[i];
if (cls.includes('sales-history-row')) {
const cells = [...body.matchAll(/<td[^>]*>\s*([\s\S]*?)\s*<\/td>/g)]
.map(td => td[1].replace(/<[^>]+>/g, '').trim());
const sale = {
date: cells[0] || null,
condition: cells[1] || null,
sleeve: cells[2] || null,
price: cells[3] ? parseFloat(cells[3].replace(/[^0-9.]/g, '')) : null,
price_orig: cells[4] || null,
comment: null,
};
if (i + 1 < allTr.length && allTr[i + 1][1].includes('sales-history-comment')) {
sale.comment = allTr[i + 1][2]
.replace(/<[^>]+>/g, '')
.replace(/Comments:\s*/i, '')
.trim() || null;
i++;
}
sales.push(sale);
}
i++;
}
return { last_sold: lastSold, currency, ...stats, sales };
}
function parseListingsHtml(html) {
const rows = [...html.matchAll(/<tr class="shortcut_navigable[^"]*"[^>]*data-release-id="\d+"/g)];
return rows.map((row, idx) => {
const block = html.slice(row.index, rows[idx + 1]?.index ?? html.length);
const condition = block.match(/item_condition[\s\S]{0,300}?<span>\s*([^\n<]+)/)?.[1]?.trim() || null;
const sleeve = block.match(/item_sleeve_condition">\s*([^<]+)/)?.[1]?.trim() || null;
const price_m = block.match(/data-pricevalue="([^"]+)"[^>]*>([^<]+)</);
const conv = block.match(/converted_price[^>]*>about\s*\n?\s*([^\s<]+)/)?.[1] || null;
const seller = block.match(/\/seller\/([^"/]+)\/profile/)?.[1] || null;
const sellerRating = block.match(/star_rating"[^>]*aria-label="[^"]*rating\s*([\d.]+)\s*out\s*of\s*5"/i)
? parseFloat(block.match(/star_rating"[^>]*aria-label="[^"]*rating\s*([\d.]+)\s*out\s*of\s*5"/i)[1]) : null;
const sellerPercent = block.match(/<strong>([\d.]+)%<\/strong>/i)
? parseFloat(block.match(/<strong>([\d.]+)%<\/strong>/i)[1]) : null;
const location = block.match(/Ships From:<\/span>([^<]+)/)?.[1]?.trim() || null;
const shipping = block.match(/item_shipping">[\s\S]*?\+([^\s<]+)/i)?.[1]?.trim() || null;
const notesRaw = block.match(/item_sleeve_condition[\s\S]{0,600}?<p class="hide_mobile">\s*([\s\S]+?)<\/p>/)?.[1];
return {
condition, sleeve,
price: price_m?.[2]?.trim() || null,
price_value: price_m ? parseFloat(price_m[1]) : null,
currency: price_m?.[2]?.replace(/[\d.,\s]/g, '').trim() || null,
price_aud: conv, seller, seller_rating: sellerRating, seller_percent: sellerPercent,
location, shipping,
notes: notesRaw ? notesRaw.replace(/<[^>]+>/g, '').trim().slice(0, 400) || null : null,
};
});
}
function buildResearchJson(rd) {
const have = parseInt(rd.have) || 0;
const want = parseInt(rd.want) || 0;
const ratio = have > 0 ? Math.round((want / have) * 100) / 100 : null;
const priceSugg = {};
if (rd.priceSuggestions) {
Object.entries(rd.priceSuggestions).forEach(([cond, data]) => {
const v = (data && typeof data === 'object') ? data.value : data;
if (v != null) priceSugg[cond] = v;
});
}
const tracklist = (rd.tracklist || [])
.filter(t => t.type_ !== 'heading')
.map(t => {
const track = { position: t.position, title: t.title };
if (t.duration) track.duration = t.duration;
const artists = (t.artists || [])
.map(a => a.name.replace(/\s*\(\d+\)$/, '').trim())
.filter(Boolean);
if (artists.length) track.artists = artists;
const extra = (t.extraartists || [])
.map(a => ({ name: a.name.replace(/\s*\(\d+\)$/, '').trim(), role: a.role }))
.filter(a => a.name);
if (extra.length) track.remixers = extra;
return track;
});
const cleanReview = r => ({
...(r.username && { username: r.username }),
...(r.date && { date: r.date }),
...(r.rating && { rating: r.rating }),
...(r.text && { text: r.text.trim() }),
...(r.helpfulCount != null && r.helpfulCount > 0 && { helpful: r.helpfulCount }),
...(r.replies?.length && { replies: r.replies.map(cleanReview) }),
});
const reviews = (rd.reviews || [])
.filter(r => r.text || r.rating)
.map(cleanReview);
const formats = (rd.formats || [])
.map(f => [f.name, ...(f.descriptions || [])].filter(Boolean).join(', '));
const coverImage = rd.imageUrl || ((rd.images || [])[0]?.uri) || null;
const companies = (rd.companies || []).map(c => ({
name: c.name,
role: c.entity_type_name || c.entity_type,
})).filter(c => c.name);
return {
_meta: {
generated_at: new Date().toISOString(),
source: 'discogs',
discogs_url: `https://www.discogs.com/release/${rd.id}`,
generator: 'PliceCogs X YT',
},
release: {
id: rd.id,
artist: rd.artist,
title: rd.title,
genre: rd.genres || (rd.genre ? rd.genre.split(', ') : []),
style: rd.styles || (rd.style ? rd.style.split(', ') : []),
label: rd.label,
catalog_no: (rd.labels || [])[0]?.catno || '',
year: rd.year,
country: rd.country,
format: formats.join(' / '),
tracklist,
identifiers: rd.identifiers || [],
companies,
cover_image: coverImage,
images: (rd.images || []).map(i => i.uri).filter(Boolean),
videos: (rd.videos || []).map(v => v.uri).filter(Boolean),
},
market: {
have,
want,
want_have_ratio: ratio,
for_sale: rd.num_for_sale != null ? parseInt(rd.num_for_sale) : null,
last_sold: rd.lastSold || null,
prices: {
low: rd.lowPrice || null,
median: rd.medianPrice || null,
high: rd.highPrice || null,
},
price_suggestions: priceSugg,
},
community: {
review_count: reviews.length,
avg_rating: rd.avg_rating || null,
reviews,
},
recommendations: (rd.recommendations || [])
.slice(0, 10)
.map(r => typeof r === 'string' ? r : (r.artist ? `${r.artist} ${r.title}` : r.title))
.filter(Boolean),
};
}
// Combine the Discogs API release object with the in-page DOM scrape
// (content.js getInitialValues) + price suggestions into the `rd` shape that
// buildResearchJson expects. Mirrors popup.js fetchAndDisplayReleaseData merge.
// ponytail: kept here so popup + background agree; popup still does its own
// inline merge (touching that 5k-line file is the bigger risk).
function combineReleaseData(api, dom, priceSuggestions) {
dom = dom || {};
return {
...api,
priceSuggestions,
artist: (api.artists || []).map(a => a.name.replace(/\(\d+\)/, '')).join(', '),
genre: (api.genres || []).join(', '),
style: (api.styles || []).join(', '),
label: api.labels ? [...new Set(api.labels.map(l => l.name.replace(/\(\d+\)/, '').replace(/ Records$/, '')))].join(', ') : '',
lowPrice: dom.lowPrice,
medianPrice: dom.medianPrice,
highPrice: dom.highPrice,
lastSold: dom.lastSold,
have: dom.have,
want: dom.want,
imageUrl: (dom.imageUrl && dom.imageUrl !== 'N/A') ? dom.imageUrl : null,
num_for_sale: api.num_for_sale,
appleId: dom.appleId || '',
reviews: dom.reviews || [],
recommendations: dom.recommendations || [],
};
}
// Export for node self-check + service-worker importScripts is automatic (globals).
if (typeof module !== 'undefined' && module.exports) {
module.exports = { parseSalesHistoryHtml, parseListingsHtml, buildResearchJson, combineReleaseData };
}
// ── self-check: `node monster_capture.js` ─────────────────────────────────────
if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.main === module) {
const assert = require('assert');
// parseSalesHistoryHtml: stats + one row with a comment
const salesHtml = `
A$12.50 <small>Average</small> A$10.00 <small>Median</small>
A$20.00 <small>High</small> A$5.00 <small>Low</small>
Last sold on 12 Mar 2026
<tr class="sales-history-row"><td>2026-03-12</td><td>VG+</td><td>VG</td><td>A$15.00</td><td>€9</td></tr>
<tr class="sales-history-comment"><td>Comments: nice copy</td></tr>`;
const sh = parseSalesHistoryHtml(salesHtml);
assert.strictEqual(sh.average, 12.5, 'average stat');
assert.strictEqual(sh.median, 10, 'median stat');
assert.strictEqual(sh.sales.length, 1, 'one sale row');
assert.strictEqual(sh.sales[0].price, 15, 'sale price parsed');
assert.strictEqual(sh.sales[0].comment, 'nice copy', 'comment attached');
// parseListingsHtml: one listing
const listHtml = `
<tr class="shortcut_navigable" data-release-id="123">
<span class="item_condition">cond<span> Mint (M)</span></span>
<span class="item_sleeve_condition"> Near Mint (NM)</span>
<span data-pricevalue="33.00">A$33.00</span>
</tr>`;
const lst = parseListingsHtml(listHtml);
assert.strictEqual(lst.length, 1, 'one listing');
assert.strictEqual(lst[0].price_value, 33, 'listing price_value');
// buildResearchJson: shape + want/have ratio
const rd = {
id: 249504, artists: [{ name: 'Rick Astley (1)' }], title: 'Never Gonna Give You Up',
genres: ['Electronic'], styles: ['Synth-pop'], labels: [{ name: 'RCA', catno: 'PB 41447' }],
year: 1987, country: 'UK', formats: [{ name: 'Vinyl', descriptions: ['7"', '45 RPM'] }],
have: 100, want: 50, num_for_sale: 12, lowPrice: 5, medianPrice: 10, highPrice: 20,
reviews: [{ username: 'x', text: 'great', rating: 5 }],
recommendations: [{ artist: 'a-ha', title: 'Take On Me' }],
priceSuggestions: { 'Mint (M)': { value: 25 } },
};
const combined = combineReleaseData(rd, {
have: 100, want: 50, lowPrice: 5,
reviews: [{ username: 'x', text: 'great', rating: 5 }],
recommendations: [{ artist: 'a-ha', title: 'Take On Me' }],
}, rd.priceSuggestions);
assert.strictEqual(combined.artist, 'Rick Astley ', 'artist (\\(\\d+\\) stripped)');
const out = buildResearchJson(combined);
assert.strictEqual(out.release.id, 249504, 'release id');
assert.strictEqual(out.market.want_have_ratio, 0.5, 'want/have ratio');
assert.strictEqual(out.market.price_suggestions['Mint (M)'], 25, 'price suggestion flattened');
assert.strictEqual(out.community.reviews.length, 1, 'review kept');
assert.strictEqual(out.recommendations[0], 'a-ha Take On Me', 'recommendation formatted');
console.log('monster_capture self-check OK');
}