scoring.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. import datetime as dt
  2. import functools
  3. from .config import parse_timezone, to_float_or_none
  4. from .filter import IPV4_RE, normalize_domain, resolve_field
  5. def parse_created_time(value, formats, timezone):
  6. if value is None:
  7. return None
  8. s = str(value).strip()
  9. if not s:
  10. return None
  11. for fmt in formats:
  12. try:
  13. parsed = dt.datetime.strptime(s, fmt)
  14. if parsed.tzinfo is None:
  15. parsed = parsed.replace(tzinfo=timezone)
  16. return parsed.astimezone(dt.timezone.utc)
  17. except Exception:
  18. continue
  19. try:
  20. iso_text = s.replace("Z", "+00:00")
  21. parsed = dt.datetime.fromisoformat(iso_text)
  22. if parsed.tzinfo is None:
  23. parsed = parsed.replace(tzinfo=timezone)
  24. return parsed.astimezone(dt.timezone.utc)
  25. except Exception:
  26. return None
  27. def build_lexicographic_descriptors(scoring_cfg, prefer_lower):
  28. out = []
  29. for item in scoring_cfg.get("lexicographic_fields", []):
  30. if isinstance(item, str):
  31. field_name = item.strip()
  32. order = "asc" if prefer_lower else "desc"
  33. else:
  34. field_name = str(item.get("field", "")).strip()
  35. order = str(item.get("order", "")).strip().lower()
  36. if not order:
  37. order = "asc" if prefer_lower else "desc"
  38. out.append({"field": field_name, "order": order})
  39. return out
  40. def parse_scored_records(records, field_map, record_mapping_cfg, scoring_cfg):
  41. if not scoring_cfg.get("enabled", False):
  42. return []
  43. strategy = str(scoring_cfg.get("strategy", "weighted_average")).strip()
  44. prefer_lower = bool(scoring_cfg.get("prefer_lower", False))
  45. timezone = parse_timezone(record_mapping_cfg.get("created_time_timezone", "UTC"))
  46. time_formats = [str(x) for x in record_mapping_cfg.get("created_time_formats", [])]
  47. weighted_fields = scoring_cfg.get("weighted_fields", []) if strategy == "weighted_average" else []
  48. lex_descriptors = build_lexicographic_descriptors(scoring_cfg, prefer_lower) if strategy == "lexicographic" else []
  49. needed_fields = set()
  50. for item in weighted_fields:
  51. needed_fields.add(str(item.get("field", "")).strip())
  52. for item in lex_descriptors:
  53. needed_fields.add(str(item.get("field", "")).strip())
  54. for item in scoring_cfg.get("tie_breakers", []):
  55. needed_fields.add(str(item.get("field", "")).strip())
  56. needed_fields.discard("domain")
  57. needed_fields.discard("created_at")
  58. out = []
  59. for record in records:
  60. domain = normalize_domain(resolve_field(record, "domain", field_map))
  61. if not domain:
  62. continue
  63. created_raw = resolve_field(record, "created_at", field_map)
  64. created_at = parse_created_time(created_raw, time_formats, timezone)
  65. field_values = {}
  66. for field_name in needed_fields:
  67. field_values[field_name] = resolve_field(record, field_name, field_map)
  68. score_value = None
  69. scores = []
  70. lex_values = []
  71. if strategy == "weighted_average":
  72. total = 0.0
  73. total_weight = 0.0
  74. missing = False
  75. for item in weighted_fields:
  76. field_name = str(item.get("field", "")).strip()
  77. weight = float(item.get("weight"))
  78. raw_v = resolve_field(record, field_name, field_map)
  79. val = to_float_or_none(raw_v)
  80. scores.append(val)
  81. if val is None:
  82. missing = True
  83. continue
  84. field_lower = item.get("prefer_lower", prefer_lower)
  85. if str(item.get("order", "")).lower() == "desc":
  86. field_lower = False
  87. elif str(item.get("order", "")).lower() == "asc":
  88. field_lower = True
  89. effective_val = val if (bool(field_lower) == prefer_lower) else -val
  90. total += effective_val * weight
  91. total_weight += weight
  92. if not missing and total_weight > 0:
  93. score_value = total / total_weight
  94. if strategy == "lexicographic":
  95. for item in lex_descriptors:
  96. field_name = item["field"]
  97. order = item["order"]
  98. raw_v = resolve_field(record, field_name, field_map)
  99. num_v = to_float_or_none(raw_v)
  100. v = num_v if num_v is not None else raw_v
  101. lex_values.append({"field": field_name, "value": v, "order": order})
  102. scores.append(v)
  103. out.append(
  104. {
  105. "domain": domain,
  106. "created_at": created_at,
  107. "created_raw": created_raw,
  108. "scores": scores,
  109. "score_value": score_value,
  110. "lex_values": lex_values,
  111. "field_values": field_values,
  112. "raw_record": record,
  113. }
  114. )
  115. return out
  116. def cmp_scalar(a, b, order):
  117. a_none = a is None
  118. b_none = b is None
  119. if a_none and b_none:
  120. return 0
  121. if a_none:
  122. return 1
  123. if b_none:
  124. return -1
  125. if isinstance(a, dt.datetime):
  126. a = a.timestamp()
  127. if isinstance(b, dt.datetime):
  128. b = b.timestamp()
  129. a_num = to_float_or_none(a)
  130. b_num = to_float_or_none(b)
  131. if a_num is not None and b_num is not None:
  132. if a_num < b_num:
  133. base = -1
  134. elif a_num > b_num:
  135. base = 1
  136. else:
  137. base = 0
  138. else:
  139. a_s = str(a).lower()
  140. b_s = str(b).lower()
  141. if a_s < b_s:
  142. base = -1
  143. elif a_s > b_s:
  144. base = 1
  145. else:
  146. base = 0
  147. return base if order == "asc" else -base
  148. def get_sort_field_value(record, field_name):
  149. if field_name == "domain":
  150. return record.get("domain")
  151. if field_name == "created_at":
  152. return record.get("created_at")
  153. return record.get("field_values", {}).get(field_name)
  154. def rank_scored_records(records, scoring_cfg):
  155. if not records:
  156. return []
  157. within_hours = float(scoring_cfg.get("within_hours", 0))
  158. strategy = str(scoring_cfg.get("strategy", "weighted_average")).strip()
  159. prefer_lower = bool(scoring_cfg.get("prefer_lower", False))
  160. tie_breakers = scoring_cfg.get("tie_breakers", [])
  161. if within_hours > 0:
  162. now = dt.datetime.now(dt.timezone.utc)
  163. cutoff = now - dt.timedelta(hours=within_hours)
  164. recent = [r for r in records if r.get("created_at") is not None and r["created_at"] >= cutoff]
  165. candidates = recent if recent else records
  166. else:
  167. candidates = records
  168. default_lex_order = "asc" if prefer_lower else "desc"
  169. def compare(a, b):
  170. if strategy == "weighted_average":
  171. order = "asc" if prefer_lower else "desc"
  172. c = cmp_scalar(a.get("score_value"), b.get("score_value"), order)
  173. if c != 0:
  174. return c
  175. elif strategy == "lexicographic":
  176. a_lex = a.get("lex_values", [])
  177. b_lex = b.get("lex_values", [])
  178. n = max(len(a_lex), len(b_lex))
  179. for i in range(n):
  180. av = a_lex[i]["value"] if i < len(a_lex) else None
  181. bv = b_lex[i]["value"] if i < len(b_lex) else None
  182. order = default_lex_order
  183. if i < len(a_lex) and a_lex[i].get("order"):
  184. order = a_lex[i]["order"]
  185. c = cmp_scalar(av, bv, order)
  186. if c != 0:
  187. return c
  188. for item in tie_breakers:
  189. field_name = str(item.get("field", "")).strip()
  190. order = str(item.get("order", "asc")).strip().lower()
  191. av = get_sort_field_value(a, field_name)
  192. bv = get_sort_field_value(b, field_name)
  193. c = cmp_scalar(av, bv, order)
  194. if c != 0:
  195. return c
  196. return cmp_scalar(a.get("domain"), b.get("domain"), "asc")
  197. return sorted(candidates, key=functools.cmp_to_key(compare))
  198. def choose_top_candidate_domains(filtered_domains, top_n, ranked_scored):
  199. if ranked_scored:
  200. domains_by_score = [x["domain"] for x in ranked_scored]
  201. return domains_by_score[:top_n]
  202. return filtered_domains[:top_n]
  203. def choose_domain(filtered_domains, top_n, ranked_scored, last_good="", switch_margin_pct=0.0):
  204. if ranked_scored:
  205. top_domains = choose_top_candidate_domains(filtered_domains, top_n, ranked_scored)
  206. if top_domains:
  207. best_domain = top_domains[0]
  208. if switch_margin_pct > 0 and last_good and best_domain != last_good:
  209. last_cand = next((r for r in ranked_scored if r.get("domain") == last_good), None)
  210. best_cand = next((r for r in ranked_scored if r.get("domain") == best_domain), None)
  211. if last_cand and best_cand:
  212. best_score = to_float_or_none(best_cand.get("score_value"))
  213. last_score = to_float_or_none(last_cand.get("score_value"))
  214. if best_score is not None and last_score is not None and last_score != 0:
  215. improvement_pct = abs((last_score - best_score) / last_score) * 100.0
  216. if improvement_pct < switch_margin_pct:
  217. top_reordered = [last_good] + [d for d in top_domains if d != last_good]
  218. return last_good, top_reordered[:top_n]
  219. return best_domain, top_domains
  220. if filtered_domains:
  221. return filtered_domains[0], filtered_domains[:top_n]
  222. return None, []
  223. def text_or_blank(value):
  224. if value is None:
  225. return ""
  226. return str(value).strip()
  227. def set_if_nonempty_text(obj, key, value):
  228. text = text_or_blank(value)
  229. if text:
  230. obj[key] = text
  231. def base_top_candidate(domain, source_type):
  232. candidate = {"domain": domain, "source_type": source_type}
  233. if IPV4_RE.match(domain):
  234. candidate["ip"] = domain
  235. return candidate
  236. def maybe_resolve_field(record, field_name, field_map):
  237. if not isinstance(record, dict):
  238. return None
  239. if field_name not in field_map:
  240. return None
  241. return resolve_field(record, field_name, field_map)
  242. def build_cfst_candidate(row):
  243. domain = row.get("domain", "")
  244. candidate = base_top_candidate(domain=domain, source_type="cfst_local")
  245. set_if_nonempty_text(candidate, "ip", row.get("ip") or domain)
  246. set_if_nonempty_text(candidate, "loss_rate", row.get("loss_rate"))
  247. set_if_nonempty_text(candidate, "avg_latency", row.get("avg_latency"))
  248. set_if_nonempty_text(candidate, "download_speed", row.get("download_speed"))
  249. set_if_nonempty_text(candidate, "region", row.get("region"))
  250. return candidate
  251. def build_api_candidate(domain, record, field_map, scored_record=None):
  252. candidate = base_top_candidate(domain=domain, source_type="api")
  253. if record:
  254. set_if_nonempty_text(candidate, "created_raw", maybe_resolve_field(record, "created_at", field_map))
  255. set_if_nonempty_text(candidate, "avg_latency", maybe_resolve_field(record, "avg_latency", field_map))
  256. set_if_nonempty_text(candidate, "loss_rate", maybe_resolve_field(record, "avg_pkg_lost_rate", field_map))
  257. set_if_nonempty_text(candidate, "download_speed", maybe_resolve_field(record, "download_speed", field_map))
  258. location_country = text_or_blank(maybe_resolve_field(record, "location_country", field_map))
  259. location_city = text_or_blank(maybe_resolve_field(record, "location_city", field_map))
  260. region = "/".join([x for x in [location_country, location_city] if x])
  261. set_if_nonempty_text(candidate, "region", region)
  262. if scored_record:
  263. if scored_record.get("score_value") is not None:
  264. candidate["score_value"] = scored_record.get("score_value")
  265. scores = list(scored_record.get("scores", []))
  266. if scores:
  267. candidate["scores"] = scores
  268. if "created_raw" not in candidate:
  269. set_if_nonempty_text(candidate, "created_raw", scored_record.get("created_raw"))
  270. return candidate
  271. def build_top_candidates(source_type, candidate_domains, cfst_rows=None, records=None, field_map=None, ranked_scored=None):
  272. if source_type == "cfst_local":
  273. row_map = {}
  274. for row in cfst_rows or []:
  275. domain = row.get("domain", "")
  276. if domain and domain not in row_map:
  277. row_map[domain] = row
  278. return [build_cfst_candidate(row_map.get(d, {"domain": d, "ip": d})) for d in candidate_domains]
  279. ranked_map = {}
  280. for item in ranked_scored or []:
  281. domain = item.get("domain", "")
  282. if domain and domain not in ranked_map:
  283. ranked_map[domain] = item
  284. record_map = {}
  285. for record in records or []:
  286. domain = normalize_domain(maybe_resolve_field(record, "domain", field_map or {}))
  287. if domain and domain not in record_map:
  288. record_map[domain] = record
  289. out = []
  290. for domain in candidate_domains:
  291. scored_record = ranked_map.get(domain)
  292. record = None
  293. if scored_record:
  294. record = scored_record.get("raw_record")
  295. if record is None:
  296. record = record_map.get(domain)
  297. out.append(build_api_candidate(domain, record, field_map or {}, scored_record=scored_record))
  298. return out