javdict 1.2.2
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/.idea/misc.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/AvDict.iml +8 -0
- package/README.md +242 -0
- package/README_en.md +239 -0
- package/index.js +85 -0
- package/lib/cache.js +73 -0
- package/lib/display.js +57 -0
- package/lib/fetcher.js +367 -0
- package/package.json +31 -0
- package/test/cache.test.js +82 -0
- package/test/display.test.js +58 -0
- package/test/fetcher.test.js +102 -0
package/.idea/misc.xml
ADDED
package/.idea/vcs.xml
ADDED
package/AvDict.iml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<module type="GENERAL_MODULE" version="4">
|
|
3
|
+
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
4
|
+
<exclude-output />
|
|
5
|
+
<content url="file://$MODULE_DIR$" />
|
|
6
|
+
<orderEntry type="sourceFolder" forTests="false" />
|
|
7
|
+
</component>
|
|
8
|
+
</module>
|
package/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# AvDict 🎬
|
|
2
|
+
|
|
3
|
+
> 一个命令行查询 AV 车牌号详细信息的工具
|
|
4
|
+
|
|
5
|
+
[](https://github.com/gdjdkid/AvDict)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[]()
|
|
9
|
+
|
|
10
|
+
输入一个车牌号,即可获取女优、发售日期、制作商、时长、类别等完整信息。支持多数据源自动兜底,无需手动切换。
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 功能特点
|
|
17
|
+
|
|
18
|
+
- 🔍 **多数据源自动兜底** — 依次查询 JAVBUS → NJAV → JavLibrary → JAVDB,任意一个命中即返回
|
|
19
|
+
- 📋 **信息完整** — 女优、男优、发售日期、时长、制作商、发行商、导演、系列、类别、封面、评分
|
|
20
|
+
- 💾 **本地缓存** — 查询结果缓存 7 天,减少重复请求
|
|
21
|
+
- 🖥️ **跨平台** — 支持 Linux、Windows、macOS
|
|
22
|
+
- 🎨 **彩色输出** — 终端美化显示,字段分色,清晰易读
|
|
23
|
+
- ⚡ **查询快速** — 直接番号拼 URL,无需登录即可使用
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 系统要求
|
|
28
|
+
|
|
29
|
+
- Node.js >= 18.0.0
|
|
30
|
+
- npm >= 6.0.0
|
|
31
|
+
- curl(Linux/macOS 系统自带;Windows 请确认 Git Bash 环境下可用)
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 支持的平台
|
|
36
|
+
|
|
37
|
+
| 平台 | JAVBUS | NJAV | JavLibrary | JAVDB |
|
|
38
|
+
|------|--------|------|------------|-------|
|
|
39
|
+
| Linux / macOS | ✅ | ✅ | ✅ | ✅ |
|
|
40
|
+
| Windows | ✅ | ✅ | ✅ | ❌ |
|
|
41
|
+
|
|
42
|
+
> JAVDB 在 Windows 上因 Cloudflare TLS 指纹限制无法使用,其他三个数据源在 Windows 上完全正常。
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 安装方法
|
|
47
|
+
|
|
48
|
+
**方式一:通过 npm 安装(推荐)**
|
|
49
|
+
```bash
|
|
50
|
+
npm install -g javdict
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**方式二:从 GitHub 克隆安装(开发者)**
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/gdjdkid/AvDict.git
|
|
56
|
+
cd AvDict
|
|
57
|
+
npm install
|
|
58
|
+
npm install -g .
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**验证安装成功:**
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
jav -v
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 使用说明
|
|
70
|
+
|
|
71
|
+
**查询车牌号:**
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
jav SSIS-001
|
|
75
|
+
jav ABF-331
|
|
76
|
+
jav JUR-067
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**输出原始 JSON 数据:**
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
jav -r SSIS-001
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**清空本地缓存:**
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
jav --clear-cache
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**配置 JAVDB Cookie(可选):**
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
jav --setup
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**显示帮助信息:**
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
jav -h
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 命令行选项
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
Usage: jav [options] [番号]
|
|
109
|
+
|
|
110
|
+
Arguments:
|
|
111
|
+
番号 要查询的车牌号,例如: SSIS-001
|
|
112
|
+
|
|
113
|
+
Options:
|
|
114
|
+
-v, --version 显示版本号
|
|
115
|
+
-r, --raw 以原始 JSON 格式输出结果
|
|
116
|
+
--setup 配置 JAVDB Cookie(可选,提高覆盖率)
|
|
117
|
+
--clear-cache 清空本地缓存
|
|
118
|
+
-h, --help 显示帮助信息
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## 自定义设置
|
|
124
|
+
|
|
125
|
+
### JAVDB Cookie 配置(可选)
|
|
126
|
+
|
|
127
|
+
不配置也可以正常使用,配置后可提高部分冷门番号的查询覆盖率(仅 Linux/macOS 生效)。
|
|
128
|
+
|
|
129
|
+
**获取步骤:**
|
|
130
|
+
|
|
131
|
+
1. 用 Chrome 打开 [https://javdb.com](https://javdb.com) 并登录账号
|
|
132
|
+
2. 安装 Chrome 插件 [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
|
133
|
+
3. 在 JAVDB 页面点击插件图标,导出 Cookie 文件
|
|
134
|
+
4. 找到 `_jdb_session` 那一行,复制最后一列的值
|
|
135
|
+
5. 运行以下命令并粘贴:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
jav --setup
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Cookie 配置保存在本地 `~/.config/javinfo/config.json`,不会上传到任何地方。
|
|
142
|
+
|
|
143
|
+
**Cookie 有效期约 2 周**,过期后重新运行 `jav --setup` 更新即可。
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 缓存说明
|
|
148
|
+
|
|
149
|
+
查询结果自动缓存到 `~/.config/javinfo/cache.json`,有效期 7 天。缓存可以:
|
|
150
|
+
|
|
151
|
+
- 加快重复查询速度
|
|
152
|
+
- 减少对数据源网站的请求压力
|
|
153
|
+
|
|
154
|
+
如需强制刷新数据,运行:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
jav --clear-cache
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## 常见问题
|
|
163
|
+
|
|
164
|
+
**Q: 查询失败提示 `未找到番号`?**
|
|
165
|
+
|
|
166
|
+
A: 可能原因有三个:
|
|
167
|
+
1. 该番号在所有数据源均未收录(极小众内容)
|
|
168
|
+
2. JAVDB Cookie 已过期,运行 `jav --setup` 重新配置
|
|
169
|
+
3. 网络无法访问数据源,请检查代理设置
|
|
170
|
+
|
|
171
|
+
**Q: Windows 上部分番号查不到?**
|
|
172
|
+
|
|
173
|
+
A: Windows 上 JAVDB 数据源因 Cloudflare 限制无法使用,少数仅收录于 JAVDB 的番号在 Windows 上无法查询。建议在 Linux 环境(如树莓派)下使用以获得最完整的查询结果。
|
|
174
|
+
|
|
175
|
+
**Q: 提示 `Permission denied`?**
|
|
176
|
+
|
|
177
|
+
A: 全局安装时需要管理员权限:
|
|
178
|
+
```bash
|
|
179
|
+
sudo npm install -g .
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Q: Cookie 多久需要更新一次?**
|
|
183
|
+
|
|
184
|
+
A: 通常 2 周左右,当出现冷门番号突然查不到时,运行 `jav --setup` 更新即可。
|
|
185
|
+
|
|
186
|
+
**Q: 支持 FC2 素人番号吗?**
|
|
187
|
+
|
|
188
|
+
A: 支持,输入格式为 `031926-100`(连字符格式),工具会自动识别并转换格式查询。
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## 数据来源
|
|
193
|
+
|
|
194
|
+
| 数据源 | 网站 | 特点 |
|
|
195
|
+
|--------|------|------|
|
|
196
|
+
| JAVBUS | [javbus.com](https://www.javbus.com) | 速度快,数据丰富 |
|
|
197
|
+
| NJAV | [njav.com](https://www.njav.com) | 覆盖率高,无需 Cookie |
|
|
198
|
+
| JavLibrary | [javlibrary.com](https://www.javlibrary.com) | 数据完整,评分信息 |
|
|
199
|
+
| JAVDB | [javdb.com](https://javdb.com) | 数据最全,需要 Cookie |
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## 开源协议
|
|
204
|
+
|
|
205
|
+
本项目基于 [MIT License](LICENSE) 开源,你可以自由使用、修改和分发。
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## 如何贡献代码
|
|
210
|
+
|
|
211
|
+
欢迎提交 PR 或 Issue!
|
|
212
|
+
|
|
213
|
+
1. Fork 这个仓库
|
|
214
|
+
2. 创建你的功能分支:`git checkout -b feat/your-feature`
|
|
215
|
+
3. 提交改动:`git commit -m "feat: 描述你的改动"`
|
|
216
|
+
4. 推送分支:`git push origin feat/your-feature`
|
|
217
|
+
5. 提交 Pull Request
|
|
218
|
+
|
|
219
|
+
**Commit 规范:**
|
|
220
|
+
- `feat:` 新增功能
|
|
221
|
+
- `fix:` 修复 bug
|
|
222
|
+
- `docs:` 修改文档
|
|
223
|
+
- `chore:` 杂项维护
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 请作者喝一杯咖啡 ☕
|
|
228
|
+
|
|
229
|
+
如果这个工具对你有帮助,欢迎打赏支持:
|
|
230
|
+
|
|
231
|
+
| 微信支付 | 支付宝 |
|
|
232
|
+
|---------|--------|
|
|
233
|
+
| *(二维码)* | *(二维码)* |
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## 更新日志
|
|
238
|
+
|
|
239
|
+
- **v1.2.1** — 修复代码细节,稳定性优化
|
|
240
|
+
- **v1.2.0** — 新增 NJAV 第四数据源,调整数据源优先级
|
|
241
|
+
- **v1.1.x** — 三数据源兜底,JAVDB Cookie 改为可选配置,跨平台兼容优化
|
|
242
|
+
- **v1.0.0** — 初始版本发布
|
package/README_en.md
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# AvDict 🎬
|
|
2
|
+
|
|
3
|
+
> A command-line tool to look up JAV metadata by title ID — cast, release date, studio, and more
|
|
4
|
+
|
|
5
|
+
[](https://github.com/gdjdkid/AvDict)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[]()
|
|
9
|
+
|
|
10
|
+
Type a title ID, get back the full details — cast, release date, studio, duration, tags, and more. Multiple data sources are queried automatically with no manual switching required.
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- 🔍 **Multi-source fallback** — Queries JAVBUS → NJAV → JavLibrary → JAVDB in order; returns on first hit
|
|
19
|
+
- 📋 **Rich metadata** — Cast, release date, duration, studio, label, director, series, tags, cover image, rating
|
|
20
|
+
- 💾 **Local cache** — Results cached for 7 days to reduce repeat requests
|
|
21
|
+
- 🖥️ **Cross-platform** — Linux, Windows, and macOS supported
|
|
22
|
+
- 🎨 **Color output** — Terminal-formatted display with field-level color coding
|
|
23
|
+
- ⚡ **Fast lookups** — Direct URL construction by title ID; no login required for most sources
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- Node.js >= 18.0.0
|
|
30
|
+
- npm >= 6.0.0
|
|
31
|
+
- curl (built into Linux/macOS; ensure it's available in your Git Bash environment on Windows)
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Platform Support
|
|
36
|
+
|
|
37
|
+
| Platform | JAVBUS | NJAV | JavLibrary | JAVDB |
|
|
38
|
+
|----------|--------|------|------------|-------|
|
|
39
|
+
| Linux / macOS | ✅ | ✅ | ✅ | ✅ |
|
|
40
|
+
| Windows | ✅ | ✅ | ✅ | ❌ |
|
|
41
|
+
|
|
42
|
+
> JAVDB is unavailable on Windows due to Cloudflare TLS fingerprint restrictions. The other three sources work fully on Windows.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
**Option 1: Install via npm (recommended)**
|
|
49
|
+
```bash
|
|
50
|
+
npm install -g javdict
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Option 2: Clone from GitHub (for developers)**
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/gdjdkid/AvDict.git
|
|
56
|
+
cd AvDict
|
|
57
|
+
npm install
|
|
58
|
+
npm install -g .
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Verify the installation:**
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
jav -v
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
**Look up a title ID:**
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
jav SSIS-001
|
|
75
|
+
jav ABF-331
|
|
76
|
+
jav JUR-067
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Output raw JSON:**
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
jav -r SSIS-001
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Clear local cache:**
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
jav --clear-cache
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Configure JAVDB Cookie (optional):**
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
jav --setup
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Show help:**
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
jav -h
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## CLI Options
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
Usage: jav [options] [id]
|
|
109
|
+
|
|
110
|
+
Arguments:
|
|
111
|
+
id Title ID to look up, e.g. SSIS-001
|
|
112
|
+
|
|
113
|
+
Options:
|
|
114
|
+
-v, --version Print version number
|
|
115
|
+
-r, --raw Output raw JSON instead of formatted display
|
|
116
|
+
--setup Configure JAVDB Cookie (optional, improves coverage)
|
|
117
|
+
--clear-cache Clear local result cache
|
|
118
|
+
-h, --help Show help
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
### JAVDB Cookie (optional)
|
|
126
|
+
|
|
127
|
+
The tool works without any configuration. Adding a JAVDB Cookie improves coverage for niche titles that only exist in JAVDB's database. This only takes effect on Linux/macOS.
|
|
128
|
+
|
|
129
|
+
**How to get your Cookie:**
|
|
130
|
+
|
|
131
|
+
1. Open [https://javdb.com](https://javdb.com) in Chrome and sign in
|
|
132
|
+
2. Install the Chrome extension [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
|
133
|
+
3. Click the extension icon on the JAVDB page and export your cookies
|
|
134
|
+
4. Find the `_jdb_session` row and copy the value in the last column
|
|
135
|
+
5. Run the setup command and paste it in:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
jav --setup
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Your Cookie is saved locally at `~/.config/javinfo/config.json` and never sent anywhere.
|
|
142
|
+
|
|
143
|
+
**Cookies typically expire in about 2 weeks.** When niche titles start returning "not found", just run `jav --setup` again to refresh.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Caching
|
|
148
|
+
|
|
149
|
+
Query results are automatically cached to `~/.config/javinfo/cache.json` with a 7-day TTL. This speeds up repeat lookups and reduces load on the source sites.
|
|
150
|
+
|
|
151
|
+
To force a fresh fetch:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
jav --clear-cache
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## FAQ
|
|
160
|
+
|
|
161
|
+
**Q: I get "title not found" — what's wrong?**
|
|
162
|
+
|
|
163
|
+
A: There are three likely causes:
|
|
164
|
+
1. The title isn't indexed by any of the four sources (extremely niche content)
|
|
165
|
+
2. Your JAVDB Cookie has expired — run `jav --setup` to refresh it
|
|
166
|
+
3. Your network can't reach the source sites — check your proxy settings
|
|
167
|
+
|
|
168
|
+
**Q: Some titles don't show up on Windows?**
|
|
169
|
+
|
|
170
|
+
A: JAVDB is unavailable on Windows due to Cloudflare restrictions. A small number of titles that only exist in JAVDB can't be found on Windows. For full coverage, use a Linux environment (e.g. a Raspberry Pi).
|
|
171
|
+
|
|
172
|
+
**Q: I get `Permission denied` when installing?**
|
|
173
|
+
|
|
174
|
+
A: Global installation requires elevated permissions:
|
|
175
|
+
```bash
|
|
176
|
+
sudo npm install -g .
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Q: How often do I need to update my Cookie?**
|
|
180
|
+
|
|
181
|
+
A: Roughly every 2 weeks. If niche titles that previously worked start returning "not found", your Cookie has likely expired — run `jav --setup` to update it.
|
|
182
|
+
|
|
183
|
+
**Q: Does it support FC2 amateur titles?**
|
|
184
|
+
|
|
185
|
+
A: Yes. Enter the title in hyphen format, e.g. `031926-100`. The tool auto-detects FC2 format and handles the conversion internally.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Data Sources
|
|
190
|
+
|
|
191
|
+
| Source | Website | Notes |
|
|
192
|
+
|--------|---------|-------|
|
|
193
|
+
| JAVBUS | [javbus.com](https://www.javbus.com) | Fast, broad coverage |
|
|
194
|
+
| NJAV | [njav.com](https://www.njav.com) | High coverage, no Cookie needed |
|
|
195
|
+
| JavLibrary | [javlibrary.com](https://www.javlibrary.com) | Detailed metadata, includes ratings |
|
|
196
|
+
| JAVDB | [javdb.com](https://javdb.com) | Most comprehensive, requires Cookie |
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
This project is open source under the [MIT License](LICENSE). You're free to use, modify, and distribute it.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Contributing
|
|
207
|
+
|
|
208
|
+
PRs and Issues are welcome!
|
|
209
|
+
|
|
210
|
+
1. Fork this repository
|
|
211
|
+
2. Create your branch: `git checkout -b feat/your-feature`
|
|
212
|
+
3. Commit your changes: `git commit -m "feat: describe your change"`
|
|
213
|
+
4. Push the branch: `git push origin feat/your-feature`
|
|
214
|
+
5. Open a Pull Request
|
|
215
|
+
|
|
216
|
+
**Commit message conventions:**
|
|
217
|
+
- `feat:` — new feature
|
|
218
|
+
- `fix:` — bug fix
|
|
219
|
+
- `docs:` — documentation update
|
|
220
|
+
- `chore:` — maintenance / housekeeping
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Buy Me a Coffee ☕
|
|
225
|
+
|
|
226
|
+
If this tool saves you time, consider supporting development:
|
|
227
|
+
|
|
228
|
+
| WeChat Pay | Alipay |
|
|
229
|
+
|------------|--------|
|
|
230
|
+
| *(QR code)* | *(QR code)* |
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## Changelog
|
|
235
|
+
|
|
236
|
+
- **v1.2.1** — Minor fixes and stability improvements
|
|
237
|
+
- **v1.2.0** — Added NJAV as fourth data source; reordered source priority
|
|
238
|
+
- **v1.1.x** — Three-source fallback; JAVDB Cookie made optional; cross-platform compatibility fixes
|
|
239
|
+
- **v1.0.0** — Initial release
|
package/index.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createInterface } from 'readline';
|
|
4
|
+
import { program } from 'commander';
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
|
+
import { search } from './lib/fetcher.js';
|
|
7
|
+
import { display } from './lib/display.js';
|
|
8
|
+
import { clearCache, setConfig } from './lib/cache.js';
|
|
9
|
+
import ora from 'ora';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const pkg = require('./package.json');
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name('jav')
|
|
17
|
+
.description('AV番号命令行查询工具')
|
|
18
|
+
.version(pkg.version, '-v, --version')
|
|
19
|
+
.argument('[番号]', '要查询的番号,例如: SSIS-001')
|
|
20
|
+
.option('-r, --raw', '显示原始详细数据')
|
|
21
|
+
.option('--clear-cache', '清空本地缓存')
|
|
22
|
+
.option('--setup', '配置JAVDB Cookie(可选,提高查询覆盖率)')
|
|
23
|
+
.action(async (id, options) => {
|
|
24
|
+
|
|
25
|
+
if (options.setup) {
|
|
26
|
+
console.log('');
|
|
27
|
+
console.log(chalk.yellow('=== 配置 JAVDB Cookie(可选)==='));
|
|
28
|
+
console.log('');
|
|
29
|
+
console.log('不配置也可以正常使用,配置后覆盖率更高。');
|
|
30
|
+
console.log('');
|
|
31
|
+
console.log('获取步骤:');
|
|
32
|
+
console.log(' 1. Chrome 打开 https://javdb.com 并登录账号');
|
|
33
|
+
console.log(' 2. 安装插件 "Get cookies.txt LOCALLY"');
|
|
34
|
+
console.log(' 3. 导出 Cookie 文件,找到 _jdb_session 那行');
|
|
35
|
+
console.log(' 4. 复制最后一列的值粘贴到下面');
|
|
36
|
+
console.log('');
|
|
37
|
+
|
|
38
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
39
|
+
await new Promise((resolve) => {
|
|
40
|
+
rl.question(chalk.cyan('请粘贴 _jdb_session 的值(直接回车跳过): '), (session) => {
|
|
41
|
+
rl.close();
|
|
42
|
+
if (!session.trim()) {
|
|
43
|
+
console.log(chalk.gray('\n已跳过,使用 JAVBUS + JavLibrary 作为数据源。'));
|
|
44
|
+
} else {
|
|
45
|
+
setConfig({ session: session.trim() });
|
|
46
|
+
console.log(chalk.green('\n✅ 配置保存成功!'));
|
|
47
|
+
console.log(chalk.gray('保存位置: ~/.config/javinfo/config.json'));
|
|
48
|
+
}
|
|
49
|
+
console.log('');
|
|
50
|
+
resolve();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (options.clearCache) {
|
|
57
|
+
clearCache();
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!id) {
|
|
62
|
+
program.help();
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const spinner = ora(`正在查询 ${id.toUpperCase()} ...`).start();
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const result = await search(id.toUpperCase());
|
|
70
|
+
spinner.stop();
|
|
71
|
+
|
|
72
|
+
if (!result) {
|
|
73
|
+
console.log(chalk.red(`\n未找到番号: ${id.toUpperCase()}`));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
display(result, options.raw);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
spinner.stop();
|
|
80
|
+
console.error('\n查询失败:', err.message);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
program.parse();
|
package/lib/cache.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
// 缓存文件存放在用户主目录下,和 yddict 的做法一致
|
|
6
|
+
const CACHE_DIR = join(homedir(), '.config', 'javinfo');
|
|
7
|
+
const CACHE_FILE = join(CACHE_DIR, 'cache.json');
|
|
8
|
+
|
|
9
|
+
// 缓存有效期:7 天(单位毫秒)
|
|
10
|
+
const TTL = 7 * 24 * 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
function loadCache() {
|
|
13
|
+
if (!existsSync(CACHE_FILE)) return {};
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
|
|
16
|
+
} catch {
|
|
17
|
+
// 文件损坏就当缓存不存在
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function saveCache(data) {
|
|
23
|
+
if (!existsSync(CACHE_DIR)) {
|
|
24
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getCache(id) {
|
|
30
|
+
const cache = loadCache();
|
|
31
|
+
const entry = cache[id];
|
|
32
|
+
if (!entry) return null;
|
|
33
|
+
|
|
34
|
+
// 检查是否过期
|
|
35
|
+
const isExpired = Date.now() - entry.cachedAt > TTL;
|
|
36
|
+
if (isExpired) return null;
|
|
37
|
+
|
|
38
|
+
return entry.data;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setCache(id, data) {
|
|
42
|
+
const cache = loadCache();
|
|
43
|
+
cache[id] = {
|
|
44
|
+
cachedAt: Date.now(),
|
|
45
|
+
data,
|
|
46
|
+
};
|
|
47
|
+
saveCache(cache);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function clearCache() {
|
|
51
|
+
if (existsSync(CACHE_FILE)) {
|
|
52
|
+
writeFileSync(CACHE_FILE, '{}', 'utf-8');
|
|
53
|
+
console.log('缓存已清空');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getConfig() {
|
|
58
|
+
const configFile = join(CACHE_DIR, 'config.json');
|
|
59
|
+
if (!existsSync(configFile)) return {};
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
62
|
+
} catch {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function setConfig(data) {
|
|
68
|
+
if (!existsSync(CACHE_DIR)) {
|
|
69
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
const configFile = join(CACHE_DIR, 'config.json');
|
|
72
|
+
writeFileSync(configFile, JSON.stringify(data, null, 2), 'utf-8');
|
|
73
|
+
}
|
package/lib/display.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
const DIVIDER = chalk.gray('─'.repeat(60));
|
|
4
|
+
|
|
5
|
+
function row(label, value, color = chalk.white) {
|
|
6
|
+
if (!value || (Array.isArray(value) && value.length === 0)) return;
|
|
7
|
+
const paddedLabel = chalk.cyan(label.padEnd(8, ' ')); // 用全角空格对齐中文
|
|
8
|
+
console.log(` ${paddedLabel} ${color(value)}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function display(info, raw = false) {
|
|
12
|
+
if (raw) {
|
|
13
|
+
console.log(JSON.stringify(info, null, 2));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log('');
|
|
18
|
+
console.log(DIVIDER);
|
|
19
|
+
console.log(
|
|
20
|
+
' ' +
|
|
21
|
+
chalk.bold.yellow('🎬 ') +
|
|
22
|
+
chalk.bold.white(info.id) +
|
|
23
|
+
(info.score ? chalk.yellow(` ⭐ ${info.score}`) : '')
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (info.title) {
|
|
27
|
+
console.log(' ' + chalk.gray(info.title));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log(DIVIDER);
|
|
31
|
+
|
|
32
|
+
row('女优', info.actresses.join(' / '), chalk.magenta);
|
|
33
|
+
row('男优', info.actors.join(' / '), chalk.blue);
|
|
34
|
+
row('发售日期', info.releaseDate, chalk.green);
|
|
35
|
+
row('时长', info.duration);
|
|
36
|
+
row('制作商', info.studio, chalk.yellow);
|
|
37
|
+
row('发行商', info.label);
|
|
38
|
+
row('导演', info.director);
|
|
39
|
+
row('系列', info.series);
|
|
40
|
+
|
|
41
|
+
if (info.tags.length > 0) {
|
|
42
|
+
const tagStr = info.tags.map(t => chalk.bgGray(` ${t} `)).join(' ');
|
|
43
|
+
console.log(' ' + chalk.cyan('类别 ') + ' ' + tagStr);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (info.coverUrl) {
|
|
47
|
+
console.log('');
|
|
48
|
+
console.log(' ' + chalk.cyan('封面 ') + ' ' + chalk.underline.gray(info.coverUrl));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (info.source) {
|
|
52
|
+
console.log(' ' + chalk.gray(`数据来源: ${info.source}`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(DIVIDER);
|
|
56
|
+
console.log('');
|
|
57
|
+
}
|
package/lib/fetcher.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import * as cheerio from 'cheerio';
|
|
3
|
+
import { getCache, setCache, getConfig } from './cache.js';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
|
|
6
|
+
// ─── 通用请求函数(JAVBUS / JavLibrary 使用)────────────
|
|
7
|
+
function fetchHtml(url, cookie = '') {
|
|
8
|
+
const cookieHeader = cookie ? `-H "Cookie: ${cookie}"` : '';
|
|
9
|
+
const result = execSync(
|
|
10
|
+
`curl -sL "${url}" \
|
|
11
|
+
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" \
|
|
12
|
+
-H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \
|
|
13
|
+
-H "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8" \
|
|
14
|
+
${cookieHeader}`,
|
|
15
|
+
{ timeout: 15000, maxBuffer: 1024 * 1024 * 10 }
|
|
16
|
+
);
|
|
17
|
+
return result.toString();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── 数据源一:JAVBUS ────────────────────────────────────
|
|
21
|
+
function searchJavbus(id) {
|
|
22
|
+
try {
|
|
23
|
+
const url = `https://www.javbus.com/${id}`;
|
|
24
|
+
const html = fetchHtml(url);
|
|
25
|
+
const $ = cheerio.load(html);
|
|
26
|
+
|
|
27
|
+
if ($('title').text().includes('404') || !$('.container .row').length) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const result = {
|
|
32
|
+
id,
|
|
33
|
+
source: 'JAVBUS',
|
|
34
|
+
title: $('h3').first().text().trim(),
|
|
35
|
+
actresses: [],
|
|
36
|
+
actors: [],
|
|
37
|
+
releaseDate: '',
|
|
38
|
+
duration: '',
|
|
39
|
+
studio: '',
|
|
40
|
+
label: '',
|
|
41
|
+
director: '',
|
|
42
|
+
series: '',
|
|
43
|
+
tags: [],
|
|
44
|
+
coverUrl: $('.screencap img').attr('src') || '',
|
|
45
|
+
score: '',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
$('.info p').each((_, el) => {
|
|
49
|
+
const text = $(el).text().trim();
|
|
50
|
+
const label = $(el).find('span.header').text().trim();
|
|
51
|
+
if (/發行日期|发行日期/.test(label)) {
|
|
52
|
+
result.releaseDate = text.replace(label, '').trim();
|
|
53
|
+
} else if (/長度|长度/.test(label)) {
|
|
54
|
+
result.duration = text.replace(label, '').trim();
|
|
55
|
+
} else if (/導演|导演/.test(label)) {
|
|
56
|
+
result.director = $(el).find('a').text().trim();
|
|
57
|
+
} else if (/製作商|制作商/.test(label)) {
|
|
58
|
+
result.studio = $(el).find('a').text().trim();
|
|
59
|
+
} else if (/發行商|发行商/.test(label)) {
|
|
60
|
+
result.label = $(el).find('a').text().trim();
|
|
61
|
+
} else if (/系列/.test(label)) {
|
|
62
|
+
result.series = $(el).find('a').text().trim();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
$('span.genre a').each((_, el) => {
|
|
67
|
+
const tag = $(el).text().trim();
|
|
68
|
+
if (tag) result.tags.push(tag);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
$('.star-name a').each((_, el) => {
|
|
72
|
+
const name = $(el).text().trim();
|
|
73
|
+
if (name) result.actresses.push(name);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return result.title ? result : null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── 数据源二:JavLibrary ────────────────────────────────
|
|
83
|
+
function searchJavlibrary(id) {
|
|
84
|
+
try {
|
|
85
|
+
const searchUrl = `https://www.javlibrary.com/cn/vl_searchbyid.php?keyword=${encodeURIComponent(id)}`;
|
|
86
|
+
const searchHtml = fetchHtml(searchUrl);
|
|
87
|
+
const $ = cheerio.load(searchHtml);
|
|
88
|
+
|
|
89
|
+
let detailHtml = searchHtml;
|
|
90
|
+
const firstResult = $('.videos .video a').first();
|
|
91
|
+
if (firstResult.length) {
|
|
92
|
+
const detailPath = firstResult.attr('href');
|
|
93
|
+
detailHtml = fetchHtml(`https://www.javlibrary.com/cn/${detailPath}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const $d = cheerio.load(detailHtml);
|
|
97
|
+
if (!$d('#video_id .text').length) return null;
|
|
98
|
+
|
|
99
|
+
const foundId = $d('#video_id .text').text().trim().toUpperCase();
|
|
100
|
+
if (foundId !== id.toUpperCase()) return null;
|
|
101
|
+
|
|
102
|
+
const result = {
|
|
103
|
+
id,
|
|
104
|
+
source: 'JavLibrary',
|
|
105
|
+
title: $d('#video_title h3').text().trim(),
|
|
106
|
+
actresses: [],
|
|
107
|
+
actors: [],
|
|
108
|
+
releaseDate: $d('#video_date .text').text().trim(),
|
|
109
|
+
duration: $d('#video_length .text').text().trim(),
|
|
110
|
+
studio: $d('#video_maker .text a').text().trim(),
|
|
111
|
+
label: $d('#video_label .text a').text().trim(),
|
|
112
|
+
director: $d('#video_director .text a').text().trim(),
|
|
113
|
+
series: $d('#video_series .text a').text().trim(),
|
|
114
|
+
tags: [],
|
|
115
|
+
coverUrl: $d('#video_jacket_img').attr('src') || '',
|
|
116
|
+
score: $d('#video_review .score').text().trim(),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
$d('#video_genres .genre a').each((_, el) => {
|
|
120
|
+
const tag = $d(el).text().trim();
|
|
121
|
+
if (tag) result.tags.push(tag);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
$d('#video_cast .cast .star a').each((_, el) => {
|
|
125
|
+
const name = $d(el).text().trim();
|
|
126
|
+
if (name) result.actresses.push(name);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return result.title ? result : null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── 数据源三:JAVDB(需要Cookie,仅Linux/Mac)──────────
|
|
136
|
+
async function searchJavdb(id) {
|
|
137
|
+
try {
|
|
138
|
+
const config = getConfig();
|
|
139
|
+
if (!config.session) return null;
|
|
140
|
+
|
|
141
|
+
// Windows上Cloudflare封锁了非OpenSSL的TLS请求,跳过
|
|
142
|
+
if (process.platform === 'win32') return null;
|
|
143
|
+
|
|
144
|
+
const session = decodeURIComponent(config.session);
|
|
145
|
+
const cookie = `locale=zh; over18=1; _jdb_session=${session}`;
|
|
146
|
+
|
|
147
|
+
function fetchJavdbHtml(url) {
|
|
148
|
+
return execSync(
|
|
149
|
+
`curl -sL "${url}" \
|
|
150
|
+
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" \
|
|
151
|
+
-H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" \
|
|
152
|
+
-H "Accept-Language: zh-CN,zh;q=0.9" \
|
|
153
|
+
-H "Cookie: ${cookie}"`,
|
|
154
|
+
{ timeout: 15000, maxBuffer: 1024 * 1024 * 10 }
|
|
155
|
+
).toString();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const searchUrl = `https://javdb.com/search?q=${encodeURIComponent(id)}&f=all`;
|
|
159
|
+
const searchHtml = fetchJavdbHtml(searchUrl);
|
|
160
|
+
|
|
161
|
+
const $ = cheerio.load(searchHtml);
|
|
162
|
+
let detailPath = null;
|
|
163
|
+
|
|
164
|
+
$('.movie-list .item a.box').each((_, el) => {
|
|
165
|
+
const foundId = $(el).find('.video-title strong').text().trim().toUpperCase();
|
|
166
|
+
const normalize = s => s.replace(/[-_]/g, '');
|
|
167
|
+
if (normalize(foundId) === normalize(id.toUpperCase())) {
|
|
168
|
+
detailPath = $(el).attr('href');
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!detailPath) detailPath = $('.movie-list .item a.box').first().attr('href');
|
|
174
|
+
if (!detailPath) return null;
|
|
175
|
+
|
|
176
|
+
// 详情页最多重试 3 次,每次间隔 2 秒
|
|
177
|
+
let detailHtml = '';
|
|
178
|
+
for (let i = 0; i < 3; i++) {
|
|
179
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
180
|
+
detailHtml = fetchJavdbHtml(`https://javdb.com${detailPath}`);
|
|
181
|
+
if (detailHtml.length > 1000) break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (detailHtml.length < 1000) return null;
|
|
185
|
+
return parseJavdb(detailHtml, id);
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── 数据源四:NJAV(无需Cookie,覆盖率高)────────────
|
|
192
|
+
function searchNjav(id) {
|
|
193
|
+
try {
|
|
194
|
+
const url = `https://www.njav.com/zh/xvideos/${id.toLowerCase()}`;
|
|
195
|
+
const html = fetchHtml(url);
|
|
196
|
+
const $ = cheerio.load(html);
|
|
197
|
+
|
|
198
|
+
// 确认页面包含正确番号
|
|
199
|
+
const pageTitle = $('title').text();
|
|
200
|
+
if (!pageTitle.includes(id.toUpperCase())) return null;
|
|
201
|
+
|
|
202
|
+
const result = {
|
|
203
|
+
id,
|
|
204
|
+
source: 'NJAV',
|
|
205
|
+
title: '',
|
|
206
|
+
actresses: [],
|
|
207
|
+
actors: [],
|
|
208
|
+
releaseDate: '',
|
|
209
|
+
duration: '',
|
|
210
|
+
studio: '',
|
|
211
|
+
label: '',
|
|
212
|
+
director: '',
|
|
213
|
+
series: '',
|
|
214
|
+
tags: [],
|
|
215
|
+
coverUrl: `https://static.javcdn.vip/images/${id.toLowerCase()}/thumb_h.webp`,
|
|
216
|
+
score: '',
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// 优先用 JSON-LD 解析,最稳定
|
|
220
|
+
const jsonLd = $('script[type="application/ld+json"]').text();
|
|
221
|
+
if (jsonLd) {
|
|
222
|
+
try {
|
|
223
|
+
const data = JSON.parse(jsonLd);
|
|
224
|
+
result.title = data.name || '';
|
|
225
|
+
result.releaseDate = data.uploadDate?.substring(0, 10) || '';
|
|
226
|
+
// 解析时长 PT3H22M51S → 3:22:51
|
|
227
|
+
if (data.duration) {
|
|
228
|
+
const m = data.duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
|
229
|
+
if (m) {
|
|
230
|
+
const h = m[1] || '0';
|
|
231
|
+
const min = m[2] ? m[2].padStart(2, '0') : '00';
|
|
232
|
+
const sec = m[3] ? m[3].padStart(2, '0') : '00';
|
|
233
|
+
result.duration = `${h}:${min}:${sec}`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (Array.isArray(data.actor)) {
|
|
237
|
+
result.actresses = data.actor.map(a => a.name).filter(Boolean);
|
|
238
|
+
}
|
|
239
|
+
if (Array.isArray(data.genre)) {
|
|
240
|
+
result.tags = data.genre;
|
|
241
|
+
}
|
|
242
|
+
if (data.partOfSeries?.name) {
|
|
243
|
+
result.series = data.partOfSeries.name;
|
|
244
|
+
}
|
|
245
|
+
} catch {}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 补充 HTML 里的字段(JSON-LD 没有的)
|
|
249
|
+
$('.detail-item div').each((_, el) => {
|
|
250
|
+
const label = $(el).find('span').first().text().trim();
|
|
251
|
+
const value = $(el).find('span').last().text().trim();
|
|
252
|
+
if (/片商|制作|製作|Studio|Maker/i.test(label)) {
|
|
253
|
+
result.studio = value;
|
|
254
|
+
} else if (/導演|Director/i.test(label)) {
|
|
255
|
+
result.director = value;
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return result.title ? result : null;
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parseJavdb(html, queryId) {
|
|
266
|
+
const $ = cheerio.load(html);
|
|
267
|
+
const result = {
|
|
268
|
+
id: queryId,
|
|
269
|
+
source: 'JAVDB',
|
|
270
|
+
title: $('strong.current-title').text().trim(),
|
|
271
|
+
actresses: [],
|
|
272
|
+
actors: [],
|
|
273
|
+
releaseDate: '',
|
|
274
|
+
duration: '',
|
|
275
|
+
studio: '',
|
|
276
|
+
label: '',
|
|
277
|
+
director: '',
|
|
278
|
+
series: '',
|
|
279
|
+
tags: [],
|
|
280
|
+
coverUrl: $('.video-cover img').attr('src') || '',
|
|
281
|
+
score: $('.score .value').first().text().trim(),
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
$('.movie-panel-info .panel-block').each((_, el) => {
|
|
285
|
+
const label = $(el).find('strong').first().text().replace(/:|:/g, '').trim();
|
|
286
|
+
const valueEl = $(el).find('.value');
|
|
287
|
+
const valueText = valueEl.text().trim();
|
|
288
|
+
|
|
289
|
+
if (/日期/.test(label)) {
|
|
290
|
+
const m = valueText.match(/\d{4}-\d{2}-\d{2}/);
|
|
291
|
+
result.releaseDate = m ? m[0] : valueText;
|
|
292
|
+
} else if (/時長|时长/.test(label)) {
|
|
293
|
+
result.duration = valueText;
|
|
294
|
+
} else if (/導演|导演/.test(label)) {
|
|
295
|
+
result.director = valueText;
|
|
296
|
+
} else if (/片商/.test(label)) {
|
|
297
|
+
result.studio = valueText;
|
|
298
|
+
} else if (/發行商|发行商/.test(label)) {
|
|
299
|
+
result.label = valueText;
|
|
300
|
+
} else if (/系列/.test(label)) {
|
|
301
|
+
result.series = valueText;
|
|
302
|
+
} else if (/類別|类别/.test(label)) {
|
|
303
|
+
valueEl.find('a').each((_, a) => {
|
|
304
|
+
const t = $(a).text().trim();
|
|
305
|
+
if (t) result.tags.push(t);
|
|
306
|
+
});
|
|
307
|
+
} else if (/演員|演员/.test(label)) {
|
|
308
|
+
valueEl.find('a').each((_, a) => {
|
|
309
|
+
const name = $(a).text().trim();
|
|
310
|
+
if (name) result.actresses.push(name);
|
|
311
|
+
});
|
|
312
|
+
} else if (/男優|男优/.test(label)) {
|
|
313
|
+
valueEl.find('a').each((_, a) => {
|
|
314
|
+
const name = $(a).text().trim();
|
|
315
|
+
if (name) result.actors.push(name);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return result.title ? result : null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ─── 主入口:依次尝试三个数据源 ─────────────────────────
|
|
324
|
+
export async function search(id) {
|
|
325
|
+
// FC2 格式识别:031926-100 → 031926_100(JAVDB 存储格式)
|
|
326
|
+
let searchId = id;
|
|
327
|
+
if (/^\d{5,6}-\d+$/.test(id)) {
|
|
328
|
+
searchId = id.replace(/-/g, '_'); // 全局替换,更安全
|
|
329
|
+
console.log(chalk.gray(` 识别为FC2格式的车牌号: ${id}`));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const cached = getCache(id);
|
|
333
|
+
if (cached) return cached;
|
|
334
|
+
|
|
335
|
+
const MAX_RETRY = 3;
|
|
336
|
+
|
|
337
|
+
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
|
|
338
|
+
let result = null;
|
|
339
|
+
|
|
340
|
+
// 第一优先:JAVBUS(最快,直接番号拼URL)
|
|
341
|
+
result = searchJavbus(searchId);
|
|
342
|
+
if (result) { setCache(id, result); return result; }
|
|
343
|
+
|
|
344
|
+
// 第二优先:NJAV(无需Cookie,覆盖率高,能查到JUR等小众番号)
|
|
345
|
+
result = searchNjav(searchId);
|
|
346
|
+
if (result) { setCache(id, result); return result; }
|
|
347
|
+
|
|
348
|
+
// 第三优先:JavLibrary(补充数据)
|
|
349
|
+
result = searchJavlibrary(searchId);
|
|
350
|
+
if (result) { setCache(id, result); return result; }
|
|
351
|
+
|
|
352
|
+
// 第四优先:JAVDB(需要Cookie,仅Linux)
|
|
353
|
+
result = await searchJavdb(searchId);
|
|
354
|
+
if (result) { setCache(id, result); return result; }
|
|
355
|
+
|
|
356
|
+
if (attempt < MAX_RETRY) {
|
|
357
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Windows 用户提示
|
|
362
|
+
if (process.platform === 'win32') {
|
|
363
|
+
process.stderr.write(chalk.gray(' 温馨提示: 此车牌号需要JAVDB数据源,Windows暂不支持~\n'));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return null;
|
|
367
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "javdict",
|
|
3
|
+
"version": "1.2.2",
|
|
4
|
+
"description": "AV番号命令行查询工具",
|
|
5
|
+
"main": "./index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node index.js",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"test:watch": "vitest",
|
|
11
|
+
"test:coverage": "vitest run --coverage"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"jav": "./index.js"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"axios": "^1.6.0",
|
|
18
|
+
"chalk": "^5.3.0",
|
|
19
|
+
"cheerio": "^1.0.0",
|
|
20
|
+
"commander": "^12.0.0",
|
|
21
|
+
"ora": "^8.0.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@vitest/coverage-v8": "^1.0.0",
|
|
25
|
+
"vitest": "^1.0.0"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18.0.0"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT"
|
|
31
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { existsSync, rmSync, mkdirSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
|
|
6
|
+
// 每次测试前后清理真实缓存文件,避免污染
|
|
7
|
+
const CACHE_FILE = join(homedir(), '.config', 'javinfo', 'cache.json');
|
|
8
|
+
|
|
9
|
+
// 动态 import 保证每次拿到最新模块
|
|
10
|
+
async function importCache() {
|
|
11
|
+
return await import('../lib/cache.js');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('cache.js', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// 清空缓存文件,保证每个测试独立
|
|
17
|
+
if (existsSync(CACHE_FILE)) {
|
|
18
|
+
rmSync(CACHE_FILE);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
if (existsSync(CACHE_FILE)) {
|
|
24
|
+
rmSync(CACHE_FILE);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('getCache:缓存不存在时返回 null', async () => {
|
|
29
|
+
const { getCache } = await importCache();
|
|
30
|
+
const result = getCache('SSIS-001');
|
|
31
|
+
expect(result).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('setCache + getCache:写入后能正确读取', async () => {
|
|
35
|
+
const { getCache, setCache } = await importCache();
|
|
36
|
+
const mockData = { id: 'SSIS-001', title: '测试标题', actresses: ['女优A'] };
|
|
37
|
+
|
|
38
|
+
setCache('SSIS-001', mockData);
|
|
39
|
+
const result = getCache('SSIS-001');
|
|
40
|
+
|
|
41
|
+
expect(result).toEqual(mockData);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('getCache:缓存过期后返回 null', async () => {
|
|
45
|
+
const { getCache, setCache } = await importCache();
|
|
46
|
+
const mockData = { id: 'ABW-001', title: '过期测试' };
|
|
47
|
+
|
|
48
|
+
setCache('ABW-001', mockData);
|
|
49
|
+
|
|
50
|
+
// 手动篡改缓存时间为 8 天前,模拟过期
|
|
51
|
+
const { readFileSync, writeFileSync } = await import('fs');
|
|
52
|
+
const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
|
|
53
|
+
raw['ABW-001'].cachedAt = Date.now() - 8 * 24 * 60 * 60 * 1000;
|
|
54
|
+
writeFileSync(CACHE_FILE, JSON.stringify(raw), 'utf-8');
|
|
55
|
+
|
|
56
|
+
const result = getCache('ABW-001');
|
|
57
|
+
expect(result).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('setCache:不同番号互不干扰', async () => {
|
|
61
|
+
const { getCache, setCache } = await importCache();
|
|
62
|
+
const dataA = { id: 'SSIS-001', title: 'A' };
|
|
63
|
+
const dataB = { id: 'IPX-001', title: 'B' };
|
|
64
|
+
|
|
65
|
+
setCache('SSIS-001', dataA);
|
|
66
|
+
setCache('IPX-001', dataB);
|
|
67
|
+
|
|
68
|
+
expect(getCache('SSIS-001')).toEqual(dataA);
|
|
69
|
+
expect(getCache('IPX-001')).toEqual(dataB);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('clearCache:清空后所有缓存消失', async () => {
|
|
73
|
+
const { getCache, setCache, clearCache } = await importCache();
|
|
74
|
+
setCache('SSIS-001', { id: 'SSIS-001' });
|
|
75
|
+
setCache('IPX-001', { id: 'IPX-001' });
|
|
76
|
+
|
|
77
|
+
clearCache();
|
|
78
|
+
|
|
79
|
+
expect(getCache('SSIS-001')).toBeNull();
|
|
80
|
+
expect(getCache('IPX-001')).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { display } from '../lib/display.js';
|
|
3
|
+
|
|
4
|
+
// 完整的 mock 数据
|
|
5
|
+
const MOCK_INFO = {
|
|
6
|
+
id: 'SSIS-001',
|
|
7
|
+
title: '测试标题',
|
|
8
|
+
actresses: ['天使もえ'],
|
|
9
|
+
actors: ['男优A'],
|
|
10
|
+
releaseDate: '2021-01-01',
|
|
11
|
+
duration: '120分钟',
|
|
12
|
+
studio: 'SOD Create',
|
|
13
|
+
label: 'SOD',
|
|
14
|
+
director: '导演A',
|
|
15
|
+
series: '系列A',
|
|
16
|
+
tags: ['独占', '美少女'],
|
|
17
|
+
coverUrl: 'https://example.com/cover.jpg',
|
|
18
|
+
score: '4.5分',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('display.js', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
// 拦截 console.log,不真正打印,但可以断言调用内容
|
|
24
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('display:正常调用不抛出异常', () => {
|
|
32
|
+
expect(() => display(MOCK_INFO)).not.toThrow();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('display:输出内容包含番号', () => {
|
|
36
|
+
display(MOCK_INFO);
|
|
37
|
+
const allOutput = console.log.mock.calls.flat().join(' ');
|
|
38
|
+
expect(allOutput).toContain('SSIS-001');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('display:输出内容包含女优名', () => {
|
|
42
|
+
display(MOCK_INFO);
|
|
43
|
+
const allOutput = console.log.mock.calls.flat().join(' ');
|
|
44
|
+
expect(allOutput).toContain('天使もえ');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('display:raw 模式输出 JSON 字符串', () => {
|
|
48
|
+
display(MOCK_INFO, true);
|
|
49
|
+
const allOutput = console.log.mock.calls.flat().join(' ');
|
|
50
|
+
// raw 模式应该输出可解析的 JSON
|
|
51
|
+
expect(() => JSON.parse(allOutput)).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('display:空字段不影响正常输出', () => {
|
|
55
|
+
const sparseInfo = { ...MOCK_INFO, actors: [], tags: [], coverUrl: '' };
|
|
56
|
+
expect(() => display(sparseInfo)).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// mock axios,避免真实网络请求
|
|
4
|
+
vi.mock('axios', () => ({
|
|
5
|
+
default: {
|
|
6
|
+
get: vi.fn(),
|
|
7
|
+
},
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import axios from 'axios';
|
|
11
|
+
import { search } from '../lib/fetcher.js';
|
|
12
|
+
|
|
13
|
+
// 模拟搜索结果页的 HTML
|
|
14
|
+
const MOCK_SEARCH_HTML = `
|
|
15
|
+
<div class="movie-list">
|
|
16
|
+
<div class="item">
|
|
17
|
+
<a href="/v/abc123">
|
|
18
|
+
<div class="video-title">SSIS-001</div>
|
|
19
|
+
</a>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
// 模拟详情页的 HTML
|
|
25
|
+
const MOCK_DETAIL_HTML = `
|
|
26
|
+
<h2 class="title"><strong class="current-title">测试标题</strong></h2>
|
|
27
|
+
<div class="video-cover"><img src="https://example.com/cover.jpg" /></div>
|
|
28
|
+
<div class="score"><span class="value">4.5分</span></div>
|
|
29
|
+
<div class="movie-panel-info">
|
|
30
|
+
<div class="panel-block">
|
|
31
|
+
<strong>日期:</strong>
|
|
32
|
+
<span class="value">2021-01-01</span>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="panel-block">
|
|
35
|
+
<strong>演員:</strong>
|
|
36
|
+
<span class="value"><a>天使もえ</a></span>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="panel-block">
|
|
39
|
+
<strong>片商:</strong>
|
|
40
|
+
<span class="value">SOD Create</span>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="panel-block">
|
|
43
|
+
<strong>時長:</strong>
|
|
44
|
+
<span class="value">120分钟</span>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
describe('fetcher.js', () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('search:正常番号能返回结构完整的对象', async () => {
|
|
55
|
+
// 第一次调用返回搜索页,第二次返回详情页
|
|
56
|
+
axios.get
|
|
57
|
+
.mockResolvedValueOnce({ data: MOCK_SEARCH_HTML })
|
|
58
|
+
.mockResolvedValueOnce({ data: MOCK_DETAIL_HTML });
|
|
59
|
+
|
|
60
|
+
const result = await search('SSIS-001');
|
|
61
|
+
|
|
62
|
+
expect(result).not.toBeNull();
|
|
63
|
+
expect(result.id).toBe('SSIS-001');
|
|
64
|
+
expect(result.title).toBe('测试标题');
|
|
65
|
+
expect(result.actresses).toContain('天使もえ');
|
|
66
|
+
expect(result.releaseDate).toBe('2021-01-01');
|
|
67
|
+
expect(result.studio).toBe('SOD Create');
|
|
68
|
+
expect(result.duration).toBe('120分钟');
|
|
69
|
+
expect(result.coverUrl).toBe('https://example.com/cover.jpg');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('search:搜索结果为空时返回 null', async () => {
|
|
73
|
+
// 返回一个没有结果的搜索页
|
|
74
|
+
axios.get.mockResolvedValueOnce({ data: '<div class="movie-list"></div>' });
|
|
75
|
+
|
|
76
|
+
const result = await search('INVALID-999');
|
|
77
|
+
expect(result).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('search:网络请求失败时抛出错误', async () => {
|
|
81
|
+
axios.get.mockRejectedValueOnce(new Error('Network Error'));
|
|
82
|
+
|
|
83
|
+
await expect(search('SSIS-001')).rejects.toThrow('Network Error');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('search:返回对象包含所有预期字段', async () => {
|
|
87
|
+
axios.get
|
|
88
|
+
.mockResolvedValueOnce({ data: MOCK_SEARCH_HTML })
|
|
89
|
+
.mockResolvedValueOnce({ data: MOCK_DETAIL_HTML });
|
|
90
|
+
|
|
91
|
+
const result = await search('SSIS-001');
|
|
92
|
+
const expectedFields = [
|
|
93
|
+
'id', 'title', 'actresses', 'actors',
|
|
94
|
+
'releaseDate', 'duration', 'studio', 'label',
|
|
95
|
+
'director', 'series', 'tags', 'coverUrl', 'score',
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
expectedFields.forEach(field => {
|
|
99
|
+
expect(result).toHaveProperty(field);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|