diff --git a/packages/server-core/src/subscription/index.ts b/packages/server-core/src/subscription/index.ts index ddad4b3..a11c64b 100644 --- a/packages/server-core/src/subscription/index.ts +++ b/packages/server-core/src/subscription/index.ts @@ -12,3 +12,4 @@ export * from "./response-headers"; export * from "./saved-sources"; export * from "./source-import"; export * from "./ssrf-ip"; +export * from "./user-agents"; diff --git a/packages/server-core/src/subscription/source-import.test.ts b/packages/server-core/src/subscription/source-import.test.ts index d337537..312b989 100644 --- a/packages/server-core/src/subscription/source-import.test.ts +++ b/packages/server-core/src/subscription/source-import.test.ts @@ -15,6 +15,11 @@ proxies: password: secret `; +function ssLink(name = "SS Node"): string { + const credential = Buffer.from("aes-128-gcm:secret").toString("base64url"); + return `ss://${credential}@ss.example.com:8388#${encodeURIComponent(name)}`; +} + describe("importSubscriptionFromUrl", () => { it("tries client user agents and keeps supplemental userinfo headers", async () => { const fetchText = vi.fn(async (request: SourceImportTransportRequest): Promise => { @@ -55,6 +60,39 @@ describe("importSubscriptionFromUrl", () => { expect(fetchText).toHaveBeenCalledWith(expect.objectContaining({ userAgent: "mihomo/1.19.24" })); }); + it("continues to Mihomo when the v2rayN response is link-only and may be incomplete", async () => { + const fetchText = vi.fn(async (request: SourceImportTransportRequest): Promise => { + if (request.userAgent.startsWith("v2rayN/")) { + return { ok: true, content: ssLink("v2rayn"), headers: { "content-type": "text/plain" } }; + } + return { + ok: true, + content: [ + "proxies:", + " - name: clash-a", + " type: trojan", + " server: clash-a.example.com", + " port: 443", + " password: secret", + " - name: clash-b", + " type: trojan", + " server: clash-b.example.com", + " port: 443", + " password: secret", + ].join("\n"), + headers: { "content-type": "text/yaml" }, + }; + }); + + const result = await importSubscriptionFromUrl({ url: "https://example.com/sub.yaml" }, { fetchText }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.parsedNodes.map((node) => node.name)).toEqual(["clash-a", "clash-b"]); + expect(fetchText).toHaveBeenCalledWith(expect.objectContaining({ userAgent: "v2rayN/7.20.4" })); + expect(fetchText).toHaveBeenCalledWith(expect.objectContaining({ userAgent: "mihomo/1.19.24" })); + }); + it("returns structured network failures", async () => { const result = await importSubscriptionFromUrl( { url: "https://example.com/sub.yaml" }, diff --git a/packages/server-core/src/subscription/source-import.ts b/packages/server-core/src/subscription/source-import.ts index 40cdda0..e425e29 100644 --- a/packages/server-core/src/subscription/source-import.ts +++ b/packages/server-core/src/subscription/source-import.ts @@ -11,12 +11,8 @@ import { } from "@subboost/core/subscription/import-error"; import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/url-input"; import type { ParseResult, ParsedNode } from "@subboost/core/types/node"; - -export const SUBSCRIPTION_IMPORT_USER_AGENTS = [ - "v2rayN/7.20.4", - "mihomo/1.19.24", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", -] as const; +import { shouldTryClashMetaForV2raynPayload } from "./fetch-profile-heuristics"; +import { SUBSCRIPTION_IMPORT_USER_AGENTS } from "./user-agents"; export type SourceImportPurpose = "content" | "userinfo"; @@ -105,6 +101,22 @@ function isUsableParsedAttempt(attempt: ParsedAttempt): attempt is Extract 0 && !looksLikeClientUpdatePlaceholderNodes(attempt.parsed.nodes); } +function shouldContinueAfterCleanAttempt(params: { + attempt: ParsedAttempt; + currentUserAgent: string; + nextUserAgent?: string; +}): boolean { + const { attempt, currentUserAgent, nextUserAgent } = params; + if (!isUsableParsedAttempt(attempt) || attempt.parsed.errors.length > 0) return true; + if ( + currentUserAgent === SUBSCRIPTION_IMPORT_USER_AGENTS[0] && + nextUserAgent === SUBSCRIPTION_IMPORT_USER_AGENTS[1] + ) { + return shouldTryClashMetaForV2raynPayload(attempt.content, attempt.parsed); + } + return false; +} + function toFailure(attempt: ParsedAttempt | null, fallback = "获取 url 失败"): SourceImportFailure { if (!attempt) { return { @@ -264,14 +276,23 @@ export async function importSubscriptionFromUrl( const userAgents = options.userAgents?.length ? options.userAgents : SUBSCRIPTION_IMPORT_USER_AGENTS; let best: ParsedAttempt | null = null; - for (const userAgent of userAgents) { + for (let index = 0; index < userAgents.length; index += 1) { + const userAgent = userAgents[index]; const attempt = await fetchAndParseWithUserAgent(url, userAgent, { timeoutMs, maxBytes, fetchText: options.fetchText, }); best = pickBetterAttempt(best, attempt); - if (isUsableParsedAttempt(attempt) && attempt.parsed.errors.length === 0) break; + if ( + !shouldContinueAfterCleanAttempt({ + attempt, + currentUserAgent: userAgent, + nextUserAgent: userAgents[index + 1], + }) + ) { + break; + } } if (!best || !best.ok || !isUsableParsedAttempt(best)) { diff --git a/packages/server-core/src/subscription/user-agents.ts b/packages/server-core/src/subscription/user-agents.ts new file mode 100644 index 0000000..fd072d7 --- /dev/null +++ b/packages/server-core/src/subscription/user-agents.ts @@ -0,0 +1,5 @@ +export const SUBSCRIPTION_IMPORT_USER_AGENTS = [ + "v2rayN/7.20.4", + "mihomo/1.19.24", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", +] as const;