import type { InstrumentId } from "./instruments"; import { goldKeys, type Direction, type GoldRow, type MarketSnapshot, type MarketSource, type Quote, type Session, type SessionKey, } from "./types"; const SOURCE_URL = "https://livedata.ir/"; /* ── shared helpers ─────────────────────────────────────────────────────── */ const stripTags = (value: string) => value.replace(/<[^>]*>/g, "").trim(); function directionOf(change: string): Direction { const value = Number.parseFloat(change.replace(/,/g, "")); if (!Number.isFinite(value) || value === 0) return "flat"; return value > 0 ? "up" : "down"; } /* ── 1. id-anchored figures ─────────────────────────────────────────────── */ function readSpan(html: string, prefix: string, id: string) { const match = html.match(new RegExp(`id="${prefix}_${id}"[^>]*>([^<]*)<`)); return match ? match[1].trim() : null; } function parseQuotes(html: string, ids: readonly InstrumentId[]): Quote[] { const quotes: Quote[] = []; for (const id of ids) { const price = readSpan(html, "s", id); // An instrument that moved or vanished upstream is skipped, not fatal. if (!price) continue; const change = readSpan(html, "c", id) ?? "0"; quotes.push({ id, price, change, changePercent: readSpan(html, "cp", id) ?? "0%", direction: directionOf(change), }); } return quotes; } /* ── 2. gold purities ───────────────────────────────────────────────────── */ /** * This table carries no ids, so it is anchored on its section heading and then * on each row's fixed Persian label. More brittle than the id-based figures — * if upstream renames a row, that row drops out and the rest survive. */ const GOLD_ROW_LABELS: Record = { "هر گرم 24": "g24", "هر گرم 22": "g22", "هر گرم 18": "g18", }; /** Upstream id for the world gold ounce, shown as the table's last row. */ const GOLD_OUNCE_ID = "300106"; function parseGold(html: string): GoldRow[] { const start = html.indexOf("قیمت عیارهای مختلف طلا"); if (start === -1) return []; const table = html.slice(start, html.indexOf("", start)); const rows: GoldRow[] = []; const pattern = /([^<]+)<\/td>\s*]*>([^<]*)<\/td>\s*]*>([^<]*)<\/td>/g; for (const match of table.matchAll(pattern)) { const key = GOLD_ROW_LABELS[stripTags(match[1])]; if (!key) continue; rows.push({ key, toman: match[2].trim(), usd: match[3].trim() }); } // The world ounce lives elsewhere on the page, under its own id. const ouncePrice = readSpan(html, "s", GOLD_OUNCE_ID); if (ouncePrice) rows.push({ key: "ounce", toman: null, usd: ouncePrice }); // Keep our declared order rather than the upstream's. return goldKeys .map((key) => rows.find((row) => row.key === key)) .filter((row): row is GoldRow => Boolean(row)); } /* ── 3. exchange sessions ───────────────────────────────────────────────── */ const SESSION_NAMES: Record = { نیویورک: "newYork", لندن: "london", توکیو: "tokyo", }; function parseSessions(html: string): Session[] { const start = html.indexOf("ساعت و وضعیت کاری"); if (start === -1) return []; const block = html.slice(start, html.indexOf("", start)); const sessions: Session[] = []; // name, then the status icon class, then optional reopening date after the text const pattern = /([^<]+)<\/span>\s*<\/span>\s*[^<]*<\/span>\s*([^<]*)/g; for (const match of block.matchAll(pattern)) { const key = SESSION_NAMES[stripTags(match[1])]; if (!key) continue; const closed = /close/i.test(match[2]); const reopensOn = match[3].trim(); sessions.push({ key, open: !closed, reopensOn: closed && reopensOn ? reopensOn : null }); } return sessionKeysOrder(sessions); } function sessionKeysOrder(sessions: Session[]) { const order: SessionKey[] = ["newYork", "london", "tokyo"]; return order .map((key) => sessions.find((session) => session.key === key)) .filter((session): session is Session => Boolean(session)); } /* ── source ─────────────────────────────────────────────────────────────── */ /** * livedata.ir publishes no API — the figures sit in a static page its server * regenerates once a minute, on the minute. We read only the numbers and label * them ourselves, in our own three languages. */ export const livedataSource: MarketSource = { name: "livedata.ir", url: SOURCE_URL, async fetchSnapshot(ids: readonly InstrumentId[]): Promise { const response = await fetch(SOURCE_URL, { // One upstream request per minute for the whole site, not one per visitor. // Matches their regeneration cadence — polling faster gains nothing. next: { revalidate: 60 }, headers: { "User-Agent": "majdtrading.com market widget" }, }); if (!response.ok) throw new Error(`livedata.ir responded ${response.status}`); const html = await response.text(); const quotes = parseQuotes(html, ids); const gold = parseGold(html); const sessions = parseSessions(html); if (quotes.length === 0 && gold.length === 0 && sessions.length === 0) { throw new Error("livedata.ir markup produced nothing — parsers need updating"); } return { quotes, gold, sessions, fetchedAt: new Date().toISOString(), source: { name: livedataSource.name, url: SOURCE_URL }, }; }, };