# -*- coding: utf-8 -*- """ TRTOI 官方網站 + 內容編輯後台 (繁體中文) Taipei Renal Transcriptomics and Outcomes Investigation 架構參考 UPenn TRIDENT。內容存在 content/*.json,可由團隊成員登入後台編輯。 用法: python web.py 啟動網站 (本機 http://127.0.0.1:5000) python web.py adduser <名稱> 新增可編輯的成員帳號 (互動輸入密碼) python web.py setpass <名稱> 重設某成員密碼 python web.py deluser <名稱> 刪除成員帳號 python web.py listusers 列出成員帳號 多人協作:各成員各自登入、不同時間分別編輯(後儲存者覆蓋,適合非同時編輯情境)。 """ import os import sys import json import getpass import tempfile import functools from flask import (Flask, render_template, abort, request, redirect, url_for, session, flash, g, Response) from werkzeug.security import generate_password_hash, check_password_hash BASE = os.path.dirname(os.path.abspath(__file__)) CONTENT_DIR = os.path.join(BASE, "content") AUTH_FILE = os.path.join(BASE, "auth.json") HERO_DIR = os.path.join(BASE, "static", "uploads", "hero") # 頂部輪播圖片 ALLOWED_IMG_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp"} TEAM_FILE = os.path.join(CONTENT_DIR, "team_members.json") # 研究團隊成員 SITE_FILE = os.path.join(CONTENT_DIR, "site.json") # 站台層級可編輯文字(側欄註記等) TEAM_PHOTO_DIR = os.path.join(BASE, "static", "uploads", "team") ANALYTICS_DIR = os.path.join(BASE, "analytics") # 流量統計(不上傳,不進版控) ANALYTICS_FILE = os.path.join(ANALYTICS_DIR, "visits.json") BACKUP_DIR = os.path.join(BASE, "backup") # 內容存檔的時間戳版本備份 # 分頁順序(左側選單順序)。新增分頁:在此加 slug,並於 content/ 放對應 .json ORDER = ["index", "ckd", "about", "different", "aims", "goals", "team", "members", "publications", "trtoi2", "links", "contact", "analytics"] SITE = { "name_zh": "TRTOI 台北腎臟世代研究", "name_full": "Taipei Renal Transcriptomics and Outcomes Investigation", "abbr": "TRTOI", "tagline": "以轉錄體學為核心的腎臟病多體學整合研究平台", } # ── 多語系:English 為預設,可切換繁中(cookie `lang`)─────────────────────── LANGS = ("en", "zh") DEFAULT_LANG = "en" # 介面 chrome 字串(非內容 JSON、寫死於模板),依語言切換。內容文字放 content/*.json。 UI_STRINGS = { "en": { "html_lang": "en", "site_name": "TRTOI · Taipei Renal Cohort", "tagline": "A transcriptomics-driven multi-omics research platform for kidney disease", "brand": "Division of Nephrology, Taipei Veterans General Hospital", "login": "Member login", "logout": "Log out (%s)", "footer_tagline": "Integrating kidney-biopsy transcriptomics, proteomics, digital pathology and clinical follow-up to advance precision nephrology in Taiwan.", "quick_links": "Quick Links", "ql_about": "What is TRTOI", "ql_aims": "Research Aims", "ql_pubs": "Publications", "ql_contact": "Contact Us", "partners": "International Kidney Biopsy Studies", "copyright": "© 2018–2026 TRTOI Consortium. For academic research and health education only; not medical advice.", "hero_reorder": "Drag to reorder", "hero_delete": "Delete", "hero_upload": "+ Upload image", "edit_page": "Edit this page", "edit_hint": "Edit mode: click any text to edit", "save": "Save", "cancel": "Cancel", "lang_en": "EN", "lang_zh": "中文", "an_today": "Today's clicks", "an_total": "Total clicks", "an_unique": "Unique visitors", "an_countries": "Countries / regions", "an_trend": "Click trend", "an_day": "Day", "an_week": "Week", "an_month": "Month", "an_map": "Global visitor distribution", "an_ranking": "Top countries / regions", "an_times": "hits", "an_sep": ": ", "an_none_geo": "No locatable visitors yet (a test IP needs internet access to show your location).", "an_nodata": "No traffic data yet.", "cf_name": "Name", "cf_name_ph": "Your name", "cf_subject": "Subject", "cf_subject_ph": "Collaboration / data request / other", "cf_message": "Message", "cf_message_ph": "Please enter your message…", "cf_submit": "Send", "cf_thanks": "Thank you for your message. We will reply soon!", "cf_send_fail": "Sending failed. Please email us directly.", "lg_backend": "TRTOI Content Editor", "lg_intro": "Log in with your research-member account to edit the site.", "lg_user": "Username", "lg_pass": "Password", "lg_submit": "Log in", "lg_back": "← Back to homepage", "lg_error": "Incorrect username or password.", "lg_locked": "Too many failed attempts. Please try again in about %d min.", }, "zh": { "html_lang": "zh-Hant", "site_name": "TRTOI 台北腎臟世代研究", "tagline": "以轉錄體學為核心的腎臟病多體學整合研究平台", "brand": "臺北榮民總醫院 腎臟科", "login": "成員登入", "logout": "登出(%s)", "footer_tagline": "整合腎臟切片轉錄體學、蛋白體學、數位病理與臨床追蹤,推動台灣腎臟病精準醫療。", "quick_links": "快速連結", "ql_about": "什麼是 TRTOI", "ql_aims": "研究目標", "ql_pubs": "研究發表", "ql_contact": "聯絡我們", "partners": "國際腎臟切片研究", "copyright": "© 2018–2026 TRTOI Consortium. 本網站僅供學術研究與衛教用途,不構成醫療建議。", "hero_reorder": "拖曳可調整順序", "hero_delete": "刪除", "hero_upload": "+ 上傳圖片", "edit_page": "編輯此頁", "edit_hint": "編輯模式:直接點文字修改", "save": "儲存", "cancel": "取消", "lang_en": "EN", "lang_zh": "中文", "an_today": "今日點擊", "an_total": "累積點擊", "an_unique": "不重複訪客", "an_countries": "涵蓋國家/地區", "an_trend": "點擊趨勢", "an_day": "日", "an_week": "週", "an_month": "月", "an_map": "全球訪客分布", "an_ranking": "訪客國家/地區排行", "an_times": "次", "an_sep": ":", "an_none_geo": "目前尚無可定位的訪客(本機測試 IP 需連得上網路才會顯示你目前的所在地)。", "an_nodata": "尚無流量資料。", "cf_name": "姓名", "cf_name_ph": "您的稱呼", "cf_subject": "主旨", "cf_subject_ph": "合作/資料申請/其他", "cf_message": "內容", "cf_message_ph": "請輸入您的訊息…", "cf_submit": "送出", "cf_thanks": "感謝您的來信,我們會盡快回覆!", "cf_send_fail": "送出失敗,請直接來信與我們聯繫。", "lg_backend": "TRTOI 編輯後台", "lg_intro": "請以研究成員帳號登入以編輯網站內容。", "lg_user": "帳號", "lg_pass": "密碼", "lg_submit": "登入", "lg_back": "← 返回網站首頁", "lg_error": "帳號或密碼錯誤", "lg_locked": "登入失敗次數過多,請約 %d 分鐘後再試。", }, } # cohort 圖表的固定中文標籤 → 英文(病理名 PATHO_MAP 本來就是英文,不需轉)。 CHART_LABELS_EN = { "糖尿病": "Diabetes", "性別": "Sex", "CKD 分期": "CKD stage", "前六大病理診斷": "Top 6 pathological diagnoses", "有": "Yes", "無": "No", "男": "Male", "女": "Female", } def current_lang(): """目前語言:優先 ?lang= 查詢參數(讓中文頁有可被爬蟲索引的獨立網址), 其次 cookie `lang`,預設 en。請求外(CLI/import)回 DEFAULT_LANG。""" try: code = request.args.get("lang") or request.cookies.get("lang") except Exception: code = None return code if code in LANGS else DEFAULT_LANG # 即時收案人數:讀 TRTOI Dashboard 的本機 SQLite DB(patient 表列數),與儀表板顯示的 # 「目前共 N 位切片病患」同一來源。只做 COUNT(*) 聚合,不讀取任何個資欄位。 # 讀不到時前台自動改用 JSON 內建數字。 import configparser import sqlite3 from pathlib import Path DASHBOARD_DIR = os.environ.get( "TRTOI_DASHBOARD_DIR", r"M:\Also Dropbox\Shuo-Ming Ou\歐朔銘\11 Journal Study\000ClaudeAI\05_TRTOIDashBoard", ) def _local_db_path(): """依 Dashboard 的 myconfig.ini [LocalDB] db_path 解析本機 DB 路徑(與儀表板一致)。""" direct = os.environ.get("TRTOI_COHORT_DB") if direct: return direct rel = "../04_PythonAnywhere_Local/example.db" try: cfg = configparser.ConfigParser() cfg.read(os.path.join(DASHBOARD_DIR, "myconfig.ini"), encoding="utf-8") rel = cfg.get("LocalDB", "db_path", fallback=rel) except Exception: pass return os.path.normpath(os.path.join(DASHBOARD_DIR, rel)) # 首頁即時圓餅圖:性別、糖尿病、CKD 分期。只做聚合計數,不取任何個資值。 _charts_cache = {"mtime": None, "data": None} # 去識別化統計快照(HF 上沒有 DB,改讀此檔;由 build_cloud_stats.py 於本機產生) STATS_FILE = os.path.join(BASE, "data_cloud", "cohort_stats.json") _stats_cache = {"mtime": None, "data": None} def _cloud_stats(): try: m = os.path.getmtime(STATS_FILE) if _stats_cache["mtime"] != m: with open(STATS_FILE, "r", encoding="utf-8") as f: _stats_cache["data"] = json.load(f) _stats_cache["mtime"] = m return _stats_cache["data"] except Exception: return None def _egfr_ckdepi_2021(scr, age, female): """CKD-EPI 2021(race-free)eGFR;缺值回 None。""" if not scr or not age or scr <= 0: return None k = 0.7 if female else 0.9 a = -0.241 if female else -0.302 r = scr / k e = 142 * (min(r, 1) ** a) * (max(r, 1) ** -1.200) * (0.9938 ** age) return e * 1.012 if female else e def _ckd_stage(e): if e is None: return None if e >= 90: return "G1" if e >= 60: return "G2" if e >= 45: return "G3a" if e >= 30: return "G3b" if e >= 15: return "G4" return "G5" # Patho_Diagnosis 代碼 → 名稱(取自 Dashboard 的 patho_map,取分號前第一碼) PATHO_MAP = { "1": "Minimal change disease", "2": "Membranous nephropathy", "3": "FSGS", "4": "Diabetic nephropathy", "5": "Nephrosclerosis", "6": "IgA nephropathy", "7": "TMA", "8": "Acute tubulointerstitial nephritis", "9": "Secondary FSGS", "10": "Mesangial proliferative GN", "11": "Minor glomerular abnormalities", "12": "C3-dominant GN", "13": "Fibrillary GN", "14": "Metastatic neuroendocrine carcinoma", "15": "Oxalate nephropathy", "16": "Dense deposit disease", "17": "Lupus nephritis", "18": "Light chain cast nephropathy", "19": "Membranoproliferative GN", "20": "Pauci-immune crescentic GN", "21": "Nodular glomerulopathy (suspicious HCDD)", "22": "Amyloidosis", "23": "Focal mesangiolysis", "24": "Fabry disease", "25": "Acute tubular injury (kappa)", "26": "Immune complex-mediated GN", "27": "Inadequate specimen", "28": "Malignant HTN", "29": "No biopsy", "30": "Chronic tubulointerstitial nephritis", "31": "Xanthogranulomatous inflammation", "32": "Acute tubular necrosis", "33": "Thin GBM (potential Alport)", "34": "IgA-dominant MPGN", "35": "Ischemic change", "36": "Mild mesangial proliferation", "37": "Idiopathic nodular glomerulosclerosis", "38": "Cryoglobulinemic GN", "39": "Chronic pyelonephritis", "40": "Anti-GBM disease", "41": "Monoclonal Ig deposition disease", "42": "IgM nephropathy", "43": "Type III collagen glomerulopathy", } def cohort_charts(): """回傳性別 / 糖尿病 / CKD 分期(圓餅)+ 前六大病理診斷(橫條)的即時分佈;失敗回 None。""" import re from collections import Counter try: db = _local_db_path() m = os.path.getmtime(db) if _charts_cache["mtime"] != m: uri = Path(db).as_uri() + "?mode=ro" con = sqlite3.connect(uri, uri=True) try: rows = con.execute( "SELECT Gender, Diabetes, CREA, Age, Patho_Diagnosis FROM patient" ).fetchall() finally: con.close() def dig(v): return re.sub(r"\D", "", str(v or "")) def num(v): mm = re.search(r"[-+]?\d*\.?\d+", str(v)) if v is not None else None return float(mm.group()) if mm else None gender, dm, ckd, patho = Counter(), Counter(), Counter(), Counter() for g_raw, d_raw, crea, age, pat in rows: g = dig(g_raw) gender[{"1": "男", "0": "女"}.get(g)] += 1 dm[{"1": "有", "0": "無"}.get(dig(d_raw))] += 1 s = _ckd_stage(_egfr_ckdepi_2021(num(crea), num(age), g == "0")) if s: ckd[s] += 1 ps = str(pat or "").strip() if ps: first = re.sub(r"\.0$", "", ps.split(";")[0].strip()) name = PATHO_MAP.get(first) if name: patho[name] += 1 def build(title, counter, order): labels, data = [], [] for k in order: if counter.get(k): labels.append(k) data.append(counter[k]) return {"title": title, "labels": labels, "data": data} top = patho.most_common(6) _charts_cache["data"] = { "pies": [ build("糖尿病", dm, ["有", "無"]), build("性別", gender, ["男", "女"]), build("CKD 分期", ckd, ["G1", "G2", "G3a", "G3b", "G4", "G5"]), ], "bar": { "title": "前六大病理診斷", "labels": [k for k, _ in top], "data": [v for _, v in top], "total": sum(patho.values()), }, } _charts_cache["mtime"] = m return _charts_cache["data"] except Exception: pass s = _cloud_stats() # HF:無 DB 時讀去識別化快照 return s.get("charts") if s else None def cohort_charts_i18n(lang="zh"): """依語言回傳圖表:en 時把固定中文標籤換成英文(病理名 PATHO_MAP 本就是英文)。""" data = cohort_charts() if not data or lang != "en": return data import copy d = copy.deepcopy(data) tr = lambda x: CHART_LABELS_EN.get(x, x) for pie in d.get("pies", []): pie["title"] = tr(pie.get("title")) pie["labels"] = [tr(x) for x in pie.get("labels", [])] bar = d.get("bar") if bar: bar["title"] = tr(bar.get("title")) bar["labels"] = [tr(x) for x in bar.get("labels", [])] return d _cohort_cache = {"mtime": None, "count": None} def cohort_count(): """回傳切片世代目前人數(patient 表列數),依 DB 檔修改時間快取;失敗回 None。""" try: db = _local_db_path() m = os.path.getmtime(db) if _cohort_cache["mtime"] != m: uri = Path(db).as_uri() + "?mode=ro" # 唯讀開啟,避免鎖檔或寫入 con = sqlite3.connect(uri, uri=True) try: _cohort_cache["count"] = con.execute( "SELECT COUNT(*) FROM patient").fetchone()[0] finally: con.close() _cohort_cache["mtime"] = m return _cohort_cache["count"] except Exception: pass s = _cloud_stats() # HF:無 DB 時讀去識別化快照 return s.get("count") if s else None app = Flask(__name__) app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 # 上傳圖片上限 16MB HERO_ORDER_FILE = os.path.join(HERO_DIR, "order.json") def _hero_files(): try: return [f for f in os.listdir(HERO_DIR) if os.path.splitext(f)[1].lower() in ALLOWED_IMG_EXT] except FileNotFoundError: return [] def _hero_read_order(): try: with open(HERO_ORDER_FILE, "r", encoding="utf-8") as fh: return json.load(fh) except Exception: return [] def _hero_write_order(order): os.makedirs(HERO_DIR, exist_ok=True) with open(HERO_ORDER_FILE, "w", encoding="utf-8") as fh: json.dump(order, fh, ensure_ascii=False) _persist_mark("static/uploads/hero/order.json", HERO_ORDER_FILE) def hero_images(): """頂部輪播圖片檔名清單,依儲存的排序;新上傳者接在後面。""" files = _hero_files() fileset = set(files) order = [f for f in _hero_read_order() if f in fileset] extra = sorted((f for f in files if f not in set(order)), key=lambda f: os.path.getmtime(os.path.join(HERO_DIR, f))) return order + extra def team_members(lang="zh"): """研究團隊成員清單:[{id, photo, bio}, ...](en 缺檔回退中文)。""" path = _lang_path(TEAM_FILE, lang) if not os.path.exists(path): path = TEAM_FILE try: with open(path, "r", encoding="utf-8") as fh: data = json.load(fh) return data if isinstance(data, list) else [] except Exception: return [] def save_team(members, lang="zh"): os.makedirs(CONTENT_DIR, exist_ok=True) target = _lang_path(TEAM_FILE, lang) fd, tmp = tempfile.mkstemp(dir=CONTENT_DIR, suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(members, fh, ensure_ascii=False, indent=2) _replace_retry(tmp, target) finally: if os.path.exists(tmp): os.remove(tmp) _backup(target) _persist_mark("content/" + os.path.basename(target), target) # 站台層級的可編輯文字(不屬於任何單一分頁,每頁共用,如側欄註記)。 SITE_TEXT_DEFAULTS = { "side_note": "TRTOI 為台灣最大腎臟 RNA 資料庫,整合全基因定序、腎臟切片轉錄體學、" "蛋白體學、數位病理與長期臨床追蹤。", } def load_site(lang="zh"): """站台層級文字:先讀中文再以該語言覆蓋(en 缺欄位 → 回退中文/預設)。""" data = dict(SITE_TEXT_DEFAULTS) paths = [SITE_FILE] if lang == "zh" else [SITE_FILE, _lang_path(SITE_FILE, lang)] for p in paths: try: with open(p, "r", encoding="utf-8") as fh: saved = json.load(fh) if isinstance(saved, dict): data.update(saved) except Exception: pass return data def save_site(data, lang="zh"): os.makedirs(CONTENT_DIR, exist_ok=True) target = _lang_path(SITE_FILE, lang) fd, tmp = tempfile.mkstemp(dir=CONTENT_DIR, suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, indent=2) _replace_retry(tmp, target) finally: if os.path.exists(tmp): os.remove(tmp) _backup(target) _persist_mark("content/" + os.path.basename(target), target) # --------------------------------------------------------------------------- # 流量統計:每日點擊 + 全球訪客分布(不含個資;只留 IP→國家的聚合) # --------------------------------------------------------------------------- import time as _time _analytics = None _analytics_last_write = 0.0 _analytics_persist_last = 0.0 def _today_str(): import datetime return datetime.date.today().isoformat() def _load_analytics(): global _analytics if _analytics is None: if _persist_enabled(): # 雲端:先讀 dataset repo 的快照(比容器內殘留的 repo 副本新) try: from huggingface_hub import hf_hub_download p = hf_hub_download(repo_id=HF_ANALYTICS_REPO, repo_type="dataset", filename="visits.json", token=HF_WRITE_TOKEN) with open(p, "r", encoding="utf-8") as f: _analytics = json.load(f) except Exception: _analytics = None if _analytics is None: try: with open(ANALYTICS_FILE, "r", encoding="utf-8") as f: _analytics = json.load(f) except Exception: _analytics = {} for k, v in (("daily", {}), ("ip_hits", {}), ("ip_geo", {}), ("total", 0)): _analytics.setdefault(k, v) return _analytics def _save_analytics(force=False): global _analytics_last_write if _analytics is None: return now = _time.time() if not force and now - _analytics_last_write < 5: # 節流:最多每 5 秒寫一次 return try: os.makedirs(ANALYTICS_DIR, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=ANALYTICS_DIR, suffix=".tmp") with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(_analytics, f, ensure_ascii=False) _replace_retry(tmp, ANALYTICS_FILE) _analytics_last_write = now except Exception: pass def _maybe_persist_analytics(force=False): """把流量檔節流上傳到 dataset repo(HF_ANALYTICS_REPO)。 ⚠️ 不可存回 Space repo:Space repo 的每個 commit 都會觸發 rebuild,而 rebuild 重啟後節流計時器歸零 + keep-alive ping 馬上又 persist → 無限 rebuild 迴圈 (2026-08-21 的 BUILD_ERROR 即此因)。dataset 的 commit 不會觸發 rebuild。""" global _analytics_persist_last if not _persist_enabled(): return now = _time.time() if not force and now - _analytics_persist_last < 600: return _analytics_persist_last = now _save_analytics(force=True) threading.Thread(target=_upload_analytics_dataset, daemon=True).start() def _upload_analytics_dataset(): try: from huggingface_hub import HfApi HfApi(token=HF_WRITE_TOKEN).upload_file( path_or_fileobj=ANALYTICS_FILE, path_in_repo="visits.json", repo_id=HF_ANALYTICS_REPO, repo_type="dataset", commit_message="persist analytics") except Exception: pass def _client_ip(): xff = request.headers.get("X-Forwarded-For", "") return xff.split(",")[0].strip() if xff else (request.remote_addr or "unknown") def _is_private_ip(ip): import ipaddress try: a = ipaddress.ip_address(ip) return a.is_private or a.is_loopback except Exception: return True def _geo_lookup(ip): """IP → 國家 / 座標(ip-api.com,免費無金鑰);私有/本機 IP 改查伺服器自身公網位置。""" import urllib.request q = "" if _is_private_ip(ip) else ip url = "http://ip-api.com/json/%s?fields=status,countryCode,country,lat,lon" % q try: d = json.loads(urllib.request.urlopen(url, timeout=3).read().decode("utf-8")) if d.get("status") == "success": return {"cc": d.get("countryCode"), "country": d.get("country"), "lat": d.get("lat"), "lng": d.get("lon")} except Exception: pass return None @app.before_request def _force_canonical(): """直接開 *.hf.space(未經 Cloudflare Worker、沒帶祕鑰)→ 301 轉到正式網域, 讓所有人統一走 trtoi.org。未設 PROXY_KEY(本機開發)則不強制。""" try: if not PROXY_KEY: return host = (request.host or "").split(":")[0].lower() if host.endswith(".hf.space") and request.headers.get("X-Proxy-Key", "") != PROXY_KEY: path = request.full_path if path.endswith("?"): path = path[:-1] return redirect("https://%s%s" % (CANONICAL_HOST, path), code=301) except Exception: pass @app.before_request def _set_lang(): """把目前語言掛到 g.lang,供路由與模板使用。""" g.lang = current_lang() @app.route("/lang/") def set_lang(code): """切換語言:設 cookie 後回到原頁。""" if code not in LANGS: code = DEFAULT_LANG resp = redirect(request.referrer or url_for("index")) resp.set_cookie("lang", code, max_age=60 * 60 * 24 * 365, samesite="Lax") return resp @app.before_request def _track_visit(): try: p = request.path if request.method != "GET": return if p.startswith("/static") or p.startswith("/admin") or p.startswith("/favicon"): return a = _load_analytics() today = _today_str() a["daily"][today] = a["daily"].get(today, 0) + 1 a["total"] += 1 ip = _client_ip() a["ip_hits"][ip] = a["ip_hits"].get(ip, 0) + 1 _save_analytics() _maybe_persist_analytics() except Exception: pass def analytics_report(): """流量摘要(供流量統計頁)。地理定位延後到此,且每次上限 40 個新 IP(尊重 API 限速)。""" a = _load_analytics() missing = [ip for ip in a["ip_hits"] if ip not in a["ip_geo"]][:40] for ip in missing: g = _geo_lookup(ip) if g and g.get("cc"): a["ip_geo"][ip] = g if missing: _save_analytics(force=True) _maybe_persist_analytics(force=True) countries = {} for ip, hits in a["ip_hits"].items(): g = a["ip_geo"].get(ip) if g and g.get("cc"): c = countries.setdefault(g["cc"], {"cc": g["cc"], "country": g["country"], "hits": 0, "lat": g["lat"], "lng": g["lng"]}) c["hits"] += hits import datetime day_items = sorted(a["daily"].items()) # 日:最近 30 天(label = MM-DD) day_series = [{"label": d[5:], "hits": h} for d, h in day_items[-30:]] # 週:以每週一為 key 彙總,最近 12 週(label = 該週日期區間 週一MM-DD~週日MM-DD) weekly = {} monthly = {} for d, h in a["daily"].items(): try: dt = datetime.date.fromisoformat(d) except Exception: continue monday = (dt - datetime.timedelta(days=dt.weekday())).isoformat() weekly[monday] = weekly.get(monday, 0) + h monthly[d[:7]] = monthly.get(d[:7], 0) + h def _wk_label(monday_iso): m = datetime.date.fromisoformat(monday_iso) sun = m + datetime.timedelta(days=6) return f"{monday_iso[5:]}~{sun.isoformat()[5:]}" # 週一MM-DD~週日MM-DD week_series = [{"label": _wk_label(k), "hits": v} for k, v in sorted(weekly.items())[-12:]] month_series = [{"label": k, "hits": v} for k, v in sorted(monthly.items())[-12:]] return { "today": a["daily"].get(_today_str(), 0), "total": a["total"], "unique": len(a["ip_hits"]), "series": {"day": day_series, "week": week_series, "month": month_series}, "countries": sorted(countries.values(), key=lambda x: -x["hits"]), } # --------------------------------------------------------------------------- # HF 持久化:把站上編輯過的檔案「合併」commit 回 Space repo(重建後仍保留) # 連續編輯(每個欄位 blur)會累積成一批、停手約 20 秒後一次提交 → 少量重建。 # --------------------------------------------------------------------------- import threading _persist_dirty = {} # repo_path -> local_path(None = 刪除) _persist_lock = threading.Lock() _persist_last = 0.0 _persist_started = False def _persist_enabled(): return bool(HF_PERSIST and HF_SPACE_REPO and HF_WRITE_TOKEN) def _persist_mark(repo_path, local_path): """標記一個檔案要同步回 repo(local_path=None 表示刪除)。""" if not _persist_enabled(): return global _persist_last, _persist_started with _persist_lock: _persist_dirty[repo_path] = local_path _persist_last = _time.time() if not _persist_started: _persist_started = True threading.Thread(target=_persist_worker, daemon=True).start() def _persist_worker(): from huggingface_hub import HfApi, CommitOperationAdd, CommitOperationDelete api = HfApi(token=HF_WRITE_TOKEN) while True: _time.sleep(5) with _persist_lock: if not _persist_dirty or (_time.time() - _persist_last) < 20: continue batch = dict(_persist_dirty) _persist_dirty.clear() ops = [] for repo_path, local_path in batch.items(): try: if local_path is None: ops.append(CommitOperationDelete(path_in_repo=repo_path)) elif os.path.exists(local_path): ops.append(CommitOperationAdd(path_in_repo=repo_path, path_or_fileobj=local_path)) except Exception: pass if not ops: continue try: api.create_commit(repo_id=HF_SPACE_REPO, repo_type="space", operations=ops, commit_message="edit via site (%d files)" % len(ops)) except Exception: with _persist_lock: # 失敗放回,下次再試 for k, v in batch.items(): _persist_dirty.setdefault(k, v) # --------------------------------------------------------------------------- # 內容檔存取 # --------------------------------------------------------------------------- def _lang_path(path, lang): """en → 在副檔名前插入 .en(xxx.json → xxx.en.json);zh → 原路徑。""" if lang == "en": root, ext = os.path.splitext(path) return root + ".en" + ext return path def content_path(slug, lang="zh"): return _lang_path(os.path.join(CONTENT_DIR, slug + ".json"), lang) def load_page(slug, lang="zh"): """讀取某分頁內容 JSON(en 缺檔 → 回退中文,未翻譯頁仍可顯示)。不存在回 None。""" if slug not in ORDER: return None path = content_path(slug, lang) if not os.path.exists(path): path = content_path(slug, "zh") # 回退中文 if not os.path.exists(path): return None with open(path, "r", encoding="utf-8") as f: return json.load(f) def _backup(local_path): """把剛存好的內容檔複製一份到 backup/ 做時間戳版本備份(每檔保留最近 20 份)。 本機與雲端都會做;雲端另有 repo commit 歷史作為版本備份。""" try: if not os.path.exists(local_path): return import datetime import glob import shutil os.makedirs(BACKUP_DIR, exist_ok=True) base = os.path.basename(local_path) stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") shutil.copy2(local_path, os.path.join(BACKUP_DIR, "%s.%s.bak" % (base, stamp))) olds = sorted(glob.glob(os.path.join(BACKUP_DIR, base + ".*.bak"))) for old in olds[:-20]: try: os.remove(old) except Exception: pass except Exception: pass def _replace_retry(src, dst, tries=6): """os.replace 加重試:Dropbox/防毒同步時可能暫時鎖檔(WinError 5)。""" import time for _ in range(tries - 1): try: os.replace(src, dst) return except PermissionError: time.sleep(0.15) os.replace(src, dst) # 最後一次,若仍失敗則讓例外拋出 def save_page(slug, data, lang="zh"): """原子性寫回內容 JSON(en 寫入 .en.json)。""" os.makedirs(CONTENT_DIR, exist_ok=True) target = content_path(slug, lang) fd, tmp = tempfile.mkstemp(dir=CONTENT_DIR, suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) _replace_retry(tmp, target) finally: if os.path.exists(tmp): os.remove(tmp) _backup(target) _persist_mark("content/" + os.path.basename(target), target) def build_nav(lang="zh"): """依 ORDER 建立左側選單;標題取自各分頁 JSON 的 nav 欄位(依語言)。""" nav = [] for slug in ORDER: data = load_page(slug, lang) if not data: continue label = data.get("nav") or data.get("title") or slug url = "/" if slug == "index" else "/" + slug nav.append({"slug": slug, "url": url, "label": label}) return nav # --------------------------------------------------------------------------- # 帳號 / 認證 # --------------------------------------------------------------------------- def load_auth(): """讀取(或初次建立)認證檔。首次會建立預設帳號 admin。""" if not os.path.exists(AUTH_FILE): data = { "secret": os.urandom(24).hex(), "users": {"admin": generate_password_hash("trtoi2026")}, } with open(AUTH_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) print("※ 已建立預設管理員帳號 admin(預設密碼見 README,請盡快用 " "`python web.py setpass admin` 更改)。") return data with open(AUTH_FILE, "r", encoding="utf-8") as f: return json.load(f) def save_auth(data): with open(AUTH_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) AUTH = load_auth() # HF 唯讀模式:公開展示、隱藏登入與編輯(本方案已改用「存回 repo」,故預設關閉) HF_MODE = os.environ.get("HF_MODE") == "1" # 正式站的 session 金鑰走環境變數(HF Space Secret);本機沿用 auth.json app.secret_key = os.environ.get("FLASK_SECRET_KEY") or AUTH["secret"] # ── HF「存回 repo」持久化(免費):站上編輯後把改動 commit 回 Space repo,重建後仍在 ── HF_PERSIST = os.environ.get("HF_PERSIST") == "1" HF_SPACE_REPO = os.environ.get("SPACE_ID", "") # HF 自動提供 "owner/space" HF_WRITE_TOKEN = os.environ.get("HF_WRITE_TOKEN", "") # fine-grained:只可寫此 repo # 流量檔存到 dataset repo(commit 不觸發 Space rebuild);預設 /-analytics HF_ANALYTICS_REPO = os.environ.get("HF_ANALYTICS_REPO") or \ (HF_SPACE_REPO + "-analytics" if HF_SPACE_REPO else "") # ── 統一入口:只放行經 Cloudflare Worker(帶祕鑰)進來的請求;直接開 hf.space 一律轉正式網域 ── PROXY_KEY = os.environ.get("PROXY_KEY", "") # Worker 與此處共用的祕鑰(Space Secret) CANONICAL_HOST = os.environ.get("CANONICAL_HOST", "trtoi.org") # 正式對外網域 # 正式站的管理員帳密走 Space Secret(不放進公開 repo) _env_admin_user = os.environ.get("ADMIN_USER") _env_admin_pw = os.environ.get("ADMIN_PASSWORD") if _env_admin_user and _env_admin_pw: AUTH.setdefault("users", {})[_env_admin_user] = generate_password_hash(_env_admin_pw) def login_required(view): @functools.wraps(view) def wrapped(*args, **kwargs): if not session.get("user"): return redirect(url_for("admin_login", next=request.path)) return view(*args, **kwargs) return wrapped # --------------------------------------------------------------------------- # 模板共用變數 # --------------------------------------------------------------------------- def asset_ver(filename): """回傳靜態檔的修改時間當版本號,讓瀏覽器在檔案更新後自動抓新版(破快取)。""" try: return str(int(os.path.getmtime(os.path.join(app.static_folder, filename)))) except OSError: return "1" @app.context_processor def inject_globals(): lang = getattr(g, "lang", DEFAULT_LANG) return {"NAV": build_nav(lang), "SITE": SITE, "site_text": load_site(lang), "T": UI_STRINGS.get(lang, UI_STRINGS[DEFAULT_LANG]), "LANG": lang, "user": session.get("user"), "ver": asset_ver, "cohort": cohort_count(), "charts": cohort_charts_i18n(lang), "hero_images": hero_images(), "team_members": team_members(lang), "readonly": HF_MODE} # --------------------------------------------------------------------------- # 前台 # --------------------------------------------------------------------------- @app.route("/") def index(): page = load_page("index", g.lang) if not page: abort(404) return render_template("page.html", page=page, active="index") # --------------------------------------------------------------------------- # SEO:robots.txt / sitemap.xml(只含公開內容頁;排除後台與流量統計) # --------------------------------------------------------------------------- SEO_HOST = "https://%s" % CANONICAL_HOST # 正式對外網域(trtoi.org) SEO_SLUGS = [s for s in ORDER if s != "analytics"] # 排除 /analytics(流量) @app.route("/robots.txt") def robots_txt(): body = ("User-agent: *\n" "Allow: /\n" "Disallow: /admin\n" "Sitemap: %s/sitemap.xml\n" % SEO_HOST) return Response(body, mimetype="text/plain") @app.route("/sitemap.xml") def sitemap_xml(): parts = ['', ''] for slug in SEO_SLUGS: path = "/" if slug == "index" else "/" + slug # 英文優先:只列乾淨英文網址,不宣告中文替代版(中文仍可用右上角切換) parts.append("%s" % (SEO_HOST + path)) parts.append("") return Response("\n".join(parts), mimetype="application/xml") @app.route("/") def page(slug): if slug not in ORDER or slug == "index": abort(404) data = load_page(slug, g.lang) if not data: abort(404) extra = {"analytics": analytics_report()} if slug == "analytics" else {} return render_template("page.html", page=data, active=slug, **extra) # --------------------------------------------------------------------------- # 後台 # --------------------------------------------------------------------------- # ── 登入限速/防暴力破解:同一 IP 於視窗內連續失敗達上限 → 暫時鎖定 ── LOGIN_MAX_FAILS = 5 # 視窗內最多失敗次數 LOGIN_WINDOW = 600 # 計算失敗的滑動視窗(秒) LOGIN_LOCKOUT = 900 # 觸發後鎖定秒數(15 分鐘) _login_lock = threading.Lock() _login_fails = {} # ip -> {"ts": [失敗時間...], "until": 鎖定到期時間} def _login_locked(ip): """回傳剩餘鎖定秒數(>0 表示鎖定中)。""" now = _time.time() with _login_lock: rec = _login_fails.get(ip) if rec and rec.get("until", 0) > now: return int(rec["until"] - now) return 0 def _login_fail(ip): now = _time.time() with _login_lock: rec = _login_fails.setdefault(ip, {"ts": [], "until": 0}) rec["ts"] = [t for t in rec["ts"] if now - t < LOGIN_WINDOW] rec["ts"].append(now) if len(rec["ts"]) >= LOGIN_MAX_FAILS: rec["until"] = now + LOGIN_LOCKOUT rec["ts"] = [] if len(_login_fails) > 5000: # 防字典無限增長:清掉已過期者 for k in [k for k, v in _login_fails.items() if v.get("until", 0) < now and not v.get("ts")]: _login_fails.pop(k, None) def _login_ok(ip): with _login_lock: _login_fails.pop(ip, None) @app.route("/admin/login", methods=["GET", "POST"]) def admin_login(): if HF_MODE: # 公開站唯讀,沒有登入/編輯 abort(404) T = UI_STRINGS.get(g.lang, UI_STRINGS[DEFAULT_LANG]) if request.method == "POST": ip = _client_ip() locked = _login_locked(ip) if locked: # 鎖定中:不驗密碼,直接擋 flash(T["lg_locked"] % max(1, locked // 60)) return render_template("admin/login.html", active=None) username = (request.form.get("username") or "").strip() password = request.form.get("password") or "" pw_hash = AUTH.get("users", {}).get(username) if pw_hash and check_password_hash(pw_hash, password): _login_ok(ip) session["user"] = username nxt = request.args.get("next") or url_for("index") return redirect(nxt) _login_fail(ip) flash(T["lg_error"]) return render_template("admin/login.html", active=None) @app.route("/admin/logout") def admin_logout(): session.pop("user", None) return redirect(url_for("index")) # --------------------------------------------------------------------------- # 聯絡表單寄信:前台表單 → 寄到設定的收件信箱(SMTP)。 # 設定來源:環境變數(HF Space Secret)優先,本機退回 hf_deploy.ini [MAIL]。 # CONTACT_TO / SMTP_USER / SMTP_PASS / SMTP_HOST(預設 smtp.gmail.com) / SMTP_PORT(587) # --------------------------------------------------------------------------- def _mail_cfg(key, default=None): v = os.environ.get(key) if v: return v try: cfg = configparser.ConfigParser() cfg.read(os.path.join(BASE, "hf_deploy.ini"), encoding="utf-8") return cfg.get("MAIL", key.lower(), fallback=default) except Exception: return default def _send_contact_mail(name, email, subject, message): to_addr = _mail_cfg("CONTACT_TO") user = _mail_cfg("SMTP_USER") pw = _mail_cfg("SMTP_PASS") host = _mail_cfg("SMTP_HOST", "smtp.gmail.com") port = int(_mail_cfg("SMTP_PORT", "587") or 587) if not (to_addr and user and pw): return False, "not_configured" import smtplib import ssl from email.message import EmailMessage from email.utils import formataddr msg = EmailMessage() msg["Subject"] = "[TRTOI 網站來信] " + (subject or "(無主旨)") msg["From"] = formataddr(("TRTOI Website", user)) msg["To"] = to_addr if email: msg["Reply-To"] = email msg.set_content("姓名 Name: %s\nEmail: %s\n主旨 Subject: %s\n\n%s" % (name, email, subject, message)) try: with smtplib.SMTP(host, port, timeout=20) as s: s.starttls(context=ssl.create_default_context()) s.login(user, pw) s.send_message(msg) return True, "sent" except Exception as e: # noqa: BLE001 return False, type(e).__name__ _contact_last = {} # ip -> 上次送出時間(簡單防洗版) @app.route("/contact/send", methods=["POST"]) def contact_send(): d = request.get_json(silent=True) or {} name = (d.get("name") or "").strip()[:200] email = (d.get("email") or "").strip()[:200] subject = (d.get("subject") or "").strip()[:300] message = (d.get("message") or "").strip()[:5000] if not (name and email and message): return {"ok": False, "error": "missing"} ip = _client_ip() now = _time.time() if now - _contact_last.get(ip, 0) < 30: # 每 IP 30 秒最多一封 return {"ok": False, "error": "rate"} ok, info = _send_contact_mail(name, email, subject, message) if ok: _contact_last[ip] = now return {"ok": bool(ok), "error": None if ok else info} def apply_patch(data, path, value): """依 'blocks.2.items.0.title' 之類的路徑,把 value 寫入 data 對應位置。""" parts = path.split(".") cur = data for p in parts[:-1]: cur = cur[int(p)] if p.isdigit() else cur[p] last = parts[-1] if last.isdigit(): cur[int(last)] = value else: cur[last] = value @app.route("/admin/hero/upload", methods=["POST"]) @login_required def hero_upload(): """上傳頂部輪播圖片(可多張)。""" import uuid os.makedirs(HERO_DIR, exist_ok=True) saved = 0 for f in request.files.getlist("images"): if not f or not f.filename: continue ext = os.path.splitext(f.filename)[1].lower() if ext not in ALLOWED_IMG_EXT: continue name = uuid.uuid4().hex + ext f.save(os.path.join(HERO_DIR, name)) _persist_mark("static/uploads/hero/" + name, os.path.join(HERO_DIR, name)) saved += 1 if saved: _hero_write_order(hero_images()) # 記住順序(新圖接在後面) return {"ok": saved > 0, "saved": saved} @app.route("/admin/hero/delete", methods=["POST"]) @login_required def hero_delete(): """刪除指定的輪播圖片。""" data = request.get_json(silent=True) or {} name = os.path.basename(data.get("name", "")) # 防目錄穿越 path = os.path.join(HERO_DIR, name) if os.path.splitext(name)[1].lower() in ALLOWED_IMG_EXT and os.path.exists(path): os.remove(path) _persist_mark("static/uploads/hero/" + name, None) _hero_write_order([f for f in _hero_read_order() if f != name]) return {"ok": True} return {"ok": False} @app.route("/admin/hero/reorder", methods=["POST"]) @login_required def hero_reorder(): """儲存拖曳後的圖片順序。""" data = request.get_json(silent=True) or {} fileset = set(_hero_files()) order = [os.path.basename(n) for n in data.get("order", []) if os.path.basename(n) in fileset] _hero_write_order(order) return {"ok": True} def _other_lang(lang): return "zh" if lang == "en" else "en" def _photo_referenced(photo, lang): """該照片是否仍被另一語言的團隊清單引用(避免刪到跨語言共用檔)。""" if not photo: return False other = _other_lang(lang) if not os.path.exists(_lang_path(TEAM_FILE, other)): return False bn = os.path.basename(photo) return any(os.path.basename((m or {}).get("photo") or "") == bn for m in team_members(other)) @app.route("/admin/team/add", methods=["POST"]) @login_required def team_add(): import uuid bio_template = ( "姓名 Name
" "現職:
" "學歷:
" "經歷:
" "研究專長:" ) members = team_members(g.lang) members.append({"id": uuid.uuid4().hex, "title": "", "photo": "", "bio": bio_template}) save_team(members, g.lang) return {"ok": True} @app.route("/admin/team/delete", methods=["POST"]) @login_required def team_delete(): data = request.get_json(silent=True) or {} mid = data.get("id", "") members = team_members(g.lang) for m in members: if m.get("id") == mid and m.get("photo") and not _photo_referenced(m["photo"], g.lang): bn = os.path.basename(m["photo"]) p = os.path.join(TEAM_PHOTO_DIR, bn) if os.path.exists(p): os.remove(p) _persist_mark("static/uploads/team/" + bn, None) save_team([m for m in members if m.get("id") != mid], g.lang) return {"ok": True} @app.route("/admin/team/bio", methods=["POST"]) @login_required def team_bio(): data = request.get_json(silent=True) or {} mid, bio = data.get("id", ""), data.get("bio", "") members = team_members(g.lang) for m in members: if m.get("id") == mid: m["bio"] = bio save_team(members, g.lang) return {"ok": True} @app.route("/admin/team/title", methods=["POST"]) @login_required def team_title(): data = request.get_json(silent=True) or {} mid, title = data.get("id", ""), data.get("title", "") members = team_members(g.lang) for m in members: if m.get("id") == mid: m["title"] = title save_team(members, g.lang) return {"ok": True} @app.route("/admin/team/photo", methods=["POST"]) @login_required def team_photo(): import uuid mid = request.form.get("id", "") f = request.files.get("photo") if not f or not f.filename: return {"ok": False, "error": "no file"} ext = os.path.splitext(f.filename)[1].lower() if ext not in ALLOWED_IMG_EXT: return {"ok": False, "error": "bad type"} os.makedirs(TEAM_PHOTO_DIR, exist_ok=True) name = uuid.uuid4().hex + ext f.save(os.path.join(TEAM_PHOTO_DIR, name)) _persist_mark("static/uploads/team/" + name, os.path.join(TEAM_PHOTO_DIR, name)) members = team_members(g.lang) for m in members: if m.get("id") == mid: if m.get("photo") and not _photo_referenced(m["photo"], g.lang): # 刪除舊照片(未被他語言引用時) oldname = os.path.basename(m["photo"]) oldp = os.path.join(TEAM_PHOTO_DIR, oldname) if os.path.exists(oldp): os.remove(oldp) _persist_mark("static/uploads/team/" + oldname, None) m["photo"] = name save_team(members, g.lang) return {"ok": True, "photo": name} @app.route("/admin/team/reorder", methods=["POST"]) @login_required def team_reorder(): data = request.get_json(silent=True) or {} order = data.get("order", []) members = team_members(g.lang) by_id = {m.get("id"): m for m in members} new = [by_id[i] for i in order if i in by_id] new += [m for m in members if m.get("id") not in set(order)] # 保底 save_team(new, g.lang) return {"ok": True} def _pub_block(data): for b in data.get("blocks", []): if b.get("type") == "publications": return b return None @app.route("/admin/pub/add", methods=["POST"]) @login_required def pub_add(): data = load_page("publications", g.lang) b = _pub_block(data) if b is None: return {"ok": False} b.setdefault("items", []).insert( 0, {"year": "", "title": "", "journal": "", "desc": "", "badge": ""}) save_page("publications", data, g.lang) return {"ok": True} @app.route("/admin/pub/delete", methods=["POST"]) @login_required def pub_delete(): idx = (request.get_json(silent=True) or {}).get("index") data = load_page("publications", g.lang) b = _pub_block(data) if b and isinstance(idx, int) and 0 <= idx < len(b.get("items", [])): b["items"].pop(idx) save_page("publications", data, g.lang) return {"ok": True} return {"ok": False} @app.route("/admin/pub/field", methods=["POST"]) @login_required def pub_field(): d = request.get_json(silent=True) or {} idx, field, value = d.get("index"), d.get("field"), d.get("value", "") if field not in {"year", "title", "journal", "desc", "badge"}: return {"ok": False} # 中性欄位(年份/標題/期刊)兩語言同步;desc/badge 只寫目前語言。 neutral = field in {"year", "title", "journal"} langs = list(LANGS) if neutral else [g.lang] ok = False for lg in langs: if lg != "zh" and not os.path.exists(content_path("publications", lg)): continue # 該語言檔尚未建立 data = load_page("publications", lg) b = _pub_block(data) if data else None if b and isinstance(idx, int) and 0 <= idx < len(b.get("items", [])): b["items"][idx][field] = value save_page("publications", data, lg) ok = True return {"ok": ok} @app.route("/admin/save-inline/", methods=["POST"]) @login_required def admin_save_inline(slug): """就地編輯的儲存端點:接收 {patches: {路徑: 新值}} 並寫回 JSON。 slug 為 'site' 時寫回站台層級文字(side-note 等每頁共用元素),其餘為分頁內容。""" payload = request.get_json(silent=True) or {} patches = payload.get("patches", {}) lang = payload.get("lang") if payload.get("lang") in LANGS else g.lang if not isinstance(patches, dict): return {"ok": False, "error": "bad payload"} if slug == "site": data = load_site(lang) saver = lambda d: save_site(d, lang) elif slug in ORDER: data = load_page(slug, lang) if not data: return {"ok": False, "error": "page not found"} saver = lambda d: save_page(slug, d, lang) else: return {"ok": False, "error": "unknown page"} for path, val in patches.items(): try: apply_patch(data, str(path), val) except Exception: pass # 略過無法對應的路徑,不影響其他欄位 saver(data) return {"ok": True} # --------------------------------------------------------------------------- # 命令列:帳號管理 # --------------------------------------------------------------------------- def cli(): args = sys.argv[1:] if not args: app.run(host="127.0.0.1", port=5000, debug=True) return cmd = args[0] auth = load_auth() if cmd == "listusers": print("成員帳號:", ", ".join(sorted(auth["users"].keys())) or "(無)") elif cmd in ("adduser", "setpass") and len(args) >= 2: name = args[1] pw1 = getpass.getpass("輸入密碼:") pw2 = getpass.getpass("再次輸入:") if pw1 != pw2: print("兩次密碼不一致,取消。") return if not pw1: print("密碼不可為空,取消。") return auth["users"][name] = generate_password_hash(pw1) save_auth(auth) print(f"已{'新增' if cmd == 'adduser' else '更新'}帳號:{name}") elif cmd == "deluser" and len(args) >= 2: name = args[1] if name in auth["users"]: del auth["users"][name] save_auth(auth) print(f"已刪除帳號:{name}") else: print(f"找不到帳號:{name}") else: print(__doc__) if __name__ == "__main__": cli()