From aab7d900b06928633f670a6db2ac15086d67f112 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 06:41:53 +0000 Subject: [PATCH] fix(lz): retry folder page with mobile UA after desktop off0 stub Desktop Chrome on Lanzou folder shares often returns an off0 offline shell (~665B, empty title, no filemoreajax). Keep PAGE_HEADERS for the first parseFileList GET and for single-file parse(); only refetch the folder HTML with the shared mobile identity when extraction fails. Co-authored-by: qaiu --- .../main/java/cn/qaiu/parser/impl/LzTool.java | 142 ++++++++++++++---- .../parser/impl/LzToolFolderPageTest.java | 124 +++++++++++++++ 2 files changed, 238 insertions(+), 28 deletions(-) create mode 100644 parser/src/test/java/cn/qaiu/parser/impl/LzToolFolderPageTest.java diff --git a/parser/src/main/java/cn/qaiu/parser/impl/LzTool.java b/parser/src/main/java/cn/qaiu/parser/impl/LzTool.java index d011746..a677ada 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/LzTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/LzTool.java @@ -101,6 +101,13 @@ public class LzTool extends PanBase { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36 """); + /** + * 目录分享移动端身份。桌面 Chrome 拉目录页常返回 off0 空壳(~665B,空 title,无 filemoreajax); + * 仅 parseFileList 抽出列表参数失败时才用这个 UA 重试首屏,单文件 parse() 仍走 PAGE_HEADERS。 + */ + private static final String FOLDER_MOBILE_UA = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/111.0.0.0 Mobile Safari/537.36"; + private static final String DOWN_AJAX_HEADERS = """ Accept: application/json, text/javascript, */*; q=0.01 Accept-Encoding: identity @@ -412,7 +419,7 @@ public class LzTool extends PanBase { } /** 页面里提取出的 ajax 调用:相对路径 + 表单参数。 */ - private record AjaxCall(String path, Map form) { + record AjaxCall(String path, Map form) { MultiMap toForm() { MultiMap m = MultiMap.caseInsensitiveMultiMap(); form.forEach(m::set); @@ -485,7 +492,7 @@ public class LzTool extends PanBase { return new AjaxCall("/" + ajaxPath, data); } - private static AjaxCall extractFolderAjax(String html, String pwd) { + static AjaxCall extractFolderAjax(String html, String pwd) { if (html == null || html.isEmpty()) { return null; } @@ -871,18 +878,68 @@ public class LzTool extends PanBase { complete(downloadUrl); } - /** 目录列表 filemoreajax.php 用移动端 UA。 */ - private static MultiMap folderListHeaders(String referer) { + /** 目录列表 filemoreajax.php 与失败重试首屏共用移动端身份。 */ + private static MultiMap folderMobileIdentity() { MultiMap headers = MultiMap.caseInsensitiveMultiMap(); - headers.set("User-Agent", "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 " - + "(KHTML, like Gecko) Chrome/111.0.0.0 Mobile Safari/537.36"); - headers.set("referer", referer); + headers.set("User-Agent", FOLDER_MOBILE_UA); headers.set("sec-ch-ua-platform", "Android"); headers.set("Accept-Language", "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2"); headers.set("sec-ch-ua-mobile", "?1"); return headers; } + /** 目录页失败重试用的移动端导航头(与 folderListHeaders 同一 UA)。 */ + static MultiMap folderPageHeaders(String referer) { + MultiMap headers = folderMobileIdentity(); + headers.set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); + headers.set("Accept-Encoding", "identity"); + headers.set("Upgrade-Insecure-Requests", "1"); + if (referer != null && !referer.isBlank()) { + headers.set("referer", referer); + } + return headers; + } + + static MultiMap folderListHeaders(String referer) { + MultiMap headers = folderMobileIdentity(); + headers.set("referer", referer); + return headers; + } + + static String desktopPageUserAgent() { + return PAGE_HEADERS.get("User-Agent"); + } + + static String folderMobileUserAgent() { + return FOLDER_MOBILE_UA; + } + + /** + * 桌面 UA 目录页抽不出 filemoreajax(常见是 off0 空壳)时,再用移动 UA 拉一次。 + * 已是真实单文件页则不重试,避免多余请求。 + */ + static boolean shouldRetryFolderPageWithMobileUa(String html, String shareUrl) { + if (isLzOfflineStub(html)) { + return true; + } + if (isLzFolderUrl(shareUrl)) { + return true; + } + return isLzFolderHtml(html); + } + + /** 蓝奏 off0 / off1 下线空壳:桌面 UA 拉目录页时常只有这个,没有 filemoreajax。 */ + static boolean isLzOfflineStub(String html) { + if (html == null || html.isBlank()) { + return true; + } + if (html.contains("class=\"off0\"") || html.contains("class='off0'") + || html.contains("id=\"off0\"") || html.contains("id='off0'") + || html.contains("class=\"off1\"") || html.contains("class='off1'")) { + return true; + } + return P_OFF_MSG.matcher(html).find(); + } @Override public Future> parseFileList() { @@ -891,37 +948,66 @@ public class LzTool extends PanBase { final String sUrl = resolveShareUrl(); final String pwd = shareLinkInfo.getSharePassword(); + // 成功路径仍用桌面 PAGE_HEADERS;抽不出目录参数再换移动 UA 重拉首屏。 getWithArg1Retry(sUrl, PAGE_HEADERS) - .onSuccess(html -> handleFileListParse(html, pwd, sUrl, listPromise)) + .compose(html -> { + AjaxCall call = tryExtractFolderAjaxCall(html, pwd, sUrl); + if (call != null) { + return postFolderList(sUrl, call); + } + if (!shouldRetryFolderPageWithMobileUa(html, sUrl) + && !isLzFolderShare(sUrl, html)) { + return Future.failedFuture(baseMsg() + "该链接为蓝奏云文件分享,请使用文件解析接口"); + } + log.warn("蓝奏目录页桌面 UA 未抽出列表参数,改用移动 UA 重试"); + return getWithArg1Retry(sUrl, folderPageHeaders(sUrl)) + .compose(mobileHtml -> { + AjaxCall mobileCall = tryExtractFolderAjaxCall(mobileHtml, pwd, sUrl); + if (mobileCall == null) { + if (!isLzFolderShare(sUrl, mobileHtml)) { + return Future.failedFuture( + baseMsg() + "该链接为蓝奏云文件分享,请使用文件解析接口"); + } + return Future.failedFuture(baseMsg() + "获取失败1, 可能分享已失效"); + } + return postFolderList(sUrl, mobileCall); + }); + }) + .onSuccess(listPromise::complete) .onFailure(listPromise::fail); return listPromise.future(); } - private void handleFileListParse(String html, String pwd, String sUrl, Promise> listPromise) { - if (!isLzFolderShare(sUrl, html)) { - listPromise.fail(baseMsg() + "该链接为蓝奏云文件分享,请使用文件解析接口"); - return; + private AjaxCall tryExtractFolderAjaxCall(String html, String pwd, String sUrl) { + String webpage = extractWebpage(sUrl, html); + if (webpage != null) { + shareLinkInfo.getOtherParam().put("webpage", webpage); + } + AjaxCall call = extractFolderAjax(html, pwd); + if (call != null) { + return call; + } + if (!isLzFolderShare(sUrl, html) && !isLzFolderHtml(html)) { + return null; } try { - String webpage = extractWebpage(sUrl, html); - if (webpage != null) { - shareLinkInfo.getOtherParam().put("webpage", webpage); - } - AjaxCall call = extractFolderAjax(html, pwd); - if (call == null) { - call = folderAjaxFromJs(html, pwd); - } - log.debug("解析参数: {}", call.form()); - - String url = joinUrl(originOf(sUrl, SHARE_ORIGIN) + "/", call.path()); - postFormWithArg1Retry(url, folderListHeaders(sUrl), call.toForm()) - .onSuccess(body -> handleFileListResponse(body, listPromise)) - .onFailure(listPromise::fail); + return folderAjaxFromJs(html, pwd); } catch (ScriptException | NoSuchMethodException | RuntimeException e) { - listPromise.fail(e); + log.debug("目录页 JS 提取失败: {}", e.getMessage()); + return null; } } + private Future> postFolderList(String sUrl, AjaxCall call) { + log.debug("解析参数: {}", call.form()); + Promise> listPromise = Promise.promise(); + String url = joinUrl(originOf(sUrl, SHARE_ORIGIN) + "/", call.path()); + postFormWithArg1Retry(url, folderListHeaders(sUrl), call.toForm()) + .onSuccess(body -> handleFileListResponse(body, listPromise)) + .onFailure(listPromise::fail); + return listPromise.future(); + } + /** 正则匹配不到 filemoreajax 块时,回退到执行页面 JS 取参数。 */ private AjaxCall folderAjaxFromJs(String html, String pwd) throws ScriptException, NoSuchMethodException { String jsText = getJsByPwd(pwd, html, "var urls =window.location.href"); @@ -958,7 +1044,7 @@ public class LzTool extends PanBase { return path.charAt(0) == 'b' || path.charAt(0) == 'B'; } - private static boolean isLzFolderHtml(String html) { + static boolean isLzFolderHtml(String html) { if (html == null || html.isBlank()) { return false; } diff --git a/parser/src/test/java/cn/qaiu/parser/impl/LzToolFolderPageTest.java b/parser/src/test/java/cn/qaiu/parser/impl/LzToolFolderPageTest.java new file mode 100644 index 0000000..7c47a68 --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/impl/LzToolFolderPageTest.java @@ -0,0 +1,124 @@ +package cn.qaiu.parser.impl; + +import io.vertx.core.MultiMap; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * 蓝奏目录首屏:桌面 UA 易返回 off0 空壳;抽出 filemoreajax 失败后再用移动 UA 重试。 + * 不访问真实蓝奏,只回归请求头选择与空壳/目录页识别。 + */ +public class LzToolFolderPageTest { + + private static final String FOLDER_URL = "https://pan.lanzoux.com/b66477"; + private static final String FILE_URL = "https://wwww.lanzoux.com/iULV2n4361c"; + + /** 桌面 UA 实测会落到的 off0 空壳(空 title,无 filemoreajax)。 */ + private static final String OFF0_STUB = """ + + + + +
+
+
+
+ + + """; + + /** 移动 UA 才能拿到的目录页片段。 */ + private static final String FOLDER_HTML = """ + + + 全网最全软件合集 + +
+ + + + """; + + private static final String FILE_HTML = """ + demo.apk + + + """; + + @Test + public void testFolderRetryHeadersUseMobileUaNotDesktop() { + String desktopUa = LzTool.desktopPageUserAgent(); + String mobileUa = LzTool.folderMobileUserAgent(); + + assertTrue("单文件页仍是桌面 Chrome", desktopUa.contains("Windows NT 10.0")); + assertFalse("单文件页不能改成 Mobile", desktopUa.contains("Mobile")); + assertTrue("目录重试 UA 是 Android Mobile", mobileUa.contains("Android")); + assertTrue(mobileUa.contains("Mobile Safari")); + assertNotEquals(desktopUa, mobileUa); + + MultiMap page = LzTool.folderPageHeaders(FOLDER_URL); + MultiMap ajax = LzTool.folderListHeaders(FOLDER_URL); + assertEquals(mobileUa, page.get("User-Agent")); + assertEquals(mobileUa, ajax.get("User-Agent")); + assertEquals("?1", page.get("sec-ch-ua-mobile")); + assertEquals("Android", page.get("sec-ch-ua-platform")); + assertEquals(FOLDER_URL, page.get("referer")); + } + + @Test + public void testOff0StubDetectedAndTriggersMobileRetry() { + assertTrue(LzTool.isLzOfflineStub(OFF0_STUB)); + assertTrue(LzTool.isLzOfflineStub("")); + assertTrue(LzTool.isLzOfflineStub(null)); + assertFalse(LzTool.isLzOfflineStub(FOLDER_HTML)); + assertFalse(LzTool.isLzFolderHtml(OFF0_STUB)); + assertNull(LzTool.extractFolderAjax(OFF0_STUB, null)); + + assertTrue("off0 空壳应触发移动 UA 重试", + LzTool.shouldRetryFolderPageWithMobileUa(OFF0_STUB, FOLDER_URL)); + assertTrue("文件链接到空壳也值得重试一次", + LzTool.shouldRetryFolderPageWithMobileUa(OFF0_STUB, FILE_URL)); + } + + @Test + public void testRealFolderHtmlExtractsFilemoreajax() { + assertTrue(LzTool.isLzFolderHtml(FOLDER_HTML)); + assertFalse(LzTool.isLzOfflineStub(FOLDER_HTML)); + + LzTool.AjaxCall call = LzTool.extractFolderAjax(FOLDER_HTML, "pwd1"); + assertNotNull(call); + assertEquals("/filemoreajax.php?file=66477", call.path()); + assertEquals("66477", call.form().get("fid")); + assertEquals("TIME_TOKEN", call.form().get("t")); + assertEquals("KEY_TOKEN", call.form().get("k")); + assertEquals("pwd1", call.form().get("pwd")); + } + + @Test + public void testRealFilePageDoesNotRetryMobileUa() { + assertFalse(LzTool.isLzFolderUrl(FILE_URL)); + assertFalse(LzTool.isLzFolderHtml(FILE_HTML)); + assertFalse(LzTool.isLzOfflineStub(FILE_HTML)); + assertFalse("真实单文件页不要为了目录去换移动 UA", + LzTool.shouldRetryFolderPageWithMobileUa(FILE_HTML, FILE_URL)); + assertTrue(LzTool.isLzFolderUrl(FOLDER_URL)); + } +}