webctx 0.2.7 → 0.2.8
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/internal/app/app.go +1 -0
- package/internal/app/data_audit_test.go +142 -1
- package/internal/app/data_execute.go +27 -8
- package/internal/app/data_replay.go +35 -0
- package/internal/app/data_research.go +24 -6
- package/internal/app/site_map_page.go +26 -7
- package/package.json +1 -1
- package/pkg/origo/data_test.go +1 -0
package/internal/app/app.go
CHANGED
|
@@ -89,6 +89,7 @@ func Run(args []string, stdout, stderr io.Writer) int {
|
|
|
89
89
|
result, e := MapSitePage(MapSiteInput{
|
|
90
90
|
URL: input, Limit: limit, Offset: offset, Query: flags["query"],
|
|
91
91
|
PathPrefix: flags["path-prefix"], Language: flags["language"],
|
|
92
|
+
limitProvided: flags["limit"] != "",
|
|
92
93
|
})
|
|
93
94
|
if e != nil {
|
|
94
95
|
return cliDataError(stderr, e)
|
|
@@ -34,6 +34,16 @@ func TestAuditPerRecordBoundsFromActualOperations(t *testing.T) {
|
|
|
34
34
|
"options": []any{map[string]any{"name": "ids", "type": "array"}}},
|
|
35
35
|
map[string]any{"ids": []any{"A", "B"}}, 4,
|
|
36
36
|
},
|
|
37
|
+
{
|
|
38
|
+
map[string]any{"id": "fullenrich/contacts/mobile", "creditsCost": 175, "perRecord": true,
|
|
39
|
+
"response": map[string]any{"paginated": true, "about": "One record in results when a mobile number was found, an empty array when not."}},
|
|
40
|
+
map[string]any{"linkedin_url": "https://www.linkedin.com/in/example-test-person/"}, 175,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
map[string]any{"id": "example/search", "creditsCost": 2, "perRecord": true,
|
|
44
|
+
"options": []any{map[string]any{"name": "k", "type": "integer"}, map[string]any{"name": "limit", "type": "integer"}}},
|
|
45
|
+
map[string]any{"limit": 1, "k": 120}, 240,
|
|
46
|
+
},
|
|
37
47
|
}
|
|
38
48
|
for _, tt := range tests {
|
|
39
49
|
got, err := expectedCallCredits(tt.contract, tt.inputs)
|
|
@@ -43,6 +53,92 @@ func TestAuditPerRecordBoundsFromActualOperations(t *testing.T) {
|
|
|
43
53
|
}
|
|
44
54
|
}
|
|
45
55
|
|
|
56
|
+
func TestFilteredCataloguePaginationBeyondFirstHundred(t *testing.T) {
|
|
57
|
+
previous := http.DefaultClient
|
|
58
|
+
t.Cleanup(func() { http.DefaultClient = previous })
|
|
59
|
+
t.Setenv("FIRECRAWL_API_KEY", "test-filtered-pagination")
|
|
60
|
+
const total = 215
|
|
61
|
+
var upstreamOffsets []int
|
|
62
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
63
|
+
if req.URL.Path != "/v2/scrape" {
|
|
64
|
+
return nil, fmt.Errorf("unexpected endpoint %s", req.URL.Path)
|
|
65
|
+
}
|
|
66
|
+
var body map[string]any
|
|
67
|
+
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
68
|
+
return nil, err
|
|
69
|
+
}
|
|
70
|
+
options := mapField(mapField(body["alexandria"])["options"])
|
|
71
|
+
offset, _ := numberField(options["offset"])
|
|
72
|
+
upstreamOffsets = append(upstreamOffsets, int(offset))
|
|
73
|
+
start, end := int(offset), int(offset)+100
|
|
74
|
+
if end > total {
|
|
75
|
+
end = total
|
|
76
|
+
}
|
|
77
|
+
var items []any
|
|
78
|
+
for i := start; i < end; i++ {
|
|
79
|
+
items = append(items, map[string]any{
|
|
80
|
+
"id": fmt.Sprintf("fred-stlouisfed-org/data/series_%03d", i),
|
|
81
|
+
"name": "Inflation series observations", "description": "Retrieve inflation observations",
|
|
82
|
+
"creditsCost": 5, "provider": "fred-stlouisfed-org",
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
encoded, _ := json.Marshal(map[string]any{"success": true, "data": map[string]any{
|
|
86
|
+
"alexandria": []any{map[string]any{"provider": "firecrawl", "capability": "find-tools",
|
|
87
|
+
"data": map[string]any{"items": items, "total": total}}}}})
|
|
88
|
+
return testHTTPResponse(req, 200, string(encoded), nil), nil
|
|
89
|
+
})}
|
|
90
|
+
input := ResearchDataInput{Query: "inflation series", Mode: "ranked", Sources: []string{"fred-stlouisfed-org"}, Limit: 10}
|
|
91
|
+
first, err := ResearchData(input)
|
|
92
|
+
if err != nil {
|
|
93
|
+
t.Fatal(err)
|
|
94
|
+
}
|
|
95
|
+
if first["ranking_incomplete"] != true || first["ranking_scope"] != "catalogue_page" {
|
|
96
|
+
t.Fatalf("ranked first page should disclose scope: %+v", first)
|
|
97
|
+
}
|
|
98
|
+
seen := map[string]bool{}
|
|
99
|
+
pages := 0
|
|
100
|
+
next := first
|
|
101
|
+
for {
|
|
102
|
+
pages++
|
|
103
|
+
rows := next["results"].([]map[string]any)
|
|
104
|
+
if len(rows) == 0 {
|
|
105
|
+
t.Fatalf("empty page %d", pages)
|
|
106
|
+
}
|
|
107
|
+
for _, row := range rows {
|
|
108
|
+
id := row["id"].(string)
|
|
109
|
+
if seen[id] {
|
|
110
|
+
t.Fatalf("pagination repeated %s", id)
|
|
111
|
+
}
|
|
112
|
+
seen[id] = true
|
|
113
|
+
}
|
|
114
|
+
cont := mapField(next["next"])
|
|
115
|
+
if cont == nil {
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
if pages > 30 {
|
|
119
|
+
t.Fatal("pagination did not terminate")
|
|
120
|
+
}
|
|
121
|
+
args := cont["arguments"].(ResearchDataInput)
|
|
122
|
+
next, err = ResearchData(args)
|
|
123
|
+
if err != nil {
|
|
124
|
+
t.Fatalf("page %d: %v", pages, err)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if len(seen) != total || pages != 22 {
|
|
128
|
+
t.Fatalf("pagination missed items: got %d of %d over %d pages", len(seen), total, pages)
|
|
129
|
+
}
|
|
130
|
+
hasSecondBatch := false
|
|
131
|
+
for _, offset := range upstreamOffsets {
|
|
132
|
+
if offset == 100 {
|
|
133
|
+
hasSecondBatch = true
|
|
134
|
+
break
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if !hasSecondBatch {
|
|
138
|
+
t.Fatalf("never fetched beyond the first 100 entries: %v", upstreamOffsets)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
46
142
|
func TestAuditPaginationAdaptersPreserveFilters(t *testing.T) {
|
|
47
143
|
tests := []struct {
|
|
48
144
|
name string
|
|
@@ -327,6 +423,46 @@ func TestAuditWorkerIdempotentReplayAndCollision(t *testing.T) {
|
|
|
327
423
|
}
|
|
328
424
|
}
|
|
329
425
|
|
|
426
|
+
func TestConcurrentSameIDHasExactlyOnePaidExecution(t *testing.T) {
|
|
427
|
+
mock := installDataTestTransport(t)
|
|
428
|
+
const workers = 12
|
|
429
|
+
id := "audit-parallel-replay-20261010"
|
|
430
|
+
input := ExecuteDataInput{RequestID: id, Calls: []DataCall{{
|
|
431
|
+
ID: "particle/podcasts/episodes/search",
|
|
432
|
+
Inputs: map[string]any{"semantic_search": "AI agents", "limit": float64(2)},
|
|
433
|
+
}}}
|
|
434
|
+
start := make(chan struct{})
|
|
435
|
+
type result struct {
|
|
436
|
+
output map[string]any
|
|
437
|
+
err error
|
|
438
|
+
}
|
|
439
|
+
out := make(chan result, workers)
|
|
440
|
+
for range workers {
|
|
441
|
+
go func() {
|
|
442
|
+
<-start
|
|
443
|
+
response, err := ExecuteData(input)
|
|
444
|
+
out <- result{response, err}
|
|
445
|
+
}()
|
|
446
|
+
}
|
|
447
|
+
close(start)
|
|
448
|
+
replays := 0
|
|
449
|
+
for range workers {
|
|
450
|
+
r := <-out
|
|
451
|
+
if r.err != nil {
|
|
452
|
+
t.Fatalf("same-ID concurrent request failed: %v", r.err)
|
|
453
|
+
}
|
|
454
|
+
if r.output["replayed"] == true {
|
|
455
|
+
replays++
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
mock.mu.Lock()
|
|
459
|
+
calls := mock.executions
|
|
460
|
+
mock.mu.Unlock()
|
|
461
|
+
if calls != 1 || replays != workers-1 {
|
|
462
|
+
t.Fatalf("concurrent same-ID calls executed %d provider requests; %d cached replays; wanted 1 and %d", calls, replays, workers-1)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
330
466
|
func TestAuditMixedTermsBatchKeepsIndependentResultsAndCredits(t *testing.T) {
|
|
331
467
|
previous := http.DefaultClient
|
|
332
468
|
t.Cleanup(func() { http.DefaultClient = previous })
|
|
@@ -374,7 +510,7 @@ func TestAuditMixedTermsBatchKeepsIndependentResultsAndCredits(t *testing.T) {
|
|
|
374
510
|
{ID: "fred/data/search", Inputs: map[string]any{"query": "inflation"}},
|
|
375
511
|
{ID: "sec/data/search", Inputs: map[string]any{"query": "filings"}},
|
|
376
512
|
}
|
|
377
|
-
result, err := ExecuteData(ExecuteDataInput{Calls: calls})
|
|
513
|
+
result, err := ExecuteData(ExecuteDataInput{Calls: calls, RequestID: "audit-partial-replay-20261010"})
|
|
378
514
|
if err != nil {
|
|
379
515
|
t.Fatal(err)
|
|
380
516
|
}
|
|
@@ -400,6 +536,11 @@ func TestAuditMixedTermsBatchKeepsIndependentResultsAndCredits(t *testing.T) {
|
|
|
400
536
|
if paid["particle"] != 1 || paid["fred"] != 1 || paid["sec"] != 1 {
|
|
401
537
|
t.Fatalf("provider calls were not independent: %+v", paid)
|
|
402
538
|
}
|
|
539
|
+
replayed, err := ExecuteData(ExecuteDataInput{Calls: calls, RequestID: "audit-partial-replay-20261010"})
|
|
540
|
+
if err != nil || replayed["replayed"] != true || paid["fred"] != 1 || paid["sec"] != 1 ||
|
|
541
|
+
replayed["credits_charged_this_request"] != 0 {
|
|
542
|
+
t.Fatalf("replay of partial batch re-executed charged successes: reply=%+v paid=%+v error=%v", replayed, paid, err)
|
|
543
|
+
}
|
|
403
544
|
}
|
|
404
545
|
|
|
405
546
|
func TestAuditHighPriceOperationCannotBeRecommendedAsRunnable(t *testing.T) {
|
|
@@ -210,14 +210,16 @@ func expectedCallCredits(contract map[string]any, inputs map[string]any) (int, e
|
|
|
210
210
|
count := 0.0
|
|
211
211
|
for _, key := range []string{"limit", "k", "top_k", "topK", "per_page", "page_size", "max_results", "count", "size", "num_results", "max_records", "results_per_page"} {
|
|
212
212
|
if n, ok := numberField(inputs[key]); ok && n >= 1 {
|
|
213
|
-
|
|
214
|
-
|
|
213
|
+
// Providers occasionally accept several independent count
|
|
214
|
+
// fields. Use the largest explicit bound instead of
|
|
215
|
+
// underestimating charges from the first matching key.
|
|
216
|
+
count = math.Max(count, n)
|
|
215
217
|
}
|
|
216
218
|
}
|
|
217
219
|
// For bulk identifier lookups, a response may be charged per input.
|
|
218
220
|
for _, key := range []string{"ids", "domains", "urls", "companies", "people", "symbols"} {
|
|
219
|
-
if records := listField(inputs[key]);
|
|
220
|
-
count = float64(len(records))
|
|
221
|
+
if records := listField(inputs[key]); len(records) > 0 {
|
|
222
|
+
count = math.Max(count, float64(len(records)))
|
|
221
223
|
}
|
|
222
224
|
}
|
|
223
225
|
if count == 0 {
|
|
@@ -226,7 +228,7 @@ func expectedCallCredits(contract map[string]any, inputs map[string]any) (int, e
|
|
|
226
228
|
for _, key := range []string{"limit", "k", "top_k", "topK", "per_page", "page_size", "max_results", "count", "size", "num_results", "max_records", "results_per_page"} {
|
|
227
229
|
if stringField(f["name"]) == key {
|
|
228
230
|
if n, ok := numberField(f["default"]); ok && n >= 1 {
|
|
229
|
-
count = n
|
|
231
|
+
count = math.Max(count, n)
|
|
230
232
|
}
|
|
231
233
|
}
|
|
232
234
|
}
|
|
@@ -252,6 +254,20 @@ func expectedCallCredits(contract map[string]any, inputs map[string]any) (int, e
|
|
|
252
254
|
count = 1
|
|
253
255
|
}
|
|
254
256
|
}
|
|
257
|
+
// FullEnrich's contact mobile enrichment is per one result even
|
|
258
|
+
// though its contract sets paginated=true. The response expressly
|
|
259
|
+
// states "One record in results", with empty/no-match free.
|
|
260
|
+
response := mapField(contract["response"])
|
|
261
|
+
description := strings.ToLower(stringField(response["about"]))
|
|
262
|
+
if count == 0 && strings.Contains(description, "one record in results") &&
|
|
263
|
+
(strings.HasSuffix(id, "/mobile") || strings.HasSuffix(id, "/lookup")) {
|
|
264
|
+
for _, key := range []string{"linkedin_url", "first_name", "domain", "professional_network_url"} {
|
|
265
|
+
if strings.TrimSpace(stringField(inputs[key])) != "" {
|
|
266
|
+
count = 1
|
|
267
|
+
break
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
255
271
|
}
|
|
256
272
|
if count == 0 {
|
|
257
273
|
return 0, dataError("unbounded_per_record_cost",
|
|
@@ -476,6 +492,8 @@ func ExecuteData(input ExecuteDataInput) (map[string]any, error) {
|
|
|
476
492
|
}
|
|
477
493
|
}
|
|
478
494
|
if input.RequestID != "" {
|
|
495
|
+
unlock := lockPaidRequest(requestID)
|
|
496
|
+
defer unlock()
|
|
479
497
|
if cached, err := cachedPaidReplay(requestID, input.Calls); err != nil {
|
|
480
498
|
return nil, err
|
|
481
499
|
} else if cached != nil {
|
|
@@ -540,9 +558,10 @@ func ExecuteData(input ExecuteDataInput) (map[string]any, error) {
|
|
|
540
558
|
if input.Raw {
|
|
541
559
|
output["raw"] = rawResponses
|
|
542
560
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
561
|
+
// Cache partial responses too. Retrying a mixed batch must not resubmit
|
|
562
|
+
// already successful paid operations just because a different provider
|
|
563
|
+
// rejected its terms.
|
|
564
|
+
storePaidReplay(requestID, input.Calls, output)
|
|
546
565
|
return output, nil
|
|
547
566
|
}
|
|
548
567
|
|
|
@@ -20,6 +20,41 @@ var paidReplayMemory = struct {
|
|
|
20
20
|
items map[string]localPaidReplay
|
|
21
21
|
}{items: map[string]localPaidReplay{}}
|
|
22
22
|
|
|
23
|
+
// In a single warm worker, serialize requests with the same idempotency key.
|
|
24
|
+
// Without this, two concurrent requests can both miss the replay cache and
|
|
25
|
+
// execute before either writes the receipt.
|
|
26
|
+
type paidReplayGate struct {
|
|
27
|
+
mu sync.Mutex
|
|
28
|
+
users int
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var paidReplayGates = struct {
|
|
32
|
+
sync.Mutex
|
|
33
|
+
items map[string]*paidReplayGate
|
|
34
|
+
}{items: map[string]*paidReplayGate{}}
|
|
35
|
+
|
|
36
|
+
func lockPaidRequest(id string) func() {
|
|
37
|
+
key := localReplayKey(id)
|
|
38
|
+
paidReplayGates.Lock()
|
|
39
|
+
gate := paidReplayGates.items[key]
|
|
40
|
+
if gate == nil {
|
|
41
|
+
gate = &paidReplayGate{}
|
|
42
|
+
paidReplayGates.items[key] = gate
|
|
43
|
+
}
|
|
44
|
+
gate.users++
|
|
45
|
+
paidReplayGates.Unlock()
|
|
46
|
+
gate.mu.Lock()
|
|
47
|
+
return func() {
|
|
48
|
+
gate.mu.Unlock()
|
|
49
|
+
paidReplayGates.Lock()
|
|
50
|
+
gate.users--
|
|
51
|
+
if gate.users == 0 {
|
|
52
|
+
delete(paidReplayGates.items, key)
|
|
53
|
+
}
|
|
54
|
+
paidReplayGates.Unlock()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
23
58
|
func localReplayKey(id string) string {
|
|
24
59
|
h := sha256.Sum256([]byte(os.Getenv("FIRECRAWL_API_KEY") + "\x00" + id))
|
|
25
60
|
return hex.EncodeToString(h[:])
|
|
@@ -70,6 +70,12 @@ func normalizeResearchInput(input ResearchDataInput) (ResearchDataInput, string,
|
|
|
70
70
|
if input.Offset < 0 || input.Offset > 100000 {
|
|
71
71
|
return input, "", nil, dataError("invalid_offset", "offset must be between 0 and 100000.")
|
|
72
72
|
}
|
|
73
|
+
if input.Mode == "ranked" && input.Offset > 0 && strings.TrimSpace(input.Query) != "" &&
|
|
74
|
+
len(input.Sources) == 0 && len(input.URLs) == 0 && len(input.Groups) == 0 &&
|
|
75
|
+
len(input.Categories) == 0 && len(input.Operations) == 0 {
|
|
76
|
+
return input, "", nil, dataError("ranked_search_not_pageable",
|
|
77
|
+
"Ranked semantic search has no native pagination cursor. Use mode=catalogue with offset, or supply a source/category filter to browse and rerank catalogue pages.")
|
|
78
|
+
}
|
|
73
79
|
for _, target := range input.URLs {
|
|
74
80
|
if err := validateSourceURL(target); err != nil {
|
|
75
81
|
return input, "", nil, dataError("invalid_url", "Website filters require public HTTP(S) URLs.")
|
|
@@ -212,11 +218,13 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
212
218
|
if localFilteredRanking {
|
|
213
219
|
// Provider-specified lexical filters can exclude operations whose
|
|
214
220
|
// descriptions describe the requested data indirectly. Fetch a
|
|
215
|
-
// catalogue
|
|
221
|
+
// catalogue page from the actual source, then rank that page.
|
|
222
|
+
// Do not reset every continuation to offset zero: large sources
|
|
223
|
+
// can have hundreds of operations.
|
|
216
224
|
delete(options, "query")
|
|
217
225
|
options["limit"] = 100
|
|
218
|
-
options["offset"] =
|
|
219
|
-
note = "
|
|
226
|
+
options["offset"] = (input.Offset / 100) * 100
|
|
227
|
+
note = "Source/category filters select candidates first. The question ranks each catalogue page locally (up to 100 operations per page); this is not a global ranking across unvisited pages."
|
|
220
228
|
if input.Mode != "catalogue" {
|
|
221
229
|
mode = "ranked_filtered"
|
|
222
230
|
}
|
|
@@ -261,7 +269,7 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
261
269
|
}
|
|
262
270
|
if localFilteredRanking {
|
|
263
271
|
count := len(items)
|
|
264
|
-
start := input.Offset
|
|
272
|
+
start := input.Offset % 100
|
|
265
273
|
if start > count {
|
|
266
274
|
start = count
|
|
267
275
|
}
|
|
@@ -270,12 +278,16 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
270
278
|
end = count
|
|
271
279
|
}
|
|
272
280
|
items = items[start:end]
|
|
273
|
-
|
|
281
|
+
sourceTotal, hasTotal := numberField(total)
|
|
282
|
+
more := count > end || (hasTotal && sourceTotal > float64((input.Offset/100)*100+count))
|
|
283
|
+
if more {
|
|
274
284
|
next = map[string]any{"tool": "research", "arguments": ResearchDataInput{
|
|
275
285
|
Query: input.Query, Mode: input.Mode, URLs: input.URLs, Sources: input.Sources,
|
|
276
286
|
Categories: input.Categories, Groups: input.Groups, View: niceDataView(view),
|
|
277
|
-
Include: input.Include, Limit: input.Limit, Offset:
|
|
287
|
+
Include: input.Include, Limit: input.Limit, Offset: input.Offset + len(items),
|
|
278
288
|
}}
|
|
289
|
+
} else {
|
|
290
|
+
next = nil
|
|
279
291
|
}
|
|
280
292
|
}
|
|
281
293
|
results := make([]map[string]any, 0, len(items))
|
|
@@ -359,6 +371,12 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
|
|
|
359
371
|
if note != "" {
|
|
360
372
|
output["mode_note"] = note
|
|
361
373
|
}
|
|
374
|
+
if localFilteredRanking {
|
|
375
|
+
output["ranking_scope"] = "catalogue_page"
|
|
376
|
+
if sourceTotal, ok := numberField(total); ok {
|
|
377
|
+
output["ranking_incomplete"] = sourceTotal > 100
|
|
378
|
+
}
|
|
379
|
+
}
|
|
362
380
|
if len(input.URLs) > 0 && len(input.Sources) > 0 {
|
|
363
381
|
output["resolved_url_sources"] = input.Sources
|
|
364
382
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package app
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"bytes"
|
|
4
5
|
"context"
|
|
5
6
|
"crypto/sha256"
|
|
6
7
|
"encoding/hex"
|
|
@@ -20,12 +21,30 @@ import (
|
|
|
20
21
|
// MapSiteInput retrieves an agent-sized page rather than dumping thousands
|
|
21
22
|
// of URLs. An omitted language prefers English; "all" allows all locales.
|
|
22
23
|
type MapSiteInput struct {
|
|
23
|
-
URL
|
|
24
|
-
Query
|
|
25
|
-
Limit
|
|
26
|
-
Offset
|
|
27
|
-
PathPrefix
|
|
28
|
-
Language
|
|
24
|
+
URL string `json:"url" jsonschema:"Website URL to map; HTTP(S) only"`
|
|
25
|
+
Query string `json:"query,omitempty" jsonschema:"Optional topic to filter and rank discovered pages"`
|
|
26
|
+
Limit int `json:"limit,omitempty" jsonschema:"Number of URLs to return, 1-100; defaults to 30"`
|
|
27
|
+
Offset int `json:"offset,omitempty" jsonschema:"Pagination offset, zero-based"`
|
|
28
|
+
PathPrefix string `json:"path_prefix,omitempty" jsonschema:"Optional URL path prefix, e.g. /api/"`
|
|
29
|
+
Language string `json:"language,omitempty" jsonschema:"Locale code such as en or fr; defaults to English, use all for all languages"`
|
|
30
|
+
limitProvided bool `json:"-"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func (input *MapSiteInput) UnmarshalJSON(raw []byte) error {
|
|
34
|
+
type fields MapSiteInput
|
|
35
|
+
var parsed fields
|
|
36
|
+
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
37
|
+
decoder.DisallowUnknownFields()
|
|
38
|
+
if err := decoder.Decode(&parsed); err != nil {
|
|
39
|
+
return err
|
|
40
|
+
}
|
|
41
|
+
var presence map[string]json.RawMessage
|
|
42
|
+
if err := json.Unmarshal(raw, &presence); err != nil {
|
|
43
|
+
return err
|
|
44
|
+
}
|
|
45
|
+
*input = MapSiteInput(parsed)
|
|
46
|
+
_, input.limitProvided = presence["limit"]
|
|
47
|
+
return nil
|
|
29
48
|
}
|
|
30
49
|
|
|
31
50
|
type siteMapCacheValue struct {
|
|
@@ -139,7 +158,7 @@ func mapPageLocale(raw string) string {
|
|
|
139
158
|
}
|
|
140
159
|
|
|
141
160
|
func MapSitePage(input MapSiteInput) (map[string]any, error) {
|
|
142
|
-
if input.Limit == 0 {
|
|
161
|
+
if input.Limit == 0 && !input.limitProvided {
|
|
143
162
|
input.Limit = 30
|
|
144
163
|
}
|
|
145
164
|
if input.Limit < 1 || input.Limit > 100 {
|
package/package.json
CHANGED
package/pkg/origo/data_test.go
CHANGED
|
@@ -74,6 +74,7 @@ func TestAuditMCPRejectsZeroLimitAndInvalidSourceURLsWithoutExceptions(t *testin
|
|
|
74
74
|
code string
|
|
75
75
|
}{
|
|
76
76
|
{"research", map[string]any{"mode": "catalogue", "view": "sources", "limit": 0}, "invalid_limit"},
|
|
77
|
+
{"map_site", map[string]any{"url": "https://docs.firecrawl.dev", "limit": 0}, "invalid_limit"},
|
|
77
78
|
{"read_link", map[string]any{"url": "file:///etc/passwd", "question": "summarize"}, "invalid_url"},
|
|
78
79
|
{"map_site", map[string]any{"url": "file:///etc/passwd"}, "invalid_url"},
|
|
79
80
|
} {
|