web-search-plus-plugin 1.2.1 → 1.2.3

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/.env.template CHANGED
@@ -4,6 +4,9 @@ SERPER_API_KEY=your-serper-key-here
4
4
  # Tavily (Research Search) — https://tavily.com
5
5
  TAVILY_API_KEY=your-tavily-key-here
6
6
 
7
+ # Querit (Multilingual AI Search) — https://querit.ai
8
+ QUERIT_API_KEY=your-querit-api-key-here
9
+
7
10
  # Exa (Neural/Deep Search) — https://exa.ai
8
11
  EXA_API_KEY=your-exa-key-here
9
12
 
package/README.md CHANGED
@@ -7,7 +7,7 @@ A standalone OpenClaw plugin that registers `web_search_plus` as a first-class t
7
7
  ## ✨ Features
8
8
 
9
9
  - **Intelligent auto-routing** — analyzes query intent and picks the best provider automatically
10
- - **6 search providers** — use one or all, graceful fallback if any is down
10
+ - **7 search providers** — use one or all, graceful fallback if any is down
11
11
  - **Local result caching** — saves API costs on repeated queries
12
12
  - **Interactive setup wizard** — guided configuration via `python3 scripts/setup.py`
13
13
  - **Native OpenClaw tool** — registers as `web_search_plus`, not a skill
@@ -18,6 +18,7 @@ A standalone OpenClaw plugin that registers `web_search_plus` as a first-class t
18
18
  |----------|----------|-----------|
19
19
  | **Serper** (Google) | Facts, news, shopping, local businesses | 2,500 queries/month |
20
20
  | **Tavily** | Deep research, analysis, explanations | 1,000 queries/month |
21
+ | **Querit** | Multi-lingual AI search with rich metadata and real-time info | 1,000 queries/month |
21
22
  | **Exa** (Neural) | Semantic discovery, finding similar content | 1,000 queries/month |
22
23
  | **Perplexity** | AI-synthesized answers with citations | Via API key |
23
24
  | **You.com** | Real-time RAG, LLM-ready snippets | Limited free tier |
@@ -31,6 +32,7 @@ The plugin analyzes your query and picks the best provider:
31
32
  |-------|-----------|-----|
32
33
  | "iPhone 16 Pro price" | Serper | Shopping intent detected |
33
34
  | "how does TCP/IP work" | Tavily | Research/explanation intent |
35
+ | "latest multilingual EV market updates" | Querit | Real-time AI search with metadata-rich results |
34
36
  | "companies like Stripe" | Exa | Discovery/semantic intent |
35
37
  | "what is quantum computing" | Perplexity | Direct answer intent |
36
38
  | "latest news AI regulation" | Serper | News intent |
@@ -87,6 +89,7 @@ Copy `.env.template` to `.env` and add at least one API key:
87
89
  |----------|----------|---------|
88
90
  | `SERPER_API_KEY` | Serper (Google) | [console.serper.dev](https://console.serper.dev) |
89
91
  | `TAVILY_API_KEY` | Tavily | [tavily.com](https://tavily.com) |
92
+ | `QUERIT_API_KEY` | Querit | [querit.ai](https://querit.ai) |
90
93
  | `EXA_API_KEY` | Exa | [exa.ai](https://exa.ai) |
91
94
  | `PERPLEXITY_API_KEY` | Perplexity | [perplexity.ai](https://docs.perplexity.ai) |
92
95
  | `KILOCODE_API_KEY` | Perplexity via Kilo | [kilocode.ai](https://kilocode.ai) |
@@ -119,7 +122,7 @@ The registered `web_search_plus` tool accepts:
119
122
  | Parameter | Type | Required | Description |
120
123
  |-----------|------|----------|-------------|
121
124
  | `query` | string | ✅ | Search query |
122
- | `provider` | string | ❌ | Force a provider: `serper`, `tavily`, `exa`, `perplexity`, `you`, `searxng`, or `auto` (default) |
125
+ | `provider` | string | ❌ | Force a provider: `serper`, `tavily`, `querit`, `exa`, `perplexity`, `you`, `searxng`, or `auto` (default) |
123
126
  | `count` | number | ❌ | Number of results (default: 5) |
124
127
 
125
128
  ## 🧪 Test Directly
@@ -139,7 +142,7 @@ python3 scripts/search.py -q "your query" --max-results 10
139
142
 
140
143
  ## ❓ FAQ
141
144
 
142
- ### Do I need all 6 providers?
145
+ ### Do I need all 7 providers?
143
146
  No. The plugin works with just one API key. Configure whichever providers you have — the auto-router will use what's available and skip what's not.
144
147
 
145
148
  ### What's the difference between this plugin and the `web-search-plus` skill?
@@ -149,7 +152,7 @@ The **plugin** registers a native tool that any agent can use directly. The **sk
149
152
  Yes, Python 3 is required. The search logic runs as a Python script. Most Linux servers and macOS have Python 3 pre-installed.
150
153
 
151
154
  ### How does auto-routing work?
152
- The router scores each provider based on query signals — keywords like "price" or "buy" boost Serper, research-oriented queries boost Tavily, semantic/discovery queries boost Exa, and direct questions boost Perplexity. The highest-scoring provider wins.
155
+ The router scores each provider based on query signals — keywords like "price" or "buy" boost Serper, deep explanation queries boost Tavily, multilingual or metadata-rich real-time search can favor Querit, semantic/discovery queries boost Exa, and direct questions boost Perplexity. The highest-scoring provider wins.
153
156
 
154
157
  ### Does it cache results?
155
158
  Yes. Results are cached locally in a `.cache/` directory inside the plugin folder. Identical queries return cached results instantly and don't consume API credits. Cache is file-based and survives restarts.
package/index.ts CHANGED
@@ -41,6 +41,7 @@ export default function (api: any) {
41
41
  const configKeyMap: Record<string, string> = {
42
42
  serperApiKey: "SERPER_API_KEY",
43
43
  tavilyApiKey: "TAVILY_API_KEY",
44
+ queritApiKey: "QUERIT_API_KEY",
44
45
  exaApiKey: "EXA_API_KEY",
45
46
  perplexityApiKey: "PERPLEXITY_API_KEY",
46
47
  kilocodeApiKey: "KILOCODE_API_KEY",
@@ -56,7 +57,7 @@ export default function (api: any) {
56
57
  {
57
58
  name: "web_search_plus",
58
59
  description:
59
- "Search the web using multi-provider intelligent routing (Serper/Google, Tavily/Research, Exa/Neural+Deep, Perplexity, You.com, SearXNG). Automatically selects the best provider based on query intent. Use for ALL web searches. Set depth='deep' for multi-source synthesis, 'deep-reasoning' for complex cross-document analysis.",
60
+ "Search the web using multi-provider intelligent routing (Serper/Google, Tavily/Research, Querit/Multilingual AI Search, Exa/Neural+Deep, Perplexity, You.com, SearXNG). Automatically selects the best provider based on query intent. Use for ALL web searches. Set depth='deep' for multi-source synthesis, 'deep-reasoning' for complex cross-document analysis.",
60
61
  parameters: Type.Object({
61
62
  query: Type.String({ description: "Search query" }),
62
63
  provider: Type.Optional(
@@ -64,6 +65,7 @@ export default function (api: any) {
64
65
  [
65
66
  Type.Literal("serper"),
66
67
  Type.Literal("tavily"),
68
+ Type.Literal("querit"),
67
69
  Type.Literal("exa"),
68
70
  Type.Literal("perplexity"),
69
71
  Type.Literal("you"),
@@ -88,10 +90,36 @@ export default function (api: any) {
88
90
  ],
89
91
  {
90
92
  description:
91
- "Exa search depth: 'deep' synthesizes across sources (4-12s), 'deep-reasoning' for complex cross-reference analysis (12-50s). Only applies when routed to Exa.",
93
+ "Exa search depth: 'deep' synthesizes across sources (4-12s), 'deep-reasoning' for complex cross-reference analysis (12-50s). When provider is auto, depth may be auto-selected based on query complexity.",
92
94
  },
93
95
  ),
94
96
  ),
97
+ time_range: Type.Optional(
98
+ Type.Union(
99
+ [
100
+ Type.Literal("day"),
101
+ Type.Literal("week"),
102
+ Type.Literal("month"),
103
+ Type.Literal("year"),
104
+ ],
105
+ {
106
+ description:
107
+ "Filter results by recency. Applies to Serper (as tbs), Perplexity (as search_recency_filter), Tavily/You.com (as freshness). Useful for news and current events.",
108
+ },
109
+ ),
110
+ ),
111
+ include_domains: Type.Optional(
112
+ Type.Array(Type.String(), {
113
+ description:
114
+ "Only include results from these domains (e.g. ['arxiv.org', 'github.com']). Supported by Tavily and Exa.",
115
+ }),
116
+ ),
117
+ exclude_domains: Type.Optional(
118
+ Type.Array(Type.String(), {
119
+ description:
120
+ "Exclude results from these domains (e.g. ['reddit.com', 'pinterest.com']). Supported by Tavily and Exa.",
121
+ }),
122
+ ),
95
123
  }),
96
124
  async execute(
97
125
  _id: string,
@@ -100,6 +128,9 @@ export default function (api: any) {
100
128
  provider?: string;
101
129
  count?: number;
102
130
  depth?: string;
131
+ time_range?: string;
132
+ include_domains?: string[];
133
+ exclude_domains?: string[];
103
134
  },
104
135
  ) {
105
136
  const args = [scriptPath, "--query", params.query, "--compact"];
@@ -119,6 +150,19 @@ export default function (api: any) {
119
150
  args.push("--exa-depth", params.depth);
120
151
  }
121
152
 
153
+ if (params.time_range) {
154
+ args.push("--time-range", params.time_range);
155
+ args.push("--freshness", params.time_range);
156
+ }
157
+
158
+ if (params.include_domains?.length) {
159
+ args.push("--include-domains", ...params.include_domains);
160
+ }
161
+
162
+ if (params.exclude_domains?.length) {
163
+ args.push("--exclude-domains", ...params.exclude_domains);
164
+ }
165
+
122
166
  const envPaths = [
123
167
  path.join(PLUGIN_DIR, ".env"),
124
168
  path.join(PLUGIN_DIR, "..", "web-search-plus", ".env"),
@@ -131,7 +175,7 @@ export default function (api: any) {
131
175
 
132
176
  try {
133
177
  const child = spawnSync("python3", args, {
134
- timeout: 65000,
178
+ timeout: 75000,
135
179
  env: childEnv,
136
180
  shell: false,
137
181
  encoding: "utf8",
@@ -2,14 +2,15 @@
2
2
  "id": "web-search-plus-plugin",
3
3
  "kind": "skill",
4
4
  "name": "Web Search Plus",
5
- "version": "1.2.1",
6
- "description": "Multi-provider web search (Serper/Google, Tavily, Exa/Neural+Deep, Perplexity, You.com, SearXNG) with intelligent auto-routing",
5
+ "version": "1.2.3",
6
+ "description": "Multi-provider web search (Serper/Google, Tavily, Querit/Multilingual AI Search, Exa/Neural+Deep, Perplexity, You.com, SearXNG) with intelligent auto-routing",
7
7
  "configSchema": {
8
8
  "type": "object",
9
9
  "additionalProperties": false,
10
10
  "properties": {
11
11
  "serperApiKey": { "type": "string" },
12
12
  "tavilyApiKey": { "type": "string" },
13
+ "queritApiKey": { "type": "string" },
13
14
  "exaApiKey": { "type": "string" },
14
15
  "perplexityApiKey": { "type": "string" },
15
16
  "kilocodeApiKey": { "type": "string" },
@@ -20,6 +21,7 @@
20
21
  "uiHints": {
21
22
  "serperApiKey": { "label": "Serper API Key", "placeholder": "sk-...", "sensitive": true },
22
23
  "tavilyApiKey": { "label": "Tavily API Key", "placeholder": "tvly-...", "sensitive": true },
24
+ "queritApiKey": { "label": "Querit API Key", "placeholder": "querit-sk-...", "sensitive": true },
23
25
  "exaApiKey": { "label": "Exa API Key", "placeholder": "exa-...", "sensitive": true },
24
26
  "perplexityApiKey": { "label": "Perplexity API Key", "placeholder": "pplx-...", "sensitive": true },
25
27
  "kilocodeApiKey": { "label": "Kilo Gateway API Key", "placeholder": "...", "sensitive": true },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "web-search-plus-plugin",
3
- "version": "1.2.1",
4
- "description": "OpenClaw plugin: multi-provider web search (Serper/Google, Tavily, Exa/Neural+Deep, Perplexity, You.com, SearXNG) with intelligent auto-routing",
3
+ "version": "1.2.3",
4
+ "description": "OpenClaw plugin: multi-provider web search (Serper/Google, Tavily, Querit/Multilingual AI Search, Exa/Neural+Deep, Perplexity, You.com, SearXNG) with intelligent auto-routing",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "files": [
@@ -12,7 +12,7 @@
12
12
  "README.md",
13
13
  "LICENSE"
14
14
  ],
15
- "keywords": ["openclaw", "plugin", "search", "serper", "tavily", "exa", "exa-deep", "perplexity", "you", "searxng", "web-search", "auto-routing"],
15
+ "keywords": ["openclaw", "plugin", "search", "serper", "tavily", "querit", "exa", "exa-deep", "perplexity", "you", "searxng", "web-search", "auto-routing"],
16
16
  "repository": {
17
17
  "type": "git",
18
18
  "url": "https://github.com/robbyczgw-cla/web-search-plus-plugin"
package/scripts/search.py CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
3
  Web Search Plus — Unified Multi-Provider Search with Intelligent Auto-Routing
4
- Supports: Serper (Google), Tavily (Research), Exa (Neural), Perplexity (Direct Answers)
4
+ Supports: Serper (Google), Tavily (Research), Querit (Multilingual AI Search),
5
+ Exa (Neural), Perplexity (Direct Answers)
5
6
 
6
7
  Smart Routing uses multi-signal analysis:
7
8
  - Query intent classification (shopping, research, discovery)
@@ -12,7 +13,7 @@ Smart Routing uses multi-signal analysis:
12
13
 
13
14
  Usage:
14
15
  python3 search.py --query "..." # Auto-route based on query
15
- python3 search.py --provider [serper|tavily|exa] --query "..." [options]
16
+ python3 search.py --provider [serper|tavily|querit|exa] --query "..." [options]
16
17
 
17
18
  Examples:
18
19
  python3 search.py -q "iPhone 16 Pro price" # → Serper (shopping intent)
@@ -21,6 +22,7 @@ Examples:
21
22
  """
22
23
 
23
24
  import argparse
25
+ from http.client import IncompleteRead
24
26
  import hashlib
25
27
  import json
26
28
  import os
@@ -275,7 +277,7 @@ DEFAULT_CONFIG = {
275
277
  "auto_routing": {
276
278
  "enabled": True,
277
279
  "fallback_provider": "serper",
278
- "provider_priority": ["tavily", "exa", "perplexity", "serper", "you", "searxng"],
280
+ "provider_priority": ["tavily", "querit", "exa", "perplexity", "serper", "you", "searxng"],
279
281
  "disabled_providers": [],
280
282
  "confidence_threshold": 0.3, # Below this, note low confidence
281
283
  },
@@ -288,6 +290,11 @@ DEFAULT_CONFIG = {
288
290
  "depth": "basic",
289
291
  "topic": "general"
290
292
  },
293
+ "querit": {
294
+ "base_url": "https://api.querit.ai",
295
+ "base_path": "/v1/search",
296
+ "timeout": 10
297
+ },
291
298
  "exa": {
292
299
  "type": "neural",
293
300
  "depth": "normal",
@@ -358,6 +365,7 @@ def get_api_key(provider: str, config: Dict[str, Any] = None) -> Optional[str]:
358
365
  key_map = {
359
366
  "serper": "SERPER_API_KEY",
360
367
  "tavily": "TAVILY_API_KEY",
368
+ "querit": "QUERIT_API_KEY",
361
369
  "exa": "EXA_API_KEY",
362
370
  "you": "YOU_API_KEY",
363
371
  }
@@ -459,24 +467,23 @@ def validate_api_key(provider: str, config: Dict[str, Any] = None) -> str:
459
467
  ],
460
468
  "provider": provider
461
469
  }
462
- print(json.dumps(error_msg, indent=2), file=sys.stderr)
463
- sys.exit(1)
464
-
470
+ raise ProviderConfigError(json.dumps(error_msg))
471
+
465
472
  # Validate URL format
466
473
  if not key.startswith(("http://", "https://")):
467
- print(json.dumps({
474
+ raise ProviderConfigError(json.dumps({
468
475
  "error": "SearXNG instance URL must start with http:// or https://",
469
476
  "provided": key,
470
477
  "provider": provider
471
- }, indent=2), file=sys.stderr)
472
- sys.exit(1)
478
+ }))
473
479
 
474
480
  return key
475
481
 
476
482
  if not key:
477
483
  env_var = {
478
484
  "serper": "SERPER_API_KEY",
479
- "tavily": "TAVILY_API_KEY",
485
+ "tavily": "TAVILY_API_KEY",
486
+ "querit": "QUERIT_API_KEY",
480
487
  "exa": "EXA_API_KEY",
481
488
  "you": "YOU_API_KEY",
482
489
  "perplexity": "KILOCODE_API_KEY"
@@ -485,6 +492,7 @@ def validate_api_key(provider: str, config: Dict[str, Any] = None) -> str:
485
492
  urls = {
486
493
  "serper": "https://serper.dev",
487
494
  "tavily": "https://tavily.com",
495
+ "querit": "https://querit.ai",
488
496
  "exa": "https://exa.ai",
489
497
  "you": "https://api.you.com",
490
498
  "perplexity": "https://api.kilo.ai"
@@ -500,16 +508,14 @@ def validate_api_key(provider: str, config: Dict[str, Any] = None) -> str:
500
508
  ],
501
509
  "provider": provider
502
510
  }
503
- print(json.dumps(error_msg, indent=2), file=sys.stderr)
504
- sys.exit(1)
505
-
511
+ raise ProviderConfigError(json.dumps(error_msg))
512
+
506
513
  if len(key) < 10:
507
- print(json.dumps({
514
+ raise ProviderConfigError(json.dumps({
508
515
  "error": f"API key for {provider} appears invalid (too short)",
509
516
  "provider": provider
510
- }, indent=2), file=sys.stderr)
511
- sys.exit(1)
512
-
517
+ }))
518
+
513
519
  return key
514
520
 
515
521
 
@@ -1165,6 +1171,7 @@ class QueryAnalyzer:
1165
1171
  provider_scores = {
1166
1172
  "serper": shopping_score + local_news_score + (recency_score * 0.35),
1167
1173
  "tavily": research_score + (complexity["complexity_score"] if not complexity["is_complex"] else 0) + (0.2 * recency_score),
1174
+ "querit": (research_score * 0.65) + (rag_score * 0.35) + (recency_score * 0.45),
1168
1175
  "exa": discovery_score + (1.0 if re.search(r"\b(similar|alternatives?|examples?)\b", query, re.IGNORECASE) else 0.0) + (exa_deep_score * 0.5) + (exa_deep_reasoning_score * 0.5),
1169
1176
  "perplexity": direct_answer_score + (local_news_score * 0.4) + (recency_score * 0.55),
1170
1177
  "you": rag_score + (recency_score * 0.25), # You.com good for real-time + RAG
@@ -1175,6 +1182,7 @@ class QueryAnalyzer:
1175
1182
  provider_matches = {
1176
1183
  "serper": shopping_matches + local_news_matches,
1177
1184
  "tavily": research_matches,
1185
+ "querit": research_matches,
1178
1186
  "exa": discovery_matches + exa_deep_matches + exa_deep_reasoning_matches,
1179
1187
  "perplexity": direct_answer_matches,
1180
1188
  "you": rag_matches,
@@ -1225,7 +1233,7 @@ class QueryAnalyzer:
1225
1233
  total_score = sum(available.values()) or 1.0
1226
1234
 
1227
1235
  # Handle ties using priority
1228
- priority = self.auto_config.get("provider_priority", ["tavily", "exa", "perplexity", "serper", "you", "searxng"])
1236
+ priority = self.auto_config.get("provider_priority", ["tavily", "querit", "exa", "perplexity", "serper", "you", "searxng"])
1229
1237
  winners = [p for p, s in available.items() if s == max_score]
1230
1238
 
1231
1239
  if len(winners) > 1:
@@ -1341,6 +1349,7 @@ def explain_routing(query: str, config: Dict[str, Any]) -> Dict[str, Any]:
1341
1349
  "intent_breakdown": {
1342
1350
  "shopping_signals": len(analysis["provider_matches"]["serper"]),
1343
1351
  "research_signals": len(analysis["provider_matches"]["tavily"]),
1352
+ "querit_signals": len(analysis["provider_matches"]["querit"]),
1344
1353
  "discovery_signals": len(analysis["provider_matches"]["exa"]),
1345
1354
  "rag_signals": len(analysis["provider_matches"]["you"]),
1346
1355
  "exa_deep_score": round(analysis.get("exa_deep_score", 0), 2),
@@ -1362,7 +1371,7 @@ def explain_routing(query: str, config: Dict[str, Any]) -> Dict[str, Any]:
1362
1371
  if matches
1363
1372
  },
1364
1373
  "available_providers": [
1365
- p for p in ["serper", "tavily", "exa", "perplexity", "you", "searxng"]
1374
+ p for p in ["serper", "tavily", "querit", "exa", "perplexity", "you", "searxng"]
1366
1375
  if get_api_key(p, config) and p not in config.get("auto_routing", {}).get("disabled_providers", [])
1367
1376
  ]
1368
1377
  }
@@ -1370,6 +1379,11 @@ def explain_routing(query: str, config: Dict[str, Any]) -> Dict[str, Any]:
1370
1379
 
1371
1380
 
1372
1381
 
1382
+ class ProviderConfigError(Exception):
1383
+ """Raised when a provider is missing or has an invalid API key/config."""
1384
+ pass
1385
+
1386
+
1373
1387
  class ProviderRequestError(Exception):
1374
1388
  """Structured provider error with retry/cooldown metadata."""
1375
1389
 
@@ -1437,6 +1451,24 @@ def reset_provider_health(provider: str) -> None:
1437
1451
  _save_provider_health(state)
1438
1452
 
1439
1453
 
1454
+ def _title_from_url(url: str) -> str:
1455
+ """Derive a readable title from a URL when none is provided."""
1456
+ try:
1457
+ parsed = urlparse(url)
1458
+ domain = parsed.netloc.replace("www.", "")
1459
+ # Use last meaningful path segment as context
1460
+ segments = [s for s in parsed.path.strip("/").split("/") if s]
1461
+ if segments:
1462
+ last = segments[-1].replace("-", " ").replace("_", " ")
1463
+ # Strip file extensions
1464
+ last = re.sub(r'\.\w{2,4}$', '', last)
1465
+ if last:
1466
+ return f"{domain} — {last[:80]}"
1467
+ return domain
1468
+ except Exception:
1469
+ return url[:60]
1470
+
1471
+
1440
1472
  def normalize_result_url(url: str) -> str:
1441
1473
  if not url:
1442
1474
  return ""
@@ -1504,6 +1536,12 @@ def make_request(url: str, headers: dict, body: dict, timeout: int = 30) -> dict
1504
1536
  reason = str(getattr(e, "reason", e))
1505
1537
  is_timeout = "timed out" in reason.lower()
1506
1538
  raise ProviderRequestError(f"Network error: {reason}. Check your internet connection.", transient=is_timeout)
1539
+ except IncompleteRead as e:
1540
+ partial_len = len(getattr(e, "partial", b"") or b"")
1541
+ raise ProviderRequestError(
1542
+ f"Connection interrupted while reading response ({partial_len} bytes received). Please retry.",
1543
+ transient=True,
1544
+ )
1507
1545
  except TimeoutError:
1508
1546
  raise ProviderRequestError(f"Request timed out after {timeout}s. Try again or reduce max_results.", transient=True)
1509
1547
 
@@ -1653,6 +1691,114 @@ def search_tavily(
1653
1691
  }
1654
1692
 
1655
1693
 
1694
+ # =============================================================================
1695
+ # Querit (Multi-lingual search API for AI, with rich metadata and real-time information)
1696
+ # =============================================================================
1697
+
1698
+ def _map_querit_time_range(time_range: Optional[str]) -> Optional[str]:
1699
+ """Map generic time ranges to Querit's compact date filter format."""
1700
+ if not time_range:
1701
+ return None
1702
+ return {
1703
+ "day": "d1",
1704
+ "week": "w1",
1705
+ "month": "m1",
1706
+ "year": "y1",
1707
+ }.get(time_range, time_range)
1708
+
1709
+
1710
+ def search_querit(
1711
+ query: str,
1712
+ api_key: str,
1713
+ max_results: int = 5,
1714
+ language: str = "en",
1715
+ country: str = "us",
1716
+ time_range: Optional[str] = None,
1717
+ include_domains: Optional[List[str]] = None,
1718
+ exclude_domains: Optional[List[str]] = None,
1719
+ base_url: str = "https://api.querit.ai",
1720
+ base_path: str = "/v1/search",
1721
+ timeout: int = 30,
1722
+ ) -> dict:
1723
+ """Search using Querit.
1724
+
1725
+ Mirrors the Querit Python SDK payload shape:
1726
+ - query
1727
+ - count
1728
+ - optional filters: languages, geo, sites, timeRange
1729
+ """
1730
+ endpoint = base_url.rstrip("/") + base_path
1731
+
1732
+ filters: Dict[str, Any] = {}
1733
+ if language:
1734
+ filters["languages"] = {"include": [language.lower()]}
1735
+ if country:
1736
+ filters["geo"] = {"countries": {"include": [country.upper()]}}
1737
+ if include_domains or exclude_domains:
1738
+ sites: Dict[str, List[str]] = {}
1739
+ if include_domains:
1740
+ sites["include"] = include_domains
1741
+ if exclude_domains:
1742
+ sites["exclude"] = exclude_domains
1743
+ filters["sites"] = sites
1744
+
1745
+ querit_time_range = _map_querit_time_range(time_range)
1746
+ if querit_time_range:
1747
+ filters["timeRange"] = {"date": querit_time_range}
1748
+
1749
+ body: Dict[str, Any] = {
1750
+ "query": query,
1751
+ "count": max_results,
1752
+ }
1753
+ if filters:
1754
+ body["filters"] = filters
1755
+
1756
+ headers = {
1757
+ "Authorization": f"Bearer {api_key}",
1758
+ "Content-Type": "application/json",
1759
+ }
1760
+
1761
+ data = make_request(endpoint, headers, body, timeout=timeout)
1762
+
1763
+ error_code = data.get("error_code")
1764
+ error_msg = data.get("error_msg")
1765
+ if error_msg or (error_code not in (None, 0, 200)):
1766
+ message = error_msg or f"Querit request failed with error_code={error_code}"
1767
+ raise ProviderRequestError(message)
1768
+
1769
+ raw_results = ((data.get("results") or {}).get("result")) or []
1770
+ results = []
1771
+ for i, item in enumerate(raw_results[:max_results]):
1772
+ snippet = item.get("snippet") or item.get("page_age") or ""
1773
+ result = {
1774
+ "title": item.get("title") or _title_from_url(item.get("url", "")),
1775
+ "url": item.get("url", ""),
1776
+ "snippet": snippet,
1777
+ "score": round(1.0 - i * 0.05, 3),
1778
+ }
1779
+ if item.get("page_time") is not None:
1780
+ result["page_time"] = item["page_time"]
1781
+ if item.get("page_age"):
1782
+ result["date"] = item["page_age"]
1783
+ if item.get("language") is not None:
1784
+ result["language"] = item["language"]
1785
+ results.append(result)
1786
+
1787
+ answer = results[0]["snippet"] if results else ""
1788
+
1789
+ return {
1790
+ "provider": "querit",
1791
+ "query": query,
1792
+ "results": results,
1793
+ "images": [],
1794
+ "answer": answer,
1795
+ "metadata": {
1796
+ "search_id": data.get("search_id"),
1797
+ "time_range": querit_time_range,
1798
+ }
1799
+ }
1800
+
1801
+
1656
1802
  # =============================================================================
1657
1803
  # Exa (Neural/Semantic/Deep Search)
1658
1804
  # =============================================================================
@@ -1759,7 +1905,7 @@ def search_exa(
1759
1905
  results.append({
1760
1906
  "title": f"Exa {exa_depth.replace('-', ' ').title()} Synthesis",
1761
1907
  "url": "",
1762
- "snippet": synthesized_text[:2000],
1908
+ "snippet": synthesized_text,
1763
1909
  "full_synthesis": synthesized_text,
1764
1910
  "score": 1.0,
1765
1911
  "grounding": grounding_citations[:10],
@@ -1781,7 +1927,7 @@ def search_exa(
1781
1927
  "type": "source",
1782
1928
  })
1783
1929
 
1784
- answer = synthesized_text[:1000] if synthesized_text else (results[1]["snippet"] if len(results) > 1 else "")
1930
+ answer = synthesized_text if synthesized_text else (results[1]["snippet"] if len(results) > 1 else "")
1785
1931
 
1786
1932
  return {
1787
1933
  "provider": "exa",
@@ -1876,13 +2022,17 @@ def search_perplexity(
1876
2022
  message = choices[0].get("message", {}) if choices else {}
1877
2023
  answer = (message.get("content") or "").strip()
1878
2024
 
1879
- urls = re.findall(r"https?://[^\s)\]}>\"']+", answer)
1880
- unique_urls = []
1881
- seen = set()
1882
- for u in urls:
1883
- if u not in seen:
1884
- seen.add(u)
1885
- unique_urls.append(u)
2025
+ # Prefer the structured citations array from Perplexity API response
2026
+ api_citations = data.get("citations", [])
2027
+
2028
+ # Fallback: extract URLs from answer text if API doesn't provide citations
2029
+ if not api_citations:
2030
+ api_citations = []
2031
+ seen = set()
2032
+ for u in re.findall(r"https?://[^\s)\]}>\"']+", answer):
2033
+ if u not in seen:
2034
+ seen.add(u)
2035
+ api_citations.append(u)
1886
2036
 
1887
2037
  results = []
1888
2038
 
@@ -1897,12 +2047,19 @@ def search_perplexity(
1897
2047
  "score": 1.0,
1898
2048
  })
1899
2049
 
1900
- # Additional results: extracted source URLs
1901
- for i, u in enumerate(unique_urls[:max_results - 1]):
2050
+ # Source results from citations
2051
+ for i, citation in enumerate(api_citations[:max_results - 1]):
2052
+ # citations can be plain URL strings or dicts with url/title
2053
+ if isinstance(citation, str):
2054
+ url = citation
2055
+ title = _title_from_url(url)
2056
+ else:
2057
+ url = citation.get("url", "")
2058
+ title = citation.get("title") or _title_from_url(url)
1902
2059
  results.append({
1903
- "title": f"Source {i+1}",
1904
- "url": u,
1905
- "snippet": "Referenced source from Perplexity answer",
2060
+ "title": title,
2061
+ "url": url,
2062
+ "snippet": f"Source cited in Perplexity answer [citation {i+1}]",
1906
2063
  "score": round(0.9 - i * 0.1, 3),
1907
2064
  })
1908
2065
 
@@ -2243,6 +2400,9 @@ Intelligent Auto-Routing:
2243
2400
 
2244
2401
  Research Intent → Tavily
2245
2402
  "how does", "explain", "what is", analysis, pros/cons, tutorials
2403
+
2404
+ Multilingual + Real-Time AI Search → Querit
2405
+ multilingual search, metadata-rich results, current information for AI workflows
2246
2406
 
2247
2407
  Discovery Intent → Exa (Neural)
2248
2408
  "similar to", "companies like", "alternatives", URLs, startups, papers
@@ -2263,7 +2423,7 @@ Full docs: See README.md and SKILL.md
2263
2423
  # Common arguments
2264
2424
  parser.add_argument(
2265
2425
  "--provider", "-p",
2266
- choices=["serper", "tavily", "exa", "perplexity", "you", "searxng", "auto"],
2426
+ choices=["serper", "tavily", "querit", "exa", "perplexity", "you", "searxng", "auto"],
2267
2427
  help="Search provider (auto=intelligent routing)"
2268
2428
  )
2269
2429
  parser.add_argument(
@@ -2323,6 +2483,19 @@ Full docs: See README.md and SKILL.md
2323
2483
  )
2324
2484
  parser.add_argument("--raw-content", action="store_true")
2325
2485
 
2486
+ # Querit-specific
2487
+ querit_config = config.get("querit", {})
2488
+ parser.add_argument(
2489
+ "--querit-base-url",
2490
+ default=querit_config.get("base_url", "https://api.querit.ai"),
2491
+ help="Querit API base URL"
2492
+ )
2493
+ parser.add_argument(
2494
+ "--querit-base-path",
2495
+ default=querit_config.get("base_path", "/v1/search"),
2496
+ help="Querit API path"
2497
+ )
2498
+
2326
2499
  # Exa-specific
2327
2500
  exa_config = config.get("exa", {})
2328
2501
  parser.add_argument(
@@ -2490,13 +2663,15 @@ Full docs: See README.md and SKILL.md
2490
2663
 
2491
2664
  # Build provider fallback list
2492
2665
  auto_config = config.get("auto_routing", {})
2493
- provider_priority = auto_config.get("provider_priority", ["tavily", "exa", "perplexity", "serper"])
2666
+ provider_priority = auto_config.get("provider_priority", ["tavily", "querit", "exa", "perplexity", "serper", "you", "searxng"])
2494
2667
  disabled_providers = auto_config.get("disabled_providers", [])
2495
2668
 
2496
2669
  # Start with the selected provider, then try others in priority order
2670
+ # Only include providers that have a configured API key (except the primary,
2671
+ # which gets a clear error if unconfigured and no fallback succeeds)
2497
2672
  providers_to_try = [provider]
2498
2673
  for p in provider_priority:
2499
- if p not in providers_to_try and p not in disabled_providers:
2674
+ if p not in providers_to_try and p not in disabled_providers and get_api_key(p, config):
2500
2675
  providers_to_try.append(p)
2501
2676
 
2502
2677
  # Skip providers currently in cooldown
@@ -2538,6 +2713,20 @@ Full docs: See README.md and SKILL.md
2538
2713
  include_images=args.images,
2539
2714
  include_raw_content=args.raw_content,
2540
2715
  )
2716
+ elif prov == "querit":
2717
+ return search_querit(
2718
+ query=args.query,
2719
+ api_key=key,
2720
+ max_results=args.max_results,
2721
+ language=args.language,
2722
+ country=args.country,
2723
+ time_range=args.time_range or args.freshness,
2724
+ include_domains=args.include_domains,
2725
+ exclude_domains=args.exclude_domains,
2726
+ base_url=args.querit_base_url,
2727
+ base_path=args.querit_base_path,
2728
+ timeout=int(querit_config.get("timeout", 30)),
2729
+ )
2541
2730
  elif prov == "exa":
2542
2731
  # CLI --exa-depth overrides; fallback to auto-routing suggestion
2543
2732
  exa_depth = args.exa_depth
@@ -2621,6 +2810,8 @@ Full docs: See README.md and SKILL.md
2621
2810
  "locale": f"{args.country}:{args.language}",
2622
2811
  "freshness": args.freshness,
2623
2812
  "time_range": args.time_range,
2813
+ "include_domains": sorted(args.include_domains) if args.include_domains else None,
2814
+ "exclude_domains": sorted(args.exclude_domains) if args.exclude_domains else None,
2624
2815
  "topic": args.topic,
2625
2816
  "search_engines": sorted(args.engines) if args.engines else None,
2626
2817
  "include_news": not args.no_news,
package/scripts/setup.py CHANGED
@@ -65,6 +65,14 @@ def print_provider_info():
65
65
  "signup": "https://tavily.com",
66
66
  "strengths": ["AI-synthesized answers", "Full page content", "Domain filtering", "Academic research"]
67
67
  },
68
+ {
69
+ "name": "Querit",
70
+ "emoji": "🗂️",
71
+ "best_for": "Multi-lingual AI search with rich metadata and real-time information",
72
+ "free_tier": "1,000 queries/month",
73
+ "signup": "https://querit.ai",
74
+ "strengths": ["Multi-lingual search", "Rich metadata", "Real-time information", "AI-ready results"]
75
+ },
68
76
  {
69
77
  "name": "Exa",
70
78
  "emoji": "🧠",
@@ -294,11 +302,12 @@ def run_setup(skill_dir: Path, force_reset: bool = False):
294
302
  "auto_routing": {"enabled": True, "fallback_provider": "serper"},
295
303
  "serper": {},
296
304
  "tavily": {},
305
+ "querit": {},
297
306
  "exa": {}
298
307
  }
299
308
 
300
309
  # Remove any existing API keys from example
301
- for provider in ["serper", "tavily", "exa"]:
310
+ for provider in ["serper", "tavily", "querit", "exa"]:
302
311
  if provider in config:
303
312
  config[provider].pop("api_key", None)
304
313
 
@@ -314,6 +323,7 @@ def run_setup(skill_dir: Path, force_reset: bool = False):
314
323
  providers_info = {
315
324
  "serper": ("Serper", "https://serper.dev", "Google results, shopping, local"),
316
325
  "tavily": ("Tavily", "https://tavily.com", "Research, explanations, analysis"),
326
+ "querit": ("Querit", "https://querit.ai", "Multi-lingual AI search, rich metadata, real-time info"),
317
327
  "exa": ("Exa", "https://exa.ai", "Semantic search, similar content"),
318
328
  "you": ("You.com", "https://api.you.com", "RAG applications, real-time info"),
319
329
  "searxng": ("SearXNG", "https://docs.searxng.org/admin/installation.html", "Privacy-first, self-hosted, $0 cost")
@@ -393,7 +403,7 @@ def run_setup(skill_dir: Path, force_reset: bool = False):
393
403
  config["defaults"]["max_results"] = max_results
394
404
 
395
405
  # Set disabled providers
396
- all_providers = ["serper", "tavily", "exa", "you", "searxng"]
406
+ all_providers = ["serper", "tavily", "querit", "exa", "you", "searxng"]
397
407
  disabled = [p for p in all_providers if p not in enabled_providers]
398
408
  config["auto_routing"]["disabled_providers"] = disabled
399
409