research-assistant 0.1.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/LICENSE +21 -0
- package/README-ZH.md +115 -0
- package/README.md +124 -0
- package/npm/bin/research-assistant.js +70 -0
- package/npm/scripts/postinstall.js +112 -0
- package/package.json +38 -0
- package/pyproject.toml +84 -0
- package/src/research_assistant/__init__.py +7 -0
- package/src/research_assistant/__main__.py +6 -0
- package/src/research_assistant/assets/researcher_persona.md +94 -0
- package/src/research_assistant/assets/skills/research-assistant/SKILL.md +248 -0
- package/src/research_assistant/cli.py +155 -0
- package/src/research_assistant/commands/__init__.py +7 -0
- package/src/research_assistant/commands/ask.py +54 -0
- package/src/research_assistant/commands/doctor.py +201 -0
- package/src/research_assistant/commands/fetch.py +99 -0
- package/src/research_assistant/commands/locate.py +71 -0
- package/src/research_assistant/commands/search.py +161 -0
- package/src/research_assistant/commands/setup.py +249 -0
- package/src/research_assistant/commands/skills.py +60 -0
- package/src/research_assistant/config.py +258 -0
- package/src/research_assistant/errors.py +106 -0
- package/src/research_assistant/fetch/__init__.py +20 -0
- package/src/research_assistant/fetch/browser.py +613 -0
- package/src/research_assistant/fetch/cfbypass.py +318 -0
- package/src/research_assistant/fetch/html2md.py +44 -0
- package/src/research_assistant/fetch/normal.py +69 -0
- package/src/research_assistant/fetch/search_engine.py +324 -0
- package/src/research_assistant/fetch/snapshot.py +50 -0
- package/src/research_assistant/http.py +118 -0
- package/src/research_assistant/installer.py +270 -0
- package/src/research_assistant/locate/__init__.py +5 -0
- package/src/research_assistant/locate/engine.py +289 -0
- package/src/research_assistant/loginstate/__init__.py +10 -0
- package/src/research_assistant/loginstate/daemon.py +145 -0
- package/src/research_assistant/loginstate/daemon_cli.py +21 -0
- package/src/research_assistant/loginstate/daemonctl.py +116 -0
- package/src/research_assistant/loginstate/extension/background.js +167 -0
- package/src/research_assistant/loginstate/extension/manifest.json +15 -0
- package/src/research_assistant/loginstate/extension/popup.html +31 -0
- package/src/research_assistant/loginstate/extension/popup.js +33 -0
- package/src/research_assistant/output.py +140 -0
- package/src/research_assistant/providers/__init__.py +10 -0
- package/src/research_assistant/providers/base.py +93 -0
- package/src/research_assistant/providers/browser.py +91 -0
- package/src/research_assistant/providers/context7.py +171 -0
- package/src/research_assistant/providers/exa.py +353 -0
- package/src/research_assistant/providers/firecrawl.py +644 -0
- package/src/research_assistant/providers/openai_compat.py +293 -0
- package/src/research_assistant/providers/registry.py +62 -0
- package/src/research_assistant/providers/tavily.py +346 -0
- package/src/research_assistant/proxy.py +58 -0
- package/src/research_assistant/targets.py +66 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
"""Firecrawl provider(自写 HTTP,ADR-0010)。
|
|
2
|
+
|
|
3
|
+
API(find-docs 查证 /firecrawl/firecrawl-docs,v2):
|
|
4
|
+
POST https://api.firecrawl.dev/v2/scrape 鉴权 Authorization: Bearer <key>
|
|
5
|
+
body: {url, formats:["markdown"|"html"], onlyMainContent, waitFor(ms)}
|
|
6
|
+
resp: {success, data:{markdown, html?, metadata{title, sourceURL,...}, warning?}}
|
|
7
|
+
POST /v2/search
|
|
8
|
+
body: {query, limit, scrapeOptions{formats:[{type:"markdown"}]}, sources}
|
|
9
|
+
resp: {success, data:[{markdown,url,title}]} (scraped) 或 {data:{web:[{url,title,description}]}}
|
|
10
|
+
POST /v2/map
|
|
11
|
+
body: {url, limit, includeSubdomains, search}
|
|
12
|
+
resp: {success, links:[{url,title,description?}]}
|
|
13
|
+
POST /v2/crawl 异步:提交返回 {id},轮询 GET /v2/crawl/{id} 到 completed/failed/cancelled
|
|
14
|
+
body: {url, limit, maxDiscoveryDepth?, includePaths[]?, excludePaths[]?,
|
|
15
|
+
allowSubdomains?, allowExternalLinks?, sitemap?, prompt?, scrapeOptions{formats}}
|
|
16
|
+
resp(POST): {success, id, url}; GET: {status, total, completed, data[{url,markdown}]}
|
|
17
|
+
POST /v2/extract 异步:提交返回 {id},轮询 GET /v2/extract/{id} 到 completed/failed/cancelled
|
|
18
|
+
body: {urls[] (含 /* 通配), prompt?, schema?, enableWebSearch?, agent{model:"FIRE-1"}?}
|
|
19
|
+
resp(POST): {success, id}; GET: {status, data, expiresAt?}
|
|
20
|
+
POST /v2/scrape/{scrapeId}/interact 有状态浏览器会话:在已有 scrape 会话里执行 prompt 或 code
|
|
21
|
+
body: {prompt} 或 {code, language("node"|"python"|"bash"), timeout(sec,1-300,默认30)}
|
|
22
|
+
resp: {success, output?, stdout?, result?, stderr?, exitCode?, killed?, cdpUrl?, liveViewUrl?}
|
|
23
|
+
scrapeId 来自 POST /v2/scrape 的 data.metadata.scrapeId;DELETE 同路径停止会话
|
|
24
|
+
POST /v2/agent 异步:提交返回 {id},轮询 GET /v2/agent/{id} 到 completed/failed(无 cancelled)
|
|
25
|
+
body: {prompt(必填,≤10000), urls[]?, schema?, maxCredits?(默认2500), model("spark-1-mini"|"spark-1-pro"), strictConstrainToURLs?}
|
|
26
|
+
resp(POST): {success, id}; GET: {status(processing|completed|failed), data?, model?, error?, creditsUsed?, expiresAt?}
|
|
27
|
+
GET /v2/crawl/active 列出当前团队所有进行中的 crawl 任务(同步,无 body)
|
|
28
|
+
resp: {success, crawls:[{id, teamId, url, status, options}]}(OpenAPI required 误写 data,实际字段是 crawls)
|
|
29
|
+
POST /v2/parse multipart/form-data 上传本地文件,转 markdown/json/html 等(同步)
|
|
30
|
+
body: file(binary,必填) + options(json: formats,onlyMainContent,includeTags,excludeTags,timeout(ms),parsers[{type:"pdf",mode,maxPages}])
|
|
31
|
+
resp: {success, data:{markdown, html?, ...}}(ScrapeResponse 结构,markdown 映射到输出 content)
|
|
32
|
+
|
|
33
|
+
对齐 api-contract.md §2.1/§3 的 firecrawl 命令与响应(links → data[])。
|
|
34
|
+
开发期无 key,实现按已查证 API;待申请 key 后实测。
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
import json
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
from ..config import ProviderConfig
|
|
44
|
+
from .. import http as http_mod
|
|
45
|
+
from ..proxy import resolve_proxy
|
|
46
|
+
from .base import ArgSpec, Capability, Provider
|
|
47
|
+
from .registry import register
|
|
48
|
+
|
|
49
|
+
DEFAULT_BASE_URL = "https://api.firecrawl.dev/v2"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@register
|
|
53
|
+
class FirecrawlProvider(Provider):
|
|
54
|
+
type = "firecrawl"
|
|
55
|
+
command = "firecrawl"
|
|
56
|
+
aliases = ["fc"]
|
|
57
|
+
help = "Firecrawl scrape / search / map (scrape+search work keyless; map needs a key)."
|
|
58
|
+
|
|
59
|
+
def capabilities(self) -> list[Capability]:
|
|
60
|
+
return [
|
|
61
|
+
Capability(
|
|
62
|
+
name="scrape",
|
|
63
|
+
help="Scrape one or more URLs to markdown/html (keyless).",
|
|
64
|
+
args=[
|
|
65
|
+
ArgSpec(["urls"], kind="positional", nargs="+", metavar="URL", help="URL(s) to scrape."),
|
|
66
|
+
ArgSpec(["--format"], choices=["markdown", "html"], default="markdown", help="Output format."),
|
|
67
|
+
ArgSpec(["--only-main-content"], action="store_true", help="Drop nav/footer boilerplate."),
|
|
68
|
+
ArgSpec(["--wait-for"], type=int, metavar="MS", help="Wait N ms before scraping."),
|
|
69
|
+
],
|
|
70
|
+
handler=self.scrape,
|
|
71
|
+
),
|
|
72
|
+
Capability(
|
|
73
|
+
name="search",
|
|
74
|
+
help="Web search (keyless); with --scrape returns full markdown per result.",
|
|
75
|
+
args=[
|
|
76
|
+
ArgSpec(["query"], kind="positional", help="Search query."),
|
|
77
|
+
ArgSpec(["--limit"], type=int, default=10, help="Max results."),
|
|
78
|
+
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
|
+
],
|
|
81
|
+
handler=self.search,
|
|
82
|
+
),
|
|
83
|
+
Capability(
|
|
84
|
+
name="map",
|
|
85
|
+
help="Map a site's URLs (requires an API key, not in the keyless tier).",
|
|
86
|
+
args=[
|
|
87
|
+
ArgSpec(["url"], kind="positional", help="Base URL to map."),
|
|
88
|
+
ArgSpec(["--limit"], type=int, default=100, help="Max links."),
|
|
89
|
+
ArgSpec(["--include-subdomains"], action="store_true", help="Include subdomains."),
|
|
90
|
+
],
|
|
91
|
+
handler=self.map,
|
|
92
|
+
),
|
|
93
|
+
Capability(
|
|
94
|
+
name="crawl",
|
|
95
|
+
help="Recursively crawl a site; blocks until done or --poll-timeout (needs a key).",
|
|
96
|
+
args=[
|
|
97
|
+
ArgSpec(["url"], kind="positional", help="Starting URL to crawl."),
|
|
98
|
+
ArgSpec(["--limit"], type=int, default=100, help="Max pages to crawl (default 100)."),
|
|
99
|
+
ArgSpec(["--max-depth"], type=int, help="Max discovery depth from the root."),
|
|
100
|
+
ArgSpec(["--include-paths"], nargs="+", metavar="REGEX", help="Pathname regexes to include."),
|
|
101
|
+
ArgSpec(["--exclude-paths"], nargs="+", metavar="REGEX", help="Pathname regexes to exclude."),
|
|
102
|
+
ArgSpec(["--allow-subdomains"], action="store_true", help="Follow links to subdomains."),
|
|
103
|
+
ArgSpec(["--allow-external"], action="store_true", help="Follow links to external sites."),
|
|
104
|
+
ArgSpec(["--sitemap"], choices=["include", "skip", "only"], help="Sitemap handling (default include)."),
|
|
105
|
+
ArgSpec(["--prompt"], metavar="TEXT", help="Natural-language prompt to generate crawl options."),
|
|
106
|
+
ArgSpec(["--poll-timeout"], type=int, default=120, help="Seconds to wait for completion (default 120)."),
|
|
107
|
+
ArgSpec(["--async-submit"], action="store_true", help="Submit only; return the job id without waiting."),
|
|
108
|
+
],
|
|
109
|
+
handler=self.crawl,
|
|
110
|
+
),
|
|
111
|
+
Capability(
|
|
112
|
+
name="extract",
|
|
113
|
+
help="Extract structured data from URLs via LLM; blocks until done or --poll-timeout (needs a key).",
|
|
114
|
+
args=[
|
|
115
|
+
ArgSpec(["urls"], kind="positional", nargs="+", metavar="URL", help="URL(s); supports /* wildcard for a whole domain."),
|
|
116
|
+
ArgSpec(["--prompt"], metavar="TEXT", help="Describe the data to extract (required if no --schema)."),
|
|
117
|
+
ArgSpec(["--schema"], metavar="JSON", help="JSON Schema string for a rigid output shape (required if no --prompt)."),
|
|
118
|
+
ArgSpec(["--enable-web-search"], action="store_true", help="Follow links beyond the given URLs."),
|
|
119
|
+
ArgSpec(["--agent"], action="store_true", help="Use the FIRE-1 agent for navigation/interaction."),
|
|
120
|
+
ArgSpec(["--poll-timeout"], type=int, default=120, help="Seconds to wait for completion (default 120)."),
|
|
121
|
+
ArgSpec(["--async-submit"], action="store_true", help="Submit only; return the job id without waiting."),
|
|
122
|
+
],
|
|
123
|
+
handler=self.extract,
|
|
124
|
+
),
|
|
125
|
+
Capability(
|
|
126
|
+
name="interact",
|
|
127
|
+
help="Run a prompt or code in a live browser session bound to a scrape (needs a key).",
|
|
128
|
+
args=[
|
|
129
|
+
ArgSpec(["prompt"], kind="positional", nargs="?", metavar="TEXT",
|
|
130
|
+
help="AI prompt to run in the browser session (mode 1; exclusive with --code)."),
|
|
131
|
+
ArgSpec(["--scrape-id"], metavar="ID",
|
|
132
|
+
help="Reuse an existing scrape session id (transparent handle, no local state)."),
|
|
133
|
+
ArgSpec(["--url"], metavar="URL",
|
|
134
|
+
help="Create a new scrape session from this URL when no --scrape-id is given."),
|
|
135
|
+
ArgSpec(["--code"], metavar="TEXT",
|
|
136
|
+
help="Code to execute (mode 2; exclusive with prompt)."),
|
|
137
|
+
ArgSpec(["--language"], choices=["node", "python", "bash"], default="node",
|
|
138
|
+
help="Code language (code mode only, default node)."),
|
|
139
|
+
ArgSpec(["--timeout"], type=int, default=30, metavar="SEC",
|
|
140
|
+
help="Execution timeout seconds (1-300, default 30)."),
|
|
141
|
+
ArgSpec(["--stop"], action="store_true",
|
|
142
|
+
help="Stop and tear down the session (requires --scrape-id)."),
|
|
143
|
+
],
|
|
144
|
+
handler=self.interact,
|
|
145
|
+
),
|
|
146
|
+
Capability(
|
|
147
|
+
name="agent",
|
|
148
|
+
help="Agentic data extraction (spark model); blocks until done or --poll-timeout (needs a key).",
|
|
149
|
+
args=[
|
|
150
|
+
ArgSpec(["prompt"], kind="positional", metavar="TEXT",
|
|
151
|
+
help="What data to extract (required, max 10000 chars)."),
|
|
152
|
+
ArgSpec(["--model"], choices=["spark-1-mini", "spark-1-pro"], default="spark-1-mini",
|
|
153
|
+
help="Agent model (default spark-1-mini; spark-1-pro for complex tasks)."),
|
|
154
|
+
ArgSpec(["--urls"], nargs="+", metavar="URL",
|
|
155
|
+
help="URLs to constrain the agent."),
|
|
156
|
+
ArgSpec(["--schema"], metavar="JSON",
|
|
157
|
+
help="JSON Schema string to structure the extracted output."),
|
|
158
|
+
ArgSpec(["--max-credits"], type=int, metavar="N",
|
|
159
|
+
help="Max credits to spend (default 2500; above 2500 bills as paid)."),
|
|
160
|
+
ArgSpec(["--strict"], action="store_true",
|
|
161
|
+
help="Only visit URLs listed in --urls."),
|
|
162
|
+
ArgSpec(["--poll-timeout"], type=int, default=120,
|
|
163
|
+
help="Seconds to wait for completion (default 120)."),
|
|
164
|
+
ArgSpec(["--async-submit"], action="store_true",
|
|
165
|
+
help="Submit only; return the job id without waiting."),
|
|
166
|
+
],
|
|
167
|
+
handler=self.agent,
|
|
168
|
+
),
|
|
169
|
+
Capability(
|
|
170
|
+
name="monitor",
|
|
171
|
+
help="List active crawl jobs for the authenticated team (needs a key).",
|
|
172
|
+
args=[],
|
|
173
|
+
handler=self.monitor,
|
|
174
|
+
),
|
|
175
|
+
Capability(
|
|
176
|
+
name="parse",
|
|
177
|
+
help="Upload a local document (.pdf/.docx/.html/...) and parse to markdown/json (needs a key).",
|
|
178
|
+
args=[
|
|
179
|
+
ArgSpec(["file"], kind="positional", metavar="PATH",
|
|
180
|
+
help="Local file path (.html/.htm/.pdf/.docx/.doc/.odt/.rtf/.xlsx/.xls)."),
|
|
181
|
+
ArgSpec(["--format"], nargs="+",
|
|
182
|
+
choices=["markdown", "html", "rawHtml", "links", "images", "summary", "json"],
|
|
183
|
+
default=["markdown"],
|
|
184
|
+
help="Output formats (default markdown)."),
|
|
185
|
+
ArgSpec(["--only-main-content"], action="store_true",
|
|
186
|
+
help="Drop nav/footer boilerplate."),
|
|
187
|
+
ArgSpec(["--include-tags"], nargs="+", metavar="TAG",
|
|
188
|
+
help="HTML tags to include."),
|
|
189
|
+
ArgSpec(["--exclude-tags"], nargs="+", metavar="TAG",
|
|
190
|
+
help="HTML tags to exclude."),
|
|
191
|
+
ArgSpec(["--timeout"], type=int, default=30000, metavar="MS",
|
|
192
|
+
help="Parse timeout ms (default 30000, max 300000)."),
|
|
193
|
+
ArgSpec(["--pdf-mode"], choices=["fast", "auto", "ocr"],
|
|
194
|
+
help="PDF parser mode (sets options.parsers[0].mode)."),
|
|
195
|
+
ArgSpec(["--max-pages"], type=int, metavar="N",
|
|
196
|
+
help="Max PDF pages to parse (1-10000, needs --pdf-mode)."),
|
|
197
|
+
],
|
|
198
|
+
handler=self.parse,
|
|
199
|
+
),
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
def _cfg(self):
|
|
203
|
+
"""firecrawl 免 key 层:scrape/search 无需 key 即可用。
|
|
204
|
+
|
|
205
|
+
未配置时返回默认(默认 base_url、无 key);有 key 才带 Authorization(更高配额 + 解锁 map)。
|
|
206
|
+
"""
|
|
207
|
+
cfg = self.config.provider("firecrawl")
|
|
208
|
+
if cfg is None:
|
|
209
|
+
cfg = ProviderConfig(type="firecrawl", base_url=DEFAULT_BASE_URL, api_key="", timeout=30)
|
|
210
|
+
if not cfg.base_url:
|
|
211
|
+
cfg.base_url = DEFAULT_BASE_URL
|
|
212
|
+
return cfg
|
|
213
|
+
|
|
214
|
+
def _headers(self, cfg) -> dict[str, str]:
|
|
215
|
+
# 免 key 层不带 Authorization;配了 key 才带(解锁更高配额与 map)
|
|
216
|
+
return {"Authorization": f"Bearer {cfg.api_key}"} if cfg.api_key else {}
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _check(data: dict[str, Any], *, endpoint: str) -> None:
|
|
220
|
+
"""firecrawl 对不支持的调用返回 200 + success:false(如免 key 调 map)→ 抛清晰错误。"""
|
|
221
|
+
if isinstance(data, dict) and data.get("success") is False:
|
|
222
|
+
from ..errors import ProviderError
|
|
223
|
+
|
|
224
|
+
msg = data.get("error") or "firecrawl 返回 success:false"
|
|
225
|
+
raise ProviderError(f"firecrawl {endpoint}: {msg}", provider="firecrawl", details=data)
|
|
226
|
+
|
|
227
|
+
async def _scrape_one(self, client: Any, url: str, fmt: str, args: Any) -> dict[str, Any]:
|
|
228
|
+
body: dict[str, Any] = {"url": url, "formats": [fmt]}
|
|
229
|
+
if getattr(args, "only_main_content", False):
|
|
230
|
+
body["onlyMainContent"] = True
|
|
231
|
+
if getattr(args, "wait_for", None):
|
|
232
|
+
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")
|
|
235
|
+
inner = data.get("data") or {}
|
|
236
|
+
out: dict[str, Any] = {}
|
|
237
|
+
if fmt == "markdown":
|
|
238
|
+
out["markdown"] = inner.get("markdown")
|
|
239
|
+
else:
|
|
240
|
+
out["html"] = inner.get("html")
|
|
241
|
+
meta = inner.get("metadata") or {}
|
|
242
|
+
out["metadata"] = {"title": meta.get("title"), "sourceURL": meta.get("sourceURL") or url}
|
|
243
|
+
if inner.get("warning"):
|
|
244
|
+
out["warning"] = inner.get("warning")
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
async def scrape(self, args: Any) -> dict[str, Any]:
|
|
248
|
+
cfg = self._cfg()
|
|
249
|
+
urls = args.urls
|
|
250
|
+
async with http_mod.make_client(
|
|
251
|
+
proxy_url=resolve_proxy(self.config.proxy.url),
|
|
252
|
+
timeout=cfg.timeout,
|
|
253
|
+
base_url=cfg.base_url,
|
|
254
|
+
headers=self._headers(cfg),
|
|
255
|
+
) as client:
|
|
256
|
+
if len(urls) == 1:
|
|
257
|
+
return {"data": await self._scrape_one(client, urls[0], args.format, args)}
|
|
258
|
+
# 批量并发
|
|
259
|
+
items = await asyncio.gather(*[self._scrape_one(client, u, args.format, args) for u in urls])
|
|
260
|
+
return {"results": [{"url": u, "data": d} for u, d in zip(urls, items)]}
|
|
261
|
+
|
|
262
|
+
async def search(self, args: Any) -> dict[str, Any]:
|
|
263
|
+
cfg = self._cfg()
|
|
264
|
+
body: dict[str, Any] = {"query": args.query, "limit": args.limit}
|
|
265
|
+
if args.scrape:
|
|
266
|
+
body["scrapeOptions"] = {"formats": [{"type": "markdown"}]}
|
|
267
|
+
if args.sources:
|
|
268
|
+
body["sources"] = args.sources
|
|
269
|
+
async with http_mod.make_client(
|
|
270
|
+
proxy_url=resolve_proxy(self.config.proxy.url),
|
|
271
|
+
timeout=cfg.timeout,
|
|
272
|
+
base_url=cfg.base_url,
|
|
273
|
+
headers=self._headers(cfg),
|
|
274
|
+
) as client:
|
|
275
|
+
data = await http_mod.request_json(client, "POST", "search", provider="firecrawl", json_body=body)
|
|
276
|
+
self._check(data, endpoint="search")
|
|
277
|
+
out: dict[str, Any] = {}
|
|
278
|
+
if args.scrape:
|
|
279
|
+
arr = data.get("data") or []
|
|
280
|
+
out["data"] = [
|
|
281
|
+
{"markdown": item.get("markdown"), "url": item.get("url"), "title": item.get("title")}
|
|
282
|
+
for item in arr
|
|
283
|
+
]
|
|
284
|
+
else:
|
|
285
|
+
web = (data.get("data") or {}).get("web") if isinstance(data.get("data"), dict) else data.get("data")
|
|
286
|
+
out["data"] = [
|
|
287
|
+
{"url": item.get("url"), "title": item.get("title"), "description": item.get("description")}
|
|
288
|
+
for item in (web or [])
|
|
289
|
+
]
|
|
290
|
+
if data.get("warning"):
|
|
291
|
+
out["warning"] = data.get("warning")
|
|
292
|
+
return out
|
|
293
|
+
|
|
294
|
+
async def map(self, args: Any) -> dict[str, Any]:
|
|
295
|
+
cfg = self._cfg()
|
|
296
|
+
body: dict[str, Any] = {"url": args.url, "limit": args.limit}
|
|
297
|
+
if args.include_subdomains:
|
|
298
|
+
body["includeSubdomains"] = True
|
|
299
|
+
async with http_mod.make_client(
|
|
300
|
+
proxy_url=resolve_proxy(self.config.proxy.url),
|
|
301
|
+
timeout=cfg.timeout,
|
|
302
|
+
base_url=cfg.base_url,
|
|
303
|
+
headers=self._headers(cfg),
|
|
304
|
+
) as client:
|
|
305
|
+
data = await http_mod.request_json(client, "POST", "map", provider="firecrawl", json_body=body)
|
|
306
|
+
self._check(data, endpoint="map")
|
|
307
|
+
links = data.get("links") or []
|
|
308
|
+
return {"data": [{"url": l.get("url"), "title": l.get("title"), "description": l.get("description")} for l in links]}
|
|
309
|
+
|
|
310
|
+
async def _poll_job(self, client: Any, path: str, *, poll_timeout: int) -> dict[str, Any]:
|
|
311
|
+
"""轮询 firecrawl 异步 job(crawl/extract)直到终态或超时。"""
|
|
312
|
+
loop = asyncio.get_event_loop()
|
|
313
|
+
deadline = loop.time() + poll_timeout
|
|
314
|
+
while True:
|
|
315
|
+
data = await http_mod.request_json(client, "GET", path, provider="firecrawl")
|
|
316
|
+
self._check(data, endpoint=path)
|
|
317
|
+
status = data.get("status")
|
|
318
|
+
if status in ("completed", "failed", "cancelled"):
|
|
319
|
+
return data
|
|
320
|
+
if loop.time() >= deadline:
|
|
321
|
+
return {"status": "timeout", "partial": data.get("data")}
|
|
322
|
+
await asyncio.sleep(2)
|
|
323
|
+
|
|
324
|
+
async def crawl(self, args: Any) -> dict[str, Any]:
|
|
325
|
+
cfg = self._cfg()
|
|
326
|
+
body: dict[str, Any] = {
|
|
327
|
+
"url": args.url,
|
|
328
|
+
"limit": args.limit,
|
|
329
|
+
"scrapeOptions": {"formats": ["markdown"]},
|
|
330
|
+
}
|
|
331
|
+
if args.max_depth:
|
|
332
|
+
body["maxDiscoveryDepth"] = args.max_depth
|
|
333
|
+
if args.include_paths:
|
|
334
|
+
body["includePaths"] = args.include_paths
|
|
335
|
+
if args.exclude_paths:
|
|
336
|
+
body["excludePaths"] = args.exclude_paths
|
|
337
|
+
if args.allow_subdomains:
|
|
338
|
+
body["allowSubdomains"] = True
|
|
339
|
+
if args.allow_external:
|
|
340
|
+
body["allowExternalLinks"] = True
|
|
341
|
+
if args.sitemap:
|
|
342
|
+
body["sitemap"] = args.sitemap
|
|
343
|
+
if args.prompt:
|
|
344
|
+
body["prompt"] = args.prompt
|
|
345
|
+
|
|
346
|
+
async with http_mod.make_client(
|
|
347
|
+
proxy_url=resolve_proxy(self.config.proxy.url),
|
|
348
|
+
timeout=cfg.timeout,
|
|
349
|
+
base_url=cfg.base_url,
|
|
350
|
+
headers=self._headers(cfg),
|
|
351
|
+
) as client:
|
|
352
|
+
submit = await http_mod.request_json(client, "POST", "crawl", provider="firecrawl", json_body=body)
|
|
353
|
+
self._check(submit, endpoint="crawl")
|
|
354
|
+
job_id = submit.get("id")
|
|
355
|
+
if args.async_submit:
|
|
356
|
+
return {"id": job_id, "url": submit.get("url"), "status": "submitted"}
|
|
357
|
+
data = await self._poll_job(client, f"crawl/{job_id}", poll_timeout=args.poll_timeout)
|
|
358
|
+
status = data.get("status")
|
|
359
|
+
pages: list[dict[str, Any]] = []
|
|
360
|
+
for p in data.get("data", []) or []:
|
|
361
|
+
item: dict[str, Any] = {"url": p.get("url")}
|
|
362
|
+
md = p.get("markdown")
|
|
363
|
+
if md:
|
|
364
|
+
item["content"] = md
|
|
365
|
+
pages.append(item)
|
|
366
|
+
out: dict[str, Any] = {"status": status, "results": pages}
|
|
367
|
+
if data.get("total") is not None:
|
|
368
|
+
out["total"] = data.get("total")
|
|
369
|
+
if data.get("completed") is not None:
|
|
370
|
+
out["completed"] = data.get("completed")
|
|
371
|
+
if status != "completed":
|
|
372
|
+
out["note"] = "job not completed; partial or timeout. Resubmit with --async-submit and poll crawl/<id>."
|
|
373
|
+
return out
|
|
374
|
+
|
|
375
|
+
async def extract(self, args: Any) -> dict[str, Any]:
|
|
376
|
+
from ..errors import ArgsError
|
|
377
|
+
|
|
378
|
+
cfg = self._cfg()
|
|
379
|
+
body: dict[str, Any] = {"urls": args.urls}
|
|
380
|
+
if args.prompt:
|
|
381
|
+
body["prompt"] = args.prompt
|
|
382
|
+
if args.schema:
|
|
383
|
+
try:
|
|
384
|
+
body["schema"] = json.loads(args.schema)
|
|
385
|
+
except (ValueError, json.JSONDecodeError) as e:
|
|
386
|
+
raise ArgsError(f"firecrawl extract: --schema 不是合法 JSON ({e})", provider="firecrawl") from e
|
|
387
|
+
if not args.prompt and not args.schema:
|
|
388
|
+
raise ArgsError("firecrawl extract: 需要 --prompt 或 --schema 之一", provider="firecrawl")
|
|
389
|
+
if args.enable_web_search:
|
|
390
|
+
body["enableWebSearch"] = True
|
|
391
|
+
if args.agent:
|
|
392
|
+
body["agent"] = {"model": "FIRE-1"}
|
|
393
|
+
|
|
394
|
+
async with http_mod.make_client(
|
|
395
|
+
proxy_url=resolve_proxy(self.config.proxy.url),
|
|
396
|
+
timeout=cfg.timeout,
|
|
397
|
+
base_url=cfg.base_url,
|
|
398
|
+
headers=self._headers(cfg),
|
|
399
|
+
) as client:
|
|
400
|
+
submit = await http_mod.request_json(client, "POST", "extract", provider="firecrawl", json_body=body)
|
|
401
|
+
self._check(submit, endpoint="extract")
|
|
402
|
+
job_id = submit.get("id")
|
|
403
|
+
if args.async_submit:
|
|
404
|
+
return {"id": job_id, "status": "submitted"}
|
|
405
|
+
data = await self._poll_job(client, f"extract/{job_id}", poll_timeout=args.poll_timeout)
|
|
406
|
+
out: dict[str, Any] = {"status": data.get("status"), "data": data.get("data")}
|
|
407
|
+
if data.get("expiresAt"):
|
|
408
|
+
out["expiresAt"] = data.get("expiresAt")
|
|
409
|
+
if data.get("status") != "completed":
|
|
410
|
+
out["note"] = "job not completed; partial or timeout. Resubmit with --async-submit and poll extract/<id>."
|
|
411
|
+
return out
|
|
412
|
+
|
|
413
|
+
async def interact(self, args: Any) -> dict[str, Any]:
|
|
414
|
+
"""有状态浏览器会话:在已有 scrape 会话里执行 prompt 或 code。
|
|
415
|
+
|
|
416
|
+
CLI 跨进程无状态,scrape_id 由调用方透传:--scrape-id 复用,或 --url 新建一次 scrape 拿 scrapeId。
|
|
417
|
+
"""
|
|
418
|
+
from ..errors import ArgsError, ProviderError
|
|
419
|
+
|
|
420
|
+
cfg = self._cfg()
|
|
421
|
+
# --stop:配合 --scrape-id,DELETE 会话
|
|
422
|
+
if args.stop:
|
|
423
|
+
if not args.scrape_id:
|
|
424
|
+
raise ArgsError("firecrawl interact: --stop 需要配合 --scrape-id", provider="firecrawl")
|
|
425
|
+
async with http_mod.make_client(
|
|
426
|
+
proxy_url=resolve_proxy(self.config.proxy.url),
|
|
427
|
+
timeout=cfg.timeout,
|
|
428
|
+
base_url=cfg.base_url,
|
|
429
|
+
headers=self._headers(cfg),
|
|
430
|
+
) as client:
|
|
431
|
+
data = await http_mod.request_json(
|
|
432
|
+
client, "DELETE", f"scrape/{args.scrape_id}/interact", provider="firecrawl"
|
|
433
|
+
)
|
|
434
|
+
self._check(data, endpoint="interact")
|
|
435
|
+
return {"stopped": True, "scrapeId": args.scrape_id}
|
|
436
|
+
|
|
437
|
+
# 模式校验:prompt 与 --code 二选一
|
|
438
|
+
if args.prompt and args.code:
|
|
439
|
+
raise ArgsError("firecrawl interact: prompt 与 --code 二选一", provider="firecrawl")
|
|
440
|
+
if not args.prompt and not args.code:
|
|
441
|
+
raise ArgsError("firecrawl interact: 需要 prompt 或 --code", provider="firecrawl")
|
|
442
|
+
|
|
443
|
+
async with http_mod.make_client(
|
|
444
|
+
proxy_url=resolve_proxy(self.config.proxy.url),
|
|
445
|
+
timeout=cfg.timeout,
|
|
446
|
+
base_url=cfg.base_url,
|
|
447
|
+
headers=self._headers(cfg),
|
|
448
|
+
) as client:
|
|
449
|
+
# 解析会话句柄:--scrape-id 优先;否则用 --url 发起 scrape 拿 metadata.scrapeId
|
|
450
|
+
scrape_id = args.scrape_id
|
|
451
|
+
if not scrape_id:
|
|
452
|
+
if not args.url:
|
|
453
|
+
raise ArgsError(
|
|
454
|
+
"firecrawl interact: 需要 --scrape-id 或 --url(用于新建会话)", provider="firecrawl"
|
|
455
|
+
)
|
|
456
|
+
seed = await http_mod.request_json(
|
|
457
|
+
client, "POST", "scrape", provider="firecrawl",
|
|
458
|
+
json_body={"url": args.url, "formats": ["markdown"]},
|
|
459
|
+
)
|
|
460
|
+
self._check(seed, endpoint="scrape")
|
|
461
|
+
seed_inner = seed.get("data") or {}
|
|
462
|
+
meta = seed_inner.get("metadata") or {}
|
|
463
|
+
scrape_id = meta.get("scrapeId")
|
|
464
|
+
if not scrape_id:
|
|
465
|
+
raise ProviderError(
|
|
466
|
+
"firecrawl interact: scrape 响应未包含 metadata.scrapeId",
|
|
467
|
+
provider="firecrawl", details=seed,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# 构造 interact body:prompt 模式 或 code 模式
|
|
471
|
+
body: dict[str, Any]
|
|
472
|
+
if args.code:
|
|
473
|
+
body = {"code": args.code, "language": args.language, "timeout": args.timeout}
|
|
474
|
+
else:
|
|
475
|
+
body = {"prompt": args.prompt}
|
|
476
|
+
|
|
477
|
+
data = await http_mod.request_json(
|
|
478
|
+
client, "POST", f"scrape/{scrape_id}/interact", provider="firecrawl", json_body=body
|
|
479
|
+
)
|
|
480
|
+
self._check(data, endpoint="interact")
|
|
481
|
+
|
|
482
|
+
# 透传会话句柄 + 执行结果(prompt→output,code→stdout/result/stderr/exitCode/killed)
|
|
483
|
+
out: dict[str, Any] = {"scrapeId": scrape_id}
|
|
484
|
+
for k in ("output", "stdout", "result", "stderr", "exitCode", "killed",
|
|
485
|
+
"cdpUrl", "liveViewUrl", "interactiveLiveViewUrl"):
|
|
486
|
+
if data.get(k) is not None:
|
|
487
|
+
out[k] = data.get(k)
|
|
488
|
+
return out
|
|
489
|
+
|
|
490
|
+
async def agent(self, args: Any) -> dict[str, Any]:
|
|
491
|
+
"""异步 agent 数据提取:POST /v2/agent 拿 id,轮询 GET /v2/agent/{id} 到 completed/failed。"""
|
|
492
|
+
from ..errors import ArgsError
|
|
493
|
+
|
|
494
|
+
cfg = self._cfg()
|
|
495
|
+
body: dict[str, Any] = {"prompt": args.prompt, "model": args.model}
|
|
496
|
+
if args.urls:
|
|
497
|
+
body["urls"] = args.urls
|
|
498
|
+
if args.schema:
|
|
499
|
+
try:
|
|
500
|
+
body["schema"] = json.loads(args.schema)
|
|
501
|
+
except (ValueError, json.JSONDecodeError) as e:
|
|
502
|
+
raise ArgsError(f"firecrawl agent: --schema 不是合法 JSON ({e})", provider="firecrawl") from e
|
|
503
|
+
if args.max_credits is not None:
|
|
504
|
+
body["maxCredits"] = args.max_credits
|
|
505
|
+
if args.strict:
|
|
506
|
+
body["strictConstrainToURLs"] = True
|
|
507
|
+
|
|
508
|
+
async with http_mod.make_client(
|
|
509
|
+
proxy_url=resolve_proxy(self.config.proxy.url),
|
|
510
|
+
timeout=cfg.timeout,
|
|
511
|
+
base_url=cfg.base_url,
|
|
512
|
+
headers=self._headers(cfg),
|
|
513
|
+
) as client:
|
|
514
|
+
submit = await http_mod.request_json(client, "POST", "agent", provider="firecrawl", json_body=body)
|
|
515
|
+
self._check(submit, endpoint="agent")
|
|
516
|
+
job_id = submit.get("id")
|
|
517
|
+
if args.async_submit:
|
|
518
|
+
return {"id": job_id, "status": "submitted"}
|
|
519
|
+
data = await self._poll_job(client, f"agent/{job_id}", poll_timeout=args.poll_timeout)
|
|
520
|
+
|
|
521
|
+
out: dict[str, Any] = {"status": data.get("status"), "data": data.get("data")}
|
|
522
|
+
if data.get("model"):
|
|
523
|
+
out["model"] = data.get("model")
|
|
524
|
+
if data.get("creditsUsed") is not None:
|
|
525
|
+
out["creditsUsed"] = data.get("creditsUsed")
|
|
526
|
+
if data.get("expiresAt"):
|
|
527
|
+
out["expiresAt"] = data.get("expiresAt")
|
|
528
|
+
if data.get("error"):
|
|
529
|
+
out["error"] = data.get("error")
|
|
530
|
+
if data.get("status") != "completed":
|
|
531
|
+
out["note"] = "job not completed; partial or timeout. Resubmit with --async-submit and poll agent/<id>."
|
|
532
|
+
return out
|
|
533
|
+
|
|
534
|
+
async def monitor(self, args: Any) -> dict[str, Any]:
|
|
535
|
+
"""列出当前团队所有进行中的 crawl 任务(GET /v2/crawl/active,同步)。
|
|
536
|
+
|
|
537
|
+
注意:这与 Firecrawl 的 Monitoring(定时调度 + 变更通知)feature 不同;此处按 active-crawls 端点实现。
|
|
538
|
+
OpenAPI 的 required 误写为 data,实际响应字段是 crawls,两个字段都兼容。
|
|
539
|
+
"""
|
|
540
|
+
cfg = self._cfg()
|
|
541
|
+
async with http_mod.make_client(
|
|
542
|
+
proxy_url=resolve_proxy(self.config.proxy.url),
|
|
543
|
+
timeout=cfg.timeout,
|
|
544
|
+
base_url=cfg.base_url,
|
|
545
|
+
headers=self._headers(cfg),
|
|
546
|
+
) as client:
|
|
547
|
+
data = await http_mod.request_json(client, "GET", "crawl/active", provider="firecrawl")
|
|
548
|
+
self._check(data, endpoint="crawl/active")
|
|
549
|
+
# 兼容文档不一致:properties 定义 crawls,required 却写 data
|
|
550
|
+
crawls = data.get("crawls")
|
|
551
|
+
if crawls is None:
|
|
552
|
+
crawls = data.get("data") or []
|
|
553
|
+
return {
|
|
554
|
+
"data": [
|
|
555
|
+
{"id": c.get("id"), "url": c.get("url"), "status": c.get("status")}
|
|
556
|
+
for c in crawls
|
|
557
|
+
]
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async def parse(self, args: Any) -> dict[str, Any]:
|
|
561
|
+
"""上传本地文件解析(POST /v2/parse,multipart/form-data)。
|
|
562
|
+
|
|
563
|
+
request_json 只支持 JSON body;parse 需要 multipart 文件上传,这里直接用 client.request,
|
|
564
|
+
复用 handle_http_error / wrap_provider_http_error 保持与其他 handler 一致的错误映射。
|
|
565
|
+
"""
|
|
566
|
+
from pathlib import Path
|
|
567
|
+
|
|
568
|
+
import httpx
|
|
569
|
+
|
|
570
|
+
from ..errors import ArgsError, NetworkError, wrap_provider_http_error
|
|
571
|
+
|
|
572
|
+
cfg = self._cfg()
|
|
573
|
+
path = Path(args.file)
|
|
574
|
+
if not path.is_file():
|
|
575
|
+
raise ArgsError(f"firecrawl parse: 文件不存在: {args.file}", provider="firecrawl")
|
|
576
|
+
allowed = {".html", ".htm", ".pdf", ".docx", ".doc", ".odt", ".rtf", ".xlsx", ".xls"}
|
|
577
|
+
suffix = path.suffix.lower()
|
|
578
|
+
if suffix not in allowed:
|
|
579
|
+
raise ArgsError(
|
|
580
|
+
f"firecrawl parse: 不支持的扩展名 {suffix}(允许 {','.join(sorted(allowed))})",
|
|
581
|
+
provider="firecrawl",
|
|
582
|
+
)
|
|
583
|
+
file_bytes = path.read_bytes()
|
|
584
|
+
if len(file_bytes) > 50 * 1024 * 1024:
|
|
585
|
+
raise ArgsError("firecrawl parse: 文件超过 50MB 上限", provider="firecrawl")
|
|
586
|
+
|
|
587
|
+
# 构造 options(JSON part)
|
|
588
|
+
options: dict[str, Any] = {"formats": args.format}
|
|
589
|
+
if args.only_main_content:
|
|
590
|
+
options["onlyMainContent"] = True
|
|
591
|
+
if args.include_tags:
|
|
592
|
+
options["includeTags"] = args.include_tags
|
|
593
|
+
if args.exclude_tags:
|
|
594
|
+
options["excludeTags"] = args.exclude_tags
|
|
595
|
+
if args.timeout is not None:
|
|
596
|
+
options["timeout"] = args.timeout
|
|
597
|
+
if args.pdf_mode:
|
|
598
|
+
parser: dict[str, Any] = {"type": "pdf", "mode": args.pdf_mode}
|
|
599
|
+
if args.max_pages is not None:
|
|
600
|
+
parser["maxPages"] = args.max_pages
|
|
601
|
+
options["parsers"] = [parser]
|
|
602
|
+
|
|
603
|
+
# multipart:file 是二进制 part,options 是 application/json part(无 filename)
|
|
604
|
+
files = {
|
|
605
|
+
"file": (path.name, file_bytes, "application/octet-stream"),
|
|
606
|
+
"options": (None, json.dumps(options), "application/json"),
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async with http_mod.make_client(
|
|
610
|
+
proxy_url=resolve_proxy(self.config.proxy.url),
|
|
611
|
+
timeout=cfg.timeout,
|
|
612
|
+
base_url=cfg.base_url,
|
|
613
|
+
headers=self._headers(cfg),
|
|
614
|
+
) 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")
|
|
631
|
+
|
|
632
|
+
# ScrapeResponse 结构:markdown 映射到 content(对齐 api-contract §2.1)
|
|
633
|
+
inner = data.get("data") or {}
|
|
634
|
+
out: dict[str, Any] = {}
|
|
635
|
+
md = inner.get("markdown")
|
|
636
|
+
if md:
|
|
637
|
+
out["content"] = md
|
|
638
|
+
for k in ("html", "rawHtml", "summary", "links", "images"):
|
|
639
|
+
if inner.get(k) is not None:
|
|
640
|
+
out[k] = inner.get(k)
|
|
641
|
+
meta = inner.get("metadata") or {}
|
|
642
|
+
if meta:
|
|
643
|
+
out["metadata"] = meta
|
|
644
|
+
return out
|