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>
165 lines
6.0 KiB
TypeScript
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 },
|
|
};
|
|
},
|
|
};
|