main.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import argparse
  2. import json
  3. import os
  4. import sys
  5. from .config import (
  6. build_output_settings,
  7. get_source_type,
  8. print_output_settings,
  9. to_float_or_none,
  10. validate_config,
  11. )
  12. from .filter import apply_filter, collect_excluded_domains
  13. from .notify import run_notify
  14. from .scoring import (
  15. build_top_candidates,
  16. choose_domain,
  17. parse_scored_records,
  18. rank_scored_records,
  19. )
  20. from .sources import extract_records, fetch_api_json, load_cfst_rows, parse_domains
  21. from .storage import (
  22. read_json_file,
  23. utc_now_iso,
  24. write_json_file,
  25. write_text_file,
  26. )
  27. def run_updater(config_path_abs, print_settings_only=False, result_json_file=""):
  28. """Execute main rotation workflow with strict error handling and state persistence."""
  29. if not os.path.exists(config_path_abs):
  30. err_msg = {"status": "error", "error": f"config file not found: {config_path_abs}"}
  31. print(json.dumps(err_msg, ensure_ascii=True), file=sys.stderr)
  32. return 1
  33. cfg = read_json_file(config_path_abs)
  34. try:
  35. validate_config(cfg)
  36. except Exception as e:
  37. err_msg = {"status": "error", "error": f"invalid config: {e}"}
  38. print(json.dumps(err_msg, ensure_ascii=True), file=sys.stderr)
  39. return 1
  40. if print_settings_only:
  41. print_output_settings(config_path_abs, cfg)
  42. return 0
  43. output_cfg = cfg.get("output", {})
  44. output_settings = build_output_settings(output_cfg, config_path_abs)
  45. notify_cfg = cfg.get("notify", {})
  46. selected_text_file = output_settings["selected_text_path"]
  47. selected_json_file = output_settings["selected_json_path"]
  48. state_file = output_settings["state_path"]
  49. vars_file = output_settings["vars_path"]
  50. selected_json_key = output_settings["selected_json_key"]
  51. state_last_good_key = output_settings["state_last_good_key"]
  52. vars_value_key = output_settings["vars_value_key"]
  53. state = read_json_file(state_file, default={})
  54. last_good = state.get(state_last_good_key, "")
  55. source_type = get_source_type(cfg)
  56. try:
  57. top_n = int(cfg.get("selection", {}).get("top_n", 3))
  58. if source_type == "cfst_local":
  59. cfst_rows = load_cfst_rows(cfg, config_path_abs)
  60. parsed = [row["domain"] for row in cfst_rows]
  61. filtered = apply_filter(parsed, cfg.get("domain_filter", {}))
  62. filtered_set = set(filtered)
  63. cfst_rows = [row for row in cfst_rows if row["domain"] in filtered_set]
  64. if not cfst_rows:
  65. raise RuntimeError("No valid IP available from cfst result after filtering")
  66. # 若未显式禁用下载测速(未配置 -dd),优先过滤掉测速失败的 0.00 下载速度记录
  67. cfst_cfg = cfg.get("cfst_local", {})
  68. disable_download = "-dd" in [str(a).strip() for a in cfst_cfg.get("run_args", [])]
  69. valid_speed_rows = [
  70. row for row in cfst_rows
  71. if to_float_or_none(row.get("download_speed")) is not None
  72. and float(to_float_or_none(row.get("download_speed"))) > 0.0
  73. ]
  74. if not disable_download and not valid_speed_rows and last_good:
  75. selected = None
  76. candidate_domains = []
  77. top_candidates = []
  78. else:
  79. eligible_rows = valid_speed_rows if (not disable_download and valid_speed_rows) else cfst_rows
  80. selected = eligible_rows[0]["domain"]
  81. candidate_domains = [row["domain"] for row in eligible_rows[:top_n]]
  82. top_candidates = build_top_candidates("cfst_local", candidate_domains, cfst_rows=eligible_rows)
  83. else:
  84. payload = fetch_api_json(cfg)
  85. parsed = parse_domains(payload, cfg.get("parser", {}))
  86. filtered = apply_filter(parsed, cfg.get("domain_filter", {}))
  87. record_mapping_cfg = cfg.get("record_mapping", {})
  88. field_map = record_mapping_cfg.get("field_map", {})
  89. records = extract_records(payload, record_mapping_cfg)
  90. record_filter_cfg = cfg.get("record_filter", {})
  91. blocked_domains = collect_excluded_domains(records, field_map, record_filter_cfg)
  92. if blocked_domains:
  93. filtered = [d for d in filtered if d not in blocked_domains]
  94. scoring_cfg = cfg.get("scoring", {})
  95. scored_records = parse_scored_records(records, field_map, record_mapping_cfg, scoring_cfg)
  96. filtered_set = set(filtered)
  97. scored_records = [r for r in scored_records if r["domain"] in filtered_set]
  98. ranked_scored = rank_scored_records(scored_records, scoring_cfg)
  99. switch_margin_pct = to_float_or_none(scoring_cfg.get("switch_margin_pct")) or 0.0
  100. selected, candidate_domains = choose_domain(
  101. filtered, top_n, ranked_scored, last_good=last_good, switch_margin_pct=switch_margin_pct
  102. )
  103. top_candidates = build_top_candidates(
  104. "api",
  105. candidate_domains,
  106. records=records,
  107. field_map=field_map,
  108. ranked_scored=ranked_scored,
  109. )
  110. status = "ok"
  111. if not selected and last_good:
  112. # 复检 last_good: 确保已被拉黑的故障节点不会借回退复活
  113. if source_type == "api" and apply_filter([last_good], cfg.get("domain_filter", {})) != [last_good]:
  114. last_good = ""
  115. else:
  116. selected = last_good
  117. status = "fallback_last_good"
  118. if not selected:
  119. if source_type == "cfst_local":
  120. raise RuntimeError("No valid IP available from cfst and no fallback in state")
  121. raise RuntimeError("No valid domain available from API and no fallback in state")
  122. write_text_file(selected_text_file, selected + "\n")
  123. current_json = {
  124. selected_json_key: selected,
  125. "updated_at": utc_now_iso(),
  126. "status": status,
  127. "source_type": source_type,
  128. "source_count": len(parsed),
  129. "top_candidates": top_candidates,
  130. }
  131. write_json_file(selected_json_file, current_json)
  132. write_json_file(
  133. vars_file,
  134. {
  135. vars_value_key: selected,
  136. "UPDATED_AT": current_json["updated_at"],
  137. "STATUS": status,
  138. },
  139. )
  140. new_state = {
  141. "updated_at": current_json["updated_at"],
  142. state_last_good_key: selected,
  143. "status": status,
  144. "source_count": len(parsed),
  145. "source_type": source_type,
  146. }
  147. write_json_file(state_file, new_state)
  148. run_notify(notify_cfg.get("command", ""), selected, status)
  149. if result_json_file:
  150. write_json_file(result_json_file, current_json)
  151. print(json.dumps(current_json, ensure_ascii=True))
  152. return 0
  153. except Exception as e:
  154. now = utc_now_iso()
  155. err_state = {
  156. "updated_at": now,
  157. "status": "error",
  158. "error": str(e),
  159. state_last_good_key: last_good,
  160. "source_type": source_type,
  161. }
  162. write_json_file(state_file, err_state)
  163. if last_good and source_type == "api":
  164. if apply_filter([last_good], cfg.get("domain_filter", {})) != [last_good]:
  165. last_good = ""
  166. if last_good:
  167. write_text_file(selected_text_file, last_good + "\n")
  168. write_json_file(
  169. selected_json_file,
  170. {
  171. selected_json_key: last_good,
  172. "updated_at": now,
  173. "status": "error_use_last_good",
  174. "error": str(e),
  175. "source_type": source_type,
  176. },
  177. )
  178. write_json_file(
  179. vars_file,
  180. {
  181. vars_value_key: last_good,
  182. "UPDATED_AT": now,
  183. "STATUS": "error_use_last_good",
  184. },
  185. )
  186. fallback_res = {
  187. selected_json_key: last_good,
  188. "status": "error_use_last_good",
  189. "error": str(e),
  190. }
  191. run_notify(notify_cfg.get("command", ""), last_good, "error_use_last_good")
  192. if result_json_file:
  193. write_json_file(result_json_file, fallback_res)
  194. print(json.dumps(fallback_res, ensure_ascii=True))
  195. return 0
  196. err_res = {"status": "error", "error": str(e)}
  197. run_notify(notify_cfg.get("command", ""), "", "error")
  198. if result_json_file:
  199. write_json_file(result_json_file, err_res)
  200. print(json.dumps(err_res, ensure_ascii=True), file=sys.stderr)
  201. return 1
  202. def main():
  203. ap = argparse.ArgumentParser(description="Auto select preferred endpoint value")
  204. ap.add_argument("--config", default="configs/config.server.json", help="Path to config JSON")
  205. ap.add_argument(
  206. "--print-output-settings",
  207. action="store_true",
  208. help="Print resolved output settings as JSON and exit",
  209. )
  210. ap.add_argument(
  211. "--result-json-file",
  212. default="",
  213. help="Optional path to output machine-readable JSON result (decouples IPC from stdout)",
  214. )
  215. args = ap.parse_args()
  216. config_path_abs = os.path.abspath(args.config)
  217. if not os.path.exists(config_path_abs):
  218. candidate_configs = os.path.abspath(os.path.join("configs", args.config))
  219. if os.path.exists(candidate_configs):
  220. config_path_abs = candidate_configs
  221. else:
  222. repo_configs = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "configs", os.path.basename(args.config)))
  223. if os.path.exists(repo_configs):
  224. config_path_abs = repo_configs
  225. rc = run_updater(
  226. config_path_abs=config_path_abs,
  227. print_settings_only=args.print_output_settings,
  228. result_json_file=args.result_json_file,
  229. )
  230. if rc != 0:
  231. sys.exit(rc)
  232. if __name__ == "__main__":
  233. main()