@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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/README.zh-CN.md +180 -0
  4. package/npm/bin/smart-search.js +68 -0
  5. package/npm/scripts/postinstall.js +87 -0
  6. package/npm/scripts/resolve-prerelease-version.js +108 -0
  7. package/npm/scripts/set-package-version.js +35 -0
  8. package/npm/scripts/sync-python-version.js +22 -0
  9. package/npm/scripts/test-wrapper-repair.js +137 -0
  10. package/npm/scripts/test.js +76 -0
  11. package/package.json +42 -0
  12. package/pyproject.toml +36 -0
  13. package/skills/smart-search-cli/SKILL.md +41 -0
  14. package/skills/smart-search-cli/agents/openai.yaml +3 -0
  15. package/skills/smart-search-cli/references/cli-contract.md +7 -0
  16. package/skills/smart-search-cli/references/cli-core.md +5 -0
  17. package/skills/smart-search-cli/references/command-patterns.md +12 -0
  18. package/skills/smart-search-cli/references/provider-routing.md +3 -0
  19. package/skills/smart-search-cli/references/regression-release.md +28 -0
  20. package/skills/smart-search-cli/references/setup-config.md +3 -0
  21. package/src/smart_search/__init__.py +1 -0
  22. package/src/smart_search/assets/skills/smart-search-cli/SKILL.md +41 -0
  23. package/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +3 -0
  24. package/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +7 -0
  25. package/src/smart_search/assets/skills/smart-search-cli/references/cli-core.md +5 -0
  26. package/src/smart_search/assets/skills/smart-search-cli/references/command-patterns.md +12 -0
  27. package/src/smart_search/assets/skills/smart-search-cli/references/provider-routing.md +3 -0
  28. package/src/smart_search/assets/skills/smart-search-cli/references/regression-release.md +28 -0
  29. package/src/smart_search/assets/skills/smart-search-cli/references/setup-config.md +3 -0
  30. package/src/smart_search/cli.py +3118 -0
  31. package/src/smart_search/config.py +809 -0
  32. package/src/smart_search/embedding_presets.py +40 -0
  33. package/src/smart_search/intent_router.py +757 -0
  34. package/src/smart_search/logger.py +43 -0
  35. package/src/smart_search/providers/__init__.py +20 -0
  36. package/src/smart_search/providers/base.py +41 -0
  37. package/src/smart_search/providers/context7.py +141 -0
  38. package/src/smart_search/providers/exa.py +206 -0
  39. package/src/smart_search/providers/jina.py +136 -0
  40. package/src/smart_search/providers/openai_compatible.py +541 -0
  41. package/src/smart_search/providers/xai_responses.py +117 -0
  42. package/src/smart_search/providers/zhipu.py +143 -0
  43. package/src/smart_search/providers/zhipu_mcp.py +230 -0
  44. package/src/smart_search/service.py +3445 -0
  45. package/src/smart_search/skill_installer.py +342 -0
  46. package/src/smart_search/sources.py +429 -0
  47. 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
+ )