diff --git a/script_library/index.json b/script_library/index.json index 832b4bb..ec1c869 100644 --- a/script_library/index.json +++ b/script_library/index.json @@ -1,7 +1,7 @@ { "format_version": 1, - "data_version": 104, - "updated": "2026-04-27T05:15:17.858Z", + "data_version": 105, + "updated": "2026-04-27T09:24:29Z", "announcement": null, "categories": [ { @@ -1746,6 +1746,30 @@ "updated": null, "status": "active", "lines": 420 + }, + { + "id": "community_户子模拟器__by_dreamingbob", + "name": "户子模拟器 — by dreamingbob", + "name_en": "户子模拟器 — by dreamingbob", + "desc": "[投稿] 户子模拟器 — by dreamingbob", + "desc_en": "[投稿] 户子模拟器 — by dreamingbob", + "category": "basic", + "file": "scripts/ui/script_mogykcgu.py", + "thumbnail": null, + "version": 1, + "file_type": "py", + "author": "社区投稿", + "author_en": "社区投稿", + "tags": [ + "community", + "basic" + ], + "requires": [], + "min_app_version": "1.5.0", + "added": "2026-04-27", + "updated": null, + "status": "active", + "lines": 1097 } ] } diff --git a/script_library/scripts/ui/script_mogykcgu.py b/script_library/scripts/ui/script_mogykcgu.py new file mode 100644 index 0000000..05a61f4 --- /dev/null +++ b/script_library/scripts/ui/script_mogykcgu.py @@ -0,0 +1,1096 @@ +import http.server +import socketserver +import socket +import threading +import webbrowser +import time +import json +import urllib.parse +import urllib.request +import csv +import io +from pathlib import Path +from datetime import datetime, timezone + + +HTML_CONTENT = r""" + + + + + 全球购买力对比 + + + + + +
+
+
+
+
+
+ 数据准备中... +
+
+
+ 缓存状态检查中... +
+
+
+ + +
+
+
+ +
+
+ + 多商品 · 多国家 · 中国基准 1x +
+

+ 全球购买力对比器 +

+

输入任意人民币金额,比较它在不同国家能买到多少生活用品。

+
+ +
+
+
+ +
+ ¥ + +
+
+ + + + +
+
+
+
PPP
+
+ +
+
+

+ + 当前商品购买力前三 +

+ +
+
+
+
+ +
+
+
+

选择对比物

+

切换后卡片、排行和热力表会一起更新

+
+ +
+
+
+ +
+
+
+

一眼看懂:各国综合购买力

+

按 6 类商品平均倍数排序,数值越高表示同样人民币换汇后越经花。

+
+ +
+
+ + + + + + + + + + + + + + +
国家综合倍数大米西瓜汉堡鸡蛋牛奶餐巾纸
+
+
+ +
+
+
+

筛选国家

+

可以只看你关心的国家,方便横向比较

+
+
+ + +
+
+
+
+ +
+
+

中国购买力 (基准 1x)

+
+
+
+

+ + 数据说明 +

+
+
+
+ +
+ + +
+ + + + + + +""" + +APP_DIR = Path(__file__).resolve().parent +CACHE_FILE = APP_DIR / "purchasing_power_cache.json" +CACHE_TTL_SECONDS = 6 * 60 * 60 + +DEFAULT_RATES = { + "CNY": 1, "USD": 0.138, "AUD": 0.21, "NZD": 0.23, "KRW": 188.5, + "HKD": 1.08, "GBP": 0.11, "EUR": 0.128, "ISK": 19.2, "CAD": 0.19 +} + +DEFAULT_PRICES = { + "CNY": { "rice": 8.6, "watermelon": 4.8, "burger": 25.0, "eggs": 12.0, "milk": 11.0, "napkins": 5.0 }, + "USD": { "rice": 4.80, "watermelon": 1.70, "burger": 5.69, "eggs": 4.50, "milk": 1.05, "napkins": 2.50 }, + "AUD": { "rice": 3.60, "watermelon": 3.90, "burger": 7.70, "eggs": 5.80, "milk": 1.75, "napkins": 3.20 }, + "NZD": { "rice": 5.10, "watermelon": 4.50, "burger": 8.10, "eggs": 8.50, "milk": 2.90, "napkins": 3.80 }, + "KRW": { "rice": 5400, "watermelon": 4200, "burger": 5500, "eggs": 7200, "milk": 2800, "napkins": 2500 }, + "HKD": { "rice": 15.2, "watermelon": 18.0, "burger": 24.0, "eggs": 32.0, "milk": 24.0, "napkins": 12.0 }, + "GBP": { "rice": 1.55, "watermelon": 1.60, "burger": 4.49, "eggs": 2.80, "milk": 1.15, "napkins": 1.80 }, + "EUR_DE": { "rice": 2.20, "watermelon": 1.90, "burger": 5.29, "eggs": 3.20, "milk": 1.10, "napkins": 1.70 }, + "ISK": { "rice": 520, "watermelon": 390, "burger": 990, "eggs": 850, "milk": 240, "napkins": 450 }, + "EUR_FR": { "rice": 2.40, "watermelon": 2.20, "burger": 5.40, "eggs": 3.60, "milk": 1.25, "napkins": 1.90 }, + "CAD": { "rice": 4.20, "watermelon": 2.20, "burger": 7.05, "eggs": 5.30, "milk": 2.60, "napkins": 2.80 } +} + +COUNTRY_ISO_TO_CODE = { + "CHN": "CNY", "USA": "USD", "AUS": "AUD", "NZL": "NZD", "KOR": "KRW", + "HKG": "HKD", "GBR": "GBP", "DEU": "EUR_DE", "ISL": "ISK", + "FRA": "EUR_FR", "CAN": "CAD" +} + + +def now_iso(): + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def http_json(url, timeout=10): + req = urllib.request.Request( + url, + headers={ + "User-Agent": "Mozilla/5.0", + "Accept": "application/json,text/plain,*/*", + }, + ) + with urllib.request.urlopen(req, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8", errors="replace")) + + +def http_text(url, timeout=10): + req = urllib.request.Request( + url, + headers={ + "User-Agent": "Mozilla/5.0", + "Accept": "text/csv,text/plain,*/*", + }, + ) + with urllib.request.urlopen(req, timeout=timeout) as response: + return response.read().decode("utf-8", errors="replace") + + +def load_cache(): + if not CACHE_FILE.exists(): + return None + try: + return json.loads(CACHE_FILE.read_text(encoding="utf-8")) + except Exception: + return None + + +def save_cache(data): + try: + CACHE_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception: + pass + + +def cache_is_fresh(cache): + if not cache or "savedAtEpoch" not in cache: + return False + return (time.time() - float(cache.get("savedAtEpoch", 0))) < CACHE_TTL_SECONDS + + +def fetch_rates_primary(): + data = http_json("https://open.er-api.com/v6/latest/CNY", timeout=10) + if data.get("result") == "success": + return { + "rates": normalize_rates(data.get("rates", {})), + "source": "open.er-api.com", + "updated": data.get("time_last_update_utc") + } + raise RuntimeError("open.er-api 返回失败") + + +def fetch_rates_backup(): + data = http_json("https://api.frankfurter.app/latest?from=CNY", timeout=10) + rates = data.get("rates", {}) + if rates: + # frankfurter 不一定支持所有货币,能拿多少拿多少 + normalized = {"CNY": 1} + for k in ["USD", "AUD", "NZD", "KRW", "HKD", "GBP", "EUR", "ISK", "CAD"]: + if k in rates: + normalized[k] = rates[k] + if len(normalized) > 2: + return { + "rates": normalized, + "source": "frankfurter.app", + "updated": data.get("date") + } + raise RuntimeError("frankfurter 返回失败") + + +def normalize_rates(raw_rates): + rates = {"CNY": 1} + for key in ["USD", "AUD", "NZD", "KRW", "HKD", "GBP", "EUR", "ISK", "CAD"]: + if key in raw_rates: + try: + rates[key] = float(raw_rates[key]) + except Exception: + pass + return rates + + +def fetch_rates_stable(cache=None): + errors = [] + for fn in [fetch_rates_primary, fetch_rates_backup]: + try: + result = fn() + merged = dict(DEFAULT_RATES) + merged.update(result["rates"]) + return merged, { + "source": result.get("source"), + "updated": result.get("updated"), + "cacheUsed": False, + "errors": errors + } + except Exception as e: + errors.append(str(e)) + + if cache and cache.get("rates"): + merged = dict(DEFAULT_RATES) + merged.update(cache["rates"]) + return merged, { + "source": "本地缓存", + "updated": cache.get("generatedAt"), + "cacheUsed": True, + "errors": errors + } + + return dict(DEFAULT_RATES), { + "source": "内置默认汇率", + "updated": None, + "cacheUsed": True, + "errors": errors + } + + +def fetch_bigmac_prices(): + urls = [ + "https://raw.githubusercontent.com/TheEconomist/big-mac-data/master/source-data/big-mac-source-data.csv", + "https://raw.githubusercontent.com/TheEconomist/big-mac-data/master/output-data/big-mac-raw-index.csv", + ] + + last_error = None + for url in urls: + try: + text = http_text(url, timeout=12) + rows = list(csv.DictReader(io.StringIO(text))) + usable = [] + for row in rows: + iso = row.get("iso_a3") or row.get("iso") + local_price = row.get("local_price") + date = row.get("date") + if iso in COUNTRY_ISO_TO_CODE and local_price: + try: + usable.append((date, iso, float(local_price))) + except Exception: + pass + + if not usable: + continue + + latest_date = sorted(set(x[0] for x in usable if x[0]))[-1] + prices = {} + for date, iso, price in usable: + if date == latest_date: + code = COUNTRY_ISO_TO_CODE[iso] + prices.setdefault(code, {})["burger"] = price + return prices, latest_date, url + except Exception as e: + last_error = str(e) + + raise RuntimeError(last_error or "Big Mac 数据获取失败") + + +def deep_merge_prices(base, update): + result = json.loads(json.dumps(base, ensure_ascii=False)) + for code, item_prices in (update or {}).items(): + if code not in result: + result[code] = {} + for item, value in item_prices.items(): + try: + value = float(value) + if value > 0: + result[code][item] = value + except Exception: + pass + return result + + +def fetch_prices_stable(cache=None): + prices = json.loads(json.dumps(DEFAULT_PRICES, ensure_ascii=False)) + meta = { + "source": "默认参考价 + 可用公开数据", + "bigmacDate": None, + "bigmacSource": None, + "cacheUsed": False, + "errors": [] + } + + try: + bigmac, date, source = fetch_bigmac_prices() + prices = deep_merge_prices(prices, bigmac) + meta["bigmacDate"] = date + meta["bigmacSource"] = source + except Exception as e: + meta["errors"].append(str(e)) + if cache and cache.get("prices"): + cached_prices = {} + for code, item_prices in cache["prices"].items(): + if "burger" in item_prices: + cached_prices.setdefault(code, {})["burger"] = item_prices["burger"] + prices = deep_merge_prices(prices, cached_prices) + meta["cacheUsed"] = True + meta["source"] = "默认参考价 + 缓存 Big Mac" + + return prices, meta + + +def build_all_data(force=False): + cache = load_cache() + if (not force) and cache_is_fresh(cache): + cache["meta"]["cacheUsed"] = True + return cache + + rates, rates_meta = fetch_rates_stable(cache) + prices, prices_meta = fetch_prices_stable(cache) + + data = { + "ok": True, + "rates": rates, + "prices": prices, + "generatedAt": now_iso(), + "savedAtEpoch": time.time(), + "meta": { + "generatedAt": now_iso(), + "cacheUsed": bool(rates_meta.get("cacheUsed") or prices_meta.get("cacheUsed")), + "rates": rates_meta, + "prices": prices_meta + } + } + save_cache(data) + return data + + +def fetch_deezer_preview(): + query = 'artist:"Antoine Chambe" track:"Andalusia (Filatov & Karas Remix)"' + encoded_query = urllib.parse.quote(query) + url = f"https://api.deezer.com/search?q={encoded_query}&limit=15" + data = http_json(url, timeout=10) + results = data.get("data", []) + if not results: + return {"ok": False, "error": "Deezer 没有返回搜索结果"} + + def score(item): + title = (item.get("title") or "").lower() + title_short = (item.get("title_short") or "").lower() + artist = (item.get("artist") or {}).get("name", "").lower() + text = title + " " + title_short + s = 0 + if "andalusia" in text: s += 20 + if "filatov" in text: s += 20 + if "karas" in text: s += 20 + if "remix" in text: s += 10 + if "antoine" in artist: s += 10 + return s + + results.sort(key=score, reverse=True) + for item in results: + preview = item.get("preview") + if preview: + return { + "ok": True, + "previewUrl": preview, + "title": item.get("title") or item.get("title_short") or "Andalusia", + "artist": (item.get("artist") or {}).get("name", ""), + } + return {"ok": False, "error": "找到了歌曲,但没有可用 previewUrl"} + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path in ("/", "/index.html"): + return self.send_bytes(HTML_CONTENT.encode("utf-8"), "text/html; charset=utf-8") + + if self.path.startswith("/api/all-data"): + force = "force=1" in self.path + try: + result = build_all_data(force=force) + result["ok"] = True + except Exception as e: + result = { + "ok": True, + "rates": DEFAULT_RATES, + "prices": DEFAULT_PRICES, + "meta": { + "generatedAt": now_iso(), + "cacheUsed": True, + "rates": {"source": "内置默认汇率", "errors": [str(e)]}, + "prices": {"source": "内置默认价格", "errors": [str(e)]} + } + } + return self.send_json(result) + + if self.path.startswith("/deezer-preview"): + try: + result = fetch_deezer_preview() + except Exception as e: + result = {"ok": False, "error": str(e)} + return self.send_json(result) + + self.send_response(404) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write("404 Not Found".encode("utf-8")) + + def send_json(self, result): + body = json.dumps(result, ensure_ascii=False).encode("utf-8") + return self.send_bytes(body, "application/json; charset=utf-8", no_store=True) + + def send_bytes(self, body, content_type, no_store=False): + self.send_response(200) + self.send_header("Content-Type", content_type) + if no_store: + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + return + + +def find_free_port(start_port=8765): + port = start_port + while port < start_port + 100: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("127.0.0.1", port)) + return port + except OSError: + port += 1 + raise RuntimeError("没有找到可用端口,请关闭其他占用端口的程序后重试。") + + +def start_server(): + port = find_free_port() + url = f"http://127.0.0.1:{port}/" + + # 启动时先尝试预热缓存,失败也不影响页面打开 + try: + build_all_data(force=False) + except Exception: + pass + + with socketserver.TCPServer(("127.0.0.1", port), Handler) as httpd: + print("=" * 60) + print("全球购买力对比器 Pro Max 已启动") + print("手机浏览器打开:") + print(url) + print() + print("升级内容:") + print("1. 多源汇率 + 缓存 + 默认值兜底。") + print("2. 汉堡优先同步 The Economist Big Mac 数据。") + print("3. 更美观 UI:排行、总览表、国家筛选、卡片对比。") + print("4. 右下角继续播放购买力小曲。") + print("=" * 60) + + def open_browser(): + time.sleep(1) + try: + webbrowser.open(url) + except Exception: + pass + + threading.Thread(target=open_browser, daemon=True).start() + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\n已停止运行。") + + +if __name__ == "__main__": + start_server()