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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README-ZH.md +115 -0
  3. package/README.md +124 -0
  4. package/npm/bin/research-assistant.js +70 -0
  5. package/npm/scripts/postinstall.js +112 -0
  6. package/package.json +38 -0
  7. package/pyproject.toml +84 -0
  8. package/src/research_assistant/__init__.py +7 -0
  9. package/src/research_assistant/__main__.py +6 -0
  10. package/src/research_assistant/assets/researcher_persona.md +94 -0
  11. package/src/research_assistant/assets/skills/research-assistant/SKILL.md +248 -0
  12. package/src/research_assistant/cli.py +155 -0
  13. package/src/research_assistant/commands/__init__.py +7 -0
  14. package/src/research_assistant/commands/ask.py +54 -0
  15. package/src/research_assistant/commands/doctor.py +201 -0
  16. package/src/research_assistant/commands/fetch.py +99 -0
  17. package/src/research_assistant/commands/locate.py +71 -0
  18. package/src/research_assistant/commands/search.py +161 -0
  19. package/src/research_assistant/commands/setup.py +249 -0
  20. package/src/research_assistant/commands/skills.py +60 -0
  21. package/src/research_assistant/config.py +258 -0
  22. package/src/research_assistant/errors.py +106 -0
  23. package/src/research_assistant/fetch/__init__.py +20 -0
  24. package/src/research_assistant/fetch/browser.py +613 -0
  25. package/src/research_assistant/fetch/cfbypass.py +318 -0
  26. package/src/research_assistant/fetch/html2md.py +44 -0
  27. package/src/research_assistant/fetch/normal.py +69 -0
  28. package/src/research_assistant/fetch/search_engine.py +324 -0
  29. package/src/research_assistant/fetch/snapshot.py +50 -0
  30. package/src/research_assistant/http.py +118 -0
  31. package/src/research_assistant/installer.py +270 -0
  32. package/src/research_assistant/locate/__init__.py +5 -0
  33. package/src/research_assistant/locate/engine.py +289 -0
  34. package/src/research_assistant/loginstate/__init__.py +10 -0
  35. package/src/research_assistant/loginstate/daemon.py +145 -0
  36. package/src/research_assistant/loginstate/daemon_cli.py +21 -0
  37. package/src/research_assistant/loginstate/daemonctl.py +116 -0
  38. package/src/research_assistant/loginstate/extension/background.js +167 -0
  39. package/src/research_assistant/loginstate/extension/manifest.json +15 -0
  40. package/src/research_assistant/loginstate/extension/popup.html +31 -0
  41. package/src/research_assistant/loginstate/extension/popup.js +33 -0
  42. package/src/research_assistant/output.py +140 -0
  43. package/src/research_assistant/providers/__init__.py +10 -0
  44. package/src/research_assistant/providers/base.py +93 -0
  45. package/src/research_assistant/providers/browser.py +91 -0
  46. package/src/research_assistant/providers/context7.py +171 -0
  47. package/src/research_assistant/providers/exa.py +353 -0
  48. package/src/research_assistant/providers/firecrawl.py +644 -0
  49. package/src/research_assistant/providers/openai_compat.py +293 -0
  50. package/src/research_assistant/providers/registry.py +62 -0
  51. package/src/research_assistant/providers/tavily.py +346 -0
  52. package/src/research_assistant/proxy.py +58 -0
  53. 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