Skip to content

Commit 77b80ad

Browse files
committed
fix(#30): detect CF challenge pages and retry with premium_proxy
When Cloudflare serves a JS challenge interstitial (HTTP 200 but no real content), the existing retry logic was never triggered because status was 200. Crawl would silently return 0 campaigns and fail the sanity check. Fix: isCFChallengePage() detects pages that contain the CF challenge platform script AND are small (<200KB). Real Kickstarter pages are 500KB+. Detected challenge pages are treated as retryable errors, which will eventually escalate to premium_proxy on the 4th attempt. Also adds a parser-level warning log when 0 campaigns are found in a large page, to surface structural changes earlier. Adds unit tests for isCFChallengePage.
1 parent 1f9f52b commit 77b80ad

3 files changed

Lines changed: 78 additions & 2 deletions

File tree

‎backend/internal/service/kickstarter_parser.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package service
33
import (
44
"encoding/json"
55
"fmt"
6+
"log"
67
"regexp"
78
"strconv"
89
"strings"
@@ -45,6 +46,10 @@ func parseDiscoverPageHTML(html string) ([]model.Campaign, error) {
4546
campaigns = parseFromHTMLStructure(doc)
4647
}
4748

49+
if len(campaigns) == 0 && len(html) > 50_000 {
50+
log.Printf("parser: 0 campaigns from %d-byte page — possible HTML structure change or blocked response", len(html))
51+
}
52+
4853
return campaigns, nil
4954
}
5055

‎backend/internal/service/scrapingbee_client.go‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/http"
1010
"net/url"
1111
"strconv"
12+
"strings"
1213
"time"
1314
)
1415

@@ -109,6 +110,15 @@ func (c *ScrapingBeeClient) FetchUsage(ctx context.Context) (*UsageResult, error
109110
return &usage, nil
110111
}
111112

113+
// isCFChallengePage returns true when the response is a Cloudflare interstitial
114+
// challenge rather than real page content. CF challenge pages are small (<200KB)
115+
// and always contain the challenge-platform main.js script. Real Kickstarter
116+
// discover pages are 500KB+ and do not require client-side JS challenge resolution.
117+
func isCFChallengePage(html string) bool {
118+
const cfMarker = "challenge-platform/scripts/jsd/main.js"
119+
return strings.Contains(html, cfMarker) && len(html) < 200_000
120+
}
121+
112122
func (c *ScrapingBeeClient) doRequest(ctx context.Context, targetURL string, useAI bool, aiQuery, aiSelector string, sessionID int) (string, error) {
113123
if err := c.rateLimiter.Acquire(ctx); err != nil {
114124
return "", fmt.Errorf("rate limiter: %w", err)
@@ -195,10 +205,20 @@ func (c *ScrapingBeeClient) doRequest(ctx context.Context, targetURL string, use
195205
return "", fmt.Errorf("read response: %w", err)
196206
}
197207

208+
// Detect Cloudflare JS challenge pages (HTTP 200 but no real content).
209+
// CF challenge pages are small and contain the challenge-platform script.
210+
// Real Kickstarter pages are 500KB+ and contain project data.
211+
bodyStr := string(body)
212+
if isCFChallengePage(bodyStr) {
213+
lastErr = fmt.Errorf("cloudflare challenge page detected (size=%d)", len(body))
214+
log.Printf("ScrapingBee: CF challenge at %s (size=%d, premium=%v), retrying", targetURL, len(body), premiumProxy)
215+
continue
216+
}
217+
198218
credits := resp.Header.Get("Spb-Cost")
199-
log.Printf("ScrapingBee success: url=%s, credits=%s, useAI=%v, premium=%v", targetURL, credits, useAI, premiumProxy)
219+
log.Printf("ScrapingBee success: url=%s, credits=%s, size=%d, useAI=%v, premium=%v", targetURL, credits, len(body), useAI, premiumProxy)
200220

201-
return string(body), nil
221+
return bodyStr, nil
202222
}
203223

204224
return "", fmt.Errorf("failed after 4 attempts: %w", lastErr)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package service
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestIsCFChallengePage(t *testing.T) {
9+
cfMarker := "challenge-platform/scripts/jsd/main.js"
10+
11+
tests := []struct {
12+
name string
13+
html string
14+
expected bool
15+
}{
16+
{
17+
name: "pure CF challenge page (small + marker)",
18+
html: strings.Repeat("x", 10_000) + cfMarker,
19+
expected: true,
20+
},
21+
{
22+
name: "real page with CF bot-management script injected (large)",
23+
html: strings.Repeat("x", 250_000) + cfMarker,
24+
expected: false,
25+
},
26+
{
27+
name: "normal page without CF marker",
28+
html: strings.Repeat("x", 800_000),
29+
expected: false,
30+
},
31+
{
32+
name: "empty page",
33+
html: "",
34+
expected: false,
35+
},
36+
{
37+
name: "small page without CF marker",
38+
html: "<html><body>Not found</body></html>",
39+
expected: false,
40+
},
41+
}
42+
43+
for _, tt := range tests {
44+
t.Run(tt.name, func(t *testing.T) {
45+
got := isCFChallengePage(tt.html)
46+
if got != tt.expected {
47+
t.Errorf("isCFChallengePage() = %v, want %v (html size=%d)", got, tt.expected, len(tt.html))
48+
}
49+
})
50+
}
51+
}

0 commit comments

Comments
 (0)