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 类商品平均倍数排序,数值越高表示同样人民币换汇后越经花。
+
+
+
+
+
+
+
+
+
+
筛选国家
+
可以只看你关心的国家,方便横向比较
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 调整当前商品价格
+
+
单位会随当前对比物变化。修改后立即用于计算,不会改动源代码。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+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()