#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
update_dnsservers.py

每小時由 cron 執行一次, 從 dnschecker.org 首頁抓取各地區節點實際使用的 DNS Server IP,
結果寫入 ddnslookup-dnsservers.json 供 ddnslookup.php 讀取。

說明:
  - dnschecker.org 受 Cloudflare 保護, 一般 curl/requests 會被擋, 故使用 cloudscraper。
  - 首頁節點清單為隨機輪替子集, 因此連抓數次取聯集以提高覆蓋率。
  - 找不到對應節點的地區 (例如 dnschecker 探測池沒有日本/英國) 使用 FALLBACK 值。
  - 若整批抓取失敗 (一個節點都沒抓到), 不覆寫舊 JSON, 保留上次良好結果。
"""

import json
import os
import re
import sys
import tempfile
import time
from datetime import datetime

import cloudscraper

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
OUT_FILE = os.path.join(BASE_DIR, "ddnslookup-dnsservers.json")
LOG_FILE = os.path.join(BASE_DIR, "update_dnsservers.log")

HOMEPAGE = "https://dnschecker.org/"
FETCH_TIMES = 6          # 連抓次數 (取聯集)
FETCH_TIMEOUT = 40       # 每次請求逾時 (秒)

LOG_MAX_BYTES = 1 * 1024 * 1024  # 單一日誌檔上限 1MB, 超過即輪替
LOG_BACKUPS = 3                  # 保留幾份歷史檔 (.1 .2 .3)

# 地區設定: cc=國碼; match=同國多節點時優先挑選地點含此關鍵字者
REGIONS = {
    "Tokyo":         {"cc": "jp"},
    "Canada":        {"cc": "ca"},
    "Russian":       {"cc": "ru"},
    "Africa":        {"cc": "za"},
    "France":        {"cc": "fr"},
    "UK":            {"cc": "gb"},
    "Singapore":     {"cc": "sg"},
    "China":         {"cc": "cn"},
    "Australia":     {"cc": "au"},
    "India":         {"cc": "in"},
    "San_Francisco": {"cc": "us", "match": "San Francisco"},
    "NY":            {"cc": "us", "match": "Ashburn"},
}

# 抓取失敗 / 該地區無節點時的備援值 (沿用 ddnslookup.php 原本設定)
FALLBACK = {
    "Tokyo":         "openresolver.initiative.jp",                       # 空字串 = 使用系統預設 DNS
    "Canada":        "208.91.112.53",
    "Russian":       "dns1.safedns.com",
    "Africa":        "ns1.gns.co.za",
    "France":        "7.86.145.83.rev.sfr.net",
    "UK":            "cns2.cw.net",
    "Singapore":     "103.86.99.100",
    "China":         "public1.alidns.com",
    "Australia":     "one.one.one.one",
    "India":         "ws89-195-133-112.rcil.gov.in",
    "San_Francisco": "resolver3.opendns.com",
    "NY":            "rdns.dynect.net",
}

# 解析每個節點: 國旗(國碼) + 地點文字 ... data-clipboardtext="IP"
NODE_RE = re.compile(
    r'flags/svg/([a-z]{2})\.svg"[^>]*alt="[a-z]{2}"\s*/>([^<]+).*?data-clipboardtext="([0-9.]+)"',
    re.S,
)


def rotate_log():
    """日誌超過上限時輪替: .log -> .log.1 -> .log.2 ... 最舊的丟棄。"""
    try:
        if not os.path.exists(LOG_FILE) or os.path.getsize(LOG_FILE) < LOG_MAX_BYTES:
            return
        oldest = "%s.%d" % (LOG_FILE, LOG_BACKUPS)
        if os.path.exists(oldest):
            os.unlink(oldest)
        for i in range(LOG_BACKUPS - 1, 0, -1):
            src = "%s.%d" % (LOG_FILE, i)
            if os.path.exists(src):
                os.replace(src, "%s.%d" % (LOG_FILE, i + 1))
        os.replace(LOG_FILE, "%s.1" % LOG_FILE)
    except OSError:
        pass


def log(msg):
    rotate_log()
    line = "%s %s\n" % (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), msg)
    try:
        with open(LOG_FILE, "a", encoding="utf-8") as f:
            f.write(line)
    except OSError:
        pass
    print(line, end="")


def scrape_nodes():
    """連抓數次首頁, 回傳 {cc: [(location, ip), ...]} (依出現順序去重)。"""
    scraper = cloudscraper.create_scraper()
    nodes = {}
    total_raw = 0
    for i in range(FETCH_TIMES):
        try:
            resp = scraper.get(HOMEPAGE, timeout=FETCH_TIMEOUT)
        except Exception as exc:  # 網路 / Cloudflare 例外
            log("fetch #%d error: %s" % (i + 1, exc))
            continue
        if resp.status_code != 200 or "Just a moment" in resp.text:
            log("fetch #%d blocked (status=%s)" % (i + 1, resp.status_code))
            continue
        for cc, loc, ip in NODE_RE.findall(resp.text):
            total_raw += 1
            loc = loc.strip()
            bucket = nodes.setdefault(cc, [])
            if (loc, ip) not in bucket:
                bucket.append((loc, ip))
        time.sleep(1)  # 禮貌性間隔
    log("scraped countries=%s raw_nodes=%d" % (sorted(nodes.keys()), total_raw))
    return nodes


def pick_ip(region, cfg, nodes):
    """依地區設定挑出 IP, 找不到回傳 None。"""
    candidates = nodes.get(cfg["cc"])
    if not candidates:
        return None
    match = cfg.get("match")
    if match:
        for loc, ip in candidates:
            if match.lower() in loc.lower():
                return ip
    return candidates[0][1]  # 無關鍵字或沒比中, 取第一個


def build_servers(nodes):
    servers = {}
    for region, cfg in REGIONS.items():
        ip = pick_ip(region, cfg, nodes) if nodes else None
        if ip:
            servers[region] = ip
        else:
            servers[region] = FALLBACK[region]
            log("region %-13s -> fallback (%s)" % (region, FALLBACK[region] or "system-default"))
    return servers


def write_json_atomic(path, data):
    fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False)
        os.chmod(tmp, 0o644)   # 讓 web server (www-data) 可讀取
        os.replace(tmp, path)  # 原子性取代, 避免 PHP 讀到半成品
    finally:
        if os.path.exists(tmp):
            os.unlink(tmp)


def main():
    nodes = scrape_nodes()
    if not nodes:
        log("ABORT: no node scraped, keep existing JSON (if any).")
        # 一個都沒抓到時不覆寫, 保留上次良好結果; 若連檔案都沒有則寫入全備援。
        if os.path.exists(OUT_FILE):
            return 1
        servers = {r: FALLBACK[r] for r in REGIONS}
        write_json_atomic(OUT_FILE, servers)
        log("wrote fallback-only JSON: %s" % json.dumps(servers, ensure_ascii=False))
        return 1

    servers = build_servers(nodes)
    write_json_atomic(OUT_FILE, servers)
    log("OK wrote %s -> %s" % (OUT_FILE, json.dumps(servers, ensure_ascii=False)))
    return 0


if __name__ == "__main__":
    sys.exit(main())
