| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- import argparse
- import json
- import os
- import sys
- from .config import (
- build_output_settings,
- get_source_type,
- print_output_settings,
- to_float_or_none,
- validate_config,
- )
- from .filter import apply_filter, collect_excluded_domains
- from .notify import run_notify
- from .scoring import (
- build_top_candidates,
- choose_domain,
- parse_scored_records,
- rank_scored_records,
- )
- from .sources import extract_records, fetch_api_json, load_cfst_rows, parse_domains
- from .storage import (
- read_json_file,
- utc_now_iso,
- write_json_file,
- write_text_file,
- )
- def run_updater(config_path_abs, print_settings_only=False, result_json_file=""):
- """Execute main rotation workflow with strict error handling and state persistence."""
- if not os.path.exists(config_path_abs):
- err_msg = {"status": "error", "error": f"config file not found: {config_path_abs}"}
- print(json.dumps(err_msg, ensure_ascii=True), file=sys.stderr)
- return 1
- cfg = read_json_file(config_path_abs)
- try:
- validate_config(cfg)
- except Exception as e:
- err_msg = {"status": "error", "error": f"invalid config: {e}"}
- print(json.dumps(err_msg, ensure_ascii=True), file=sys.stderr)
- return 1
- if print_settings_only:
- print_output_settings(config_path_abs, cfg)
- return 0
- output_cfg = cfg.get("output", {})
- output_settings = build_output_settings(output_cfg, config_path_abs)
- notify_cfg = cfg.get("notify", {})
- selected_text_file = output_settings["selected_text_path"]
- selected_json_file = output_settings["selected_json_path"]
- state_file = output_settings["state_path"]
- vars_file = output_settings["vars_path"]
- selected_json_key = output_settings["selected_json_key"]
- state_last_good_key = output_settings["state_last_good_key"]
- vars_value_key = output_settings["vars_value_key"]
- state = read_json_file(state_file, default={})
- last_good = state.get(state_last_good_key, "")
- source_type = get_source_type(cfg)
- try:
- top_n = int(cfg.get("selection", {}).get("top_n", 3))
- if source_type == "cfst_local":
- cfst_rows = load_cfst_rows(cfg, config_path_abs)
- parsed = [row["domain"] for row in cfst_rows]
- filtered = apply_filter(parsed, cfg.get("domain_filter", {}))
- filtered_set = set(filtered)
- cfst_rows = [row for row in cfst_rows if row["domain"] in filtered_set]
- if not cfst_rows:
- raise RuntimeError("No valid IP available from cfst result after filtering")
- # 若未显式禁用下载测速(未配置 -dd),优先过滤掉测速失败的 0.00 下载速度记录
- cfst_cfg = cfg.get("cfst_local", {})
- disable_download = "-dd" in [str(a).strip() for a in cfst_cfg.get("run_args", [])]
- valid_speed_rows = [
- row for row in cfst_rows
- if to_float_or_none(row.get("download_speed")) is not None
- and float(to_float_or_none(row.get("download_speed"))) > 0.0
- ]
- if not disable_download and not valid_speed_rows and last_good:
- selected = None
- candidate_domains = []
- top_candidates = []
- else:
- eligible_rows = valid_speed_rows if (not disable_download and valid_speed_rows) else cfst_rows
- selected = eligible_rows[0]["domain"]
- candidate_domains = [row["domain"] for row in eligible_rows[:top_n]]
- top_candidates = build_top_candidates("cfst_local", candidate_domains, cfst_rows=eligible_rows)
- else:
- payload = fetch_api_json(cfg)
- parsed = parse_domains(payload, cfg.get("parser", {}))
- filtered = apply_filter(parsed, cfg.get("domain_filter", {}))
- record_mapping_cfg = cfg.get("record_mapping", {})
- field_map = record_mapping_cfg.get("field_map", {})
- records = extract_records(payload, record_mapping_cfg)
- record_filter_cfg = cfg.get("record_filter", {})
- blocked_domains = collect_excluded_domains(records, field_map, record_filter_cfg)
- if blocked_domains:
- filtered = [d for d in filtered if d not in blocked_domains]
- scoring_cfg = cfg.get("scoring", {})
- scored_records = parse_scored_records(records, field_map, record_mapping_cfg, scoring_cfg)
- filtered_set = set(filtered)
- scored_records = [r for r in scored_records if r["domain"] in filtered_set]
- ranked_scored = rank_scored_records(scored_records, scoring_cfg)
- switch_margin_pct = to_float_or_none(scoring_cfg.get("switch_margin_pct")) or 0.0
- selected, candidate_domains = choose_domain(
- filtered, top_n, ranked_scored, last_good=last_good, switch_margin_pct=switch_margin_pct
- )
- top_candidates = build_top_candidates(
- "api",
- candidate_domains,
- records=records,
- field_map=field_map,
- ranked_scored=ranked_scored,
- )
- status = "ok"
- if not selected and last_good:
- # 复检 last_good: 确保已被拉黑的故障节点不会借回退复活
- if source_type == "api" and apply_filter([last_good], cfg.get("domain_filter", {})) != [last_good]:
- last_good = ""
- else:
- selected = last_good
- status = "fallback_last_good"
- if not selected:
- if source_type == "cfst_local":
- raise RuntimeError("No valid IP available from cfst and no fallback in state")
- raise RuntimeError("No valid domain available from API and no fallback in state")
- write_text_file(selected_text_file, selected + "\n")
- current_json = {
- selected_json_key: selected,
- "updated_at": utc_now_iso(),
- "status": status,
- "source_type": source_type,
- "source_count": len(parsed),
- "top_candidates": top_candidates,
- }
- write_json_file(selected_json_file, current_json)
- write_json_file(
- vars_file,
- {
- vars_value_key: selected,
- "UPDATED_AT": current_json["updated_at"],
- "STATUS": status,
- },
- )
- new_state = {
- "updated_at": current_json["updated_at"],
- state_last_good_key: selected,
- "status": status,
- "source_count": len(parsed),
- "source_type": source_type,
- }
- write_json_file(state_file, new_state)
- run_notify(notify_cfg.get("command", ""), selected, status)
- if result_json_file:
- write_json_file(result_json_file, current_json)
- print(json.dumps(current_json, ensure_ascii=True))
- return 0
- except Exception as e:
- now = utc_now_iso()
- err_state = {
- "updated_at": now,
- "status": "error",
- "error": str(e),
- state_last_good_key: last_good,
- "source_type": source_type,
- }
- write_json_file(state_file, err_state)
- if last_good and source_type == "api":
- if apply_filter([last_good], cfg.get("domain_filter", {})) != [last_good]:
- last_good = ""
- if last_good:
- write_text_file(selected_text_file, last_good + "\n")
- write_json_file(
- selected_json_file,
- {
- selected_json_key: last_good,
- "updated_at": now,
- "status": "error_use_last_good",
- "error": str(e),
- "source_type": source_type,
- },
- )
- write_json_file(
- vars_file,
- {
- vars_value_key: last_good,
- "UPDATED_AT": now,
- "STATUS": "error_use_last_good",
- },
- )
- fallback_res = {
- selected_json_key: last_good,
- "status": "error_use_last_good",
- "error": str(e),
- }
- run_notify(notify_cfg.get("command", ""), last_good, "error_use_last_good")
- if result_json_file:
- write_json_file(result_json_file, fallback_res)
- print(json.dumps(fallback_res, ensure_ascii=True))
- return 0
- err_res = {"status": "error", "error": str(e)}
- run_notify(notify_cfg.get("command", ""), "", "error")
- if result_json_file:
- write_json_file(result_json_file, err_res)
- print(json.dumps(err_res, ensure_ascii=True), file=sys.stderr)
- return 1
- def main():
- ap = argparse.ArgumentParser(description="Auto select preferred endpoint value")
- ap.add_argument("--config", default="configs/config.server.json", help="Path to config JSON")
- ap.add_argument(
- "--print-output-settings",
- action="store_true",
- help="Print resolved output settings as JSON and exit",
- )
- ap.add_argument(
- "--result-json-file",
- default="",
- help="Optional path to output machine-readable JSON result (decouples IPC from stdout)",
- )
- args = ap.parse_args()
- config_path_abs = os.path.abspath(args.config)
- if not os.path.exists(config_path_abs):
- candidate_configs = os.path.abspath(os.path.join("configs", args.config))
- if os.path.exists(candidate_configs):
- config_path_abs = candidate_configs
- else:
- repo_configs = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "configs", os.path.basename(args.config)))
- if os.path.exists(repo_configs):
- config_path_abs = repo_configs
- rc = run_updater(
- config_path_abs=config_path_abs,
- print_settings_only=args.print_output_settings,
- result_json_file=args.result_json_file,
- )
- if rc != 0:
- sys.exit(rc)
- if __name__ == "__main__":
- main()
|