webctx 0.2.3 → 0.2.4

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.
@@ -18,20 +18,7 @@ type scrapedPage struct {
18
18
  }
19
19
 
20
20
  func usableScrapedMarkdown(markdown string) bool {
21
- text := strings.ToLower(strings.TrimSpace(markdown))
22
- if len(text) < 10 {
23
- return false
24
- }
25
- for _, marker := range []string{
26
- "just a moment...", "checking your browser", "verify you are human",
27
- "enable javascript and cookies", "access denied | cloudflare",
28
- "attention required! | cloudflare", "no content extracted",
29
- } {
30
- if strings.Contains(text, marker) && len(text) < 2000 {
31
- return false
32
- }
33
- }
34
- return true
21
+ return usableSourceContent(markdown)
35
22
  }
36
23
 
37
24
  func firecrawlScrape(rawURL, apiKey, proxy string) (scrapedPage, error) {
@@ -165,6 +152,9 @@ func firecrawlBrowserFallback(rawURL, apiKey string) (string, string, error) {
165
152
  if parsed, err := strconv.Unquote(strings.TrimSpace(text)); err == nil {
166
153
  text = parsed
167
154
  }
155
+ if reason := challengeReason(text); reason != "" {
156
+ return "", "", fmt.Errorf("browser fallback returned %s rather than documentation", reason)
157
+ }
168
158
  return "Browser extraction", text, nil
169
159
  }
170
160
 
@@ -1,6 +1,7 @@
1
1
  package app
2
2
 
3
3
  import (
4
+ "encoding/json"
4
5
  "io"
5
6
  "net/http"
6
7
  "strings"
@@ -18,6 +19,43 @@ func TestSourceURLSafety(t *testing.T) {
18
19
  }
19
20
  }
20
21
 
22
+ func TestLongBrowserCAPTCHAFallbackFailsClearly(t *testing.T) {
23
+ original := http.DefaultClient
24
+ t.Cleanup(func() { http.DefaultClient = original })
25
+ challenge := "hCaptcha Please verify you are human. " +
26
+ strings.Repeat("French Deutsch Español English 日本語 中文 ", 150)
27
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
28
+ switch req.Method + " " + req.URL.Path {
29
+ case "POST /v2/scrape":
30
+ content, _ := json.Marshal(map[string]any{
31
+ "success": true,
32
+ "data": map[string]any{"markdown": challenge},
33
+ })
34
+ return testHTTPResponse(req, http.StatusOK, string(content), nil), nil
35
+ case "POST /v2/interact":
36
+ return testHTTPResponse(req, http.StatusOK, `{"success":true,"id":"test-captcha"}`, nil), nil
37
+ case "POST /v2/interact/test-captcha/execute":
38
+ body, _ := json.Marshal(map[string]any{
39
+ "success": true, "stdout": challenge, "exitCode": 0,
40
+ })
41
+ return testHTTPResponse(req, http.StatusOK, string(body), nil), nil
42
+ case "DELETE /v2/interact/test-captcha":
43
+ return testHTTPResponse(req, http.StatusOK, `{"success":true}`, nil), nil
44
+ default:
45
+ t.Fatalf("unexpected request: %s %s", req.Method, req.URL)
46
+ return nil, nil
47
+ }
48
+ })}
49
+ t.Setenv("FIRECRAWL_API_KEY", "unit-test")
50
+ _, returned, err := scrapeLinkWithFirecrawl("https://docs.example.com/challenged")
51
+ if err == nil || returned != "" || !strings.Contains(err.Error(), "challenge") {
52
+ t.Fatalf("long browser challenge leaked into result or lacked clear failure: %q %v", returned, err)
53
+ }
54
+ if strings.Contains(err.Error(), "French Deutsch") {
55
+ t.Fatalf("challenge payload should not be logged verbatim in errors: %s", err)
56
+ }
57
+ }
58
+
21
59
  func TestScrapeEscalatesToEnhancedOnBlockedBody(t *testing.T) {
22
60
  originalClient := http.DefaultClient
23
61
  t.Cleanup(func() { http.DefaultClient = originalClient })
@@ -0,0 +1,76 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "net/url"
6
+ "path"
7
+ "strings"
8
+ "time"
9
+ )
10
+
11
+ const maxNativeMarkdownBytes = 1024 * 1024
12
+
13
+ // Documentation sites frequently expose canonical HTML and an authored
14
+ // Markdown representation. Use bounded GET probes instead of HEAD:
15
+ // Stripe sends Content-Length: 0 on a successful Markdown HEAD response,
16
+ // and Cloudflare uses /index.md for directory URLs.
17
+ func markdownCandidateURLs(rawURL string) []string {
18
+ u, err := url.Parse(rawURL)
19
+ if err != nil {
20
+ return nil
21
+ }
22
+ p := u.Path
23
+ if strings.HasSuffix(strings.ToLower(p), ".md") {
24
+ return []string{rawURL}
25
+ }
26
+ var candidates []string
27
+ if strings.HasSuffix(p, "/") || p == "" {
28
+ candidates = append(candidates, strings.TrimSuffix(p, "/")+"/index.md")
29
+ } else if path.Ext(p) == "" {
30
+ candidates = append(candidates, p+".md", p+"/index.md")
31
+ } else {
32
+ return nil
33
+ }
34
+ seen := map[string]bool{}
35
+ var out []string
36
+ for _, candidate := range candidates {
37
+ next := *u
38
+ next.Path = candidate
39
+ next.RawPath = ""
40
+ next.Fragment = ""
41
+ next.RawFragment = ""
42
+ value := next.String()
43
+ if !seen[value] && validateSourceURL(value) == nil {
44
+ out = append(out, value)
45
+ seen[value] = true
46
+ }
47
+ }
48
+ return out
49
+ }
50
+
51
+ func usableNativeMarkdown(content string) bool {
52
+ text := strings.TrimSpace(content)
53
+ if len(text) < 18 || strings.IndexByte(text, 0) >= 0 || challengeReason(text) != "" {
54
+ return false
55
+ }
56
+ prefix := strings.ToLower(text[:min(len(text), 700)])
57
+ if strings.Contains(prefix, "<!doctype html") || strings.Contains(prefix, "<html") ||
58
+ strings.Contains(prefix, "<head") || strings.Contains(prefix, "<body") {
59
+ return false
60
+ }
61
+ return true
62
+ }
63
+
64
+ func readNativeMarkdown(rawURL string) (*MarkdownResult, error) {
65
+ for _, candidate := range markdownCandidateURLs(rawURL) {
66
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
67
+ body, err := fetchPublicText(ctx, candidate, maxNativeMarkdownBytes)
68
+ cancel()
69
+ if err != nil || !usableNativeMarkdown(body) {
70
+ continue
71
+ }
72
+ title := firstHeadingOrFallback(body, path.Base(rawURL))
73
+ return &MarkdownResult{URL: rawURL, SourceURL: candidate, Title: title, Markdown: body}, nil
74
+ }
75
+ return nil, nil
76
+ }
@@ -0,0 +1,78 @@
1
+ package app
2
+
3
+ import (
4
+ "net/http"
5
+ "strings"
6
+ "testing"
7
+ )
8
+
9
+ func TestCanonicalStripeGuidePrefersMarkdownWithZeroHeadLength(t *testing.T) {
10
+ previous := http.DefaultClient
11
+ t.Cleanup(func() { http.DefaultClient = previous })
12
+ calls := []string{}
13
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
14
+ calls = append(calls, req.Method+" "+req.URL.Path)
15
+ if req.Method == http.MethodHead {
16
+ t.Fatal("native Markdown detection must not require HEAD metadata")
17
+ }
18
+ if req.Method == http.MethodGet && req.URL.Path == "/payments/checkout.md" {
19
+ r := testHTTPResponse(req, http.StatusOK, "# Build a payments page\n\nStripe-hosted checkout is supported.", map[string]string{"Content-Type": "text/markdown"})
20
+ r.ContentLength = 0 // Publishers can return zero or unknown length metadata.
21
+ return r, nil
22
+ }
23
+ t.Fatalf("unexpected extra request: %s %s", req.Method, req.URL)
24
+ return nil, nil
25
+ })}
26
+ out, err := ReadLink("https://docs.stripe.com/payments/checkout")
27
+ if err != nil || !strings.Contains(out, "**Markdown source:** https://docs.stripe.com/payments/checkout.md") {
28
+ t.Fatalf("native source not selected: %v %s", err, out)
29
+ }
30
+ if len(calls) != 1 {
31
+ t.Fatalf("unexpected calls: %+v", calls)
32
+ }
33
+ }
34
+
35
+ func TestCanonicalCloudflareDirectoryReadsIndexMarkdown(t *testing.T) {
36
+ previous := http.DefaultClient
37
+ t.Cleanup(func() { http.DefaultClient = previous })
38
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
39
+ if req.Method != http.MethodGet || req.URL.Path != "/durable-objects/index.md" {
40
+ t.Fatalf("unexpected URL: %s %s", req.Method, req.URL)
41
+ }
42
+ return testHTTPResponse(req, http.StatusOK,
43
+ "# Cloudflare Durable Objects\n\n[Get started](https://developers.cloudflare.com/durable-objects/get-started/)",
44
+ map[string]string{"Content-Type": "text/markdown"}), nil
45
+ })}
46
+ out, err := ReadLink("https://developers.cloudflare.com/durable-objects/")
47
+ if err != nil || !strings.Contains(out, "**Markdown source:** https://developers.cloudflare.com/durable-objects/index.md") {
48
+ t.Fatalf("directory-index native Markdown failed: %v %s", err, out)
49
+ }
50
+ }
51
+
52
+ func TestCAPTCHADisguisedAsMarkdownIsRejected(t *testing.T) {
53
+ previous := http.DefaultClient
54
+ t.Cleanup(func() { http.DefaultClient = previous })
55
+ var scrapeCalled bool
56
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
57
+ if req.URL.Path == "/guide.md" {
58
+ return testHTTPResponse(req, http.StatusOK, "# hCaptcha\nPlease verify you are human. "+
59
+ strings.Repeat("English French Deutsch Español Chinese 日本語 ", 200), map[string]string{"Content-Type": "text/markdown"}), nil
60
+ }
61
+ if req.URL.Path == "/guide/index.md" {
62
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
63
+ }
64
+ if req.Method == http.MethodPost && req.URL.Path == "/v2/scrape" {
65
+ scrapeCalled = true
66
+ return testHTTPResponse(req, http.StatusOK,
67
+ `{"success":true,"data":{"markdown":"# Legitimate guide\n\nFull instructions for a public API."}}`,
68
+ nil), nil
69
+ }
70
+ t.Fatalf("unexpected request: %s %s", req.Method, req.URL)
71
+ return nil, nil
72
+ })}
73
+ t.Setenv("FIRECRAWL_API_KEY", "test")
74
+ out, err := ReadLink("https://docs.example.com/guide")
75
+ if err != nil || !scrapeCalled || strings.Contains(out, "Deutsch Español") || !strings.Contains(out, "Full instructions") {
76
+ t.Fatalf("CAPTCHA disguised as Markdown leaked or fallback failed: %v %s", err, out)
77
+ }
78
+ }
@@ -20,10 +20,19 @@ const (
20
20
  focusedPrimaryBudget = 22000
21
21
  focusedRelatedBudget = 6500
22
22
  searchSourceBudget = 4000
23
- maxRelatedSources = 2
23
+ maxRelatedSources = 5
24
+ maxFollowupSources = 2
24
25
  maxSearchSources = 3
25
26
  )
26
27
 
28
+ func relatedExcerptBudget(rawURL string) int {
29
+ lower := strings.ToLower(rawURL)
30
+ if strings.Contains(lower, "/get-started/") || strings.Contains(lower, "/quickstart") {
31
+ return 18000 // Preserve end-to-end runnable setup guides.
32
+ }
33
+ return focusedRelatedBudget
34
+ }
35
+
27
36
  var (
28
37
  mdLinkPattern = regexp.MustCompile(`\[([^\]\n]{2,120})\]\(([^\s)]+)(?:\s+"[^"]*")?\)`)
29
38
  wordPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9_-]{2,}`)
@@ -79,25 +88,17 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
79
88
  return "", err
80
89
  }
81
90
 
82
- links := relatedSourceLinks(primary, rawURL, question, maxRelatedSources)
83
- if len(links) < maxRelatedSources {
84
- // Many doc systems publish llms.txt as their navigation index. It is a
85
- // cheap way to find exact API reference pages not linked from this page.
86
- for _, candidate := range linksFromDocsIndex(rawURL, question, maxRelatedSources) {
87
- if len(links) == maxRelatedSources {
88
- break
89
- }
90
- duplicate := candidate.url == rawURL
91
- for _, chosen := range links {
92
- duplicate = duplicate || chosen.url == candidate.url
93
- }
94
- if !duplicate {
95
- links = append(links, candidate)
96
- }
97
- }
98
- }
91
+ // Combine on-page links and authoritative Markdown index links instead of
92
+ // filling the entire budget from whichever links happen to appear first.
93
+ // In particular, an implementation question should prefer get-started,
94
+ // SQLite, webhook, and API references over incidental alarm/concept pages.
95
+ links := researchCandidates(rawURL, primary, question, maxRelatedSources)
99
96
  structuredDone := make(chan string, 1)
100
- go func() { structuredDone <- embeddedStructuredContext(rawURL, question) }()
97
+ if strings.Contains(primary, "**Markdown source:**") {
98
+ structuredDone <- ""
99
+ } else {
100
+ go func() { structuredDone <- embeddedStructuredContext(rawURL, question) }()
101
+ }
101
102
  more := readSources(links, reader)
102
103
  parts := []string{
103
104
  "# Source-grounded context",
@@ -107,27 +108,91 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
107
108
  "## Primary source",
108
109
  "**Source:** " + rawURL,
109
110
  "",
110
- focusExcerpt(primary, question, focusedPrimaryBudget),
111
111
  }
112
+ primaryExcerpt := focusExcerpt(primary, question, focusedPrimaryBudget)
113
+ parts = append(parts, primaryExcerpt)
112
114
  successful := 1
115
+ relatedSuccessful := 0
116
+ allRetrieved := primary
117
+ outputEvidence := primaryExcerpt
118
+ seenURLs := map[string]bool{rawURL: true}
113
119
  for _, source := range more {
114
120
  if source.err != nil {
115
121
  continue
116
122
  }
117
123
  successful++
118
- parts = append(parts, "", "## Related source", "**Source:** "+source.url, "", focusExcerpt(source.content, question, focusedRelatedBudget))
124
+ relatedSuccessful++
125
+ seenURLs[source.url] = true
126
+ allRetrieved += "\n" + source.content
127
+ excerpt := focusExcerpt(source.content, question, relatedExcerptBudget(source.url))
128
+ outputEvidence += "\n" + excerpt
129
+ parts = append(parts, "", "## Related source", "**Source:** "+source.url, "", excerpt)
130
+ }
131
+ // One additional bounded expansion if required code/API evidence remains
132
+ // missing. This follows references from the implementation pages we actually
133
+ // found, not arbitrary link hops across the entire site.
134
+ missing := missingResearchEvidence(question, allRetrieved)
135
+ if len(missing) > 0 {
136
+ var next []sourceCandidate
137
+ for _, source := range more {
138
+ if source.err != nil {
139
+ continue
140
+ }
141
+ if strings.Contains(strings.ToLower(source.content), "article has multiple variants") {
142
+ for _, candidate := range relatedSourceLinks(source.content, source.url, question, 8) {
143
+ if !seenURLs[candidate.url] {
144
+ seenURLs[candidate.url] = true
145
+ candidate.score += 50 // A published page variant is the actual documentation, not an index.
146
+ if strings.Contains(strings.ToLower(question), "hosted") &&
147
+ strings.Contains(strings.ToLower(candidate.url+" "+candidate.label), "hosted") {
148
+ candidate.score += 20
149
+ }
150
+ next = append(next, candidate)
151
+ }
152
+ }
153
+ }
154
+ for _, candidate := range relatedSourceLinks(source.content, source.url, strings.Join(missing, " "), 8) {
155
+ if !seenURLs[candidate.url] {
156
+ seenURLs[candidate.url] = true
157
+ candidate.score += implementationLinkBonus(candidate, question)
158
+ next = append(next, candidate)
159
+ }
160
+ }
161
+ }
162
+ sort.SliceStable(next, func(i, j int) bool { return next[i].score > next[j].score })
163
+ if len(next) > maxFollowupSources {
164
+ next = next[:maxFollowupSources]
165
+ }
166
+ followups := readSources(next, reader)
167
+ more = append(more, followups...)
168
+ for _, source := range followups {
169
+ if source.err != nil {
170
+ continue
171
+ }
172
+ successful++
173
+ relatedSuccessful++
174
+ allRetrieved += "\n" + source.content
175
+ excerpt := focusExcerpt(source.content, question, relatedExcerptBudget(source.url))
176
+ outputEvidence += "\n" + excerpt
177
+ parts = append(parts, "", "## Supporting implementation reference", "**Source:** "+source.url, "", excerpt)
178
+ }
119
179
  }
120
180
  if openAPIDone != nil {
121
181
  if contract := <-openAPIDone; contract.Content != "" {
122
182
  successful++
183
+ outputEvidence += "\n" + contract.Content
123
184
  parts = append(parts, "", "## REST API contract", "**Source:** "+contract.URL, "", contract.Content)
124
185
  }
125
186
  }
126
187
  if structured := <-structuredDone; structured != "" {
188
+ outputEvidence += "\n" + structured
127
189
  parts = append(parts, "", structured)
128
190
  }
191
+ if unmet := missingResearchEvidence(question, outputEvidence); len(unmet) > 0 {
192
+ parts = append(parts, "", "**Requested details not fully evidenced in returned excerpts:** "+strings.Join(unmet, ", ")+". Follow the cited source pages rather than assuming these details.")
193
+ }
129
194
  parts = append(parts, "", fmt.Sprintf("**Coverage:** %d source document(s) inspected. Extracts are source-grounded, not an independently verified synthesis. REST commands assembled from an OpenAPI operation and its published example are labeled as such.", successful))
130
- if failed := len(more) - (successful - 1); failed > 0 {
195
+ if failed := len(more) - relatedSuccessful; failed > 0 {
131
196
  parts = append(parts, fmt.Sprintf("%d related page(s) could not be retrieved; the primary source is preserved.", failed))
132
197
  }
133
198
  return strings.Join(parts, "\n"), nil
@@ -184,24 +249,48 @@ func enrichSearchWithReader(query string, ranked []SearchDoc, reader func(string
184
249
  }
185
250
 
186
251
  func linksFromDocsIndex(rawURL, question string, limit int) []sourceCandidate {
252
+ return linksFromDocsIndexWithPrimary(rawURL, "", question, limit)
253
+ }
254
+
255
+ func linksFromDocsIndexWithPrimary(rawURL, primary, question string, limit int) []sourceCandidate {
187
256
  u, err := url.Parse(rawURL)
188
257
  if err != nil || u.Hostname() == "github.com" || directSourceLanguage(rawURL) != "" {
189
258
  return nil
190
259
  }
191
- if !strings.HasPrefix(u.Hostname(), "docs.") && !strings.Contains(u.Path, "/docs/") && !strings.Contains(u.Path, "/reference/") {
260
+ if !strings.HasPrefix(u.Hostname(), "docs.") && !strings.Contains(u.Path, "/docs/") &&
261
+ !strings.Contains(u.Path, "/reference/") && !strings.Contains(primary, "llms.txt") {
192
262
  return nil
193
263
  }
194
- indexURL := u.Scheme + "://" + u.Host + "/llms.txt"
195
- if rawURL == indexURL {
196
- return nil
264
+ indexURLs := documentIndexURLs(u, primary)
265
+ var candidates []sourceCandidate
266
+ seen := map[string]bool{}
267
+ for _, indexURL := range indexURLs {
268
+ if rawURL == indexURL {
269
+ continue
270
+ }
271
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
272
+ index, err := fetchPublicText(ctx, indexURL, 300000)
273
+ cancel()
274
+ if err != nil || !strings.Contains(index, "](") {
275
+ continue
276
+ }
277
+ for _, item := range relatedSourceLinks(index, indexURL, question, 300) {
278
+ item.score += implementationLinkBonus(item, question)
279
+ item.score += sourceScopeBonus(item, rawURL, question)
280
+ if !seen[item.url] {
281
+ seen[item.url] = true
282
+ candidates = append(candidates, item)
283
+ }
284
+ }
285
+ if len(candidates) >= limit {
286
+ break
287
+ }
197
288
  }
198
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
199
- defer cancel()
200
- index, err := fetchPublicText(ctx, indexURL, 300000)
201
- if err != nil || !strings.Contains(index, "](") {
202
- return nil
289
+ sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
290
+ if len(candidates) > limit {
291
+ candidates = candidates[:limit]
203
292
  }
204
- return relatedSourceLinks(index, indexURL, question, limit)
293
+ return candidates
205
294
  }
206
295
 
207
296
  func readSources(candidates []sourceCandidate, reader func(string) (string, error)) []sourceRead {
@@ -279,7 +368,7 @@ func relatedSourceLinks(markdown, baseURL, question string, limit int) []sourceC
279
368
  terms := queryTerms(question)
280
369
  seen := map[string]bool{canonicalSourceURL(base): true}
281
370
  var links []sourceCandidate
282
- for i, match := range mdLinkPattern.FindAllStringSubmatch(markdown, 300) {
371
+ for i, match := range mdLinkPattern.FindAllStringSubmatch(markdown, 1000) {
283
372
  destination := strings.TrimSpace(match[2])
284
373
  if strings.Contains(destination, "\\") { // malformed Markdown-escaped URLs
285
374
  continue
@@ -0,0 +1,237 @@
1
+ package app
2
+
3
+ import (
4
+ "net/url"
5
+ "regexp"
6
+ "sort"
7
+ "strings"
8
+ )
9
+
10
+ var documentationIndexPattern = regexp.MustCompile("https?://[^\\s<>\"')\\]]+/llms\\.txt")
11
+
12
+ func documentIndexURLs(base *url.URL, primary string) []string {
13
+ var urls []string
14
+ seen := map[string]bool{}
15
+ for _, raw := range documentationIndexPattern.FindAllString(primary, 5) {
16
+ target, err := url.Parse(raw)
17
+ if err != nil || validateSourceURL(raw) != nil ||
18
+ !strings.EqualFold(base.Hostname(), target.Hostname()) || target.Port() != base.Port() {
19
+ continue
20
+ }
21
+ if !seen[raw] {
22
+ urls = append(urls, raw)
23
+ seen[raw] = true
24
+ }
25
+ }
26
+ fallback := base.Scheme + "://" + base.Host + "/llms.txt"
27
+ if !seen[fallback] {
28
+ urls = append(urls, fallback)
29
+ }
30
+ return urls
31
+ }
32
+
33
+ func implementationQuestion(question string) bool {
34
+ text := strings.ToLower(question)
35
+ for _, signal := range []string{
36
+ "implement", "integrat", "runnable", "configuration",
37
+ "code example", "complete", "how to", "set up", "using a", "creating",
38
+ } {
39
+ if strings.Contains(text, signal) {
40
+ return true
41
+ }
42
+ }
43
+ return false
44
+ }
45
+
46
+ func implementationLinkBonus(candidate sourceCandidate, question string) int {
47
+ lower := strings.ToLower(candidate.url + " " + candidate.label)
48
+ q := strings.ToLower(question)
49
+ score := 0
50
+ if implementationQuestion(question) {
51
+ switch {
52
+ case strings.Contains(lower, "/get-started/"), strings.Contains(lower, "/quickstart"),
53
+ strings.Contains(lower, "getting started"):
54
+ score += 64 // Publisher's end-to-end guide, not isolated snippets.
55
+ case strings.Contains(lower, "tutorial"), strings.Contains(lower, "integration"),
56
+ strings.Contains(lower, "how-to"):
57
+ score += 16
58
+ case strings.Contains(lower, "example"):
59
+ score += 6
60
+ }
61
+ }
62
+ if strings.Contains(q, "sql") || strings.Contains(q, "sqlite") {
63
+ if strings.Contains(lower, "sqlite") || strings.Contains(lower, "storage") {
64
+ score += 26
65
+ } else if strings.Contains(lower, "/examples/") {
66
+ score -= 16
67
+ }
68
+ }
69
+ if strings.Contains(q, "webhook") || strings.Contains(q, "fulfill") || strings.Contains(q, "fulfil") {
70
+ if strings.Contains(lower, "webhook") || strings.Contains(lower, "fulfill") || strings.Contains(lower, "fulfil") {
71
+ score += 30
72
+ }
73
+ }
74
+ if strings.Contains(q, "checkout") && (strings.Contains(lower, "checkout") || strings.Contains(lower, "session")) {
75
+ score += 6
76
+ }
77
+ if strings.Contains(q, "signature") && strings.Contains(lower, "signature") {
78
+ score += 24
79
+ }
80
+ if strings.Contains(q, "curl") || strings.Contains(q, "rest") || strings.Contains(q, "endpoint") {
81
+ if strings.Contains(lower, "/api/") || strings.Contains(lower, "api-reference") {
82
+ score += 10
83
+ }
84
+ }
85
+ if strings.Contains(q, "session") && strings.Contains(q, "creat") &&
86
+ (strings.Contains(lower, "/sessions/create") || strings.Contains(lower, "create a checkout session")) {
87
+ score += 38
88
+ }
89
+ if strings.Contains(q, "checkout") && strings.Contains(lower, "/checkout/quickstart") {
90
+ score += 20
91
+ }
92
+ if strings.Contains(q, "fulfill") || strings.Contains(q, "fulfil") {
93
+ if strings.Contains(lower, "/checkout/fulfillment") {
94
+ score += 24
95
+ }
96
+ }
97
+ if strings.Contains(lower, "/alarms/") && !strings.Contains(q, "alarm") {
98
+ score -= 24
99
+ }
100
+ if strings.Contains(lower, "/concepts/") && implementationQuestion(question) {
101
+ score -= 12
102
+ }
103
+ if strings.Contains(lower, "legacy") && (strings.Contains(q, "new ") || strings.Contains(q, "current")) && !strings.Contains(q, "legacy") {
104
+ score -= 25
105
+ }
106
+ return score
107
+ }
108
+
109
+ // Search indices on a large documentation host often contain links for many
110
+ // unrelated products. Prefer links in the original section or with a product
111
+ // named in the question. This prevents "webhooks for Climate Orders" from
112
+ // outranking generic Checkout webhooks solely because both contain "webhook".
113
+ func sourceScopeBonus(candidate sourceCandidate, rawURL, question string) int {
114
+ base, err1 := url.Parse(rawURL)
115
+ target, err2 := url.Parse(candidate.url)
116
+ if err1 != nil || err2 != nil {
117
+ return 0
118
+ }
119
+ q := strings.ToLower(question)
120
+ sourceSegments := strings.Split(strings.Trim(base.Path, "/"), "/")
121
+ targetSegments := strings.Split(strings.Trim(target.Path, "/"), "/")
122
+ if len(targetSegments) == 0 {
123
+ return 0
124
+ }
125
+ score := 0
126
+ if len(sourceSegments) > 0 && targetSegments[0] == sourceSegments[0] {
127
+ score += 8
128
+ }
129
+ if len(targetSegments) > 0 && targetSegments[0] != "" &&
130
+ !strings.Contains(q, strings.TrimSuffix(targetSegments[0], "s")) &&
131
+ !strings.Contains(q, targetSegments[0]) &&
132
+ targetSegments[0] != "api" && targetSegments[0] != "webhooks" &&
133
+ targetSegments[0] != "events" &&
134
+ (len(sourceSegments) == 0 || targetSegments[0] != sourceSegments[0]) {
135
+ score -= 24
136
+ }
137
+ // A general Worker overview is less relevant than Durable Objects'
138
+ // focused setup instructions even if both mention "Worker".
139
+ if strings.Contains(q, "durable object") && strings.Contains(target.Path, "/workers/") &&
140
+ !strings.Contains(target.Path, "/durable-objects/") {
141
+ score -= 14
142
+ }
143
+ return score
144
+ }
145
+
146
+ func normalizedDocumentationURL(raw string) string {
147
+ u, err := url.Parse(raw)
148
+ if err != nil {
149
+ return raw
150
+ }
151
+ p := strings.TrimSuffix(u.Path, "/index.md")
152
+ p = strings.TrimSuffix(p, ".md")
153
+ p = strings.TrimSuffix(p, "/")
154
+ u.Path = p
155
+ u.Fragment = ""
156
+ u.RawFragment = ""
157
+ u.RawPath = ""
158
+ return u.String()
159
+ }
160
+
161
+ func researchCandidates(rawURL, primary, question string, limit int) []sourceCandidate {
162
+ baseCandidates := relatedSourceLinks(primary, rawURL, question, limit*4)
163
+ indexCandidates := linksFromDocsIndexWithPrimary(rawURL, primary, question, limit*5)
164
+ seen := map[string]bool{normalizedDocumentationURL(rawURL): true}
165
+ var candidates []sourceCandidate
166
+ for _, c := range append(baseCandidates, indexCandidates...) {
167
+ key := normalizedDocumentationURL(c.url)
168
+ if seen[key] {
169
+ continue
170
+ }
171
+ seen[key] = true
172
+ c.score += implementationLinkBonus(c, question)
173
+ c.score += sourceScopeBonus(c, rawURL, question)
174
+ candidates = append(candidates, c)
175
+ }
176
+ sort.SliceStable(candidates, func(i, j int) bool {
177
+ if candidates[i].score == candidates[j].score {
178
+ return candidates[i].order < candidates[j].order
179
+ }
180
+ return candidates[i].score > candidates[j].score
181
+ })
182
+ if len(candidates) > limit {
183
+ return candidates[:limit]
184
+ }
185
+ return candidates
186
+ }
187
+
188
+ type evidenceNeed struct {
189
+ name string
190
+ alternatives []string
191
+ }
192
+
193
+ // Check for explicit source evidence; do not claim that a plausible guess
194
+ // supplies missing request fields or code.
195
+ func evidenceNeeds(question string) []evidenceNeed {
196
+ q := strings.ToLower(question)
197
+ var needs []evidenceNeed
198
+ for _, literal := range []string{"line_items", "success_url", "cancel_url", "sql.exec", "wrangler", "sqlite"} {
199
+ if strings.Contains(q, literal) {
200
+ needs = append(needs, evidenceNeed{literal, []string{literal}})
201
+ }
202
+ }
203
+ if strings.Contains(q, "webhook") {
204
+ needs = append(needs, evidenceNeed{"webhook events", []string{"webhook", "checkout.session.completed"}})
205
+ if strings.Contains(q, "signature") || strings.Contains(q, "secure") {
206
+ needs = append(needs, evidenceNeed{"webhook signature verification", []string{"stripe-signature", "constructevent", "signature verification", "signatureverification"}})
207
+ }
208
+ }
209
+ if strings.Contains(q, "sql table") || strings.Contains(q, "creating a sql") {
210
+ needs = append(needs, evidenceNeed{"SQL table creation", []string{"create table"}})
211
+ }
212
+ if strings.Contains(q, "accesses the binding") || strings.Contains(q, "access the binding") {
213
+ needs = append(needs, evidenceNeed{"Worker binding access", []string{"getbyname", "env.my_durable_object", "env.durable_object"}})
214
+ }
215
+ if strings.Contains(q, "checkout") && (strings.Contains(q, "fulfill") || strings.Contains(q, "fulfil")) {
216
+ needs = append(needs, evidenceNeed{"Checkout fulfillment event", []string{"checkout.session.completed"}})
217
+ }
218
+ return needs
219
+ }
220
+
221
+ func missingResearchEvidence(question, text string) []string {
222
+ lower := strings.ToLower(text)
223
+ var missing []string
224
+ for _, need := range evidenceNeeds(question) {
225
+ found := false
226
+ for _, candidate := range need.alternatives {
227
+ if strings.Contains(lower, candidate) {
228
+ found = true
229
+ break
230
+ }
231
+ }
232
+ if !found {
233
+ missing = append(missing, need.name)
234
+ }
235
+ }
236
+ return missing
237
+ }
@@ -0,0 +1,72 @@
1
+ package app
2
+
3
+ import (
4
+ "net/http"
5
+ "strings"
6
+ "testing"
7
+ )
8
+
9
+ func TestStripeFocusedLinksPreferCheckoutAndWebhookDocs(t *testing.T) {
10
+ old := http.DefaultClient
11
+ t.Cleanup(func() { http.DefaultClient = old })
12
+ index := "# Documentation\n\n" +
13
+ "- [Climate order webhooks](https://docs.stripe.com/climate/orders/webhooks.md)\n" +
14
+ "- [Create a Checkout Session](https://docs.stripe.com/api/checkout/sessions/create.md)\n" +
15
+ "- [Build a Stripe-hosted checkout page](https://docs.stripe.com/checkout/quickstart.md)\n" +
16
+ "- [Set up and deploy a webhook](https://docs.stripe.com/webhooks/quickstart.md)\n" +
17
+ "- [Fulfill orders](https://docs.stripe.com/checkout/fulfillment.md)\n" +
18
+ "- [Verify webhook signatures](https://docs.stripe.com/webhooks.md#verify-signature)\n"
19
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
20
+ if req.URL.Path == "/llms.txt" {
21
+ return testHTTPResponse(req, http.StatusOK, index, nil), nil
22
+ }
23
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
24
+ })}
25
+ question := "Implement Stripe-hosted Checkout: create a Checkout Session REST request and securely fulfill orders using webhooks, including signature verification"
26
+ primary := "# Stripe Checkout\n\n[Start building your checkout integration](https://docs.stripe.com/checkout/quickstart.md)"
27
+ got := researchCandidates("https://docs.stripe.com/payments/checkout", primary, question, 5)
28
+ var links []string
29
+ for _, c := range got {
30
+ links = append(links, c.url)
31
+ }
32
+ output := strings.Join(links, "\n")
33
+ for _, want := range []string{
34
+ "/checkout/quickstart.md",
35
+ "/api/checkout/sessions/create.md",
36
+ "/webhooks/quickstart.md",
37
+ "/checkout/fulfillment.md",
38
+ } {
39
+ if !strings.Contains(output, want) {
40
+ t.Errorf("expected %q among selected source links: %s", want, output)
41
+ }
42
+ }
43
+ if strings.Contains(output, "/climate/") {
44
+ t.Errorf("irrelevant Climate-specific webhook outranked Checkout sources: %s", output)
45
+ }
46
+ }
47
+
48
+ func TestCloudflareNewSQLiteQuestionPrefersGettingStarted(t *testing.T) {
49
+ old := http.DefaultClient
50
+ t.Cleanup(func() { http.DefaultClient = old })
51
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
52
+ return testHTTPResponse(req, http.StatusForbidden, "", nil), nil
53
+ })}
54
+ primary := "# Cloudflare Durable Objects\n" +
55
+ "[Durable Object alarms](https://developers.cloudflare.com/durable-objects/api/alarms/)\n" +
56
+ "[SQLite storage](https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/)\n" +
57
+ "[Get started](https://developers.cloudflare.com/durable-objects/get-started/)\n" +
58
+ "> Documentation Index https://developers.cloudflare.com/durable-objects/llms.txt\n"
59
+ question := "For a new Cloudflare Worker using a SQLite-backed Durable Object, show complete wrangler configuration, Worker binding, and SQL table code."
60
+ got := researchCandidates("https://developers.cloudflare.com/durable-objects/", primary, question, 2)
61
+ if len(got) != 2 {
62
+ t.Fatalf("unexpected number of selected references: %+v", got)
63
+ }
64
+ var links []string
65
+ for _, c := range got {
66
+ links = append(links, c.url)
67
+ }
68
+ output := strings.Join(links, "\n")
69
+ if !strings.Contains(output, "/get-started/") || strings.Contains(output, "/alarms/") {
70
+ t.Fatalf("unrelated alarm material displaced getting-started guide: %s", output)
71
+ }
72
+ }
@@ -5,6 +5,7 @@ import (
5
5
  "fmt"
6
6
  "net/http"
7
7
  "strings"
8
+ "sync"
8
9
  "testing"
9
10
  )
10
11
 
@@ -35,8 +36,11 @@ func TestFocusedReadExpandsRelevantSameHostLinks(t *testing.T) {
35
36
  "https://docs.example.com/pagination": "# Pagination\nPass next_cursor to read the next page.",
36
37
  }
37
38
  requested := make(map[string]int)
39
+ var requestedMu sync.Mutex
38
40
  reader := func(url string) (string, error) {
41
+ requestedMu.Lock()
39
42
  requested[url]++
43
+ requestedMu.Unlock()
40
44
  value, ok := urls[url]
41
45
  if !ok {
42
46
  return "", errors.New("missing URL")
@@ -0,0 +1,106 @@
1
+ package app
2
+
3
+ import (
4
+ "fmt"
5
+ "log"
6
+ "net/url"
7
+ "os"
8
+ "strings"
9
+ "time"
10
+ )
11
+
12
+ // CAPTCHA pages can contain thousands of bytes of translated prompts. Page
13
+ // length is not evidence of useful content, so challenge detection must not
14
+ // be gated on the size of the extracted Markdown.
15
+ func challengeReason(markdown string) string {
16
+ text := strings.ToLower(strings.TrimSpace(markdown))
17
+ if text == "" {
18
+ return "empty source"
19
+ }
20
+ prefix := text[:min(len(text), 4000)]
21
+ for _, marker := range []string{
22
+ "just a moment...",
23
+ "checking your browser",
24
+ "access denied | cloudflare",
25
+ "attention required! | cloudflare",
26
+ "enable javascript and cookies",
27
+ "no content extracted",
28
+ "please complete the security check",
29
+ "verify you are human",
30
+ "verifying you are human",
31
+ "verify that you are human",
32
+ "please verify you are a human",
33
+ "we need to verify your browser",
34
+ "challenge-platform",
35
+ } {
36
+ if strings.Contains(prefix, marker) {
37
+ return "browser verification challenge"
38
+ }
39
+ }
40
+ if strings.Contains(prefix, "hcaptcha") || strings.Contains(prefix, "h-captcha") {
41
+ for _, secondary := range []string{
42
+ "select all", "please verify", "i am human",
43
+ "privacy - terms", "please complete", "security check",
44
+ "captcha challenge", "verify your", "please solve",
45
+ } {
46
+ if strings.Contains(text, secondary) {
47
+ return "hCaptcha challenge"
48
+ }
49
+ }
50
+ }
51
+ if (strings.Count(text, "captcha") > 2 || strings.Count(text, "verify you are human") > 0) &&
52
+ (strings.Contains(text, "try again") || strings.Contains(text, "challenge") || strings.Contains(text, "i am human")) {
53
+ return "CAPTCHA challenge"
54
+ }
55
+ return ""
56
+ }
57
+
58
+ func usableSourceContent(markdown string) bool {
59
+ text := strings.TrimSpace(markdown)
60
+ if len(text) < 10 || challengeReason(text) != "" {
61
+ return false
62
+ }
63
+ // Guard against a 200 status containing an HTML block/error page.
64
+ prefix := strings.ToLower(text[:min(len(text), 350)])
65
+ return !strings.Contains(prefix, "<!doctype html") &&
66
+ !strings.Contains(prefix, "<html")
67
+ }
68
+
69
+ // WEBCTX_DEBUG=1 enables stage traces. Only host + URL path are included;
70
+ // query parameters and credentials are intentionally not logged.
71
+ func traceRetrieval(rawURL, stage, outcome string, start time.Time, err error) {
72
+ if os.Getenv("WEBCTX_DEBUG") != "1" {
73
+ return
74
+ }
75
+ source := "invalid-url"
76
+ if u, parseErr := url.Parse(rawURL); parseErr == nil && u.Hostname() != "" {
77
+ source = u.Hostname() + u.EscapedPath()
78
+ }
79
+ detail := ""
80
+ if err != nil {
81
+ detail = fmt.Sprintf(" error=%q", sanitizedRetrievalError(err))
82
+ }
83
+ log.Printf("webctx retrieval source=%q stage=%q outcome=%q duration_ms=%d%s",
84
+ source, stage, outcome, time.Since(start).Milliseconds(), detail)
85
+ }
86
+
87
+ func sanitizedRetrievalError(err error) string {
88
+ if err == nil {
89
+ return ""
90
+ }
91
+ message := strings.ToLower(err.Error())
92
+ switch {
93
+ case strings.Contains(message, "403"), strings.Contains(message, "forbidden"):
94
+ return "forbidden"
95
+ case strings.Contains(message, "429"), strings.Contains(message, "rate limit"):
96
+ return "rate-limited"
97
+ case strings.Contains(message, "timeout"), strings.Contains(message, "deadline"):
98
+ return "timeout"
99
+ case strings.Contains(message, "captcha"), strings.Contains(message, "challenge"):
100
+ return "challenge"
101
+ case strings.Contains(message, "404"), strings.Contains(message, "not found"):
102
+ return "not-found"
103
+ default:
104
+ return "source-unavailable"
105
+ }
106
+ }
@@ -0,0 +1,63 @@
1
+ package app
2
+
3
+ import (
4
+ "bytes"
5
+ "errors"
6
+ "log"
7
+ "strings"
8
+ "testing"
9
+ "time"
10
+ )
11
+
12
+ func TestLongCAPTCHAPayloadCannotPassAsDocumentation(t *testing.T) {
13
+ longChallenge := "hCaptcha - Please verify you are human. " +
14
+ strings.Repeat("Deutsch Français 日本語 中文 Hindi Español English ", 1000)
15
+ if usableScrapedMarkdown(longChallenge) || usableNativeMarkdown(longChallenge) {
16
+ t.Fatal("large CAPTCHA payload must be rejected even when >2KB")
17
+ }
18
+ if challengeReason(longChallenge) != "browser verification challenge" {
19
+ t.Fatalf("missing browser challenge reason: %q", challengeReason(longChallenge))
20
+ }
21
+ }
22
+
23
+ func TestNormalDocumentationAboutCAPTCHAsIsNotAutomaticallyBlocked(t *testing.T) {
24
+ doc := "# hCaptcha integration guide\n\nUse the hCaptcha SDK to add a verification widget to your website.\n" +
25
+ strings.Repeat("Install the client library and validate tokens on your server.\n", 50)
26
+ if !usableScrapedMarkdown(doc) {
27
+ t.Fatal("ordinary documentation about captcha should not be rejected")
28
+ }
29
+ }
30
+
31
+ func TestExplicitEvidenceGapReportsMissingFields(t *testing.T) {
32
+ q := "Create a Checkout Session with line_items, mode, success_url and cancel_url and explain webhook signature verification"
33
+ excerpts := "Use line_items and success_url in a session. Verify webhooks using Stripe-Signature."
34
+ gaps := missingResearchEvidence(q, excerpts)
35
+ if !strings.Contains(strings.Join(gaps, ","), "cancel_url") {
36
+ t.Fatalf("missing required request field was not reported: %+v", gaps)
37
+ }
38
+ }
39
+
40
+ func TestEmbeddedJSONExampleDoesNotBypassExcerptBudget(t *testing.T) {
41
+ doc := "**URL:** https://example.com/guide\n\n# Guide\n" +
42
+ strings.Repeat("This guide describes a public API and its fields.\n", 1000) +
43
+ "\n" + backtickFence + "json\n" + `{"example":{"id":"ok"}}` + "\n" + backtickFence
44
+ out := focusExcerpt(doc, "public API fields", 3500)
45
+ if len(out) > 4000 {
46
+ t.Fatalf("returned source bypassed excerpt budget: size=%d", len(out))
47
+ }
48
+ }
49
+
50
+ func TestRetrievalDebugTraceDoesNotLogCredentials(t *testing.T) {
51
+ original := log.Writer()
52
+ t.Cleanup(func() { log.SetOutput(original) })
53
+ var output bytes.Buffer
54
+ log.SetOutput(&output)
55
+ t.Setenv("WEBCTX_DEBUG", "1")
56
+ traceRetrieval("https://docs.example.com/guide?api_key=secret-value&token=sensitive",
57
+ "browser", "blocked", time.Now(), errors.New("unauthorized token sensitive"))
58
+ result := output.String()
59
+ if !strings.Contains(result, `stage="browser"`) || !strings.Contains(result, `error="source-unavailable"`) ||
60
+ strings.Contains(result, "secret-value") || strings.Contains(result, "sensitive") {
61
+ t.Fatalf("debug trace leaked query parameters or credentials: %s", result)
62
+ }
63
+ }
@@ -2,9 +2,6 @@ package app
2
2
 
3
3
  import (
4
4
  "context"
5
- "fmt"
6
- "io"
7
- "net/http"
8
5
  "os"
9
6
  "os/exec"
10
7
  "path/filepath"
@@ -15,9 +12,10 @@ import (
15
12
  )
16
13
 
17
14
  type MarkdownResult struct {
18
- URL string
19
- Title string
20
- Markdown string
15
+ URL string
16
+ SourceURL string
17
+ Title string
18
+ Markdown string
21
19
  }
22
20
 
23
21
  const keychainServiceName = "webctx"
@@ -29,65 +27,6 @@ var (
29
27
  keychainLookup = lookupKeychainSecret
30
28
  )
31
29
 
32
- func checkMarkdownAvailable(raw string) (bool, error) {
33
- mdURL := raw
34
- if !strings.HasSuffix(strings.ToLower(mdURL), ".md") {
35
- mdURL += ".md"
36
- }
37
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
38
- defer cancel()
39
- req, err := http.NewRequestWithContext(ctx, http.MethodHead, mdURL, nil)
40
- if err != nil {
41
- return false, err
42
- }
43
- resp, err := http.DefaultClient.Do(req)
44
- if err != nil {
45
- return false, err
46
- }
47
- defer resp.Body.Close()
48
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
49
- return false, nil
50
- }
51
- contentType := strings.ToLower(resp.Header.Get("Content-Type"))
52
- contentLength := resp.ContentLength
53
- return (strings.Contains(contentType, "markdown") || strings.Contains(contentType, "text/plain")) && contentLength > 50, nil
54
- }
55
-
56
- func fetchMarkdownContent(raw string) (*MarkdownResult, error) {
57
- mdURL := raw
58
- if !strings.HasSuffix(strings.ToLower(mdURL), ".md") {
59
- mdURL += ".md"
60
- }
61
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
62
- defer cancel()
63
- content, status, err := fetchText(ctx, mdURL)
64
- if err != nil {
65
- return nil, err
66
- }
67
- if status < 200 || status >= 300 {
68
- return nil, fmt.Errorf("Failed to fetch markdown: %d", status)
69
- }
70
- title := firstHeadingOrFallback(content, filepath.Base(raw))
71
- return &MarkdownResult{URL: raw, Title: title, Markdown: content}, nil
72
- }
73
-
74
- func fetchText(ctx context.Context, rawURL string) (string, int, error) {
75
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
76
- if err != nil {
77
- return "", 0, err
78
- }
79
- resp, err := http.DefaultClient.Do(req)
80
- if err != nil {
81
- return "", 0, err
82
- }
83
- defer resp.Body.Close()
84
- body, err := io.ReadAll(resp.Body)
85
- if err != nil {
86
- return "", resp.StatusCode, err
87
- }
88
- return string(body), resp.StatusCode, nil
89
- }
90
-
91
30
  func firstHeadingOrFallback(markdown, fallback string) string {
92
31
  for _, line := range strings.Split(markdown, "\n") {
93
32
  trimmed := strings.TrimSpace(line)
@@ -228,10 +228,20 @@ func selectedJSONFields(raw any, question string, budget int) string {
228
228
  }
229
229
 
230
230
  func focusedRawJSON(markdown, question string, budget int) string {
231
+ // Raw JSON documents have one JSON fence just after the URL. Looking for
232
+ // JSON examples in a long Markdown guide previously allowed the entire
233
+ // preceding guide to escape the excerpt budget.
234
+ if !strings.HasPrefix(strings.TrimSpace(markdown), "**URL:**") {
235
+ return ""
236
+ }
231
237
  begin := strings.Index(markdown, "```json\n")
232
238
  if begin < 0 {
233
239
  return ""
234
240
  }
241
+ prefix := strings.TrimSpace(markdown[:begin])
242
+ if len(prefix) > 400 || strings.Contains(prefix, "\n#") {
243
+ return ""
244
+ }
235
245
  jsonStart := begin + len("```json\n")
236
246
  end := strings.LastIndex(markdown, "\n```")
237
247
  if end <= jsonStart {
@@ -171,12 +171,12 @@ func ReadLink(rawURL string) (string, error) {
171
171
  return direct, nil
172
172
  }
173
173
 
174
- if ok, _ := checkMarkdownAvailable(rawURL); ok {
175
- result, err := fetchMarkdownContent(rawURL)
176
- if err == nil {
177
- return formatReadLink(result.Title, result.URL, result.Markdown), nil
178
- }
174
+ nativeStarted := time.Now()
175
+ if result, err := readNativeMarkdown(rawURL); err == nil && result != nil {
176
+ traceRetrieval(rawURL, "native-markdown", "success", nativeStarted, nil)
177
+ return formatReadLink(result.Title, result.URL, "**Markdown source:** "+result.SourceURL+"\n\n"+result.Markdown), nil
179
178
  }
179
+ traceRetrieval(rawURL, "native-markdown", "miss", nativeStarted, nil)
180
180
 
181
181
  title, markdown, err := scrapeLinkWithFirecrawl(rawURL)
182
182
  if err != nil {
@@ -217,28 +217,37 @@ func scrapeLinkWithFirecrawl(rawURL string) (string, string, error) {
217
217
  // Firecrawl's default auto proxy already tries basic then enhanced when the
218
218
  // transport fails. A second explicit enhanced attempt is only useful when a
219
219
  // scrape responds successfully but returns a challenge/empty document.
220
+ started := time.Now()
220
221
  first, err := firecrawlScrape(rawURL, apiKey, "")
221
222
  if err == nil && usableScrapedMarkdown(first.Markdown) {
223
+ traceRetrieval(rawURL, "firecrawl-auto", "success", started, nil)
222
224
  return first.Title, withSourceTools(first.Markdown, first.ToolSummary), nil
223
225
  }
224
226
  lastError := err
227
+ traceRetrieval(rawURL, "firecrawl-auto", "escalate", started, err)
225
228
  if err == nil {
226
- lastError = fmt.Errorf("Firecrawl returned empty or blocked page content")
229
+ lastError = fmt.Errorf("Firecrawl returned empty or blocked page content: %s", challengeReason(first.Markdown))
230
+ started = time.Now()
227
231
  second, enhancedErr := firecrawlScrape(rawURL, apiKey, "enhanced")
228
232
  if enhancedErr == nil && usableScrapedMarkdown(second.Markdown) {
233
+ traceRetrieval(rawURL, "firecrawl-enhanced", "success", started, nil)
229
234
  return second.Title, withSourceTools(second.Markdown, second.ToolSummary), nil
230
235
  }
236
+ traceRetrieval(rawURL, "firecrawl-enhanced", "escalate", started, enhancedErr)
231
237
  if enhancedErr != nil {
232
238
  lastError = enhancedErr
233
239
  }
234
240
  }
241
+ started = time.Now()
235
242
  title, markdown, browserErr := firecrawlBrowserFallback(rawURL, apiKey)
236
243
  if browserErr == nil && usableScrapedMarkdown(markdown) {
244
+ traceRetrieval(rawURL, "browser", "success", started, nil)
237
245
  return title, markdown, nil
238
246
  }
239
247
  if browserErr == nil {
240
- browserErr = fmt.Errorf("browser produced no usable content")
248
+ browserErr = fmt.Errorf("browser produced no usable content: %s", challengeReason(markdown))
241
249
  }
250
+ traceRetrieval(rawURL, "browser", "blocked", started, browserErr)
242
251
  return "", "", fmt.Errorf("Error reading web page: scrape: %v; browser fallback: %w", lastError, browserErr)
243
252
  }
244
253
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webctx",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Pure Go web search and browsing CLI using Brave, Tavily, Exa, and Firecrawl",
5
5
  "license": "MIT",
6
6
  "author": "amxv",