From 2f84a59d483656fc0506b3e69d100407328b0c6e Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:31:44 +0800 Subject: [PATCH 1/2] fix: retry clash import after link-only v2rayn response --- .../src/subscription/source-import.test.ts | 38 +++++++++++++++++++ .../src/subscription/source-import.ts | 30 ++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) 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..56f3f51 100644 --- a/packages/server-core/src/subscription/source-import.ts +++ b/packages/server-core/src/subscription/source-import.ts @@ -11,6 +11,7 @@ import { } from "@subboost/core/subscription/import-error"; import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/url-input"; import type { ParseResult, ParsedNode } from "@subboost/core/types/node"; +import { shouldTryClashMetaForV2raynPayload } from "./fetch-profile-heuristics"; export const SUBSCRIPTION_IMPORT_USER_AGENTS = [ "v2rayN/7.20.4", @@ -105,6 +106,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 +281,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)) { From ff473d066ed378917857321a66947b92bc558aad Mon Sep 17 00:00:00 2001 From: RyanVan <150385913+Ryson-32@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:40:12 +0800 Subject: [PATCH 2/2] refactor: share subscription import user agents --- packages/server-core/src/subscription/index.ts | 1 + packages/server-core/src/subscription/source-import.ts | 7 +------ packages/server-core/src/subscription/user-agents.ts | 5 +++++ 3 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 packages/server-core/src/subscription/user-agents.ts 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.ts b/packages/server-core/src/subscription/source-import.ts index 56f3f51..e425e29 100644 --- a/packages/server-core/src/subscription/source-import.ts +++ b/packages/server-core/src/subscription/source-import.ts @@ -12,12 +12,7 @@ import { import { tryNormalizeSubscriptionUrlInput } from "@subboost/core/subscription/url-input"; import type { ParseResult, ParsedNode } from "@subboost/core/types/node"; import { shouldTryClashMetaForV2raynPayload } from "./fetch-profile-heuristics"; - -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 { SUBSCRIPTION_IMPORT_USER_AGENTS } from "./user-agents"; export type SourceImportPurpose = "content" | "userinfo"; 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;