diff --git a/scripts/test-lanzou.mts b/scripts/test-lanzou.mts
index 2dc3b9f4..e5f0edf9 100644
--- a/scripts/test-lanzou.mts
+++ b/scripts/test-lanzou.mts
@@ -120,7 +120,7 @@ await test("Lanzou Driver get() 直链与请求头验证(纯 User-Agent,无
try {
(globalThis as any).fetch = async (url: string, init?: RequestInit) => {
const urlStr = String(url)
- if (urlStr.includes("ajaxm.php")) {
+ if (urlStr.includes("ajaxfile.php") || urlStr.includes("ajaxm.php")) {
return new Response(JSON.stringify({
zt: 1,
dom: "https://developer.lanzoui.com",
@@ -161,6 +161,156 @@ await test("Lanzou Driver get() 直链与请求头验证(纯 User-Agent,无
}
})
+// 5. Test 2026-10 改版 /fn 内页(apifile 绝对地址 + 签名)
+await test("Lanzou Driver get() 适配改版 /fn 内页(apifile 绝对地址)", async () => {
+ const driver = new LanzouDriver({
+ type: "url",
+ root_folder_id: "b00test",
+ shareUrl: "https://pan.lanzoui.com",
+ })
+
+ const mockFilePage = `
+
+
sample.zip - 蓝奏云
+ 大小:10.5 M
+
+
+ `
+ // 改版后:下载接口是 apifile 绝对地址,签名放在 wp_sign / ajaxdata
+ const mockIframe = `
+
+
+
+ `
+
+ let posted: { url: string; form: URLSearchParams } | null = null
+ const originalFetch = globalThis.fetch
+ try {
+ (globalThis as any).fetch = async (url: string, init: RequestInit = {}) => {
+ const urlStr = String(url)
+ const method = (init.method || "GET").toUpperCase()
+ if (urlStr.includes("/fn?")) return new Response(mockIframe)
+ if (method === "POST") {
+ posted = {
+ url: urlStr,
+ form: new URLSearchParams(String(init.body || "")),
+ }
+ return new Response(
+ JSON.stringify({
+ zt: 1,
+ dom: "https://developer.lanzoui.com",
+ url: "token_new",
+ }),
+ )
+ }
+ if (urlStr.includes("developer.lanzoui.com/file")) {
+ return new Response(null, {
+ status: 302,
+ headers: { location: "https://cdn.example.com/new.zip" },
+ })
+ }
+ if (urlStr.includes("pan.lanzoui.com/b00test")) {
+ return new Response(mockFilePage)
+ }
+ return new Response("Not Found", { status: 404 })
+ }
+
+ const item = await driver.get("/b00test", "/sample.zip")
+ const got = posted as { url: string; form: URLSearchParams } | null
+ if (!got) throw new Error("下载接口未被调用")
+ if (!got.url.startsWith("https://apifile.woozooo.com/ajaxfile.php?file=987654")) {
+ throw new Error(`下载接口地址错误: ${got.url}`)
+ }
+ if (got.form.get("sign") !== "NEWSIGN") throw new Error("sign 未透传")
+ if (got.form.get("signs") !== "NEWDATA") throw new Error("signs 未透传")
+ if (got.form.get("kd") !== "1") throw new Error("kd 未按改版结构传 1")
+ if (item.raw_url !== "https://cdn.example.com/new.zip") {
+ throw new Error(`直链错误: ${item.raw_url}`)
+ }
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+})
+
+// 6. 旧版 /fn 内页回归:确认改版适配没有破坏原有结构
+await test("Lanzou Driver get() 保持旧版 /fn 内页兼容性", async () => {
+ const driver = new LanzouDriver({
+ type: "url",
+ root_folder_id: "b00test",
+ shareUrl: "https://pan.lanzoui.com",
+ })
+
+ const mockFilePage = `
+
+ legacy.zip - 蓝奏云
+ 大小:1.2 M
+
+
+ `
+ // 旧版:相对路径 + data 对象(改版前一直用的结构)
+ const mockIframe = `
+
+
+
+ `
+
+ let posted: { url: string; form: URLSearchParams } | null = null
+ const originalFetch = globalThis.fetch
+ try {
+ (globalThis as any).fetch = async (url: string, init: RequestInit = {}) => {
+ const urlStr = String(url)
+ const method = (init.method || "GET").toUpperCase()
+ if (urlStr.includes("/fn?")) return new Response(mockIframe)
+ if (method === "POST") {
+ posted = { url: urlStr, form: new URLSearchParams(String(init.body || "")) }
+ return new Response(
+ JSON.stringify({ zt: 1, dom: "https://developer.lanzoui.com", url: "token_old" }),
+ )
+ }
+ if (urlStr.includes("developer.lanzoui.com/file")) {
+ return new Response(null, {
+ status: 302,
+ headers: { location: "https://cdn.example.com/legacy.zip" },
+ })
+ }
+ if (urlStr.includes("pan.lanzoui.com/b00test")) {
+ return new Response(mockFilePage)
+ }
+ return new Response("Not Found", { status: 404 })
+ }
+
+ const item = await driver.get("/b00test", "/legacy.zip")
+ const got = posted as { url: string; form: URLSearchParams } | null
+ if (!got) throw new Error("下载接口未被调用")
+ // 旧版仍应 POST 到分享域相对地址,而不是 apifile
+ if (got.url !== "https://pan.lanzoui.com/ajaxfile.php?file=555") {
+ throw new Error(`旧版下载接口地址错误: ${got.url}`)
+ }
+ if (got.form.get("signs") !== "OLD_AJAX") throw new Error("signs 未透传")
+ if (got.form.get("sign") !== "OLD_SIGN") throw new Error("sign 未透传")
+ if (item.raw_url !== "https://cdn.example.com/legacy.zip") {
+ throw new Error(`直链错误: ${item.raw_url}`)
+ }
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+})
+
// Clean up scratch files
try {
const fs = await import("fs")
diff --git a/src/backend/drivers/lanzou/help.ts b/src/backend/drivers/lanzou/help.ts
index 3c3d275d..56e48e33 100644
--- a/src/backend/drivers/lanzou/help.ts
+++ b/src/backend/drivers/lanzou/help.ts
@@ -117,7 +117,7 @@ export function removeJSComment(data: string): string {
inComment = true
i++
continue
- } else if (nextChar === "/") {
+ } else if (nextChar === "/" && !isUrlDoubleSlash(result)) {
inSingleLineComment = true
i++
continue
@@ -129,6 +129,22 @@ export function removeJSComment(data: string): string {
return result
}
+/**
+ * 判断紧随其后的 '//' 是否为 URL 的一部分(而非行注释)。
+ * 两类已知误伤都要放过:
+ * 1. 'https://' —— scheme 冒号紧跟 '//'(改版后页面大量使用绝对地址);
+ * 2. 协议相对地址 '//'cdn...'' —— 出现在字符串字面量里,前一个已输出字符是引号。
+ * 宁多保留、不误删:多留一段注释文本无害,截断真实 URL 致命。
+ */
+function isUrlDoubleSlash(emitted: string): boolean {
+ return (
+ emitted.endsWith(":") ||
+ emitted.endsWith("'") ||
+ emitted.endsWith('"') ||
+ emitted.endsWith("`")
+ )
+}
+
function unbox(hexStr: string): string {
const box = [
6, 28, 34, 31, 33, 18, 30, 23, 9, 8, 19, 38, 17, 24, 0, 5, 32, 21, 10, 22,
diff --git a/src/backend/drivers/lanzou/util.test.ts b/src/backend/drivers/lanzou/util.test.ts
new file mode 100644
index 00000000..4fbed019
--- /dev/null
+++ b/src/backend/drivers/lanzou/util.test.ts
@@ -0,0 +1,301 @@
+import assert from "node:assert/strict"
+import { afterEach, test } from "node:test"
+
+import { isTrustedLanzouApiUrl, LanzouClient, parseFnPage } from "./util"
+import { removeJSComment } from "./help"
+
+const originalFetch = globalThis.fetch
+
+afterEach(() => {
+ globalThis.fetch = originalFetch
+})
+
+const SHARE_DOMAIN = "https://pan.lanzoui.com"
+const SHARE_ID = "b00test"
+
+const SHARE_PAGE = `测试文件.zip - 蓝奏云
+
+`
+
+/** 2026-10 改版 /fn 内页:下载接口为 apifile 绝对地址 + 签名参数 */
+const FN_PAGE = ``
+
+/** 旧版 /fn 内页:相对路径 + data 对象 */
+const LEGACY_FN_PAGE = ``
+
+interface CapturedPost {
+ url: string
+ form: URLSearchParams
+ headers: Record
+}
+
+/** 安装 fetch 桩:/fn 内页返回 fnPage,下载接口返回 JSON,直链探测 302 到 CDN */
+function installFetch(fnPage: string): {
+ posts: CapturedPost[]
+ gets: string[]
+} {
+ const posts: CapturedPost[] = []
+ const gets: string[] = []
+ globalThis.fetch = (async (input: any, init: any = {}) => {
+ const url = typeof input === "string" ? input : input.url
+ const method = (init.method || "GET").toUpperCase()
+
+ if (url.includes("/fn?")) {
+ gets.push(url)
+ return new Response(fnPage, { status: 200 })
+ }
+ if (method === "POST") {
+ posts.push({
+ url,
+ form: new URLSearchParams(String(init.body || "")),
+ headers: init.headers || {},
+ })
+ return new Response(
+ JSON.stringify({ zt: 1, dom: "https://dev.lanzou.com", url: "abcdef" }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ )
+ }
+ if (url.includes("/file/")) {
+ return new Response(null, {
+ status: 302,
+ headers: { location: "https://cdn.example.com/real.zip" },
+ })
+ }
+ throw new Error(`unexpected request: ${method} ${url}`)
+ }) as typeof fetch
+ return { posts, gets }
+}
+
+function newClient(): LanzouClient {
+ return new LanzouClient({ type: "url", shareUrl: SHARE_DOMAIN })
+}
+
+test("parseFnPage extracts apifile url and sign form", () => {
+ const parsed = parseFnPage(FN_PAGE)
+ assert.ok(parsed)
+ assert.equal(
+ parsed!.url,
+ "https://apifile.woozooo.com/ajaxfile.php?file=1234567",
+ )
+ assert.equal(parsed!.fileId, "1234567")
+ assert.deepEqual(parsed!.form, {
+ action: "downprocess",
+ websignkey: "DATAVALUE",
+ signs: "DATAVALUE",
+ sign: "SIGNVALUE",
+ websign: "2",
+ kd: "1",
+ ves: "1",
+ })
+})
+
+test("parseFnPage returns null for legacy page", () => {
+ assert.equal(parseFnPage(LEGACY_FN_PAGE), null)
+})
+
+test("parseFnPage returns null when sign vars are missing", () => {
+ // 只有 domain1、缺 wp_sign / ajaxdata:不算改版结构,交给旧版兜底
+ const partial = ``
+ assert.equal(parseFnPage(partial), null)
+
+ const noAjaxData = ``
+ assert.equal(parseFnPage(noAjaxData), null)
+})
+
+test("removeJSComment keeps url '//' but strips real comments", () => {
+ const src = [
+ "var a = 'https://apifile.woozooo.com/ajaxfile.php?file=1' // 行注释",
+ "/* 块注释 */ var b = 1;",
+ ].join("\n")
+ const out = removeJSComment(src)
+ assert.ok(out.includes("https://apifile.woozooo.com/ajaxfile.php?file=1"))
+ assert.ok(!out.includes("行注释"))
+ assert.ok(!out.includes("块注释"))
+ assert.ok(out.includes("var b = 1;"))
+})
+
+test("removeJSComment keeps protocol-relative urls inside strings", () => {
+ // 评审 P2:src="//cdn..." 的前两个字符也是 '//' 且前面是引号,不能当行注释截断
+ const src = ` var b = 1 // 真注释`
+ const out = removeJSComment(src)
+ assert.ok(out.includes("//cdn.woozooo.com/x.js"))
+ assert.ok(!out.includes("真注释"))
+ assert.ok(out.includes("var b = 1"))
+})
+
+test("isTrustedLanzouApiUrl only allows https lanzou hosts", () => {
+ // 可信:蓝奏云自家域(含子域)
+ assert.equal(
+ isTrustedLanzouApiUrl("https://apifile.woozooo.com/ajaxfile.php?file=1"),
+ true,
+ )
+ assert.equal(isTrustedLanzouApiUrl("https://user.lanzn.com/fn?x=1"), true)
+ assert.equal(
+ isTrustedLanzouApiUrl("https://pan.lanzoui.com/ajaxfile.php?file=1"),
+ true,
+ )
+ // 不可信:非 https
+ assert.equal(
+ isTrustedLanzouApiUrl("http://apifile.woozooo.com/ajaxfile.php?file=1"),
+ false,
+ )
+ // 不可信:近似域(evilwoozooo.com 不是 .woozooo.com 的子域)
+ assert.equal(
+ isTrustedLanzouApiUrl(
+ "https://apifile.evilwoozooo.com/ajaxfile.php?file=1",
+ ),
+ false,
+ )
+ assert.equal(
+ isTrustedLanzouApiUrl("https://apifile.woozooo.com.evil.com/ajaxfile.php"),
+ false,
+ )
+ // 不可信:userinfo 伪装(apifile.woozooo.com@evil.com 的主机是 evil.com)
+ assert.equal(
+ isTrustedLanzouApiUrl("https://apifile.woozooo.com@evil.com/ajaxfile.php"),
+ false,
+ )
+ // 不可信:loopback / 私网 / 内网元数据地址
+ assert.equal(
+ isTrustedLanzouApiUrl("https://127.0.0.1/ajaxfile.php?file=1"),
+ false,
+ )
+ assert.equal(
+ isTrustedLanzouApiUrl("https://169.254.169.254/ajaxfile.php?file=1"),
+ false,
+ )
+ assert.equal(
+ isTrustedLanzouApiUrl("https://10.0.0.5/ajaxfile.php?file=1"),
+ false,
+ )
+ assert.equal(
+ isTrustedLanzouApiUrl("https://[::1]/ajaxfile.php?file=1"),
+ false,
+ )
+ // 不可信:非法 URL
+ assert.equal(isTrustedLanzouApiUrl("not a url"), false)
+})
+
+test("new /fn structure posts to absolute apifile url with sign form", async () => {
+ const { posts, gets } = installFetch(FN_PAGE)
+ const file = await newClient().getFilesByShareUrl(
+ SHARE_ID,
+ "",
+ SHARE_PAGE,
+ SHARE_DOMAIN,
+ )
+
+ assert.deepEqual(gets, [`${SHARE_DOMAIN}/fn?abcd1234`])
+ assert.equal(posts.length, 1)
+ assert.equal(
+ posts[0].url,
+ "https://apifile.woozooo.com/ajaxfile.php?file=1234567",
+ )
+ assert.equal(posts[0].form.get("action"), "downprocess")
+ assert.equal(posts[0].form.get("websignkey"), "DATAVALUE")
+ assert.equal(posts[0].form.get("signs"), "DATAVALUE")
+ assert.equal(posts[0].form.get("sign"), "SIGNVALUE")
+ assert.equal(posts[0].form.get("websign"), "2")
+ // 与上游 Go 版一致:跨域 POST 的 Referer 回落为 baseUrl
+ assert.equal(posts[0].headers["Referer"], "https://pc.woozooo.com")
+
+ assert.equal(file.name_all, "测试文件.zip")
+ assert.equal(file.url, "https://cdn.example.com/real.zip")
+})
+
+test("legacy /fn structure still posts to share-domain ajaxfile.php", async () => {
+ const { posts } = installFetch(LEGACY_FN_PAGE)
+ const file = await newClient().getFilesByShareUrl(
+ SHARE_ID,
+ "",
+ SHARE_PAGE,
+ SHARE_DOMAIN,
+ )
+
+ assert.equal(posts.length, 1)
+ assert.equal(`${SHARE_DOMAIN}/ajaxfile.php?file=888`, posts[0].url)
+ assert.equal(posts[0].form.get("signs"), "OLDSIGN")
+ assert.equal(file.url, "https://cdn.example.com/real.zip")
+})
+
+test("absolute iframe src is used as-is instead of being prefixed", async () => {
+ const { gets, posts } = installFetch(FN_PAGE)
+ const subDomain = "https://user.lanzn.com"
+ const page = ``
+
+ await newClient().getFilesByShareUrl(SHARE_ID, "", page, subDomain)
+
+ assert.deepEqual(gets, [`${subDomain}/fn?abcd1234`])
+ assert.equal(
+ posts[0].url,
+ "https://apifile.woozooo.com/ajaxfile.php?file=1234567",
+ )
+})
+
+test("legacy markers win when the page carries both legacy and fn structure", async () => {
+ // 优先级约定:旧版严格正则先命中就不再看 /fn 结构(与上游 Go 版一致)
+ const mixed = ``
+
+ const { posts } = installFetch(mixed)
+ await newClient().getFilesByShareUrl(SHARE_ID, "", SHARE_PAGE, SHARE_DOMAIN)
+
+ assert.equal(posts.length, 1)
+ assert.equal(`${SHARE_DOMAIN}/ajaxfile.php?file=777`, posts[0].url)
+ assert.equal(posts[0].form.get("signs"), "OLDSIGN")
+})
+
+test("unrecognized page reports an actionable error instead of 'not find file id'", async () => {
+ const unknown = ``
+
+ installFetch(unknown)
+ await assert.rejects(
+ () =>
+ newClient().getFilesByShareUrl(SHARE_ID, "", SHARE_PAGE, SHARE_DOMAIN),
+ /未能识别下载页结构/,
+ )
+})
+
+test("fn page pointing outside lanzou domains is rejected (SSRF guard)", async () => {
+ // 评审 P1:domain1 由远端页面内容决定,指向非蓝奏云域时必须拒绝,
+ // 且不能发出任何请求( Cookie 不得外送)
+ for (const badHost of [
+ "https://evil.example.com",
+ "https://127.0.0.1",
+ "https://169.254.169.254",
+ "http://apifile.woozooo.com",
+ ]) {
+ const evil = FN_PAGE.replace("https://apifile.woozooo.com", badHost)
+ const { posts } = installFetch(evil)
+ await assert.rejects(
+ () =>
+ newClient().getFilesByShareUrl(SHARE_ID, "", SHARE_PAGE, SHARE_DOMAIN),
+ /可信域名/,
+ )
+ assert.equal(posts.length, 0, `${badHost} 不应发出任何请求`)
+ }
+})
diff --git a/src/backend/drivers/lanzou/util.ts b/src/backend/drivers/lanzou/util.ts
index 42e8d7a5..12d1914e 100644
--- a/src/backend/drivers/lanzou/util.ts
+++ b/src/backend/drivers/lanzou/util.ts
@@ -27,6 +27,90 @@ const LANZOU_SHARE_DOMAINS = [
/** 蓝奏云 Cookie 过期提示文案(cookie 有效期约 15 天) */
const COOKIE_EXPIRED_MSG = "[Lanzou] Cookie 已过期或失效,请在管理后台重新填写 Cookie(有效期约 15 天)"
+/** 旧版分享下载页结构:相对路径 '/ajaxm.php?file=N'(带引号) */
+const LEGACY_FILE_ID_RE = /'\/ajax(?:file|m)\.php\?file=(\d+)'/
+
+/** 2026-10 改版:文件页将下载参数移入 /fn? 内页,接口变为 apifile 绝对地址并携带签名 */
+const FN_DOMAIN_RE = /var\s+domain[12]\s*=\s*'([^']*(?:ajaxfile|ajaxm)\.php\?file=(\d+)[^']*)'/
+const FN_SIGN_RE = /var\s+wp_sign\s*=\s*'([^']*)'/
+const FN_AJAXDATA_RE = /var\s+ajaxdata\s*=\s*'([^']*)'/
+
+/** 新旧结构都不匹配时的提示:说明页面可能又改版了,而不是笼统的"未找到文件 ID" */
+const UNKNOWN_SHARE_PAGE_MSG =
+ "[Lanzou] 未能识别下载页结构(旧版 /ajaxm.php 与 2026-10 的 /fn 结构都不匹配),蓝奏云可能再次改版,需要更新驱动"
+
+/** /fn 页面给出的接口地址不在可信域名内的提示(SSRF / Cookie 外送防护) */
+const UNTRUSTED_API_URL_MSG =
+ "[Lanzou] /fn 页面给出的下载接口地址不在蓝奏云可信域名内,已拒绝请求(防止 SSRF)。" +
+ "如蓝奏云更换接口域名,请更新驱动的可信域名列表"
+
+/**
+ * 下载接口允许的域名后缀(后缀匹配,近似域如 evilwoozooo.com 不会命中)。
+ * /fn 页面给出的绝对地址只允许发往蓝奏云自家域:既防止被篡改/注入的页面把
+ * 服务端请求引向任意主机(SSRF / 内网探测),也保证驱动 Cookie 不会带出蓝奏云域。
+ */
+const TRUSTED_API_HOST_SUFFIXES = [
+ "woozooo.com", // apifile.woozooo.com(2026-10 改版的下载接口)
+ "lanzn.com", // 蓝奏云账号分享子域(xxx.lanzn.com)
+ "lanzoui.com", // 以下为蓝奏云轮换使用的分享/接口域家族
+ "lanzouw.com",
+ "lanzoux.com",
+ "lanzouy.com",
+ "lanzou.com",
+]
+
+/**
+ * 校验 /fn 页面解析出的下载接口地址是否可信。
+ * @returns 仅当协议为 https 且主机属于蓝奏云可信域(精确后缀匹配,含子域)时返回 true;
+ * 非法 URL、非 https、loopback/私网 IP、近似域、userinfo 伪装一律 false
+ */
+export function isTrustedLanzouApiUrl(raw: string): boolean {
+ let url: URL
+ try {
+ url = new URL(raw)
+ } catch {
+ return false
+ }
+ if (url.protocol !== "https:") return false
+ const host = url.hostname.toLowerCase()
+ return TRUSTED_API_HOST_SUFFIXES.some(
+ (s) => host === s || host.endsWith(`.${s}`),
+ )
+}
+
+/**
+ * 从改版后的 /fn? 内页提取下载接口地址与签名表单。
+ * 对应页面 JS:POST domain1 {'action':'downprocess','websignkey':ajaxdata,
+ * 'signs':ajaxdata,'sign':wp_sign,'websign':'2','kd':kdns,'ves':1}
+ * @param pageData /fn 内页 HTML(建议先过 removeNotes)
+ * @returns 解析成功返回 { url, fileId, form };未命中改版结构(缺 domain1/wp_sign/ajaxdata)
+ * 返回 null —— 这不是错误,调用方应回退到旧版解析
+ */
+export function parseFnPage(pageData: string): {
+ url: string
+ fileId: string
+ form: Record
+} | null {
+ const domainMatch = pageData.match(FN_DOMAIN_RE)
+ if (!domainMatch) return null
+ const signMatch = pageData.match(FN_SIGN_RE)
+ const ajaxDataMatch = pageData.match(FN_AJAXDATA_RE)
+ if (!signMatch || !ajaxDataMatch) return null
+ return {
+ url: domainMatch[1],
+ fileId: domainMatch[2],
+ form: {
+ action: "downprocess",
+ websignkey: ajaxDataMatch[1],
+ signs: ajaxDataMatch[1],
+ sign: signMatch[1],
+ websign: "2",
+ kd: "1",
+ ves: "1",
+ },
+ }
+}
+
export class LanzouClient {
private addition: LanzouAddition
private cookie: string = ""
@@ -667,6 +751,31 @@ export class LanzouClient {
return files
}
+ /**
+ * POST 分享下载接口并解析响应。
+ * 旧版走分享域相对地址(ajaxfile.php?file=N),2026-10 改版走 apifile 绝对地址 + 签名表单。
+ * @param referer 省略时使用 baseUrl(与上游 Go 版一致)
+ */
+ private async postShareAjax(
+ url: string,
+ form: Record,
+ referer?: string,
+ ): Promise {
+ const resStr = await this.request(url, "POST", form, referer)
+ let resp: LanzouShareResp
+ try {
+ resp = JSON.parse(resStr)
+ } catch {
+ throw new Error(`[Lanzou] 下载接口响应格式错误: ${resStr}`)
+ }
+ if (resp.zt !== 1) {
+ throw new Error(
+ resp.info || resp.text || `[Lanzou] 提取链接失败 (zt=${resp.zt})`,
+ )
+ }
+ return resp
+ }
+
/**
* 解析单文件分享页面并提取下载直链
*/
@@ -749,7 +858,11 @@ export class LanzouClient {
}
const iframePath = iframeMatch[1]
- const iframeFullUrl = `${shareBaseDomain}${iframePath.startsWith("/") ? "" : "/"}${iframePath}`
+ // 改版后 iframe 可能直接给绝对地址(如 https://xxx.lanzn.com/fn?token),
+ // 此时不能再拼 shareBaseDomain,否则会拼成 https://hosthttps://host/fn?...
+ const iframeFullUrl = /^https?:\/\//i.test(iframePath)
+ ? iframePath
+ : `${shareBaseDomain}${iframePath.startsWith("/") ? "" : "/"}${iframePath}`
const nextPageData = await this.request(
iframeFullUrl,
"GET",
@@ -757,32 +870,49 @@ export class LanzouClient {
sharePageUrl,
)
const cleanNextPage = removeNotes(nextPageData)
- param = htmlJsonToMap(cleanNextPage, cleanNextPage)
- const fileIdMatch =
- cleanNextPage.match(/['"]?\/?ajax(?:file|m)\.php\?file=(\d+)['"]?/) ||
- cleanNextPage.match(/file\s*[:=]\s*['"]?(\d+)['"]?/) ||
- cleanNextPage.match(/file=(\d+)/) ||
- cleanNextPage.match(/var\s+file_id\s*=\s*['"]?(\d+)['"]?/)
- const fileId = fileIdMatch ? fileIdMatch[1] : ""
- if (!fileId) throw new Error("[Lanzou] 未找到文件 ID")
-
- const resStr = await this.request(
- `${shareBaseDomain}/ajaxfile.php?file=${fileId}`,
- "POST",
- param,
- iframeFullUrl,
- )
let resp: LanzouShareResp
- try {
- resp = JSON.parse(resStr)
- } catch {
- throw new Error(`[Lanzou] ajaxfile.php 响应格式错误: ${resStr}`)
- }
- if (resp.zt !== 1) {
- throw new Error(
- resp.info || resp.text || `[Lanzou] 提取链接失败 (zt=${resp.zt})`,
+ const legacyIdMatch = cleanNextPage.match(LEGACY_FILE_ID_RE)
+ if (legacyIdMatch) {
+ // 旧版结构:相对路径 /ajaxm.php?file=N
+ param = htmlJsonToMap(cleanNextPage, cleanNextPage)
+ resp = await this.postShareAjax(
+ `${shareBaseDomain}/ajaxfile.php?file=${legacyIdMatch[1]}`,
+ param,
+ iframeFullUrl,
)
+ } else {
+ const fnPage = parseFnPage(cleanNextPage)
+ if (fnPage) {
+ // 2026-10 改版结构:/fn? 内页携带 apifile 绝对地址与签名参数,
+ // 必须原样 POST 到该地址(签名与 file=N 绑定),不能再拼成分享域相对路径。
+ // 接口地址来自远端页面文本,先做可信域校验(评审 P1:防 SSRF / Cookie 外送)
+ if (!isTrustedLanzouApiUrl(fnPage.url)) {
+ throw new Error(
+ `${UNTRUSTED_API_URL_MSG}(host: ${safeHost(fnPage.url)})`,
+ )
+ }
+ resp = await this.postShareAjax(fnPage.url, fnPage.form)
+ } else {
+ // 兜底:页面变体(无引号 / 无前导斜杠),沿用原有宽松提取。
+ // 注意:不放行裸 `file=(\d+)` / `file: 123` 这类猜测 —— 它们会在 apifile
+ // 绝对地址上"蒙对" file ID 然后发一个注定失败的请求;识别不了就显式失败
+ try {
+ param = htmlJsonToMap(cleanNextPage, cleanNextPage)
+ } catch {
+ throw new Error(UNKNOWN_SHARE_PAGE_MSG)
+ }
+ const fileIdMatch =
+ cleanNextPage.match(/['"]?\/?ajax(?:file|m)\.php\?file=(\d+)['"]?/) ||
+ cleanNextPage.match(/var\s+file_id\s*=\s*['"]?(\d+)['"]?/)
+ const fileId = fileIdMatch ? fileIdMatch[1] : ""
+ if (!fileId) throw new Error(UNKNOWN_SHARE_PAGE_MSG)
+ resp = await this.postShareAjax(
+ `${shareBaseDomain}/ajaxfile.php?file=${fileId}`,
+ param,
+ iframeFullUrl,
+ )
+ }
}
baseUrl = `${resp.dom}/file`