import urllib.request
import xml.etree.ElementTree as ET
import re
from datetime import datetime
import threading
import time
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5'
}
_cache = {
"data": None,
"last_fetched": 0
}
_diagram_cache = {}
_lock = threading.Lock()
FIELD_MAP = {
"copper": "LME_Cu_cash",
"cu": "LME_Cu_cash",
"aluminium": "LME_Al_cash",
"al": "LME_Al_cash",
"aluminum": "LME_Al_cash",
"zinc": "LME_Zn_cash",
"zn": "LME_Zn_cash",
"nickel": "LME_Ni_cash",
"ni": "LME_Ni_cash",
"lead": "LME_Pb_cash",
"pb": "LME_Pb_cash",
"tin": "LME_Sn_cash",
"sn": "LME_Sn_cash",
}
FA_NAMES = {
"Copper": "مس",
"Tin": "قلع",
"Lead": "سرب",
"Zinc": "روی",
"Aluminium": "آلومینیوم",
"Nickel": "نیکل",
"WM_Cu_low": "مس کاتد آلمان (کف قیمت)",
"WM_Cu_high": "مس کاتد آلمان (سقف قیمت)",
"WI_Cu": "مس ویلند (Wieland)",
"ACI": "شاخص مس پیشرفته (ACI)",
"MB_bronze_94_6": "برنز ۹۴/۶",
"MB_MS_58_1": "برنج MS 58 (مرحله ۱)",
"MB_MS_58_2": "برنج MS 58 (مرحله ۲)",
"MB_MS_63_37": "برنج MS 63/37",
"MB_MS_63_wire": "مفتول برنجی MS 63",
"USD_ozt_London": "انس طلای لندن (Fixing)",
"Au": "طلای شمش در اروپا",
"Au_processed": "طلای ساختهشده در اروپا",
"Ag": "نقره خالص در اروپا",
"Ag_processed": "نقره ساختهشده در اروپا",
}
SERIES_FA_NAMES = {
"LME Copper Cash-Settlement": "قیمت تسویه نقدی مس",
"LME Copper stock": "موجودی انبار مس",
"LME Aluminium Cash-Settlement": "قیمت تسویه نقدی آلومینیوم",
"LME Aluminium stock": "موجودی انبار آلومینیوم",
"LME Zinc Cash-Settlement": "قیمت تسویه نقدی روی",
"LME Zinc stock": "موجودی انبار روی",
"LME Nickel Cash-Settlement": "قیمت تسویه نقدی نیکل",
"LME Nickel stock": "موجودی انبار نیکل",
"LME Lead Cash-Settlement": "قیمت تسویه نقدی سرب",
"LME Lead stock": "موجودی انبار سرب",
"LME Tin Cash-Settlement": "قیمت تسویه نقدی قلع",
"LME Tin stock": "موجودی انبار قلع",
"WM_Cu_low": "کف قیمت مس کاتد آلمان",
"WM_Cu_high": "سقف قیمت مس کاتد آلمان",
"Gold London Fixing": "انس طلای لندن",
"Fine Silver in Euro/kg": "نقره خالص در اروپا",
"Gold in Euro/kg": "طلای شمش در اروپا",
"Advanced Copper Index (ACI)": "شاخص مس پیشرفته (ACI)",
"Wieland-Copper": "مس ویلند",
}
def clean_num(val: str) -> float:
if not val:
return 0.0
val = val.replace(',', '').replace('+', '').replace('$', '').strip()
try:
return float(val)
except:
return 0.0
def fetch_raw():
url = 'https://www.westmetall.com/en/markdaten.php'
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=12) as resp:
html = resp.read().decode('utf-8', errors='ignore')
tables = re.findall(r'
', html)
if len(tables) < 2:
return None
# Table 0: Official LME Prices
t0_rows = re.findall(r']*>([\s\S]*?)
', tables[0])
date_str = ""
prices = []
if t0_rows:
header_cells = re.findall(r']*>([\s\S]*?)', t0_rows[0])
if len(header_cells) > 1:
date_str = re.sub(r'<.*?>', '', header_cells[1]).strip()
for r in t0_rows[2:]:
cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r']*>([\s\S]*?)', r)]
links = re.findall(r'field=([a-zA-Z0-9_]+)', r)
field = links[0] if links else f"LME_{cells[0][:2]}_cash"
if len(cells) >= 3 and cells[0]:
prices.append({
"metal": cells[0],
"name_fa": FA_NAMES.get(cells[0], cells[0]),
"field": field,
"cash": clean_num(cells[1]),
"cash_str": cells[1],
"three_months": clean_num(cells[2]),
"three_months_str": cells[2],
"unit": "دلار بر تن"
})
# Table 1: LME Stocks
t1_rows = re.findall(r']*>([\s\S]*?)
', tables[1])
stocks = []
if len(t1_rows) > 2:
for r in t1_rows[2:]:
cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r']*>([\s\S]*?)', r)]
links = re.findall(r'field=([a-zA-Z0-9_]+)', r)
field = links[0] if links else ""
if len(cells) >= 3 and cells[0]:
stocks.append({
"metal": cells[0],
"name_fa": FA_NAMES.get(cells[0], cells[0]),
"field": field,
"stocks": clean_num(cells[1]),
"stocks_str": cells[1],
"change": clean_num(cells[2]),
"change_str": cells[2],
"unit": "تن"
})
# Combine Prices + Stocks by metal
combined_metals = []
stocks_map = {s["metal"]: s for s in stocks}
for p in prices:
m_name = p["metal"]
s_data = stocks_map.get(m_name, {})
spread = round(p["three_months"] - p["cash"], 2)
combined_metals.append({
"metal": m_name,
"name_fa": p["name_fa"],
"field": p["field"],
"cash_settlement": p["cash"],
"cash_str": p["cash_str"],
"three_months": p["three_months"],
"three_months_str": p["three_months_str"],
"spread": spread,
"spread_str": f"{spread:+,.2f}",
"stocks": s_data.get("stocks", 0),
"stocks_str": s_data.get("stocks_str", "-"),
"stocks_change": s_data.get("change", 0),
"stocks_change_str": s_data.get("change_str", "-"),
"unit": "دلار بر تن"
})
# Table 2: FX Rates
fx = []
if len(tables) > 2:
for r in re.findall(r']*>([\s\S]*?)
', tables[2])[1:]:
cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r']*>([\s\S]*?)', r)]
if len(cells) >= 2 and cells[0]:
fx.append({
"pair": cells[0],
"rate": clean_num(cells[1]),
"rate_str": cells[1]
})
# Table 3: German Metal Quotations
german_symbols = []
if len(tables) > 3:
for r in re.findall(r']*>([\s\S]*?)
', tables[3])[2:]:
cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r']*>([\s\S]*?)', r)]
links = re.findall(r'field=([a-zA-Z0-9_]+)', r)
field = links[0] if links else ""
if len(cells) >= 3 and cells[0]:
german_symbols.append({
"name": cells[0],
"name_fa": FA_NAMES.get(field, cells[0]),
"field": field,
"price_str": cells[1],
"prev_str": cells[2],
"unit": "یورو بر ۱۰۰ کیلوگرم"
})
# Table 4: Precious Metals
precious = []
if len(tables) > 4:
for r in re.findall(r']*>([\s\S]*?)
', tables[4])[1:]:
cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r']*>([\s\S]*?)', r)]
links = re.findall(r'field=([a-zA-Z0-9_]+)', r)
field = links[0] if links else ""
if len(cells) >= 2 and cells[0]:
precious.append({
"name": cells[0],
"name_fa": FA_NAMES.get(field, cells[0]),
"field": field,
"price_str": cells[1],
"prev_str": cells[2] if len(cells) > 2 else "-"
})
return {
"date": date_str or datetime.utcnow().strftime("%d. %B %Y"),
"updated_at": datetime.utcnow().isoformat(),
"metals": combined_metals,
"stocks": stocks,
"german_symbols": german_symbols,
"exchange_rates": fx,
"precious_metals": precious
}
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:
return _cache["data"]
try:
fresh = fetch_raw()
if fresh:
_cache["data"] = fresh
_cache["last_fetched"] = now
return fresh
except Exception as e:
if _cache["data"]:
return _cache["data"]
raise e
return _cache["data"]
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)
now = time.time()
with _lock:
cached = _diagram_cache.get(field_clean)
if cached and (now - cached["time"]) < ttl_seconds:
return cached["data"]
url = f'https://www.westmetall.com/api/marketdata/en/{field_clean}/'
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=15) as r:
xml_text = r.read().decode('utf-8')
root = ET.fromstring(xml_text)
series_elem = root.find('series')
if series_elem is None:
return {"field": field_clean, "lines": []}
lines = []
for line in series_elem:
raw_name = line.attrib.get('name', field_clean)
fa_line_name = SERIES_FA_NAMES.get(raw_name, raw_name)
color = line.attrib.get('lineColor', '#D02E00')
raw_unit = line.attrib.get('yUnit', '')
precision = int(line.attrib.get('yPrecision', '2'))
# Persian unit & type
is_stock = "stock" in raw_name.lower() or "bestände" in raw_name.lower() or "انبار" in fa_line_name
if is_stock:
unit = "تن"
series_type = "stocks"
color = "#f97316" # vibrant orange for stocks
elif "€" in raw_unit or "euro" in raw_unit.lower():
unit = "یورو"
series_type = "price"
color = "#D02E00" # red for primary price
elif "$" in raw_unit or "usd" in raw_unit.lower():
unit = "دلار"
series_type = "price"
color = "#D02E00"
else:
unit = raw_unit
series_type = "price"
pts = []
last_valid_y = None
for val in line:
raw_x = val.attrib.get('x', '') # YYYY/MM/DD
date_iso = raw_x.replace('/', '-')
parts = date_iso.split('-')
date_display = f"{parts[0]}/{parts[1]}/{parts[2]}" if len(parts) == 3 else date_iso
y_str = val.attrib.get('y', '0').replace(',', '')
try:
y_val = float(y_str)
except:
y_val = 0.0
# Filter out bank holiday / reporting zero anomalies (e.g. market closed)
if y_val <= 0:
if last_valid_y is not None:
y_val = last_valid_y
else:
continue
else:
last_valid_y = y_val
pts.append({
"x": date_iso,
"date_display": date_display,
"y": y_val
})
lines.append({
"name": fa_line_name,
"raw_name": raw_name,
"series_type": series_type,
"color": color,
"unit": unit,
"precision": precision,
"count": len(pts),
"points": pts
})
result = {
"field": field_clean,
"name_fa": FA_NAMES.get(field_clean, field_clean),
"total_points": lines[0]["count"] if lines else 0,
"lines": lines
}
with _lock:
_diagram_cache[field_clean] = {"time": now, "data": result}
return result
def get_metal_history(metal_key: str, ttl_seconds: int = 300):
return get_entire_diagram_data(metal_key, ttl_seconds)