research-assistant 0.1.1 → 0.2.0

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-ZH.md CHANGED
@@ -66,6 +66,7 @@ research-assistant locate <md_path> "<q>" # 锚点式相关性扫描
66
66
 
67
67
  # 管理
68
68
  research-assistant setup # 配置 + 安装 skill/agent
69
+ research-assistant config fields # 查看各 provider 需填的配置字段
69
70
  research-assistant doctor [--show-config] # 连通性诊断
70
71
  research-assistant skills status # 受管 skill/agent 新鲜度
71
72
  ```
package/README.md CHANGED
@@ -73,6 +73,7 @@ research-assistant locate <md_path> "<q>" # anchor-based relevance scan
73
73
 
74
74
  # Management
75
75
  research-assistant setup # configure + install skill/agent
76
+ research-assistant config fields # show each provider's required config fields
76
77
  research-assistant doctor [--show-config] # connectivity diagnostics
77
78
  research-assistant skills status # managed skill/agent freshness
78
79
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "research-assistant",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Search/research sub-agent + concurrent search/fetch/locate CLI atomic tools for AI agents (Exa, Tavily, Firecrawl, Context7, OpenAI-compatible).",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/arsonist-g/research-assistant-cli#readme",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "research-assistant"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Search/research sub-agent + a set of concurrent search/fetch/locate CLI atomic tools for AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -76,7 +76,7 @@ tier genuinely cannot return the content.
76
76
  | Tier | Tool | Strength | Use when |
77
77
  |------|------|----------|----------|
78
78
  | 1. API | `exa`, `tavily` | Structured search APIs; clean JSON, no rendering. `tavily extract` returns ready markdown, `tavily map`/`crawl` cover a whole site; `exa` adds `similar`, `contents`, `answer`, and async `research`. Fast and cheap. | Simple or public pages; search with snippets; fast fetch of a known URL; site map or crawl. |
79
- | 2. Firecrawl | `firecrawl` | Residential or home-bandwidth infrastructure; `scrape` and `search` are **keyless**, the other endpoints (`map`, `crawl`, `extract`, `interact`, `agent`, `monitor`, `parse`) need a key. More likely than Tier 1 to succeed on lightly protected pages, and the only tier with site-wide crawl and structured extraction. | A page refuses raw HTTP but shows no hard Cloudflare challenge. Batch scrape many URLs, crawl a whole site, or extract structured data with a schema. |
79
+ | 2. Firecrawl | `firecrawl` | Residential or home-bandwidth infrastructure; `scrape`/`search`/`interact`/`parse` are **keyless**, the other endpoints (`map`, `crawl`, `extract`, `agent`, `monitor`) need a key. More likely than Tier 1 to succeed on lightly protected pages, and the only tier with site-wide crawl and structured extraction. | A page refuses raw HTTP but shows no hard Cloudflare challenge. Batch scrape many URLs, crawl a whole site, or extract structured data with a schema. |
80
80
  | 3. Browser | `fetch`, `browser` | Drives the user's real Edge/Chromium, same engine as the daily browser so login cookies stay valid. `fetch` auto-escalates: normal API, then a headed browser with Cloudflare auto-detect (no challenge means solve returns instantly). `browser` is the direct browser platform when you already know you want the browser: `browser fetch` (headed + CF auto-detect, skips the API) and `browser search` (headless search engine: Bing CN/intl, Google). | Everything above failed; or the page is behind Cloudflare, needs JS, or needs login cookies; or you want a browser search engine directly. |
81
81
 
82
82
  Notes:
@@ -157,29 +157,32 @@ and accept `--async-submit` to return the job id immediately instead of waiting.
157
157
 
158
158
  ```sh
159
159
  # Tier 1: structured search APIs. Clean JSON, fastest, cheapest. Start here for simple or public pages.
160
- research-assistant exa search "<query>" [--num-results N] [--type auto|keyword|neural|fast] [--text] [--highlights] [--category CAT] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--include-domains D ...] [--exclude-domains D ...]
161
- research-assistant exa similar <url> [--num-results N] # pages semantically similar to a URL
160
+ research-assistant exa search "<query>" [--num-results N (1-100)] [--type auto|keyword|neural|fast] [--text] [--highlights] [--category CAT] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--include-domains D ...] [--exclude-domains D ...]
161
+ research-assistant exa similar <url> [--num-results N (1-100)] # pages semantically similar to a URL ("more like this")
162
162
  research-assistant exa contents <id>... [--text] [--highlights] # fetch body for Exa IDs or URLs
163
- research-assistant exa answer "<question>" [--text] # LLM answer grounded in Exa results
164
- research-assistant exa research "<instructions>" [--model exa-research|exa-research-pro] [--output-schema JSON | --infer-schema] [--poll-timeout N] [--async-submit] # async in-depth research
165
-
166
- research-assistant tavily search "<query>" [--depth ultra-fast|fast|basic|advanced] [--max-results N] [--topic general|news|finance] [--time-range day|week|month|year] [--include-answer basic|true|advanced] [--country C] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--chunks-per-source N] [--include-raw-content markdown|text] [--include-images] [--include-domains D ...] [--exclude-domains D ...] [--auto-parameters]
167
- research-assistant tavily extract <url>... [--extract-depth basic|advanced] [--format markdown|text] # ready markdown, no rendering
168
- research-assistant tavily map <url> [--max-depth N] [--max-breadth N] [--limit N] [--select-paths REGEX ...] [--instructions TEXT] [--timeout N] # list a site's reachable URLs
169
- research-assistant tavily crawl <url> [--max-depth N] [--limit N] [--instructions TEXT] [--extract-depth basic|advanced] [--no-external] [--timeout N] # recursive crawl, markdown per page
170
-
171
- # Tier 2: Firecrawl. Residential bandwidth. scrape and search are keyless; the rest need a key.
163
+ research-assistant exa answer "<question>" [--text] # LLM answer grounded in Exa results (treat as a lead; verify with fetch)
164
+ research-assistant exa research "<instructions>" [--model exa-research|exa-research-pro] [--output-schema JSON | --infer-schema] [--poll-timeout N] [--async-submit] # async in-depth research; --infer-schema lets the model pick fields, --output-schema pins a rigid shape
165
+
166
+ research-assistant tavily search "<query>" [--depth ultra-fast|fast|basic|advanced] [--max-results N] [--topic general|news|finance] [--time-range day|week|month|year] [--include-answer basic|true|advanced] [--country C] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--chunks-per-source N (1-3, advanced)] [--include-raw-content markdown|text] [--include-images] [--include-domains D ...] [--exclude-domains D ...] [--auto-parameters]
167
+ research-assistant tavily extract <url>... [--extract-depth basic|advanced] [--format markdown|text] # ready markdown, no rendering (fastest fetch of known URLs)
168
+ research-assistant tavily map <url> [--max-depth N (1-5)] [--max-breadth N (1-500)] [--limit N] [--select-paths REGEX ...] [--instructions TEXT] [--timeout N (10-150)] # list a site's reachable URLs (sitemap-aware, returns URLs only)
169
+ research-assistant tavily crawl <url> [--max-depth N (1-5)] [--limit N] [--instructions TEXT] [--extract-depth basic|advanced] [--no-external] [--timeout N (10-150)] # recursive crawl, markdown per page (depth-bounded)
170
+
171
+ # Tier 2: Firecrawl. Residential bandwidth. scrape/search/interact/parse are keyless; map/crawl/extract/agent/monitor need a key.
172
+ # Keyless commands default to auto: try keyless first, fall back to your key on rate-limit/IP failure
173
+ # (a keyed call bills credits, so keyless-first saves them). --use-key skips keyless (needs api_key);
174
+ # --keyless forces keyless (no fallback).
172
175
  # Keyless endpoints are sensitive to IP quality: residential/home IPs work; datacenter IPs may
173
176
  # hit 403 ("IP looks suspicious") and need a home-bandwidth proxy.
174
- research-assistant firecrawl scrape <url>... [--format markdown|html] [--only-main-content] [--wait-for MS] # batch markdown, keyless
175
- research-assistant firecrawl search "<query>" [--limit N] [--sources web|news] [--scrape] # keyless; --scrape returns full markdown per result
176
- research-assistant firecrawl map <url> [--limit N] [--include-subdomains] # needs key
177
- research-assistant firecrawl crawl <url> [--limit N] [--max-depth N] [--include-paths REGEX ...] [--allow-subdomains] [--prompt TEXT] [--poll-timeout N] [--async-submit] # async recursive crawl
178
- research-assistant firecrawl extract <url>... [--prompt TEXT | --schema JSON] [--enable-web-search] [--agent] [--poll-timeout N] [--async-submit] # LLM structured extraction, async
179
- research-assistant firecrawl interact "<prompt>" [--scrape-id ID | --url URL] [--code TEXT --language node|python|bash] [--timeout S] [--stop] # live browser session bound to a scrape
180
- research-assistant firecrawl agent "<prompt>" [--model spark-1-mini|spark-1-pro] [--urls U ...] [--schema JSON] [--max-credits N] [--strict] [--poll-timeout N] [--async-submit] # autonomous extraction
181
- research-assistant firecrawl monitor # list active crawl jobs
182
- research-assistant firecrawl parse <file> [--format markdown|html|json ...] [--pdf-mode fast|auto|ocr] [--max-pages N] # parse a local document
177
+ research-assistant firecrawl scrape <url>... [--format markdown|html] [--only-main-content] [--wait-for MS] # keyless; single or batch URL → markdown/html (auto: keyless first, falls back to key)
178
+ research-assistant firecrawl search "<query>" [--limit N (1-100)] [--sources web|news] [--scrape] # keyless; web search; --scrape returns full markdown per result (bills more credits)
179
+ research-assistant firecrawl map <url> [--limit N] [--include-subdomains] # needs key; discover all reachable URLs of a site (sitemap + link crawl), URLs only; no page content
180
+ research-assistant firecrawl crawl <url> [--limit N] [--max-depth N] [--include-paths REGEX ...] [--allow-subdomains] [--prompt TEXT] [--poll-timeout N] [--async-submit] # needs key; async recursive crawl following links, markdown per page; --prompt lets an LLM generate crawl options
181
+ research-assistant firecrawl extract <url>... [--prompt TEXT | --schema JSON] [--enable-web-search] [--agent] [--poll-timeout N] [--async-submit] # needs key; LLM pulls structured data from URLs by --prompt (free-form) or --schema (rigid shape); --agent navigates pages first; async
182
+ research-assistant firecrawl interact "<prompt>" [--scrape-id ID | --url URL] [--code TEXT --language node|python|bash] [--timeout S (1-300)] [--stop] # keyless; live browser session bound to a scrape: run a prompt or code in the already-loaded page
183
+ research-assistant firecrawl agent "<prompt>" [--model spark-1-mini|spark-1-pro] [--urls U ...] [--schema JSON] [--max-credits N] [--strict] [--poll-timeout N] [--async-submit] # needs key; autonomous agent: give a goal, it browses sites and extracts the data (spark model); async, bills credits
184
+ research-assistant firecrawl monitor # needs key; list your team's currently active crawl jobs
185
+ research-assistant firecrawl parse <file> [--format markdown|html|json ...] [--pdf-mode fast|auto|ocr] [--max-pages N (1-10000)] # keyless; upload a local PDF/DOCX/HTML → markdown/json (no URL needed)
183
186
 
184
187
  # Tier 3: real browser. fetch auto-escalates (normal API, then headed browser with CF auto-detect).
185
188
  research-assistant fetch <url> [<url>...] [--concurrency N] [--login|--no-login] [--no-browser] [--output PATH]
@@ -218,6 +221,7 @@ research-assistant locate <md_path> "<query>" [--top N] [--scope lines|paragraph
218
221
 
219
222
  ```sh
220
223
  research-assistant setup [--non-interactive | --config-inline TOML] [--provider TYPE[,k=v,...]] ... [--proxy URL] [--browser-channel msedge|chrome] [--daemon-port N] [--install-skills claude,codex,...] # configure providers/proxy/browser; --install-skills writes the skill+agent files
224
+ research-assistant config fields # each provider's config fields (api_key/model required, default base_url, keyless tier, notes); read-only
221
225
  research-assistant doctor [--show-config] [--target <name>] # connectivity diagnostics; --show-config prints config (masked), --target checks one provider or builtin
222
226
  research-assistant skills status [--targets claude,codex,...] [--skills-root PATH] # managed skill/agent freshness
223
227
  research-assistant skills update [--targets claude,codex,...] [--skills-root PATH] # refresh managed files
@@ -80,8 +80,9 @@ def _build_parser(config: Any) -> argparse.ArgumentParser:
80
80
  from .commands import setup as setup_cmd
81
81
  from .commands import skills as skills_cmd
82
82
  from .commands import doctor as doctor_cmd
83
+ from .commands import config as config_cmd
83
84
 
84
- for cmd in (fetch_cmd, locate_cmd, search_cmd, ask_cmd, setup_cmd, skills_cmd, doctor_cmd):
85
+ for cmd in (fetch_cmd, locate_cmd, search_cmd, ask_cmd, config_cmd, setup_cmd, skills_cmd, doctor_cmd):
85
86
  cmd.register(subs)
86
87
 
87
88
  return parser
@@ -0,0 +1,80 @@
1
+ """provider 配置元数据(setup 交互式 + config fields 共享,单源)。
2
+
3
+ 集中描述每个 provider type 在 config.toml 里该填什么:是否必填 api_key / model、
4
+ base_url 默认值、免 key 层说明、配置提示。setup 的 _PROVIDER_TYPES 从此派生,避免两处漂移。
5
+
6
+ 字段:
7
+ type config.toml 的 type 键
8
+ label 人类可读名
9
+ needs_model 是否必填 model
10
+ default_base base_url 默认值(空=用户必填)
11
+ api_key "required" | "optional"
12
+ keyless 免 key 层说明(空=无免 key 层)
13
+ notes 配置提示
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+
21
+ @dataclass
22
+ class ProviderMeta:
23
+ type: str
24
+ label: str
25
+ needs_model: bool
26
+ default_base: str
27
+ api_key: str # "required" | "optional"
28
+ keyless: str = ""
29
+ notes: str = ""
30
+
31
+
32
+ # 顺序即展示顺序(LLM 主干 → 搜索 API → 文档 → firecrawl)。
33
+ PROVIDER_META: dict[str, ProviderMeta] = {
34
+ "openai_compat": ProviderMeta(
35
+ type="openai_compat",
36
+ label="OpenAI-compatible (drives ask)",
37
+ needs_model=True,
38
+ default_base="https://api.openai.com/v1",
39
+ api_key="required",
40
+ notes="base_url defaults to OpenAI (with /v1/). For a compatible gateway, set its URL including /v1/. Pick a model with built-in web search.",
41
+ ),
42
+ "locate": ProviderMeta(
43
+ type="locate",
44
+ label="locate model (small OpenAI-compatible model for long-doc locate)",
45
+ needs_model=True,
46
+ default_base="https://api.openai.com/v1",
47
+ api_key="required",
48
+ notes="Reuses the OpenAI-compatible protocol; a cheap small model is enough. base_url defaults to OpenAI; a gateway URL must include /v1/.",
49
+ ),
50
+ "exa": ProviderMeta(
51
+ type="exa",
52
+ label="Exa (structured search API)",
53
+ needs_model=False,
54
+ default_base="https://api.exa.ai",
55
+ api_key="required",
56
+ ),
57
+ "tavily": ProviderMeta(
58
+ type="tavily",
59
+ label="Tavily (search + extract + map + crawl)",
60
+ needs_model=False,
61
+ default_base="https://api.tavily.com",
62
+ api_key="required",
63
+ ),
64
+ "firecrawl": ProviderMeta(
65
+ type="firecrawl",
66
+ label="Firecrawl (keyless scrape/search/interact/parse; map/crawl/extract/agent/monitor need key)",
67
+ needs_model=False,
68
+ default_base="https://api.firecrawl.dev/v2",
69
+ api_key="optional",
70
+ keyless="scrape/search/interact/parse are keyless (IP-rate-limited); map/crawl/extract/agent/monitor need a key.",
71
+ notes="Keyless commands default to auto (keyless first, fall back to key on rate-limit/IP failure). Configure a key to unlock map/crawl/extract/agent/monitor.",
72
+ ),
73
+ "context7": ProviderMeta(
74
+ type="context7",
75
+ label="Context7 (library/framework/SDK/CLI/cloud docs)",
76
+ needs_model=False,
77
+ default_base="https://context7.com",
78
+ api_key="required",
79
+ ),
80
+ }
@@ -0,0 +1,47 @@
1
+ """config 命令:配置相关的只读查询。
2
+
3
+ research-assistant config fields # 打印各 provider 配置字段表(只读,不连网,不动配置)
4
+
5
+ 未来可扩展更多子命令(如 config show 打印当前值,与 doctor --show-config 区分)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from typing import Any
12
+
13
+ from ..config import Config
14
+ from ._provider_meta import PROVIDER_META
15
+
16
+ NAME = "config"
17
+ ALIASES: list[str] = []
18
+ HELP = "Read-only configuration queries (provider field reference)."
19
+
20
+
21
+ def register(subparsers: argparse._SubParsersAction) -> None:
22
+ p = subparsers.add_parser(NAME, help=HELP, description=HELP)
23
+ sub = p.add_subparsers(dest="subcommand", required=True, metavar="<fields>")
24
+ f = sub.add_parser(
25
+ "fields",
26
+ help="Print each provider type's config fields (api_key/model required, default base_url, keyless tier, notes).",
27
+ )
28
+ f.set_defaults(_handler=run_fields)
29
+
30
+
31
+ async def run_fields(args: argparse.Namespace, config: Config) -> dict[str, Any]:
32
+ """各 provider 配置字段表({provider_type: fields})。只读:不读 config.toml,不连网。
33
+
34
+ 返回 dict-of-dict 而非 list,使 markdown 渲染走普通 dict 路径(每字段 200 字符宽),
35
+ notes 不被 list 的 80 字符截断。
36
+ """
37
+ out: dict[str, Any] = {}
38
+ for ptype, m in PROVIDER_META.items():
39
+ out[m.type] = {
40
+ "label": m.label,
41
+ "api_key": m.api_key,
42
+ "model": "required" if m.needs_model else "n/a",
43
+ "default_base_url": m.default_base or "(set by user)",
44
+ "keyless": m.keyless or "-",
45
+ "notes": m.notes or "-",
46
+ }
47
+ return out
@@ -18,19 +18,16 @@ from ..config import Config, ProviderConfig, load, save
18
18
  from ..errors import ArgsError, ResearchAssistantError
19
19
  from .. import targets as targets_mod
20
20
  from .. import installer
21
+ from ._provider_meta import PROVIDER_META
21
22
 
22
23
  NAME = "setup"
23
24
  ALIASES: list[str] = ["init"]
24
25
  HELP = "Configure providers/proxy/browser and optionally install managed skill/agent."
25
26
 
26
- # 已知 provider 类型 + 是否需要 model
27
+ # 已知 provider 类型 + 是否需要 model(从共享元数据派生,单源;setup 只用 needs_model/label/default_base)
27
28
  _PROVIDER_TYPES: dict[str, dict[str, Any]] = {
28
- "openai_compat": {"needs_model": True, "label": "OpenAI-compatible (search/LLM)", "default_base": ""},
29
- "locate": {"needs_model": True, "label": "locate 模型 (openai 兼容小模型)", "default_base": ""},
30
- "exa": {"needs_model": False, "label": "Exa", "default_base": "https://api.exa.ai"},
31
- "tavily": {"needs_model": False, "label": "Tavily", "default_base": "https://api.tavily.com"},
32
- "firecrawl": {"needs_model": False, "label": "Firecrawl", "default_base": "https://api.firecrawl.dev/v2"},
33
- "context7": {"needs_model": False, "label": "Context7", "default_base": "https://context7.com"},
29
+ t: {"needs_model": m.needs_model, "label": m.label, "default_base": m.default_base}
30
+ for t, m in PROVIDER_META.items()
34
31
  }
35
32
 
36
33
 
@@ -51,7 +51,7 @@ class BrowserProvider(Provider):
51
51
  help="Search engine (default bing-intl; intl avoids CN-sensitive-word filtering).",
52
52
  ),
53
53
  ArgSpec(["--limit"], type=int, default=10, help="Min results to collect (default 10)."),
54
- ArgSpec(["--max-pages"], type=int, default=10, help="Max pages to paginate (default 10)."),
54
+ ArgSpec(["--max-pages"], type=int, default=10, help="Max pages to paginate (default 10; engine may offer fewer)."),
55
55
  ],
56
56
  handler=self.search,
57
57
  ),
@@ -87,7 +87,7 @@ class ExaProvider(Provider):
87
87
  help="Search the web with Exa (auto/keyword/neural/fast).",
88
88
  args=[
89
89
  ArgSpec(["query"], kind="positional", help="Search query."),
90
- ArgSpec(["--num-results"], type=int, default=10, help="Number of results (default 10)."),
90
+ ArgSpec(["--num-results"], type=int, default=10, help="Number of results (1-100, default 10)."),
91
91
  ArgSpec(
92
92
  ["--type"],
93
93
  choices=["auto", "keyword", "neural", "fast"],
@@ -124,7 +124,7 @@ class ExaProvider(Provider):
124
124
  help="Find pages similar to a URL.",
125
125
  args=[
126
126
  ArgSpec(["url"], kind="positional", help="Source URL."),
127
- ArgSpec(["--num-results"], type=int, default=10, help="Number of results."),
127
+ ArgSpec(["--num-results"], type=int, default=10, help="Number of results (1-100)."),
128
128
  ],
129
129
  handler=self.similar,
130
130
  ),
@@ -49,12 +49,26 @@ from .registry import register
49
49
  DEFAULT_BASE_URL = "https://api.firecrawl.dev/v2"
50
50
 
51
51
 
52
+ def _keyless_flags() -> list[ArgSpec]:
53
+ """keyless 命令(scrape/search/interact/parse)共享的 key 策略 flags。
54
+
55
+ 默认 auto:配了 key 先走免 key,失败(限流/IP/suspicious)再带 key 重试一次。
56
+ --use-key 强制带 key(跳过免 key);--keyless 强制免 key(失败不 fallback)。二者互斥。
57
+ """
58
+ return [
59
+ ArgSpec(["--use-key"], action="store_true",
60
+ help="Force the keyed tier; skip the keyless attempt (needs api_key)."),
61
+ ArgSpec(["--keyless"], action="store_true",
62
+ help="Force the keyless tier; do not fall back to key on failure."),
63
+ ]
64
+
65
+
52
66
  @register
53
67
  class FirecrawlProvider(Provider):
54
68
  type = "firecrawl"
55
69
  command = "firecrawl"
56
70
  aliases = ["fc"]
57
- help = "Firecrawl scrape / search / map (scrape+search work keyless; map needs a key)."
71
+ help = "Firecrawl scrape/search/interact/parse (keyless) + map/crawl/extract/agent/monitor (need key)."
58
72
 
59
73
  def capabilities(self) -> list[Capability]:
60
74
  return [
@@ -66,7 +80,7 @@ class FirecrawlProvider(Provider):
66
80
  ArgSpec(["--format"], choices=["markdown", "html"], default="markdown", help="Output format."),
67
81
  ArgSpec(["--only-main-content"], action="store_true", help="Drop nav/footer boilerplate."),
68
82
  ArgSpec(["--wait-for"], type=int, metavar="MS", help="Wait N ms before scraping."),
69
- ],
83
+ ] + _keyless_flags(),
70
84
  handler=self.scrape,
71
85
  ),
72
86
  Capability(
@@ -74,10 +88,10 @@ class FirecrawlProvider(Provider):
74
88
  help="Web search (keyless); with --scrape returns full markdown per result.",
75
89
  args=[
76
90
  ArgSpec(["query"], kind="positional", help="Search query."),
77
- ArgSpec(["--limit"], type=int, default=10, help="Max results."),
91
+ ArgSpec(["--limit"], type=int, default=10, help="Max results (1-100)."),
78
92
  ArgSpec(["--sources"], nargs="+", choices=["web", "news"], help="Result sources."),
79
- ArgSpec(["--scrape"], action="store_true", help="Scrape full content for each result (needs key on keyless tier)."),
80
- ],
93
+ ArgSpec(["--scrape"], action="store_true", help="Scrape full content for each result."),
94
+ ] + _keyless_flags(),
81
95
  handler=self.search,
82
96
  ),
83
97
  Capability(
@@ -124,7 +138,7 @@ class FirecrawlProvider(Provider):
124
138
  ),
125
139
  Capability(
126
140
  name="interact",
127
- help="Run a prompt or code in a live browser session bound to a scrape (needs a key).",
141
+ help="Run a prompt or code in a live browser session bound to a scrape (keyless).",
128
142
  args=[
129
143
  ArgSpec(["prompt"], kind="positional", nargs="?", metavar="TEXT",
130
144
  help="AI prompt to run in the browser session (mode 1; exclusive with --code)."),
@@ -140,7 +154,7 @@ class FirecrawlProvider(Provider):
140
154
  help="Execution timeout seconds (1-300, default 30)."),
141
155
  ArgSpec(["--stop"], action="store_true",
142
156
  help="Stop and tear down the session (requires --scrape-id)."),
143
- ],
157
+ ] + _keyless_flags(),
144
158
  handler=self.interact,
145
159
  ),
146
160
  Capability(
@@ -174,7 +188,7 @@ class FirecrawlProvider(Provider):
174
188
  ),
175
189
  Capability(
176
190
  name="parse",
177
- help="Upload a local document (.pdf/.docx/.html/...) and parse to markdown/json (needs a key).",
191
+ help="Upload a local document (.pdf/.docx/.html/...) and parse to markdown/json (keyless).",
178
192
  args=[
179
193
  ArgSpec(["file"], kind="positional", metavar="PATH",
180
194
  help="Local file path (.html/.htm/.pdf/.docx/.doc/.odt/.rtf/.xlsx/.xls)."),
@@ -194,7 +208,7 @@ class FirecrawlProvider(Provider):
194
208
  help="PDF parser mode (sets options.parsers[0].mode)."),
195
209
  ArgSpec(["--max-pages"], type=int, metavar="N",
196
210
  help="Max PDF pages to parse (1-10000, needs --pdf-mode)."),
197
- ],
211
+ ] + _keyless_flags(),
198
212
  handler=self.parse,
199
213
  ),
200
214
  ]
@@ -224,14 +238,106 @@ class FirecrawlProvider(Provider):
224
238
  msg = data.get("error") or "firecrawl 返回 success:false"
225
239
  raise ProviderError(f"firecrawl {endpoint}: {msg}", provider="firecrawl", details=data)
226
240
 
227
- async def _scrape_one(self, client: Any, url: str, fmt: str, args: Any) -> dict[str, Any]:
241
+ def _require_keyed(self, cfg: ProviderConfig, endpoint: str) -> None:
242
+ """keyed 命令(map/crawl/extract/agent/monitor)必须配 key,否则前置失败,不发请求。"""
243
+ if not cfg.api_key:
244
+ from ..errors import ArgsError
245
+ raise ArgsError(
246
+ f"firecrawl {endpoint}: 此命令需要 api_key(免 key 层仅支持 scrape/search/interact/parse)",
247
+ provider="firecrawl",
248
+ )
249
+
250
+ def _resolve_mode(self, args: Any, cfg: ProviderConfig) -> str:
251
+ """解析 keyless 命令的 key 模式:keyless | use_key | auto。"""
252
+ from ..errors import ArgsError
253
+ if getattr(args, "keyless", False) and getattr(args, "use_key", False):
254
+ raise ArgsError("firecrawl: --keyless 与 --use-key 互斥", provider="firecrawl")
255
+ if getattr(args, "keyless", False):
256
+ return "keyless"
257
+ if getattr(args, "use_key", False):
258
+ if not cfg.api_key:
259
+ raise ArgsError("firecrawl: --use-key 需要 api_key,但未配置", provider="firecrawl")
260
+ return "use_key"
261
+ return "auto" # 默认:有 key 先免 key 失败再带 key,无 key 只免 key
262
+
263
+ @staticmethod
264
+ def _is_keyless_retryable(err: Exception) -> bool:
265
+ """免 key 失败是否值得带 key 重试(限流/IP/suspicious/quota 类;网络错误不算)。"""
266
+ from ..errors import ProviderError
267
+ if not isinstance(err, ProviderError):
268
+ return False
269
+ details = getattr(err, "details", None) or {}
270
+ status = details.get("status_code")
271
+ if status in (403, 429):
272
+ return True
273
+ msg = (getattr(err, "message", "") or "").lower()
274
+ keywords = ("rate limit", "ratelimit", "too many", "suspicious", "quota", "limit reached", "ip ")
275
+ return any(k in msg for k in keywords)
276
+
277
+ async def _do_request(
278
+ self, client: Any, method: str, path: str, *,
279
+ json_body: Any = None, files: Any = None,
280
+ headers: dict[str, str] | None, endpoint: str,
281
+ ) -> dict[str, Any]:
282
+ """单次 firecrawl 请求(JSON 或 multipart),按 headers 决定是否带 key;统一 _check。"""
283
+ if files is not None:
284
+ import httpx
285
+ from ..errors import NetworkError, wrap_provider_http_error
286
+ try:
287
+ resp = await client.request(method, path, files=files, headers=headers)
288
+ except httpx.HTTPError as e:
289
+ raise wrap_provider_http_error("firecrawl", e) from e
290
+ if resp.status_code >= 400:
291
+ raise http_mod.handle_http_error("firecrawl", httpx.HTTPStatusError(
292
+ f"firecrawl HTTP {resp.status_code}", request=resp.request, response=resp
293
+ ))
294
+ try:
295
+ data = resp.json()
296
+ except ValueError as e:
297
+ raise NetworkError(f"firecrawl: 响应非 JSON ({e})", provider="firecrawl") from e
298
+ else:
299
+ data = await http_mod.request_json(
300
+ client, method, path, provider="firecrawl", json_body=json_body, headers=headers
301
+ )
302
+ self._check(data, endpoint=endpoint)
303
+ return data
304
+
305
+ async def _request_keyless(
306
+ self, client: Any, method: str, path: str, *,
307
+ cfg: ProviderConfig, mode: str,
308
+ json_body: Any = None, files: Any = None, endpoint: str,
309
+ ) -> dict[str, Any]:
310
+ """keyless 命令请求调度:按 mode 决定是否带 key;auto 下免 key 失败(可重试)带 key 重试一次。
311
+
312
+ - keyless:永不带 key,失败即抛。
313
+ - use_key:必带 key(无 key 在 _resolve_mode 已挡)。
314
+ - auto:先免 key;失败且可重试(限流/IP/suspicious)且有 key → 带 key 重试一次。
315
+ """
316
+ from ..errors import ProviderError
317
+ key_headers = {"Authorization": f"Bearer {cfg.api_key}"} if cfg.api_key else None
318
+
319
+ if mode == "use_key":
320
+ return await self._do_request(client, method, path, json_body=json_body, files=files,
321
+ headers=key_headers, endpoint=endpoint)
322
+
323
+ # keyless 或 auto:先免 key
324
+ try:
325
+ return await self._do_request(client, method, path, json_body=json_body, files=files,
326
+ headers=None, endpoint=endpoint)
327
+ except ProviderError as e:
328
+ if mode == "keyless" or not cfg.api_key or not self._is_keyless_retryable(e):
329
+ raise
330
+ # auto + 有 key + 可重试:带 key 再来一次
331
+ return await self._do_request(client, method, path, json_body=json_body, files=files,
332
+ headers=key_headers, endpoint=endpoint)
333
+
334
+ async def _scrape_one(self, client: Any, url: str, fmt: str, args: Any, cfg: ProviderConfig, mode: str) -> dict[str, Any]:
228
335
  body: dict[str, Any] = {"url": url, "formats": [fmt]}
229
336
  if getattr(args, "only_main_content", False):
230
337
  body["onlyMainContent"] = True
231
338
  if getattr(args, "wait_for", None):
232
339
  body["waitFor"] = args.wait_for
233
- data = await http_mod.request_json(client, "POST", "scrape", provider="firecrawl", json_body=body)
234
- self._check(data, endpoint="scrape")
340
+ data = await self._request_keyless(client, "POST", "scrape", cfg=cfg, mode=mode, json_body=body, endpoint="scrape")
235
341
  inner = data.get("data") or {}
236
342
  out: dict[str, Any] = {}
237
343
  if fmt == "markdown":
@@ -246,21 +352,22 @@ class FirecrawlProvider(Provider):
246
352
 
247
353
  async def scrape(self, args: Any) -> dict[str, Any]:
248
354
  cfg = self._cfg()
355
+ mode = self._resolve_mode(args, cfg)
249
356
  urls = args.urls
250
357
  async with http_mod.make_client(
251
358
  proxy_url=resolve_proxy(self.config.proxy.url),
252
359
  timeout=cfg.timeout,
253
360
  base_url=cfg.base_url,
254
- headers=self._headers(cfg),
255
361
  ) as client:
256
362
  if len(urls) == 1:
257
- return {"data": await self._scrape_one(client, urls[0], args.format, args)}
363
+ return {"data": await self._scrape_one(client, urls[0], args.format, args, cfg, mode)}
258
364
  # 批量并发
259
- items = await asyncio.gather(*[self._scrape_one(client, u, args.format, args) for u in urls])
365
+ items = await asyncio.gather(*[self._scrape_one(client, u, args.format, args, cfg, mode) for u in urls])
260
366
  return {"results": [{"url": u, "data": d} for u, d in zip(urls, items)]}
261
367
 
262
368
  async def search(self, args: Any) -> dict[str, Any]:
263
369
  cfg = self._cfg()
370
+ mode = self._resolve_mode(args, cfg)
264
371
  body: dict[str, Any] = {"query": args.query, "limit": args.limit}
265
372
  if args.scrape:
266
373
  body["scrapeOptions"] = {"formats": [{"type": "markdown"}]}
@@ -270,10 +377,8 @@ class FirecrawlProvider(Provider):
270
377
  proxy_url=resolve_proxy(self.config.proxy.url),
271
378
  timeout=cfg.timeout,
272
379
  base_url=cfg.base_url,
273
- headers=self._headers(cfg),
274
380
  ) as client:
275
- data = await http_mod.request_json(client, "POST", "search", provider="firecrawl", json_body=body)
276
- self._check(data, endpoint="search")
381
+ data = await self._request_keyless(client, "POST", "search", cfg=cfg, mode=mode, json_body=body, endpoint="search")
277
382
  out: dict[str, Any] = {}
278
383
  if args.scrape:
279
384
  arr = data.get("data") or []
@@ -293,6 +398,7 @@ class FirecrawlProvider(Provider):
293
398
 
294
399
  async def map(self, args: Any) -> dict[str, Any]:
295
400
  cfg = self._cfg()
401
+ self._require_keyed(cfg, "map")
296
402
  body: dict[str, Any] = {"url": args.url, "limit": args.limit}
297
403
  if args.include_subdomains:
298
404
  body["includeSubdomains"] = True
@@ -323,6 +429,7 @@ class FirecrawlProvider(Provider):
323
429
 
324
430
  async def crawl(self, args: Any) -> dict[str, Any]:
325
431
  cfg = self._cfg()
432
+ self._require_keyed(cfg, "crawl")
326
433
  body: dict[str, Any] = {
327
434
  "url": args.url,
328
435
  "limit": args.limit,
@@ -376,6 +483,7 @@ class FirecrawlProvider(Provider):
376
483
  from ..errors import ArgsError
377
484
 
378
485
  cfg = self._cfg()
486
+ self._require_keyed(cfg, "extract")
379
487
  body: dict[str, Any] = {"urls": args.urls}
380
488
  if args.prompt:
381
489
  body["prompt"] = args.prompt
@@ -418,6 +526,7 @@ class FirecrawlProvider(Provider):
418
526
  from ..errors import ArgsError, ProviderError
419
527
 
420
528
  cfg = self._cfg()
529
+ mode = self._resolve_mode(args, cfg)
421
530
  # --stop:配合 --scrape-id,DELETE 会话
422
531
  if args.stop:
423
532
  if not args.scrape_id:
@@ -426,12 +535,11 @@ class FirecrawlProvider(Provider):
426
535
  proxy_url=resolve_proxy(self.config.proxy.url),
427
536
  timeout=cfg.timeout,
428
537
  base_url=cfg.base_url,
429
- headers=self._headers(cfg),
430
538
  ) as client:
431
- data = await http_mod.request_json(
432
- client, "DELETE", f"scrape/{args.scrape_id}/interact", provider="firecrawl"
539
+ await self._request_keyless(
540
+ client, "DELETE", f"scrape/{args.scrape_id}/interact",
541
+ cfg=cfg, mode=mode, endpoint="interact",
433
542
  )
434
- self._check(data, endpoint="interact")
435
543
  return {"stopped": True, "scrapeId": args.scrape_id}
436
544
 
437
545
  # 模式校验:prompt 与 --code 二选一
@@ -444,7 +552,6 @@ class FirecrawlProvider(Provider):
444
552
  proxy_url=resolve_proxy(self.config.proxy.url),
445
553
  timeout=cfg.timeout,
446
554
  base_url=cfg.base_url,
447
- headers=self._headers(cfg),
448
555
  ) as client:
449
556
  # 解析会话句柄:--scrape-id 优先;否则用 --url 发起 scrape 拿 metadata.scrapeId
450
557
  scrape_id = args.scrape_id
@@ -453,11 +560,10 @@ class FirecrawlProvider(Provider):
453
560
  raise ArgsError(
454
561
  "firecrawl interact: 需要 --scrape-id 或 --url(用于新建会话)", provider="firecrawl"
455
562
  )
456
- seed = await http_mod.request_json(
457
- client, "POST", "scrape", provider="firecrawl",
458
- json_body={"url": args.url, "formats": ["markdown"]},
563
+ seed = await self._request_keyless(
564
+ client, "POST", "scrape", cfg=cfg, mode=mode,
565
+ json_body={"url": args.url, "formats": ["markdown"]}, endpoint="scrape",
459
566
  )
460
- self._check(seed, endpoint="scrape")
461
567
  seed_inner = seed.get("data") or {}
462
568
  meta = seed_inner.get("metadata") or {}
463
569
  scrape_id = meta.get("scrapeId")
@@ -474,10 +580,10 @@ class FirecrawlProvider(Provider):
474
580
  else:
475
581
  body = {"prompt": args.prompt}
476
582
 
477
- data = await http_mod.request_json(
478
- client, "POST", f"scrape/{scrape_id}/interact", provider="firecrawl", json_body=body
583
+ data = await self._request_keyless(
584
+ client, "POST", f"scrape/{scrape_id}/interact",
585
+ cfg=cfg, mode=mode, json_body=body, endpoint="interact",
479
586
  )
480
- self._check(data, endpoint="interact")
481
587
 
482
588
  # 透传会话句柄 + 执行结果(prompt→output,code→stdout/result/stderr/exitCode/killed)
483
589
  out: dict[str, Any] = {"scrapeId": scrape_id}
@@ -492,6 +598,7 @@ class FirecrawlProvider(Provider):
492
598
  from ..errors import ArgsError
493
599
 
494
600
  cfg = self._cfg()
601
+ self._require_keyed(cfg, "agent")
495
602
  body: dict[str, Any] = {"prompt": args.prompt, "model": args.model}
496
603
  if args.urls:
497
604
  body["urls"] = args.urls
@@ -538,6 +645,7 @@ class FirecrawlProvider(Provider):
538
645
  OpenAPI 的 required 误写为 data,实际响应字段是 crawls,两个字段都兼容。
539
646
  """
540
647
  cfg = self._cfg()
648
+ self._require_keyed(cfg, "monitor")
541
649
  async with http_mod.make_client(
542
650
  proxy_url=resolve_proxy(self.config.proxy.url),
543
651
  timeout=cfg.timeout,
@@ -558,18 +666,17 @@ class FirecrawlProvider(Provider):
558
666
  }
559
667
 
560
668
  async def parse(self, args: Any) -> dict[str, Any]:
561
- """上传本地文件解析(POST /v2/parse,multipart/form-data)。
669
+ """上传本地文件解析(POST /v2/parse,multipart/form-data,keyless)。
562
670
 
563
- request_json 只支持 JSON body;parse 需要 multipart 文件上传,这里直接用 client.request,
564
- 复用 handle_http_error / wrap_provider_http_error 保持与其他 handler 一致的错误映射。
671
+ _request_keyless multipart 分支(_do_request files=...),享受与 scrape 等一致的
672
+ key 策略(auto/keyless/use_key)与错误映射。
565
673
  """
566
674
  from pathlib import Path
567
675
 
568
- import httpx
569
-
570
- from ..errors import ArgsError, NetworkError, wrap_provider_http_error
676
+ from ..errors import ArgsError
571
677
 
572
678
  cfg = self._cfg()
679
+ mode = self._resolve_mode(args, cfg)
573
680
  path = Path(args.file)
574
681
  if not path.is_file():
575
682
  raise ArgsError(f"firecrawl parse: 文件不存在: {args.file}", provider="firecrawl")
@@ -610,24 +717,10 @@ class FirecrawlProvider(Provider):
610
717
  proxy_url=resolve_proxy(self.config.proxy.url),
611
718
  timeout=cfg.timeout,
612
719
  base_url=cfg.base_url,
613
- headers=self._headers(cfg),
614
720
  ) as client:
615
- try:
616
- resp = await client.request("POST", "parse", files=files)
617
- except httpx.HTTPError as e:
618
- raise wrap_provider_http_error("firecrawl", e) from e
619
- if resp.status_code >= 400:
620
- raise http_mod.handle_http_error(
621
- "firecrawl",
622
- httpx.HTTPStatusError(
623
- f"firecrawl HTTP {resp.status_code}", request=resp.request, response=resp
624
- ),
625
- )
626
- try:
627
- data = resp.json()
628
- except ValueError as e:
629
- raise NetworkError(f"firecrawl: 响应非 JSON ({e})", provider="firecrawl") from e
630
- self._check(data, endpoint="parse")
721
+ data = await self._request_keyless(
722
+ client, "POST", "parse", cfg=cfg, mode=mode, files=files, endpoint="parse",
723
+ )
631
724
 
632
725
  # ScrapeResponse 结构:markdown 映射到 content(对齐 api-contract §2.1)
633
726
  inner = data.get("data") or {}