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)");
- private static final Pattern FILE_SIZE_PATTERN = Pattern.compile(">文件大小:(.*?)
|\"n_filesize\">大小:(.*?)");
- private static final Pattern SHARE_USER_PATTERN = Pattern.compile(">分享用户:(.*?)|获取(.*?)的文件|\"user-name\">(.*?)");
- private static final Pattern DESCRIPTION_PATTERN = Pattern.compile("(?s)文件描述:
(.*?)|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("", 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