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 +1 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/research_assistant/assets/skills/research-assistant/SKILL.md +25 -21
- package/src/research_assistant/cli.py +2 -1
- package/src/research_assistant/commands/_provider_meta.py +80 -0
- package/src/research_assistant/commands/config.py +47 -0
- package/src/research_assistant/commands/setup.py +4 -7
- package/src/research_assistant/providers/browser.py +1 -1
- package/src/research_assistant/providers/exa.py +2 -2
- package/src/research_assistant/providers/firecrawl.py +146 -53
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.
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
175
|
-
research-assistant firecrawl search "<query>" [--limit N] [--sources web|news] [--scrape]
|
|
176
|
-
research-assistant firecrawl map <url> [--limit N] [--include-subdomains]
|
|
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
|
|
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
|
|
181
|
-
research-assistant firecrawl monitor
|
|
182
|
-
research-assistant firecrawl parse <file> [--format markdown|html|json ...] [--pdf-mode fast|auto|ocr] [--max-pages N] #
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
432
|
-
client, "DELETE", f"scrape/{args.scrape_id}/interact",
|
|
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
|
|
457
|
-
client, "POST", "scrape",
|
|
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
|
|
478
|
-
client, "POST", f"scrape/{scrape_id}/interact",
|
|
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
|
-
|
|
564
|
-
|
|
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
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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 {}
|