steelforesight/panel/scraper.js

157 lines
5.6 KiB
JavaScript
Raw 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.

import * as cheerio from 'cheerio';
import { db, upsertPrice } from './db.js';
const SOURCES = [
{ url: 'https://www.tgju.org/basemetal', kind: 'basemetal', defaultUnit: 'دلار/تن' },
{ url: 'https://www.tgju.org/gold-global', kind: 'gold', defaultUnit: 'دلار/اونس' },
];
const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/125.0 Safari/537.36';
async function fetchPage(url, timeoutMs = 20000) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, {
signal: ctrl.signal,
headers: {
'User-Agent': USER_AGENT,
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'fa,en;q=0.8',
},
});
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
return await res.text();
} finally {
clearTimeout(t);
}
}
// Convert Persian/Arabic digits to ASCII, drop thousand separators, keep sign/decimal
function toNumber(raw) {
if (raw == null) return null;
const ascii = String(raw)
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)))
.replace(/[,،\s]/g, '')
.replace(/[()٪%]/g, '')
.replace(/[^\d.+\-]/g, '');
if (!ascii || ascii === '+' || ascii === '-' || ascii === '.') return null;
const n = parseFloat(ascii);
return Number.isFinite(n) ? n : null;
}
// Parse the TGJU change cell — known formats:
// "(0.26%) 12.38" → pct=0.26, value=12.38
// "(-0.5%) -55" → pct=-0.5, value=-55
// "0" or "" → pct=0, value=0
function parseChangeCell(raw) {
if (!raw) return { changePct: null, changeValue: null };
const text = raw.trim();
if (!text || text === '0' || text === '۰' || text === '٠') {
return { changePct: 0, changeValue: 0 };
}
let changePct = null;
let changeValue = null;
const pctMatch = text.match(/\(\s*(-?[\d.,۰-۹٠-٩]+)\s*%\s*\)/);
if (pctMatch) changePct = toNumber(pctMatch[1]);
// Remove the parenthesised pct, what's left should be the absolute change
const withoutPct = text.replace(/\([^)]*\)/g, '').trim();
if (withoutPct) changeValue = toNumber(withoutPct);
return { changePct, changeValue };
}
function parsePrices(html, sourceUrl, defaultUnit) {
const $ = cheerio.load(html);
const byName = new Map();
$('tr[data-market-row], tr[data-market-nameslug]').each((idx, tr) => {
const $tr = $(tr);
const symbolAttr = $tr.attr('data-market-row') || $tr.attr('data-market-nameslug') || `row-${idx}`;
// Name lives in <th> for these tables (not the first <td>).
let name = $tr.find('th').first().text().trim();
if (!name) name = $tr.find('a').first().text().trim();
name = name.replace(/\s+/g, ' ').trim();
if (!name || /^(عنوان|بازار|نام)$/.test(name)) return;
if (name.length > 60) return;
// Price: prefer the row-level data-price attr (always clean numeric),
// else first <td>'s text.
const dataPrice = $tr.attr('data-price');
let value = toNumber(dataPrice);
if (value === null) {
const firstTd = $tr.find('td').first().text().trim();
value = toNumber(firstTd);
}
if (!Number.isFinite(value) || value === 0) return;
// Change: TGJU puts the entire change expression in the SECOND <td>
// ("(0.26%) 12.38" format). Earlier rows of the page may not have it,
// so we tolerate missing data.
const changeCellRaw = $tr.find('td').eq(1).text().trim();
const { changePct, changeValue } = parseChangeCell(changeCellRaw);
// Dedup by visible name; prefer non-generic symbols.
const existing = byName.get(name);
const isGeneric = /^general[_-]?\d+$/i.test(symbolAttr);
if (existing) {
const existingIsGeneric = /^general[_-]?\d+$/i.test(existing.symbol);
if (!existingIsGeneric || isGeneric) return;
}
byName.set(name, {
symbol: symbolAttr,
name,
value,
unit: defaultUnit,
changeValue,
changePct,
sourceUrl,
});
});
return Array.from(byName.values());
}
export async function scrapeOnce() {
// Collect results before mutating DB — if ALL sources fail, keep old data.
const successful = [];
for (const src of SOURCES) {
try {
const html = await fetchPage(src.url);
const items = parsePrices(html, src.url, src.defaultUnit);
successful.push({ src, items });
console.log(`[scraper] ${src.kind}: ${items.length} prices parsed`);
} catch (err) {
console.error(`[scraper] ${src.kind} failed: ${err.message}`);
}
}
if (successful.length === 0) {
console.log('[scraper] all sources failed — keeping previous data');
return 0;
}
// At least one source returned — replace the rows from those sources only.
// This way a temporary failure of one page doesn't wipe the other's data.
for (const { src, items } of successful) {
db.prepare('DELETE FROM prices WHERE source_url = ?').run(src.url);
for (const it of items) upsertPrice(it);
}
return successful.reduce((s, x) => s + x.items.length, 0);
}
let interval = null;
export function startScraperLoop(intervalMs = 2 * 60 * 1000) {
if (interval) return;
setTimeout(() => { scrapeOnce().catch((e) => console.error('[scraper] initial:', e)); }, 1500);
interval = setInterval(() => {
scrapeOnce().catch((e) => console.error('[scraper] tick:', e));
}, intervalMs);
}
export function stopScraperLoop() {
if (interval) { clearInterval(interval); interval = null; }
}