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>
263 lines
12 KiB
JavaScript
263 lines
12 KiB
JavaScript
// 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');
|
||
}
|