domain_updater.py 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245
  1. #!/usr/bin/env python3
  2. import argparse
  3. import csv
  4. import datetime as dt
  5. import functools
  6. import json
  7. import math
  8. import os
  9. import re
  10. import subprocess
  11. import sys
  12. import tempfile
  13. import urllib.parse
  14. import urllib.request
  15. DOMAIN_RE = re.compile(r"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))+$")
  16. IPV4_RE = re.compile(r"^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$")
  17. def utc_now_iso():
  18. return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
  19. def read_json_file(path, default=None):
  20. if default is None:
  21. default = {}
  22. if not os.path.exists(path):
  23. return default
  24. try:
  25. with open(path, "r", encoding="utf-8") as f:
  26. return json.load(f)
  27. except (ValueError, json.JSONDecodeError):
  28. return default
  29. def _atomic_write(path, payload_bytes):
  30. """Write `payload_bytes` to `path` atomically.
  31. Strategy: write to a sibling temp file, fsync, then os.replace.
  32. Concurrent readers either see the previous version or the new
  33. one — never a truncated/empty file. Required for state.json,
  34. which is the sole fallback source on the next run.
  35. """
  36. parent = os.path.dirname(path) or "."
  37. os.makedirs(parent, exist_ok=True)
  38. fd, tmp_path = tempfile.mkstemp(
  39. prefix=".{}.".format(os.path.basename(path)),
  40. suffix=".tmp",
  41. dir=parent,
  42. )
  43. try:
  44. with os.fdopen(fd, "wb") as f:
  45. f.write(payload_bytes)
  46. f.flush()
  47. try:
  48. os.fsync(f.fileno())
  49. except OSError:
  50. # fsync may fail on some filesystems (e.g. tmpfs without
  51. # backing store); the rename below still gives atomicity
  52. # within a single filesystem.
  53. pass
  54. os.replace(tmp_path, path)
  55. except Exception:
  56. try:
  57. os.unlink(tmp_path)
  58. except OSError:
  59. pass
  60. raise
  61. def write_json_file(path, data):
  62. payload = json.dumps(data, ensure_ascii=True, indent=2)
  63. _atomic_write(path, payload.encode("utf-8"))
  64. def write_text_file(path, data):
  65. _atomic_write(path, data.encode("utf-8"))
  66. def build_url(base_url, params):
  67. if not params:
  68. return base_url
  69. parsed = urllib.parse.urlparse(base_url)
  70. current = urllib.parse.parse_qs(parsed.query)
  71. for k, v in params.items():
  72. current[k] = [str(v)]
  73. query = urllib.parse.urlencode(current, doseq=True)
  74. return urllib.parse.urlunparse(parsed._replace(query=query))
  75. def resolve_path(base_dir, path_value):
  76. path_text = str(path_value or "").strip()
  77. if not path_text:
  78. return ""
  79. if os.path.isabs(path_text):
  80. return os.path.normpath(path_text)
  81. return os.path.normpath(os.path.join(base_dir, path_text))
  82. def get_source_type(cfg):
  83. source_cfg = cfg.get("source", {})
  84. if isinstance(source_cfg, dict):
  85. source_type = str(source_cfg.get("type", "api")).strip().lower()
  86. if source_type:
  87. return source_type
  88. return "api"
  89. def fetch_api_json(cfg):
  90. api = cfg["api"]
  91. url = build_url(api["url"], api.get("params", {}))
  92. method = api.get("method", "GET").upper()
  93. headers = api.get("headers", {})
  94. timeout = int(api.get("timeout_sec", 10))
  95. body_obj = api.get("body")
  96. body = None
  97. if body_obj is not None:
  98. body = json.dumps(body_obj).encode("utf-8")
  99. headers = {**headers, "Content-Type": "application/json"}
  100. req = urllib.request.Request(url=url, data=body, headers=headers, method=method)
  101. with urllib.request.urlopen(req, timeout=timeout) as resp:
  102. raw = resp.read().decode("utf-8", errors="replace")
  103. return json.loads(raw)
  104. def load_cfst_rows(cfg, config_path_abs):
  105. cfst_cfg = cfg.get("cfst_local", {})
  106. config_dir = os.path.dirname(config_path_abs)
  107. work_dir = resolve_path(config_dir, cfst_cfg.get("work_dir", "./cfst"))
  108. binary_path = resolve_path(work_dir, cfst_cfg.get("binary", "./cfst"))
  109. result_file = resolve_path(work_dir, cfst_cfg.get("result_file", "result.csv"))
  110. encoding = str(cfst_cfg.get("encoding", "utf-8")).strip() or "utf-8"
  111. skip_run = bool(cfst_cfg.get("skip_run", False))
  112. timeout_sec = int(cfst_cfg.get("run_timeout_sec", 600))
  113. run_args = cfst_cfg.get("run_args", ["-o", os.path.basename(result_file)])
  114. if not isinstance(run_args, list):
  115. raise ValueError("cfst_local.run_args must be an array")
  116. command = [binary_path] + [str(x) for x in run_args]
  117. if not skip_run:
  118. completed = subprocess.run(
  119. command,
  120. cwd=work_dir,
  121. check=False,
  122. capture_output=True,
  123. text=True,
  124. encoding=encoding,
  125. errors="replace",
  126. timeout=timeout_sec,
  127. )
  128. if completed.returncode != 0:
  129. stderr = (completed.stderr or "").strip()
  130. stdout = (completed.stdout or "").strip()
  131. details = stderr or stdout or f"exit code {completed.returncode}"
  132. raise RuntimeError(f"cfst run failed: {details}")
  133. if not os.path.exists(result_file):
  134. raise RuntimeError(f"cfst result file not found: {result_file}")
  135. with open(result_file, "r", encoding=encoding, errors="replace", newline="") as f:
  136. reader = csv.reader(f)
  137. rows = [row for row in reader if any(str(col).strip() for col in row)]
  138. header_rows = int(cfst_cfg.get("header_rows", 1))
  139. if len(rows) <= header_rows:
  140. raise RuntimeError("cfst result has no data rows")
  141. columns_cfg = cfst_cfg.get("columns", {})
  142. if not isinstance(columns_cfg, dict):
  143. raise ValueError("cfst_local.columns must be an object")
  144. def col_index(name, default_index):
  145. raw = columns_cfg.get(name, default_index)
  146. try:
  147. idx = int(raw)
  148. except Exception as exc:
  149. raise ValueError(f"cfst_local.columns.{name} must be an integer") from exc
  150. if idx < 0:
  151. raise ValueError(f"cfst_local.columns.{name} must be >= 0")
  152. return idx
  153. ip_idx = col_index("ip", 0)
  154. sent_idx = col_index("sent", 1)
  155. received_idx = col_index("received", 2)
  156. loss_idx = col_index("loss_rate", 3)
  157. latency_idx = col_index("avg_latency", 4)
  158. speed_idx = col_index("download_speed", 5)
  159. region_idx = col_index("region", 6)
  160. out = []
  161. for row in rows[header_rows:]:
  162. if ip_idx >= len(row):
  163. continue
  164. domain = normalize_domain(row[ip_idx])
  165. if not domain:
  166. continue
  167. out.append(
  168. {
  169. "domain": domain,
  170. "ip": domain,
  171. "sent": row[sent_idx].strip() if sent_idx < len(row) else "",
  172. "received": row[received_idx].strip() if received_idx < len(row) else "",
  173. "loss_rate": row[loss_idx].strip() if loss_idx < len(row) else "",
  174. "avg_latency": row[latency_idx].strip() if latency_idx < len(row) else "",
  175. "download_speed": row[speed_idx].strip() if speed_idx < len(row) else "",
  176. "region": row[region_idx].strip() if region_idx < len(row) else "",
  177. }
  178. )
  179. if not out:
  180. raise RuntimeError("cfst result parsed to zero valid rows")
  181. return out
  182. def flatten_values(value):
  183. out = []
  184. if isinstance(value, str):
  185. out.append(value)
  186. elif isinstance(value, list):
  187. for item in value:
  188. out.extend(flatten_values(item))
  189. elif isinstance(value, dict):
  190. for item in value.values():
  191. out.extend(flatten_values(item))
  192. return out
  193. def get_by_json_path(data, path):
  194. cur = data
  195. for part in path.split("."):
  196. if isinstance(cur, dict) and part in cur:
  197. cur = cur[part]
  198. else:
  199. return None
  200. return cur
  201. def get_values_by_path(data, path):
  202. parts = path.split(".")
  203. def walk(cur, idx):
  204. if idx >= len(parts):
  205. return [cur]
  206. part = parts[idx]
  207. if part.endswith("[]"):
  208. key = part[:-2]
  209. if isinstance(cur, dict):
  210. arr = cur.get(key)
  211. else:
  212. arr = None
  213. if not isinstance(arr, list):
  214. return []
  215. out = []
  216. for item in arr:
  217. out.extend(walk(item, idx + 1))
  218. return out
  219. if isinstance(cur, dict) and part in cur:
  220. return walk(cur[part], idx + 1)
  221. return []
  222. return walk(data, 0)
  223. def parse_domains(payload, parser_cfg):
  224. domains = []
  225. for p in parser_cfg.get("field_paths", []):
  226. values = get_values_by_path(payload, p)
  227. domains.extend(flatten_values(values))
  228. for p in parser_cfg.get("json_paths", []):
  229. v = get_by_json_path(payload, p)
  230. if v is not None:
  231. domains.extend(flatten_values(v))
  232. if not domains:
  233. regex_s = parser_cfg.get("regex", r"[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
  234. text = json.dumps(payload, ensure_ascii=True)
  235. domains.extend(re.findall(regex_s, text))
  236. clean = []
  237. seen = set()
  238. for d in domains:
  239. d = str(d).strip().lower().rstrip(".")
  240. if (DOMAIN_RE.match(d) or IPV4_RE.match(d)) and d not in seen:
  241. seen.add(d)
  242. clean.append(d)
  243. return clean
  244. def parse_timezone(tz_raw):
  245. if tz_raw is None:
  246. return dt.timezone.utc
  247. s = str(tz_raw).strip().upper()
  248. if s in {"", "UTC", "Z", "+00:00", "+0000"}:
  249. return dt.timezone.utc
  250. m = re.match(r"^([+-])(\d{2}):?(\d{2})$", s)
  251. if not m:
  252. raise ValueError(f"invalid created_time_timezone: {tz_raw}")
  253. sign = 1 if m.group(1) == "+" else -1
  254. hh = int(m.group(2))
  255. mm = int(m.group(3))
  256. if hh > 23 or mm > 59:
  257. raise ValueError(f"invalid created_time_timezone offset: {tz_raw}")
  258. return dt.timezone(sign * dt.timedelta(hours=hh, minutes=mm))
  259. def parse_created_time(value, formats, timezone):
  260. if value is None:
  261. return None
  262. s = str(value).strip()
  263. if not s:
  264. return None
  265. for fmt in formats:
  266. try:
  267. parsed = dt.datetime.strptime(s, fmt)
  268. if parsed.tzinfo is None:
  269. parsed = parsed.replace(tzinfo=timezone)
  270. return parsed.astimezone(dt.timezone.utc)
  271. except Exception:
  272. continue
  273. try:
  274. iso_text = s.replace("Z", "+00:00")
  275. parsed = dt.datetime.fromisoformat(iso_text)
  276. if parsed.tzinfo is None:
  277. parsed = parsed.replace(tzinfo=timezone)
  278. return parsed.astimezone(dt.timezone.utc)
  279. except Exception:
  280. return None
  281. def normalize_domain(value):
  282. if value is None:
  283. return ""
  284. return str(value).strip().lower().rstrip(".")
  285. def to_float_or_none(value):
  286. try:
  287. f = float(value)
  288. if not math.isfinite(f):
  289. return None
  290. return f
  291. except Exception:
  292. return None
  293. def resolve_field(record, field_name, field_map):
  294. path = field_map.get(field_name)
  295. if not path:
  296. raise ValueError(f"field '{field_name}' is not registered in record_mapping.field_map")
  297. if not isinstance(record, dict):
  298. return None
  299. return get_by_json_path(record, path)
  300. def extract_records(payload, record_mapping):
  301. records_path = str(record_mapping.get("records_path", "")).strip()
  302. raw = get_values_by_path(payload, records_path)
  303. return [x for x in raw if isinstance(x, dict)]
  304. def validate_config(cfg):
  305. source_type = get_source_type(cfg)
  306. if source_type not in {"api", "cfst_local"}:
  307. raise ValueError("source.type must be 'api' or 'cfst_local'")
  308. output_cfg = cfg.get("output", {})
  309. if output_cfg and not isinstance(output_cfg, dict):
  310. raise ValueError("output must be an object")
  311. if source_type == "cfst_local":
  312. cfst_cfg = cfg.get("cfst_local")
  313. if not isinstance(cfst_cfg, dict):
  314. raise ValueError("cfst_local is required and must be an object when source.type=cfst_local")
  315. work_dir = str(cfst_cfg.get("work_dir", "")).strip()
  316. if not work_dir:
  317. raise ValueError("cfst_local.work_dir is required")
  318. binary = str(cfst_cfg.get("binary", "")).strip()
  319. if not binary:
  320. raise ValueError("cfst_local.binary is required")
  321. result_file = str(cfst_cfg.get("result_file", "")).strip()
  322. if not result_file:
  323. raise ValueError("cfst_local.result_file is required")
  324. run_args = cfst_cfg.get("run_args", [])
  325. if not isinstance(run_args, list):
  326. raise ValueError("cfst_local.run_args must be an array")
  327. columns_cfg = cfst_cfg.get("columns", {})
  328. if columns_cfg and not isinstance(columns_cfg, dict):
  329. raise ValueError("cfst_local.columns must be an object")
  330. return
  331. # source.type == "api" 校验
  332. api_cfg = cfg.get("api")
  333. if not isinstance(api_cfg, dict):
  334. raise ValueError("api is required and must be an object when source.type=api")
  335. api_url = str(api_cfg.get("url", "")).strip()
  336. if not api_url:
  337. raise ValueError("api.url is required when source.type=api")
  338. parser_cfg = cfg.get("parser")
  339. if parser_cfg is not None and not isinstance(parser_cfg, dict):
  340. raise ValueError("parser must be an object")
  341. selection_cfg = cfg.get("selection")
  342. if selection_cfg is not None and not isinstance(selection_cfg, dict):
  343. raise ValueError("selection must be an object")
  344. domain_filter_cfg = cfg.get("domain_filter")
  345. if domain_filter_cfg is not None and not isinstance(domain_filter_cfg, dict):
  346. raise ValueError("domain_filter must be an object")
  347. record_mapping = cfg.get("record_mapping")
  348. if not isinstance(record_mapping, dict):
  349. raise ValueError("record_mapping is required and must be an object")
  350. records_path = str(record_mapping.get("records_path", "")).strip()
  351. if not records_path:
  352. raise ValueError("record_mapping.records_path is required")
  353. field_map = record_mapping.get("field_map")
  354. if not isinstance(field_map, dict) or not field_map:
  355. raise ValueError("record_mapping.field_map is required and must be a non-empty object")
  356. for key, path in field_map.items():
  357. if not str(key).strip() or not str(path).strip():
  358. raise ValueError("record_mapping.field_map contains empty field name or path")
  359. for required in ["domain", "created_at"]:
  360. if required not in field_map:
  361. raise ValueError(f"record_mapping.field_map.{required} is required")
  362. created_time_formats = record_mapping.get("created_time_formats")
  363. if not isinstance(created_time_formats, list) or not created_time_formats:
  364. raise ValueError("record_mapping.created_time_formats is required and must be a non-empty array")
  365. for fmt in created_time_formats:
  366. if not str(fmt).strip():
  367. raise ValueError("record_mapping.created_time_formats contains empty format")
  368. parse_timezone(record_mapping.get("created_time_timezone", "UTC"))
  369. def ensure_field_registered(field_name, where):
  370. if field_name not in field_map:
  371. raise ValueError(f"{where}: field '{field_name}' is not in record_mapping.field_map")
  372. record_filter = cfg.get("record_filter", {})
  373. if record_filter.get("enabled", False):
  374. rules = record_filter.get("exclude_if_any", [])
  375. if not isinstance(rules, list):
  376. raise ValueError("record_filter.exclude_if_any must be an array")
  377. for i, rule in enumerate(rules):
  378. if not isinstance(rule, dict):
  379. raise ValueError(f"record_filter.exclude_if_any[{i}] must be an object")
  380. field_name = str(rule.get("field", "")).strip()
  381. if not field_name:
  382. raise ValueError(f"record_filter.exclude_if_any[{i}].field is required")
  383. ensure_field_registered(field_name, f"record_filter.exclude_if_any[{i}]")
  384. has_matcher = any(k in rule for k in ["contains", "equals", "regex"])
  385. if not has_matcher:
  386. raise ValueError(f"record_filter.exclude_if_any[{i}] must include one of contains/equals/regex")
  387. scoring = cfg.get("scoring", {})
  388. if scoring.get("enabled", False):
  389. strategy = str(scoring.get("strategy", "")).strip()
  390. if strategy not in {"weighted_average", "lexicographic"}:
  391. raise ValueError("scoring.strategy must be 'weighted_average' or 'lexicographic'")
  392. within_hours = to_float_or_none(scoring.get("within_hours", 0))
  393. if within_hours is None or within_hours < 0:
  394. raise ValueError("scoring.within_hours must be a non-negative number (0 = disabled)")
  395. if strategy == "weighted_average":
  396. weighted_fields = scoring.get("weighted_fields")
  397. if not isinstance(weighted_fields, list) or not weighted_fields:
  398. raise ValueError("scoring.weighted_fields is required for weighted_average strategy")
  399. for i, item in enumerate(weighted_fields):
  400. if not isinstance(item, dict):
  401. raise ValueError(f"scoring.weighted_fields[{i}] must be an object")
  402. field_name = str(item.get("field", "")).strip()
  403. if not field_name:
  404. raise ValueError(f"scoring.weighted_fields[{i}].field is required")
  405. ensure_field_registered(field_name, f"scoring.weighted_fields[{i}]")
  406. weight = to_float_or_none(item.get("weight"))
  407. if weight is None or weight <= 0:
  408. raise ValueError(f"scoring.weighted_fields[{i}].weight must be > 0")
  409. if strategy == "lexicographic":
  410. lex_fields = scoring.get("lexicographic_fields")
  411. if not isinstance(lex_fields, list) or not lex_fields:
  412. raise ValueError("scoring.lexicographic_fields is required for lexicographic strategy")
  413. for i, item in enumerate(lex_fields):
  414. if isinstance(item, str):
  415. field_name = item.strip()
  416. order = ""
  417. elif isinstance(item, dict):
  418. field_name = str(item.get("field", "")).strip()
  419. order = str(item.get("order", "")).strip().lower()
  420. else:
  421. raise ValueError(f"scoring.lexicographic_fields[{i}] must be string or object")
  422. if not field_name:
  423. raise ValueError(f"scoring.lexicographic_fields[{i}] field is required")
  424. ensure_field_registered(field_name, f"scoring.lexicographic_fields[{i}]")
  425. if order and order not in {"asc", "desc"}:
  426. raise ValueError(f"scoring.lexicographic_fields[{i}].order must be asc or desc")
  427. tie_breakers = scoring.get("tie_breakers", [])
  428. if tie_breakers is not None:
  429. if not isinstance(tie_breakers, list):
  430. raise ValueError("scoring.tie_breakers must be an array")
  431. for i, item in enumerate(tie_breakers):
  432. if not isinstance(item, dict):
  433. raise ValueError(f"scoring.tie_breakers[{i}] must be an object")
  434. field_name = str(item.get("field", "")).strip()
  435. order = str(item.get("order", "")).strip().lower()
  436. if not field_name:
  437. raise ValueError(f"scoring.tie_breakers[{i}].field is required")
  438. if order not in {"asc", "desc"}:
  439. raise ValueError(f"scoring.tie_breakers[{i}].order must be asc or desc")
  440. ensure_field_registered(field_name, f"scoring.tie_breakers[{i}]")
  441. def rule_matches(value, rule):
  442. if value is None or not isinstance(rule, dict):
  443. return False
  444. values = flatten_values(value)
  445. if not values:
  446. values = [value]
  447. case_sensitive = bool(rule.get("case_sensitive", False))
  448. if "contains" in rule:
  449. needle = str(rule.get("contains", ""))
  450. if not needle:
  451. return False
  452. for item in values:
  453. hay = str(item)
  454. if case_sensitive:
  455. if needle in hay:
  456. return True
  457. else:
  458. if needle.lower() in hay.lower():
  459. return True
  460. return False
  461. if "equals" in rule:
  462. target = str(rule.get("equals", ""))
  463. for item in values:
  464. item_s = str(item)
  465. if case_sensitive:
  466. if item_s == target:
  467. return True
  468. else:
  469. if item_s.lower() == target.lower():
  470. return True
  471. return False
  472. if "regex" in rule:
  473. pattern = str(rule.get("regex", ""))
  474. if not pattern:
  475. return False
  476. flags = 0 if case_sensitive else re.IGNORECASE
  477. try:
  478. rx = re.compile(pattern, flags)
  479. except Exception:
  480. return False
  481. for item in values:
  482. if rx.search(str(item)):
  483. return True
  484. return False
  485. return False
  486. def collect_excluded_domains(records, field_map, record_filter_cfg):
  487. if not record_filter_cfg.get("enabled", False):
  488. return set()
  489. rules = record_filter_cfg.get("exclude_if_any", [])
  490. if not rules:
  491. return set()
  492. blocked = set()
  493. for record in records:
  494. domain = normalize_domain(resolve_field(record, "domain", field_map))
  495. if not domain:
  496. continue
  497. for rule in rules:
  498. field_name = str(rule.get("field", "")).strip()
  499. if not field_name:
  500. continue
  501. value = resolve_field(record, field_name, field_map)
  502. if rule_matches(value, rule):
  503. blocked.add(domain)
  504. break
  505. return blocked
  506. def build_lexicographic_descriptors(scoring_cfg, prefer_lower):
  507. out = []
  508. for item in scoring_cfg.get("lexicographic_fields", []):
  509. if isinstance(item, str):
  510. field_name = item.strip()
  511. order = "asc" if prefer_lower else "desc"
  512. else:
  513. field_name = str(item.get("field", "")).strip()
  514. order = str(item.get("order", "")).strip().lower()
  515. if not order:
  516. order = "asc" if prefer_lower else "desc"
  517. out.append({"field": field_name, "order": order})
  518. return out
  519. def parse_scored_records(records, field_map, record_mapping_cfg, scoring_cfg):
  520. if not scoring_cfg.get("enabled", False):
  521. return []
  522. strategy = str(scoring_cfg.get("strategy", "weighted_average")).strip()
  523. prefer_lower = bool(scoring_cfg.get("prefer_lower", False))
  524. timezone = parse_timezone(record_mapping_cfg.get("created_time_timezone", "UTC"))
  525. time_formats = [str(x) for x in record_mapping_cfg.get("created_time_formats", [])]
  526. weighted_fields = scoring_cfg.get("weighted_fields", []) if strategy == "weighted_average" else []
  527. lex_descriptors = build_lexicographic_descriptors(scoring_cfg, prefer_lower) if strategy == "lexicographic" else []
  528. needed_fields = set()
  529. for item in weighted_fields:
  530. needed_fields.add(str(item.get("field", "")).strip())
  531. for item in lex_descriptors:
  532. needed_fields.add(str(item.get("field", "")).strip())
  533. for item in scoring_cfg.get("tie_breakers", []):
  534. needed_fields.add(str(item.get("field", "")).strip())
  535. needed_fields.discard("domain")
  536. needed_fields.discard("created_at")
  537. out = []
  538. for record in records:
  539. domain = normalize_domain(resolve_field(record, "domain", field_map))
  540. if not domain:
  541. continue
  542. created_raw = resolve_field(record, "created_at", field_map)
  543. created_at = parse_created_time(created_raw, time_formats, timezone)
  544. field_values = {}
  545. for field_name in needed_fields:
  546. field_values[field_name] = resolve_field(record, field_name, field_map)
  547. score_value = None
  548. scores = []
  549. lex_values = []
  550. if strategy == "weighted_average":
  551. total = 0.0
  552. total_weight = 0.0
  553. missing = False
  554. for item in weighted_fields:
  555. field_name = str(item.get("field", "")).strip()
  556. weight = float(item.get("weight"))
  557. raw_v = resolve_field(record, field_name, field_map)
  558. val = to_float_or_none(raw_v)
  559. scores.append(val)
  560. if val is None:
  561. missing = True
  562. continue
  563. # 支持字段级单独指定极性(prefer_lower 或 order="desc"/"asc")
  564. field_lower = item.get("prefer_lower", prefer_lower)
  565. if str(item.get("order", "")).lower() == "desc":
  566. field_lower = False
  567. elif str(item.get("order", "")).lower() == "asc":
  568. field_lower = True
  569. effective_val = val if (bool(field_lower) == prefer_lower) else -val
  570. total += effective_val * weight
  571. total_weight += weight
  572. if not missing and total_weight > 0:
  573. score_value = total / total_weight
  574. if strategy == "lexicographic":
  575. for item in lex_descriptors:
  576. field_name = item["field"]
  577. order = item["order"]
  578. raw_v = resolve_field(record, field_name, field_map)
  579. num_v = to_float_or_none(raw_v)
  580. v = num_v if num_v is not None else raw_v
  581. lex_values.append({"field": field_name, "value": v, "order": order})
  582. scores.append(v)
  583. out.append(
  584. {
  585. "domain": domain,
  586. "created_at": created_at,
  587. "created_raw": created_raw,
  588. "scores": scores,
  589. "score_value": score_value,
  590. "lex_values": lex_values,
  591. "field_values": field_values,
  592. "raw_record": record,
  593. }
  594. )
  595. return out
  596. def cmp_scalar(a, b, order):
  597. a_none = a is None
  598. b_none = b is None
  599. if a_none and b_none:
  600. return 0
  601. if a_none:
  602. return 1
  603. if b_none:
  604. return -1
  605. if isinstance(a, dt.datetime):
  606. a = a.timestamp()
  607. if isinstance(b, dt.datetime):
  608. b = b.timestamp()
  609. a_num = to_float_or_none(a)
  610. b_num = to_float_or_none(b)
  611. if a_num is not None and b_num is not None:
  612. if a_num < b_num:
  613. base = -1
  614. elif a_num > b_num:
  615. base = 1
  616. else:
  617. base = 0
  618. else:
  619. a_s = str(a).lower()
  620. b_s = str(b).lower()
  621. if a_s < b_s:
  622. base = -1
  623. elif a_s > b_s:
  624. base = 1
  625. else:
  626. base = 0
  627. return base if order == "asc" else -base
  628. def get_sort_field_value(record, field_name):
  629. if field_name == "domain":
  630. return record.get("domain")
  631. if field_name == "created_at":
  632. return record.get("created_at")
  633. return record.get("field_values", {}).get(field_name)
  634. def rank_scored_records(records, scoring_cfg):
  635. if not records:
  636. return []
  637. within_hours = float(scoring_cfg.get("within_hours", 0))
  638. strategy = str(scoring_cfg.get("strategy", "weighted_average")).strip()
  639. prefer_lower = bool(scoring_cfg.get("prefer_lower", False))
  640. tie_breakers = scoring_cfg.get("tie_breakers", [])
  641. if within_hours > 0:
  642. now = dt.datetime.now(dt.timezone.utc)
  643. cutoff = now - dt.timedelta(hours=within_hours)
  644. recent = [r for r in records if r.get("created_at") is not None and r["created_at"] >= cutoff]
  645. candidates = recent if recent else records
  646. else:
  647. candidates = records
  648. default_lex_order = "asc" if prefer_lower else "desc"
  649. def compare(a, b):
  650. if strategy == "weighted_average":
  651. order = "asc" if prefer_lower else "desc"
  652. c = cmp_scalar(a.get("score_value"), b.get("score_value"), order)
  653. if c != 0:
  654. return c
  655. elif strategy == "lexicographic":
  656. a_lex = a.get("lex_values", [])
  657. b_lex = b.get("lex_values", [])
  658. n = max(len(a_lex), len(b_lex))
  659. for i in range(n):
  660. av = a_lex[i]["value"] if i < len(a_lex) else None
  661. bv = b_lex[i]["value"] if i < len(b_lex) else None
  662. order = default_lex_order
  663. if i < len(a_lex) and a_lex[i].get("order"):
  664. order = a_lex[i]["order"]
  665. c = cmp_scalar(av, bv, order)
  666. if c != 0:
  667. return c
  668. for item in tie_breakers:
  669. field_name = str(item.get("field", "")).strip()
  670. order = str(item.get("order", "asc")).strip().lower()
  671. av = get_sort_field_value(a, field_name)
  672. bv = get_sort_field_value(b, field_name)
  673. c = cmp_scalar(av, bv, order)
  674. if c != 0:
  675. return c
  676. return cmp_scalar(a.get("domain"), b.get("domain"), "asc")
  677. return sorted(candidates, key=functools.cmp_to_key(compare))
  678. def apply_filter(domains, filter_cfg):
  679. include_suffixes = [s.lower() for s in filter_cfg.get("include_suffixes", []) if s]
  680. exclude_regex = [re.compile(x) for x in filter_cfg.get("exclude_regex", []) if x]
  681. out = []
  682. for d in domains:
  683. if include_suffixes and not any(d.endswith(s) for s in include_suffixes):
  684. continue
  685. if any(rx.search(d) for rx in exclude_regex):
  686. continue
  687. out.append(d)
  688. return out
  689. def choose_top_candidate_domains(filtered_domains, top_n, ranked_scored):
  690. if ranked_scored:
  691. domains_by_score = [x["domain"] for x in ranked_scored]
  692. return domains_by_score[:top_n]
  693. return filtered_domains[:top_n]
  694. def text_or_blank(value):
  695. if value is None:
  696. return ""
  697. return str(value).strip()
  698. def set_if_nonempty_text(obj, key, value):
  699. text = text_or_blank(value)
  700. if text:
  701. obj[key] = text
  702. def base_top_candidate(domain, source_type):
  703. candidate = {"domain": domain, "source_type": source_type}
  704. if IPV4_RE.match(domain):
  705. candidate["ip"] = domain
  706. return candidate
  707. def maybe_resolve_field(record, field_name, field_map):
  708. if not isinstance(record, dict):
  709. return None
  710. if field_name not in field_map:
  711. return None
  712. return resolve_field(record, field_name, field_map)
  713. def build_cfst_candidate(row):
  714. domain = row.get("domain", "")
  715. candidate = base_top_candidate(domain=domain, source_type="cfst_local")
  716. set_if_nonempty_text(candidate, "ip", row.get("ip") or domain)
  717. set_if_nonempty_text(candidate, "loss_rate", row.get("loss_rate"))
  718. set_if_nonempty_text(candidate, "avg_latency", row.get("avg_latency"))
  719. set_if_nonempty_text(candidate, "download_speed", row.get("download_speed"))
  720. set_if_nonempty_text(candidate, "region", row.get("region"))
  721. return candidate
  722. def build_api_candidate(domain, record, field_map, scored_record=None):
  723. candidate = base_top_candidate(domain=domain, source_type="api")
  724. if record:
  725. set_if_nonempty_text(candidate, "created_raw", maybe_resolve_field(record, "created_at", field_map))
  726. set_if_nonempty_text(candidate, "avg_latency", maybe_resolve_field(record, "avg_latency", field_map))
  727. set_if_nonempty_text(candidate, "loss_rate", maybe_resolve_field(record, "avg_pkg_lost_rate", field_map))
  728. set_if_nonempty_text(candidate, "download_speed", maybe_resolve_field(record, "download_speed", field_map))
  729. location_country = text_or_blank(maybe_resolve_field(record, "location_country", field_map))
  730. location_city = text_or_blank(maybe_resolve_field(record, "location_city", field_map))
  731. region = "/".join([x for x in [location_country, location_city] if x])
  732. set_if_nonempty_text(candidate, "region", region)
  733. if scored_record:
  734. if scored_record.get("score_value") is not None:
  735. candidate["score_value"] = scored_record.get("score_value")
  736. scores = list(scored_record.get("scores", []))
  737. if scores:
  738. candidate["scores"] = scores
  739. if "created_raw" not in candidate:
  740. set_if_nonempty_text(candidate, "created_raw", scored_record.get("created_raw"))
  741. return candidate
  742. def build_top_candidates(source_type, candidate_domains, cfst_rows=None, records=None, field_map=None, ranked_scored=None):
  743. if source_type == "cfst_local":
  744. row_map = {}
  745. for row in cfst_rows or []:
  746. domain = row.get("domain", "")
  747. if domain and domain not in row_map:
  748. row_map[domain] = row
  749. return [build_cfst_candidate(row_map.get(d, {"domain": d, "ip": d})) for d in candidate_domains]
  750. ranked_map = {}
  751. for item in ranked_scored or []:
  752. domain = item.get("domain", "")
  753. if domain and domain not in ranked_map:
  754. ranked_map[domain] = item
  755. record_map = {}
  756. for record in records or []:
  757. domain = normalize_domain(maybe_resolve_field(record, "domain", field_map or {}))
  758. if domain and domain not in record_map:
  759. record_map[domain] = record
  760. out = []
  761. for domain in candidate_domains:
  762. scored_record = ranked_map.get(domain)
  763. record = None
  764. if scored_record:
  765. record = scored_record.get("raw_record")
  766. if record is None:
  767. record = record_map.get(domain)
  768. out.append(build_api_candidate(domain, record, field_map or {}, scored_record=scored_record))
  769. return out
  770. def run_notify(cmd, domain, status, error=""):
  771. if not cmd:
  772. return
  773. env = os.environ.copy()
  774. env["AUTODOMAIN"] = str(domain or "")
  775. env["AUTODOMAIN_STATUS"] = str(status or "")
  776. env["AUTODOMAIN_ERROR"] = str(error or "")
  777. try:
  778. subprocess.run(
  779. ["/bin/sh", "-c", cmd],
  780. env=env,
  781. stdout=sys.stderr,
  782. stderr=sys.stderr,
  783. timeout=15,
  784. check=False,
  785. )
  786. except Exception as exc:
  787. print(f"WARN: notify command execution failed: {exc}", file=sys.stderr)
  788. def choose_domain(filtered_domains, top_n, ranked_scored, last_good="", switch_margin_pct=0.0):
  789. if ranked_scored:
  790. top_domains = choose_top_candidate_domains(filtered_domains, top_n, ranked_scored)
  791. if top_domains:
  792. best_domain = top_domains[0]
  793. # 迟滞防抖:当新最优候选相比当前仍在候选池中的 last_good 领先幅度不足 switch_margin_pct 时,保持 last_good
  794. if switch_margin_pct > 0 and last_good and best_domain != last_good:
  795. last_cand = next((r for r in ranked_scored if r.get("domain") == last_good), None)
  796. best_cand = next((r for r in ranked_scored if r.get("domain") == best_domain), None)
  797. if last_cand and best_cand:
  798. best_score = to_float_or_none(best_cand.get("score_value"))
  799. last_score = to_float_or_none(last_cand.get("score_value"))
  800. if best_score is not None and last_score is not None and last_score != 0:
  801. improvement_pct = abs((last_score - best_score) / last_score) * 100.0
  802. if improvement_pct < switch_margin_pct:
  803. top_reordered = [last_good] + [d for d in top_domains if d != last_good]
  804. return last_good, top_reordered[:top_n]
  805. return best_domain, top_domains
  806. if filtered_domains:
  807. return filtered_domains[0], filtered_domains[:top_n]
  808. return None, []
  809. def build_output_settings(output_cfg, config_path_abs):
  810. # H3: legacy keys (current_domain_file/current_domain_json/substore_vars_file)
  811. # were ambiguous and drifted between server/local configs. Refuse to start
  812. # so operators migrate to the unified key set documented in CLAUDE.md.
  813. legacy_key_map = {
  814. "current_domain_file": "selected_value_file",
  815. "current_domain_json": "selected_value_json",
  816. "substore_vars_file": "export_vars_file",
  817. }
  818. legacy_in_use = [k for k in legacy_key_map if k in output_cfg]
  819. if legacy_in_use:
  820. renames = ", ".join(
  821. "{} -> {}".format(k, legacy_key_map[k]) for k in legacy_in_use
  822. )
  823. raise ValueError(
  824. "deprecated output keys in config: {}. Rename them: {}.".format(
  825. ", ".join(legacy_in_use), renames
  826. )
  827. )
  828. runtime_dir_cfg = output_cfg.get("runtime_dir", "./runtime")
  829. runtime_dir = resolve_path(os.path.dirname(config_path_abs), runtime_dir_cfg)
  830. selected_text_name = output_cfg.get("selected_value_file", "current_domain.txt")
  831. selected_json_name = output_cfg.get("selected_value_json", "current_domain.json")
  832. state_name = output_cfg.get("state_file", "state.json")
  833. vars_name = output_cfg.get("export_vars_file", "substore_vars.json")
  834. return {
  835. "runtime_dir": runtime_dir,
  836. "selected_text_path": os.path.join(runtime_dir, selected_text_name),
  837. "selected_json_path": os.path.join(runtime_dir, selected_json_name),
  838. "state_path": os.path.join(runtime_dir, state_name),
  839. "vars_path": os.path.join(runtime_dir, vars_name),
  840. "selected_json_key": str(output_cfg.get("selected_value_json_key", "domain")).strip() or "domain",
  841. "state_last_good_key": str(output_cfg.get("state_last_good_key", "last_good_domain")).strip() or "last_good_domain",
  842. "vars_value_key": str(output_cfg.get("substore_value_key", "AUTO_DOMAIN")).strip() or "AUTO_DOMAIN",
  843. }
  844. def print_output_settings(config_path_abs, cfg):
  845. output_cfg = cfg.get("output", {})
  846. settings = build_output_settings(output_cfg, config_path_abs)
  847. print(json.dumps(settings, ensure_ascii=True))
  848. def main():
  849. ap = argparse.ArgumentParser(description="Auto select preferred endpoint value")
  850. ap.add_argument("--config", default="config.server.json", help="Path to config JSON")
  851. ap.add_argument(
  852. "--print-output-settings",
  853. action="store_true",
  854. help="Print resolved output settings as JSON and exit",
  855. )
  856. ap.add_argument(
  857. "--result-json-file",
  858. default="",
  859. help="Optional path to output machine-readable JSON result (decouples IPC from stdout)",
  860. )
  861. args = ap.parse_args()
  862. config_path_abs = os.path.abspath(args.config)
  863. if not os.path.exists(config_path_abs):
  864. print(json.dumps({"status": "error", "error": f"config file not found: {config_path_abs}"}, ensure_ascii=True), file=sys.stderr)
  865. sys.exit(1)
  866. cfg = read_json_file(config_path_abs)
  867. try:
  868. validate_config(cfg)
  869. except Exception as e:
  870. print(json.dumps({"status": "error", "error": f"invalid config: {e}"}, ensure_ascii=True), file=sys.stderr)
  871. sys.exit(1)
  872. if args.print_output_settings:
  873. print_output_settings(config_path_abs, cfg)
  874. return
  875. output_cfg = cfg.get("output", {})
  876. output_settings = build_output_settings(output_cfg, config_path_abs)
  877. notify_cfg = cfg.get("notify", {})
  878. selected_text_file = output_settings["selected_text_path"]
  879. selected_json_file = output_settings["selected_json_path"]
  880. state_file = output_settings["state_path"]
  881. vars_file = output_settings["vars_path"]
  882. selected_json_key = output_settings["selected_json_key"]
  883. state_last_good_key = output_settings["state_last_good_key"]
  884. vars_value_key = output_settings["vars_value_key"]
  885. state = read_json_file(state_file, default={})
  886. last_good = state.get(state_last_good_key, "")
  887. source_type = get_source_type(cfg)
  888. try:
  889. top_n = int(cfg.get("selection", {}).get("top_n", 3))
  890. payload = None
  891. if source_type == "cfst_local":
  892. cfst_rows = load_cfst_rows(cfg, config_path_abs)
  893. parsed = [row["domain"] for row in cfst_rows]
  894. filtered = apply_filter(parsed, cfg.get("domain_filter", {}))
  895. filtered_set = set(filtered)
  896. cfst_rows = [row for row in cfst_rows if row["domain"] in filtered_set]
  897. if not cfst_rows:
  898. raise RuntimeError("No valid IP available from cfst result after filtering")
  899. # 若未显式禁用下载测速(未配置 -dd),优先过滤掉测速失败的 0.00 下载速度记录
  900. cfst_cfg = cfg.get("cfst_local", {})
  901. disable_download = "-dd" in [str(a).strip() for a in cfst_cfg.get("run_args", [])]
  902. valid_speed_rows = [
  903. row for row in cfst_rows
  904. if to_float_or_none(row.get("download_speed")) is not None
  905. and float(to_float_or_none(row.get("download_speed"))) > 0.0
  906. ]
  907. if not disable_download and not valid_speed_rows and last_good:
  908. selected = None
  909. candidate_domains = []
  910. top_candidates = []
  911. else:
  912. eligible_rows = valid_speed_rows if (not disable_download and valid_speed_rows) else cfst_rows
  913. selected = eligible_rows[0]["domain"]
  914. candidate_domains = [row["domain"] for row in eligible_rows[:top_n]]
  915. top_candidates = build_top_candidates("cfst_local", candidate_domains, cfst_rows=eligible_rows)
  916. else:
  917. payload = fetch_api_json(cfg)
  918. parsed = parse_domains(payload, cfg.get("parser", {}))
  919. filtered = apply_filter(parsed, cfg.get("domain_filter", {}))
  920. record_mapping_cfg = cfg.get("record_mapping", {})
  921. field_map = record_mapping_cfg.get("field_map", {})
  922. records = extract_records(payload, record_mapping_cfg)
  923. record_filter_cfg = cfg.get("record_filter", {})
  924. blocked_domains = collect_excluded_domains(records, field_map, record_filter_cfg)
  925. if blocked_domains:
  926. filtered = [d for d in filtered if d not in blocked_domains]
  927. scoring_cfg = cfg.get("scoring", {})
  928. scored_records = parse_scored_records(records, field_map, record_mapping_cfg, scoring_cfg)
  929. filtered_set = set(filtered)
  930. scored_records = [r for r in scored_records if r["domain"] in filtered_set]
  931. switch_margin_pct = to_float_or_none(scoring_cfg.get("switch_margin_pct")) or 0.0
  932. selected, candidate_domains = choose_domain(
  933. filtered, top_n, ranked_scored, last_good=last_good, switch_margin_pct=switch_margin_pct
  934. )
  935. top_candidates = build_top_candidates(
  936. "api",
  937. candidate_domains,
  938. records=records,
  939. field_map=field_map,
  940. ranked_scored=ranked_scored,
  941. )
  942. status = "ok"
  943. if not selected and last_good:
  944. # 复检 last_good: 确保已被拉黑的故障节点不会借回退复活
  945. if source_type == "api" and apply_filter([last_good], cfg.get("domain_filter", {})) != [last_good]:
  946. last_good = ""
  947. else:
  948. selected = last_good
  949. status = "fallback_last_good"
  950. if not selected:
  951. if source_type == "cfst_local":
  952. raise RuntimeError("No valid IP available from cfst and no fallback in state")
  953. raise RuntimeError("No valid domain available from API and no fallback in state")
  954. write_text_file(selected_text_file, selected + "\n")
  955. current_json = {
  956. selected_json_key: selected,
  957. "updated_at": utc_now_iso(),
  958. "status": status,
  959. "source_type": source_type,
  960. "source_count": len(parsed),
  961. "top_candidates": top_candidates,
  962. }
  963. write_json_file(selected_json_file, current_json)
  964. write_json_file(
  965. vars_file,
  966. {
  967. vars_value_key: selected,
  968. "UPDATED_AT": current_json["updated_at"],
  969. "STATUS": status,
  970. },
  971. )
  972. new_state = {
  973. "updated_at": current_json["updated_at"],
  974. state_last_good_key: selected,
  975. "status": status,
  976. "source_count": len(parsed),
  977. "source_type": source_type,
  978. }
  979. write_json_file(state_file, new_state)
  980. run_notify(notify_cfg.get("command", ""), selected, status)
  981. if args.result_json_file:
  982. write_json_file(args.result_json_file, current_json)
  983. print(json.dumps(current_json, ensure_ascii=True))
  984. except Exception as e:
  985. now = utc_now_iso()
  986. err_state = {
  987. "updated_at": now,
  988. "status": "error",
  989. "error": str(e),
  990. state_last_good_key: last_good,
  991. "source_type": source_type,
  992. }
  993. write_json_file(state_file, err_state)
  994. if last_good and source_type == "api":
  995. if apply_filter([last_good], cfg.get("domain_filter", {})) != [last_good]:
  996. last_good = ""
  997. if last_good:
  998. write_text_file(selected_text_file, last_good + "\n")
  999. write_json_file(
  1000. selected_json_file,
  1001. {
  1002. selected_json_key: last_good,
  1003. "updated_at": now,
  1004. "status": "error_use_last_good",
  1005. "error": str(e),
  1006. "source_type": source_type,
  1007. },
  1008. )
  1009. write_json_file(
  1010. vars_file,
  1011. {
  1012. vars_value_key: last_good,
  1013. "UPDATED_AT": now,
  1014. "STATUS": "error_use_last_good",
  1015. },
  1016. )
  1017. fallback_res = {
  1018. selected_json_key: last_good,
  1019. "status": "error_use_last_good",
  1020. "error": str(e),
  1021. }
  1022. run_notify(notify_cfg.get("command", ""), last_good, "error_use_last_good")
  1023. if args.result_json_file:
  1024. write_json_file(args.result_json_file, fallback_res)
  1025. print(json.dumps(fallback_res, ensure_ascii=True))
  1026. return
  1027. err_res = {"status": "error", "error": str(e)}
  1028. run_notify(notify_cfg.get("command", ""), "", "error")
  1029. if args.result_json_file:
  1030. write_json_file(args.result_json_file, err_res)
  1031. print(json.dumps(err_res, ensure_ascii=True), file=sys.stderr)
  1032. sys.exit(1)
  1033. if __name__ == "__main__":
  1034. main()