webctx 0.2.3 → 0.2.5
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/CONTRIBUTORS.md +1 -1
- package/README.md +23 -5
- package/internal/app/app.go +137 -0
- package/internal/app/data_client.go +220 -0
- package/internal/app/data_execute.go +424 -0
- package/internal/app/data_inspect.go +176 -0
- package/internal/app/data_research.go +323 -0
- package/internal/app/data_tools_test.go +336 -0
- package/internal/app/data_types.go +40 -0
- package/internal/app/firecrawl_fallback.go +4 -14
- package/internal/app/firecrawl_fallback_test.go +38 -0
- package/internal/app/native_markdown.go +76 -0
- package/internal/app/native_markdown_test.go +78 -0
- package/internal/app/research.go +122 -33
- package/internal/app/research_navigation.go +237 -0
- package/internal/app/research_navigation_test.go +72 -0
- package/internal/app/research_test.go +4 -0
- package/internal/app/retrieval_quality.go +106 -0
- package/internal/app/retrieval_quality_test.go +63 -0
- package/internal/app/scrape.go +4 -65
- package/internal/app/structured_source.go +10 -0
- package/internal/app/tools.go +16 -7
- package/package.json +2 -2
- package/pkg/origo/data_test.go +134 -0
- package/pkg/origo/server.go +46 -0
- package/pkg/origo/server_test.go +11 -6
- package/pkg/retrieval/data.go +13 -0
package/CONTRIBUTORS.md
CHANGED
|
@@ -97,7 +97,7 @@ gh workflow run npm-publish-retry.yml --repo amxv/webctx --ref main -f version=0
|
|
|
97
97
|
- `cmd/webctx/main.go`: CLI entrypoint
|
|
98
98
|
- `internal/app/`: CLI parsing, search, ranking, scrape, env loading, and Firecrawl queue logic
|
|
99
99
|
- `pkg/retrieval/`: small public adapter to the shared read-link / site-map engine
|
|
100
|
-
- `pkg/origo/`: authenticated, stateless MCP server with
|
|
100
|
+
- `pkg/origo/`: authenticated, stateless MCP server with five tools (including structured-data discovery, inspection and execution)
|
|
101
101
|
- `api/mcp.go`: Vercel Go Function entrypoint for Origo
|
|
102
102
|
- `vercel.mjs`: project-specific Vercel settings for docs and Origo
|
|
103
103
|
- `internal/buildinfo/`: build-time version plumbing for `--version`
|
package/README.md
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Web context for agents, from the terminal.
|
|
4
4
|
|
|
5
|
-
`webctx`
|
|
5
|
+
`webctx` provides six commands: three for web documents and three for structured data.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
webctx search "agent web research"
|
|
9
9
|
webctx read-link <url>
|
|
10
10
|
webctx map-site <url>
|
|
11
|
+
webctx research "podcast conversations about AI agents"
|
|
12
|
+
webctx inspect particle/podcasts/episodes/search
|
|
13
|
+
webctx execute particle/podcasts/episodes/search --inputs '{"semantic_search":"AI agents","limit":2}' --max-credits 20
|
|
11
14
|
```
|
|
12
15
|
|
|
13
16
|
The output is plain text or markdown, so it is easy to hand to ChatGPT, Codex, Claude Code, a shell script, or another tool. Search now retrieves relevant source excerpts automatically instead of returning only URLs. For a specific URL, add an optional question to explore related documentation and surface exact code and API details.
|
|
@@ -19,11 +22,25 @@ webctx read-link https://docs.example.com/api --question "How do authentication
|
|
|
19
22
|
|
|
20
23
|
## Origo: hosted MCP access
|
|
21
24
|
|
|
22
|
-
**Origo** is the MCP transport over the same WebCTX retrieval engine. Connect an MCP client to `https://api.origo.ashray.xyz/mcp?key=<your-private-key>` to access
|
|
25
|
+
**Origo** is the MCP transport over the same WebCTX retrieval engine. Connect an MCP client to `https://api.origo.ashray.xyz/mcp?key=<your-private-key>` to access five tools: `read_link`, `map_site`, `research`, `inspect`, and `execute`. Origo deliberately omits federated web `search` but exposes the complete progressive data-research workflow. `read_link` accepts an optional `question` and keeps URL-only behavior unchanged.
|
|
23
26
|
|
|
24
27
|
Both surfaces share native GitHub/Markdown readers and Firecrawl fallback behavior. Firecrawl's built-in cache is limited to **30 minutes**. Difficult pages automatically escalate through Firecrawl's proxy fallback and, if necessary, a temporary Browser Sandbox session. There is no Redis or additional cache database.
|
|
25
28
|
|
|
26
|
-
Source-aware reading also prefers raw JSON, YAML, and OpenAPI files when the URL identifies one; question-focused reads can follow
|
|
29
|
+
Source-aware reading also prefers raw JSON, YAML, and OpenAPI files when the URL identifies one; question-focused reads can follow relevant references, including links found in `llms.txt`, and inspect embedded structured JSON. The new `research`, `inspect`, and `execute` commands give agents direct control over Firecrawl Alexandria's catalogue and paid data operations without learning provider-specific transport syntax. The original source URLs remain explicit so an agent can verify facts.
|
|
30
|
+
|
|
31
|
+
## Structured data: discover → inspect → execute
|
|
32
|
+
|
|
33
|
+
`research` discovers datasets and operations by meaning, website, source, category, or group. `inspect` retrieves exact parameter types, required fields, response shapes, pricing, and copy-ready `execute` arguments. `execute` validates the inputs and credit estimate before calling one or more data operations, and preserves their source-native JSON and pagination.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
webctx research "podcast conversations about AI agents" --limit 5
|
|
37
|
+
webctx research --view sources --limit 5
|
|
38
|
+
webctx research --mode catalogue --view operations --sources particle --limit 10
|
|
39
|
+
webctx inspect particle/podcasts/episodes/search
|
|
40
|
+
webctx execute particle/podcasts/episodes/search --inputs '{"semantic_search":"AI agents","limit":2}' --max-credits 20
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Browse [the structured-data guide](https://webctx.ashray.xyz/docs/data-sources) for filters, paging, batches, full upstream responses, validation, and credit handling. Discovery/inspection is free; `execute` uses paid Alexandria credits. The per-request default preflight cap is 100 credits (configurable via `WEBCTX_ALEXANDRIA_MAX_CREDITS`). Third-party provider terms require separate human acceptance; Origo never accepts them automatically.
|
|
27
44
|
|
|
28
45
|
Origo deploys independently through the `origo-api` Vercel project on pushes to `main`. CLI releases and npm publishing are unchanged. See the [Origo MCP guide](https://webctx.ashray.xyz/docs/origo) for architecture, setup, and security notes.
|
|
29
46
|
|
|
@@ -78,7 +95,7 @@ Recognized native GitHub auth, private/not-found, and rate-limit failures stay a
|
|
|
78
95
|
|
|
79
96
|
## Search
|
|
80
97
|
|
|
81
|
-
Normal search asks Brave, Tavily, and Exa, removes duplicate URLs, automatically reads
|
|
98
|
+
Normal search asks Brave, Tavily, and Exa, removes duplicate URLs, automatically reads promising sources, follows relevant links, and returns excerpts plus the ranked list. Alexandria discovery is included when available; use `execute` when you need provider-native data.
|
|
82
99
|
|
|
83
100
|
```bash
|
|
84
101
|
webctx search "next.js server components"
|
|
@@ -115,7 +132,7 @@ GH_TOKEN=...
|
|
|
115
132
|
```
|
|
116
133
|
|
|
117
134
|
- `BRAVE_API_KEY`, `TAVILY_API_KEY`, and `EXA_API_KEY` power normal `search`.
|
|
118
|
-
- `FIRECRAWL_API_KEY` powers `map-site
|
|
135
|
+
- `FIRECRAWL_API_KEY` powers `map-site`, page-crawl fallbacks, and Alexandria discovery/inspection/paid execution.
|
|
119
136
|
- `GH_TOKEN` or `GITHUB_TOKEN` is optional. It increases GitHub capacity and unlocks reads such as blame, Discussions, some Actions job logs, private resources the token can access, and richer PR review state.
|
|
120
137
|
|
|
121
138
|
Environment variables, `.env.local`, and macOS Keychain are supported. `GH_TOKEN` takes precedence over `GITHUB_TOKEN`.
|
|
@@ -129,6 +146,7 @@ Start with the guides in `src/content/docs`:
|
|
|
129
146
|
- **Use webctx with agents** — practical research, repo, PR, and CI workflows
|
|
130
147
|
- **Search the web** — federated search and filtering
|
|
131
148
|
- **Map a site** — discover pages before reading them
|
|
149
|
+
- **Research structured data** — browse data sources, inspect exact contracts, and execute paid queries with bounded credits
|
|
132
150
|
- **Credentials** — keys and optional GitHub auth
|
|
133
151
|
- **How URL reading works** — native/direct fast paths before Firecrawl
|
|
134
152
|
- **How search ranking works** — URL normalization, position scoring, and provider agreement
|
package/internal/app/app.go
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
package app
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"encoding/json"
|
|
4
5
|
"fmt"
|
|
5
6
|
"io"
|
|
7
|
+
"os"
|
|
8
|
+
"strconv"
|
|
6
9
|
"strings"
|
|
7
10
|
|
|
8
11
|
"github.com/amxv/webctx/internal/buildinfo"
|
|
@@ -74,6 +77,90 @@ func Run(args []string, stdout, stderr io.Writer) int {
|
|
|
74
77
|
}
|
|
75
78
|
_, _ = fmt.Fprintln(stdout, text)
|
|
76
79
|
return 0
|
|
80
|
+
case "research":
|
|
81
|
+
research := ResearchDataInput{
|
|
82
|
+
Query: strings.Join(positional, " "),
|
|
83
|
+
Mode: flags["mode"],
|
|
84
|
+
URLs: splitCSV(flags["urls"]),
|
|
85
|
+
Sources: splitCSV(flags["sources"]),
|
|
86
|
+
Categories: splitCSV(flags["categories"]),
|
|
87
|
+
Groups: splitCSV(flags["groups"]),
|
|
88
|
+
Operations: splitCSV(flags["operations"]),
|
|
89
|
+
View: flags["view"],
|
|
90
|
+
Include: splitCSV(flags["include"]),
|
|
91
|
+
Raw: flags["raw"] == "true",
|
|
92
|
+
}
|
|
93
|
+
if q := flags["query"]; q != "" {
|
|
94
|
+
research.Query = q
|
|
95
|
+
}
|
|
96
|
+
if flag := flags["limit"]; flag != "" {
|
|
97
|
+
n, e := strconv.Atoi(flag)
|
|
98
|
+
if e != nil {
|
|
99
|
+
return cliDataError(stderr, dataError("invalid_limit", "--limit requires an integer."))
|
|
100
|
+
}
|
|
101
|
+
research.Limit = n
|
|
102
|
+
}
|
|
103
|
+
if flag := flags["offset"]; flag != "" {
|
|
104
|
+
n, e := strconv.Atoi(flag)
|
|
105
|
+
if e != nil {
|
|
106
|
+
return cliDataError(stderr, dataError("invalid_offset", "--offset requires an integer."))
|
|
107
|
+
}
|
|
108
|
+
research.Offset = n
|
|
109
|
+
}
|
|
110
|
+
result, e := ResearchData(research)
|
|
111
|
+
if e != nil {
|
|
112
|
+
return cliDataError(stderr, e)
|
|
113
|
+
}
|
|
114
|
+
writeDataJSON(stdout, result)
|
|
115
|
+
return 0
|
|
116
|
+
case "inspect":
|
|
117
|
+
if strings.TrimSpace(input) == "" {
|
|
118
|
+
return cliDataError(stderr, dataError("missing_id", "Usage: webctx inspect <operation-or-source-id> [--raw]"))
|
|
119
|
+
}
|
|
120
|
+
result, e := InspectData(InspectDataInput{ID: input, Include: splitCSV(flags["include"]), Raw: flags["raw"] == "true"})
|
|
121
|
+
if e != nil {
|
|
122
|
+
return cliDataError(stderr, e)
|
|
123
|
+
}
|
|
124
|
+
writeDataJSON(stdout, result)
|
|
125
|
+
return 0
|
|
126
|
+
case "execute":
|
|
127
|
+
request := ExecuteDataInput{Raw: flags["raw"] == "true", RequestID: flags["request-id"]}
|
|
128
|
+
if flag := flags["max-credits"]; flag != "" {
|
|
129
|
+
n, e := strconv.Atoi(flag)
|
|
130
|
+
if e != nil {
|
|
131
|
+
return cliDataError(stderr, dataError("invalid_budget", "--max-credits requires an integer."))
|
|
132
|
+
}
|
|
133
|
+
request.MaxCredits = n
|
|
134
|
+
}
|
|
135
|
+
if jsonCalls := flags["calls"]; jsonCalls != "" {
|
|
136
|
+
data, e := cliJSONString(jsonCalls)
|
|
137
|
+
if e != nil {
|
|
138
|
+
return cliDataError(stderr, e)
|
|
139
|
+
}
|
|
140
|
+
if e := json.Unmarshal([]byte(data), &request.Calls); e != nil {
|
|
141
|
+
return cliDataError(stderr, dataError("invalid_calls", "--calls must be a JSON array of {id,inputs} objects."))
|
|
142
|
+
}
|
|
143
|
+
} else if strings.TrimSpace(input) != "" {
|
|
144
|
+
var inputs map[string]any
|
|
145
|
+
if source := flags["inputs"]; source != "" {
|
|
146
|
+
data, e := cliJSONString(source)
|
|
147
|
+
if e != nil {
|
|
148
|
+
return cliDataError(stderr, e)
|
|
149
|
+
}
|
|
150
|
+
if e := json.Unmarshal([]byte(data), &inputs); e != nil {
|
|
151
|
+
return cliDataError(stderr, dataError("invalid_inputs", "--inputs must be a JSON object."))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
request.Calls = []DataCall{{ID: input, Inputs: inputs}}
|
|
155
|
+
} else {
|
|
156
|
+
return cliDataError(stderr, dataError("missing_calls", "Usage: webctx execute <operation-id> --inputs JSON [--max-credits N] or --calls JSON."))
|
|
157
|
+
}
|
|
158
|
+
result, e := ExecuteData(request)
|
|
159
|
+
if e != nil {
|
|
160
|
+
return cliDataError(stderr, e)
|
|
161
|
+
}
|
|
162
|
+
writeDataJSON(stdout, result)
|
|
163
|
+
return 0
|
|
77
164
|
default:
|
|
78
165
|
_, _ = fmt.Fprintln(stderr, "Unknown tool:", tool)
|
|
79
166
|
_, _ = fmt.Fprintln(stdout, usageText())
|
|
@@ -88,11 +175,19 @@ Usage:
|
|
|
88
175
|
webctx search <query> [--exclude domain1,domain2] [--keyword phrase]
|
|
89
176
|
webctx read-link <url> [--question 'what to find']
|
|
90
177
|
webctx map-site <url>
|
|
178
|
+
webctx research [question] [--view sources|groups|operations] [--sources IDs] [--include inputs,output,examples] [--limit N] [--offset N]
|
|
179
|
+
webctx inspect <operation-or-source-id> [--raw]
|
|
180
|
+
webctx execute <operation-id> --inputs '{"field":"value"}' [--max-credits N] [--request-id ID]
|
|
181
|
+
webctx execute --calls '[{"id":"source/operation","inputs":{}}]' [--max-credits N]
|
|
91
182
|
|
|
92
183
|
Examples:
|
|
93
184
|
webctx search "next.js server components"
|
|
94
185
|
webctx search "react hooks" --exclude youtube.com,vimeo.com
|
|
95
186
|
webctx search "drizzle orm" --keyword "migration guide"
|
|
187
|
+
webctx research "podcast conversations about AI agents"
|
|
188
|
+
webctx research --view sources --limit 5
|
|
189
|
+
webctx inspect particle/podcasts/episodes/search
|
|
190
|
+
webctx execute particle/podcasts/episodes/search --inputs '{"semantic_search":"AI agents","limit":2}' --max-credits 20
|
|
96
191
|
webctx read-link https://docs.example.com/guide
|
|
97
192
|
webctx read-link https://docs.example.com/api --question "How do auth and pagination work?"
|
|
98
193
|
webctx map-site https://example.com`, version)
|
|
@@ -102,6 +197,16 @@ func parseArgs(args []string) (map[string]string, []string) {
|
|
|
102
197
|
flags := map[string]string{}
|
|
103
198
|
positional := make([]string, 0, len(args))
|
|
104
199
|
for i := 0; i < len(args); i++ {
|
|
200
|
+
if args[i] == "--raw" {
|
|
201
|
+
flags["raw"] = "true"
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
if strings.HasPrefix(args[i], "--") {
|
|
205
|
+
if k, v, ok := strings.Cut(strings.TrimPrefix(args[i], "--"), "="); ok {
|
|
206
|
+
flags[k] = v
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
}
|
|
105
210
|
if strings.HasPrefix(args[i], "--") && i+1 < len(args) {
|
|
106
211
|
flags[strings.TrimPrefix(args[i], "--")] = args[i+1]
|
|
107
212
|
i++
|
|
@@ -112,6 +217,38 @@ func parseArgs(args []string) (map[string]string, []string) {
|
|
|
112
217
|
return flags, positional
|
|
113
218
|
}
|
|
114
219
|
|
|
220
|
+
func cliJSONString(source string) (string, error) {
|
|
221
|
+
if !strings.HasPrefix(source, "@") {
|
|
222
|
+
return source, nil
|
|
223
|
+
}
|
|
224
|
+
path := strings.TrimPrefix(source, "@")
|
|
225
|
+
if path == "" {
|
|
226
|
+
return "", dataError("invalid_file", "The @file argument requires a path.")
|
|
227
|
+
}
|
|
228
|
+
b, err := os.ReadFile(path)
|
|
229
|
+
if err != nil {
|
|
230
|
+
return "", dataError("invalid_file", "Unable to read JSON input file: "+err.Error())
|
|
231
|
+
}
|
|
232
|
+
if len(b) > 1<<20 {
|
|
233
|
+
return "", dataError("invalid_file", "JSON input file exceeds 1 MiB.")
|
|
234
|
+
}
|
|
235
|
+
return string(b), nil
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
func writeDataJSON(out io.Writer, value any) {
|
|
239
|
+
bytes, err := json.MarshalIndent(value, "", " ")
|
|
240
|
+
if err != nil {
|
|
241
|
+
_, _ = fmt.Fprintln(out, "{}")
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
_, _ = fmt.Fprintln(out, string(bytes))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
func cliDataError(stderr io.Writer, err error) int {
|
|
248
|
+
writeDataJSON(stderr, dataRawOrError(err))
|
|
249
|
+
return 1
|
|
250
|
+
}
|
|
251
|
+
|
|
115
252
|
func splitCSV(v string) []string {
|
|
116
253
|
if strings.TrimSpace(v) == "" {
|
|
117
254
|
return nil
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/rand"
|
|
6
|
+
"encoding/hex"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"errors"
|
|
9
|
+
"fmt"
|
|
10
|
+
"io"
|
|
11
|
+
"net/http"
|
|
12
|
+
"net/url"
|
|
13
|
+
"os"
|
|
14
|
+
"regexp"
|
|
15
|
+
"strconv"
|
|
16
|
+
"strings"
|
|
17
|
+
"time"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const dataAPIBase = "https://api.firecrawl.dev/v2"
|
|
21
|
+
const defaultDataCreditBudget = 100
|
|
22
|
+
const maxDataResponseBytes int64 = 12 << 20
|
|
23
|
+
|
|
24
|
+
type DataError struct {
|
|
25
|
+
Code string `json:"code"`
|
|
26
|
+
Message string `json:"message"`
|
|
27
|
+
RequiresAction map[string]any `json:"requires_action,omitempty"`
|
|
28
|
+
Details any `json:"details,omitempty"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func (e *DataError) Error() string { return e.Code + ": " + e.Message }
|
|
32
|
+
func dataError(code, message string) error {
|
|
33
|
+
return &DataError{Code: code, Message: message}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func dataAPIKey() (string, error) {
|
|
37
|
+
key := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
|
|
38
|
+
if key == "" {
|
|
39
|
+
return "", dataError("missing_credentials", "Set FIRECRAWL_API_KEY to use data source discovery and queries.")
|
|
40
|
+
}
|
|
41
|
+
return key, nil
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func dataCreditCap() int {
|
|
45
|
+
value, err := strconv.Atoi(strings.TrimSpace(os.Getenv("WEBCTX_ALEXANDRIA_MAX_CREDITS")))
|
|
46
|
+
if err != nil || value < 1 || value > 100000 {
|
|
47
|
+
return defaultDataCreditBudget
|
|
48
|
+
}
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func paidDataEnabled() bool {
|
|
53
|
+
switch strings.ToLower(strings.TrimSpace(os.Getenv("WEBCTX_ALEXANDRIA_PAID"))) {
|
|
54
|
+
case "0", "false", "no", "off":
|
|
55
|
+
return false
|
|
56
|
+
default:
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
var safeRequestID = regexp.MustCompile("^[A-Za-z0-9_-]{8,128}$")
|
|
62
|
+
|
|
63
|
+
func newRequestID() (string, error) {
|
|
64
|
+
var bytes [16]byte
|
|
65
|
+
if _, err := rand.Read(bytes[:]); err != nil {
|
|
66
|
+
return "", err
|
|
67
|
+
}
|
|
68
|
+
return "webctx-" + hex.EncodeToString(bytes[:]), nil
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func doDataAPI(ctx context.Context, endpoint string, payload any, requestID string) (map[string]any, error) {
|
|
72
|
+
key, err := dataAPIKey()
|
|
73
|
+
if err != nil {
|
|
74
|
+
return nil, err
|
|
75
|
+
}
|
|
76
|
+
body, err := json.Marshal(payload)
|
|
77
|
+
if err != nil {
|
|
78
|
+
return nil, dataError("invalid_request", "Could not encode request as JSON: "+err.Error())
|
|
79
|
+
}
|
|
80
|
+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, dataAPIBase+endpoint, strings.NewReader(string(body)))
|
|
81
|
+
if err != nil {
|
|
82
|
+
return nil, err
|
|
83
|
+
}
|
|
84
|
+
req.Header.Set("Authorization", "Bearer "+key)
|
|
85
|
+
req.Header.Set("Content-Type", "application/json")
|
|
86
|
+
if requestID != "" {
|
|
87
|
+
req.Header.Set("x-request-id", requestID)
|
|
88
|
+
}
|
|
89
|
+
res, err := http.DefaultClient.Do(req)
|
|
90
|
+
if err != nil {
|
|
91
|
+
return nil, &DataError{Code: "network_error", Message: "The provider request did not complete. For a paid call, retry only with the same request_id to prevent duplicate charges."}
|
|
92
|
+
}
|
|
93
|
+
defer res.Body.Close()
|
|
94
|
+
raw, err := io.ReadAll(io.LimitReader(res.Body, maxDataResponseBytes+1))
|
|
95
|
+
if err != nil {
|
|
96
|
+
return nil, dataError("read_error", "Could not read provider response.")
|
|
97
|
+
}
|
|
98
|
+
if int64(len(raw)) > maxDataResponseBytes {
|
|
99
|
+
return nil, dataError("response_too_large", "Provider response exceeded 12 MiB; use a smaller limit or pagination.")
|
|
100
|
+
}
|
|
101
|
+
var decoded map[string]any
|
|
102
|
+
if json.Unmarshal(raw, &decoded) != nil {
|
|
103
|
+
return nil, dataError("invalid_upstream_response", fmt.Sprintf("Provider returned non-JSON HTTP %d.", res.StatusCode))
|
|
104
|
+
}
|
|
105
|
+
if res.StatusCode < 200 || res.StatusCode >= 300 || decoded["success"] == false {
|
|
106
|
+
code := stringValue(decoded["code"])
|
|
107
|
+
if code == "" {
|
|
108
|
+
code = fmt.Sprintf("http_%d", res.StatusCode)
|
|
109
|
+
}
|
|
110
|
+
message := stringValue(decoded["error"])
|
|
111
|
+
if message == "" {
|
|
112
|
+
message = stringValue(decoded["message"])
|
|
113
|
+
}
|
|
114
|
+
if message == "" {
|
|
115
|
+
message = "The provider rejected the request."
|
|
116
|
+
}
|
|
117
|
+
if len(message) > 800 {
|
|
118
|
+
message = message[:800]
|
|
119
|
+
}
|
|
120
|
+
out := &DataError{Code: code, Message: message}
|
|
121
|
+
if action, ok := decoded["requiresAction"].(map[string]any); ok {
|
|
122
|
+
if target := stringValue(action["url"]); target != "" {
|
|
123
|
+
if u, e := url.Parse(target); e == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" {
|
|
124
|
+
out.RequiresAction = map[string]any{"url": target}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return nil, out
|
|
129
|
+
}
|
|
130
|
+
return decoded, nil
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func nativeDataCall(ctx context.Context, provider, capability string, options map[string]any, requestID string) (map[string]any, error) {
|
|
134
|
+
request := map[string]any{"alexandria": map[string]any{
|
|
135
|
+
"provider": provider, "capability": capability, "options": options,
|
|
136
|
+
}}
|
|
137
|
+
return doDataAPI(ctx, "/scrape", request, requestID)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func extractNativeData(response map[string]any) (map[string]any, error) {
|
|
141
|
+
data, ok := response["data"].(map[string]any)
|
|
142
|
+
if !ok {
|
|
143
|
+
return nil, dataError("invalid_upstream_response", "The response has no data object.")
|
|
144
|
+
}
|
|
145
|
+
items, ok := data["alexandria"].([]any)
|
|
146
|
+
if !ok || len(items) == 0 {
|
|
147
|
+
return nil, dataError("invalid_upstream_response", "The response contains no Alexandria results.")
|
|
148
|
+
}
|
|
149
|
+
item, ok := items[0].(map[string]any)
|
|
150
|
+
if !ok {
|
|
151
|
+
return nil, dataError("invalid_upstream_response", "The source returned an invalid result.")
|
|
152
|
+
}
|
|
153
|
+
return item, nil
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func freeDataBrowse(ctx context.Context, options map[string]any) (map[string]any, map[string]any, error) {
|
|
157
|
+
response, err := nativeDataCall(ctx, "firecrawl", "find-tools", options, "")
|
|
158
|
+
if err != nil {
|
|
159
|
+
return nil, nil, err
|
|
160
|
+
}
|
|
161
|
+
item, err := extractNativeData(response)
|
|
162
|
+
if err != nil {
|
|
163
|
+
return nil, nil, err
|
|
164
|
+
}
|
|
165
|
+
details, ok := item["data"].(map[string]any)
|
|
166
|
+
if !ok {
|
|
167
|
+
return nil, nil, dataError("invalid_upstream_response", "The catalogue returned no browsable entries.")
|
|
168
|
+
}
|
|
169
|
+
return details, response, nil
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func defaultDataContext() (context.Context, context.CancelFunc) {
|
|
173
|
+
return context.WithTimeout(context.Background(), 45*time.Second)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
func dataRawOrError(err error) map[string]any {
|
|
177
|
+
var typed *DataError
|
|
178
|
+
if errors.As(err, &typed) {
|
|
179
|
+
return map[string]any{"error": typed}
|
|
180
|
+
}
|
|
181
|
+
return map[string]any{"error": map[string]any{"code": "internal_error", "message": err.Error()}}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// DataErrorResult preserves actionable failures in both CLI and MCP JSON.
|
|
185
|
+
func DataErrorResult(err error) map[string]any { return dataRawOrError(err) }
|
|
186
|
+
|
|
187
|
+
func listField(value any) []any {
|
|
188
|
+
switch v := value.(type) {
|
|
189
|
+
case []any:
|
|
190
|
+
return v
|
|
191
|
+
case []string:
|
|
192
|
+
out := make([]any, 0, len(v))
|
|
193
|
+
for _, s := range v {
|
|
194
|
+
out = append(out, s)
|
|
195
|
+
}
|
|
196
|
+
return out
|
|
197
|
+
default:
|
|
198
|
+
return nil
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func stringField(value any) string { s, _ := value.(string); return s }
|
|
203
|
+
func mapField(value any) map[string]any { m, _ := value.(map[string]any); return m }
|
|
204
|
+
func numberField(value any) (float64, bool) {
|
|
205
|
+
switch n := value.(type) {
|
|
206
|
+
case float64:
|
|
207
|
+
return n, true
|
|
208
|
+
case float32:
|
|
209
|
+
return float64(n), true
|
|
210
|
+
case int:
|
|
211
|
+
return float64(n), true
|
|
212
|
+
case int64:
|
|
213
|
+
return float64(n), true
|
|
214
|
+
case json.Number:
|
|
215
|
+
f, e := n.Float64()
|
|
216
|
+
return f, e == nil
|
|
217
|
+
default:
|
|
218
|
+
return 0, false
|
|
219
|
+
}
|
|
220
|
+
}
|