diff --git a/backend/ime_metals.py b/backend/ime_metals.py index cd71334..2127daa 100644 --- a/backend/ime_metals.py +++ b/backend/ime_metals.py @@ -2,16 +2,21 @@ import urllib.request import json import ssl import time +import re import threading from typing import Dict, Any, List, Optional IME_TOKEN = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiMDkzNjMxNDAyNjIiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjE0ODgiLCJleHAiOjE3ODgzMjQ5MzN9.9r-XUKmRUBQ6uEOZGq09l474ZaDDPazNVUDHuXY2_QmBtS2RU78xJLt753GaGU2SZtpndzNYKWDYaoc58SNyAA" BASE_URL = "https://api.yektazob.com" +# 6 hours cache +CACHE_TTL = 21600 + _cache_categories = { "data": None, "last_fetched": 0 } +_cache_sub_symbols = {} _cache_symbols = {} _lock = threading.Lock() @@ -25,7 +30,31 @@ HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } -def get_ime_categories(ttl_seconds: int = 3600) -> List[Dict[str, Any]]: +def clean_persian_title(title: str) -> tuple[str, str]: + """ + Removes English symbol codes (e.g. FTKC-BSG017OO-00, KHSS-DRIBRI-00) + and returns (clean_title, manufacturer). + """ + if not title: + return "", "" + parts = [p.strip() for p in title.split(" - ") if p.strip()] + if len(parts) >= 3: + # Check if middle part is an English symbol code + if re.search(r'^[A-Za-z0-9_-]+$', parts[1]): + product = parts[0] + manufacturer = parts[2] + return f"{product} ({manufacturer})", manufacturer + elif len(parts) == 2: + if re.search(r'^[A-Za-z0-9_-]+$', parts[1]): + return parts[0], "" + return f"{parts[0]} ({parts[1]})", parts[1] + + # Remove any standalone English codes like FTKC-... + cleaned = re.sub(r'\b[A-Za-z0-9]{3,}-[A-Za-z0-9_-]+\b', '', title) + cleaned = re.sub(r'\s*-\s*-+\s*', ' - ', cleaned).strip(" -") + return cleaned or title, "" + +def get_ime_categories(ttl_seconds: int = CACHE_TTL) -> List[Dict[str, Any]]: now = time.time() with _lock: if _cache_categories["data"] and (now - _cache_categories["last_fetched"]) < ttl_seconds: @@ -48,18 +77,15 @@ def get_ime_categories(ttl_seconds: int = 3600) -> List[Dict[str, Any]]: sub_title = sub.get("title", "") symbols = [] for s in sub.get("symbols", []): - s_title = s.get("title", "") - parts = [p.strip() for p in s_title.split(" - ") if p.strip()] - product_name = s.get("productName") or (parts[0] if len(parts) > 0 else s_title) - symbol_code = s.get("symbol") or (parts[1] if len(parts) > 1 else "") - manufacturer = s.get("manufacturer") or (parts[2] if len(parts) > 2 else "") + raw_title = s.get("title", "") + clean_title, mfr = clean_persian_title(raw_title) + product_name = s.get("productName") or clean_title symbols.append({ "id": s.get("id"), - "title": s_title, + "title": clean_title, "product_name": product_name, - "symbol_code": symbol_code, - "manufacturer": manufacturer, + "manufacturer": s.get("manufacturer") or mfr, "is_active": s.get("isActive", True), "updated_at": s.get("updatedAt", "") }) @@ -86,8 +112,59 @@ def get_ime_categories(ttl_seconds: int = 3600) -> List[Dict[str, Any]]: return cleaned +def get_ime_subcategory_symbols(sub_id: int, ttl_seconds: int = CACHE_TTL) -> List[Dict[str, Any]]: + """ + Fetches all symbols of a subcategory WITH their latest prices attached. + """ + now = time.time() + with _lock: + cached = _cache_sub_symbols.get(sub_id) + if cached and (now - cached["time"]) < ttl_seconds: + return cached["data"] -def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = 1800) -> Dict[str, Any]: + url = f"{BASE_URL}/Symbols/IME/Symbols/GetAll" + payload = json.dumps({"PageNumber": 1, "PageSize": 1000, "CategoryId": sub_id}).encode('utf-8') + req = urllib.request.Request(url, data=payload, headers=HEADERS) + + with urllib.request.urlopen(req, context=ctx, timeout=12) as resp: + raw_syms = json.loads(resp.read().decode('utf-8')) + + results = [] + for s in raw_syms: + raw_title = s.get("title", "") + clean_title, mfr = clean_persian_title(raw_title) + product_name = s.get("productName") or clean_title + + # Latest price from symbolPrices + prices = s.get("symbolPrices") or [] + latest_p = None + if prices: + last = prices[-1] + latest_p = { + "id": last.get("id"), + "date": last.get("date", "").split("T")[0], + "high": last.get("high"), + "low": last.get("low"), + "mid": last.get("mid") + } + + results.append({ + "id": s.get("id"), + "cat_id": s.get("imeCategoryId") or sub_id, + "title": clean_title, + "product_name": product_name, + "manufacturer": s.get("manufacturer") or mfr, + "latest_price": latest_p, + "total_points": len(prices), + "is_active": s.get("isActive", True) + }) + + with _lock: + _cache_sub_symbols[sub_id] = {"time": now, "data": results} + + return results + +def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = CACHE_TTL) -> Dict[str, Any]: now = time.time() with _lock: cached = _cache_symbols.get(symbol_id) @@ -104,21 +181,32 @@ def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = 1800) -> Dict[str, sorted_prices = sorted(raw_prices, key=lambda p: p.get("date", "")) clean_prices = [] + last_valid_mid = None for p in sorted_prices: d_str = p.get("date", "") date_iso = d_str.split("T")[0] if "T" in d_str else d_str + mid_val = p.get("mid") or p.get("high") or p.get("low") or 0 + if mid_val <= 0 and last_valid_mid is not None: + mid_val = last_valid_mid + else: + last_valid_mid = mid_val + clean_prices.append({ "id": p.get("id"), "date": date_iso, - "high": p.get("high"), - "low": p.get("low"), - "mid": p.get("mid") + "high": p.get("high") or mid_val, + "low": p.get("low") or mid_val, + "mid": mid_val }) + raw_title = raw_detail.get("title", "") + clean_title, mfr = clean_persian_title(raw_title) + result = { "id": raw_detail.get("id"), "cat_id": raw_detail.get("catID"), - "title": raw_detail.get("title"), + "title": clean_title, + "manufacturer": mfr, "order_no": raw_detail.get("orderNo"), "small_desc": raw_detail.get("smallDesc"), "total_points": len(clean_prices), diff --git a/backend/main.py b/backend/main.py index ce06577..9b84e17 100644 --- a/backend/main.py +++ b/backend/main.py @@ -261,6 +261,14 @@ def get_ime_categories_route(_: object = Depends(require_session)): except Exception as e: raise HTTPException(status_code=502, detail=f"IME categories error: {e}") + +@app.get("/api/ime/subcategory/{sub_id}/symbols") +def get_ime_sub_symbols_route(sub_id: int, _: object = Depends(require_session)): + try: + return ime_metals.get_ime_subcategory_symbols(sub_id) + except Exception as e: + raise HTTPException(status_code=502, detail=f"IME subcategory symbols error: {e}") + @app.get("/api/ime/symbol/{symbol_id}") def get_ime_symbol_route(symbol_id: int, _: object = Depends(require_session)): try: diff --git a/backend/scrape_lme.py b/backend/scrape_lme.py index 8fe6ece..3eff00a 100644 --- a/backend/scrape_lme.py +++ b/backend/scrape_lme.py @@ -222,7 +222,7 @@ def fetch_raw(): "precious_metals": precious } -def get_lme_data(ttl_seconds: int = 180): +def get_lme_data(ttl_seconds: int = 21600): # 6 hours cache now = time.time() with _lock: if _cache["data"] and (now - _cache["last_fetched"]) < ttl_seconds: @@ -239,7 +239,7 @@ def get_lme_data(ttl_seconds: int = 180): raise e return _cache["data"] -def get_entire_diagram_data(field: str, ttl_seconds: int = 3600): +def get_entire_diagram_data(field: str, ttl_seconds: int = 21600): # 6 hours cache field_clean = field.strip() field_clean = FIELD_MAP.get(field_clean.lower(), field_clean) diff --git a/frontend/src/components/ImeView.tsx b/frontend/src/components/ImeView.tsx index fe21528..8943311 100644 --- a/frontend/src/components/ImeView.tsx +++ b/frontend/src/components/ImeView.tsx @@ -2,24 +2,18 @@ import { useState, useEffect, useMemo } from "react"; import { Search, Factory, - Layers, - ChevronDown, LineChart as LineChartIcon, X, Loader2, Calendar, - Sparkles, - ArrowUpRight, - TrendingUp, - TrendingDown, Building2, - Hash + TrendingUp, + ArrowUpRight } from "lucide-react"; import { ResponsiveContainer, ComposedChart, Area, - Line, XAxis, YAxis, Tooltip, @@ -29,14 +23,22 @@ import { API_BASE } from "@/lib/api"; const API = API_BASE; +export interface ImePricePoint { + id: number; + date: string; // YYYY-MM-DD + high: number; + low: number; + mid: number; +} + export interface ImeSymbol { id: number; title: string; product_name: string; - symbol_code: string; manufacturer: string; is_active: boolean; updated_at: string; + latest_price?: ImePricePoint; } export interface ImeSubcategory { @@ -54,18 +56,11 @@ export interface ImeCategory { subcategories: ImeSubcategory[]; } -export interface ImePricePoint { - id: number; - date: string; // YYYY-MM-DD - high: number; - low: number; - mid: number; -} - export interface ImeSymbolDetail { id: number; cat_id: number; title: string; + manufacturer?: string; order_no?: number; small_desc?: string; total_points: number; @@ -85,13 +80,17 @@ export function ImeView() { const [selectedSubId, setSelectedSubId] = useState("ALL"); const [search, setSearch] = useState(""); + // Subcategory price cache (populated when subcategory is clicked) + const [subPricesMap, setSubPricesMap] = useState>>({}); + const [subLoading, setSubLoading] = useState(false); + // Modal Detail const [selectedSymbol, setSelectedSymbol] = useState(null); const [symbolDetail, setSymbolDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [timeframe, setTimeframe] = useState("ENTIRE"); - // Load all IME categories + // Load categories once on mount (no aggressive interval) useEffect(() => { let alive = true; setLoading(true); @@ -118,7 +117,42 @@ export function ImeView() { }; }, []); - // Fetch symbol detail & price history when modal opens + // When a specific subcategory is selected, fetch all its symbols with latest prices attached + useEffect(() => { + if (selectedSubId === "ALL") return; + + let alive = true; + setSubLoading(true); + + fetch(`${API}/api/ime/subcategory/${selectedSubId}/symbols`, { credentials: "include" }) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then((symbols: Array) => { + if (!alive) return; + const pMap: Record = {}; + for (const s of symbols) { + if (s.latest_price) { + pMap[s.id] = s.latest_price; + } + } + setSubPricesMap((prev) => ({ + ...prev, + [selectedSubId as number]: pMap + })); + }) + .catch(() => {}) + .finally(() => { + if (alive) setSubLoading(false); + }); + + return () => { + alive = false; + }; + }, [selectedSubId]); + + // Fetch symbol detail & full price history when modal opens useEffect(() => { if (!selectedSymbol) { setSymbolDetail(null); @@ -160,16 +194,14 @@ export function ImeView() { // Active subcategories based on root category selection const currentSubcategories = useMemo(() => { - if (selectedRootId === "ALL") { - return []; - } + if (selectedRootId === "ALL") return []; const cat = categories.find((c) => c.id === selectedRootId); return cat ? cat.subcategories : []; }, [categories, selectedRootId]); - // Flattened symbols filtered by root, subcategory, and search query + // Flattened symbols filtered by root, subcategory, and search query (100% clean of English codes) const filteredSymbols = useMemo(() => { - let list: Array = []; + let list: Array = []; for (const root of categories) { if (selectedRootId !== "ALL" && root.id !== selectedRootId) continue; @@ -178,10 +210,15 @@ export function ImeView() { if (selectedSubId !== "ALL" && sub.id !== selectedSubId) continue; for (const sym of sub.symbols) { + // Look up latest price from subPricesMap if available + const priceObj = subPricesMap[sub.id]?.[sym.id] || sym.latest_price; + list.push({ ...sym, + latest_price: priceObj, rootTitle: root.title, - subTitle: sub.title + subTitle: sub.title, + subId: sub.id }); } } @@ -194,12 +231,11 @@ export function ImeView() { (s) => s.title.toLowerCase().includes(q) || s.product_name.toLowerCase().includes(q) || - s.symbol_code.toLowerCase().includes(q) || s.manufacturer.toLowerCase().includes(q) || s.rootTitle.toLowerCase().includes(q) || s.subTitle.toLowerCase().includes(q) ); - }, [categories, selectedRootId, selectedSubId, search]); + }, [categories, selectedRootId, selectedSubId, search, subPricesMap]); // Filtered prices for modal chart by timeframe const chartPoints = useMemo(() => { @@ -247,7 +283,7 @@ export function ImeView() { بورس فلزات ایران (IME)

- تابلوی معاملات فیزیکی بورس کالای ایران، نرخ‌های کشف‌شده و آرشیو تاریخی + تابلوی معاملات فیزیکی بورس کالا • آخرین نرخ‌های کشف‌شده و آرشیو تاریخی

@@ -259,7 +295,7 @@ export function ImeView() { type="text" value={search} onChange={(e) => setSearch(e.target.value)} - placeholder="جستجوی نماد، محصول یا تولیدکننده..." + placeholder="جستجوی نام کالا یا تولیدکننده..." className="w-full rounded-xl border border-border/80 bg-background/80 py-1.5 pr-9 pl-3 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary" /> @@ -306,7 +342,7 @@ export function ImeView() { ))} - {/* Subcategory Pills (when a root is selected) */} + {/* Subcategory Pills */} {currentSubcategories.length > 0 && (