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.
- package/README.en.md +58 -145
- package/README.es.md +58 -145
- package/README.ja.md +64 -151
- package/README.ko.md +63 -150
- package/README.md +65 -154
- package/SKILL.md +3 -3
- package/assets/readme/hero.svg +2 -2
- package/backends/domain_profiles.json +48 -0
- package/backends/engine_registry.yaml +118 -4
- package/backends/quota_profiles.json +92 -12
- package/backends/source_types_cn.json +27 -1
- package/bin/argo +74 -5
- package/bin/argo.js +33 -2
- package/config.yaml +219 -60
- package/docs/ENGINE_CATALOG.md +50 -39
- package/docs/MCP_SETUP.md +3 -1
- package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
- package/engines/specs/cfets.yaml +41 -0
- package/engines/specs/defillama.yaml +38 -0
- package/engines/specs/firecrawl.yaml +1 -1
- package/engines/specs/fxtwitter.yaml +5 -0
- package/engines/specs/gleif.yaml +55 -0
- package/engines/specs/hkex.yaml +40 -0
- package/engines/specs/keenable_free.yaml +36 -0
- package/engines/specs/shfe.yaml +39 -0
- package/engines/specs/stackexchange.yaml +2 -2
- package/engines/specs/tavily_free.yaml +56 -0
- package/engines/specs/yuanbao.yaml +93 -0
- package/mcp/clients.yaml +32 -0
- package/package.json +6 -2
- package/packages/dsh-plugin/README.md +23 -5
- package/packages/dsh-plugin/dsh/client.js +304 -15
- package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
- package/packages/dsh-plugin/dsh/index.js +112 -1875
- package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
- package/packages/dsh-plugin/dsh/runner.js +666 -0
- package/packages/dsh-plugin/dsh/wide-research.js +878 -0
- package/packages/dsh-plugin/package.json +2 -2
- package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
- package/packages/dsh-plugin/test/client.test.js +175 -1
- package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
- package/packages/dsh-plugin/test/index.test.js +44 -0
- package/scripts/adaptive.py +24 -1
- package/scripts/article_meta.py +131 -0
- package/scripts/budget.py +247 -0
- package/scripts/budget_cli.py +93 -0
- package/scripts/build_release_assets.sh +65 -0
- package/scripts/cache.py +60 -72
- package/scripts/ci_drift_gate.py +90 -0
- package/scripts/content_boundary.py +46 -0
- package/scripts/content_signals.py +26 -5
- package/scripts/cross_lingual_trace.py +293 -0
- package/scripts/dispatch_result.py +98 -0
- package/scripts/engine_dispatch.py +49 -86
- package/scripts/engine_env.py +5 -0
- package/scripts/engine_families.py +6 -0
- package/scripts/engine_policy.py +7 -7
- package/scripts/engines.py +21 -0
- package/scripts/engines_base.py +3 -3
- package/scripts/engines_builders.py +12 -0
- package/scripts/engines_builders_batch10.py +48 -3
- package/scripts/engines_builders_batch14.py +656 -0
- package/scripts/engines_builders_batch15.py +249 -0
- package/scripts/engines_builders_keyless.py +111 -0
- package/scripts/engines_builders_tech.py +0 -1
- package/scripts/evidence.py +31 -3
- package/scripts/evidence_loop.py +54 -22
- package/scripts/fetch_v3.py +25 -0
- package/scripts/gen_routing_snapshot.py +11 -0
- package/scripts/install.ps1 +18 -0
- package/scripts/install.sh +16 -1
- package/scripts/job.py +3 -3
- package/scripts/local_seek.py +41 -5
- package/scripts/macro_countries.py +15 -0
- package/scripts/matrix_search_eval.py +82 -0
- package/scripts/mcp_compact.py +234 -0
- package/scripts/mcp_handlers.py +60 -642
- package/scripts/mcp_launch.sh +10 -8
- package/scripts/mcp_local.py +241 -0
- package/scripts/mcp_server.py +19 -14
- package/scripts/mcp_setup.py +33 -9
- package/scripts/mcp_tools.py +1 -0
- package/scripts/mcp_transport.py +1 -1
- package/scripts/mcp_warm.py +127 -0
- package/scripts/output_transparency.py +200 -0
- package/scripts/paid_policy.py +98 -0
- package/scripts/quota.py +158 -13
- package/scripts/recovery_runner.py +150 -0
- package/scripts/replay_eval.py +18 -0
- package/scripts/research.py +37 -21
- package/scripts/research_expand.py +24 -1
- package/scripts/retry_policy.py +76 -0
- package/scripts/route.py +6 -6
- package/scripts/route_cache.py +18 -0
- package/scripts/route_combo.py +7 -0
- package/scripts/route_domains.py +32 -19
- package/scripts/search.py +15 -27
- package/scripts/search_attribution.py +100 -3
- package/scripts/search_cli.py +24 -11
- package/scripts/search_entry.py +7 -1
- package/scripts/search_output.py +99 -15
- package/scripts/search_pipeline.py +42 -193
- package/scripts/search_types.py +107 -9
- package/scripts/semantic_evidence.py +179 -3
- package/scripts/serp_guard.py +115 -7
- package/scripts/serp_kill_log.py +25 -2
- package/scripts/stats_cli.py +13 -1
- package/scripts/wide_research.py +45 -0
- package/scripts/zhihu_quota.py +139 -0
- package/sub-skills/local-search/bm25.py +27 -3
- package/sub-skills/local-search/cli_runner.py +331 -0
- package/sub-skills/local-search/parsers.py +328 -0
- package/sub-skills/local-search/search_v3.py +31 -674
- package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
- package/assets/readme/settings-keys.png +0 -0
- package/assets/readme/settings-tools.png +0 -0
- package/scripts/image_vision/vision_probe +0 -0
- package/scripts/local_engines.py +0 -321
- package/sub-skills/ego-search/references/browser-runtime.md +0 -43
- package/sub-skills/ego-search/references/install.md +0 -67
- package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
- package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
- package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
- package/sub-skills/js-run/tests/test_jsrun.py +0 -165
- package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
- package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
- package/sub-skills/local-seek/references/git-integration.md +0 -54
- package/sub-skills/local-seek/references/remote-handoff.md +0 -46
- package/sub-skills/local-seek/references/rg-recipes.md +0 -96
- package/sub-skills/local-seek/references/strategies.md +0 -52
- package/sub-skills/local-seek/references/structural-search.md +0 -59
- package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
- package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
- package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
- package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
- package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
- package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
- package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
- package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
- 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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
775
|
-
|
|
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,
|