Files
FlClash/core/service_rules.go
chen08209 2fa2f7f816 feat(core): bind the Android Core with gomobile
Changelog: Share link import and mrs rule sets
2026-10-11 12:28:52 +08:00

469 lines
15 KiB
Go

package core
import (
"encoding/json"
"net/url"
"regexp"
"slices"
"strings"
"github.com/metacubex/http"
)
// disneyAuthHeader is the public browser client token Disney+ hands to any web
// visitor; the device and token calls below are rejected without it.
const disneyAuthHeader = "Bearer ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84"
var (
youTubeRegionPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)id=["']country-code["'][^>]*>\s*([A-Za-z]{2,3})\s*<`),
regexp.MustCompile(`"GL"\s*:\s*"([A-Za-z]{2})"`),
regexp.MustCompile(`"countryCode"\s*:\s*"([A-Za-z]{2})"`),
regexp.MustCompile(`"country_code"\s*:\s*"([A-Za-z]{2})"`),
}
tikTokRegionPattern = regexp.MustCompile(`"webapp\.app-context"\s*:\s*\{[^{}]*?"region"\s*:\s*"([^"]*)"`)
primeRegionPattern = regexp.MustCompile(`"currentTerritory":"([^"]+)"`)
disneyAssertionPattern = regexp.MustCompile(`"assertion"\s*:\s*"([^"]+)"`)
disneyRefreshPattern = regexp.MustCompile(`"refresh_token"\s*:\s*"([^"]+)"`)
disneyCountryPattern = regexp.MustCompile(`"countryCode"\s*:\s*"([^"]+)"`)
disneySupportedPattern = regexp.MustCompile(`"inSupportedLocation"\s*:\s*(false|true)`)
spotifyCountryPattern = regexp.MustCompile(`"countryCode"\s*:\s*"([^"]+)"`)
discordCountryPattern = regexp.MustCompile(`"country_code"\s*:\s*"([^"]+)"`)
geminiRegionMarker = `,2,1,200,"`
claudeBlockedRegions = []string{"AF", "BY", "CN", "CU", "HK", "IR", "KP", "MO", "RU", "SY"}
geminiBlockedRegions = []string{"CHN", "RUS", "BLR", "CUB", "IRN", "PRK", "SYR", "HKG", "MAC"}
// A playable episode runs past the small body cap; only its leading code is read.
bilibiliCodePattern = regexp.MustCompile(`^\s*\{\s*"code"\s*:\s*(-?\d+)`)
)
func firstSubmatch(pattern *regexp.Regexp, body string) string {
match := pattern.FindStringSubmatch(body)
if len(match) < 2 {
return ""
}
return match[1]
}
func checkClaude(env serviceEnv) ServiceCheckItem {
result := env.get("https://claude.ai/cdn-cgi/trace", serviceSmallMaxBody)
item := itemFrom(result)
if !answered(item, result) {
return item
}
region := normalizeRegion(traceValue(result.Body, "loc"))
if region == "" {
item.Status = serviceFailed
return item
}
item.Region = region
item.Status = serviceAvailable
if slices.Contains(claudeBlockedRegions, region) {
item.Status = serviceUnsupportedRegion
}
return item
}
func checkGemini(env serviceEnv) ServiceCheckItem {
result := env.scan("https://gemini.google.com", func(tail string) bool {
index := strings.Index(tail, geminiRegionMarker)
return index >= 0 && index+len(geminiRegionMarker)+3 <= len(tail)
})
item := itemFrom(result)
if !answered(item, result) {
return item
}
index := strings.Index(result.Body, geminiRegionMarker)
if index < 0 || index+len(geminiRegionMarker)+3 > len(result.Body) {
item.Status = serviceFailed
return item
}
start := index + len(geminiRegionMarker)
code := result.Body[start : start+3]
if !isAsciiUpper(code) {
item.Status = serviceFailed
return item
}
item.Region = normalizeRegion(code)
item.Status = serviceAvailable
if slices.Contains(geminiBlockedRegions, code) {
item.Status = serviceUnsupportedRegion
}
return item
}
// checkChatGpt folds Verge's separate iOS and Web results into one entry: the
// card shows a single row per service, and a split would put two identical
// OpenAI icons next to each other.
func checkChatGpt(env serviceEnv) ServiceCheckItem {
ios := env.get("https://ios.chat.openai.com/", serviceSmallMaxBody)
item := itemFrom(ios)
if answered(item, ios) {
item.Status = chatGptIosStatus(ios)
}
trace := env.get("https://chat.openai.com/cdn-cgi/trace", serviceSmallMaxBody)
if traceItem := itemFrom(trace); answered(traceItem, trace) {
item.Region = normalizeRegion(traceValue(trace.Body, "loc"))
}
web := env.get("https://api.openai.com/compliance/cookie_requirements", serviceSmallMaxBody)
if webItem := itemFrom(web); answered(webItem, web) &&
bodyContains(web, "unsupported_country") {
item.Status = serviceUnsupportedRegion
}
return item
}
func chatGptIosStatus(result *ProbeResult) string {
switch {
case bodyContains(result, "you may be connected to a disallowed isp"):
return serviceDisallowedIsp
case bodyContains(result, "sorry, you have been blocked"):
return serviceBlocked
case bodyContains(result, "request is not allowed. please try again later."):
// Being turned away as an API client is what a reachable region looks
// like here; anything else means the request never got that far.
return serviceAvailable
default:
return serviceFailed
}
}
func checkYouTubePremium(env serviceEnv) ServiceCheckItem {
result := env.get("https://www.youtube.com/premium?hl=en", serviceHtmlMaxBody)
item := itemFrom(result)
if !answered(item, result) {
return item
}
for _, pattern := range youTubeRegionPatterns {
if code := firstSubmatch(pattern, result.Body); code != "" {
item.Region = normalizeRegion(code)
break
}
}
switch {
case bodyContains(result,
"youtube premium is not available in your country",
"premium is not available in your country",
"premium is not available in your region"):
item.Status = serviceUnsupportedRegion
case result.StatusCode >= 200 && result.StatusCode < 300 &&
bodyContains(result, "youtube premium", "ad-free", `"browseid":"spunlimited"`):
item.Status = serviceAvailable
default:
item.Status = statusFromCode(result.StatusCode)
}
return item
}
func checkSpotify(env serviceEnv) ServiceCheckItem {
result := env.get(
"https://www.spotify.com/api/content/v1/country-selector?platform=web&format=json",
serviceSmallMaxBody,
)
item := itemFrom(result)
if !answered(item, result) {
return item
}
item.Region = normalizeRegion(spotifyRegion(result))
switch {
case result.StatusCode == http.StatusForbidden ||
result.StatusCode == http.StatusUnavailableForLegalReasons:
item.Status = serviceUnsupportedRegion
case result.StatusCode < 200 || result.StatusCode >= 300:
item.Status = statusFromCode(result.StatusCode)
case bodyContains(result, "not available in your country"):
item.Status = serviceUnsupportedRegion
default:
item.Status = serviceAvailable
}
return item
}
// spotifyRegion prefers the country Spotify redirected the request into, and
// falls back to the one named in the payload.
func spotifyRegion(result *ProbeResult) string {
if final, err := url.Parse(result.Url); err == nil {
segments := strings.Split(strings.Trim(final.Path, "/"), "/")
if first := segments[0]; first != "" && first != "api" {
return strings.Split(first, "-")[0]
}
}
return firstSubmatch(spotifyCountryPattern, result.Body)
}
// TikTok is not behind Cloudflare: /cdn-cgi/trace redirects to a 404 page.
func checkTikTok(env serviceEnv) ServiceCheckItem {
result := env.scan("https://www.tiktok.com/", tikTokRegionPattern.MatchString)
item := itemFrom(result)
if !answered(item, result) {
return item
}
item.Status = tikTokStatus(result)
item.Region = tikTokRegion(result.Body)
return item
}
func tikTokStatus(result *ProbeResult) string {
switch {
case result.StatusCode == http.StatusForbidden ||
result.StatusCode == http.StatusUnavailableForLegalReasons:
return serviceUnsupportedRegion
case result.StatusCode < 200 || result.StatusCode >= 300:
return serviceFailed
case bodyContains(result, "access denied", "not available in your region", "tiktok is not available"):
return serviceUnsupportedRegion
default:
return serviceAvailable
}
}
// The page names its serving cluster "region" too ("EU-TTP2"); the visitor's
// country is the one in the web app's context.
func tikTokRegion(body string) string {
return normalizeRegion(firstSubmatch(tikTokRegionPattern, body))
}
func checkBilibili(env serviceEnv) ServiceCheckItem {
const mainland = "https://api.bilibili.com/pgc/player/web/playurl?avid=82846771&qn=0&type=&otype=json&ep_id=307247&fourk=1&fnver=0&fnval=16&module=bangumi"
const hongKongMacauTaiwan = "https://api.bilibili.com/pgc/player/web/playurl?avid=18281381&cid=29892777&qn=0&type=&otype=json&ep_id=183799&fourk=1&fnver=0&fnval=16&module=bangumi"
item := bilibiliEpisode(env, mainland)
switch item.Status {
case serviceAvailable:
item.Region = "CN"
return item
case serviceUnsupportedRegion:
return bilibiliEpisode(env, hongKongMacauTaiwan)
default:
return item
}
}
func bilibiliEpisode(env serviceEnv, url string) ServiceCheckItem {
result := env.get(url, serviceSmallMaxBody)
item := itemFrom(result)
if !answered(item, result) {
return item
}
item.Status = bilibiliStatus(result.Body)
return item
}
func bilibiliStatus(body string) string {
switch firstSubmatch(bilibiliCodePattern, body) {
case "0":
return serviceAvailable
case "-10403":
return serviceUnsupportedRegion
default:
return serviceFailed
}
}
func checkDiscord(env serviceEnv) ServiceCheckItem {
result := env.get("https://discord.com/api/v9/auth/location-metadata", serviceSmallMaxBody)
item := itemFrom(result)
if !answered(item, result) {
return item
}
item.Status = statusFromCode(result.StatusCode)
if item.Status == serviceAvailable {
item.Region = normalizeRegion(firstSubmatch(discordCountryPattern, result.Body))
}
return item
}
func checkPrimeVideo(env serviceEnv) ServiceCheckItem {
result := env.scan("https://www.primevideo.com", func(tail string) bool {
return strings.Contains(tail, "isServiceRestricted") || primeRegionPattern.MatchString(tail)
})
item := itemFrom(result)
if !answered(item, result) {
return item
}
if strings.Contains(result.Body, "isServiceRestricted") {
item.Status = serviceUnsupportedRegion
return item
}
region := firstSubmatch(primeRegionPattern, result.Body)
if region == "" {
item.Status = serviceFailed
return item
}
item.Region = normalizeRegion(region)
item.Status = serviceAvailable
return item
}
func checkNetflix(env serviceEnv) ServiceCheckItem {
if item := netflixFromCdn(env); item.Status == serviceAvailable || item.Status == serviceBlocked {
return item
}
first := env.get("https://www.netflix.com/title/81280792", 0)
firstItem := itemFrom(first)
if !answered(firstItem, first) {
return firstItem
}
second := env.get("https://www.netflix.com/title/70143836", 0)
secondItem := itemFrom(second)
if !answered(secondItem, second) {
return secondItem
}
item := firstItem
switch {
case first.StatusCode == http.StatusNotFound && second.StatusCode == http.StatusNotFound:
item.Status = serviceOriginalsOnly
case first.StatusCode == http.StatusForbidden || second.StatusCode == http.StatusForbidden:
item.Status = serviceUnsupportedRegion
case netflixServed(first.StatusCode) || netflixServed(second.StatusCode):
item.Status = serviceAvailable
item.Region = netflixRegion(env)
default:
item.Status = statusFromCode(first.StatusCode)
}
return item
}
func netflixServed(code int) bool {
return code == http.StatusOK || code == http.StatusMovedPermanently
}
// netflixRegion reads the country out of the redirect Netflix would send a
// visitor to, so the redirect must not be followed.
func netflixRegion(env serviceEnv) string {
result := env.send(probeRequest{
method: http.MethodGet,
url: "https://www.netflix.com/title/80018499",
noRedirect: true,
})
if result == nil || result.Error != "" || result.header == nil {
return ""
}
segments := strings.Split(result.header.Get("Location"), "/")
if len(segments) < 4 {
return ""
}
return normalizeRegion(strings.Split(segments[3], "-")[0])
}
func netflixFromCdn(env serviceEnv) ServiceCheckItem {
const url = "https://api.fast.com/netflix/speedtest/v2?https=true&token=YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm&urlCount=5"
result := env.get(url, serviceSmallMaxBody)
item := itemFrom(result)
if !answered(item, result) {
return item
}
if result.StatusCode == http.StatusForbidden {
item.Status = serviceBlocked
return item
}
var payload struct {
Targets []struct {
Location struct {
Country string `json:"country"`
} `json:"location"`
} `json:"targets"`
}
if err := json.Unmarshal([]byte(result.Body), &payload); err != nil || len(payload.Targets) == 0 {
item.Status = serviceFailed
return item
}
item.Region = normalizeRegion(payload.Targets[0].Location.Country)
item.Status = serviceAvailable
return item
}
// checkDisneyPlus walks the browser hand-shake: register a device, exchange the
// assertion for a refresh token, then ask the session which region it landed
// in. Any step can answer on its own that the address is not welcome.
func checkDisneyPlus(env serviceEnv) ServiceCheckItem {
device := env.send(probeRequest{
method: http.MethodPost,
url: "https://disney.api.edge.bamgrid.com/devices",
headers: map[string]string{
"authorization": disneyAuthHeader,
"content-type": "application/json; charset=UTF-8",
},
body: []byte(`{"deviceFamily":"browser","applicationRuntime":"chrome","deviceProfile":"windows","attributes":{}}`),
maxBody: serviceSmallMaxBody,
})
item := itemFrom(device)
if !answered(item, device) {
return item
}
if device.StatusCode == http.StatusForbidden {
item.Status = serviceBlocked
return item
}
assertion := firstSubmatch(disneyAssertionPattern, device.Body)
if assertion == "" {
item.Status = serviceFailed
return item
}
form := strings.NewReplacer("{assertion}", url.QueryEscape(assertion)).Replace(
"grant_type=urn:ietf:params:oauth:grant-type:token-exchange" +
"&latitude=0&longitude=0&platform=browser" +
"&subject_token={assertion}" +
"&subject_token_type=urn:bamtech:params:oauth:token-type:device",
)
token := env.send(probeRequest{
method: http.MethodPost,
url: "https://disney.api.edge.bamgrid.com/token",
headers: map[string]string{
"authorization": disneyAuthHeader,
"content-type": "application/x-www-form-urlencoded",
},
body: []byte(form),
maxBody: serviceSmallMaxBody,
})
if tokenItem := itemFrom(token); !answered(tokenItem, token) {
return tokenItem
}
if strings.Contains(token.Body, "forbidden-location") || strings.Contains(token.Body, "403 ERROR") {
item.Status = serviceBlocked
return item
}
refresh := firstSubmatch(disneyRefreshPattern, token.Body)
if refresh == "" {
item.Status = serviceFailed
return item
}
query := `{"query":"mutation refreshToken($input: RefreshTokenInput!) { refreshToken(refreshToken: $input) { activeSession { sessionId } } }","variables":{"input":{"refreshToken":"` +
refresh + `"}}}`
session := env.send(probeRequest{
method: http.MethodPost,
url: "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql",
headers: map[string]string{
"authorization": disneyAuthHeader,
"content-type": "application/json",
},
body: []byte(query),
maxBody: serviceSmallMaxBody,
})
sessionItem := itemFrom(session)
if !answered(sessionItem, session) || session.Body == "" || session.StatusCode >= 400 {
item.Status = serviceFailed
return item
}
region := normalizeRegion(firstSubmatch(disneyCountryPattern, session.Body))
if region == "" {
item.Status = serviceUnavailable
return item
}
item.Region = region
switch firstSubmatch(disneySupportedPattern, session.Body) {
case "true":
item.Status = serviceAvailable
case "false":
item.Status = serviceComingSoon
default:
item.Status = serviceFailed
}
return item
}