Files
majd-trading/src/lib/market/livedata.ts
T
Alireza HassaniandClaude Opus 5 02aaf61bb6 Initial import: Majd Trading website
Next.js 15 site for گروه بازرگانی بین المللی آریو مجد آسیا, rebuilt from
majdtrading.com. Persian, English and Arabic; RTL and LTR.

- Pages: home, about, contact, six product categories, blog index and detail
- i18n: prose in per-locale JSON, structure in TypeScript, drift checked by
  `npm run i18n:check`. Blog stays single-language by design
- Meshkee backend: blog content, tenant resolution, session via the customer
  portal, and /meshkee/sitemap-config.json
- Live market data from livedata.ir, cached server-side once a minute
- Self-hosted fonts: IRANYekan (fa), IBM Plex Sans Arabic (ar), Space Grotesk
  and BBH Bartle (Latin)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:16:32 +03:30

165 lines
6.0 KiB
TypeScript

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<string, (typeof goldKeys)[number]> = {
"هر گرم 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("</table>", start));
const rows: GoldRow[] = [];
const pattern = /<td class="tabna">([^<]+)<\/td>\s*<td[^>]*>([^<]*)<\/td>\s*<td[^>]*>([^<]*)<\/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<string, SessionKey> = {
نیویورک: "newYork",
لندن: "london",
توکیو: "tokyo",
};
function parseSessions(html: string): Session[] {
const start = html.indexOf("ساعت و وضعیت کاری");
if (start === -1) return [];
const block = html.slice(start, html.indexOf("</ul>", start));
const sessions: Session[] = [];
// name, then the status icon class, then optional reopening date after the text
const pattern =
/<span class="nma">([^<]+)<\/span>\s*<span class="sp ([^"]*)"><\/span>\s*<span>[^<]*<\/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<MarketSnapshot> {
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 },
};
},
};