diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 0a4fa8c..bea3e6a 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -262,9 +262,27 @@ jobs: name: ${{ matrix.artifact-name }} path: ${{ matrix.artifact-name }}.zip - - name: 上传到 Release + # ================================================================ + # 阶段三:创建 GitHub Release(只跑一次,避免矩阵任务重复拼接更新日志) + # ================================================================ + publish-release: + name: 发布 GitHub Release + needs: native-package + if: github.event_name != 'pull_request' && github.ref_type == 'tag' + runs-on: ubuntu-latest + steps: + - name: 下载原生安装包 + uses: actions/download-artifact@v4 + with: + pattern: netdisk-fast-download-* + merge-multiple: true + + - name: 创建 Release uses: softprops/action-gh-release@v2 with: - files: ${{ matrix.artifact-name }}.zip + files: | + netdisk-fast-download-linux-amd64.zip + netdisk-fast-download-windows-amd64.zip tag_name: ${{ github.ref_name }} generate_release_notes: true + fail_on_unmatched_files: true 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 9f0f2f6..4c6333b 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/LzTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/LzTool.java @@ -1,5 +1,6 @@ package cn.qaiu.parser.impl; +import cn.qaiu.WebClientVertxInit; import cn.qaiu.entity.FileInfo; import cn.qaiu.entity.ShareLinkInfo; import cn.qaiu.parser.PanBase; @@ -8,20 +9,30 @@ import io.netty.handler.codec.http.cookie.DefaultCookie; import io.vertx.core.Future; import io.vertx.core.MultiMap; import io.vertx.core.Promise; +import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonObject; +import io.vertx.core.net.ProxyOptions; +import io.vertx.core.net.ProxyType; +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.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.regex.Matcher; import java.util.regex.Pattern; +import java.util.zip.GZIPInputStream; /** * 蓝奏云解析工具 @@ -30,12 +41,26 @@ import java.util.regex.Pattern; */ public class LzTool extends PanBase { - WebClientSession webClientSession = WebClientSession.create(clientNoRedirects); + /** ESA 对 gzip 响应常见不带可识别的 Content-Encoding,需客户端自动解压。 */ + private final WebClient lzClient; + private 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\">(.*?)<"); + 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_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_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+)"); + 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\">(.*?)"); @@ -43,13 +68,14 @@ public class LzTool extends PanBase { 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=\"(/fn\\?[a-zA-Z\\d_+/=]{16,})\""); + 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(""" 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: gzip, deflate + 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 @@ -68,20 +94,115 @@ public class LzTool extends PanBase { public LzTool(ShareLinkInfo shareLinkInfo) { super(shareLinkInfo); + this.lzClient = createLzClient(shareLinkInfo); + this.webClientSession = WebClientSession.create(lzClient); + } + + /** + * ESA 对 gzip 响应常见不带可识别的 Content-Encoding,需客户端自动解压; + * 代理模式必须带上 shareLinkInfo 里的 proxy,不能绕过全局代理。 + */ + private static WebClient createLzClient(ShareLinkInfo shareLinkInfo) { + WebClientOptions opts = new WebClientOptions() + .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) { + 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")); + } + if (StringUtils.isNotEmpty(proxy.getString("password"))) { + proxyOptions.setPassword(proxy.getString("password")); + } + opts.setProxyOptions(proxyOptions); + } + } + return WebClient.create(WebClientVertxInit.get(), opts); + } + + /** + * Vert.x decompressionSupported 可能已经解压,但响应头仍带 gzip; + * 再走 PanBase.asText 会二次解压得到乱码,iframe / down_p 都匹配不到。 + */ + @Override + protected String asText(HttpResponse res) { + try { + Object raw = res.body(); + if (raw instanceof Buffer body && body.length() > 0) { + int i = 0; + int len = body.length(); + while (i < len) { + byte c = body.getByte(i); + if (c != ' ' && c != '\n' && c != '\r' && c != '\t') { + if (c == '<' || c == '{' || c == '[') { + return body.toString(); + } + if ((c & 0xff) == 0x1f && i + 1 < len && (body.getByte(i + 1) & 0xff) == 0x8b) { + return gunzipUtf8(body); + } + break; + } + i++; + } + } + } catch (Exception ignored) { + } + return super.asText(res); + } + + private static String gunzipUtf8(Buffer body) { + try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(body.getBytes()))) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + return body.toString(); + } + } + + @Override + protected JsonObject asJson(HttpResponse res) { + JsonObject parsed = parseLzJson(asText(res)); + if (parsed != null) { + return parsed; + } + return super.asJson(res); + } + + /** ajax 常被标成 gzip/text/json,body 可能已解压或为空,不能直接 bodyAsJsonObject。 */ + private static JsonObject parseLzJson(String text) { + if (text == null) { + return null; + } + String t = text.trim(); + int start = t.indexOf('{'); + if (start < 0) { + return null; + } + try { + return new JsonObject(t.substring(start)); + } catch (Exception e) { + return null; + } } public Future parse() { String sUrl = shareLinkInfo.getStandardUrl(); String pwd = shareLinkInfo.getSharePassword(); - WebClient client = clientNoRedirects; - client.getAbs(sUrl) + webClientSession.getAbs(sUrl) .putHeaders(headers0) .send().onSuccess(res -> { try { String html = asText(res); if (hasAcwArg1(html)) { - webClientSession = WebClientSession.create(clientNoRedirects); + webClientSession = WebClientSession.create(lzClient); if (!setCookie(html, sUrl)) { fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常"); return; @@ -117,14 +238,14 @@ public class LzTool extends PanBase { fail("分享已失效或文件已取消分享"); return; } - // 检测是否为目录分享链接 (含 /s/、/b/ 路径段或 b 开头的路径段) - if (sUrl.matches(".*/(s|b)/[^/]+.*") || sUrl.matches(".*/b[^/]+.*")) { + // 检测是否为目录分享链接 (含 /s/、/b/ 路径段或 b0 开头的路径段) + if (sUrl.matches(".*/(s|b)/[^/]+.*") || sUrl.matches(".*/b0[^/]+.*")) { fail("该链接为蓝奏云目录分享,请使用目录解析接口"); return; } // 若仍是校验页 (parse()中cookie域名与实际URL不匹配时会出现), 重试一次 if (hasAcwArg1(html)) { - webClientSession = WebClientSession.create(clientNoRedirects); + webClientSession = WebClientSession.create(lzClient); if (!setCookie(html, sUrl)) { fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常"); return; @@ -168,63 +289,33 @@ public class LzTool extends PanBase { Matcher matcher = IFRAME_SRC_PATTERN.matcher(html); // 没有Iframe说明是加密分享, 匹配sign通过密码请求下载页面 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 { - String jsText = getJsByPwd(pwd, html, "document.getElementById('rpt')"); - ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, "down_p"); - getDownURL(sUrl, scriptObjectMirror); + if (!postAjaxFromHtml(sUrl, html, pwd)) { + fail("未找到下载参数,可能密码错误或分享已失效 htmlLen=" + html.length() + + " hasFn=" + html.contains("/fn?") + + " hasIframe=" + html.contains("iframe")); + } } catch (Exception e) { - fail(e, "js引擎执行失败"); + fail(e, "js引擎执行失败 htmlLen=" + html.length() + + " hasFn=" + html.contains("/fn?") + + " hasIframe=" + html.contains("iframe")); } } else { // 没有密码 String iframePath = matcher.group(1); - String absoluteURI = SHARE_URL_PREFIX + iframePath; + 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); - if (isShareCancelledPage(html2)) { - fail("分享已失效或文件已取消分享"); - return; - } - String jsText = getJsText(html2); - if (jsText == null) { - if (!setCookie(html2, absoluteURI)) { - fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常"); - return; - } - webClientSession.getAbs(absoluteURI).send().onSuccess(res3 -> { - try { - String html3 = asText(res3); - if (isShareCancelledPage(html3)) { - fail("分享已失效或文件已取消分享"); - return; - } - String jsText3 = getJsText(html3); - if (jsText3 != null) { - try { - ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText3, null); - getDownURL(sUrl, scriptObjectMirror); - } catch (ScriptException | NoSuchMethodException e) { - fail(e, "引擎执行失败"); - } - } else { - fail(SHARE_URL_PREFIX + iframePath + " -> " + sUrl + ": 获取失败0, 可能分享已失效"); - } - } catch (Exception e) { - fail("蓝奏云 iframe 响应处理异常: {}", e.getMessage()); - } - }).onFailure(handleFail(absoluteURI)); - } else { - try { - ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, null); - getDownURL(sUrl, scriptObjectMirror); - } catch (ScriptException | NoSuchMethodException e) { - fail(e, "js引擎执行失败"); - } - } + handleIframeHtml(html2, sUrl, absoluteURI, iframePath, headersCopy); } catch (Exception e) { fail("蓝奏云 iframe 响应处理异常: {}", e.getMessage()); } @@ -232,6 +323,43 @@ public class LzTool extends PanBase { } } + 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; + } + submitIframeAjax(html3, sUrl, absoluteURI, iframePath); + } catch (Exception e) { + fail("蓝奏云 iframe 响应处理异常: {}", e.getMessage()); + } + }).onFailure(handleFail(absoluteURI)); + return; + } + submitIframeAjax(html2, sUrl, absoluteURI, iframePath); + } + + private void submitIframeAjax(String iframeHtml, String sUrl, String absoluteURI, String iframePath) { + try { + if (!postAjaxFromHtml(absoluteURI, iframeHtml, null)) { + fail(SHARE_URL_PREFIX + iframePath + " -> " + sUrl + ": 获取失败0, 可能分享已失效"); + } + } catch (ScriptException | NoSuchMethodException e) { + fail(e, "js引擎执行失败"); + } + } + private boolean setCookie(String html, String url) { String arg1 = extractAcwArg1(html); if (arg1 == null) { @@ -262,9 +390,17 @@ public class LzTool extends PanBase { String jsText = getJsText(html); if (jsText == null) { - throw new RuntimeException("获取失败1, 可能分享已失效"); + 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); @@ -273,21 +409,43 @@ public class LzTool extends PanBase { } private String getJsText(String html) { - if (html == null) { + 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 = ""; int index = html.lastIndexOf(jsTagStart); if (index == -1) { return null; } int startPos = index + jsTagStart.length(); - int endPos = html.indexOf(jsTagEnd, startPos); - if (endPos <= startPos) { + int endPos = html.indexOf("", startPos); + if (endPos < 0) { return null; } - return html.substring(startPos, endPos).replaceAll("", ""); + 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) { @@ -316,20 +474,210 @@ public class LzTool extends PanBase { return html != null && html.contains("var arg1='"); } + private boolean postAjaxFromHtml(String referer, String html, String pwd) + throws ScriptException, NoSuchMethodException { + 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) { + 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); + return true; + } + + /** 页面里提取出的 ajax 调用:相对路径 + 表单参数。 */ + record AjaxCall(String path, Map form) { + MultiMap toForm() { + MultiMap m = MultiMap.caseInsensitiveMultiMap(); + form.forEach(m::set); + return m; + } + } + + 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) { + if (html == null || html.isEmpty()) { + return null; + } + Matcher ajax = P_AJAX_PATH.matcher(html); + if (!ajax.find()) { + return null; + } + String ajaxPath = ajax.group(1); + Map data = new LinkedHashMap<>(); + data.put("action", "downprocess"); + Matcher wp = P_WP_SIGN.matcher(html); + Matcher ad = P_AJAXDATA.matcher(html); + Matcher isngis = P_ISNGIS.matcher(html); + String lastIsngis = null; + while (isngis.find()) { + if (!isngis.group(1).isEmpty()) { + 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()) { + data.put("websignkey", ad.group(1)); + data.put("signs", ad.group(1)); + } + data.put("websign", ""); + Matcher ws = P_WEBSIGN.matcher(html); + if (ws.find()) { + data.put("websign", ws.group(1)); + } + data.put("kd", kd); + 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); + if (pwd != null && !pwd.isEmpty()) { + data.put("p", pwd); + } + } else { + List signs = new ArrayList<>(); + Matcher sm = P_SIGN.matcher(html); + while (sm.find()) { + signs.add(sm.group(1)); + } + if (signs.isEmpty()) { + return null; + } + data.put("sign", signs.size() > 1 ? signs.get(1) : signs.get(0)); + if (pwd != null && !pwd.isEmpty()) { + data.put("p", pwd); + } + data.put("kd", kd); + Matcher ad2 = P_AJAXDATA.matcher(html); + if (ad2.find()) { + data.put("websignkey", ad2.group(1)); + data.put("signs", ad2.group(1)); + } + } + return new AjaxCall("/" + ajaxPath, data); + } + + static AjaxCall extractFolderAjax(String html, String pwd) { + if (html == null || html.isEmpty()) { + return null; + } + Matcher block = P_FILEMORE.matcher(html); + if (!block.find()) { + return null; + } + Map data = new LinkedHashMap<>(); + Matcher kv = P_DATA_KV.matcher(block.group(2)); + while (kv.find()) { + String key = kv.group(1); + String raw = kv.group(2); + if ("pwd".equals(key)) { + data.put(key, pwd == null ? "" : pwd); + continue; + } + if ("pg".equals(key) || "pgs".equals(key)) { + data.put("pg", "1"); + continue; + } + data.put(key, resolveJsValue(html, raw)); + } + if (!data.containsKey("fid") || !data.containsKey("t") || !data.containsKey("k")) { + return null; + } + if (pwd != null && !pwd.isEmpty()) { + data.put("pwd", pwd); + } + return new AjaxCall(block.group(1), data); + } + + private static String resolveJsValue(String html, String raw) { + if (raw == null) { + return ""; + } + if (raw.length() >= 2 && raw.charAt(0) == '\'' && raw.charAt(raw.length() - 1) == '\'') { + return raw.substring(1, raw.length() - 1); + } + if (raw.matches("\\d+")) { + return raw; + } + Matcher m = Pattern.compile("var\\s+" + Pattern.quote(raw) + "\\s*=\\s*'([^']*)'").matcher(html); + if (m.find()) { + return m.group(1); + } + if ("pgs".equals(raw)) { + return "1"; + } + return raw; + } + + private static void parseFormString(MultiMap map, String form) { + if (form == null || form.isBlank()) { + return; + } + for (String part : form.split("&")) { + int eq = part.indexOf('='); + if (eq > 0) { + map.add(part.substring(0, eq), part.substring(eq + 1)); + } + } + } + private void getDownURL(String key, Map obj) { if (obj == null) { fail("需要访问密码"); return; } - Map signMap = (Map)obj.get("data"); + Object dataObj = obj.get("data"); + if (dataObj == null) { + fail("需要访问密码"); + return; + } String url0 = String.valueOf(obj.get("url")); MultiMap map = MultiMap.caseInsensitiveMultiMap(); - signMap.forEach((k, v) -> { - map.add((String) k, v.toString()); - }); + 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: gzip, deflate, br + 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 @@ -347,83 +695,176 @@ public class LzTool extends PanBase { headers.set("referer", key); // action=downprocess&signs=%3Fctdf&websignkey=I5gl&sign=BWMGOF1sBTRWXwI9BjZdYVA7BDhfNAIyUG9UawJtUGMIPlAhACkCa1UyUTAAYFxvUj5XY1E7UGFXaFVq&websign=&kd=1&ves=1 - String url = SHARE_URL_PREFIX + url0; + String url = joinUrl(SHARE_URL_PREFIX, url0); webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res2 -> { try { JsonObject urlJson = asJson(res2); - String name = urlJson.getString("inf"); - if (urlJson.getInteger("zt") != 1) { - fail(name); + 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 (urlJson.containsKey("inf") && urlJson.getMap().get("inf") instanceof CharSequence) { - ((FileInfo)shareLinkInfo.getOtherParam().get("fileInfo")).setFileName(name); + 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"); - headers.remove("Referer"); - webClientSession.getAbs(downUrl).putHeaders(headers).send() - .onSuccess(res3 -> { - try { - String location = res3.headers().get("Location"); - if (location == null) { - String text = asText(res3); - if (isShareCancelledPage(text)) { - fail(downUrl + " -> 分享已失效或文件已取消分享"); - return; - } - // 使用cookie 再请求一次 - headers.add("Referer", downUrl); - String arg1 = extractAcwArg1(text); - if (arg1 == null) { - fail(downUrl + " -> 蓝奏云反爬 arg1 Cookie 解析失败,可能分享已失效"); - return; - } - String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1); - // 从 downUrl 中动态提取域名 - String downDomain = ".lanrar.com"; - try { - java.net.URL du = new java.net.URL(downUrl); - String h = du.getHost(); - int dot = h.indexOf('.'); - if (dot >= 0) downDomain = h.substring(dot); - } catch (MalformedURLException ignored) {} - // 创建一个 Cookie 并放入 CookieStore - DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2); - nettyCookie.setDomain(downDomain); - nettyCookie.setPath("/"); - nettyCookie.setSecure(false); - nettyCookie.setHttpOnly(false); - WebClientSession webClientSession2 = WebClientSession.create(clientNoRedirects); - webClientSession2.cookieStore().put(nettyCookie); - webClientSession2.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)); - return; - } - setDateAndComplete(location); - } catch (Exception e) { - fail("蓝奏云直链响应处理异常: {}", e.getMessage()); - } - }) - .onFailure(handleFail(downUrl)); + followFileUrl(downUrl, headers); } catch (Exception e) { - fail("解析异常"); + fail(e, "解析异常"); } }).onFailure(handleFail(url)); } + /** + * 下载域已不再走 arg1/acw_sc__v2 挑战页;带 down_ip=1 时通常直接 302。 + * 未跳转则走页面 down_r → POST /ajax.php 二次验证。 + */ + private void followFileUrl(String downUrl, MultiMap headers) { + 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); + 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()); + } + }) + .onFailure(handleFail(downUrl)); + } + + 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 + " -> 二次验证参数缺失"); + return; + } + String file = fileM.group(1); + String sign = signM.group(1); + WebClientVertxInit.get().setTimer(2000, id -> postVerifyAjax(origin, downUrl, file, sign, headers, false)); + } + + 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; + } + 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)); + } + + private void putSessionCookie(String name, String value, String url, String fallbackDomain) { + String domain = fallbackDomain; + 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); + } + + private static String originOf(String url, String fallback) { + try { + java.net.URL u = new java.net.URL(url); + return u.getProtocol() + "://" + u.getHost(); + } catch (MalformedURLException e) { + return fallback; + } + } + + private static String joinUrl(String base, String path) { + if (path == null || path.isBlank()) { + return base; + } + if (path.startsWith("http://") || path.startsWith("https://")) { + return path; + } + if (base.endsWith("/") && path.startsWith("/")) { + return base.substring(0, base.length() - 1) + path; + } + if (!base.endsWith("/") && !path.startsWith("/")) { + return base + "/" + path; + } + return base + path; + } + private void setDateAndComplete(String location0) { // 分享时间 提取url中的时间戳格式:lanzoui.com/abc/abc/yyyy/mm/dd/ Matcher matcher = URL_DATE_PATTERN.matcher(location0); @@ -460,7 +901,7 @@ public class LzTool extends PanBase { String html = asText(res); // 检查是否需要 cookie 验证 if (hasAcwArg1(html)) { - webClientSession = WebClientSession.create(clientNoRedirects); + webClientSession = WebClientSession.create(lzClient); if (!setCookie(html, sUrl)) { promise.tryFail(baseMsg() + "蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常"); return; @@ -492,21 +933,26 @@ public class LzTool extends PanBase { promise.tryFail(baseMsg() + "分享已失效或文件已取消分享"); return; } - // 检测是否为文件分享链接 (不含 /s/、/b/ 路径段且不含 b 开头的路径段) - if (!sUrl.matches(".*/(s|b)/[^/]+.*") && !sUrl.matches(".*/b[^/]+.*")) { + // 检测是否为文件分享链接 (不含 /s/、/b/ 路径段且不含 b0 开头的路径段) + if (!sUrl.matches(".*/(s|b)/[^/]+.*") && !sUrl.matches(".*/b0[^/]+.*")) { promise.tryFail(baseMsg() + "该链接为蓝奏云文件分享,请使用文件解析接口"); return; } try { - String jsText = getJsByPwd(pwd, html, "var urls =window.location.href"); - ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, "file"); - Map data = CastUtil.cast(scriptObjectMirror.get("data")); - MultiMap map = MultiMap.caseInsensitiveMultiMap(); - data.forEach((k, v) -> map.set(k, v.toString())); - log.debug("解析参数: {}", map); + 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); + } + log.debug("解析参数: {}", call.form()); MultiMap headers = getHeaders(sUrl); + MultiMap map = call.toForm(); - String url = SHARE_URL_PREFIX + "filemoreajax.php?file=" + data.get("fid"); + String url = joinUrl(SHARE_URL_PREFIX, call.path()); webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res2 -> { try { String resBody = asText(res2); @@ -542,7 +988,10 @@ public class LzTool extends PanBase { promise.tryFail(baseMsg() + "蓝奏云文件列表响应为空"); return; } - JsonObject fileListJson = new JsonObject(responseBody); + JsonObject fileListJson = parseLzJson(responseBody); + if (fileListJson == null) { + fileListJson = new JsonObject(responseBody); + } if (fileListJson.getInteger("zt") != 1) { promise.tryFail(baseMsg() + fileListJson.getString("info")); return; diff --git a/parser/src/main/java/cn/qaiu/util/JsContent.java b/parser/src/main/java/cn/qaiu/util/JsContent.java index 42c84a4..9d6afba 100644 --- a/parser/src/main/java/cn/qaiu/util/JsContent.java +++ b/parser/src/main/java/cn/qaiu/util/JsContent.java @@ -101,59 +101,384 @@ public interface JsContent { """; String lz = """ /** - * 蓝奏云解析器js签名获取工具 + * 蓝奏云解析器 JS 沙箱:伪装 jQuery / document / window。 + * 新版页面会用 document.cookie、location.reload、querySelector、 + * $('#pwd').val()、.html()、.css() 等,这里做成可链式的最小实现。 + * kdns.js 在浏览器里是 `var killdns = true`,需一并注入,否则 kd 会被改成 0。 */ var signObj; + var __lzPwd = ''; + var killdns = true; + function __lzSetPwd(p) { + __lzPwd = p == null ? '' : String(p); + } + + function __lzEl(id) { + var nid = String(id == null ? '' : id).replace(/^[#.]/, ''); + var el = { + id: nid, + _value: nid === 'pwd' ? __lzPwd : '', + checked: false, + disabled: false, + innerHTML: '', + innerText: '', + textContent: '', + className: '', + style: { display: '', visibility: '', width: '', height: '' }, + classList: { + add: function () {}, + remove: function () {}, + contains: function () { return false; }, + toggle: function () {} + }, + setAttribute: function () {}, + getAttribute: function () { return null; }, + removeAttribute: function () {}, + addEventListener: function (t, fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} + } + }, + removeEventListener: function () {}, + appendChild: function (n) { return n; }, + removeChild: function (n) { return n; }, + insertBefore: function (n) { return n; }, + click: function () {}, + focus: function () {}, + blur: function () {}, + submit: function () {}, + select: function () {}, + reset: function () {} + }; + el.parentNode = el; + el.parentElement = el; + el.children = []; + el.childNodes = []; + el.firstChild = null; + el.lastChild = null; + try { + Object.defineProperty(el, 'value', { + get: function () { return nid === 'pwd' ? __lzPwd : el._value; }, + set: function (v) { + el._value = v; + if (nid === 'pwd') { + __lzPwd = v == null ? '' : String(v); + } + } + }); + } catch (e) { + el.value = el._value; + } + return el; + } + + function __lzJq(sel) { + var id = ''; + if (typeof sel === 'string') { + id = sel.replace(/^[#.]/, ''); + } else if (sel && sel.id) { + id = String(sel.id); + } + var el = (sel && sel.style && sel.addEventListener) ? sel : __lzEl(id); + var api = { + 0: el, + length: 1, + selector: sel, + ready: function (fn) { + if (typeof fn === 'function') { + try { fn(jQuery); } catch (e) {} + } + return api; + }, + on: function (t, fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} + } + return api; + }, + off: function () { return api; }, + bind: function (t, fn) { return api.on(t, fn); }, + unbind: function () { return api; }, + click: function (fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} + } + return api; + }, + focus: function (fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} + } + return api; + }, + blur: function () { return api; }, + keyup: function (fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} + } + return api; + }, + keydown: function (fn) { return api.keyup(fn); }, + keypress: function (fn) { return api.keyup(fn); }, + submit: function (fn) { return api.click(fn); }, + change: function (fn) { return api.click(fn); }, + hover: function () { return api; }, + val: function (v) { + if (arguments.length === 0) { + if (typeof el.value !== 'undefined') { + return el.value; + } + return (id === 'pwd') ? __lzPwd : ''; + } + el.value = v; + return api; + }, + html: function (v) { + if (arguments.length === 0) { + return el.innerHTML; + } + el.innerHTML = v; + return api; + }, + text: function (v) { + if (arguments.length === 0) { + return el.innerText; + } + el.innerText = v; + el.textContent = v; + return api; + }, + attr: function (k, v) { + if (arguments.length < 2) { + return null; + } + return api; + }, + prop: function (k, v) { + if (arguments.length < 2) { + return false; + } + return api; + }, + css: function () { return api; }, + addClass: function () { return api; }, + removeClass: function () { return api; }, + toggleClass: function () { return api; }, + hasClass: function () { return false; }, + show: function () { + el.style.display = ''; + return api; + }, + hide: function () { + el.style.display = 'none'; + return api; + }, + fadeIn: function () { return api; }, + fadeOut: function () { return api; }, + animate: function () { return api; }, + find: function () { return api; }, + parent: function () { return api; }, + children: function () { return api; }, + eq: function () { return api; }, + first: function () { return api; }, + last: function () { return api; }, + each: function (fn) { + if (typeof fn === 'function') { + try { fn.call(el, 0, el); } catch (e) {} + } + return api; + }, + append: function () { return api; }, + prepend: function () { return api; }, + remove: function () { return api; }, + empty: function () { return api; }, + ajax: function (obj) { + signObj = obj; + return api; + }, + get: function () { return el; } + }; + return api; + } var $, jQuery; - - $ = jQuery = function () { - return new jQuery.fn.init(); - } + $ = jQuery = function (sel) { + if (typeof sel === 'function') { + try { sel(jQuery); } catch (e) {} + return __lzJq(document); + } + return __lzJq(sel); + }; jQuery.fn = jQuery.prototype = { - init: function () { - return { - focus: function (a) { - - }, - keyup: function(a) { - - }, - ajax: function (obj) { - signObj = obj - }, - val: function(a) { - - }, - - } - }, - - } - + init: function (sel) { + return __lzJq(sel); + } + }; jQuery.fn.init.prototype = jQuery.fn; - + $.fn = jQuery.fn; $.ajax = function (obj) { - signObj = obj - } + signObj = obj; + return { + done: function () { return this; }, + fail: function () { return this; }, + always: function () { return this; } + }; + }; + $.get = function () {}; + $.post = function () {}; + $.extend = function () { + var t = arguments[0] || {}; + for (var i = 1; i < arguments.length; i++) { + var s = arguments[i]; + if (s) { + for (var k in s) { + if (s.hasOwnProperty(k)) { + t[k] = s[k]; + } + } + } + } + return t; + }; + $.each = function (obj, fn) { + if (!obj || typeof fn !== 'function') { + return obj; + } + if (typeof obj.length === 'number') { + for (var i = 0; i < obj.length; i++) { + fn.call(obj[i], i, obj[i]); + } + } else { + for (var k in obj) { + if (obj.hasOwnProperty(k)) { + fn.call(obj[k], k, obj[k]); + } + } + } + return obj; + }; + $.isFunction = function (f) { return typeof f === 'function'; }; + $.isArray = function (a) { + return Object.prototype.toString.call(a) === '[object Array]'; + }; + $.trim = function (s) { + return s == null ? '' : String(s).replace(/^\\s+|\\s+$/g, ''); + }; + + var __lzLocation = { + href: '', + search: '', + pathname: '/', + hash: '', + host: '', + hostname: '', + protocol: 'https:', + port: '', + origin: '', + assign: function () {}, + replace: function () {}, + reload: function () {} + }; var document = { - getElementById: function (v) { - return { - value: 'v', - style: { - display: '' - }, - addEventListener: function() {} + cookie: '', + title: '', + domain: '', + referrer: '', + readyState: 'complete', + hidden: false, + visibilityState: 'visible', + documentElement: null, + body: null, + head: null, + location: __lzLocation, + getElementById: function (id) { return __lzEl(id); }, + getElementsByClassName: function () { return []; }, + getElementsByTagName: function (t) { + return t === 'script' ? [] : [__lzEl(t)]; + }, + getElementsByName: function () { return []; }, + querySelector: function (s) { return __lzEl(s); }, + querySelectorAll: function (s) { return [__lzEl(s)]; }, + createElement: function (t) { return __lzEl(t); }, + createTextNode: function (t) { return { nodeValue: t, data: t }; }, + createDocumentFragment: function () { return __lzEl('fragment'); }, + addEventListener: function (t, fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} } }, + removeEventListener: function () {}, + write: function () {}, + writeln: function () {}, + open: function () {}, + close: function () {} + }; + document.documentElement = __lzEl('html'); + document.body = __lzEl('body'); + document.head = __lzEl('head'); + + var navigator = { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36', + platform: 'Win32', + language: 'zh-CN', + cookieEnabled: true, + onLine: true + }; + + var location = __lzLocation; + var console = { + log: function () {}, + warn: function () {}, + error: function () {}, + info: function () {}, + debug: function () {} + }; + + function setTimeout(fn, delay) { + if (typeof fn === 'function' && (!delay || delay <= 0)) { + try { fn(); } catch (e) {} + } + return 0; } - - var window = {location: {}} + function setInterval() { return 0; } + function clearTimeout() {} + function clearInterval() {} + + var window = { + location: __lzLocation, + document: document, + navigator: navigator, + console: console, + innerWidth: 1920, + innerHeight: 1080, + setTimeout: setTimeout, + setInterval: setInterval, + clearTimeout: clearTimeout, + clearInterval: clearInterval, + addEventListener: function (t, fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} + } + }, + removeEventListener: function () {}, + atob: function (s) { return s; }, + btoa: function (s) { return s; } + }; + window.window = window; + window.top = window; + window.self = window; + window.parent = window; + window.jQuery = jQuery; + window.$ = $; + + var top = window; + var self = window; + var parent = window; """; String kwSignString = """ diff --git a/parser/src/main/java/cn/qaiu/util/JsExecUtils.java b/parser/src/main/java/cn/qaiu/util/JsExecUtils.java index 0abd79a..342b214 100644 --- a/parser/src/main/java/cn/qaiu/util/JsExecUtils.java +++ b/parser/src/main/java/cn/qaiu/util/JsExecUtils.java @@ -50,21 +50,47 @@ public class JsExecUtils { */ public static ScriptObjectMirror executeDynamicJs(String jsText, String funName) throws ScriptException, NoSuchMethodException { - ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎 + return executeDynamicJs(jsText, funName, null); + } + + /** + * @param pwd 分享密码,写入伪 DOM(#pwd / getElementById('pwd')),供新版页面取值 + */ + public static ScriptObjectMirror executeDynamicJs(String jsText, String funName, String pwd) throws ScriptException, + NoSuchMethodException { + ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); try { - engine.eval(JsContent.lz + "\n" + jsText); + engine.eval(JsContent.lz); Invocable inv = (Invocable) engine; - //调用js中的函数 - if (StringUtils.isNotEmpty(funName)) { - inv.invokeFunction(funName); + if (pwd != null) { + inv.invokeFunction("__lzSetPwd", pwd); } - return (ScriptObjectMirror) engine.get("signObj"); + try { + engine.eval(jsText); + if (StringUtils.isNotEmpty(funName)) { + inv.invokeFunction(funName); + } + } catch (ScriptException | NoSuchMethodException | RuntimeException e) { + ScriptObjectMirror captured = asSignObj(engine.get("signObj")); + if (captured != null) { + return captured; + } + throw e; + } + return asSignObj(engine.get("signObj")); } finally { - // 清理引擎持有的引用,帮助 GC 回收 clearEngineBindings(engine); } } + private static ScriptObjectMirror asSignObj(Object sign) { + if (sign instanceof ScriptObjectMirror mirror + && (mirror.get("url") != null || mirror.get("data") != null)) { + return mirror; + } + return null; + } + /** * 调用执行js文件(使用缓存的 ScriptEngineManager 创建新引擎实例) diff --git a/parser/src/main/resources/js/lz.js b/parser/src/main/resources/js/lz.js index 7df1e7b..fd16f53 100644 --- a/parser/src/main/resources/js/lz.js +++ b/parser/src/main/resources/js/lz.js @@ -1,46 +1,379 @@ /** - * 蓝奏云解析器js签名获取工具 + * 蓝奏云解析器 JS 沙箱:伪装 jQuery / document / window。 + * 新版页面会用 document.cookie、location.reload、querySelector、 + * $('#pwd').val()、.html()、.css() 等,这里做成可链式的最小实现。 + * kdns.js 在浏览器里是 `var killdns = true`,需一并注入,否则 kd 会被改成 0。 */ var signObj; +var __lzPwd = ''; +var killdns = true; +function __lzSetPwd(p) { + __lzPwd = p == null ? '' : String(p); +} + +function __lzEl(id) { + var nid = String(id == null ? '' : id).replace(/^[#.]/, ''); + var el = { + id: nid, + _value: nid === 'pwd' ? __lzPwd : '', + checked: false, + disabled: false, + innerHTML: '', + innerText: '', + textContent: '', + className: '', + style: { display: '', visibility: '', width: '', height: '' }, + classList: { + add: function () {}, + remove: function () {}, + contains: function () { return false; }, + toggle: function () {} + }, + setAttribute: function () {}, + getAttribute: function () { return null; }, + removeAttribute: function () {}, + addEventListener: function (t, fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} + } + }, + removeEventListener: function () {}, + appendChild: function (n) { return n; }, + removeChild: function (n) { return n; }, + insertBefore: function (n) { return n; }, + click: function () {}, + focus: function () {}, + blur: function () {}, + submit: function () {}, + select: function () {}, + reset: function () {} + }; + el.parentNode = el; + el.parentElement = el; + el.children = []; + el.childNodes = []; + el.firstChild = null; + el.lastChild = null; + try { + Object.defineProperty(el, 'value', { + get: function () { return nid === 'pwd' ? __lzPwd : el._value; }, + set: function (v) { + el._value = v; + if (nid === 'pwd') { + __lzPwd = v == null ? '' : String(v); + } + } + }); + } catch (e) { + el.value = el._value; + } + return el; +} + +function __lzJq(sel) { + var id = ''; + if (typeof sel === 'string') { + id = sel.replace(/^[#.]/, ''); + } else if (sel && sel.id) { + id = String(sel.id); + } + var el = (sel && sel.style && sel.addEventListener) ? sel : __lzEl(id); + var api = { + 0: el, + length: 1, + selector: sel, + ready: function (fn) { + if (typeof fn === 'function') { + try { fn(jQuery); } catch (e) {} + } + return api; + }, + on: function (t, fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} + } + return api; + }, + off: function () { return api; }, + bind: function (t, fn) { return api.on(t, fn); }, + unbind: function () { return api; }, + click: function (fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} + } + return api; + }, + focus: function (fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} + } + return api; + }, + blur: function () { return api; }, + keyup: function (fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} + } + return api; + }, + keydown: function (fn) { return api.keyup(fn); }, + keypress: function (fn) { return api.keyup(fn); }, + submit: function (fn) { return api.click(fn); }, + change: function (fn) { return api.click(fn); }, + hover: function () { return api; }, + val: function (v) { + if (arguments.length === 0) { + if (typeof el.value !== 'undefined') { + return el.value; + } + return (id === 'pwd') ? __lzPwd : ''; + } + el.value = v; + return api; + }, + html: function (v) { + if (arguments.length === 0) { + return el.innerHTML; + } + el.innerHTML = v; + return api; + }, + text: function (v) { + if (arguments.length === 0) { + return el.innerText; + } + el.innerText = v; + el.textContent = v; + return api; + }, + attr: function (k, v) { + if (arguments.length < 2) { + return null; + } + return api; + }, + prop: function (k, v) { + if (arguments.length < 2) { + return false; + } + return api; + }, + css: function () { return api; }, + addClass: function () { return api; }, + removeClass: function () { return api; }, + toggleClass: function () { return api; }, + hasClass: function () { return false; }, + show: function () { + el.style.display = ''; + return api; + }, + hide: function () { + el.style.display = 'none'; + return api; + }, + fadeIn: function () { return api; }, + fadeOut: function () { return api; }, + animate: function () { return api; }, + find: function () { return api; }, + parent: function () { return api; }, + children: function () { return api; }, + eq: function () { return api; }, + first: function () { return api; }, + last: function () { return api; }, + each: function (fn) { + if (typeof fn === 'function') { + try { fn.call(el, 0, el); } catch (e) {} + } + return api; + }, + append: function () { return api; }, + prepend: function () { return api; }, + remove: function () { return api; }, + empty: function () { return api; }, + ajax: function (obj) { + signObj = obj; + return api; + }, + get: function () { return el; } + }; + return api; +} var $, jQuery; - -$ = jQuery = function () { - return new jQuery.fn.init(); -} +$ = jQuery = function (sel) { + if (typeof sel === 'function') { + try { sel(jQuery); } catch (e) {} + return __lzJq(document); + } + return __lzJq(sel); +}; jQuery.fn = jQuery.prototype = { - init: function () { - return { - focus: function (a) { - - }, - keyup: function(a) { - - }, - ajax: function (obj) { - signObj = obj - } - - } - }, - -} - + init: function (sel) { + return __lzJq(sel); + } +}; jQuery.fn.init.prototype = jQuery.fn; +$.fn = jQuery.fn; - -// 伪装jquery.ajax函数获取关键数据 $.ajax = function (obj) { - signObj = obj -} + signObj = obj; + return { + done: function () { return this; }, + fail: function () { return this; }, + always: function () { return this; } + }; +}; +$.get = function () {}; +$.post = function () {}; +$.extend = function () { + var t = arguments[0] || {}; + for (var i = 1; i < arguments.length; i++) { + var s = arguments[i]; + if (s) { + for (var k in s) { + if (s.hasOwnProperty(k)) { + t[k] = s[k]; + } + } + } + } + return t; +}; +$.each = function (obj, fn) { + if (!obj || typeof fn !== 'function') { + return obj; + } + if (typeof obj.length === 'number') { + for (var i = 0; i < obj.length; i++) { + fn.call(obj[i], i, obj[i]); + } + } else { + for (var k in obj) { + if (obj.hasOwnProperty(k)) { + fn.call(obj[k], k, obj[k]); + } + } + } + return obj; +}; +$.isFunction = function (f) { return typeof f === 'function'; }; +$.isArray = function (a) { + return Object.prototype.toString.call(a) === '[object Array]'; +}; +$.trim = function (s) { + return s == null ? '' : String(s).replace(/^\s+|\s+$/g, ''); +}; + +var __lzLocation = { + href: '', + search: '', + pathname: '/', + hash: '', + host: '', + hostname: '', + protocol: 'https:', + port: '', + origin: '', + assign: function () {}, + replace: function () {}, + reload: function () {} +}; var document = { - getElementById: function (v) { - return { - value: 'v' + cookie: '', + title: '', + domain: '', + referrer: '', + readyState: 'complete', + hidden: false, + visibilityState: 'visible', + documentElement: null, + body: null, + head: null, + location: __lzLocation, + getElementById: function (id) { return __lzEl(id); }, + getElementsByClassName: function () { return []; }, + getElementsByTagName: function (t) { + return t === 'script' ? [] : [__lzEl(t)]; + }, + getElementsByName: function () { return []; }, + querySelector: function (s) { return __lzEl(s); }, + querySelectorAll: function (s) { return [__lzEl(s)]; }, + createElement: function (t) { return __lzEl(t); }, + createTextNode: function (t) { return { nodeValue: t, data: t }; }, + createDocumentFragment: function () { return __lzEl('fragment'); }, + addEventListener: function (t, fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} } }, + removeEventListener: function () {}, + write: function () {}, + writeln: function () {}, + open: function () {}, + close: function () {} +}; +document.documentElement = __lzEl('html'); +document.body = __lzEl('body'); +document.head = __lzEl('head'); + +var navigator = { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36', + platform: 'Win32', + language: 'zh-CN', + cookieEnabled: true, + onLine: true +}; + +var location = __lzLocation; +var console = { + log: function () {}, + warn: function () {}, + error: function () {}, + info: function () {}, + debug: function () {} +}; + +function setTimeout(fn, delay) { + if (typeof fn === 'function' && (!delay || delay <= 0)) { + try { fn(); } catch (e) {} + } + return 0; } +function setInterval() { return 0; } +function clearTimeout() {} +function clearInterval() {} + +var window = { + location: __lzLocation, + document: document, + navigator: navigator, + console: console, + innerWidth: 1920, + innerHeight: 1080, + setTimeout: setTimeout, + setInterval: setInterval, + clearTimeout: clearTimeout, + clearInterval: clearInterval, + addEventListener: function (t, fn) { + if (typeof fn === 'function') { + try { fn(); } catch (e) {} + } + }, + removeEventListener: function () {}, + atob: function (s) { return s; }, + btoa: function (s) { return s; } +}; +window.window = window; +window.top = window; +window.self = window; +window.parent = window; +window.jQuery = jQuery; +window.$ = $; + +var top = window; +var self = window; +var parent = window; diff --git a/parser/src/test/java/cn/qaiu/parser/impl/LzToolAjaxExtractTest.java b/parser/src/test/java/cn/qaiu/parser/impl/LzToolAjaxExtractTest.java new file mode 100644 index 0000000..104f77e --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/impl/LzToolAjaxExtractTest.java @@ -0,0 +1,52 @@ +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/parser/src/test/java/cn/qaiu/util/JsExecUtilsLzTest.java b/parser/src/test/java/cn/qaiu/util/JsExecUtilsLzTest.java new file mode 100644 index 0000000..96a52c0 --- /dev/null +++ b/parser/src/test/java/cn/qaiu/util/JsExecUtilsLzTest.java @@ -0,0 +1,90 @@ +package cn.qaiu.util; + +import org.junit.Test; +import org.openjdk.nashorn.api.scripting.ScriptObjectMirror; + +import java.util.Map; + +import static org.junit.Assert.*; + +/** + * 新版蓝奏页面会调用更多 document / jQuery API,沙箱必须能跑完并抓住 $.ajax。 + */ +public class JsExecUtilsLzTest { + + @Test + public void testNewIframeAjaxWithDomApis() throws Exception { + String js = """ + var lanosso = ''; + var down_1 = ''; + var wsk_sign = 'c20230908'; + var wp_sign = 'SIGN_ABC'; + var ajaxdata = 'MrBR'; + var kdns = 1; + if (typeof(killdns)=='undefined'){ + var kdns = 0; + } + document.cookie = 'x=1'; + document.location.reload(); + document.querySelector('#tourl'); + document.createElement('div'); + $.ajax({ + type : 'post', + url : '/ajaxfile.php?file=150233466', + data : { 'action':'downprocess','websignkey':ajaxdata,'signs':ajaxdata,'sign':wp_sign,'websign':'','kd':kdns,'ves':1 }, + dataType : 'json', + success:function(msg){ + $("#tourl").html("ok"); + $("#outime").css("display","block"); + } + }); + """; + ScriptObjectMirror sign = JsExecUtils.executeDynamicJs(js, null); + assertNotNull(sign); + assertEquals("/ajaxfile.php?file=150233466", String.valueOf(sign.get("url"))); + @SuppressWarnings("unchecked") + Map data = (Map) sign.get("data"); + assertEquals("downprocess", String.valueOf(data.get("action"))); + assertEquals("SIGN_ABC", String.valueOf(data.get("sign"))); + assertEquals("MrBR", String.valueOf(data.get("websignkey"))); + assertEquals("1", String.valueOf(data.get("kd"))); + } + + @Test + public void testPwdViaJqueryValAndDocument() throws Exception { + String js = """ + function down_p(){ + var pwd = $('#pwd').val(); + var pwd2 = document.getElementById('pwd').value; + var pwd3 = document.querySelector('#pwd').value; + $(".passwdinput").focus(); + $.ajax({ + type : 'post', + url : '/ajaxm.php', + data : { 'action':'downprocess','sign':'S1','p':pwd,'p2':pwd2,'p3':pwd3 } + }); + } + """; + ScriptObjectMirror sign = JsExecUtils.executeDynamicJs(js, "down_p", "e4k4"); + assertNotNull(sign); + @SuppressWarnings("unchecked") + Map data = (Map) sign.get("data"); + assertEquals("e4k4", String.valueOf(data.get("p"))); + assertEquals("e4k4", String.valueOf(data.get("p2"))); + assertEquals("e4k4", String.valueOf(data.get("p3"))); + } + + @Test + public void testReadyAndSuccessCallbackDoNotDropAjax() throws Exception { + String js = """ + $(function(){ + document.getElementById('rpt').style.display = 'none'; + $.ajax({ url: '/ajaxm.php?file=1', data: { a: 1 } }); + $("#tourl").html("x"); + }); + """; + ScriptObjectMirror sign = JsExecUtils.executeDynamicJs(js, null); + assertNotNull(sign); + assertTrue(String.valueOf(sign.get("url")).contains("ajaxm.php")); + } +} diff --git a/pom.xml b/pom.xml index a8834dd..c2f9d53 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ - 0.4.2 + 0.4.3 17 17 17