332 lines
12 KiB
Python
332 lines
12 KiB
Python
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'<table[^>]*>([\s\S]*?)</table>', html)
|
||
if len(tables) < 2:
|
||
return None
|
||
|
||
# Table 0: Official LME Prices
|
||
t0_rows = re.findall(r'<tr[^>]*>([\s\S]*?)</tr>', tables[0])
|
||
date_str = ""
|
||
prices = []
|
||
if t0_rows:
|
||
header_cells = re.findall(r'<t[dh][^>]*>([\s\S]*?)</t[dh]>', 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'<t[dh][^>]*>([\s\S]*?)</t[dh]>', 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'<tr[^>]*>([\s\S]*?)</tr>', 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'<t[dh][^>]*>([\s\S]*?)</t[dh]>', 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'<tr[^>]*>([\s\S]*?)</tr>', tables[2])[1:]:
|
||
cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r'<t[dh][^>]*>([\s\S]*?)</t[dh]>', 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'<tr[^>]*>([\s\S]*?)</tr>', tables[3])[2:]:
|
||
cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r'<t[dh][^>]*>([\s\S]*?)</t[dh]>', 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'<tr[^>]*>([\s\S]*?)</tr>', tables[4])[1:]:
|
||
cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r'<t[dh][^>]*>([\s\S]*?)</t[dh]>', 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 = 180):
|
||
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 = 3600):
|
||
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 = []
|
||
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
|
||
|
||
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)
|