@shirlytaylor73/smart-search 0.2.0-beta.1
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/LICENSE +21 -0
- package/README.md +188 -0
- package/README.zh-CN.md +180 -0
- package/npm/bin/smart-search.js +68 -0
- package/npm/scripts/postinstall.js +87 -0
- package/npm/scripts/resolve-prerelease-version.js +108 -0
- package/npm/scripts/set-package-version.js +35 -0
- package/npm/scripts/sync-python-version.js +22 -0
- package/npm/scripts/test-wrapper-repair.js +137 -0
- package/npm/scripts/test.js +76 -0
- package/package.json +42 -0
- package/pyproject.toml +36 -0
- package/skills/smart-search-cli/SKILL.md +41 -0
- package/skills/smart-search-cli/agents/openai.yaml +3 -0
- package/skills/smart-search-cli/references/cli-contract.md +7 -0
- package/skills/smart-search-cli/references/cli-core.md +5 -0
- package/skills/smart-search-cli/references/command-patterns.md +12 -0
- package/skills/smart-search-cli/references/provider-routing.md +3 -0
- package/skills/smart-search-cli/references/regression-release.md +28 -0
- package/skills/smart-search-cli/references/setup-config.md +3 -0
- package/src/smart_search/__init__.py +1 -0
- package/src/smart_search/assets/skills/smart-search-cli/SKILL.md +41 -0
- package/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +3 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +7 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/cli-core.md +5 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/command-patterns.md +12 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/provider-routing.md +3 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/regression-release.md +28 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/setup-config.md +3 -0
- package/src/smart_search/cli.py +3118 -0
- package/src/smart_search/config.py +809 -0
- package/src/smart_search/embedding_presets.py +40 -0
- package/src/smart_search/intent_router.py +757 -0
- package/src/smart_search/logger.py +43 -0
- package/src/smart_search/providers/__init__.py +20 -0
- package/src/smart_search/providers/base.py +41 -0
- package/src/smart_search/providers/context7.py +141 -0
- package/src/smart_search/providers/exa.py +206 -0
- package/src/smart_search/providers/jina.py +136 -0
- package/src/smart_search/providers/openai_compatible.py +541 -0
- package/src/smart_search/providers/xai_responses.py +117 -0
- package/src/smart_search/providers/zhipu.py +143 -0
- package/src/smart_search/providers/zhipu_mcp.py +230 -0
- package/src/smart_search/service.py +3445 -0
- package/src/smart_search/skill_installer.py +342 -0
- package/src/smart_search/sources.py +429 -0
- package/src/smart_search/utils.py +220 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from .config import config
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger("smart_search")
|
|
7
|
+
logger.setLevel(getattr(logging, config.log_level))
|
|
8
|
+
logger.addHandler(logging.NullHandler())
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _file_logging_enabled() -> bool:
|
|
12
|
+
return config.debug_enabled or config.log_to_file_enabled
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _configure_file_logging() -> None:
|
|
16
|
+
if not _file_logging_enabled():
|
|
17
|
+
return
|
|
18
|
+
if any(isinstance(handler, logging.FileHandler) for handler in logger.handlers):
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
log_dir = config.log_dir
|
|
22
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
log_file = log_dir / f"smart_search_{datetime.now().strftime('%Y%m%d')}.log"
|
|
24
|
+
|
|
25
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
26
|
+
file_handler.setLevel(getattr(logging, config.log_level))
|
|
27
|
+
|
|
28
|
+
formatter = logging.Formatter(
|
|
29
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
30
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
31
|
+
)
|
|
32
|
+
file_handler.setFormatter(formatter)
|
|
33
|
+
logger.addHandler(file_handler)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_configure_file_logging()
|
|
37
|
+
|
|
38
|
+
async def log_info(ctx, message: str, is_debug: bool = False):
|
|
39
|
+
if is_debug:
|
|
40
|
+
logger.info(message)
|
|
41
|
+
|
|
42
|
+
if ctx:
|
|
43
|
+
await ctx.info(message)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .base import BaseSearchProvider, SearchResult
|
|
2
|
+
from .context7 import Context7Provider
|
|
3
|
+
from .openai_compatible import OpenAICompatibleSearchProvider
|
|
4
|
+
from .xai_responses import XAIResponsesSearchProvider
|
|
5
|
+
from .exa import ExaSearchProvider
|
|
6
|
+
from .jina import JinaReaderProvider
|
|
7
|
+
from .zhipu import ZhipuWebSearchProvider
|
|
8
|
+
from .zhipu_mcp import ZhipuMCPProvider
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BaseSearchProvider",
|
|
12
|
+
"SearchResult",
|
|
13
|
+
"Context7Provider",
|
|
14
|
+
"OpenAICompatibleSearchProvider",
|
|
15
|
+
"XAIResponsesSearchProvider",
|
|
16
|
+
"ExaSearchProvider",
|
|
17
|
+
"JinaReaderProvider",
|
|
18
|
+
"ZhipuWebSearchProvider",
|
|
19
|
+
"ZhipuMCPProvider",
|
|
20
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Dict, List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SearchResult:
|
|
6
|
+
def __init__(
|
|
7
|
+
self,
|
|
8
|
+
title: str,
|
|
9
|
+
url: str,
|
|
10
|
+
snippet: str,
|
|
11
|
+
source: str = "",
|
|
12
|
+
published_date: str = "",
|
|
13
|
+
):
|
|
14
|
+
self.title = title
|
|
15
|
+
self.url = url
|
|
16
|
+
self.snippet = snippet
|
|
17
|
+
self.source = source
|
|
18
|
+
self.published_date = published_date
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> Dict[str, str]:
|
|
21
|
+
return {
|
|
22
|
+
"title": self.title,
|
|
23
|
+
"url": self.url,
|
|
24
|
+
"snippet": self.snippet,
|
|
25
|
+
"source": self.source,
|
|
26
|
+
"published_date": self.published_date,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BaseSearchProvider(ABC):
|
|
31
|
+
def __init__(self, api_url: str, api_key: str):
|
|
32
|
+
self.api_url = api_url
|
|
33
|
+
self.api_key = api_key
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
async def search(self, query: str, max_results: int = 5) -> List[SearchResult]:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def get_provider_name(self) -> str:
|
|
41
|
+
pass
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt, wait_random_exponential
|
|
8
|
+
|
|
9
|
+
from .base import BaseSearchProvider
|
|
10
|
+
from ..config import config
|
|
11
|
+
from ..logger import log_info
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_retryable_exception(exc) -> bool:
|
|
18
|
+
if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError, httpx.ConnectError)):
|
|
19
|
+
return True
|
|
20
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
21
|
+
return exc.response.status_code in RETRYABLE_STATUS_CODES
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _normalize_library(item: dict[str, Any]) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"id": item.get("id") or "",
|
|
28
|
+
"title": item.get("title") or "",
|
|
29
|
+
"description": item.get("description") or "",
|
|
30
|
+
"trust_score": item.get("trustScore"),
|
|
31
|
+
"benchmark_score": item.get("benchmarkScore"),
|
|
32
|
+
"total_snippets": item.get("totalSnippets"),
|
|
33
|
+
"stars": item.get("stars"),
|
|
34
|
+
"provider": "context7",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Context7Provider(BaseSearchProvider):
|
|
39
|
+
def __init__(self, api_url: str, api_key: str, timeout: float = 30.0):
|
|
40
|
+
super().__init__(api_url.rstrip("/"), api_key)
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
|
|
43
|
+
def get_provider_name(self) -> str:
|
|
44
|
+
return "Context7"
|
|
45
|
+
|
|
46
|
+
async def search(self, query: str, max_results: int = 5) -> str:
|
|
47
|
+
return await self.library(query)
|
|
48
|
+
|
|
49
|
+
def _headers(self) -> dict[str, str]:
|
|
50
|
+
headers = {
|
|
51
|
+
"Accept": "application/json, text/plain",
|
|
52
|
+
"X-Context7-Source": "smart-search",
|
|
53
|
+
}
|
|
54
|
+
if self.api_key:
|
|
55
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
56
|
+
return headers
|
|
57
|
+
|
|
58
|
+
async def library(self, name: str, query: str = "", ctx=None) -> str:
|
|
59
|
+
request_query = f"{name} {query}".strip()
|
|
60
|
+
endpoint = f"{self.api_url}/api/v2/search?query={quote(request_query)}"
|
|
61
|
+
await log_info(ctx, f"Context7 library: {request_query}", config.debug_enabled)
|
|
62
|
+
start_time = time.time()
|
|
63
|
+
try:
|
|
64
|
+
data = await self._get_with_retry(endpoint)
|
|
65
|
+
elapsed_ms = round((time.time() - start_time) * 1000, 2)
|
|
66
|
+
raw_results = data if isinstance(data, list) else data.get("results", [])
|
|
67
|
+
results = [_normalize_library(item) for item in raw_results or []]
|
|
68
|
+
output = {
|
|
69
|
+
"ok": True,
|
|
70
|
+
"query": request_query,
|
|
71
|
+
"provider": "context7",
|
|
72
|
+
"results": results,
|
|
73
|
+
"total": len(results),
|
|
74
|
+
"elapsed_ms": elapsed_ms,
|
|
75
|
+
}
|
|
76
|
+
except Exception as e:
|
|
77
|
+
elapsed_ms = round((time.time() - start_time) * 1000, 2)
|
|
78
|
+
output = {
|
|
79
|
+
"ok": False,
|
|
80
|
+
"query": request_query,
|
|
81
|
+
"provider": "context7",
|
|
82
|
+
"error": str(e),
|
|
83
|
+
"elapsed_ms": elapsed_ms,
|
|
84
|
+
}
|
|
85
|
+
return json.dumps(output, ensure_ascii=False, indent=2)
|
|
86
|
+
|
|
87
|
+
async def docs(self, library_id: str, query: str, ctx=None) -> str:
|
|
88
|
+
endpoint = f"{self.api_url}/api/v2/context?libraryId={quote(library_id, safe='')}&query={quote(query)}"
|
|
89
|
+
await log_info(ctx, f"Context7 docs: {library_id} {query}", config.debug_enabled)
|
|
90
|
+
start_time = time.time()
|
|
91
|
+
try:
|
|
92
|
+
data = await self._get_with_retry(endpoint)
|
|
93
|
+
elapsed_ms = round((time.time() - start_time) * 1000, 2)
|
|
94
|
+
snippets = data.get("codeSnippets", []) if isinstance(data, dict) else []
|
|
95
|
+
info = data.get("infoSnippets", []) if isinstance(data, dict) else []
|
|
96
|
+
content = json.dumps(data, ensure_ascii=False) if isinstance(data, dict) else str(data)
|
|
97
|
+
output = {
|
|
98
|
+
"ok": True,
|
|
99
|
+
"library_id": library_id,
|
|
100
|
+
"query": query,
|
|
101
|
+
"provider": "context7",
|
|
102
|
+
"code_snippets": snippets,
|
|
103
|
+
"info_snippets": info,
|
|
104
|
+
"results": snippets + info,
|
|
105
|
+
"total": len(snippets) + len(info),
|
|
106
|
+
"content": content,
|
|
107
|
+
"elapsed_ms": elapsed_ms,
|
|
108
|
+
}
|
|
109
|
+
except Exception as e:
|
|
110
|
+
elapsed_ms = round((time.time() - start_time) * 1000, 2)
|
|
111
|
+
output = {
|
|
112
|
+
"ok": False,
|
|
113
|
+
"library_id": library_id,
|
|
114
|
+
"query": query,
|
|
115
|
+
"provider": "context7",
|
|
116
|
+
"error": str(e),
|
|
117
|
+
"elapsed_ms": elapsed_ms,
|
|
118
|
+
}
|
|
119
|
+
return json.dumps(output, ensure_ascii=False, indent=2)
|
|
120
|
+
|
|
121
|
+
async def _get_with_retry(self, endpoint: str) -> Any:
|
|
122
|
+
timeout = httpx.Timeout(connect=6.0, read=self.timeout, write=10.0, pool=None)
|
|
123
|
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
124
|
+
async for attempt in AsyncRetrying(
|
|
125
|
+
stop=stop_after_attempt(config.retry_max_attempts + 1),
|
|
126
|
+
wait=wait_random_exponential(multiplier=config.retry_multiplier, max=config.retry_max_wait),
|
|
127
|
+
retry=retry_if_exception(_is_retryable_exception),
|
|
128
|
+
reraise=True,
|
|
129
|
+
):
|
|
130
|
+
with attempt:
|
|
131
|
+
response = await client.get(endpoint, headers=self._headers())
|
|
132
|
+
response.raise_for_status()
|
|
133
|
+
content_type = response.headers.get("content-type", "")
|
|
134
|
+
if "application/json" in content_type:
|
|
135
|
+
return response.json()
|
|
136
|
+
text = response.text
|
|
137
|
+
try:
|
|
138
|
+
return json.loads(text)
|
|
139
|
+
except json.JSONDecodeError:
|
|
140
|
+
return {"content": text, "results": []}
|
|
141
|
+
return {}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt, wait_random_exponential
|
|
7
|
+
|
|
8
|
+
from .base import BaseSearchProvider
|
|
9
|
+
from ..config import config
|
|
10
|
+
from ..logger import log_info
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_retryable_exception(exc) -> bool:
|
|
17
|
+
if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError, httpx.ConnectError)):
|
|
18
|
+
return True
|
|
19
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
20
|
+
return exc.response.status_code in RETRYABLE_STATUS_CODES
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _normalize_result(item: dict[str, Any], *, include_text: bool, include_highlights: bool) -> dict[str, Any]:
|
|
25
|
+
out = {
|
|
26
|
+
"id": item.get("id"),
|
|
27
|
+
"title": item.get("title") or "",
|
|
28
|
+
"url": item.get("url") or item.get("id") or "",
|
|
29
|
+
"publishedDate": item.get("publishedDate"),
|
|
30
|
+
"author": item.get("author") or "",
|
|
31
|
+
"score": item.get("score"),
|
|
32
|
+
}
|
|
33
|
+
if include_text and "text" in item:
|
|
34
|
+
out["text"] = item.get("text") or ""
|
|
35
|
+
if include_highlights and "highlights" in item:
|
|
36
|
+
out["highlights"] = item.get("highlights") or []
|
|
37
|
+
if "image" in item:
|
|
38
|
+
out["image"] = item.get("image")
|
|
39
|
+
if "favicon" in item:
|
|
40
|
+
out["favicon"] = item.get("favicon")
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _error_payload(exc: Exception) -> dict[str, Any]:
|
|
45
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
46
|
+
status_code = exc.response.status_code
|
|
47
|
+
body = exc.response.text.strip()
|
|
48
|
+
detail = f" - {body[:500]}" if body else ""
|
|
49
|
+
if status_code == 429:
|
|
50
|
+
error_type = "rate_limited"
|
|
51
|
+
elif status_code in {400, 422}:
|
|
52
|
+
error_type = "parameter_error"
|
|
53
|
+
elif status_code in {401, 403}:
|
|
54
|
+
error_type = "auth_error"
|
|
55
|
+
else:
|
|
56
|
+
error_type = "network_error"
|
|
57
|
+
return {"error_type": error_type, "error": f"HTTP {status_code}: {exc.response.reason_phrase}{detail}"}
|
|
58
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
59
|
+
return {"error_type": "timeout", "error": "request timed out"}
|
|
60
|
+
if isinstance(exc, httpx.RequestError):
|
|
61
|
+
return {"error_type": "network_error", "error": str(exc)}
|
|
62
|
+
return {"error_type": "runtime_error", "error": str(exc)}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ExaSearchProvider(BaseSearchProvider):
|
|
66
|
+
def __init__(self, api_url: str, api_key: str, timeout: float = 30.0):
|
|
67
|
+
super().__init__(api_url, api_key)
|
|
68
|
+
self.timeout = timeout
|
|
69
|
+
|
|
70
|
+
def get_provider_name(self) -> str:
|
|
71
|
+
return "Exa"
|
|
72
|
+
|
|
73
|
+
async def search(
|
|
74
|
+
self,
|
|
75
|
+
query: str,
|
|
76
|
+
num_results: int = 5,
|
|
77
|
+
search_type: str = "neural",
|
|
78
|
+
include_text: bool = False,
|
|
79
|
+
include_highlights: bool = False,
|
|
80
|
+
start_published_date: str | None = None,
|
|
81
|
+
include_domains: list[str] | None = None,
|
|
82
|
+
exclude_domains: list[str] | None = None,
|
|
83
|
+
category: str | None = None,
|
|
84
|
+
ctx=None,
|
|
85
|
+
) -> str:
|
|
86
|
+
endpoint = f"{self.api_url.rstrip('/')}/search"
|
|
87
|
+
headers = {
|
|
88
|
+
"accept": "application/json",
|
|
89
|
+
"content-type": "application/json",
|
|
90
|
+
"x-api-key": self.api_key,
|
|
91
|
+
}
|
|
92
|
+
payload: dict[str, Any] = {
|
|
93
|
+
"query": query,
|
|
94
|
+
"numResults": num_results,
|
|
95
|
+
"type": search_type,
|
|
96
|
+
"useAutoprompt": True,
|
|
97
|
+
}
|
|
98
|
+
if include_text or include_highlights:
|
|
99
|
+
payload["contents"] = {
|
|
100
|
+
"text": include_text,
|
|
101
|
+
"highlights": include_highlights,
|
|
102
|
+
}
|
|
103
|
+
if start_published_date:
|
|
104
|
+
payload["startPublishedDate"] = start_published_date
|
|
105
|
+
if include_domains:
|
|
106
|
+
payload["includeDomains"] = include_domains
|
|
107
|
+
if exclude_domains:
|
|
108
|
+
payload["excludeDomains"] = exclude_domains
|
|
109
|
+
if category:
|
|
110
|
+
payload["category"] = category
|
|
111
|
+
|
|
112
|
+
await log_info(ctx, f"Exa search: {query}", config.debug_enabled)
|
|
113
|
+
|
|
114
|
+
start_time = time.time()
|
|
115
|
+
try:
|
|
116
|
+
data = await self._request_with_retry(endpoint, headers, payload, ctx)
|
|
117
|
+
elapsed_ms = round((time.time() - start_time) * 1000, 2)
|
|
118
|
+
|
|
119
|
+
results = [
|
|
120
|
+
_normalize_result(item, include_text=include_text, include_highlights=include_highlights)
|
|
121
|
+
for item in data.get("results", [])
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
output = {
|
|
125
|
+
"ok": True,
|
|
126
|
+
"query": query,
|
|
127
|
+
"search_type": search_type,
|
|
128
|
+
"results": results,
|
|
129
|
+
"total": len(results),
|
|
130
|
+
"elapsed_ms": elapsed_ms,
|
|
131
|
+
}
|
|
132
|
+
except Exception as e:
|
|
133
|
+
elapsed_ms = round((time.time() - start_time) * 1000, 2)
|
|
134
|
+
error = _error_payload(e)
|
|
135
|
+
output = {
|
|
136
|
+
"ok": False,
|
|
137
|
+
"query": query,
|
|
138
|
+
"error_type": error["error_type"],
|
|
139
|
+
"error": error["error"],
|
|
140
|
+
"elapsed_ms": elapsed_ms,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await log_info(ctx, "Exa search finished!", config.debug_enabled)
|
|
144
|
+
return json.dumps(output, ensure_ascii=False, indent=2)
|
|
145
|
+
|
|
146
|
+
async def find_similar(self, url: str, num_results: int = 5, ctx=None) -> str:
|
|
147
|
+
endpoint = f"{self.api_url.rstrip('/')}/findSimilar"
|
|
148
|
+
headers = {
|
|
149
|
+
"accept": "application/json",
|
|
150
|
+
"content-type": "application/json",
|
|
151
|
+
"x-api-key": self.api_key,
|
|
152
|
+
}
|
|
153
|
+
payload = {
|
|
154
|
+
"url": url,
|
|
155
|
+
"numResults": num_results,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await log_info(ctx, f"Exa find_similar: {url}", config.debug_enabled)
|
|
159
|
+
|
|
160
|
+
start_time = time.time()
|
|
161
|
+
try:
|
|
162
|
+
data = await self._request_with_retry(endpoint, headers, payload, ctx)
|
|
163
|
+
elapsed_ms = round((time.time() - start_time) * 1000, 2)
|
|
164
|
+
|
|
165
|
+
results = [
|
|
166
|
+
_normalize_result(item, include_text=False, include_highlights=False)
|
|
167
|
+
for item in data.get("results", [])
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
output = {
|
|
171
|
+
"ok": True,
|
|
172
|
+
"url": url,
|
|
173
|
+
"results": results,
|
|
174
|
+
"total": len(results),
|
|
175
|
+
"elapsed_ms": elapsed_ms,
|
|
176
|
+
}
|
|
177
|
+
except Exception as e:
|
|
178
|
+
elapsed_ms = round((time.time() - start_time) * 1000, 2)
|
|
179
|
+
error = _error_payload(e)
|
|
180
|
+
output = {
|
|
181
|
+
"ok": False,
|
|
182
|
+
"url": url,
|
|
183
|
+
"error_type": error["error_type"],
|
|
184
|
+
"error": error["error"],
|
|
185
|
+
"elapsed_ms": elapsed_ms,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await log_info(ctx, "Exa find_similar finished!", config.debug_enabled)
|
|
189
|
+
return json.dumps(output, ensure_ascii=False, indent=2)
|
|
190
|
+
|
|
191
|
+
async def _request_with_retry(
|
|
192
|
+
self, endpoint: str, headers: dict, payload: dict, ctx=None
|
|
193
|
+
) -> dict[str, Any]:
|
|
194
|
+
timeout = httpx.Timeout(connect=6.0, read=self.timeout, write=10.0, pool=None)
|
|
195
|
+
|
|
196
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
197
|
+
async for attempt in AsyncRetrying(
|
|
198
|
+
stop=stop_after_attempt(config.retry_max_attempts + 1),
|
|
199
|
+
wait=wait_random_exponential(multiplier=config.retry_multiplier, max=config.retry_max_wait),
|
|
200
|
+
retry=retry_if_exception(_is_retryable_exception),
|
|
201
|
+
reraise=True,
|
|
202
|
+
):
|
|
203
|
+
with attempt:
|
|
204
|
+
response = await client.post(endpoint, headers=headers, json=payload)
|
|
205
|
+
response.raise_for_status()
|
|
206
|
+
return response.json()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CHALLENGE_MARKERS = (
|
|
9
|
+
"title: just a moment",
|
|
10
|
+
"checking if the site connection is secure",
|
|
11
|
+
"attention required! | cloudflare",
|
|
12
|
+
"enable javascript and cookies to continue",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _elapsed_ms(start: float) -> float:
|
|
17
|
+
return round((time.time() - start) * 1000, 2)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _error_payload(exc: Exception) -> dict[str, str]:
|
|
21
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
22
|
+
status_code = exc.response.status_code
|
|
23
|
+
if status_code in {401, 403}:
|
|
24
|
+
error_type = "auth_error"
|
|
25
|
+
elif status_code == 422:
|
|
26
|
+
error_type = "parameter_error"
|
|
27
|
+
elif status_code == 429:
|
|
28
|
+
error_type = "rate_limited"
|
|
29
|
+
else:
|
|
30
|
+
error_type = "network_error"
|
|
31
|
+
body = (exc.response.text or exc.response.reason_phrase or "")[:300]
|
|
32
|
+
return {"error_type": error_type, "error": f"HTTP {status_code}: {body}"}
|
|
33
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
34
|
+
return {"error_type": "timeout", "error": "request timed out"}
|
|
35
|
+
if isinstance(exc, httpx.RequestError):
|
|
36
|
+
return {"error_type": "network_error", "error": str(exc)}
|
|
37
|
+
return {"error_type": "runtime_error", "error": str(exc)}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _mask_secret(text: str, secret: str) -> str:
|
|
41
|
+
return text.replace(secret, "***") if secret else text
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _quality_error(content: str) -> str:
|
|
45
|
+
lower = content.strip().lower()
|
|
46
|
+
if not lower:
|
|
47
|
+
return "empty response"
|
|
48
|
+
for marker in CHALLENGE_MARKERS:
|
|
49
|
+
if marker in lower:
|
|
50
|
+
return f"low-quality challenge page detected: {marker}"
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class JinaReaderProvider:
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
reader_api_url: str,
|
|
58
|
+
api_key: str | None = None,
|
|
59
|
+
respond_with: str = "",
|
|
60
|
+
timeout: float = 30.0,
|
|
61
|
+
):
|
|
62
|
+
self.reader_api_url = reader_api_url.rstrip("/")
|
|
63
|
+
self.api_key = api_key or ""
|
|
64
|
+
self.respond_with = respond_with.strip()
|
|
65
|
+
self.timeout = timeout
|
|
66
|
+
|
|
67
|
+
async def fetch(self, url: str) -> str:
|
|
68
|
+
start = time.time()
|
|
69
|
+
if self.respond_with and not self.api_key:
|
|
70
|
+
return json.dumps(
|
|
71
|
+
{
|
|
72
|
+
"ok": False,
|
|
73
|
+
"provider": "jina",
|
|
74
|
+
"url": url,
|
|
75
|
+
"error_type": "config_error",
|
|
76
|
+
"error": "JINA_RESPOND_WITH requires JINA_API_KEY.",
|
|
77
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
78
|
+
},
|
|
79
|
+
ensure_ascii=False,
|
|
80
|
+
indent=2,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
headers = {"X-Return-Format": "markdown", "Accept": "text/plain, text/markdown, */*"}
|
|
84
|
+
if self.respond_with:
|
|
85
|
+
headers["X-Respond-With"] = self.respond_with
|
|
86
|
+
if self.api_key:
|
|
87
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
88
|
+
|
|
89
|
+
endpoint = f"{self.reader_api_url}/{url}"
|
|
90
|
+
try:
|
|
91
|
+
timeout = httpx.Timeout(connect=6.0, read=self.timeout, write=10.0, pool=None)
|
|
92
|
+
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
|
93
|
+
response = await client.get(endpoint, headers=headers)
|
|
94
|
+
response.raise_for_status()
|
|
95
|
+
content = response.text.strip()
|
|
96
|
+
quality_error = _quality_error(content)
|
|
97
|
+
if quality_error:
|
|
98
|
+
return json.dumps(
|
|
99
|
+
{
|
|
100
|
+
"ok": False,
|
|
101
|
+
"provider": "jina",
|
|
102
|
+
"url": url,
|
|
103
|
+
"error_type": "quality_error",
|
|
104
|
+
"error": quality_error,
|
|
105
|
+
"content": content,
|
|
106
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
107
|
+
},
|
|
108
|
+
ensure_ascii=False,
|
|
109
|
+
indent=2,
|
|
110
|
+
)
|
|
111
|
+
return json.dumps(
|
|
112
|
+
{
|
|
113
|
+
"ok": True,
|
|
114
|
+
"provider": "jina",
|
|
115
|
+
"url": url,
|
|
116
|
+
"content": content,
|
|
117
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
118
|
+
},
|
|
119
|
+
ensure_ascii=False,
|
|
120
|
+
indent=2,
|
|
121
|
+
)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
error = _error_payload(e)
|
|
124
|
+
message = _mask_secret(error["error"], self.api_key)
|
|
125
|
+
return json.dumps(
|
|
126
|
+
{
|
|
127
|
+
"ok": False,
|
|
128
|
+
"provider": "jina",
|
|
129
|
+
"url": url,
|
|
130
|
+
"error_type": error["error_type"],
|
|
131
|
+
"error": message,
|
|
132
|
+
"elapsed_ms": _elapsed_ms(start),
|
|
133
|
+
},
|
|
134
|
+
ensure_ascii=False,
|
|
135
|
+
indent=2,
|
|
136
|
+
)
|