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>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user