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
for these tables (not the first | ).
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 | '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 |
// ("(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; }
}
|