Skip to content

Commit e9c2e49

Browse files
committed
fix(scraper): update SuperFlix API host to use the canonical .online domain and improve error handling for non-JSON responses
1 parent 109ede4 commit e9c2e49

6 files changed

Lines changed: 198 additions & 12 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ require (
4141
github.com/enetx/http3 v1.0.7 // indirect
4242
github.com/enetx/iter v0.0.0-20250912135656-f1583323588f // indirect
4343
github.com/go-logfmt/logfmt v0.6.1 // indirect
44-
github.com/klauspost/compress v1.18.5 // indirect
44+
github.com/klauspost/compress v1.18.6 // indirect
4545
github.com/kr/text v0.2.0 // indirect
4646
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
4747
github.com/quic-go/qpack v0.6.0 // indirect

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
9090
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
9191
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
9292
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
93-
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
94-
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
93+
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
94+
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
9595
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
9696
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
9797
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=

internal/api/providers/metadata/metadata.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -703,8 +703,10 @@ func (e *Enricher) buildSeasonMapFromSuperFlix(ctx context.Context, animeName st
703703
return nil
704704
}
705705

706-
// Step 1: Search SuperFlix
707-
searchURL := "https://superflixapi.rest/pesquisar?s=" + url.QueryEscape(cleanName)
706+
// Step 1: Search SuperFlix. Use the canonical .online host directly: the
707+
// legacy .rest host 301-redirects here and Go's http.Client downgrades
708+
// POSTs to GETs across the redirect, which breaks the player API.
709+
searchURL := "https://superflixapi.online/pesquisar?s=" + url.QueryEscape(cleanName)
708710
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
709711
if err != nil {
710712
return nil
@@ -740,13 +742,13 @@ func (e *Enricher) buildSeasonMapFromSuperFlix(ctx context.Context, animeName st
740742
// Step 2: Fetch episode data from player page
741743
// Must include Referer and Sec-Fetch-* headers or SuperFlix returns
742744
// "ACESSO RESTRITO" instead of the actual player page with ALL_EPISODES.
743-
epURL := "https://superflixapi.rest/serie/" + tmdbID
745+
epURL := "https://superflixapi.online/serie/" + tmdbID
744746
req2, err := http.NewRequestWithContext(ctx, "GET", epURL, nil)
745747
if err != nil {
746748
return nil
747749
}
748750
req2.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
749-
req2.Header.Set("Referer", "https://superflixapi.rest/")
751+
req2.Header.Set("Referer", "https://superflixapi.online/")
750752
req2.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
751753
req2.Header.Set("Accept-Language", "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7")
752754
req2.Header.Set("Sec-Fetch-Dest", "iframe")

internal/api/providers/metadata/metadata_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -354,10 +354,10 @@ func TestEnrichAnime_SuperFlixFallback(t *testing.T) {
354354
<div class="card">
355355
<h3>Black Clover</h3>
356356
<button data-msg="Copiar TMDB" data-copy="73223">TMDB</button>
357-
<button data-msg="Copiar Link" data-copy="https://superflixapi.rest/serie/73223">Link</button>
357+
<button data-msg="Copiar Link" data-copy="https://superflixapi.online/serie/73223">Link</button>
358358
</div>
359359
</body></html>`
360-
mock.responses["GET:superflixapi.rest/pesquisar"] = &http.Response{
360+
mock.responses["GET:superflixapi.online/pesquisar"] = &http.Response{
361361
StatusCode: http.StatusOK,
362362
Body: io.NopCloser(strings.NewReader(searchHTML)),
363363
}
@@ -377,7 +377,7 @@ func TestEnrichAnime_SuperFlixFallback(t *testing.T) {
377377
`{"epi_num":"16","title":"ep","air_date":"2021-03-30"}` +
378378
`]};`
379379
playerHTML := `<html><body><script>` + allEpisodes + `</script></body></html>`
380-
mock.responses["GET:superflixapi.rest/serie/73223"] = &http.Response{
380+
mock.responses["GET:superflixapi.online/serie/73223"] = &http.Response{
381381
StatusCode: http.StatusOK,
382382
Body: io.NopCloser(strings.NewReader(playerHTML)),
383383
}

internal/scraper/superflix.go

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ import (
2020
)
2121

2222
const (
23-
SuperFlixBase = "https://superflixapi.rest"
23+
// SuperFlixBase is the canonical SuperFlix host. The legacy
24+
// `superflixapi.rest` host now 301-redirects here; Go's http.Client follows
25+
// the redirect but downgrades the POST to a GET (dropping the body), which
26+
// makes /player/bootstrap return HTML 404 and break JSON decoding.
27+
SuperFlixBase = "https://superflixapi.online"
2428
SuperFlixUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
2529
)
2630

@@ -431,6 +435,10 @@ func (c *SuperFlixClient) Bootstrap(ctx context.Context, tokens *SuperFlixTokens
431435
return nil, fmt.Errorf("failed to read response: %w", err)
432436
}
433437

438+
if err := ensureJSONResponse("bootstrap", resp, body); err != nil {
439+
return nil, err
440+
}
441+
434442
var result struct {
435443
Data struct {
436444
Options []SuperFlixServer `json:"options"`
@@ -477,6 +485,10 @@ func (c *SuperFlixClient) GetSourceURL(ctx context.Context, videoID string, toke
477485
return "", fmt.Errorf("failed to read response: %w", err)
478486
}
479487

488+
if err := ensureJSONResponse("source", resp, body); err != nil {
489+
return "", err
490+
}
491+
480492
var result struct {
481493
Data struct {
482494
VideoURL string `json:"video_url"`
@@ -621,6 +633,10 @@ func (c *SuperFlixClient) GetVideoAPI(ctx context.Context, playerBaseURL, videoH
621633
return "", "", fmt.Errorf("failed to read response: %w", err)
622634
}
623635

636+
if err := ensureJSONResponse("video API", resp, body); err != nil {
637+
return "", "", err
638+
}
639+
624640
var result struct {
625641
SecuredLink string `json:"securedLink"`
626642
VideoSource string `json:"videoSource"`
@@ -768,6 +784,33 @@ func (m *SuperFlixMedia) ToAnimeModel() *models.Anime {
768784
return anime
769785
}
770786

787+
// ensureJSONResponse fails fast when a SuperFlix API endpoint replies with an
788+
// HTML body or a non-2xx status. Without this, callers get the unhelpful
789+
// `invalid character '<' looking for beginning of value` JSON error — which
790+
// hides real causes like the host having moved (the .rest → .online 301 that
791+
// silently downgrades POST → GET) or a Cloudflare/captcha interstitial.
792+
//
793+
// Trust the body, not the Content-Type header. Some upstream players (e.g.
794+
// firevideoplayer.com behind llanfairpwllgwyngy.com) serve real JSON with
795+
// `Content-Type: text/html`, so a header-only check would reject valid
796+
// responses.
797+
func ensureJSONResponse(label string, resp *http.Response, body []byte) error {
798+
trimmed := strings.TrimLeft(string(body), " \t\r\n\ufeff")
799+
looksHTML := len(trimmed) > 0 && trimmed[0] == '<'
800+
801+
if looksHTML {
802+
finalURL := ""
803+
if resp.Request != nil && resp.Request.URL != nil {
804+
finalURL = resp.Request.URL.String()
805+
}
806+
return fmt.Errorf("%s endpoint returned HTML (status %d, url=%q) — provider may have moved or is blocking the request", label, resp.StatusCode, finalURL)
807+
}
808+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
809+
return fmt.Errorf("%s endpoint returned status %d", label, resp.StatusCode)
810+
}
811+
return nil
812+
}
813+
771814
// Helper: split string by separator and trim each part
772815
func splitAndTrim(s, sep string) []string {
773816
parts := strings.Split(s, sep)

internal/scraper/superflix_test.go

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1034,8 +1034,9 @@ func TestGetVideoAPI_NoStreamURL(t *testing.T) {
10341034
func TestGetVideoAPI_InvalidJSON(t *testing.T) {
10351035
t.Parallel()
10361036

1037+
// Non-JSON, non-HTML body still surfaces a JSON decode error.
10371038
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1038-
fmt.Fprint(w, `<html>Error</html>`)
1039+
fmt.Fprint(w, `{not json`)
10391040
}))
10401041
defer srv.Close()
10411042

@@ -1955,3 +1956,143 @@ func TestRegexPatterns(t *testing.T) {
19551956
assert.Equal(t, "https://subs.example.com/pt.vtt", match[2])
19561957
})
19571958
}
1959+
1960+
// =============================================================================
1961+
// Regression tests (added 2026-04-30)
1962+
//
1963+
// Context: SuperFlix moved from `superflixapi.rest` to `superflixapi.online`
1964+
// using a server-side 301 redirect. Go's http.Client follows the redirect but
1965+
// downgrades the POST to a GET (dropping the body), so /player/bootstrap
1966+
// returned an HTML 404 page. The JSON decoder then surfaced the cryptic
1967+
// `invalid character '<' looking for beginning of value`, breaking playback.
1968+
// These tests pin (a) the canonical base URL and (b) that an HTML/non-2xx
1969+
// response from the player API produces a clear, actionable error rather
1970+
// than the cryptic JSON decode error.
1971+
// =============================================================================
1972+
1973+
func TestSuperFlixBase_PointsToOnlineHost_2026_04_30(t *testing.T) {
1974+
t.Parallel()
1975+
// Pinning the canonical host. If this needs to change in the future,
1976+
// also update internal/api/providers/metadata/metadata.go.
1977+
assert.Equal(t, "https://superflixapi.online", SuperFlixBase)
1978+
}
1979+
1980+
func TestBootstrap_HTMLResponseSurfacesActionableError_2026_04_30(t *testing.T) {
1981+
t.Parallel()
1982+
1983+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1984+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
1985+
w.WriteHeader(http.StatusNotFound)
1986+
fmt.Fprint(w, `<!DOCTYPE html><html><head><title>Not Found</title></head><body>404</body></html>`)
1987+
}))
1988+
defer srv.Close()
1989+
1990+
client := newTestSuperFlixClient(srv.URL)
1991+
tokens := &SuperFlixTokens{CSRF: "a", PageToken: "b", ContentID: "1", ContentType: "filme"}
1992+
_, err := client.Bootstrap(context.Background(), tokens)
1993+
1994+
require.Error(t, err)
1995+
// Must NOT leak the cryptic JSON decode error.
1996+
assert.NotContains(t, err.Error(), "invalid character '<'")
1997+
// Must surface the real cause: HTML body with status code in context.
1998+
assert.Contains(t, err.Error(), "bootstrap")
1999+
assert.Contains(t, err.Error(), "HTML")
2000+
assert.Contains(t, err.Error(), "404")
2001+
}
2002+
2003+
func TestGetSourceURL_HTMLResponseSurfacesActionableError_2026_04_30(t *testing.T) {
2004+
t.Parallel()
2005+
2006+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2007+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
2008+
w.WriteHeader(http.StatusForbidden)
2009+
fmt.Fprint(w, `<html><body>blocked</body></html>`)
2010+
}))
2011+
defer srv.Close()
2012+
2013+
client := newTestSuperFlixClient(srv.URL)
2014+
tokens := &SuperFlixTokens{CSRF: "a", PageToken: "b"}
2015+
_, err := client.GetSourceURL(context.Background(), "vid", tokens)
2016+
2017+
require.Error(t, err)
2018+
assert.NotContains(t, err.Error(), "invalid character '<'")
2019+
assert.Contains(t, err.Error(), "source")
2020+
assert.Contains(t, err.Error(), "HTML")
2021+
assert.Contains(t, err.Error(), "403")
2022+
}
2023+
2024+
func TestGetVideoAPI_HTMLResponseSurfacesActionableError_2026_04_30(t *testing.T) {
2025+
t.Parallel()
2026+
2027+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2028+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
2029+
fmt.Fprint(w, `<html><body>captcha</body></html>`)
2030+
}))
2031+
defer srv.Close()
2032+
2033+
client := newTestSuperFlixClient(srv.URL)
2034+
_, _, err := client.GetVideoAPI(context.Background(), srv.URL, "hash", srv.URL+"/")
2035+
2036+
require.Error(t, err)
2037+
assert.NotContains(t, err.Error(), "invalid character '<'")
2038+
assert.Contains(t, err.Error(), "video API")
2039+
assert.Contains(t, err.Error(), "HTML")
2040+
}
2041+
2042+
// Some upstream players (firevideoplayer.com behind llanfairpwllgwyngy.com)
2043+
// serve real JSON with `Content-Type: text/html; charset=utf-8`. Trusting the
2044+
// header alone would reject these valid responses. The body sniff is the
2045+
// source of truth.
2046+
func TestGetVideoAPI_AcceptsJSONBodyWithHTMLContentType_2026_04_30(t *testing.T) {
2047+
t.Parallel()
2048+
2049+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2050+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
2051+
fmt.Fprint(w, `{"hls":true,"securedLink":"https://example.com/master.m3u8","videoSource":"https://example.com/master.txt","videoImage":"https://example.com/thumb.jpg"}`)
2052+
}))
2053+
defer srv.Close()
2054+
2055+
client := newTestSuperFlixClient(srv.URL)
2056+
streamURL, thumb, err := client.GetVideoAPI(context.Background(), srv.URL, "hash", srv.URL+"/")
2057+
2058+
require.NoError(t, err)
2059+
assert.Equal(t, "https://example.com/master.m3u8", streamURL)
2060+
assert.Equal(t, "https://example.com/thumb.jpg", thumb)
2061+
}
2062+
2063+
func TestBootstrap_AcceptsJSONBodyWithHTMLContentType_2026_04_30(t *testing.T) {
2064+
t.Parallel()
2065+
2066+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2067+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
2068+
fmt.Fprint(w, `{"data":{"options":[{"ID":"sv1","name":"Server 1"}]}}`)
2069+
}))
2070+
defer srv.Close()
2071+
2072+
client := newTestSuperFlixClient(srv.URL)
2073+
tokens := &SuperFlixTokens{CSRF: "a", PageToken: "b", ContentID: "1", ContentType: "filme"}
2074+
servers, err := client.Bootstrap(context.Background(), tokens)
2075+
2076+
require.NoError(t, err)
2077+
require.Len(t, servers, 1)
2078+
assert.Equal(t, "Server 1", servers[0].Name)
2079+
}
2080+
2081+
func TestEnsureJSONResponse_BlankBodyWithBadStatus_2026_04_30(t *testing.T) {
2082+
t.Parallel()
2083+
2084+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
2085+
w.Header().Set("Content-Type", "application/json")
2086+
w.WriteHeader(http.StatusInternalServerError)
2087+
// Empty body — JSON decode would also fail with EOF; ensure the
2088+
// status-code path produces a useful error first.
2089+
}))
2090+
defer srv.Close()
2091+
2092+
client := newTestSuperFlixClient(srv.URL)
2093+
tokens := &SuperFlixTokens{CSRF: "a", PageToken: "b", ContentID: "1", ContentType: "filme"}
2094+
_, err := client.Bootstrap(context.Background(), tokens)
2095+
2096+
require.Error(t, err)
2097+
assert.Contains(t, err.Error(), "500")
2098+
}

0 commit comments

Comments
 (0)