148 lines
6.2 KiB
Python
148 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from datetime import datetime, timezone, timedelta
|
||
import requests
|
||
|
||
CACHE = "/root/.openclaw/workspace/fx_realtime.json"
|
||
REPORT_TZ = timezone(timedelta(hours=8))
|
||
FETCH = "/root/.openclaw/workspace/web_tasks/fx_fetch_real.js"
|
||
PAIRS = {"USD/CNY", "EUR/CNY", "SGD/CNY", "GBP/CNY", "USD/EUR", "GBP/EUR"}
|
||
PAT = re.compile(r'\[\[(20\d{2}),(\d{1,2}),(\d{1,2}),(\d{1,2}),(\d{1,2}),null,null,\[\]\],\[([0-9]+\.[0-9]+),')
|
||
|
||
def google_rate(base, quote):
|
||
s = requests.Session()
|
||
s.headers.update({"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0 Safari/537.36", "Accept-Language":"en-US,en;q=0.9"})
|
||
s.cookies.set("SOCS", "CAESHAgBEhJnd3NfMjAyMzA4MTAtMF9SQzIaAmVuIAEaBgiAo_CmBg", domain=".google.com")
|
||
u = f"https://www.google.com/finance/quote/{base}-{quote}"
|
||
r = s.get(u, timeout=15, allow_redirects=True)
|
||
hits = PAT.findall(r.text)
|
||
if not hits:
|
||
return None, None
|
||
y, mo, d, h, mi, price = max(hits, key=lambda x: tuple(int(v) for v in x[:5]))
|
||
return float(price), f"{y}-{int(mo):02d}-{int(d):02d} {int(h):02d}:{int(mi):02d} UTC"
|
||
|
||
def fresh_cache():
|
||
try:
|
||
with open(CACHE, encoding="utf-8") as f: raw = json.load(f)
|
||
gen = datetime.fromisoformat(raw.get("generatedAt", "").replace("Z", "+00:00"))
|
||
return raw if (datetime.now(timezone.utc) - gen).total_seconds() <= 600 else None
|
||
except Exception:
|
||
return None
|
||
|
||
def ensure_real():
|
||
raw = fresh_cache()
|
||
if raw:
|
||
return raw
|
||
try:
|
||
subprocess.run(["node", FETCH], cwd=os.path.dirname(FETCH), timeout=150, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
||
except Exception:
|
||
return None
|
||
return fresh_cache()
|
||
|
||
def network_rates(raw, base, quote):
|
||
key = f"{base}/{quote}"
|
||
rev = f"{quote}/{base}"
|
||
for name, data in (("Visa", (raw.get("visa") or {}).get(key)), ("Mastercard", (raw.get("mastercard") or {}).get(key))):
|
||
if data and data.get("ok"):
|
||
try:
|
||
orig = data["data"]["originalValues"]
|
||
fx = float(orig["fxRateVisa"])
|
||
f, t = orig.get("fromCurrency"), orig.get("toCurrency")
|
||
if f == base and t == quote: yield name, fx, data.get("queryDate") or data.get("data", {}).get("disclaimerDate", "")
|
||
elif f == quote and t == base: yield name, 1/fx, data.get("queryDate", "")
|
||
except Exception: pass
|
||
data = (raw.get("mastercard") or {}).get(key)
|
||
if data and data.get("data") and data["data"].get("data"):
|
||
try: yield "Mastercard", float(data["data"]["data"]["conversionRate"]), data["data"]["data"].get("fxDate", "")
|
||
except Exception: pass
|
||
data = (raw.get("mastercard") or {}).get(rev)
|
||
if data and data.get("data") and data["data"].get("data"):
|
||
try: yield "Mastercard", 1/float(data["data"]["data"]["conversionRate"]), data["data"]["data"].get("fxDate", "")
|
||
except Exception: pass
|
||
|
||
def parse_date(value):
|
||
if not value:
|
||
return None
|
||
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%Y/%m/%d"):
|
||
try:
|
||
return datetime.strptime(value[:10], fmt).date()
|
||
except ValueError:
|
||
continue
|
||
return None
|
||
|
||
def age_label(date_text):
|
||
d = parse_date(date_text)
|
||
if not d:
|
||
return ""
|
||
days = (datetime.now(REPORT_TZ).date() - d).days
|
||
if days <= 0:
|
||
return "🟢 最新"
|
||
if days == 1:
|
||
return "🟢 1天前"
|
||
if days <= 3:
|
||
return f"🟠 {days}天前"
|
||
return f"🔴 {days}天前"
|
||
|
||
def fmt_date(date_text):
|
||
d = parse_date(date_text)
|
||
return d.isoformat() if d else (date_text or "未知日期")
|
||
|
||
def main():
|
||
if len(sys.argv) != 4: raise SystemExit("usage: rate_query.py AMOUNT BASE QUOTE")
|
||
amount = float(sys.argv[1]); base = sys.argv[2].upper(); quote = sys.argv[3].upper()
|
||
g, gt = google_rate(base, quote)
|
||
raw = ensure_real() if f"{base}/{quote}" in PAIRS or f"{quote}/{base}" in PAIRS else None
|
||
lines = [f"💱 {amount:g} {base} → {quote}", ""]
|
||
rates = []
|
||
if g is not None:
|
||
lines += ["市场参考", f"Google 1 {base} = {g:.6f} {quote}", f" {amount:g} {base} = {amount*g:.2f} {quote}"]
|
||
rates.append(("Google", g))
|
||
else:
|
||
lines += ["市场参考", "Google 暂时无法获取"]
|
||
|
||
network = []
|
||
if raw:
|
||
for name, rate, date in network_rates(raw, base, quote):
|
||
if any(n == name for n, _, _ in network):
|
||
continue
|
||
network.append((name, rate, date))
|
||
lines += ["", "卡组织汇率"]
|
||
if network:
|
||
for name, rate, date in network:
|
||
freshness = age_label(date)
|
||
date_part = f"📅 {fmt_date(date)}"
|
||
if freshness:
|
||
date_part += f" · {freshness}"
|
||
lines += [f"{name:<12}1 {base} = {rate:.6f} {quote}", f" {amount:g} {base} = {amount*rate:.2f} {quote}", f" {date_part}"]
|
||
rates.append((name, rate))
|
||
else:
|
||
lines.append("Visa/MC 暂无最新官方结算数据")
|
||
|
||
if network and g is not None:
|
||
lines += ["", "📊 相对 Google"]
|
||
for name, rate, _ in network:
|
||
diff = (rate / g - 1.0) * 100.0
|
||
sign = "+" if diff >= 0 else ""
|
||
lines.append(f"{name:<12}{sign}{diff:.3f}%")
|
||
if len(network) >= 1:
|
||
card_rates = [r for _, r, _ in network]
|
||
lo, hi = min(card_rates), max(card_rates)
|
||
if len(card_rates) == 1:
|
||
lines += ["", f"💳 卡组织参考:{amount*lo:.2f} {quote}"]
|
||
else:
|
||
lines += ["", f"💳 卡组织参考区间:{amount*lo:.2f}–{amount*hi:.2f} {quote}"]
|
||
|
||
stale = [(n, d) for n, _, d in network if parse_date(d) and (datetime.now(timezone.utc).date() - parse_date(d)).days >= 2]
|
||
if stale:
|
||
lines += ["", "⚠️ " + ";".join(f"{n} 数据距今天 {(datetime.now(REPORT_TZ).date()-parse_date(d)).days} 天" for n, d in stale) + ",不要将其视为当前实时市场价。"]
|
||
|
||
lines += ["", "Google 为当前市场参考价;Visa/万事达为官方卡组织结算汇率。实际扣款还可能受发卡行费用、入账时间等影响。"]
|
||
print("\n".join(lines))
|
||
|
||
if __name__ == "__main__":
|
||
main()
|