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 4c6333b..d011746 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/LzTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/LzTool.java @@ -17,19 +17,17 @@ import io.vertx.ext.web.client.HttpResponse; import io.vertx.ext.web.client.WebClient; import io.vertx.ext.web.client.WebClientOptions; import io.vertx.ext.web.client.WebClientSession; -import org.apache.commons.lang3.StringUtils; import org.openjdk.nashorn.api.scripting.ScriptObjectMirror; import javax.script.ScriptException; import java.io.ByteArrayInputStream; -import java.net.MalformedURLException; +import java.net.URL; import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.zip.GZIPInputStream; @@ -41,90 +39,138 @@ import java.util.zip.GZIPInputStream; */ public class LzTool extends PanBase { - /** ESA 对 gzip 响应常见不带可识别的 Content-Encoding,需客户端自动解压。 */ - private final WebClient lzClient; - private WebClientSession webClientSession; + private final WebClientSession webClientSession; - public static final String SHARE_URL_PREFIX = "https://w1.lanzn.com/"; - - // 静态编译的正则表达式,避免每次调用都重新编译 - private static final Pattern FILE_NAME_PATTERN = Pattern.compile("padding: 56px 0px 20px 0px;\">(.*?)<|filenajax\">(.*?)<"); + /** 实测服务器出口 IP 上 wwww.lanzoux.com 可用,且个性域名目录内文件 ID 也能打开。 */ + public static final String SHARE_URL_PREFIX = "https://wwww.lanzoux.com/"; + private static final String SHARE_ORIGIN = "https://wwww.lanzoux.com"; + /** 分享页 / ajax 请求超时 */ + private static final long REQUEST_TIMEOUT = 8000; + /** 下载域跳转与二次验证超时,链路更长 */ + private static final long DOWNLOAD_TIMEOUT = 10000; + private static final Pattern IFRAME_FN = Pattern.compile( + "src\\s*=\\s*[\"'](/fn\\?[^\"'\\s>]+)[\"']", Pattern.CASE_INSENSITIVE); private static final Pattern P_WP_SIGN = Pattern.compile("wp_sign\\s*=\\s*'([^']+)'"); private static final Pattern P_AJAXDATA = Pattern.compile("ajaxdata\\s*=\\s*'([^']+)'"); private static final Pattern P_WEBSIGN = Pattern.compile("'websign'\\s*:\\s*'([^']*)'"); + private static final Pattern P_WEBPAGE = Pattern.compile("[?&]webpage=([^&\"'\\s#]+)"); private static final Pattern P_AJAX_PATH = Pattern.compile("(?:['\"/]|^)(ajax(?:m|file)\\.php\\?file=\\d+)"); private static final Pattern P_SIGN = Pattern.compile("'sign'\\s*:\\s*'([^']+)'"); private static final Pattern P_ISNGIS = Pattern.compile("var\\s+isngis\\s*=\\s*'([^']+)'"); - private static final Pattern P_KDNS = Pattern.compile("var\\s+kdns\\s*=\\s*(\\d+)"); + private static final Pattern P_VERIFY_FILE = Pattern.compile("'file'\\s*:\\s*'([^']+)'"); + /** 蓝奏「取消分享 / 地址错误」提示页 */ + private static final Pattern P_OFF_MSG = Pattern.compile("class=\"off1\">\\s*\\s*([^<]{2,80})<"); + /** ESA 反爬挑战页特征 */ + private static final String ARG1_MARK = "var arg1='"; private static final Pattern P_FILEMORE = Pattern.compile( "url\\s*:\\s*'(/filemoreajax\\.php\\?file=\\d+)'[\\s\\S]*?data\\s*:\\s*\\{([^}]+)\\}"); private static final Pattern P_DATA_KV = Pattern.compile("'(\\w+)'\\s*:\\s*('(?:\\\\'|[^'])*'|\\d+|\\w+)"); + /** CDN 直链里的 yyyy/mm/dd 分享日期 */ + private static final Pattern P_URL_DATE = Pattern.compile("\\d{4}/\\d{1,2}/\\d{1,2}"); private static final Pattern P_INLINE_SCRIPT = Pattern.compile("(?is)]*\\bsrc\\s*=)[^>]*>(.*?)"); - private static final Pattern FILE_SIZE_PATTERN = Pattern.compile(">文件大小:(.*?)
|\"n_filesize\">大小:(.*?)"); - private static final Pattern SHARE_USER_PATTERN = Pattern.compile(">分享用户:(.*?)|获取(.*?)的文件|\"user-name\">(.*?)
(.*?)|class=\"n_box_des\">(.*?)"); - private static final Pattern FILE_ID_PATTERN = Pattern.compile("\\?f=(.*?)&|fid = (.*?);"); - private static final Pattern CREATE_TIME_PATTERN = Pattern.compile(">上传时间:(.*?)<"); - private static final Pattern URL_DATE_PATTERN = Pattern.compile("(\\d{4}/\\d{1,2}/\\d{1,2})"); - private static final Pattern ARG1_PATTERN = Pattern.compile("var arg1='([^']+)'"); - private static final Pattern IFRAME_SRC_PATTERN = Pattern.compile( - "src\\s*=\\s*[\"'](/fn\\?[^\"'\\s>]+)[\"']", Pattern.CASE_INSENSITIVE); - private static final Pattern RELATIVE_TIME_PATTERN = Pattern.compile("^(\\d+|几)\\s*(分钟|小时)前$"); - private static final Pattern DATE_PATTERN = Pattern.compile("^(\\d{4})\\s*[-/年]\\s*(\\d{1,2})\\s*[-/月]\\s*(\\d{1,2})\\s*日?$"); - private static final Pattern MONTH_DAY_PATTERN = Pattern.compile("^(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*日?$"); - MultiMap headers0 = HeaderUtils.parseHeaders(""" + private static final Pattern P_FI_NAME = + Pattern.compile("padding: 56px 0px 20px 0px;\">(.*?)<|filenajax\">(.*?)<" + + "|class=\"b\">(.*?)"); + private static final Pattern P_FI_SIZE = + Pattern.compile(">文件大小:(.*?)
|\"n_filesize\">大小:(.*?)" + + "|文件大小:
(.*?)
"); + private static final Pattern P_FI_AUTHOR = + Pattern.compile(">分享用户:(.*?)|获取(.*?)的文件|\"user-name\">(.*?)[\\s\\S]{0,400}?]*>(.*?)"); + private static final Pattern P_FI_DESC = + Pattern.compile("(?s)文件描述:
(.*?)|class=\"n_box_des\">(.*?)"); + private static final Pattern P_FI_ID = Pattern.compile("\\?f=(.*?)&|fid = (.*?);"); + private static final Pattern P_FI_TIME = Pattern.compile(">上传时间:(.*?)<"); + /** 分享页导航头,只读共享;需要改写时先 addAll 到新 MultiMap。 */ + private static final MultiMap PAGE_HEADERS = HeaderUtils.parseHeaders(""" Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 Accept-Encoding: identity Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6 Cache-Control: max-age=0 DNT: 1 Priority: u=0, i - Sec-CH-UA: "Chromium";v="140", "Not=A?Brand";v="24", "Microsoft Edge";v="140" + Sec-CH-UA: "Chromium";v="134", "Not:A-Brand";v="24", "Google Chrome";v="134" Sec-CH-UA-Mobile: ?0 - Sec-CH-UA-Platform: "macOS" + Sec-CH-UA-Platform: "Windows" Sec-Fetch-Dest: document Sec-Fetch-Mode: navigate Sec-Fetch-Site: cross-site Sec-Fetch-User: ?1 Upgrade-Insecure-Requests: 1 - User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0 + 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 """); + private static final String DOWN_AJAX_HEADERS = """ + Accept: application/json, text/javascript, */*; q=0.01 + Accept-Encoding: identity + Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6 + Cache-Control: no-cache + Content-Type: application/x-www-form-urlencoded + Pragma: no-cache + Sec-Fetch-Dest: empty + Sec-Fetch-Mode: cors + Sec-Fetch-Site: same-origin + 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 + X-Requested-With: XMLHttpRequest + sec-ch-ua: "Chromium";v="134", "Not:A-Brand";v="24", "Google Chrome";v="134" + sec-ch-ua-mobile: ?0 + sec-ch-ua-platform: "Windows" + """; + + private static final String VERIFY_AJAX_HEADERS = """ + Accept: application/json, text/javascript, */*; q=0.01 + Accept-Encoding: identity + Accept-Language: zh-CN,zh;q=0.9 + Content-Type: application/x-www-form-urlencoded + Sec-Fetch-Dest: empty + Sec-Fetch-Mode: cors + Sec-Fetch-Site: same-origin + X-Requested-With: XMLHttpRequest + """; + + public LzTool() { + super(); + this.webClientSession = WebClientSession.create(createLzClient(null)); + } public LzTool(ShareLinkInfo shareLinkInfo) { super(shareLinkInfo); - this.lzClient = createLzClient(shareLinkInfo); - this.webClientSession = WebClientSession.create(lzClient); + this.webClientSession = WebClientSession.create(createLzClient(shareLinkInfo)); } /** * ESA 对 gzip 响应常见不带可识别的 Content-Encoding,需客户端自动解压; - * 代理模式必须带上 shareLinkInfo 里的 proxy,不能绕过全局代理。 + * 开源版没有 createBaseClientOptions,这里按 PanBase 同样规则带上 proxy,避免绕过全局代理。 */ private static WebClient createLzClient(ShareLinkInfo shareLinkInfo) { WebClientOptions opts = new WebClientOptions() + .setConnectTimeout(10000) .setFollowRedirects(false) .setDecompressionSupported(true) .setUserAgentEnabled(false) - .setConnectTimeout(10000) .setIdleTimeout(12); if (shareLinkInfo != null && shareLinkInfo.getOtherParam().containsKey("proxy")) { JsonObject proxy = (JsonObject) shareLinkInfo.getOtherParam().get("proxy"); - if (proxy != null && proxy.getString("host") != null) { + if (proxy != null && proxy.getString("host") != null && proxy.getInteger("port") != null) { ProxyOptions proxyOptions = new ProxyOptions() .setType(ProxyType.valueOf(proxy.getString("type", "http").toUpperCase())) .setHost(proxy.getString("host")) - .setPort(proxy.getInteger("port", 0)); - if (StringUtils.isNotEmpty(proxy.getString("username"))) { - proxyOptions.setUsername(proxy.getString("username")); + .setPort(proxy.getInteger("port")); + String username = proxy.getString("username"); + if (username != null && !username.isEmpty()) { + proxyOptions.setUsername(username); } - if (StringUtils.isNotEmpty(proxy.getString("password"))) { - proxyOptions.setPassword(proxy.getString("password")); + String password = proxy.getString("password"); + if (password != null && !password.isEmpty()) { + proxyOptions.setPassword(password); } opts.setProxyOptions(proxyOptions); } } + if (opts.getConnectTimeout() <= 0) { + opts.setConnectTimeout(10000); + } return WebClient.create(WebClientVertxInit.get(), opts); } @@ -192,197 +238,153 @@ public class LzTool extends PanBase { } } + private static String jsonString(JsonObject json, String key) { + Object v = json.getValue(key); + if (v == null) { + return null; + } + String s = String.valueOf(v).trim(); + if (s.isEmpty() || "0".equals(s) || "null".equalsIgnoreCase(s)) { + return null; + } + return s; + } + + /** 目录文件下载接口会先用 shareKey="-" 占位,不能当成真实分享路径。 */ + private static boolean isPlaceholderShare(String u) { + if (u == null || u.isBlank()) { + return true; + } + String path = lzSharePath(u); + return path.isEmpty() || "-".equals(path); + } + + /** 统一改写到 {@link #SHARE_URL_PREFIX},只保留分享路径。 */ + private String resolveShareUrl() { + String u = shareLinkInfo.getShareUrl(); + if (isPlaceholderShare(u)) { + u = shareLinkInfo.getStandardUrl(); + } + if (isPlaceholderShare(u)) { + String key = shareLinkInfo.getShareKey(); + if (key == null || key.isBlank() || "-".equals(key)) { + return SHARE_URL_PREFIX; + } + return SHARE_URL_PREFIX + key; + } + int q = u.indexOf('?'); + String query = null; + if (q > 0) { + query = u.substring(q + 1); + u = u.substring(0, q); + } + int schemeEnd = u.indexOf("://"); + if (schemeEnd < 0) { + return appendWebpage(u, query); + } + int pathStart = u.indexOf('/', schemeEnd + 3); + if (pathStart < 0) { + String key = shareLinkInfo.getShareKey(); + return appendWebpage(SHARE_URL_PREFIX + (key == null ? "" : key), query); + } + return appendWebpage(SHARE_ORIGIN + u.substring(pathStart), query); + } + + /** 目录文件 ID 可能带 webpage=,必须保留;pwd 走独立字段,不拼进 URL。 */ + private static String appendWebpage(String url, String query) { + if (query == null || query.isBlank()) { + return url; + } + for (String part : query.split("&")) { + if (part.startsWith("webpage=")) { + return url + "?" + part; + } + } + return url; + } + public Future parse() { - String sUrl = shareLinkInfo.getStandardUrl(); - String pwd = shareLinkInfo.getSharePassword(); + String sUrl = resolveShareUrl(); + String rawPwd = shareLinkInfo.getSharePassword(); + final String pwd = "-".equals(rawPwd) ? null : rawPwd; - webClientSession.getAbs(sUrl) - .putHeaders(headers0) - .send().onSuccess(res -> { - try { - String html = asText(res); - if (hasAcwArg1(html)) { - webClientSession = WebClientSession.create(lzClient); - if (!setCookie(html, sUrl)) { - fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常"); - return; - } - webClientSession.getAbs(sUrl) - .putHeaders(headers0) - .send().onSuccess(res2 -> { - try { - String html2 = asText(res2); - doParser(html2, pwd, sUrl); - } catch (Exception e) { - fail("蓝奏云页面响应处理异常: {}", e.getMessage()); - } - }).onFailure(handleFail(sUrl)); - - } else { - doParser(html, pwd, sUrl); - } - } catch (Exception e) { - fail("蓝奏云页面响应处理异常: {}", e.getMessage()); - } - - }).onFailure(handleFail(sUrl)); + getWithArg1Retry(sUrl, PAGE_HEADERS) + .onSuccess(html -> doParser(html, pwd, sUrl)) + .onFailure(handleFail(sUrl)); return promise.future(); } private void doParser(String html, String pwd, String sUrl) { - if (html == null || html.isBlank()) { - fail("蓝奏云页面响应为空"); - return; - } - if (isShareCancelledPage(html)) { - fail("分享已失效或文件已取消分享"); - return; - } - // 检测是否为目录分享链接 (含 /s/、/b/ 路径段或 b0 开头的路径段) - if (sUrl.matches(".*/(s|b)/[^/]+.*") || sUrl.matches(".*/b0[^/]+.*")) { + if (isLzFolderShare(sUrl, html)) { fail("该链接为蓝奏云目录分享,请使用目录解析接口"); return; } - // 若仍是校验页 (parse()中cookie域名与实际URL不匹配时会出现), 重试一次 - if (hasAcwArg1(html)) { - webClientSession = WebClientSession.create(lzClient); - if (!setCookie(html, sUrl)) { - fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常"); - return; - } - webClientSession.getAbs(sUrl).putHeaders(headers0).send().onSuccess(res -> { - try { - String html2 = asText(res); - if (isShareCancelledPage(html2)) { - fail("分享已失效或文件已取消分享"); - return; - } - if (hasAcwArg1(html2)) { - fail("蓝奏云反爬校验失败,请稍后重试"); - return; - } - doParserInternal(html2, pwd, sUrl); - } catch (Exception e) { - fail("蓝奏云页面响应处理异常: {}", e.getMessage()); - } - }).onFailure(handleFail(sUrl)); - return; - } - doParserInternal(html, pwd, sUrl); - } - - private void doParserInternal(String html, String pwd, String sUrl) { - if (html == null || html.isBlank()) { - fail("蓝奏云页面响应为空"); - return; - } - if (isShareCancelledPage(html)) { - fail("分享已失效或文件已取消分享"); - return; - } - try { - setFileInfo(html, shareLinkInfo); - } catch (Exception e) { - log.error("文件信息解析异常", e); - } - // 匹配iframe - Matcher matcher = IFRAME_SRC_PATTERN.matcher(html); - // 没有Iframe说明是加密分享, 匹配sign通过密码请求下载页面 + setFileInfo(html, shareLinkInfo); + Matcher matcher = IFRAME_FN.matcher(html); if (!matcher.find()) { - boolean pwdPage = html.contains("down_p") || html.contains("id=\"pwd\"") || html.contains("id='pwd'"); - if (pwdPage && (pwd == null || pwd.isBlank())) { - fail("需要访问密码"); - return; - } - try { + if (html.contains("down_p") || html.contains("id=\"pwd\"") || html.contains("id='pwd'")) { + if (pwd == null || pwd.isBlank()) { + fail("需要访问密码"); + return; + } if (!postAjaxFromHtml(sUrl, html, pwd)) { - fail("未找到下载参数,可能密码错误或分享已失效 htmlLen=" + html.length() - + " hasFn=" + html.contains("/fn?") - + " hasIframe=" + html.contains("iframe")); + fail("密码页未找到下载参数,可能密码错误或分享已失效"); + } + } else { + Matcher offMatcher = P_OFF_MSG.matcher(html); + if (offMatcher.find()) { + fail(offMatcher.group(1).trim()); + } else { + fail("未找到下载入口,可能被风控拦截或分享已失效"); } - } catch (Exception e) { - fail(e, "js引擎执行失败 htmlLen=" + html.length() - + " hasFn=" + html.contains("/fn?") - + " hasIframe=" + html.contains("iframe")); } - } else { - // 没有密码 - String iframePath = matcher.group(1); - String absoluteURI = joinUrl(SHARE_URL_PREFIX, iframePath); - // 创建局部副本,避免修改实例字段导致累积 - MultiMap headersCopy = MultiMap.caseInsensitiveMultiMap().addAll(headers0); - headersCopy.add("Referer", absoluteURI); - webClientSession.getAbs(absoluteURI).putHeaders(headersCopy).send().onSuccess(res2 -> { - try { - String html2 = asText(res2); - handleIframeHtml(html2, sUrl, absoluteURI, iframePath, headersCopy); - } catch (Exception e) { - fail("蓝奏云 iframe 响应处理异常: {}", e.getMessage()); - } - }).onFailure(handleFail(SHARE_URL_PREFIX)); - } - } - - private void handleIframeHtml(String html2, String sUrl, String absoluteURI, String iframePath, MultiMap headersCopy) { - if (isShareCancelledPage(html2)) { - fail("分享已失效或文件已取消分享"); return; } - if (hasAcwArg1(html2)) { - if (!setCookie(html2, absoluteURI)) { - fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常"); - return; - } - webClientSession.getAbs(absoluteURI).putHeaders(headersCopy).send().onSuccess(res3 -> { - try { - String html3 = asText(res3); - if (isShareCancelledPage(html3)) { - fail("分享已失效或文件已取消分享"); - return; + String iframePath = matcher.group(1); + String absoluteURI = joinUrl(originOf(sUrl, SHARE_ORIGIN) + "/", iframePath); + getWithArg1Retry(absoluteURI, PAGE_HEADERS) + .onSuccess(iframeHtml -> { + if (!postAjaxFromHtml(absoluteURI, iframeHtml, null)) { + fail(absoluteURI + " -> " + sUrl + ": 获取失败0, 可能分享已失效"); } - submitIframeAjax(html3, sUrl, absoluteURI, iframePath); - } catch (Exception e) { - fail("蓝奏云 iframe 响应处理异常: {}", e.getMessage()); - } - }).onFailure(handleFail(absoluteURI)); - return; - } - submitIframeAjax(html2, sUrl, absoluteURI, iframePath); + }) + .onFailure(handleFail(absoluteURI)); } - private void submitIframeAjax(String iframeHtml, String sUrl, String absoluteURI, String iframePath) { - try { - if (!postAjaxFromHtml(absoluteURI, iframeHtml, null)) { - fail(SHARE_URL_PREFIX + iframePath + " -> " + sUrl + ": 获取失败0, 可能分享已失效"); + private Future getWithArg1Retry(String url, MultiMap headers) { + return sendWithArg1Retry(url, + () -> webClientSession.getAbs(url).timeout(REQUEST_TIMEOUT).putHeaders(headers).send()); + } + + private Future postFormWithArg1Retry(String url, MultiMap headers, MultiMap form) { + return sendWithArg1Retry(url, + () -> webClientSession.postAbs(url).timeout(REQUEST_TIMEOUT).putHeaders(headers).sendForm(form)); + } + + /** ESA 挑战页只需算出 acw_sc__v2 后原样重放一次,各解析步骤共用。 */ + private Future sendWithArg1Retry(String url, Supplier>> sender) { + return sender.get().map(this::asText).compose(html -> { + if (html == null || !html.contains(ARG1_MARK)) { + return Future.succeededFuture(html); } - } catch (ScriptException | NoSuchMethodException e) { - fail(e, "js引擎执行失败"); - } + if (!applyArg1Cookie(html, url)) { + return Future.failedFuture("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常"); + } + return sender.get().map(this::asText).compose(retried -> + retried != null && retried.contains(ARG1_MARK) + ? Future.failedFuture("蓝奏云反爬校验失败,请稍后重试") + : Future.succeededFuture(retried)); + }); } - private boolean setCookie(String html, String url) { - String arg1 = extractAcwArg1(html); - if (arg1 == null) { + private boolean applyArg1Cookie(String html, String url) { + int beginIndex = html.indexOf("arg1='") + 6; + int endIndex = html.indexOf("';", beginIndex); + if (beginIndex < 6 || endIndex == -1 || endIndex <= beginIndex) { return false; } - String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1); - // 从 URL 中动态提取域名(如 lanzoum.com, lanzoux.com 等) - String domain = ".lanzn.com"; // 默认兜底 - try { - java.net.URL urlObj = new java.net.URL(url); - String host = urlObj.getHost(); // e.g. "dzvip.lanzoum.com" - int firstDot = host.indexOf('.'); - if (firstDot >= 0) { - domain = host.substring(firstDot); // e.g. ".lanzoum.com" - } - } catch (MalformedURLException ignored) {} - // 创建一个 Cookie 并放入 CookieStore - DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2); - nettyCookie.setDomain(domain); - nettyCookie.setPath("/"); - nettyCookie.setSecure(false); - nettyCookie.setHttpOnly(false); - webClientSession.cookieStore().put(nettyCookie); + String arg1 = html.substring(beginIndex, endIndex); + putSessionCookie("acw_sc__v2", AcwScV2Generator.acwScV2Simple(arg1), url, null); return true; } @@ -390,17 +392,9 @@ public class LzTool extends PanBase { String jsText = getJsText(html); if (jsText == null) { - throw new RuntimeException("获取失败1, 可能分享已失效 htmlLen=" + (html == null ? 0 : html.length()) - + " hasFn=" + (html != null && html.contains("/fn?")) - + " hasScript=" + (html != null && html.contains(" 0) { jsText = jsText.substring(0, i); @@ -408,101 +402,17 @@ public class LzTool extends PanBase { return jsText; } - private String getJsText(String html) { - if (html == null || html.isEmpty()) { - return null; - } - Matcher m = P_INLINE_SCRIPT.matcher(html); - String lastAjax = null; - while (m.find()) { - String body = m.group(1).replaceAll("", "").trim(); - if (body.isEmpty() || body.contains("var arg1=") || body.contains("arg1='")) { - continue; - } - if (body.contains("$.ajax") || body.contains("down_p") || body.contains("wp_sign") - || body.contains("ajaxdata") || body.contains("filemoreajax")) { - lastAjax = body; - } - } - if (lastAjax != null) { - return lastAjax; - } - String jsTagStart = "", startPos); - if (endPos < 0) { - return null; - } - String fallback = html.substring(startPos, endPos).replaceAll("", ""); - if (fallback.contains("var arg1=") || fallback.contains("arg1='")) { - return null; - } - if (fallback.contains("$.ajax") || fallback.contains("wp_sign") || fallback.contains("down_p") - || fallback.contains("ajaxdata") || fallback.contains("filemoreajax")) { - return fallback; - } - return null; - } - - static String extractAcwArg1(String html) { - if (html == null) { - return null; - } - int beginIndex = html.indexOf("arg1='"); - if (beginIndex < 0) { - return null; - } - beginIndex += 6; - int endIndex = html.indexOf("';", beginIndex); - if (endIndex <= beginIndex) { - return null; - } - return html.substring(beginIndex, endIndex); - } - - static boolean isShareCancelledPage(String html) { - return html != null - && ((html.contains("来晚啦") && html.contains("取消分享")) - || (html.contains("class=\"off\"") && html.contains("取消分享"))); - } - - private static boolean hasAcwArg1(String html) { - return html != null && html.contains("var arg1='"); - } - - private boolean postAjaxFromHtml(String referer, String html, String pwd) - throws ScriptException, NoSuchMethodException { + private boolean postAjaxFromHtml(String referer, String html, String pwd) { AjaxCall call = extractAjaxFromHtml(html, pwd); - if (call != null) { - getDownURL(referer, call); - return true; - } - String jsText; - try { - jsText = (pwd != null && !pwd.isBlank()) - ? getJsByPwd(pwd, html, "document.getElementById('rpt')") - : getJsText(html); - } catch (RuntimeException e) { + if (call == null) { return false; } - if (jsText == null) { - return false; - } - String fun = (pwd != null && !pwd.isBlank() && jsText.contains("down_p")) ? "down_p" : null; - ScriptObjectMirror mirror = JsExecUtils.executeDynamicJs(jsText, fun, pwd); - if (mirror == null) { - return false; - } - getDownURL(referer, mirror); + getDownURL(referer, call); return true; } /** 页面里提取出的 ajax 调用:相对路径 + 表单参数。 */ - record AjaxCall(String path, Map form) { + private record AjaxCall(String path, Map form) { MultiMap toForm() { MultiMap m = MultiMap.caseInsensitiveMultiMap(); form.forEach(m::set); @@ -510,14 +420,7 @@ public class LzTool extends PanBase { } } - private void getDownURL(String referer, AjaxCall call) { - Map obj = new LinkedHashMap<>(); - obj.put("url", call.path()); - obj.put("data", call.form()); - getDownURL(referer, obj); - } - - static AjaxCall extractAjaxFromHtml(String html, String pwd) { + private static AjaxCall extractAjaxFromHtml(String html, String pwd) { if (html == null || html.isEmpty()) { return null; } @@ -537,11 +440,6 @@ public class LzTool extends PanBase { lastIsngis = isngis.group(1); } } - String kd = "1"; - Matcher kdns = P_KDNS.matcher(html); - if (kdns.find()) { - kd = kdns.group(1); - } if (wp.find()) { data.put("sign", wp.group(1)); if (ad.find()) { @@ -553,14 +451,14 @@ public class LzTool extends PanBase { if (ws.find()) { data.put("websign", ws.group(1)); } - data.put("kd", kd); + data.put("kd", "1"); data.put("ves", "1"); if (pwd != null && !pwd.isEmpty()) { data.put("p", pwd); } } else if (lastIsngis != null) { data.put("sign", lastIsngis); - data.put("kd", kd); + data.put("kd", "1"); if (pwd != null && !pwd.isEmpty()) { data.put("p", pwd); } @@ -577,7 +475,7 @@ public class LzTool extends PanBase { if (pwd != null && !pwd.isEmpty()) { data.put("p", pwd); } - data.put("kd", kd); + data.put("kd", "1"); Matcher ad2 = P_AJAXDATA.matcher(html); if (ad2.find()) { data.put("websignkey", ad2.group(1)); @@ -587,7 +485,7 @@ public class LzTool extends PanBase { return new AjaxCall("/" + ajaxPath, data); } - static AjaxCall extractFolderAjax(String html, String pwd) { + private static AjaxCall extractFolderAjax(String html, String pwd) { if (html == null || html.isEmpty()) { return null; } @@ -639,216 +537,315 @@ public class LzTool extends PanBase { return raw; } - private static void parseFormString(MultiMap map, String form) { - if (form == null || form.isBlank()) { - return; + private String getJsText(String html) { + if (html == null || html.isEmpty()) { + return null; } - for (String part : form.split("&")) { - int eq = part.indexOf('='); - if (eq > 0) { - map.add(part.substring(0, eq), part.substring(eq + 1)); + Matcher m = P_INLINE_SCRIPT.matcher(html); + String lastAjax = null; + while (m.find()) { + String body = m.group(1).replaceAll("", "").trim(); + if (body.isEmpty()) { + continue; + } + if (body.contains("$.ajax") || body.contains("down_p") || body.contains("wp_sign") + || body.contains("ajaxdata") || body.contains("filemoreajax")) { + lastAjax = body; } } + if (lastAjax != null) { + return lastAjax; + } + String jsTagStart = "", startPos); + if (endPos < 0) { + return null; + } + String fallback = html.substring(startPos, endPos).replaceAll("", ""); + if (fallback.contains("$.ajax") || fallback.contains("wp_sign") || fallback.contains("down_p") + || fallback.contains("ajaxdata") || fallback.contains("filemoreajax")) { + return fallback; + } + return null; } - private void getDownURL(String key, Map obj) { - if (obj == null) { - fail("需要访问密码"); - return; - } - Object dataObj = obj.get("data"); - if (dataObj == null) { - fail("需要访问密码"); - return; - } - String url0 = String.valueOf(obj.get("url")); - MultiMap map = MultiMap.caseInsensitiveMultiMap(); - if (dataObj instanceof CharSequence) { - parseFormString(map, dataObj.toString()); - } else if (dataObj instanceof Map signMap) { - signMap.forEach((k, v) -> { - if (k != null) { - map.add(k.toString(), v == null ? "" : v.toString()); - } - }); - } else { - fail("需要访问密码"); - return; - } - MultiMap headers = HeaderUtils.parseHeaders(""" - Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 - Accept-Encoding: identity - Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6 - Cache-Control: no-cache - Connection: keep-alive - Content-Type: application/x-www-form-urlencoded - Pragma: no-cache - Sec-Fetch-Dest: empty - Sec-Fetch-Mode: cors - Sec-Fetch-Site: same-origin - 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 Edg/134.0.0.0 - X-Requested-With: XMLHttpRequest - sec-ch-ua: "Chromium";v="134", "Not:A-Brand";v="24", "Microsoft Edge";v="134" - sec-ch-ua-mobile: ?0 - sec-ch-ua-platform: "Windows" - """); + private void getDownURL(String referer, AjaxCall call) { + MultiMap headers = HeaderUtils.parseHeaders(DOWN_AJAX_HEADERS); + // 个性域名 ajaxfile.php 会立刻 inf=已超时;POST 必须打 wwww。iframe 请求 Referer 用 iframe 地址。 + headers.set("referer", referer != null && !referer.isBlank() ? referer : resolveShareUrl()); + String url = joinUrl(SHARE_ORIGIN + "/", call.path()); + postFormWithArg1Retry(url, headers, call.toForm()) + .onSuccess(this::handleAjaxDownResponse) + .onFailure(handleFail(url)); + } - headers.set("referer", key); - // action=downprocess&signs=%3Fctdf&websignkey=I5gl&sign=BWMGOF1sBTRWXwI9BjZdYVA7BDhfNAIyUG9UawJtUGMIPlAhACkCa1UyUTAAYFxvUj5XY1E7UGFXaFVq&websign=&kd=1&ves=1 - String url = joinUrl(SHARE_URL_PREFIX, url0); - webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res2 -> { - try { - JsonObject urlJson = asJson(res2); - Object infVal = urlJson.getValue("inf"); - String name = infVal instanceof CharSequence ? infVal.toString() : null; - Integer zt = urlJson.getInteger("zt"); - if (zt == null || zt != 1) { - fail(name != null ? name : String.valueOf(infVal)); - return; - } - // 文件名 - if (name != null) { - Object fi = shareLinkInfo.getOtherParam().get("fileInfo"); - if (fi instanceof FileInfo fileInfo) { - fileInfo.setFileName(name); - } - } - - String downUrl = urlJson.getString("dom") + "/file/" + urlJson.getString("url"); - followFileUrl(downUrl, headers); - } catch (Exception e) { - fail(e, "解析异常"); + private void handleAjaxDownResponse(String text) { + JsonObject urlJson = parseLzJson(text); + if (urlJson == null) { + String preview = text == null ? "空响应" : text.substring(0, Math.min(200, text.length())); + fail("ajax 返回非JSON: " + preview); + return; + } + Object infVal = urlJson.getValue("inf"); + String name = infVal instanceof CharSequence ? infVal.toString() : null; + Integer zt = urlJson.getInteger("zt"); + if (zt == null || zt != 1) { + fail(name != null ? name : String.valueOf(infVal)); + return; + } + if (name != null) { + Object fi = shareLinkInfo.getOtherParam().get("fileInfo"); + if (fi instanceof FileInfo fileInfo) { + fileInfo.setFileName(name); } - }).onFailure(handleFail(url)); + } + String dom = jsonString(urlJson, "dom"); + String token = jsonString(urlJson, "url"); + if (dom == null || token == null) { + fail("ajax 未返回下载地址"); + return; + } + String downUrl = dom + "/file/" + token; + followFileUrl(downUrl, false); } /** - * 下载域已不再走 arg1/acw_sc__v2 挑战页;带 down_ip=1 时通常直接 302。 - * 未跳转则走页面 down_r → POST /ajax.php 二次验证。 + * 中间链:部分出口仍 302;多数(含机房 IP)固定出「验证并下载」页。 + * dmpdmp 等下载域仍会出 arg1 挑战(Windows UA 更常见),必须算 acw_sc__v2 再请求。 + * 必须等约 2s 后 POST /file/ajax.php el=2 拿 CDN 直链;立刻 POST 会 ?SignError。 + * 中间页不能当成功结果返回——用户侧裸打开永远是验证 HTML。 */ - private void followFileUrl(String downUrl, MultiMap headers) { + private void followFileUrl(String downUrl, boolean arg1Retried) { + String host = hostOf(downUrl); + String parent = registrableDomain(host); + putSessionCookie("down_ip", "1", downUrl, parent != null ? parent : "lanrar.com"); + if (parent != null) { + DefaultCookie parentCookie = new DefaultCookie("down_ip", "1"); + parentCookie.setDomain(parent); + parentCookie.setPath("/"); + webClientSession.cookieStore().put(parentCookie); + } + String origin = originOf(downUrl, "https://developer2.lanrar.com"); - putSessionCookie("down_ip", "1", downUrl, ".lanrar.com"); - headers.set("referer", origin); - webClientSession.getAbs(downUrl).putHeaders(headers).send() - .onSuccess(res3 -> { - try { - String location = res3.headers().get("Location"); - if (location != null) { - setDateAndComplete(location); + MultiMap h = lanrarPageHeaders(origin); + webClientSession.getAbs(downUrl).timeout(DOWNLOAD_TIMEOUT).putHeaders(h).send() + .onSuccess(res -> { + String location = firstHttpLocation(res); + if (isIosInstallRedirect(location)) { + log.warn("下载域按 iOS 在线安装跳转,忽略 plist: {}", location); + } else if (isDirectLink(location)) { + completeDownloadLink(location); + return; + } + String html = asText(res); + if (html != null && html.contains(ARG1_MARK)) { + if (!arg1Retried && applyArg1Cookie(html, downUrl)) { + followFileUrl(downUrl, true); return; } - String text = asText(res3); - if (isShareCancelledPage(text)) { - fail(downUrl + " -> 分享已失效或文件已取消分享"); - return; - } - if (text.contains("down_r") && text.contains("ajax.php")) { - verifyDownloadPage(origin, downUrl, text, headers); - return; - } - if (hasAcwArg1(text)) { - retryFileUrlWithAcw(downUrl, text, headers); - return; - } - fail(downUrl + " -> 直链获取失败2, 可能分享已失效"); - } catch (Exception e) { - fail("蓝奏云直链响应处理异常: {}", e.getMessage()); + fail("蓝奏下载域反爬校验失败,请稍后重试"); + return; + } + if (html != null && html.contains("down_r") && html.contains("ajax.php")) { + postVerifyAjax(downUrl, origin, html, 0); + return; + } + log.warn("下载域未出验证页也未跳转 status={} host={}", res.statusCode(), host); + fail("蓝奏下载域未返回直链,请稍后重试"); + }) + .onFailure(t -> { + log.warn("下载域请求失败: {}", t.getMessage()); + fail("蓝奏下载域请求失败: " + t.getMessage()); + }); + } + + private void postVerifyAjax(String downUrl, String origin, String html, int attempt) { + Matcher file = P_VERIFY_FILE.matcher(html); + Matcher sign = P_SIGN.matcher(html); + if (!file.find() || !sign.find()) { + fail("蓝奏验证页缺少 file/sign"); + return; + } + MultiMap form = MultiMap.caseInsensitiveMultiMap(); + form.add("file", file.group(1)); + form.add("el", "2"); + form.add("sign", sign.group(1)); + String ajaxUrl = origin + "/file/ajax.php"; + MultiMap h = lanrarAjaxHeaders(downUrl); + h.set("Origin", origin); + // 验证页 loading ~2s;过早 POST → zt=1 但 url=?SignError + long delay = attempt == 0 ? 2200 : 2000; + WebClientVertxInit.get().setTimer(delay, id -> + webClientSession.postAbs(ajaxUrl).timeout(DOWNLOAD_TIMEOUT).putHeaders(h).sendForm(form) + .onSuccess(res -> { + JsonObject json = parseLzJson(asText(res)); + String url = json == null ? null : jsonString(json, "url"); + if (isDirectLink(url)) { + completeDownloadLink(url); + return; + } + log.warn("验证 ajax 未返回直链 attempt={} url={}", attempt, url); + if (attempt < 1) { + retryVerifyWithFreshPage(downUrl, origin, attempt); + return; + } + fail("蓝奏二次验证未返回直链"); + }) + .onFailure(t -> { + log.warn("验证 ajax 失败 attempt={}: {}", attempt, t.getMessage()); + if (attempt < 1) { + retryVerifyWithFreshPage(downUrl, origin, attempt); + return; + } + fail("蓝奏二次验证请求失败: " + t.getMessage()); + })); + } + + /** SignError / 请求失败都要重新拉验证页换新 file/sign,复用旧 sign 必然再失败。 */ + private void retryVerifyWithFreshPage(String downUrl, String origin, int attempt) { + webClientSession.getAbs(downUrl).timeout(DOWNLOAD_TIMEOUT).putHeaders(lanrarPageHeaders(origin)).send() + .onSuccess(res -> { + String html = asText(res); + if (html != null && html.contains("down_r")) { + postVerifyAjax(downUrl, origin, html, attempt + 1); + } else { + fail("蓝奏二次验证失败"); } }) - .onFailure(handleFail(downUrl)); + .onFailure(t -> fail("蓝奏二次验证刷新失败: " + t.getMessage())); } - private void verifyDownloadPage(String origin, String downUrl, String html, MultiMap headers) { - Matcher fileM = Pattern.compile("'file'\\s*:\\s*'([^']+)'").matcher(html); - Matcher signM = Pattern.compile("'sign'\\s*:\\s*'([^']+)'").matcher(html); - if (!fileM.find() || !signM.find()) { - fail(downUrl + " -> 二次验证参数缺失"); + private void completeDownloadLink(String location) { + if (isIntermediateLanrar(location) || isIosInstallRedirect(location)) { + fail("蓝奏未解析到 CDN 直链"); return; } - String file = fileM.group(1); - String sign = signM.group(1); - WebClientVertxInit.get().setTimer(2000, id -> postVerifyAjax(origin, downUrl, file, sign, headers, false)); + completeWithShareDate(location); } - private void postVerifyAjax(String origin, String downUrl, String file, String sign, MultiMap headers, boolean filePath) { - MultiMap map = MultiMap.caseInsensitiveMultiMap(); - map.add("file", file); - map.add("el", "2"); - map.add("sign", sign); - String ajaxUrl = origin + (filePath ? "/file/ajax.php" : "/ajax.php"); - headers.set("referer", origin); - webClientSession.postAbs(ajaxUrl).putHeaders(headers).sendForm(map).onSuccess(res -> { - try { - JsonObject json = asJson(res); - Integer zt = json.getInteger("zt"); - String u = json.getString("url"); - if (zt != null && zt == 1 && u != null && u.startsWith("http") && !u.contains("SignError")) { - setDateAndComplete(u); - return; - } - if (!filePath) { - postVerifyAjax(origin, downUrl, file, sign, headers, true); - return; - } - fail(downUrl + " -> 二次验证失败: " + (u != null ? u : String.valueOf(json.getValue("inf")))); - } catch (Exception e) { - fail("二次验证解析异常: {}", e.getMessage()); - } - }).onFailure(handleFail(ajaxUrl)); - } - - private void retryFileUrlWithAcw(String downUrl, String text, MultiMap headers) { - String arg1 = extractAcwArg1(text); - if (arg1 == null) { - fail(downUrl + " -> 直链获取失败2, 可能分享已失效"); - return; + private static boolean isIntermediateLanrar(String url) { + if (url == null || url.isBlank()) { + return false; } - String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1); - putSessionCookie("acw_sc__v2", acw_sc__v2, downUrl, ".lanrar.com"); - headers.set("referer", originOf(downUrl, "https://developer2.lanrar.com")); - webClientSession.getAbs(downUrl).putHeaders(headers).send() - .onSuccess(res4 -> { - try { - String location0 = res4.headers().get("Location"); - if (location0 == null) { - fail(downUrl + " -> 直链获取失败2, 可能分享已失效"); - } else { - setDateAndComplete(location0); - } - } catch (Exception e) { - fail("蓝奏云直链二次响应处理异常: {}", e.getMessage()); - } - }).onFailure(handleFail(downUrl)); + String u = url.toLowerCase(); + return u.contains("lanrar.com/file/") || (u.contains("/file/?") && u.contains("lanrar")); + } + + /** Mac/iOS UA 会把 ipa 当成「Safari 在线安装」,302 到 itms-services/plist,不是直链。 */ + private static boolean isIosInstallRedirect(String url) { + if (url == null || url.isBlank()) { + return false; + } + String u = url.toLowerCase(); + return u.startsWith("itms-services:") || u.startsWith("itms:") + || u.contains(".plist") + || u.contains("ipa.lanrar.com"); + } + + /** 最后一步 GET 用完整浏览器导航头,裸 curl / 无 UA 会固定落到验证页。 */ + private static MultiMap lanrarPageHeaders(String referer) { + MultiMap h = MultiMap.caseInsensitiveMultiMap(); + h.addAll(PAGE_HEADERS); + h.set("referer", referer); + return h; + } + + private static MultiMap lanrarAjaxHeaders(String referer) { + MultiMap h = HeaderUtils.parseHeaders(VERIFY_AJAX_HEADERS); + h.set("referer", referer); + copyHeader(h, "User-Agent", "User-Agent"); + copyHeader(h, "Sec-CH-UA", "sec-ch-ua"); + copyHeader(h, "Sec-CH-UA-Mobile", "sec-ch-ua-mobile"); + copyHeader(h, "Sec-CH-UA-Platform", "sec-ch-ua-platform"); + return h; + } + + private static void copyHeader(MultiMap target, String from, String to) { + String v = PAGE_HEADERS.get(from); + if (v != null && !v.isBlank()) { + target.set(to, v); + } + } + + private static String firstHttpLocation(HttpResponse res) { + String loc = res.headers().get("Location"); + if (loc == null) { + loc = res.headers().get("location"); + } + return loc; + } + + private static boolean isDirectLink(String url) { + if (url == null || url.isBlank() || url.contains("SignError") || url.contains("/file/?")) { + return false; + } + if (isIosInstallRedirect(url)) { + return false; + } + return url.startsWith("http://") || url.startsWith("https://"); } private void putSessionCookie(String name, String value, String url, String fallbackDomain) { - String domain = fallbackDomain; + DefaultCookie hostCookie = new DefaultCookie(name, value); + hostCookie.setPath("/"); + hostCookie.setSecure(false); + hostCookie.setHttpOnly(false); + webClientSession.cookieStore().put(hostCookie); + String host = null; try { - java.net.URL urlObj = new java.net.URL(url); - String host = urlObj.getHost(); - int firstDot = host.indexOf('.'); - if (firstDot >= 0) { - domain = host.substring(firstDot); - } - } catch (MalformedURLException ignored) {} - DefaultCookie nettyCookie = new DefaultCookie(name, value); - nettyCookie.setDomain(domain); - nettyCookie.setPath("/"); - nettyCookie.setSecure(false); - nettyCookie.setHttpOnly(false); - webClientSession.cookieStore().put(nettyCookie); + host = new URL(url).getHost(); + } catch (Exception ignored) {} + if (host == null || host.isBlank()) { + host = fallbackDomain; + } + if (host != null && host.startsWith(".")) { + host = host.substring(1); + } + if (host != null && !host.isBlank()) { + DefaultCookie domainCookie = new DefaultCookie(name, value); + domainCookie.setDomain(host); + domainCookie.setPath("/"); + domainCookie.setSecure(false); + domainCookie.setHttpOnly(false); + webClientSession.cookieStore().put(domainCookie); + } } private static String originOf(String url, String fallback) { try { - java.net.URL u = new java.net.URL(url); + URL u = new URL(url); return u.getProtocol() + "://" + u.getHost(); - } catch (MalformedURLException e) { + } catch (Exception e) { return fallback; } } + private static String hostOf(String url) { + try { + return new URL(url).getHost(); + } catch (Exception e) { + return null; + } + } + + /** u1305138.dmpdmp.com → dmpdmp.com;developer2.lanrar.com → lanrar.com */ + private static String registrableDomain(String host) { + if (host == null || host.isBlank()) { + return null; + } + int last = host.lastIndexOf('.'); + if (last <= 0) { + return host; + } + int prev = host.lastIndexOf('.', last - 1); + return prev >= 0 ? host.substring(prev + 1) : host; + } + private static String joinUrl(String base, String path) { if (path == null || path.isBlank()) { return base; @@ -865,135 +862,148 @@ public class LzTool extends PanBase { return base + path; } - private void setDateAndComplete(String location0) { + private void completeWithShareDate(String downloadUrl) { // 分享时间 提取url中的时间戳格式:lanzoui.com/abc/abc/yyyy/mm/dd/ - Matcher matcher = URL_DATE_PATTERN.matcher(location0); - if (matcher.find()) { - String dateStr = parseLanzouFileTime(matcher.group()); - ((FileInfo)shareLinkInfo.getOtherParam().get("fileInfo")).setCreateTime(dateStr); + Matcher matcher = P_URL_DATE.matcher(downloadUrl); + if (matcher.find() && shareLinkInfo.getOtherParam().get("fileInfo") instanceof FileInfo fileInfo) { + fileInfo.setCreateTime(matcher.group().replace("/", "-")); } - promise.complete(location0); + complete(downloadUrl); } - private static MultiMap getHeaders(String key) { + /** 目录列表 filemoreajax.php 用移动端 UA。 */ + private static MultiMap folderListHeaders(String referer) { MultiMap headers = MultiMap.caseInsensitiveMultiMap(); - var userAgent2 = "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("User-Agent", userAgent2); - headers.set("referer", key); + 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("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", "sec-ch-ua-mobile"); + headers.set("sec-ch-ua-mobile", "?1"); return headers; } @Override public Future> parseFileList() { - Promise> promise = Promise.promise(); + Promise> listPromise = Promise.promise(); - String sUrl = shareLinkInfo.getShareUrl(); - String pwd = shareLinkInfo.getSharePassword(); + final String sUrl = resolveShareUrl(); + final String pwd = shareLinkInfo.getSharePassword(); - webClientSession.getAbs(sUrl).send().onSuccess(res -> { - try { - String html = asText(res); - // 检查是否需要 cookie 验证 - if (hasAcwArg1(html)) { - webClientSession = WebClientSession.create(lzClient); - if (!setCookie(html, sUrl)) { - promise.tryFail(baseMsg() + "蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常"); - return; - } - // 重新请求 - webClientSession.getAbs(sUrl).send().onSuccess(res2 -> { - try { - handleFileListParse(asText(res2), pwd, sUrl, promise); - } catch (Exception e) { - promise.tryFail(e); - } - }).onFailure(promise::tryFail); - return; - } - handleFileListParse(html, pwd, sUrl, promise); - } catch (Exception e) { - promise.tryFail(e); - } - }).onFailure(promise::tryFail); - return promise.future(); + getWithArg1Retry(sUrl, PAGE_HEADERS) + .onSuccess(html -> handleFileListParse(html, pwd, sUrl, listPromise)) + .onFailure(listPromise::fail); + return listPromise.future(); } - private void handleFileListParse(String html, String pwd, String sUrl, Promise> promise) { - if (html == null || html.isBlank()) { - promise.tryFail(baseMsg() + "蓝奏云页面响应为空"); - return; - } - if (isShareCancelledPage(html)) { - promise.tryFail(baseMsg() + "分享已失效或文件已取消分享"); - return; - } - // 检测是否为文件分享链接 (不含 /s/、/b/ 路径段且不含 b0 开头的路径段) - if (!sUrl.matches(".*/(s|b)/[^/]+.*") && !sUrl.matches(".*/b0[^/]+.*")) { - promise.tryFail(baseMsg() + "该链接为蓝奏云文件分享,请使用文件解析接口"); + private void handleFileListParse(String html, String pwd, String sUrl, Promise> listPromise) { + if (!isLzFolderShare(sUrl, html)) { + listPromise.fail(baseMsg() + "该链接为蓝奏云文件分享,请使用文件解析接口"); return; } try { + String webpage = extractWebpage(sUrl, html); + if (webpage != null) { + shareLinkInfo.getOtherParam().put("webpage", webpage); + } AjaxCall call = extractFolderAjax(html, pwd); if (call == null) { - String jsText = getJsByPwd(pwd, html, "var urls =window.location.href"); - ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, "file", pwd); - Map data = CastUtil.cast(scriptObjectMirror.get("data")); - Map form = new LinkedHashMap<>(); - data.forEach((k, v) -> form.put(k, String.valueOf(v))); - call = new AjaxCall("/filemoreajax.php?file=" + form.get("fid"), form); + call = folderAjaxFromJs(html, pwd); } log.debug("解析参数: {}", call.form()); - MultiMap headers = getHeaders(sUrl); - MultiMap map = call.toForm(); - String url = joinUrl(SHARE_URL_PREFIX, call.path()); - webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res2 -> { - try { - String resBody = asText(res2); - // 再次检查是否需要 cookie 验证 - if (hasAcwArg1(resBody)) { - if (!setCookie(resBody, url)) { - promise.tryFail(baseMsg() + "蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常"); - return; - } - // 重新请求 - webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res3 -> { - try { - handleFileListResponse(asText(res3), promise); - } catch (Exception e) { - promise.tryFail(e); - } - }).onFailure(promise::tryFail); - return; - } - handleFileListResponse(resBody, promise); - } catch (Exception e) { - promise.tryFail(e); - } - }).onFailure(promise::tryFail); + String url = joinUrl(originOf(sUrl, SHARE_ORIGIN) + "/", call.path()); + postFormWithArg1Retry(url, folderListHeaders(sUrl), call.toForm()) + .onSuccess(body -> handleFileListResponse(body, listPromise)) + .onFailure(listPromise::fail); } catch (ScriptException | NoSuchMethodException | RuntimeException e) { - promise.tryFail(e); + listPromise.fail(e); } } - private void handleFileListResponse(String responseBody, Promise> promise) { - try { - if (responseBody == null || responseBody.isBlank()) { - promise.tryFail(baseMsg() + "蓝奏云文件列表响应为空"); - return; + /** 正则匹配不到 filemoreajax 块时,回退到执行页面 JS 取参数。 */ + private AjaxCall folderAjaxFromJs(String html, String pwd) throws ScriptException, NoSuchMethodException { + String jsText = getJsByPwd(pwd, html, "var urls =window.location.href"); + ScriptObjectMirror mirror = JsExecUtils.executeDynamicJs(jsText, "file"); + Map raw = CastUtil.cast(mirror.get("data")); + Map form = new LinkedHashMap<>(); + raw.forEach((k, v) -> form.put(k, String.valueOf(v))); + return new AjaxCall("/filemoreajax.php?file=" + form.get("fid"), form); + } + + private boolean isLzFolderShare(String sUrl, String html) { + return isLzFolderUrl(sUrl) + || isLzFolderUrl(shareLinkInfo.getShareUrl()) + || isLzFolderUrl(shareLinkInfo.getShareKey()) + || isLzFolderHtml(html); + } + + /** 目录:shareKey 以 b 开头(/b710887、/b0xxx),或 /s/ 自定义目录。没有 /b/ 这种路径。 */ + static boolean isLzFolderUrl(String url) { + if (url == null || url.isBlank()) { + return false; + } + String path = lzSharePath(url); + if (path.isEmpty()) { + return false; + } + if (path.startsWith("s/")) { + return true; + } + // 单段路径才是分享 key;/tp/xxx 是手机端文件页 + if (path.indexOf('/') >= 0) { + return false; + } + return path.charAt(0) == 'b' || path.charAt(0) == 'B'; + } + + private static boolean isLzFolderHtml(String html) { + if (html == null || html.isBlank()) { + return false; + } + return html.contains("filemoreajax") || html.contains("id=\"infos\"") || html.contains("id='infos'"); + } + + private static String lzSharePath(String url) { + String u = url.trim(); + int q = u.indexOf('?'); + if (q >= 0) { + u = u.substring(0, q); + } + int hash = u.indexOf('#'); + if (hash >= 0) { + u = u.substring(0, hash); + } + int schemeEnd = u.indexOf("://"); + if (schemeEnd >= 0) { + int pathStart = u.indexOf('/', schemeEnd + 3); + if (pathStart < 0) { + return ""; } + u = u.substring(pathStart + 1); + } else if (u.indexOf('/') >= 0 && u.indexOf('.') >= 0 && u.indexOf('.') < u.indexOf('/')) { + u = u.substring(u.indexOf('/') + 1); + } + while (u.startsWith("/")) { + u = u.substring(1); + } + while (u.endsWith("/")) { + u = u.substring(0, u.length() - 1); + } + return u; + } + + private void handleFileListResponse(String responseBody, Promise> listPromise) { + try { JsonObject fileListJson = parseLzJson(responseBody); if (fileListJson == null) { - fileListJson = new JsonObject(responseBody); + listPromise.fail(baseMsg() + "目录列表返回非JSON"); + return; } - if (fileListJson.getInteger("zt") != 1) { - promise.tryFail(baseMsg() + fileListJson.getString("info")); + Integer zt = fileListJson.getInteger("zt"); + if (zt == null || zt != 1) { + listPromise.fail(baseMsg() + fileListJson.getString("info")); return; } List list = new ArrayList<>(); @@ -1020,11 +1030,16 @@ public class LzTool extends PanBase { // 构建 base64 参数,用于 /v2/redirectUrl 接口 JsonObject paramJson = new JsonObject() .put("id", id) - .put("fileName", fileName); + .put("fileName", fileName) + .put("shareKey", shareLinkInfo.getShareKey()); + String webpage = currentWebpage(); + if (webpage != null) { + paramJson.put("webpage", webpage); + } String param = CommonUtils.urlBase64Encode(paramJson.encode()); fileInfo.setFileName(fileName) .setFileId(id) - .setCreateTime(parseLanzouFileTime(fileJson.getString("time"))) + .setCreateTime(fileJson.getString("time")) .setFileType(fileJson.getString("icon")) .setSizeStr(fileJson.getString("size")) .setSize(sizeNum) @@ -1035,87 +1050,77 @@ public class LzTool extends PanBase { log.debug("文件信息: {}", fileInfo); list.add(fileInfo); }); - promise.complete(list); + listPromise.complete(list); } catch (Exception e) { - promise.tryFail(e); + listPromise.fail(e); } } - private static String parseLanzouFileTime(String timeText) { - if (timeText == null || timeText.isBlank()) { - return timeText; - } - String normalized = timeText.trim().replaceAll("\\s+", " "); - Matcher matcher = RELATIVE_TIME_PATTERN.matcher(normalized); - if (matcher.matches()) { - int amount = "几".equals(matcher.group(1)) ? 1 : Integer.parseInt(matcher.group(1)); - String unit = matcher.group(2); - LocalDateTime time = LocalDateTime.now(); - if ("小时".equals(unit)) { - time = time.minusHours(amount); - } else { - time = time.minusMinutes(amount); - } - return time.toLocalDate().toString(); - } - matcher = DATE_PATTERN.matcher(normalized); - if (matcher.matches()) { - return LocalDate.of( - Integer.parseInt(matcher.group(1)), - Integer.parseInt(matcher.group(2)), - Integer.parseInt(matcher.group(3)) - ).toString(); - } - matcher = MONTH_DAY_PATTERN.matcher(normalized); - if (matcher.matches()) { - return LocalDate.of( - LocalDate.now().getYear(), - Integer.parseInt(matcher.group(1)), - Integer.parseInt(matcher.group(2)) - ).toString(); - } - return normalized; - } - @Override public Future parseById() { JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson"); String id = paramJson.getString("id"); - // 以文件ID重新构造标准访问URL,复用 parse() 流程 - shareLinkInfo.setStandardUrl(SHARE_URL_PREFIX + id); + if (id == null || id.isBlank()) { + fail("缺少文件ID"); + return promise.future(); + } + // getFileDownInfo 用 shareKey="-" 占位;目录内文件要按文件ID打开,并带上 webpage + String webpage = paramJson.getString("webpage"); + if (webpage == null || webpage.isBlank()) { + webpage = extractWebpage(shareLinkInfo.getShareUrl(), null); + } + String fileUrl = SHARE_URL_PREFIX + id; + if (webpage != null && !webpage.isBlank()) { + fileUrl = fileUrl + "?webpage=" + webpage; + } + shareLinkInfo.setShareKey(id); + shareLinkInfo.setShareUrl(fileUrl); + shareLinkInfo.setStandardUrl(fileUrl); return parse(); } + private String currentWebpage() { + Object cached = shareLinkInfo.getOtherParam().get("webpage"); + if (cached instanceof String s && !s.isBlank()) { + return s; + } + return extractWebpage(shareLinkInfo.getShareUrl(), null); + } + + private static String extractWebpage(String url, String html) { + if (url != null) { + Matcher m = P_WEBPAGE.matcher(url); + if (m.find()) { + return m.group(1); + } + } + if (html != null) { + Matcher m = P_WEBPAGE.matcher(html); + if (m.find()) { + return m.group(1); + } + } + return null; + } + void setFileInfo(String html, ShareLinkInfo shareLinkInfo) { - // 写入 fileInfo FileInfo fileInfo = new FileInfo(); shareLinkInfo.getOtherParam().put("fileInfo", fileInfo); try { - // 提取文件名 - String fileName = CommonUtils.extract(html, FILE_NAME_PATTERN); - String sizeStr = CommonUtils.extract(html, FILE_SIZE_PATTERN); - String createBy = CommonUtils.extract(html, SHARE_USER_PATTERN); - String description = CommonUtils.extract(html, DESCRIPTION_PATTERN); - // String icon = CommonUtils.extract(html, Pattern.compile("class=\"n_file_icon\" src=\"(.*?)\"")); - String fileId = CommonUtils.extract(html, FILE_ID_PATTERN); - String createTime = CommonUtils.extract(html, CREATE_TIME_PATTERN); - try { - fileInfo.setFileName(fileName) - .setCreateBy(createBy) - .setPanType(shareLinkInfo.getType()) - .setDescription(description) - .setFileType("file") - .setFileId(fileId) - .setCreateTime(parseLanzouFileTime(createTime)); - if (sizeStr != null && !sizeStr.isBlank()) { - long bytes = FileSizeConverter.convertToBytes(sizeStr); - fileInfo.setSize(bytes).setSizeStr(FileSizeConverter.convertToReadableSize(bytes)); - } - } catch (Exception e) { - log.warn("文件信息解析异常", e); + String sizeStr = CommonUtils.extract(html, P_FI_SIZE); + fileInfo.setFileName(CommonUtils.extract(html, P_FI_NAME)) + .setCreateBy(CommonUtils.extract(html, P_FI_AUTHOR)) + .setPanType(shareLinkInfo.getType()) + .setDescription(CommonUtils.extract(html, P_FI_DESC)) + .setFileType("file") + .setFileId(CommonUtils.extract(html, P_FI_ID)) + .setCreateTime(CommonUtils.extract(html, P_FI_TIME)); + if (sizeStr != null && !sizeStr.isBlank()) { + long bytes = FileSizeConverter.convertToBytes(sizeStr); + fileInfo.setSize(bytes).setSizeStr(FileSizeConverter.convertToReadableSize(bytes)); } } catch (Exception e) { - log.warn("文件信息匹配异常", e); + log.warn("文件信息解析异常", e); } } } diff --git a/parser/src/test/java/cn/qaiu/parser/impl/LzToolAjaxExtractTest.java b/parser/src/test/java/cn/qaiu/parser/impl/LzToolAjaxExtractTest.java deleted file mode 100644 index 104f77e..0000000 --- a/parser/src/test/java/cn/qaiu/parser/impl/LzToolAjaxExtractTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package cn.qaiu.parser.impl; - -import org.junit.Test; - -import static org.junit.Assert.*; - -public class LzToolAjaxExtractTest { - - @Test - public void testExtractWpSignAjaxfile() { - String html = """ - - """; - LzTool.AjaxCall call = LzTool.extractAjaxFromHtml(html, null); - assertNotNull(call); - assertEquals("/ajaxfile.php?file=150233466", call.path()); - assertEquals("BWMGOF5v", call.form().get("sign")); - assertEquals("MrBR", call.form().get("websignkey")); - assertEquals("downprocess", call.form().get("action")); - assertEquals("1", call.form().get("kd")); - } - - @Test - public void testExtractPasswordSign() { - String html = """ - - - """; - LzTool.AjaxCall call = LzTool.extractAjaxFromHtml(html, "e4k4"); - assertNotNull(call); - assertEquals("/ajaxm.php?file=1", call.path()); - assertEquals("ABC123", call.form().get("sign")); - assertEquals("e4k4", call.form().get("p")); - } -} diff --git a/pom.xml b/pom.xml index c2f9d53..a8c287c 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ - 0.4.3 + 0.4.4 17 17 17