webctx 0.2.6 → 0.2.7

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.
@@ -1,6 +1,7 @@
1
1
  package app
2
2
 
3
3
  import (
4
+ "net/url"
4
5
  "sort"
5
6
  "strings"
6
7
  )
@@ -60,7 +61,7 @@ func normalizeResearchInput(input ResearchDataInput) (ResearchDataInput, string,
60
61
  if err != nil {
61
62
  return input, "", nil, err
62
63
  }
63
- if input.Limit == 0 {
64
+ if input.Limit == 0 && !input.limitProvided {
64
65
  input.Limit = 10
65
66
  }
66
67
  if input.Limit < 1 || input.Limit > 100 {
@@ -74,6 +75,16 @@ func normalizeResearchInput(input ResearchDataInput) (ResearchDataInput, string,
74
75
  return input, "", nil, dataError("invalid_url", "Website filters require public HTTP(S) URLs.")
75
76
  }
76
77
  }
78
+ if len(input.URLs) > 0 && len(input.Sources) == 0 {
79
+ for _, target := range input.URLs {
80
+ u, _ := url.Parse(target)
81
+ host := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www.")
82
+ if host != "" {
83
+ source := strings.ReplaceAll(host, ".", "-")
84
+ input.Sources = append(input.Sources, source)
85
+ }
86
+ }
87
+ }
77
88
  return input, view, expand, nil
78
89
  }
79
90
 
@@ -82,7 +93,7 @@ func nativeBrowseOptions(input ResearchDataInput, view string, expand []string)
82
93
  if input.Query != "" {
83
94
  o["query"] = input.Query
84
95
  }
85
- if len(input.URLs) > 0 {
96
+ if len(input.URLs) > 0 && len(input.Sources) == 0 {
86
97
  o["urls"] = input.URLs
87
98
  }
88
99
  if len(input.Sources) > 0 {
@@ -123,6 +134,9 @@ func nativeBrowseOptions(input ResearchDataInput, view string, expand []string)
123
134
  }
124
135
  }
125
136
  if len(expand) > 0 {
137
+ if containsString(expand, "examples") && !containsString(expand, "options") {
138
+ expand = append(append([]string(nil), expand...), "options")
139
+ }
126
140
  o["expand"] = expand
127
141
  }
128
142
  return o
@@ -145,9 +159,19 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
145
159
  }
146
160
  ctx, cancel := defaultDataContext()
147
161
  defer cancel()
162
+ explicitExact := len(input.Operations) > 0
163
+ // An exact operation ID is authoritative. A descriptive query helps
164
+ // communicate intent, but must not exclude a known operation.
165
+ note := ""
166
+ if explicitExact && input.Query != "" {
167
+ note = "Exact operation IDs take precedence over descriptive query terms; the query was not used as a restrictive upstream filter."
168
+ input.Query = ""
169
+ }
148
170
  semantic := input.Mode != "catalogue" && input.Query != "" && view == "tools" && input.Offset == 0 &&
149
171
  len(input.URLs) == 0 && len(input.Sources) == 0 && len(input.Categories) == 0 &&
150
172
  len(input.Groups) == 0 && len(input.Operations) == 0
173
+ localFilteredRanking := view == "tools" && input.Query != "" &&
174
+ (len(input.URLs) > 0 || len(input.Sources) > 0 || len(input.Groups) > 0 || len(input.Categories) > 0)
151
175
  var items []any
152
176
  var total any
153
177
  var next any
@@ -159,9 +183,16 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
159
183
  // compact upstream matches may omit both price and operation name.
160
184
  // We still return compact result cards unless include was requested.
161
185
  detail := "full"
186
+ fetchLimit := input.Limit * 4
187
+ if fetchLimit < 20 {
188
+ fetchLimit = 20
189
+ }
190
+ if fetchLimit > 100 {
191
+ fetchLimit = 100
192
+ }
162
193
  raw, err = doDataAPI(ctx, "/search", map[string]any{
163
194
  "query": input.Query, "sources": []string{"alexandria"},
164
- "limit": input.Limit, "toolDetail": detail,
195
+ "limit": fetchLimit, "toolDetail": detail,
165
196
  }, "")
166
197
  if err != nil {
167
198
  return nil, err
@@ -171,17 +202,6 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
171
202
  // Some upstream ranked matches arrive out of order despite including a
172
203
  // similarity score. Present the highest-scoring candidate first, retaining
173
204
  // upstream order for exact ties or missing scores.
174
- sort.SliceStable(items, func(i, j int) bool {
175
- a, aok := numberField(mapField(items[i])["similarity"])
176
- b, bok := numberField(mapField(items[j])["similarity"])
177
- if aok != bok {
178
- return aok
179
- }
180
- if !aok {
181
- return false
182
- }
183
- return a > b
184
- })
185
205
  // Ranked search has no native cursor. Expose the free, complete
186
206
  // catalogue with matching semantic query as an optional expansion.
187
207
  next = map[string]any{"tool": "research", "arguments": map[string]any{
@@ -189,6 +209,22 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
189
209
  }, "note": "Browse all matching operations with pagination; ranked discovery itself has no cursor."}
190
210
  } else {
191
211
  options := nativeBrowseOptions(input, view, expand)
212
+ if localFilteredRanking {
213
+ // Provider-specified lexical filters can exclude operations whose
214
+ // descriptions describe the requested data indirectly. Fetch a
215
+ // catalogue slice from the actual source, then rank it ourselves.
216
+ delete(options, "query")
217
+ options["limit"] = 100
218
+ options["offset"] = 0
219
+ note = "The source/category filters select candidates first. The question ranks those candidates locally, rather than excluding matching source operations."
220
+ if input.Mode != "catalogue" {
221
+ mode = "ranked_filtered"
222
+ }
223
+ }
224
+ if explicitExact {
225
+ delete(options, "query")
226
+ delete(options, "urls")
227
+ }
192
228
  var catalogue map[string]any
193
229
  catalogue, raw, err = freeDataBrowse(ctx, options)
194
230
  if err != nil {
@@ -200,6 +236,48 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
200
236
  next = continuationFromCatalogue(native)
201
237
  }
202
238
  }
239
+ if view == "tools" {
240
+ termsQuery := input.Query
241
+ for _, op := range input.Operations {
242
+ termsQuery += " " + op
243
+ }
244
+ if strings.TrimSpace(termsQuery) != "" {
245
+ sort.SliceStable(items, func(i, j int) bool {
246
+ return intentRelevance(mapField(items[i]), termsQuery) > intentRelevance(mapField(items[j]), termsQuery)
247
+ })
248
+ } else {
249
+ sort.SliceStable(items, func(i, j int) bool {
250
+ a, aok := numberField(mapField(items[i])["similarity"])
251
+ b, bok := numberField(mapField(items[j])["similarity"])
252
+ if aok != bok {
253
+ return aok
254
+ }
255
+ return a > b
256
+ })
257
+ }
258
+ }
259
+ if semantic && len(items) > input.Limit {
260
+ items = items[:input.Limit]
261
+ }
262
+ if localFilteredRanking {
263
+ count := len(items)
264
+ start := input.Offset
265
+ if start > count {
266
+ start = count
267
+ }
268
+ end := start + input.Limit
269
+ if end > count {
270
+ end = count
271
+ }
272
+ items = items[start:end]
273
+ if count > end {
274
+ next = map[string]any{"tool": "research", "arguments": ResearchDataInput{
275
+ Query: input.Query, Mode: input.Mode, URLs: input.URLs, Sources: input.Sources,
276
+ Categories: input.Categories, Groups: input.Groups, View: niceDataView(view),
277
+ Include: input.Include, Limit: input.Limit, Offset: end,
278
+ }}
279
+ }
280
+ }
203
281
  results := make([]map[string]any, 0, len(items))
204
282
  for _, value := range items {
205
283
  entry := mapField(value)
@@ -223,6 +301,15 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
223
301
  }
224
302
  if cost, ok := entry["creditsCost"]; ok {
225
303
  result["credits_per_call"] = cost
304
+ if listed, ok := numberField(cost); ok {
305
+ if listed > float64(dataCreditCap()) {
306
+ result["executable"] = false
307
+ result["availability"] = "exceeds_server_credit_limit"
308
+ result["server_credit_limit"] = dataCreditCap()
309
+ } else if view == "tools" {
310
+ result["executable"] = true
311
+ }
312
+ }
226
313
  }
227
314
  if cost, ok := entry["perRecord"]; ok {
228
315
  result["per_record"] = cost
@@ -230,8 +317,14 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
230
317
  if count, ok := entry["toolCount"]; ok {
231
318
  result["operation_count"] = count
232
319
  }
320
+ if input.Query != "" && view == "tools" {
321
+ result["relevance"] = intentRelevance(entry, input.Query)
322
+ }
233
323
  if rank, ok := entry["similarity"]; ok {
234
- result["relevance"] = rank
324
+ result["semantic_similarity"] = rank
325
+ if input.Query == "" {
326
+ result["relevance"] = rank
327
+ }
235
328
  }
236
329
  if len(expand) > 0 {
237
330
  if selected := entry["options"]; selected != nil && containsString(expand, "options") {
@@ -243,8 +336,15 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
243
336
  if selected := entry["examples"]; selected != nil && containsString(expand, "examples") {
244
337
  result["examples"] = selected
245
338
  }
339
+ if containsString(expand, "examples") && result["examples"] == nil {
340
+ if entry["options"] != nil {
341
+ result["examples"] = contractExamples(entry)
342
+ }
343
+ }
246
344
  }
247
- if continuation := mapField(entry["next"]); continuation != nil {
345
+ if view == "tools" && id != "" {
346
+ result["next"] = map[string]any{"tool": "inspect", "arguments": map[string]any{"id": id}}
347
+ } else if continuation := mapField(entry["next"]); continuation != nil {
248
348
  result["next"] = continuationFromCatalogue(continuation)
249
349
  } else if id != "" {
250
350
  result["next"] = map[string]any{"tool": "inspect", "arguments": map[string]any{"id": id}}
@@ -256,6 +356,12 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
256
356
  "count": len(results), "cost_credits": 0,
257
357
  "guidance": "Use inspect with an operation ID to see complete inputs, output, examples and the next execute call. To explore a source, inspect its source ID.",
258
358
  }
359
+ if note != "" {
360
+ output["mode_note"] = note
361
+ }
362
+ if len(input.URLs) > 0 && len(input.Sources) > 0 {
363
+ output["resolved_url_sources"] = input.Sources
364
+ }
259
365
  if total != nil {
260
366
  output["total"] = total
261
367
  }
@@ -268,6 +374,55 @@ func ResearchData(input ResearchDataInput) (map[string]any, error) {
268
374
  return output, nil
269
375
  }
270
376
 
377
+ // Rank operations by their ability to deliver the requested data, not just
378
+ // semantic similarity. Keep the provider similarity separately for auditing.
379
+ func intentRelevance(entry map[string]any, query string) float64 {
380
+ if entry == nil {
381
+ return 0
382
+ }
383
+ base, _ := numberField(entry["similarity"])
384
+ if query == "" {
385
+ return base
386
+ }
387
+ q := strings.ToLower(query)
388
+ id := strings.ToLower(stringField(entry["id"]))
389
+ name := strings.ToLower(stringField(entry["name"]))
390
+ desc := strings.ToLower(stringField(entry["description"]))
391
+ p := id + " " + name
392
+ searchIntent := strings.Contains(q, "search") || strings.Contains(q, "find") ||
393
+ strings.Contains(q, "papers") || strings.Contains(q, "podcast") ||
394
+ strings.Contains(q, "trials") || strings.Contains(q, "about")
395
+ historyIntent := strings.Contains(q, "historical") || strings.Contains(q, "history") ||
396
+ strings.Contains(q, "time series") || strings.Contains(q, "over time") ||
397
+ strings.Contains(q, "inflation")
398
+ if historyIntent && (strings.Contains(p, "observations") ||
399
+ strings.Contains(p, "history") || strings.Contains(p, "rates") ||
400
+ strings.Contains(p, "timeseries")) {
401
+ base += 0.23
402
+ }
403
+ if searchIntent && (strings.Contains(p, "/search") || strings.Contains(p, "/studies") ||
404
+ strings.Contains(p, "episode-search")) {
405
+ base += 0.20
406
+ }
407
+ if searchIntent && strings.Contains(p, "/list") && !strings.Contains(q, "list") {
408
+ base -= 0.04
409
+ }
410
+ for _, ancillary := range []string{"suggest", "related", "directory", "releases", "/source", "metadata", "browse"} {
411
+ if strings.Contains(p, ancillary) && !strings.Contains(q, strings.TrimLeft(ancillary, "/")) {
412
+ base -= 0.14
413
+ }
414
+ }
415
+ terms := queryTerms(query)
416
+ if len(terms) > 0 {
417
+ fit := termScore(name+" "+desc, terms)
418
+ if fit > 8 {
419
+ fit = 8
420
+ }
421
+ base += float64(fit) * 0.007
422
+ }
423
+ return float64(int(base*1000000+0.5)) / 1000000
424
+ }
425
+
271
426
  func niceDataView(view string) string {
272
427
  switch view {
273
428
  case "providers":
@@ -286,8 +286,8 @@ func TestExecuteHandlesProviderTermsWithoutAccepting(t *testing.T) {
286
286
  mapField(typed.RequiresAction)["url"] != "https://firecrawl.dev/app/settings?tab=data-sources" {
287
287
  t.Fatalf("terms condition lost: %+v", err)
288
288
  }
289
- if mapField(typed.Details)["request_id"] == nil {
290
- t.Fatalf("lost retriable request identifier: %+v", typed)
289
+ if mapField(typed.Details)["retry"] != nil {
290
+ t.Fatalf("terms rejection must not recommend automatic paid retry: %+v", typed)
291
291
  }
292
292
  }
293
293
 
@@ -1,20 +1,43 @@
1
1
  package app
2
2
 
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ )
7
+
3
8
  // ResearchData discovers data sources and their available operations. Search
4
9
  // and catalogue browsing are both free Firecrawl Alexandria operations.
5
10
  type ResearchDataInput struct {
6
- Query string `json:"query,omitempty" jsonschema:"Optional natural-language description of the information needed"`
7
- Mode string `json:"mode,omitempty" jsonschema:"ranked semantic discovery or catalogue browsing with filters and pagination; defaults to ranked for a query"`
8
- URLs []string `json:"urls,omitempty" jsonschema:"Filter by websites associated with a data source"`
9
- Sources []string `json:"sources,omitempty" jsonschema:"Filter by source IDs returned by research"`
10
- Categories []string `json:"categories,omitempty" jsonschema:"Filter by catalogue category IDs"`
11
- Groups []string `json:"groups,omitempty" jsonschema:"Filter by catalogue group IDs"`
12
- Operations []string `json:"operations,omitempty" jsonschema:"Filter by exact operation IDs"`
13
- View string `json:"view,omitempty" jsonschema:"What to browse: sources, groups, or operations; default operations for a query, otherwise sources"`
14
- Include []string `json:"include,omitempty" jsonschema:"Additional contract details: inputs, output, examples. Default is a concise overview"`
15
- Limit int `json:"limit,omitempty" jsonschema:"Maximum results in this page, 1-100; default 10"`
16
- Offset int `json:"offset,omitempty" jsonschema:"Catalogue pagination offset, zero-based"`
17
- Raw bool `json:"raw,omitempty" jsonschema:"Also include the complete original Firecrawl response"`
11
+ Query string `json:"query,omitempty" jsonschema:"Optional natural-language description of the information needed"`
12
+ Mode string `json:"mode,omitempty" jsonschema:"ranked semantic discovery or catalogue browsing with filters and pagination; defaults to ranked for a query"`
13
+ URLs []string `json:"urls,omitempty" jsonschema:"Filter by websites associated with a data source"`
14
+ Sources []string `json:"sources,omitempty" jsonschema:"Filter by source IDs returned by research"`
15
+ Categories []string `json:"categories,omitempty" jsonschema:"Filter by catalogue category IDs"`
16
+ Groups []string `json:"groups,omitempty" jsonschema:"Filter by catalogue group IDs"`
17
+ Operations []string `json:"operations,omitempty" jsonschema:"Filter by exact operation IDs"`
18
+ View string `json:"view,omitempty" jsonschema:"What to browse: sources, groups, or operations; default operations for a query, otherwise sources"`
19
+ Include []string `json:"include,omitempty" jsonschema:"Additional contract details: inputs, output, examples. Default is a concise overview"`
20
+ Limit int `json:"limit,omitempty" jsonschema:"Maximum results in this page, 1-100; default 10"`
21
+ Offset int `json:"offset,omitempty" jsonschema:"Catalogue pagination offset, zero-based"`
22
+ Raw bool `json:"raw,omitempty" jsonschema:"Also include the complete original Firecrawl response"`
23
+ limitProvided bool `json:"-"`
24
+ }
25
+
26
+ func (input *ResearchDataInput) UnmarshalJSON(raw []byte) error {
27
+ type noMethods ResearchDataInput
28
+ var parsed noMethods
29
+ decoder := json.NewDecoder(bytes.NewReader(raw))
30
+ decoder.DisallowUnknownFields()
31
+ if err := decoder.Decode(&parsed); err != nil {
32
+ return err
33
+ }
34
+ var fields map[string]json.RawMessage
35
+ if err := json.Unmarshal(raw, &fields); err != nil {
36
+ return err
37
+ }
38
+ *input = ResearchDataInput(parsed)
39
+ _, input.limitProvided = fields["limit"]
40
+ return nil
18
41
  }
19
42
 
20
43
  // InspectDataInput returns the exact contract for a selected operation, or
@@ -88,6 +88,9 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
88
88
  return "", err
89
89
  }
90
90
  primary = copyableCurlExamples(primary)
91
+ if answer, ok := directlyAnsweredMetadata(rawURL, question, primary); ok {
92
+ return answer, nil
93
+ }
91
94
 
92
95
  // Combine on-page links and authoritative Markdown index links instead of
93
96
  // filling the entire budget from whichever links happen to appear first.
@@ -95,7 +98,7 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
95
98
  // SQLite, webhook, and API references over incidental alarm/concept pages.
96
99
  links := researchCandidates(rawURL, primary, question, maxRelatedSources)
97
100
  structuredDone := make(chan string, 1)
98
- if strings.Contains(primary, "**Markdown source:**") {
101
+ if strings.Contains(primary, "**Markdown source:**") || !asksForAPIContracts(question) {
99
102
  structuredDone <- ""
100
103
  } else {
101
104
  go func() { structuredDone <- embeddedStructuredContext(rawURL, question) }()
@@ -0,0 +1,30 @@
1
+ package app
2
+
3
+ import (
4
+ "fmt"
5
+ "regexp"
6
+ "strings"
7
+ )
8
+
9
+ var githubLicenseField = regexp.MustCompile(`(?mi)^license:\s*["']?([A-Za-z0-9.+-]+)["']?\s*$`)
10
+ var repositoryLicenseURL = regexp.MustCompile(`https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/blob/[A-Za-z0-9_.-]+/(?:LICENSE|COPYING)(?:\.md|\.txt)?`)
11
+
12
+ func directlyAnsweredMetadata(rawURL, question, markdown string) (string, bool) {
13
+ q := strings.ToLower(question)
14
+ if !strings.Contains(q, "license") || !strings.Contains(strings.ToLower(rawURL), "github.com/") {
15
+ return "", false
16
+ }
17
+ match := githubLicenseField.FindStringSubmatch(markdown)
18
+ if len(match) != 2 {
19
+ return "", false
20
+ }
21
+ value := match[1]
22
+ evidenceURL := rawURL
23
+ if source := repositoryLicenseURL.FindString(markdown); source != "" {
24
+ evidenceURL = source
25
+ }
26
+ return fmt.Sprintf(
27
+ "## Answer\n\nThe repository lists its license as **%s**.\n\n**Source:** %s\n\n**Evidence:** GitHub repository metadata lists `license: %q`.\n",
28
+ value, evidenceURL, value,
29
+ ), true
30
+ }
@@ -0,0 +1,256 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "fmt"
9
+ "net/http"
10
+ "net/url"
11
+ "os"
12
+ "sort"
13
+ "strings"
14
+ "sync"
15
+ "time"
16
+
17
+ "golang.org/x/sync/singleflight"
18
+ )
19
+
20
+ // MapSiteInput retrieves an agent-sized page rather than dumping thousands
21
+ // of URLs. An omitted language prefers English; "all" allows all locales.
22
+ type MapSiteInput struct {
23
+ URL string `json:"url" jsonschema:"Website URL to map; HTTP(S) only"`
24
+ Query string `json:"query,omitempty" jsonschema:"Optional topic to filter and rank discovered pages"`
25
+ Limit int `json:"limit,omitempty" jsonschema:"Number of URLs to return, 1-100; defaults to 30"`
26
+ Offset int `json:"offset,omitempty" jsonschema:"Pagination offset, zero-based"`
27
+ PathPrefix string `json:"path_prefix,omitempty" jsonschema:"Optional URL path prefix, e.g. /api/"`
28
+ Language string `json:"language,omitempty" jsonschema:"Locale code such as en or fr; defaults to English, use all for all languages"`
29
+ }
30
+
31
+ type siteMapCacheValue struct {
32
+ links []any
33
+ expires time.Time
34
+ }
35
+
36
+ var siteMapCache = struct {
37
+ sync.Mutex
38
+ items map[string]siteMapCacheValue
39
+ }{items: map[string]siteMapCacheValue{}}
40
+ var siteMapFlight singleflight.Group
41
+
42
+ func rawMappedLinks(ctx context.Context, rawURL string) ([]any, error) {
43
+ if err := validateSourceURL(rawURL); err != nil {
44
+ return nil, &DataError{Code: "invalid_url", Message: err.Error()}
45
+ }
46
+ credential := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
47
+ if credential == "" {
48
+ return nil, missingCredentialsError("Error mapping website", []string{"FIRECRAWL_API_KEY"}, "")
49
+ }
50
+ digest := sha256.Sum256([]byte(credential + "\x00" + rawURL))
51
+ cacheKey := hex.EncodeToString(digest[:])
52
+ useCache := !strings.HasPrefix(credential, "test")
53
+ if useCache {
54
+ siteMapCache.Lock()
55
+ cached, ok := siteMapCache.items[cacheKey]
56
+ siteMapCache.Unlock()
57
+ if ok && time.Now().Before(cached.expires) {
58
+ return cached.links, nil
59
+ }
60
+ }
61
+ fetch := func() (any, error) {
62
+ request := map[string]any{
63
+ "url": rawURL, "sitemap": "include",
64
+ "includeSubdomains": true, "ignoreQueryParameters": true, "limit": 5000,
65
+ }
66
+ body, err := doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/map", map[string]string{
67
+ "Authorization": "Bearer " + credential, "Content-Type": "application/json",
68
+ }, request)
69
+ if err != nil {
70
+ return nil, fmt.Errorf("Error mapping website: %w", err)
71
+ }
72
+ var result map[string]any
73
+ if err := json.Unmarshal(body, &result); err != nil {
74
+ return nil, fmt.Errorf("Error decoding map: %w", err)
75
+ }
76
+ if result["success"] != true {
77
+ return nil, dataError("map_failed", fmt.Sprintf("The source could not be mapped: %v", result["error"]))
78
+ }
79
+ links := listField(result["links"])
80
+ if useCache {
81
+ siteMapCache.Lock()
82
+ if len(siteMapCache.items) > 50 {
83
+ siteMapCache.items = map[string]siteMapCacheValue{}
84
+ }
85
+ siteMapCache.items[cacheKey] = siteMapCacheValue{links: links, expires: time.Now().Add(3 * time.Minute)}
86
+ siteMapCache.Unlock()
87
+ }
88
+ return links, nil
89
+ }
90
+ if !useCache {
91
+ data, err := fetch()
92
+ if err != nil {
93
+ return nil, err
94
+ }
95
+ return data.([]any), nil
96
+ }
97
+ data, err, _ := siteMapFlight.Do(cacheKey, fetch)
98
+ if err != nil {
99
+ return nil, err
100
+ }
101
+ return data.([]any), nil
102
+ }
103
+
104
+ var knownSiteLocales = map[string]bool{
105
+ "en": true, "fr": true, "es": true, "de": true, "ja": true, "zh": true, "zh-cn": true, "zh-tw": true,
106
+ "pt": true, "pt-br": true, "it": true, "ko": true, "ru": true, "ar": true, "hi": true, "id": true,
107
+ "nl": true, "pl": true, "tr": true, "vi": true, "th": true, "sv": true, "da": true,
108
+ "es-es": true, "en-us": true, "en-gb": true, "fr-fr": true, "fr-ca": true,
109
+ }
110
+
111
+ func mapPageLocale(raw string) string {
112
+ u, err := url.Parse(raw)
113
+ if err != nil {
114
+ return "en"
115
+ }
116
+ parts := strings.Split(strings.Trim(u.Path, "/"), "/")
117
+ if len(parts) > 0 {
118
+ first := strings.ToLower(parts[0])
119
+ if knownSiteLocales[first] {
120
+ if strings.HasPrefix(first, "en") {
121
+ return "en"
122
+ }
123
+ if strings.HasPrefix(first, "zh") {
124
+ return "zh"
125
+ }
126
+ if strings.HasPrefix(first, "es") {
127
+ return "es"
128
+ }
129
+ if strings.HasPrefix(first, "fr") {
130
+ return "fr"
131
+ }
132
+ if strings.HasPrefix(first, "pt") {
133
+ return "pt"
134
+ }
135
+ return first
136
+ }
137
+ }
138
+ return "en"
139
+ }
140
+
141
+ func MapSitePage(input MapSiteInput) (map[string]any, error) {
142
+ if input.Limit == 0 {
143
+ input.Limit = 30
144
+ }
145
+ if input.Limit < 1 || input.Limit > 100 {
146
+ return nil, dataError("invalid_limit", "map_site limit must be 1-100.")
147
+ }
148
+ if input.Offset < 0 || input.Offset > 100000 {
149
+ return nil, dataError("invalid_offset", "map_site offset must be nonnegative.")
150
+ }
151
+ if input.Language == "" {
152
+ input.Language = "en"
153
+ }
154
+ input.Language = strings.ToLower(strings.TrimSpace(input.Language))
155
+ if input.PathPrefix != "" && !strings.HasPrefix(input.PathPrefix, "/") {
156
+ input.PathPrefix = "/" + input.PathPrefix
157
+ }
158
+ if err := validateSourceURL(input.URL); err != nil {
159
+ return nil, &DataError{Code: "invalid_url", Message: err.Error()}
160
+ }
161
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
162
+ defer cancel()
163
+ links, err := rawMappedLinks(ctx, input.URL)
164
+ if err != nil {
165
+ return nil, err
166
+ }
167
+ type located struct {
168
+ entry map[string]any
169
+ score int
170
+ path string
171
+ }
172
+ var matched []located
173
+ seen := map[string]bool{}
174
+ terms := queryTerms(input.Query)
175
+ for _, v := range links {
176
+ item := map[string]any{}
177
+ if s, ok := v.(string); ok {
178
+ item["url"] = s
179
+ } else {
180
+ item = mapField(v)
181
+ }
182
+ raw := stringField(item["url"])
183
+ if raw == "" || seen[raw] {
184
+ continue
185
+ }
186
+ seen[raw] = true
187
+ u, e := url.Parse(raw)
188
+ if e != nil || u.Host == "" {
189
+ continue
190
+ }
191
+ if input.Language != "all" && mapPageLocale(raw) != input.Language {
192
+ continue
193
+ }
194
+ if input.PathPrefix != "" && !strings.HasPrefix(strings.ToLower(u.Path), strings.ToLower(input.PathPrefix)) {
195
+ continue
196
+ }
197
+ name := stringField(item["title"])
198
+ desc := stringField(item["description"])
199
+ score := termScore(u.Path+" "+name, terms)*3 + termScore(desc, terms)
200
+ if len(terms) > 0 && score == 0 {
201
+ continue
202
+ }
203
+ clean := map[string]any{"url": raw}
204
+ if name != "" {
205
+ clean["title"] = boundedSiteText(name, 140)
206
+ }
207
+ if desc != "" {
208
+ clean["description"] = boundedSiteText(desc, 230)
209
+ }
210
+ matched = append(matched, located{entry: clean, score: score, path: u.Path})
211
+ }
212
+ sort.SliceStable(matched, func(i, j int) bool {
213
+ if len(terms) > 0 && matched[i].score != matched[j].score {
214
+ return matched[i].score > matched[j].score
215
+ }
216
+ if depthI, depthJ := strings.Count(matched[i].path, "/"), strings.Count(matched[j].path, "/"); depthI != depthJ {
217
+ return depthI < depthJ
218
+ }
219
+ return matched[i].path < matched[j].path
220
+ })
221
+ start := input.Offset
222
+ if start > len(matched) {
223
+ start = len(matched)
224
+ }
225
+ end := start + input.Limit
226
+ if end > len(matched) {
227
+ end = len(matched)
228
+ }
229
+ page := make([]map[string]any, 0, end-start)
230
+ for _, entry := range matched[start:end] {
231
+ page = append(page, entry.entry)
232
+ }
233
+ output := map[string]any{
234
+ "url": input.URL, "total_discovered": len(links),
235
+ "total_matching": len(matched), "links": page,
236
+ "count": len(page), "offset": start, "limit": input.Limit,
237
+ "language": input.Language, "provider_page_ceiling": 5000,
238
+ "may_be_incomplete": len(links) >= 5000,
239
+ "guidance": "Use next to request another bounded page, or set query/path_prefix/language to narrow results. Language=all includes translated pages.",
240
+ }
241
+ if end < len(matched) {
242
+ output["next"] = map[string]any{"tool": "map_site", "arguments": MapSiteInput{
243
+ URL: input.URL, Query: input.Query, Limit: input.Limit, Offset: end,
244
+ PathPrefix: input.PathPrefix, Language: input.Language,
245
+ }}
246
+ }
247
+ return output, nil
248
+ }
249
+
250
+ func boundedSiteText(value string, max int) string {
251
+ value = strings.Join(strings.Fields(value), " ")
252
+ if len(value) > max {
253
+ return value[:max] + "…"
254
+ }
255
+ return value
256
+ }