argo-search 2.9.7 → 2.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.en.md +58 -145
  2. package/README.es.md +58 -145
  3. package/README.ja.md +64 -151
  4. package/README.ko.md +63 -150
  5. package/README.md +65 -154
  6. package/SKILL.md +3 -3
  7. package/assets/readme/hero.svg +2 -2
  8. package/backends/domain_profiles.json +48 -0
  9. package/backends/engine_registry.yaml +118 -4
  10. package/backends/quota_profiles.json +92 -12
  11. package/backends/source_types_cn.json +27 -1
  12. package/bin/argo +74 -5
  13. package/bin/argo.js +33 -2
  14. package/config.yaml +219 -60
  15. package/docs/ENGINE_CATALOG.md +50 -39
  16. package/docs/MCP_SETUP.md +3 -1
  17. package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
  18. package/engines/specs/cfets.yaml +41 -0
  19. package/engines/specs/defillama.yaml +38 -0
  20. package/engines/specs/firecrawl.yaml +1 -1
  21. package/engines/specs/fxtwitter.yaml +5 -0
  22. package/engines/specs/gleif.yaml +55 -0
  23. package/engines/specs/hkex.yaml +40 -0
  24. package/engines/specs/keenable_free.yaml +36 -0
  25. package/engines/specs/shfe.yaml +39 -0
  26. package/engines/specs/stackexchange.yaml +2 -2
  27. package/engines/specs/tavily_free.yaml +56 -0
  28. package/engines/specs/yuanbao.yaml +93 -0
  29. package/mcp/clients.yaml +32 -0
  30. package/package.json +6 -2
  31. package/packages/dsh-plugin/README.md +23 -5
  32. package/packages/dsh-plugin/dsh/client.js +304 -15
  33. package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
  34. package/packages/dsh-plugin/dsh/index.js +112 -1875
  35. package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
  36. package/packages/dsh-plugin/dsh/runner.js +666 -0
  37. package/packages/dsh-plugin/dsh/wide-research.js +878 -0
  38. package/packages/dsh-plugin/package.json +2 -2
  39. package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
  40. package/packages/dsh-plugin/test/client.test.js +175 -1
  41. package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
  42. package/packages/dsh-plugin/test/index.test.js +44 -0
  43. package/scripts/adaptive.py +24 -1
  44. package/scripts/article_meta.py +131 -0
  45. package/scripts/budget.py +247 -0
  46. package/scripts/budget_cli.py +93 -0
  47. package/scripts/build_release_assets.sh +65 -0
  48. package/scripts/cache.py +60 -72
  49. package/scripts/ci_drift_gate.py +90 -0
  50. package/scripts/content_boundary.py +46 -0
  51. package/scripts/content_signals.py +26 -5
  52. package/scripts/cross_lingual_trace.py +293 -0
  53. package/scripts/dispatch_result.py +98 -0
  54. package/scripts/engine_dispatch.py +49 -86
  55. package/scripts/engine_env.py +5 -0
  56. package/scripts/engine_families.py +6 -0
  57. package/scripts/engine_policy.py +7 -7
  58. package/scripts/engines.py +21 -0
  59. package/scripts/engines_base.py +3 -3
  60. package/scripts/engines_builders.py +12 -0
  61. package/scripts/engines_builders_batch10.py +48 -3
  62. package/scripts/engines_builders_batch14.py +656 -0
  63. package/scripts/engines_builders_batch15.py +249 -0
  64. package/scripts/engines_builders_keyless.py +111 -0
  65. package/scripts/engines_builders_tech.py +0 -1
  66. package/scripts/evidence.py +31 -3
  67. package/scripts/evidence_loop.py +54 -22
  68. package/scripts/fetch_v3.py +25 -0
  69. package/scripts/gen_routing_snapshot.py +11 -0
  70. package/scripts/install.ps1 +18 -0
  71. package/scripts/install.sh +16 -1
  72. package/scripts/job.py +3 -3
  73. package/scripts/local_seek.py +41 -5
  74. package/scripts/macro_countries.py +15 -0
  75. package/scripts/matrix_search_eval.py +82 -0
  76. package/scripts/mcp_compact.py +234 -0
  77. package/scripts/mcp_handlers.py +60 -642
  78. package/scripts/mcp_launch.sh +10 -8
  79. package/scripts/mcp_local.py +241 -0
  80. package/scripts/mcp_server.py +19 -14
  81. package/scripts/mcp_setup.py +33 -9
  82. package/scripts/mcp_tools.py +1 -0
  83. package/scripts/mcp_transport.py +1 -1
  84. package/scripts/mcp_warm.py +127 -0
  85. package/scripts/output_transparency.py +200 -0
  86. package/scripts/paid_policy.py +98 -0
  87. package/scripts/quota.py +158 -13
  88. package/scripts/recovery_runner.py +150 -0
  89. package/scripts/replay_eval.py +18 -0
  90. package/scripts/research.py +37 -21
  91. package/scripts/research_expand.py +24 -1
  92. package/scripts/retry_policy.py +76 -0
  93. package/scripts/route.py +6 -6
  94. package/scripts/route_cache.py +18 -0
  95. package/scripts/route_combo.py +7 -0
  96. package/scripts/route_domains.py +32 -19
  97. package/scripts/search.py +15 -27
  98. package/scripts/search_attribution.py +100 -3
  99. package/scripts/search_cli.py +24 -11
  100. package/scripts/search_entry.py +7 -1
  101. package/scripts/search_output.py +99 -15
  102. package/scripts/search_pipeline.py +42 -193
  103. package/scripts/search_types.py +107 -9
  104. package/scripts/semantic_evidence.py +179 -3
  105. package/scripts/serp_guard.py +115 -7
  106. package/scripts/serp_kill_log.py +25 -2
  107. package/scripts/stats_cli.py +13 -1
  108. package/scripts/wide_research.py +45 -0
  109. package/scripts/zhihu_quota.py +139 -0
  110. package/sub-skills/local-search/bm25.py +27 -3
  111. package/sub-skills/local-search/cli_runner.py +331 -0
  112. package/sub-skills/local-search/parsers.py +328 -0
  113. package/sub-skills/local-search/search_v3.py +31 -674
  114. package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
  115. package/assets/readme/settings-keys.png +0 -0
  116. package/assets/readme/settings-tools.png +0 -0
  117. package/scripts/image_vision/vision_probe +0 -0
  118. package/scripts/local_engines.py +0 -321
  119. package/sub-skills/ego-search/references/browser-runtime.md +0 -43
  120. package/sub-skills/ego-search/references/install.md +0 -67
  121. package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
  122. package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
  123. package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
  124. package/sub-skills/js-run/tests/test_jsrun.py +0 -165
  125. package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
  126. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
  127. package/sub-skills/local-seek/references/git-integration.md +0 -54
  128. package/sub-skills/local-seek/references/remote-handoff.md +0 -46
  129. package/sub-skills/local-seek/references/rg-recipes.md +0 -96
  130. package/sub-skills/local-seek/references/strategies.md +0 -52
  131. package/sub-skills/local-seek/references/structural-search.md +0 -59
  132. package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
  133. package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
  134. package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
  135. package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
  136. package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
  137. package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
  138. package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
  139. package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
  140. package/sub-skills/local-seek/tests/test_seek_fts.py +0 -313
@@ -413,322 +413,43 @@ def _lang_param(param: str, query: str) -> str:
413
413
  return ""
414
414
 
415
415
 
416
- # ── HTML 解析 ──────────────────────────────────────────────────────────────────
417
-
418
- def _select_with_bs4(soup: Any, selector: str):
419
- try:
420
- return soup.select(selector)
421
- except Exception:
422
- return []
423
-
424
-
425
- def _select_first_with_bs4(soup: Any, selector: str) -> Any:
426
- items = _select_with_bs4(soup, selector)
427
- return items[0] if items else None
428
-
429
-
430
- def _fallback_extract(html: str, engine_name: str, base: str,
431
- default_score: float = 0.45, max_items: int = 8) -> list[dict[str, Any]]:
432
- """通用解析保底:选择器 0 命中时,用链接 + 锚文本启发式回退。
433
-
434
- 零依赖:只用 BeautifulSoup 已有能力,抽页面内最像「结果卡片」的链接块,
435
- 避免页面改版导致整引擎 0 结果。命中者 score 保守(0.45 起),不与主映射抢位。
436
- """
437
- try:
438
- from bs4 import BeautifulSoup
439
- soup = BeautifulSoup(html, "html.parser")
440
- except Exception:
441
- return []
442
- for sel in ("nav", "header", "footer", "script", "style", "noscript"):
443
- try:
444
- for tag in soup.select(sel):
445
- tag.decompose()
446
- except Exception:
447
- pass
448
- candidates: list[tuple[str, str, str]] = []
449
- for a in soup.find_all("a", href=True):
450
- try:
451
- href = str(a.get("href") or "").strip()
452
- title = a.get_text(strip=True)
453
- except Exception:
454
- continue
455
- if not title or len(title) < 6:
456
- continue
457
- if href.startswith("#") or href.startswith("javascript:"):
458
- continue
459
- low = href.lower()
460
- if any(x in low for x in ("login", "signin", "signup", "cookie", "privacy", "terms")):
461
- continue
462
- parent = a.parent
463
- snippet = ""
464
- try:
465
- if parent is not None:
466
- snippet = parent.get_text(" ", strip=True)[:300]
467
- if snippet == title:
468
- gp = parent.parent
469
- snippet = gp.get_text(" ", strip=True)[:300] if gp is not None else ""
470
- except Exception:
471
- snippet = ""
472
- if href.startswith("/"):
473
- try:
474
- href = urllib.parse.urljoin(base, href)
475
- except Exception:
476
- pass
477
- candidates.append((title[:200], href[:500], snippet))
478
- if len(candidates) >= max_items * 3:
479
- break
480
- seen: set[str] = set()
481
- out: list[dict[str, Any]] = []
482
- for title, url, snippet in candidates:
483
- key = url or title
484
- if not key or key in seen:
485
- continue
486
- seen.add(key)
487
- out.append({
488
- "title": title,
489
- "url": url,
490
- "snippet": snippet,
491
- "score": round(max(default_score - len(out) * 0.05, 0.1), 3),
492
- "source": engine_name,
493
- "_fallback": True,
494
- })
495
- if len(out) >= max_items:
496
- break
497
- return out
498
-
499
-
500
- def _parse_html(engine_name: str, html: str, spec: dict[str, Any],
501
- maps: dict[str, Any]) -> list[dict[str, Any]]:
502
- html_maps = maps.get("html", {})
503
- mapping = html_maps.get(engine_name, html_maps.get("default", {}))
504
- container_sel = mapping.get("container")
505
- title_sel = mapping.get("title")
506
- url_sel = mapping.get("url")
507
- snippet_sel = mapping.get("snippet")
508
- url_attr = mapping.get("url_attr", "href")
509
- default_score = mapping.get("score", 0.5)
510
-
511
- if not container_sel:
512
- return []
513
-
514
- try:
515
- from bs4 import BeautifulSoup
516
- soup = BeautifulSoup(html, "html.parser")
517
- containers = _select_with_bs4(soup, container_sel)
518
- except Exception:
519
- return []
520
-
521
- results: list[dict[str, Any]] = []
522
- base = spec.get("_base", spec.get("url", ""))
523
- for idx, item in enumerate(containers):
524
- try:
525
- title_el = _select_first_with_bs4(item, title_sel) if title_sel else None
526
- url_el = _select_first_with_bs4(item, url_sel) if url_sel else None
527
- snippet_el = _select_first_with_bs4(item, snippet_sel) if snippet_sel else None
528
-
529
- title = title_el.get_text(strip=True)[:200] if title_el else ""
530
- url = ""
531
- if url_el and url_el.has_attr(url_attr):
532
- url = url_el[url_attr]
533
- snippet = snippet_el.get_text(strip=True)[:300] if snippet_el else ""
534
-
535
- if not title and not url:
536
- continue
537
-
538
- if url and url.startswith("/"):
539
- url = urllib.parse.urljoin(base, url)
540
-
541
- score = max(default_score - idx * 0.05, 0.1)
542
- results.append({
543
- "title": title,
544
- "url": url,
545
- "snippet": snippet,
546
- "score": round(score, 3),
547
- "source": engine_name,
548
- })
549
- except Exception:
550
- continue
551
- if not results:
552
- try:
553
- fb = _fallback_extract(html, engine_name, base, default_score=0.45, max_items=8)
554
- if fb:
555
- return fb
556
- except Exception:
557
- pass
558
- return results
559
-
416
+ # ── HTML / XML / JSON 解析(解耦至 parsers.py) ──────────────────────────────
417
+ from parsers import (
418
+ _fallback_extract,
419
+ _format_url,
420
+ _get_path,
421
+ _parse_html,
422
+ _parse_json as _raw_parse_json,
423
+ _parse_xml as _raw_parse_xml,
424
+ _select_first_with_bs4,
425
+ _select_with_bs4,
426
+ )
560
427
 
561
- # ── XML / RSS 解析 ─────────────────────────────────────────────────────────────
562
428
 
563
429
  def _parse_xml(engine_name: str, text: str, maps: dict[str, Any],
564
430
  is_rss: bool = False) -> list[dict[str, Any]]:
565
- if is_rss:
566
- mapping = maps.get("rss", {}).get("default", {})
567
- else:
568
- mapping = maps.get("xml", {}).get(engine_name, {})
569
-
570
- entry_path = mapping.get("entry_path") or mapping.get("item_path", ".//item")
571
- title_tag = mapping.get("title", "title")
572
- url_tag = mapping.get("url", "link")
573
- snippet_tag = mapping.get("snippet", "description")
574
- pub_tag = mapping.get("published_at", "pubDate")
575
- namespaces = mapping.get("namespaces", {})
576
-
577
- def _find_tag(entry: Any, tag: str) -> str:
578
- """按标签取值,兼容 atom:xxx 命名空间前缀。"""
579
- if tag.startswith("atom:"):
580
- tag_name = tag.split(":")[1]
581
- ns = namespaces.get("atom")
582
- node = entry.find(f"{{{ns}}}{tag_name}") if ns else None
583
- return (node.text or "").strip() if node is not None else ""
584
- node = entry.findtext(tag, default="")
585
- return (node or "").strip()
586
-
587
- results: list[dict[str, Any]] = []
588
- try:
589
- root = ET.fromstring(text)
590
- except ET.ParseError:
591
- return results
592
-
593
- if entry_path.startswith(".//{"):
594
- entries = root.findall(entry_path, namespaces)
595
- else:
596
- entries = root.findall(entry_path)
597
-
598
- for idx, entry in enumerate(entries):
599
- try:
600
- title = _find_tag(entry, title_tag)
601
- url = _find_tag(entry, url_tag)
602
- snippet = _find_tag(entry, snippet_tag)
603
- raw_pub = _find_tag(entry, pub_tag) if pub_tag else ""
604
-
605
- title = re.sub(r"\s+", " ", title)[:200]
606
- snippet = re.sub(r"\s+", " ", snippet)[:300]
607
- score = max(0.7 - idx * 0.05, 0.1)
608
- results.append({
609
- "title": title,
610
- "url": url,
611
- "snippet": snippet,
612
- "score": round(score, 3),
613
- "source": engine_name,
614
- "published_at": _normalize_date(raw_pub),
615
- })
616
- except Exception:
617
- continue
618
- return results
619
-
620
-
621
- # ── JSON 解析 ──────────────────────────────────────────────────────────────────
622
-
623
- def _get_path(data: Any, path: str) -> Any:
624
- if path == ".":
625
- return data
626
- obj = data
627
- for part in path.split("."):
628
- if part.isdigit() and isinstance(obj, (list, tuple)):
629
- idx = int(part)
630
- obj = obj[idx] if idx < len(obj) else None
631
- elif isinstance(obj, dict):
632
- obj = obj.get(part)
633
- else:
634
- return None
635
- return obj
636
-
637
-
638
- def _format_url(template: str | None, item: dict[str, Any], default: str = "") -> str:
639
- if not template:
640
- return default
641
- try:
642
- return template.format(**item)
643
- except (KeyError, IndexError):
644
- return default
431
+ return _raw_parse_xml(engine_name, text, maps, is_rss=is_rss, normalize_date_fn=_normalize_date)
645
432
 
646
433
 
647
434
  def _parse_json(engine_name: str, text: str, maps: dict[str, Any]) -> list[dict[str, Any]]:
648
- mapping = maps.get("json", {}).get(engine_name, {})
649
- items_path = mapping.get("items", ".")
650
- title_key = mapping.get("title")
651
- url_key = mapping.get("url")
652
- snippet_key = mapping.get("snippet")
653
- url_template = mapping.get("url_template")
654
- published_key = mapping.get("published_at")
655
-
656
- results: list[dict[str, Any]] = []
657
- try:
658
- data = json.loads(text)
659
- except json.JSONDecodeError:
660
- return results
661
-
662
- items = _get_path(data, items_path)
663
- if not isinstance(items, list):
664
- return results
665
-
666
- for idx, item in enumerate(items):
667
- if not isinstance(item, dict):
668
- if engine_name == "local_pubmed" and isinstance(item, str):
669
- item = {"pmid": item}
670
- else:
671
- continue
672
-
673
- title = url = snippet = ""
674
- if title_key:
675
- raw = _get_path(item, title_key)
676
- if isinstance(raw, list):
677
- raw = raw[0] if raw else ""
678
- title = str(raw or "")[:200]
679
- if url_key:
680
- raw = _get_path(item, url_key)
681
- url = str(raw or "")[:500]
682
- elif url_template:
683
- url = _format_url(url_template, item)
684
- if snippet_key:
685
- raw = _get_path(item, snippet_key)
686
- snippet = str(raw or "")[:300]
687
- published_at = _normalize_date(_get_path(item, published_key)) if published_key else None
688
-
689
- title = re.sub(r"<[^>]+>", " ", title)
690
- title = re.sub(r"\s+", " ", title).strip()
691
- snippet = re.sub(r"<[^>]+>", " ", snippet)
692
- snippet = re.sub(r"\s+", " ", snippet).strip()
693
-
694
- score = max(0.7 - idx * 0.05, 0.1)
695
- results.append({
696
- "title": title,
697
- "url": url,
698
- "snippet": snippet,
699
- "score": round(score, 3),
700
- "source": engine_name,
701
- "published_at": published_at,
702
- })
703
- return results
704
-
705
-
706
- # ── 单个引擎执行 ─────────────────────────────────────────────────────────────────
707
-
708
- # ddgs 各子命令支持的 timelimit 取值:videos 仅 d/w/m(无 y)。
709
- # 传 y 会直接 crash(Invalid value for '-t'),必须按下推子命令过滤。
710
- _DDGS_TIMELIMITS: dict[str, set[str]] = {
711
- "text": {"d", "w", "m", "y"},
712
- "news": {"d", "w", "m", "y"},
713
- "images": {"d", "w", "m", "y"},
714
- "videos": {"d", "w", "m"},
715
- }
716
-
717
- # ddgs 失败信号标记:9.14.4 起这些错误在 rc=0 时打到 stdout(吞错根源,
718
- # 实测 yahoo 间歇 "No results"、bing TLS "ConnectError" 均 rc=0),
719
- # 必须在任何解析前统一识别;命中后重试 1 次(后端间歇失败实测 20-50%)。
720
- _CLI_ERROR_MARKERS: tuple[str, ...] = (
721
- "DDGSException", "No results", "ConnectError", "ConnectTimeout",
722
- "ReadTimeout", "Timeout", "backends do not exist",
435
+ return _raw_parse_json(engine_name, text, maps, normalize_date_fn=_normalize_date)
436
+
437
+
438
+ # ── CLI 工具引擎调度(解耦至 cli_runner.py) ────────────────────────────────────
439
+ from cli_runner import (
440
+ _CLI_AVAIL_CACHE,
441
+ _CLI_AVAIL_TTL,
442
+ _CLI_ERROR_MARKERS,
443
+ _CLI_RETRIES,
444
+ _DDGS_TIMELIMITS,
445
+ _RETRY_FAST_S,
446
+ _attempt_loop,
447
+ _check_cli_available,
448
+ _parse_cli_json,
449
+ _parse_cli_output,
450
+ _run_cli_engine as _raw_run_cli_engine,
451
+ _since_to_ddgs_timelimit,
723
452
  )
724
- _CLI_RETRIES = 1
725
-
726
- # 重试的「快失败」判据(秒,2026-09-29 G 修订):只有**快**结束的失败才值得
727
- # 重试——传输层瞬时错误(DecodeError/ConnectError)在连接阶段就暴露(实测
728
- # <2.5s);跑了数秒才返回的 "No results" 意味着后端真的做完了检索,是真空
729
- # 或慢性故障,重试大概率重演。实测尾部形态:第一次 4.4s 慢空 → 重试 →
730
- # 第二次挂满 8s 超时 = 12.1s;本判据把这类尾部砍回单次(~4s)。
731
- _RETRY_FAST_S = 2.5
732
453
 
733
454
  # 第二引擎共识宽限(秒,2026-10-06):够数早停从「死等 ≥2 引擎」改为
734
455
  # 「先等 1s 收共识,宽限到点放行」。语义与实测依据见 search_engines 内
@@ -737,373 +458,9 @@ _CONSENSUS_GRACE_S = 1.0
737
458
  _CONSENSUS_POLL_S = 0.25
738
459
 
739
460
 
740
- def _since_to_ddgs_timelimit(since: str | None, until: str | None) -> str | None:
741
- """Convert since/until to ddgs timelimit argument.
742
-
743
- ddgs supports: d (day), w (week), m (month), y (year)
744
- """
745
- if not since:
746
- return None
747
-
748
- # Parse relative time like 7d, 1m, 3w
749
- match = re.match(r'^(\d+)([dwmy])$', since.lower())
750
- if match:
751
- num, unit = match.groups()
752
- num = int(num)
753
- # Map to ddgs timelimit
754
- if unit == 'y':
755
- return 'y'
756
- elif unit == 'm':
757
- return 'm'
758
- elif unit == 'w':
759
- return 'w'
760
- elif unit == 'd':
761
- if num >= 365:
762
- return 'y'
763
- elif num >= 30:
764
- return 'm'
765
- elif num >= 7:
766
- return 'w'
767
- else:
768
- return 'd'
769
-
770
- return None
771
-
772
-
773
461
  def _run_cli_engine(spec: dict[str, Any], query: str, n: int, timeout: float,
774
- since: str | None = None, until: str | None = None) -> tuple[list[dict[str, Any]], str]:
775
- """Run a CLI-based search engine (e.g., ddgs).
776
-
777
- Args:
778
- since: Time window start (7d, 1m, etc.)
779
- until: Time window end
780
- """
781
- cli_cmd = spec.get("cli_command", "")
782
- cli_args_template = spec.get("cli_args", [])
783
- key = spec.get("key", cli_cmd)
784
-
785
- if not cli_cmd:
786
- return [], "CLI engine missing cli_command"
787
-
788
- # Check if CLI is available
789
- if not _check_cli_available(cli_cmd):
790
- return [], f"{cli_cmd} not available"
791
-
792
- # Add time limit if since/until provided
793
- time_limit = _since_to_ddgs_timelimit(since, until)
794
-
795
- # Build command with query and result count substitution
796
- cli_args = []
797
- for arg in cli_args_template:
798
- formatted = arg.format(query=query, n=n)
799
- cli_args.append(formatted)
800
-
801
- # Insert time limit if supported and needed
802
- # 按子命令限定取值:videos 不支持 y,直接追加 -t y 会让 ddgs 报错退出。
803
- if time_limit and "-t" not in cli_args and "--timelimit" not in cli_args:
804
- sub = next((a for a in cli_args_template if not str(a).startswith("-")), None)
805
- allowed = _DDGS_TIMELIMITS.get(str(sub), _DDGS_TIMELIMITS["text"])
806
- if time_limit in allowed:
807
- cli_args.extend(["-t", time_limit])
808
-
809
- # 区域参数(ddgs -r region):按查询主语言映射(zh→cn-zh 等),
810
- # 引擎声明 use_lang_region: true 时启用;显式 -r 不覆盖。
811
- if spec.get("use_lang_region") and not any(a in ("-r", "--region") for a in cli_args):
812
- region = _lang_param("region", query)
813
- if region:
814
- cli_args.extend(["-r", region])
815
-
816
- # 结构化 JSON 输出:ddgs -o <file> 把结果写入文件(stdout 无输出),
817
- # 避免脆弱的文本行解析;失败回退文本解析。引擎声明 output_format: json。
818
- output_format = spec.get("output_format", "")
819
- tmp_path: str | None = None
820
- if output_format == "json" and not any(
821
- a in ("-o", "--output") for a in cli_args
822
- ):
823
- import tempfile
824
- fd, tmp_path = tempfile.mkstemp(suffix=".json", prefix="ddgs_")
825
- os.close(fd)
826
- _TMP_FILES.add(tmp_path) # 兜底登记:弃置线程对应的进程若直接退出也可清理
827
- cli_args.extend(["-o", tmp_path])
828
-
829
- cmd = [cli_cmd] + cli_args
830
-
831
- # 失败重试:ddgs 各 backend 间歇性失败(yahoo "No results"、bing TLS
832
- # ConnectError 均实测存在),且 9.14.4 起这些错误在 rc=0 时打到 stdout,
833
- # 输出文件为 0 字节。逐次尝试:识别错误信号 → 重试 1 次 → 明确错误。
834
- last_err = ""
835
- try:
836
- return _attempt_loop(cmd, cli_cmd, key, tmp_path, timeout)
837
- finally:
838
- # 函数级兜底:覆盖 helper 内全部早退路径(timeout/FileNotFound/异常/
839
- # 正常返回),保证临时文件与登记表条目都被清(2026-10-12 审查:
840
- # 早退分支此前直接 return,daemon 常驻期间在 /tmp 累积输出文件)。
841
- if tmp_path is not None:
842
- try:
843
- os.unlink(tmp_path)
844
- except OSError:
845
- pass
846
- _TMP_FILES.discard(tmp_path)
847
-
848
-
849
- def _attempt_loop(cmd: list[str], cli_cmd: str, key: str,
850
- tmp_path: str | None, timeout: float):
851
- """CLI 引擎的单引擎尝试循环(从 _run_cli_engine 提取,便于统一收尾)。
852
-
853
- 返回 (results, err);临时文件的清理由调用方 finally 统一负责。
854
- """
855
- last_err = ""
856
- for attempt in range(_CLI_RETRIES + 1):
857
- t_attempt = time.monotonic()
858
- try:
859
- result = subprocess.run(
860
- cmd,
861
- capture_output=True,
862
- text=True,
863
- encoding="utf-8", # Windows GBK locale 下 UTF-8 输出会解码崩
864
- errors="replace",
865
- timeout=timeout,
866
- )
867
- except subprocess.TimeoutExpired:
868
- # 慢失败不重试:后端 timeout 秒都没回来,0.3s 后再来一次大概率
869
- # 还是 timeout(网络/服务端慢是相关信号),重试只把最坏墙钟翻倍、
870
- # 结果仍为空(实测空结果查询单引擎 15.8s = 8s×2+0.3s)。快失败
871
- # (DecodeError/No results,2-3s 内返回)才是真间歇,下方重试。
872
- return [], f"{cli_cmd} timed out after {timeout}s"
873
- except FileNotFoundError:
874
- return [], f"{cli_cmd} not found"
875
- except Exception as e:
876
- return [], f"{cli_cmd} error: {e}"
877
-
878
- combined_output = result.stdout + result.stderr
879
-
880
- # 结构化 JSON 优先(ddgs -o json 输出到临时文件):有结果直接返回,
881
- # stdout 的 warning(如 bing "backends do not exist" 但结果正常)不干扰。
882
- if tmp_path is not None:
883
- json_ok = False
884
- try:
885
- with open(tmp_path, encoding="utf-8") as f:
886
- data = json.load(f)
887
- parsed = _parse_cli_json(data, key)
888
- if parsed:
889
- return parsed, ""
890
- json_ok = True # 合法空列表:真无结果,不重试
891
- except (OSError, ValueError):
892
- json_ok = False
893
- finally:
894
- # 逐次 unlink:防重试读到上一轮的陈旧输出文件。登记表条目
895
- # 不在这里摘——重试第二次会把同一路径重新写出来,若那次
896
- # 超时/异常,登记表仍持有路径,atexit 与调用方 finally 都能
897
- # 兜住(2026-10-12 审查:提前 discard 会让它失去双重兜底)。
898
- try:
899
- os.unlink(tmp_path)
900
- except OSError:
901
- pass
902
- if json_ok:
903
- return [], ""
904
- else:
905
- json_ok = False
906
-
907
- # 无 JSON 结果:识别错误信号(无论 rc)→ 重试。
908
- # 9.14.4 常见 rc=0 + stdout 错误 + 空输出文件,
909
- # 旧逻辑只在 rc!=0 分支检查,导致静默吞错返回 0 结果。
910
- err_marker = next(
911
- (m for m in _CLI_ERROR_MARKERS if m in combined_output), None
912
- )
913
- if err_marker:
914
- detail = combined_output.strip().replace("\n", " ")[:120]
915
- last_err = f"{cli_cmd}: {err_marker}: {detail}"
916
- # 快失败判据:慢失败(后端真跑完了检索的慢空/慢性故障)不重试
917
- if attempt < _CLI_RETRIES and time.monotonic() - t_attempt <= _RETRY_FAST_S:
918
- time.sleep(0.3)
919
- continue
920
- return [], last_err
921
- if result.returncode != 0:
922
- last_err = f"{cli_cmd} exit {result.returncode}: {combined_output[:200]}"
923
- if attempt < _CLI_RETRIES and time.monotonic() - t_attempt <= _RETRY_FAST_S:
924
- time.sleep(0.3)
925
- continue
926
- return [], last_err
927
-
928
- # 0 字节/解析失败或无 -o 引擎:文本解析保底(handles text/news/images/videos)
929
- parsed, parse_err = _parse_cli_output(result.stdout, key)
930
- if parsed:
931
- return parsed, parse_err
932
- last_err = f"{cli_cmd}: empty output"
933
- if attempt < _CLI_RETRIES and time.monotonic() - t_attempt <= _RETRY_FAST_S:
934
- time.sleep(0.3)
935
- continue
936
- return [], last_err
937
- return [], last_err
938
-
939
-
940
- def _parse_cli_json(data: Any, engine_name: str) -> list[dict[str, Any]]:
941
- """解析 ddgs 结构化 JSON 输出(-o json 写入文件)。
942
-
943
- ddgs 各子命令的 JSON item 形态:
944
- text: {title, href, body}
945
- news: {date, title, body, url, image, source}
946
- images: {title, image, thumbnail, url, height, width, source}
947
- videos: {title, content, duration, provider}
948
- 统一映射为 argo 结果字段;news 的 date 归一化为 published_at。
949
- """
950
- if not isinstance(data, list):
951
- return []
952
- results: list[dict[str, Any]] = []
953
- for item in data:
954
- if not isinstance(item, dict):
955
- continue
956
- title = item.get("title", "")
957
- url = item.get("href", item.get("url", item.get("link", "")))
958
- snippet = item.get("body", item.get("snippet",
959
- item.get("description", item.get("content", ""))))
960
- if not title and not url:
961
- continue
962
- r: dict[str, Any] = {
963
- "title": str(title),
964
- "url": str(url),
965
- "snippet": str(snippet),
966
- "source": engine_name,
967
- }
968
- for key in ("image", "thumbnail", "source", "duration", "provider",
969
- "height", "width"):
970
- if key in item and item[key] not in (None, ""):
971
- r[key] = item[key]
972
- date = item.get("date")
973
- if date:
974
- r["published_at"] = str(date)[:64]
975
- # 元数据增强:author/publisher/info 拼入 snippet,
976
- # 否则这些字段会被通用映射丢弃。
977
- meta = " · ".join(
978
- str(item[k]) for k in ("author", "publisher", "info")
979
- if item.get(k)
980
- )
981
- if meta:
982
- r["snippet"] = (str(snippet) + " · " + meta)[:400] if snippet else meta[:400]
983
- results.append(r)
984
- return results
985
-
986
-
987
- def _check_cli_available(cmd: str) -> bool:
988
- """Check if a CLI command is available(结果缓存 TTL=_CLI_AVAIL_TTL)。
989
-
990
- 判据用 `shutil.which`:微秒级、无子进程。旧判据是 `cmd --help` 子进程
991
- (ddgs 冷启 ~190ms),而 TTL 缓存只在**同一进程内**有效——CLI 用法下
992
- 每次 argo 调用都是新进程,等于每次搜索都白付一次 190ms。which 只回答
993
- 「PATH 上有没有这个可执行文件」,正是可用性判定需要的判据; shim 损坏
994
- (能 which 到但跑不动)由后续真实调用暴露,与改动前的失败路径相同。
995
- """
996
- import shutil
997
-
998
- now = time.time()
999
- hit = _CLI_AVAIL_CACHE.get(cmd)
1000
- if hit is not None and now - hit[0] < _CLI_AVAIL_TTL:
1001
- return hit[1]
1002
- ok = shutil.which(cmd) is not None
1003
- _CLI_AVAIL_CACHE[cmd] = (time.time(), ok)
1004
- return ok
1005
-
1006
-
1007
- def _parse_cli_output(stdout: str, engine_name: str) -> tuple[list[dict[str, Any]], str]:
1008
- """Parse output from CLI search tools like ddgs.
1009
-
1010
- Handles multiple output formats:
1011
- - text: title/href/body
1012
- - news: date/title/body/url/image/source
1013
- - images: title/image/thumbnail/url/height/width/source
1014
- - videos: title/content/duration/provider
1015
- """
1016
- results = []
1017
-
1018
- # Try JSON parse first
1019
- try:
1020
- data = json.loads(stdout)
1021
- if isinstance(data, list):
1022
- for item in data:
1023
- result = {
1024
- "title": item.get("title", ""),
1025
- "url": item.get("href", item.get("url", item.get("link", ""))),
1026
- "snippet": item.get("body", item.get("snippet", item.get("description", item.get("content", "")))),
1027
- "source": engine_name,
1028
- }
1029
- for key in ["date", "image", "thumbnail", "source"]:
1030
- if key in item:
1031
- result[key] = item[key]
1032
- results.append(result)
1033
- return results, ""
1034
- except json.JSONDecodeError:
1035
- pass
1036
-
1037
- # Parse text format with flexible field detection
1038
- current_result = {}
1039
- current_field = None
1040
-
1041
- for line in stdout.split("\n"):
1042
- line_stripped = line.strip()
1043
- if not line_stripped:
1044
- continue
1045
-
1046
- # Detect numbered entry start: "1.", "2.", etc.
1047
- # (此前 len<=4 的任意数字行都被当分隔符,正文数字会被误切;
1048
- # 仅识别「数字+句点」形式的序号)
1049
- if re.fullmatch(r"\d+\.\s*", line_stripped):
1050
- if current_result:
1051
- results.append(current_result)
1052
- current_result = {}
1053
- current_field = None
1054
- continue
1055
-
1056
- # Detect field lines with flexible matching
1057
- field_matched = False
1058
- for field, target_key in [
1059
- ("title", "title"),
1060
- ("href", "url"),
1061
- ("url", "url"),
1062
- ("body", "snippet"),
1063
- ("snippet", "snippet"),
1064
- ("description", "snippet"),
1065
- ("content", "snippet"),
1066
- ("date", "date"),
1067
- ("image", "image"),
1068
- ("thumbnail", "thumbnail"),
1069
- ("source", "source"),
1070
- ("duration", "duration"),
1071
- ("provider", "provider"),
1072
- ("height", "height"),
1073
- ("width", "width"),
1074
- ]:
1075
- if line_stripped.startswith(field) and len(line_stripped) > len(field):
1076
- value = line_stripped[len(field):].strip()
1077
- if value:
1078
- current_result[target_key] = value
1079
- current_field = field
1080
- field_matched = True
1081
- break
1082
-
1083
- if not field_matched and current_field:
1084
- # Continuation of multi-line field value
1085
- if current_field in ("body", "snippet", "description", "content"):
1086
- current_result.setdefault("snippet", "")
1087
- current_result["snippet"] += " " + line_stripped
1088
-
1089
- if current_result:
1090
- results.append(current_result)
1091
-
1092
- # Ensure all results have required fields, score, and published_at
1093
- for i, r in enumerate(results):
1094
- r.setdefault("source", engine_name)
1095
- r.setdefault("title", "")
1096
- r.setdefault("url", "")
1097
- r.setdefault("snippet", "")
1098
- # Add score based on result position (higher = better)
1099
- r.setdefault("score", round(0.9 - (i * 0.05), 2))
1100
- # Add published_at for time-window filtering
1101
- if "date" in r:
1102
- r["published_at"] = r["date"]
1103
- else:
1104
- r.setdefault("published_at", "")
1105
-
1106
- return results, ""
462
+ since: str | None = None, until: str | None = None) -> tuple[list[dict[str, Any]], str]:
463
+ return _raw_run_cli_engine(spec, query, n, timeout, since=since, until=until, lang_param_fn=_lang_param)
1107
464
 
1108
465
 
1109
466
  def _resolve_redirect_results(results: list[dict[str, Any]], n: int,