lanzou 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/index.cjs +385 -0
- package/dist/index.d.cts +67 -0
- package/dist/index.d.mts +67 -0
- package/dist/index.mjs +352 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 <your name>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# lanzou
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/lanzou)
|
|
4
|
+
[](https://github.com/<your-username>/lanzou/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](https://www.npmjs.com/package/lanzou)
|
|
7
|
+
|
|
8
|
+
蓝奏云分享链接解析库:获取文件列表与下载直链。
|
|
9
|
+
|
|
10
|
+
## 特性
|
|
11
|
+
|
|
12
|
+
- **全自动 WAF 破解** — 命中 `acw_sc__v2` 反爬挑战页时本地计算通过验证,不执行远端 JS(无 vm 逃逸风险)
|
|
13
|
+
- **密码分享** — 支持带密码的分享链接
|
|
14
|
+
- **翻页获取** — 自动翻页取回文件夹内全部文件
|
|
15
|
+
- **域名回退** — 分享域名失效时自动切换兜底域名池(`filemoreajax` 是全局接口,任一存活子域均可查询)
|
|
16
|
+
- **直链提取** — 依次尝试 `vkjxld+hyggid` 拼接 / `iframe` / JS 跳转等多种页面结构,兼容站点改版
|
|
17
|
+
- **时间归一化** — "3 小时前"、"昨天 20:31" 等相对时间统一转为 `YYYY-MM-DD HH:mm`
|
|
18
|
+
- **内置限速** — 相邻请求间隔至少 1 秒,降低触发风控的概率
|
|
19
|
+
- **零异常抛出** — 主 API 永不 throw,失败信息通过返回值传递
|
|
20
|
+
|
|
21
|
+
## 安装
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install lanzou
|
|
25
|
+
# 或
|
|
26
|
+
pnpm add lanzou
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
要求 Node.js >= 18。
|
|
30
|
+
|
|
31
|
+
## 快速开始
|
|
32
|
+
|
|
33
|
+
### 场景 A:只取最新文件(如自动更新,请求数最少)
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { getLatestFile } from 'lanzou'
|
|
37
|
+
|
|
38
|
+
const file = await getLatestFile('https://wwp.lanzouj.com/xxxxx', { pwd: 'abc1' })
|
|
39
|
+
if (file) {
|
|
40
|
+
console.log(file.fileName) // 文件名
|
|
41
|
+
console.log(file.fileSize) // 大小
|
|
42
|
+
console.log(file.updateTime) // 更新时间 YYYY-MM-DD HH:mm
|
|
43
|
+
console.log(file.directLink) // 下载直链
|
|
44
|
+
} else {
|
|
45
|
+
console.log('获取失败或分享为空')
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 场景 B:获取全部文件的直链
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { getLanzouFiles } from 'lanzou'
|
|
53
|
+
|
|
54
|
+
const files = await getLanzouFiles('https://wwp.lanzouj.com/xxxxx', { debug: true })
|
|
55
|
+
for (const f of files) {
|
|
56
|
+
console.log(f.directLink ? `✓ ${f.fileName} -> ${f.directLink}` : `✗ ${f.fileName}: ${f.error}`)
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 下载直链
|
|
61
|
+
|
|
62
|
+
直链绑定 IP/UA 且有时效,拿到后请立即下载,并携带同款移动端 UA:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import axios from 'axios'
|
|
66
|
+
import { BASE_UA } from 'lanzou'
|
|
67
|
+
|
|
68
|
+
const res = await axios.get(directLink, {
|
|
69
|
+
headers: { 'User-Agent': BASE_UA },
|
|
70
|
+
responseType: 'stream',
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## API
|
|
75
|
+
|
|
76
|
+
### `getLanzouFiles(url, options?) => Promise<FileInfo[]>`
|
|
77
|
+
|
|
78
|
+
获取分享链接内全部文件的信息与直链。整体失败返回 `[]`;单个文件直链获取失败时该项 `directLink` 为 `null` 并附 `error` 说明。
|
|
79
|
+
|
|
80
|
+
### `getLatestFile(url, options?) => Promise<FileInfo | null>`
|
|
81
|
+
|
|
82
|
+
只取更新时间最新的文件:仅比较列表时间,只为该文件请求直链,其余文件不发任何请求。失败返回 `null`。
|
|
83
|
+
|
|
84
|
+
### `checkLanzouUrl(url) => { valid: true } | { valid: false; message }`
|
|
85
|
+
|
|
86
|
+
检测 URL 是否为有效的蓝奏云分享链接(`lanzou*.com`、`lanzn.com`、`lanpw.com` 等域名)。
|
|
87
|
+
|
|
88
|
+
### `LanzouOptions`
|
|
89
|
+
|
|
90
|
+
| 字段 | 类型 | 默认 | 说明 |
|
|
91
|
+
| --- | --- | --- | --- |
|
|
92
|
+
| `pwd` | `string` | `''` | 分享密码(如有) |
|
|
93
|
+
| `debug` | `boolean` | `false` | 输出进度日志(默认静默) |
|
|
94
|
+
| `timeout` | `number` | `15000` | 单次请求超时(毫秒) |
|
|
95
|
+
|
|
96
|
+
### `FileInfo`
|
|
97
|
+
|
|
98
|
+
| 字段 | 类型 | 说明 |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| `fileName` | `string` | 文件名 |
|
|
101
|
+
| `fileSize` | `string` | 文件大小(站点原始字符串) |
|
|
102
|
+
| `updateTime` | `string?` | 更新时间 `YYYY-MM-DD HH:mm`(相对时间已换算) |
|
|
103
|
+
| `directLink` | `string \| null` | 下载直链,失败为 `null` |
|
|
104
|
+
| `error` | `string?` | 直链获取失败的原因 |
|
|
105
|
+
|
|
106
|
+
### 其他导出
|
|
107
|
+
|
|
108
|
+
- `BASE_UA` — 站点校验所用移动端 UA,下载直链时需携带
|
|
109
|
+
- `calcAcwScV2(arg1)` — 本地计算 `acw_sc__v2` WAF 挑战答案
|
|
110
|
+
- `parseSiteTime(raw)` / `absTime(raw)` — 站点时间字符串转时间戳 / 标准格式
|
|
111
|
+
- `resetCookies()` — 清空模块级 Cookie 容器(长驻进程重置 WAF 状态时使用)
|
|
112
|
+
- `CookieJar` / `extractParam` — `@internal`,测试与高级用途
|
|
113
|
+
|
|
114
|
+
## 注意事项
|
|
115
|
+
|
|
116
|
+
- **全局限速**:相邻请求间隔至少 1 秒(模块级共享,同一进程内所有调用共用),文件多时耗时线性增长,属预期行为
|
|
117
|
+
- **直链时效**:直链绑定 IP/UA 且会过期,请获取后立即使用;跨机器传递直链大概率失效
|
|
118
|
+
- **Cookie 复用**:模块级 Cookie 容器会跨调用复用 WAF 验证状态,通常有利;如遇异常可 `resetCookies()`
|
|
119
|
+
- **免责声明**:本项目仅供学习交流,请勿用于任何违反蓝奏云服务条款或法律法规的用途
|
|
120
|
+
|
|
121
|
+
## 开发
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
npm install
|
|
125
|
+
npm run build # tsdown 构建 ESM + CJS + d.ts
|
|
126
|
+
npm test # vitest 单元测试(不联网)
|
|
127
|
+
npm run example -- <分享链接> [密码] # 端到端实测
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 发布
|
|
131
|
+
|
|
132
|
+
仓库已配置 [Trusted Publishing](https://docs.npmjs.com/generating-provenance-statements/)(OIDC):
|
|
133
|
+
|
|
134
|
+
1. npmjs.com → Access Tokens → Trusted Publishers → 添加 ` <你的用户名>/lanzou ` + `release.yml`
|
|
135
|
+
2. 修改 `package.json` 的 `version` 并提交
|
|
136
|
+
3. 在 GitHub 创建对应 tag 的 Release,`release.yml` 自动执行 `npm publish --provenance`
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
11
|
+
key = keys[i];
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
13
|
+
get: ((k) => from[k]).bind(null, key),
|
|
14
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
|
|
20
|
+
value: mod,
|
|
21
|
+
enumerable: true
|
|
22
|
+
}) : target, mod));
|
|
23
|
+
//#endregion
|
|
24
|
+
let axios = require("axios");
|
|
25
|
+
axios = __toESM(axios, 1);
|
|
26
|
+
//#region src/index.ts
|
|
27
|
+
/** 站点校验用的移动端 UA,下载直链时建议携带同款,否则可能 403 */
|
|
28
|
+
const BASE_UA = "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36";
|
|
29
|
+
const DEFAULT_TIMEOUT = 15e3;
|
|
30
|
+
/** 文件列表接口的兜底域名池:filemoreajax是全局接口,任一活着的蓝奏子域均可查询;分享域名失效时按序尝试 */
|
|
31
|
+
const FALLBACK_HOSTS = [
|
|
32
|
+
"ricarda.lanzouu.com",
|
|
33
|
+
"wwa.lanzouq.com",
|
|
34
|
+
"wwa.lanzouw.com"
|
|
35
|
+
];
|
|
36
|
+
/** 按域名存储Cookie,供跨请求携带(WAF验证需要);`@internal` */
|
|
37
|
+
var CookieJar = class {
|
|
38
|
+
store = /* @__PURE__ */ new Map();
|
|
39
|
+
set(domain, name, value) {
|
|
40
|
+
const key = domain.toLowerCase().replace(/^\./, "");
|
|
41
|
+
const bucket = this.store.get(key) ?? /* @__PURE__ */ new Map();
|
|
42
|
+
bucket.set(name, value);
|
|
43
|
+
this.store.set(key, bucket);
|
|
44
|
+
}
|
|
45
|
+
/** 记录响应Set-Cookie,优先使用其Domain属性,否则归属请求域名 */
|
|
46
|
+
addFromResponse(setCookie, requestUrl) {
|
|
47
|
+
for (const item of setCookie ?? []) {
|
|
48
|
+
const [pair] = item.split(";");
|
|
49
|
+
const eq = pair.indexOf("=");
|
|
50
|
+
if (eq <= 0) continue;
|
|
51
|
+
const domain = item.match(/domain=([^;]+)/i)?.[1].trim() ?? new URL(requestUrl).hostname;
|
|
52
|
+
this.set(domain, pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** 生成请求应携带的Cookie头(包含当前域名及其父域的Cookie) */
|
|
56
|
+
headerFor(url) {
|
|
57
|
+
const parts = new URL(url).hostname.toLowerCase().split(".");
|
|
58
|
+
const cookies = [];
|
|
59
|
+
for (let i = 0; i < parts.length - 1; i++) this.store.get(parts.slice(i).join("."))?.forEach((value, name) => cookies.push(`${name}=${value}`));
|
|
60
|
+
return cookies.join("; ");
|
|
61
|
+
}
|
|
62
|
+
/** 清空全部Cookie */
|
|
63
|
+
clear() {
|
|
64
|
+
this.store.clear();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const cookieJar = new CookieJar();
|
|
68
|
+
/** 清空模块级Cookie容器(长驻进程中重置WAF状态时使用) */
|
|
69
|
+
const resetCookies = () => cookieJar.clear();
|
|
70
|
+
/** 判断页面是否为WAF挑战页(acw_sc__v2反爬验证) */
|
|
71
|
+
const isAcwChallenge = (html) => html.includes("acw_sc__v2") && html.includes("arg1");
|
|
72
|
+
/** acw_sc__v2算法的固定参数:40位重排表与异或密钥 */
|
|
73
|
+
const ACW_POS = [
|
|
74
|
+
15,
|
|
75
|
+
35,
|
|
76
|
+
29,
|
|
77
|
+
24,
|
|
78
|
+
33,
|
|
79
|
+
16,
|
|
80
|
+
1,
|
|
81
|
+
38,
|
|
82
|
+
10,
|
|
83
|
+
9,
|
|
84
|
+
19,
|
|
85
|
+
31,
|
|
86
|
+
40,
|
|
87
|
+
27,
|
|
88
|
+
22,
|
|
89
|
+
23,
|
|
90
|
+
25,
|
|
91
|
+
13,
|
|
92
|
+
6,
|
|
93
|
+
11,
|
|
94
|
+
39,
|
|
95
|
+
18,
|
|
96
|
+
20,
|
|
97
|
+
8,
|
|
98
|
+
14,
|
|
99
|
+
21,
|
|
100
|
+
32,
|
|
101
|
+
26,
|
|
102
|
+
2,
|
|
103
|
+
30,
|
|
104
|
+
7,
|
|
105
|
+
4,
|
|
106
|
+
17,
|
|
107
|
+
5,
|
|
108
|
+
3,
|
|
109
|
+
28,
|
|
110
|
+
34,
|
|
111
|
+
37,
|
|
112
|
+
12,
|
|
113
|
+
36
|
|
114
|
+
];
|
|
115
|
+
const ACW_KEY = "3000176000856006061501533003690027800375";
|
|
116
|
+
/** 本地计算acw_sc__v2:按固定表重排arg1(40位hex),再与密钥逐字节异或 */
|
|
117
|
+
const calcAcwScV2 = (arg1) => {
|
|
118
|
+
return ACW_POS.map((pos) => arg1[pos - 1]).join("").match(/../g).map((pair, i) => (parseInt(pair, 16) ^ parseInt(ACW_KEY.slice(i * 2, i * 2 + 2), 16)).toString(16).padStart(2, "0")).join("");
|
|
119
|
+
};
|
|
120
|
+
/** 从挑战页提取arg1并本地算出acw_sc__v2写入Cookie容器(不执行远端JS,规避vm逃逸风险) */
|
|
121
|
+
const solveAcwChallenge = (html, url) => {
|
|
122
|
+
const arg1 = html.match(/arg1\s*=\s*['"]([0-9A-Fa-f]{40})['"]/)?.[1];
|
|
123
|
+
if (!arg1) return false;
|
|
124
|
+
cookieJar.set(new URL(url).hostname, "acw_sc__v2", calcAcwScV2(arg1));
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
let lastRequestAt = 0;
|
|
128
|
+
/** 全局限速:相邻请求间隔至少1秒(模块级,同一进程内所有调用共享,属礼貌性限速) */
|
|
129
|
+
async function rateLimit() {
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, lastRequestAt + 1e3 - Date.now())));
|
|
131
|
+
lastRequestAt = Date.now();
|
|
132
|
+
}
|
|
133
|
+
const buildHeaders = (url, referer) => {
|
|
134
|
+
const headers = { "User-Agent": BASE_UA };
|
|
135
|
+
const cookie = cookieJar.headerFor(url);
|
|
136
|
+
if (cookie) headers.Cookie = cookie;
|
|
137
|
+
if (referer) headers.Referer = referer;
|
|
138
|
+
return headers;
|
|
139
|
+
};
|
|
140
|
+
/** 发送请求,命中WAF挑战页时算出acw_sc__v2后重试(最多3次) */
|
|
141
|
+
const httpSend = async (method, url, ctx, data, referer) => {
|
|
142
|
+
let body = "";
|
|
143
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
144
|
+
const response = await axios.default.request({
|
|
145
|
+
method,
|
|
146
|
+
url,
|
|
147
|
+
data,
|
|
148
|
+
headers: buildHeaders(url, referer),
|
|
149
|
+
timeout: ctx.timeout
|
|
150
|
+
});
|
|
151
|
+
cookieJar.addFromResponse(response.headers["set-cookie"], url);
|
|
152
|
+
body = response.data;
|
|
153
|
+
if (typeof body !== "string" || !isAcwChallenge(body)) return body;
|
|
154
|
+
ctx.log(`命中WAF挑战页,计算acw_sc__v2后重试 (${attempt + 1}/3)`);
|
|
155
|
+
if (!solveAcwChallenge(body, url)) break;
|
|
156
|
+
}
|
|
157
|
+
return body;
|
|
158
|
+
};
|
|
159
|
+
/** GET页面文本 */
|
|
160
|
+
const httpGetText = async (url, ctx, referer) => {
|
|
161
|
+
const body = await httpSend("get", url, ctx, void 0, referer);
|
|
162
|
+
return typeof body === "string" ? body : String(body ?? "");
|
|
163
|
+
};
|
|
164
|
+
/** 从页面提取key对应的值('key':value / key=value、引号均可);不带引号且像变量名的值回查var定义;`@internal` */
|
|
165
|
+
const extractParam = (html, key) => {
|
|
166
|
+
const m = html.match(new RegExp(`['"]?\\b${key}\\b['"]?\\s*[:=]\\s*(['"]?)([^,'";\\s}]+)`, "i"));
|
|
167
|
+
if (!m) return null;
|
|
168
|
+
const [, quote, raw] = m;
|
|
169
|
+
if (quote) return raw;
|
|
170
|
+
if (/^[A-Za-z_$][\w$]*$/.test(raw)) return html.match(new RegExp(`var\\s+${raw}\\s*=\\s*['"]([^'"]+)['"]`))?.[1] ?? null;
|
|
171
|
+
return raw;
|
|
172
|
+
};
|
|
173
|
+
/** 从分享页提取filemoreajax接口所需参数 */
|
|
174
|
+
const prepareData = async (url, pwd, ctx) => {
|
|
175
|
+
ctx.log(`正在获取分享页: ${url}`);
|
|
176
|
+
const html = await httpGetText(url, ctx);
|
|
177
|
+
const get = (key) => extractParam(html, key);
|
|
178
|
+
const fid = get("fid");
|
|
179
|
+
const t = get("t") ?? html.match(/var\s+\w+\s*=\s*['"](\d{10})['"]/)?.[1];
|
|
180
|
+
const k = get("k");
|
|
181
|
+
if (!fid || !t || !k) throw new Error(`无法从页面提取必要参数 fid=${fid}, t=${t}, k=${k}`);
|
|
182
|
+
return {
|
|
183
|
+
lx: get("lx") ?? "2",
|
|
184
|
+
fid,
|
|
185
|
+
uid: get("uid") ?? "0",
|
|
186
|
+
rep: get("rep") ?? "0",
|
|
187
|
+
t,
|
|
188
|
+
k,
|
|
189
|
+
up: get("up") ?? "1",
|
|
190
|
+
ls: get("ls") ?? "1",
|
|
191
|
+
pwd
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
/** 请求单页文件列表:zt=1返回文件,zt=2返回[],其他情况返回null(由调用方重试) */
|
|
195
|
+
const fetchFilePage = async (data, pg, host, ctx) => {
|
|
196
|
+
const hosts = FALLBACK_HOSTS.includes(host) ? FALLBACK_HOSTS : [host, ...FALLBACK_HOSTS];
|
|
197
|
+
for (const h of hosts) try {
|
|
198
|
+
ctx.log(`请求文件列表 第${pg}页 @ ${h}`);
|
|
199
|
+
const result = await httpSend("post", `https://${h}/filemoreajax.php`, ctx, new URLSearchParams({
|
|
200
|
+
...data,
|
|
201
|
+
pg: String(pg)
|
|
202
|
+
}));
|
|
203
|
+
if (!result || typeof result.zt === "undefined") continue;
|
|
204
|
+
if (result.zt === 1) return result.text;
|
|
205
|
+
if (result.zt === 2) return [];
|
|
206
|
+
ctx.log(`触发频率限制或未知状态(zt=${result.zt}): ${result.info}`);
|
|
207
|
+
return null;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
ctx.log(`域名 ${h} 请求失败: ${error.message}`);
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
};
|
|
213
|
+
/** 翻页获取全部文件,单页失败最多重试4次 */
|
|
214
|
+
const getAllFileList = async (url, pwd, ctx) => {
|
|
215
|
+
const data = await prepareData(url, pwd, ctx);
|
|
216
|
+
const host = new URL(url).host;
|
|
217
|
+
const files = [];
|
|
218
|
+
for (let pg = 1;; pg++) {
|
|
219
|
+
let page = null;
|
|
220
|
+
for (let retry = 0; retry < 4 && page === null; retry++) {
|
|
221
|
+
await rateLimit();
|
|
222
|
+
page = await fetchFilePage(data, pg, host, ctx);
|
|
223
|
+
}
|
|
224
|
+
if (!page?.length) return files;
|
|
225
|
+
ctx.log(`第${pg}页获取到 ${page.length} 个文件`);
|
|
226
|
+
files.push(...page);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
/** 处理下载跳转页,提取最终直链 */
|
|
230
|
+
const processDownloadPage = async (tp, host, filePageUrl, ctx) => {
|
|
231
|
+
const tpUrl = /^https?:\/\//i.test(tp) ? tp : `https://${host}${tp}`;
|
|
232
|
+
const tpHtml = await httpGetText(tpUrl, ctx, filePageUrl);
|
|
233
|
+
const vkjxld = tpHtml.match(/var\s+vkjxld\s*=\s*'([^']+)'/)?.[1];
|
|
234
|
+
const hyggid = tpHtml.match(/var\s+hyggid\s*=\s*'([^']+)'/)?.[1];
|
|
235
|
+
const found = (vkjxld && hyggid ? vkjxld + hyggid : null) ?? tpHtml.match(/var\s+\w+\s*=\s*'(https?:\/\/[^']+\/file\/[^']*)'/)?.[1] ?? tpHtml.match(/<iframe[^>]+src="([^"]+)"/)?.[1];
|
|
236
|
+
if (!found) throw new Error("无法从下载页提取跳转参数(vkjxld/hyggid)");
|
|
237
|
+
const finalPageUrl = /^https?:\/\//i.test(found) ? found : new URL(found, tpUrl).href;
|
|
238
|
+
ctx.log(`获取最终下载页: ${finalPageUrl}`);
|
|
239
|
+
const finalHtml = await httpGetText(finalPageUrl, ctx, tpUrl);
|
|
240
|
+
const link = finalHtml.match(/location\.(?:href\s*=|replace\()\s*["']([^"']+)["']/)?.[1] ?? finalHtml.match(/<a\s+href="(https?:\/\/[^"]+)"/)?.[1] ?? finalHtml.match(/href="(https?:\/\/[^"]+download[^"]*)"/i)?.[1];
|
|
241
|
+
if (!link) throw new Error("最终页面未找到下载直链");
|
|
242
|
+
return /^https?:\/\//i.test(link) ? link : new URL(link, finalPageUrl).href;
|
|
243
|
+
};
|
|
244
|
+
/** 获取单个文件的最终下载链接,失败抛出异常 */
|
|
245
|
+
const getFinalLink = async (id, host, ctx, referer) => {
|
|
246
|
+
const filePageUrl = `https://${host}/${id}`;
|
|
247
|
+
const html = await httpGetText(filePageUrl, ctx, referer);
|
|
248
|
+
if (isAcwChallenge(html)) throw new Error("文件页被WAF拦截且挑战破解失败");
|
|
249
|
+
const tp = html.match(/<a[^>]+href="([^"]+)"[^>]*id="downurl"/)?.[1] ?? html.match(/<a[^>]+id="downurl"[^>]+href="([^"]+)"/)?.[1] ?? html.match(/<iframe[^>]+src="([^"]+)"/)?.[1];
|
|
250
|
+
if (!tp) throw new Error("文件页未找到下载入口(downurl),页面可能需要密码或结构已变更");
|
|
251
|
+
ctx.log(`文件 ${id} 跳转链接: ${tp}`);
|
|
252
|
+
return processDownloadPage(tp, host, filePageUrl, ctx);
|
|
253
|
+
};
|
|
254
|
+
const TIME_UNITS = {
|
|
255
|
+
秒: 1e3,
|
|
256
|
+
分钟: 6e4,
|
|
257
|
+
小时: 36e5,
|
|
258
|
+
天: 864e5
|
|
259
|
+
};
|
|
260
|
+
/** 站点时间字符串转时间戳(相对"3 小时前"/"昨天 20:31"/"刚刚"与绝对"2026-08-30"均支持),无法解析返回0 */
|
|
261
|
+
const parseSiteTime = (raw) => {
|
|
262
|
+
const rel = raw.match(/(\d+)\s*(秒|分钟|小时|天)前/);
|
|
263
|
+
if (rel) return Date.now() - +rel[1] * TIME_UNITS[rel[2]];
|
|
264
|
+
const day = raw.match(/(昨天|前天)\s*(\d{1,2}):(\d{2})/);
|
|
265
|
+
if (day) {
|
|
266
|
+
const d = /* @__PURE__ */ new Date(Date.now() - (day[1] === "昨天" ? 1 : 2) * 864e5);
|
|
267
|
+
d.setHours(+day[2], +day[3], 0, 0);
|
|
268
|
+
return +d;
|
|
269
|
+
}
|
|
270
|
+
if (raw.includes("刚刚")) return Date.now();
|
|
271
|
+
const s = raw.replace(/\//g, "-");
|
|
272
|
+
return +new Date(/^\d{4}-\d{2}-\d{2}$/.test(s) ? `${s} 00:00` : s) || 0;
|
|
273
|
+
};
|
|
274
|
+
/** 时间戳统一格式化为 "YYYY-MM-DD HH:mm" */
|
|
275
|
+
const fmtTime = (ts) => {
|
|
276
|
+
const d = new Date(ts);
|
|
277
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
278
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
279
|
+
};
|
|
280
|
+
/** 站点时间统一为 "YYYY-MM-DD HH:mm",无法解析则原样返回 */
|
|
281
|
+
const absTime = (raw) => {
|
|
282
|
+
const ts = parseSiteTime(raw);
|
|
283
|
+
return ts ? fmtTime(ts) : raw;
|
|
284
|
+
};
|
|
285
|
+
const buildCtx = (options) => ({
|
|
286
|
+
log: options.debug ? (message) => console.log(message) : () => {},
|
|
287
|
+
timeout: options.timeout ?? DEFAULT_TIMEOUT
|
|
288
|
+
});
|
|
289
|
+
/**
|
|
290
|
+
* 获取蓝奏云文件夹中所有文件的信息和直链
|
|
291
|
+
*
|
|
292
|
+
* 永不抛出异常:整体失败返回 `[]`,单个文件直链获取失败时该项 `directLink` 为 `null` 并附 `error` 说明。
|
|
293
|
+
*
|
|
294
|
+
* @param url - 蓝奏云分享URL
|
|
295
|
+
* @param options - 可选配置(密码 / 调试日志 / 超时)
|
|
296
|
+
*/
|
|
297
|
+
const getLanzouFiles = async (url, options = {}) => {
|
|
298
|
+
const ctx = buildCtx(options);
|
|
299
|
+
try {
|
|
300
|
+
const host = new URL(url).host;
|
|
301
|
+
const fileList = await getAllFileList(url, options.pwd ?? "", ctx);
|
|
302
|
+
if (!fileList.length) {
|
|
303
|
+
ctx.log("未找到文件或文件列表为空");
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
ctx.log(`共 ${fileList.length} 个文件,开始获取直链`);
|
|
307
|
+
const results = [];
|
|
308
|
+
for (const [index, file] of fileList.entries()) {
|
|
309
|
+
ctx.log(`(${index + 1}/${fileList.length}) ${file.name_all}`);
|
|
310
|
+
await rateLimit();
|
|
311
|
+
try {
|
|
312
|
+
const directLink = await getFinalLink(file.id, host, ctx, url);
|
|
313
|
+
results.push({
|
|
314
|
+
fileName: file.name_all,
|
|
315
|
+
fileSize: file.size,
|
|
316
|
+
updateTime: file.time ? absTime(file.time) : void 0,
|
|
317
|
+
directLink
|
|
318
|
+
});
|
|
319
|
+
ctx.log(`✓ ${file.name_all} 直链获取成功`);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
ctx.log(`✗ ${file.name_all} 直链获取失败: ${error.message}`);
|
|
322
|
+
results.push({
|
|
323
|
+
fileName: file.name_all,
|
|
324
|
+
fileSize: file.size,
|
|
325
|
+
updateTime: file.time ? absTime(file.time) : void 0,
|
|
326
|
+
directLink: null,
|
|
327
|
+
error: error.message
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
ctx.log(`完成:${results.filter((f) => f.directLink).length}/${fileList.length} 个直链获取成功`);
|
|
332
|
+
return results;
|
|
333
|
+
} catch (error) {
|
|
334
|
+
ctx.log(`获取蓝奏云文件失败: ${error.message}`);
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
/** 检测URL是否为有效的蓝奏云分享链接 */
|
|
339
|
+
const checkLanzouUrl = (url) => {
|
|
340
|
+
return /https?:\/\/[\w.-]+\.(?:lanzou[a-z]?|lanzn|lanpw|lanpv|lanzv)\.com\/\w+/i.test(url) ? { valid: true } : {
|
|
341
|
+
valid: false,
|
|
342
|
+
message: "URL不是有效的蓝奏云链接,蓝奏云域名包括:lanzou*.com、lanzn.com等"
|
|
343
|
+
};
|
|
344
|
+
};
|
|
345
|
+
/**
|
|
346
|
+
* 只取更新时间最新的文件:仅用列表比较时间,只为该文件请求直链,其余文件不发任何请求
|
|
347
|
+
*
|
|
348
|
+
* 失败返回 `null`,不抛出异常。
|
|
349
|
+
*
|
|
350
|
+
* @param url - 蓝奏云分享URL
|
|
351
|
+
* @param options - 可选配置(密码 / 调试日志 / 超时)
|
|
352
|
+
*/
|
|
353
|
+
const getLatestFile = async (url, options = {}) => {
|
|
354
|
+
const ctx = buildCtx(options);
|
|
355
|
+
try {
|
|
356
|
+
const host = new URL(url).host;
|
|
357
|
+
const fileList = await getAllFileList(url, options.pwd ?? "", ctx);
|
|
358
|
+
if (!fileList.length) return null;
|
|
359
|
+
const newest = fileList.reduce((a, b) => parseSiteTime(b.time ?? "") > parseSiteTime(a.time ?? "") ? b : a);
|
|
360
|
+
ctx.log(`最新文件: ${newest.name_all},获取其直链`);
|
|
361
|
+
await rateLimit();
|
|
362
|
+
const directLink = await getFinalLink(newest.id, host, ctx, url);
|
|
363
|
+
ctx.log(`✓ ${newest.name_all} 直链获取成功`);
|
|
364
|
+
return {
|
|
365
|
+
fileName: newest.name_all,
|
|
366
|
+
fileSize: newest.size,
|
|
367
|
+
updateTime: newest.time ? absTime(newest.time) : void 0,
|
|
368
|
+
directLink
|
|
369
|
+
};
|
|
370
|
+
} catch (error) {
|
|
371
|
+
ctx.log(`获取最新文件失败: ${error.message}`);
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
//#endregion
|
|
376
|
+
exports.BASE_UA = BASE_UA;
|
|
377
|
+
exports.CookieJar = CookieJar;
|
|
378
|
+
exports.absTime = absTime;
|
|
379
|
+
exports.calcAcwScV2 = calcAcwScV2;
|
|
380
|
+
exports.checkLanzouUrl = checkLanzouUrl;
|
|
381
|
+
exports.extractParam = extractParam;
|
|
382
|
+
exports.getLanzouFiles = getLanzouFiles;
|
|
383
|
+
exports.getLatestFile = getLatestFile;
|
|
384
|
+
exports.parseSiteTime = parseSiteTime;
|
|
385
|
+
exports.resetCookies = resetCookies;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
/** 站点校验用的移动端 UA,下载直链时建议携带同款,否则可能 403 */
|
|
3
|
+
export declare const BASE_UA = "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36";
|
|
4
|
+
export interface FileInfo {
|
|
5
|
+
fileName: string;
|
|
6
|
+
fileSize: string;
|
|
7
|
+
/** 更新时间 YYYY-MM-DD HH:mm(站点返回相对时间时已换算,精度受限于相对单位) */
|
|
8
|
+
updateTime?: string;
|
|
9
|
+
directLink: string | null;
|
|
10
|
+
/** 直链获取失败时的原因(directLink 为 null 时存在) */
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface LanzouOptions {
|
|
14
|
+
/** 分享密码(如有) */
|
|
15
|
+
pwd?: string;
|
|
16
|
+
/** 开启进度日志,默认静默 */
|
|
17
|
+
debug?: boolean;
|
|
18
|
+
/** 单次请求超时(毫秒),默认 15000 */
|
|
19
|
+
timeout?: number;
|
|
20
|
+
}
|
|
21
|
+
/** 按域名存储Cookie,供跨请求携带(WAF验证需要);`@internal` */
|
|
22
|
+
export declare class CookieJar {
|
|
23
|
+
private store;
|
|
24
|
+
set(domain: string, name: string, value: string): void;
|
|
25
|
+
/** 记录响应Set-Cookie,优先使用其Domain属性,否则归属请求域名 */
|
|
26
|
+
addFromResponse(setCookie: string[] | undefined, requestUrl: string): void;
|
|
27
|
+
/** 生成请求应携带的Cookie头(包含当前域名及其父域的Cookie) */
|
|
28
|
+
headerFor(url: string): string;
|
|
29
|
+
/** 清空全部Cookie */
|
|
30
|
+
clear(): void;
|
|
31
|
+
}
|
|
32
|
+
/** 清空模块级Cookie容器(长驻进程中重置WAF状态时使用) */
|
|
33
|
+
export declare const resetCookies: () => void;
|
|
34
|
+
/** 本地计算acw_sc__v2:按固定表重排arg1(40位hex),再与密钥逐字节异或 */
|
|
35
|
+
export declare const calcAcwScV2: (arg1: string) => string;
|
|
36
|
+
/** 从页面提取key对应的值('key':value / key=value、引号均可);不带引号且像变量名的值回查var定义;`@internal` */
|
|
37
|
+
export declare const extractParam: (html: string, key: string) => string | null;
|
|
38
|
+
/** 站点时间字符串转时间戳(相对"3 小时前"/"昨天 20:31"/"刚刚"与绝对"2026-08-30"均支持),无法解析返回0 */
|
|
39
|
+
export declare const parseSiteTime: (raw: string) => number;
|
|
40
|
+
/** 站点时间统一为 "YYYY-MM-DD HH:mm",无法解析则原样返回 */
|
|
41
|
+
export declare const absTime: (raw: string) => string;
|
|
42
|
+
/**
|
|
43
|
+
* 获取蓝奏云文件夹中所有文件的信息和直链
|
|
44
|
+
*
|
|
45
|
+
* 永不抛出异常:整体失败返回 `[]`,单个文件直链获取失败时该项 `directLink` 为 `null` 并附 `error` 说明。
|
|
46
|
+
*
|
|
47
|
+
* @param url - 蓝奏云分享URL
|
|
48
|
+
* @param options - 可选配置(密码 / 调试日志 / 超时)
|
|
49
|
+
*/
|
|
50
|
+
export declare const getLanzouFiles: (url: string, options?: LanzouOptions) => Promise<FileInfo[]>;
|
|
51
|
+
/** 检测URL是否为有效的蓝奏云分享链接 */
|
|
52
|
+
export declare const checkLanzouUrl: (url: string) => {
|
|
53
|
+
valid: true;
|
|
54
|
+
} | {
|
|
55
|
+
valid: false;
|
|
56
|
+
message: string;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* 只取更新时间最新的文件:仅用列表比较时间,只为该文件请求直链,其余文件不发任何请求
|
|
60
|
+
*
|
|
61
|
+
* 失败返回 `null`,不抛出异常。
|
|
62
|
+
*
|
|
63
|
+
* @param url - 蓝奏云分享URL
|
|
64
|
+
* @param options - 可选配置(密码 / 调试日志 / 超时)
|
|
65
|
+
*/
|
|
66
|
+
export declare const getLatestFile: (url: string, options?: LanzouOptions) => Promise<FileInfo | null>;
|
|
67
|
+
//#endregion
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
/** 站点校验用的移动端 UA,下载直链时建议携带同款,否则可能 403 */
|
|
3
|
+
export declare const BASE_UA = "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36";
|
|
4
|
+
export interface FileInfo {
|
|
5
|
+
fileName: string;
|
|
6
|
+
fileSize: string;
|
|
7
|
+
/** 更新时间 YYYY-MM-DD HH:mm(站点返回相对时间时已换算,精度受限于相对单位) */
|
|
8
|
+
updateTime?: string;
|
|
9
|
+
directLink: string | null;
|
|
10
|
+
/** 直链获取失败时的原因(directLink 为 null 时存在) */
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface LanzouOptions {
|
|
14
|
+
/** 分享密码(如有) */
|
|
15
|
+
pwd?: string;
|
|
16
|
+
/** 开启进度日志,默认静默 */
|
|
17
|
+
debug?: boolean;
|
|
18
|
+
/** 单次请求超时(毫秒),默认 15000 */
|
|
19
|
+
timeout?: number;
|
|
20
|
+
}
|
|
21
|
+
/** 按域名存储Cookie,供跨请求携带(WAF验证需要);`@internal` */
|
|
22
|
+
export declare class CookieJar {
|
|
23
|
+
private store;
|
|
24
|
+
set(domain: string, name: string, value: string): void;
|
|
25
|
+
/** 记录响应Set-Cookie,优先使用其Domain属性,否则归属请求域名 */
|
|
26
|
+
addFromResponse(setCookie: string[] | undefined, requestUrl: string): void;
|
|
27
|
+
/** 生成请求应携带的Cookie头(包含当前域名及其父域的Cookie) */
|
|
28
|
+
headerFor(url: string): string;
|
|
29
|
+
/** 清空全部Cookie */
|
|
30
|
+
clear(): void;
|
|
31
|
+
}
|
|
32
|
+
/** 清空模块级Cookie容器(长驻进程中重置WAF状态时使用) */
|
|
33
|
+
export declare const resetCookies: () => void;
|
|
34
|
+
/** 本地计算acw_sc__v2:按固定表重排arg1(40位hex),再与密钥逐字节异或 */
|
|
35
|
+
export declare const calcAcwScV2: (arg1: string) => string;
|
|
36
|
+
/** 从页面提取key对应的值('key':value / key=value、引号均可);不带引号且像变量名的值回查var定义;`@internal` */
|
|
37
|
+
export declare const extractParam: (html: string, key: string) => string | null;
|
|
38
|
+
/** 站点时间字符串转时间戳(相对"3 小时前"/"昨天 20:31"/"刚刚"与绝对"2026-08-30"均支持),无法解析返回0 */
|
|
39
|
+
export declare const parseSiteTime: (raw: string) => number;
|
|
40
|
+
/** 站点时间统一为 "YYYY-MM-DD HH:mm",无法解析则原样返回 */
|
|
41
|
+
export declare const absTime: (raw: string) => string;
|
|
42
|
+
/**
|
|
43
|
+
* 获取蓝奏云文件夹中所有文件的信息和直链
|
|
44
|
+
*
|
|
45
|
+
* 永不抛出异常:整体失败返回 `[]`,单个文件直链获取失败时该项 `directLink` 为 `null` 并附 `error` 说明。
|
|
46
|
+
*
|
|
47
|
+
* @param url - 蓝奏云分享URL
|
|
48
|
+
* @param options - 可选配置(密码 / 调试日志 / 超时)
|
|
49
|
+
*/
|
|
50
|
+
export declare const getLanzouFiles: (url: string, options?: LanzouOptions) => Promise<FileInfo[]>;
|
|
51
|
+
/** 检测URL是否为有效的蓝奏云分享链接 */
|
|
52
|
+
export declare const checkLanzouUrl: (url: string) => {
|
|
53
|
+
valid: true;
|
|
54
|
+
} | {
|
|
55
|
+
valid: false;
|
|
56
|
+
message: string;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* 只取更新时间最新的文件:仅用列表比较时间,只为该文件请求直链,其余文件不发任何请求
|
|
60
|
+
*
|
|
61
|
+
* 失败返回 `null`,不抛出异常。
|
|
62
|
+
*
|
|
63
|
+
* @param url - 蓝奏云分享URL
|
|
64
|
+
* @param options - 可选配置(密码 / 调试日志 / 超时)
|
|
65
|
+
*/
|
|
66
|
+
export declare const getLatestFile: (url: string, options?: LanzouOptions) => Promise<FileInfo | null>;
|
|
67
|
+
//#endregion
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
/** 站点校验用的移动端 UA,下载直链时建议携带同款,否则可能 403 */
|
|
4
|
+
const BASE_UA = "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36";
|
|
5
|
+
const DEFAULT_TIMEOUT = 15e3;
|
|
6
|
+
/** 文件列表接口的兜底域名池:filemoreajax是全局接口,任一活着的蓝奏子域均可查询;分享域名失效时按序尝试 */
|
|
7
|
+
const FALLBACK_HOSTS = [
|
|
8
|
+
"ricarda.lanzouu.com",
|
|
9
|
+
"wwa.lanzouq.com",
|
|
10
|
+
"wwa.lanzouw.com"
|
|
11
|
+
];
|
|
12
|
+
/** 按域名存储Cookie,供跨请求携带(WAF验证需要);`@internal` */
|
|
13
|
+
var CookieJar = class {
|
|
14
|
+
store = /* @__PURE__ */ new Map();
|
|
15
|
+
set(domain, name, value) {
|
|
16
|
+
const key = domain.toLowerCase().replace(/^\./, "");
|
|
17
|
+
const bucket = this.store.get(key) ?? /* @__PURE__ */ new Map();
|
|
18
|
+
bucket.set(name, value);
|
|
19
|
+
this.store.set(key, bucket);
|
|
20
|
+
}
|
|
21
|
+
/** 记录响应Set-Cookie,优先使用其Domain属性,否则归属请求域名 */
|
|
22
|
+
addFromResponse(setCookie, requestUrl) {
|
|
23
|
+
for (const item of setCookie ?? []) {
|
|
24
|
+
const [pair] = item.split(";");
|
|
25
|
+
const eq = pair.indexOf("=");
|
|
26
|
+
if (eq <= 0) continue;
|
|
27
|
+
const domain = item.match(/domain=([^;]+)/i)?.[1].trim() ?? new URL(requestUrl).hostname;
|
|
28
|
+
this.set(domain, pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** 生成请求应携带的Cookie头(包含当前域名及其父域的Cookie) */
|
|
32
|
+
headerFor(url) {
|
|
33
|
+
const parts = new URL(url).hostname.toLowerCase().split(".");
|
|
34
|
+
const cookies = [];
|
|
35
|
+
for (let i = 0; i < parts.length - 1; i++) this.store.get(parts.slice(i).join("."))?.forEach((value, name) => cookies.push(`${name}=${value}`));
|
|
36
|
+
return cookies.join("; ");
|
|
37
|
+
}
|
|
38
|
+
/** 清空全部Cookie */
|
|
39
|
+
clear() {
|
|
40
|
+
this.store.clear();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const cookieJar = new CookieJar();
|
|
44
|
+
/** 清空模块级Cookie容器(长驻进程中重置WAF状态时使用) */
|
|
45
|
+
const resetCookies = () => cookieJar.clear();
|
|
46
|
+
/** 判断页面是否为WAF挑战页(acw_sc__v2反爬验证) */
|
|
47
|
+
const isAcwChallenge = (html) => html.includes("acw_sc__v2") && html.includes("arg1");
|
|
48
|
+
/** acw_sc__v2算法的固定参数:40位重排表与异或密钥 */
|
|
49
|
+
const ACW_POS = [
|
|
50
|
+
15,
|
|
51
|
+
35,
|
|
52
|
+
29,
|
|
53
|
+
24,
|
|
54
|
+
33,
|
|
55
|
+
16,
|
|
56
|
+
1,
|
|
57
|
+
38,
|
|
58
|
+
10,
|
|
59
|
+
9,
|
|
60
|
+
19,
|
|
61
|
+
31,
|
|
62
|
+
40,
|
|
63
|
+
27,
|
|
64
|
+
22,
|
|
65
|
+
23,
|
|
66
|
+
25,
|
|
67
|
+
13,
|
|
68
|
+
6,
|
|
69
|
+
11,
|
|
70
|
+
39,
|
|
71
|
+
18,
|
|
72
|
+
20,
|
|
73
|
+
8,
|
|
74
|
+
14,
|
|
75
|
+
21,
|
|
76
|
+
32,
|
|
77
|
+
26,
|
|
78
|
+
2,
|
|
79
|
+
30,
|
|
80
|
+
7,
|
|
81
|
+
4,
|
|
82
|
+
17,
|
|
83
|
+
5,
|
|
84
|
+
3,
|
|
85
|
+
28,
|
|
86
|
+
34,
|
|
87
|
+
37,
|
|
88
|
+
12,
|
|
89
|
+
36
|
|
90
|
+
];
|
|
91
|
+
const ACW_KEY = "3000176000856006061501533003690027800375";
|
|
92
|
+
/** 本地计算acw_sc__v2:按固定表重排arg1(40位hex),再与密钥逐字节异或 */
|
|
93
|
+
const calcAcwScV2 = (arg1) => {
|
|
94
|
+
return ACW_POS.map((pos) => arg1[pos - 1]).join("").match(/../g).map((pair, i) => (parseInt(pair, 16) ^ parseInt(ACW_KEY.slice(i * 2, i * 2 + 2), 16)).toString(16).padStart(2, "0")).join("");
|
|
95
|
+
};
|
|
96
|
+
/** 从挑战页提取arg1并本地算出acw_sc__v2写入Cookie容器(不执行远端JS,规避vm逃逸风险) */
|
|
97
|
+
const solveAcwChallenge = (html, url) => {
|
|
98
|
+
const arg1 = html.match(/arg1\s*=\s*['"]([0-9A-Fa-f]{40})['"]/)?.[1];
|
|
99
|
+
if (!arg1) return false;
|
|
100
|
+
cookieJar.set(new URL(url).hostname, "acw_sc__v2", calcAcwScV2(arg1));
|
|
101
|
+
return true;
|
|
102
|
+
};
|
|
103
|
+
let lastRequestAt = 0;
|
|
104
|
+
/** 全局限速:相邻请求间隔至少1秒(模块级,同一进程内所有调用共享,属礼貌性限速) */
|
|
105
|
+
async function rateLimit() {
|
|
106
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, lastRequestAt + 1e3 - Date.now())));
|
|
107
|
+
lastRequestAt = Date.now();
|
|
108
|
+
}
|
|
109
|
+
const buildHeaders = (url, referer) => {
|
|
110
|
+
const headers = { "User-Agent": BASE_UA };
|
|
111
|
+
const cookie = cookieJar.headerFor(url);
|
|
112
|
+
if (cookie) headers.Cookie = cookie;
|
|
113
|
+
if (referer) headers.Referer = referer;
|
|
114
|
+
return headers;
|
|
115
|
+
};
|
|
116
|
+
/** 发送请求,命中WAF挑战页时算出acw_sc__v2后重试(最多3次) */
|
|
117
|
+
const httpSend = async (method, url, ctx, data, referer) => {
|
|
118
|
+
let body = "";
|
|
119
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
120
|
+
const response = await axios.request({
|
|
121
|
+
method,
|
|
122
|
+
url,
|
|
123
|
+
data,
|
|
124
|
+
headers: buildHeaders(url, referer),
|
|
125
|
+
timeout: ctx.timeout
|
|
126
|
+
});
|
|
127
|
+
cookieJar.addFromResponse(response.headers["set-cookie"], url);
|
|
128
|
+
body = response.data;
|
|
129
|
+
if (typeof body !== "string" || !isAcwChallenge(body)) return body;
|
|
130
|
+
ctx.log(`命中WAF挑战页,计算acw_sc__v2后重试 (${attempt + 1}/3)`);
|
|
131
|
+
if (!solveAcwChallenge(body, url)) break;
|
|
132
|
+
}
|
|
133
|
+
return body;
|
|
134
|
+
};
|
|
135
|
+
/** GET页面文本 */
|
|
136
|
+
const httpGetText = async (url, ctx, referer) => {
|
|
137
|
+
const body = await httpSend("get", url, ctx, void 0, referer);
|
|
138
|
+
return typeof body === "string" ? body : String(body ?? "");
|
|
139
|
+
};
|
|
140
|
+
/** 从页面提取key对应的值('key':value / key=value、引号均可);不带引号且像变量名的值回查var定义;`@internal` */
|
|
141
|
+
const extractParam = (html, key) => {
|
|
142
|
+
const m = html.match(new RegExp(`['"]?\\b${key}\\b['"]?\\s*[:=]\\s*(['"]?)([^,'";\\s}]+)`, "i"));
|
|
143
|
+
if (!m) return null;
|
|
144
|
+
const [, quote, raw] = m;
|
|
145
|
+
if (quote) return raw;
|
|
146
|
+
if (/^[A-Za-z_$][\w$]*$/.test(raw)) return html.match(new RegExp(`var\\s+${raw}\\s*=\\s*['"]([^'"]+)['"]`))?.[1] ?? null;
|
|
147
|
+
return raw;
|
|
148
|
+
};
|
|
149
|
+
/** 从分享页提取filemoreajax接口所需参数 */
|
|
150
|
+
const prepareData = async (url, pwd, ctx) => {
|
|
151
|
+
ctx.log(`正在获取分享页: ${url}`);
|
|
152
|
+
const html = await httpGetText(url, ctx);
|
|
153
|
+
const get = (key) => extractParam(html, key);
|
|
154
|
+
const fid = get("fid");
|
|
155
|
+
const t = get("t") ?? html.match(/var\s+\w+\s*=\s*['"](\d{10})['"]/)?.[1];
|
|
156
|
+
const k = get("k");
|
|
157
|
+
if (!fid || !t || !k) throw new Error(`无法从页面提取必要参数 fid=${fid}, t=${t}, k=${k}`);
|
|
158
|
+
return {
|
|
159
|
+
lx: get("lx") ?? "2",
|
|
160
|
+
fid,
|
|
161
|
+
uid: get("uid") ?? "0",
|
|
162
|
+
rep: get("rep") ?? "0",
|
|
163
|
+
t,
|
|
164
|
+
k,
|
|
165
|
+
up: get("up") ?? "1",
|
|
166
|
+
ls: get("ls") ?? "1",
|
|
167
|
+
pwd
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
/** 请求单页文件列表:zt=1返回文件,zt=2返回[],其他情况返回null(由调用方重试) */
|
|
171
|
+
const fetchFilePage = async (data, pg, host, ctx) => {
|
|
172
|
+
const hosts = FALLBACK_HOSTS.includes(host) ? FALLBACK_HOSTS : [host, ...FALLBACK_HOSTS];
|
|
173
|
+
for (const h of hosts) try {
|
|
174
|
+
ctx.log(`请求文件列表 第${pg}页 @ ${h}`);
|
|
175
|
+
const result = await httpSend("post", `https://${h}/filemoreajax.php`, ctx, new URLSearchParams({
|
|
176
|
+
...data,
|
|
177
|
+
pg: String(pg)
|
|
178
|
+
}));
|
|
179
|
+
if (!result || typeof result.zt === "undefined") continue;
|
|
180
|
+
if (result.zt === 1) return result.text;
|
|
181
|
+
if (result.zt === 2) return [];
|
|
182
|
+
ctx.log(`触发频率限制或未知状态(zt=${result.zt}): ${result.info}`);
|
|
183
|
+
return null;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
ctx.log(`域名 ${h} 请求失败: ${error.message}`);
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
};
|
|
189
|
+
/** 翻页获取全部文件,单页失败最多重试4次 */
|
|
190
|
+
const getAllFileList = async (url, pwd, ctx) => {
|
|
191
|
+
const data = await prepareData(url, pwd, ctx);
|
|
192
|
+
const host = new URL(url).host;
|
|
193
|
+
const files = [];
|
|
194
|
+
for (let pg = 1;; pg++) {
|
|
195
|
+
let page = null;
|
|
196
|
+
for (let retry = 0; retry < 4 && page === null; retry++) {
|
|
197
|
+
await rateLimit();
|
|
198
|
+
page = await fetchFilePage(data, pg, host, ctx);
|
|
199
|
+
}
|
|
200
|
+
if (!page?.length) return files;
|
|
201
|
+
ctx.log(`第${pg}页获取到 ${page.length} 个文件`);
|
|
202
|
+
files.push(...page);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
/** 处理下载跳转页,提取最终直链 */
|
|
206
|
+
const processDownloadPage = async (tp, host, filePageUrl, ctx) => {
|
|
207
|
+
const tpUrl = /^https?:\/\//i.test(tp) ? tp : `https://${host}${tp}`;
|
|
208
|
+
const tpHtml = await httpGetText(tpUrl, ctx, filePageUrl);
|
|
209
|
+
const vkjxld = tpHtml.match(/var\s+vkjxld\s*=\s*'([^']+)'/)?.[1];
|
|
210
|
+
const hyggid = tpHtml.match(/var\s+hyggid\s*=\s*'([^']+)'/)?.[1];
|
|
211
|
+
const found = (vkjxld && hyggid ? vkjxld + hyggid : null) ?? tpHtml.match(/var\s+\w+\s*=\s*'(https?:\/\/[^']+\/file\/[^']*)'/)?.[1] ?? tpHtml.match(/<iframe[^>]+src="([^"]+)"/)?.[1];
|
|
212
|
+
if (!found) throw new Error("无法从下载页提取跳转参数(vkjxld/hyggid)");
|
|
213
|
+
const finalPageUrl = /^https?:\/\//i.test(found) ? found : new URL(found, tpUrl).href;
|
|
214
|
+
ctx.log(`获取最终下载页: ${finalPageUrl}`);
|
|
215
|
+
const finalHtml = await httpGetText(finalPageUrl, ctx, tpUrl);
|
|
216
|
+
const link = finalHtml.match(/location\.(?:href\s*=|replace\()\s*["']([^"']+)["']/)?.[1] ?? finalHtml.match(/<a\s+href="(https?:\/\/[^"]+)"/)?.[1] ?? finalHtml.match(/href="(https?:\/\/[^"]+download[^"]*)"/i)?.[1];
|
|
217
|
+
if (!link) throw new Error("最终页面未找到下载直链");
|
|
218
|
+
return /^https?:\/\//i.test(link) ? link : new URL(link, finalPageUrl).href;
|
|
219
|
+
};
|
|
220
|
+
/** 获取单个文件的最终下载链接,失败抛出异常 */
|
|
221
|
+
const getFinalLink = async (id, host, ctx, referer) => {
|
|
222
|
+
const filePageUrl = `https://${host}/${id}`;
|
|
223
|
+
const html = await httpGetText(filePageUrl, ctx, referer);
|
|
224
|
+
if (isAcwChallenge(html)) throw new Error("文件页被WAF拦截且挑战破解失败");
|
|
225
|
+
const tp = html.match(/<a[^>]+href="([^"]+)"[^>]*id="downurl"/)?.[1] ?? html.match(/<a[^>]+id="downurl"[^>]+href="([^"]+)"/)?.[1] ?? html.match(/<iframe[^>]+src="([^"]+)"/)?.[1];
|
|
226
|
+
if (!tp) throw new Error("文件页未找到下载入口(downurl),页面可能需要密码或结构已变更");
|
|
227
|
+
ctx.log(`文件 ${id} 跳转链接: ${tp}`);
|
|
228
|
+
return processDownloadPage(tp, host, filePageUrl, ctx);
|
|
229
|
+
};
|
|
230
|
+
const TIME_UNITS = {
|
|
231
|
+
秒: 1e3,
|
|
232
|
+
分钟: 6e4,
|
|
233
|
+
小时: 36e5,
|
|
234
|
+
天: 864e5
|
|
235
|
+
};
|
|
236
|
+
/** 站点时间字符串转时间戳(相对"3 小时前"/"昨天 20:31"/"刚刚"与绝对"2026-08-30"均支持),无法解析返回0 */
|
|
237
|
+
const parseSiteTime = (raw) => {
|
|
238
|
+
const rel = raw.match(/(\d+)\s*(秒|分钟|小时|天)前/);
|
|
239
|
+
if (rel) return Date.now() - +rel[1] * TIME_UNITS[rel[2]];
|
|
240
|
+
const day = raw.match(/(昨天|前天)\s*(\d{1,2}):(\d{2})/);
|
|
241
|
+
if (day) {
|
|
242
|
+
const d = /* @__PURE__ */ new Date(Date.now() - (day[1] === "昨天" ? 1 : 2) * 864e5);
|
|
243
|
+
d.setHours(+day[2], +day[3], 0, 0);
|
|
244
|
+
return +d;
|
|
245
|
+
}
|
|
246
|
+
if (raw.includes("刚刚")) return Date.now();
|
|
247
|
+
const s = raw.replace(/\//g, "-");
|
|
248
|
+
return +new Date(/^\d{4}-\d{2}-\d{2}$/.test(s) ? `${s} 00:00` : s) || 0;
|
|
249
|
+
};
|
|
250
|
+
/** 时间戳统一格式化为 "YYYY-MM-DD HH:mm" */
|
|
251
|
+
const fmtTime = (ts) => {
|
|
252
|
+
const d = new Date(ts);
|
|
253
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
254
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
255
|
+
};
|
|
256
|
+
/** 站点时间统一为 "YYYY-MM-DD HH:mm",无法解析则原样返回 */
|
|
257
|
+
const absTime = (raw) => {
|
|
258
|
+
const ts = parseSiteTime(raw);
|
|
259
|
+
return ts ? fmtTime(ts) : raw;
|
|
260
|
+
};
|
|
261
|
+
const buildCtx = (options) => ({
|
|
262
|
+
log: options.debug ? (message) => console.log(message) : () => {},
|
|
263
|
+
timeout: options.timeout ?? DEFAULT_TIMEOUT
|
|
264
|
+
});
|
|
265
|
+
/**
|
|
266
|
+
* 获取蓝奏云文件夹中所有文件的信息和直链
|
|
267
|
+
*
|
|
268
|
+
* 永不抛出异常:整体失败返回 `[]`,单个文件直链获取失败时该项 `directLink` 为 `null` 并附 `error` 说明。
|
|
269
|
+
*
|
|
270
|
+
* @param url - 蓝奏云分享URL
|
|
271
|
+
* @param options - 可选配置(密码 / 调试日志 / 超时)
|
|
272
|
+
*/
|
|
273
|
+
const getLanzouFiles = async (url, options = {}) => {
|
|
274
|
+
const ctx = buildCtx(options);
|
|
275
|
+
try {
|
|
276
|
+
const host = new URL(url).host;
|
|
277
|
+
const fileList = await getAllFileList(url, options.pwd ?? "", ctx);
|
|
278
|
+
if (!fileList.length) {
|
|
279
|
+
ctx.log("未找到文件或文件列表为空");
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
ctx.log(`共 ${fileList.length} 个文件,开始获取直链`);
|
|
283
|
+
const results = [];
|
|
284
|
+
for (const [index, file] of fileList.entries()) {
|
|
285
|
+
ctx.log(`(${index + 1}/${fileList.length}) ${file.name_all}`);
|
|
286
|
+
await rateLimit();
|
|
287
|
+
try {
|
|
288
|
+
const directLink = await getFinalLink(file.id, host, ctx, url);
|
|
289
|
+
results.push({
|
|
290
|
+
fileName: file.name_all,
|
|
291
|
+
fileSize: file.size,
|
|
292
|
+
updateTime: file.time ? absTime(file.time) : void 0,
|
|
293
|
+
directLink
|
|
294
|
+
});
|
|
295
|
+
ctx.log(`✓ ${file.name_all} 直链获取成功`);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
ctx.log(`✗ ${file.name_all} 直链获取失败: ${error.message}`);
|
|
298
|
+
results.push({
|
|
299
|
+
fileName: file.name_all,
|
|
300
|
+
fileSize: file.size,
|
|
301
|
+
updateTime: file.time ? absTime(file.time) : void 0,
|
|
302
|
+
directLink: null,
|
|
303
|
+
error: error.message
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
ctx.log(`完成:${results.filter((f) => f.directLink).length}/${fileList.length} 个直链获取成功`);
|
|
308
|
+
return results;
|
|
309
|
+
} catch (error) {
|
|
310
|
+
ctx.log(`获取蓝奏云文件失败: ${error.message}`);
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
/** 检测URL是否为有效的蓝奏云分享链接 */
|
|
315
|
+
const checkLanzouUrl = (url) => {
|
|
316
|
+
return /https?:\/\/[\w.-]+\.(?:lanzou[a-z]?|lanzn|lanpw|lanpv|lanzv)\.com\/\w+/i.test(url) ? { valid: true } : {
|
|
317
|
+
valid: false,
|
|
318
|
+
message: "URL不是有效的蓝奏云链接,蓝奏云域名包括:lanzou*.com、lanzn.com等"
|
|
319
|
+
};
|
|
320
|
+
};
|
|
321
|
+
/**
|
|
322
|
+
* 只取更新时间最新的文件:仅用列表比较时间,只为该文件请求直链,其余文件不发任何请求
|
|
323
|
+
*
|
|
324
|
+
* 失败返回 `null`,不抛出异常。
|
|
325
|
+
*
|
|
326
|
+
* @param url - 蓝奏云分享URL
|
|
327
|
+
* @param options - 可选配置(密码 / 调试日志 / 超时)
|
|
328
|
+
*/
|
|
329
|
+
const getLatestFile = async (url, options = {}) => {
|
|
330
|
+
const ctx = buildCtx(options);
|
|
331
|
+
try {
|
|
332
|
+
const host = new URL(url).host;
|
|
333
|
+
const fileList = await getAllFileList(url, options.pwd ?? "", ctx);
|
|
334
|
+
if (!fileList.length) return null;
|
|
335
|
+
const newest = fileList.reduce((a, b) => parseSiteTime(b.time ?? "") > parseSiteTime(a.time ?? "") ? b : a);
|
|
336
|
+
ctx.log(`最新文件: ${newest.name_all},获取其直链`);
|
|
337
|
+
await rateLimit();
|
|
338
|
+
const directLink = await getFinalLink(newest.id, host, ctx, url);
|
|
339
|
+
ctx.log(`✓ ${newest.name_all} 直链获取成功`);
|
|
340
|
+
return {
|
|
341
|
+
fileName: newest.name_all,
|
|
342
|
+
fileSize: newest.size,
|
|
343
|
+
updateTime: newest.time ? absTime(newest.time) : void 0,
|
|
344
|
+
directLink
|
|
345
|
+
};
|
|
346
|
+
} catch (error) {
|
|
347
|
+
ctx.log(`获取最新文件失败: ${error.message}`);
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
//#endregion
|
|
352
|
+
export { BASE_UA, CookieJar, absTime, calcAcwScV2, checkLanzouUrl, extractParam, getLanzouFiles, getLatestFile, parseSiteTime, resetCookies };
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lanzou",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "蓝奏云分享链接解析库:文件列表 + 直链提取,自动处理 acw_sc__v2 WAF 挑战、密码分享、翻页与域名回退",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.mjs",
|
|
8
|
+
"types": "./dist/index.d.mts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.mts",
|
|
13
|
+
"default": "./dist/index.mjs"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsdown",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"example": "tsx examples/demo.ts",
|
|
35
|
+
"format": "prettier --write .",
|
|
36
|
+
"prepublishOnly": "npm run build && npm test"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"lanzou",
|
|
40
|
+
"蓝奏云",
|
|
41
|
+
"lanzouyun",
|
|
42
|
+
"direct-link",
|
|
43
|
+
"download",
|
|
44
|
+
"scraper",
|
|
45
|
+
"crawler",
|
|
46
|
+
"acw_sc__v2"
|
|
47
|
+
],
|
|
48
|
+
"author": "",
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"axios": "^1.7.0"
|
|
52
|
+
},
|
|
53
|
+
"prettier": {
|
|
54
|
+
"semi": false,
|
|
55
|
+
"singleQuote": true,
|
|
56
|
+
"trailingComma": "all",
|
|
57
|
+
"printWidth": 120
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/node": "^26.6.2",
|
|
61
|
+
"prettier": "^3.9.9",
|
|
62
|
+
"tsdown": "^0.23.0",
|
|
63
|
+
"tsx": "^4.23.15",
|
|
64
|
+
"typescript": "^7.0.2",
|
|
65
|
+
"vitest": "^5.0.1"
|
|
66
|
+
}
|
|
67
|
+
}
|