@jackwener/opencli 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/.github/workflows/ci.yml +26 -0
- package/.github/workflows/release.yml +40 -0
- package/README.md +67 -0
- package/SKILL.md +230 -0
- package/dist/bilibili.d.ts +13 -0
- package/dist/bilibili.js +93 -0
- package/dist/browser.d.ts +48 -0
- package/dist/browser.js +261 -0
- package/dist/clis/bilibili/favorite.d.ts +1 -0
- package/dist/clis/bilibili/favorite.js +39 -0
- package/dist/clis/bilibili/feed.d.ts +1 -0
- package/dist/clis/bilibili/feed.js +64 -0
- package/dist/clis/bilibili/history.d.ts +1 -0
- package/dist/clis/bilibili/history.js +44 -0
- package/dist/clis/bilibili/me.d.ts +1 -0
- package/dist/clis/bilibili/me.js +13 -0
- package/dist/clis/bilibili/search.d.ts +1 -0
- package/dist/clis/bilibili/search.js +24 -0
- package/dist/clis/bilibili/user-videos.d.ts +1 -0
- package/dist/clis/bilibili/user-videos.js +38 -0
- package/dist/clis/github/search.d.ts +1 -0
- package/dist/clis/github/search.js +20 -0
- package/dist/clis/index.d.ts +13 -0
- package/dist/clis/index.js +16 -0
- package/dist/clis/zhihu/search.d.ts +1 -0
- package/dist/clis/zhihu/search.js +58 -0
- package/dist/engine.d.ts +6 -0
- package/dist/engine.js +77 -0
- package/dist/explore.d.ts +17 -0
- package/dist/explore.js +603 -0
- package/dist/generate.d.ts +11 -0
- package/dist/generate.js +134 -0
- package/dist/main.d.ts +5 -0
- package/dist/main.js +117 -0
- package/dist/output.d.ts +11 -0
- package/dist/output.js +98 -0
- package/dist/pipeline.d.ts +9 -0
- package/dist/pipeline.js +315 -0
- package/dist/promote.d.ts +1 -0
- package/dist/promote.js +3 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.js +2 -0
- package/dist/registry.d.ts +50 -0
- package/dist/registry.js +42 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +27 -0
- package/dist/scaffold.d.ts +2 -0
- package/dist/scaffold.js +2 -0
- package/dist/smoke.d.ts +2 -0
- package/dist/smoke.js +2 -0
- package/dist/snapshotFormatter.d.ts +9 -0
- package/dist/snapshotFormatter.js +41 -0
- package/dist/synthesize.d.ts +10 -0
- package/dist/synthesize.js +191 -0
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +73 -0
- package/dist/verify.d.ts +2 -0
- package/dist/verify.js +9 -0
- package/package.json +47 -0
- package/src/bilibili.ts +111 -0
- package/src/browser.ts +260 -0
- package/src/clis/bilibili/favorite.ts +42 -0
- package/src/clis/bilibili/feed.ts +71 -0
- package/src/clis/bilibili/history.ts +48 -0
- package/src/clis/bilibili/hot.yaml +38 -0
- package/src/clis/bilibili/me.ts +14 -0
- package/src/clis/bilibili/search.ts +25 -0
- package/src/clis/bilibili/user-videos.ts +42 -0
- package/src/clis/github/search.ts +21 -0
- package/src/clis/github/trending.yaml +58 -0
- package/src/clis/hackernews/top.yaml +36 -0
- package/src/clis/index.ts +19 -0
- package/src/clis/twitter/trending.yaml +40 -0
- package/src/clis/v2ex/hot.yaml +29 -0
- package/src/clis/v2ex/latest.yaml +28 -0
- package/src/clis/zhihu/hot.yaml +28 -0
- package/src/clis/zhihu/search.ts +65 -0
- package/src/engine.ts +86 -0
- package/src/explore.ts +648 -0
- package/src/generate.ts +145 -0
- package/src/main.ts +103 -0
- package/src/output.ts +96 -0
- package/src/pipeline.ts +295 -0
- package/src/promote.ts +3 -0
- package/src/register.ts +2 -0
- package/src/registry.ts +87 -0
- package/src/runtime.ts +36 -0
- package/src/scaffold.ts +2 -0
- package/src/smoke.ts +2 -0
- package/src/snapshotFormatter.ts +51 -0
- package/src/synthesize.ts +210 -0
- package/src/validate.ts +55 -0
- package/src/verify.ts +9 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthesize: turn explore capabilities into ready-to-use CLI definitions.
|
|
3
|
+
*
|
|
4
|
+
* Takes the structured capabilities from Deep Explore and generates
|
|
5
|
+
* YAML pipeline files that can be directly registered as CLI commands.
|
|
6
|
+
*
|
|
7
|
+
* This is the bridge between discovery (explore) and usability (CLI).
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import yaml from 'js-yaml';
|
|
12
|
+
export function synthesizeFromExplore(target, opts = {}) {
|
|
13
|
+
const exploreDir = fs.existsSync(target) ? target : path.join('.opencli', 'explore', target);
|
|
14
|
+
if (!fs.existsSync(exploreDir))
|
|
15
|
+
throw new Error(`Explore dir not found: ${target}`);
|
|
16
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(exploreDir, 'manifest.json'), 'utf-8'));
|
|
17
|
+
const capabilities = JSON.parse(fs.readFileSync(path.join(exploreDir, 'capabilities.json'), 'utf-8'));
|
|
18
|
+
const endpoints = JSON.parse(fs.readFileSync(path.join(exploreDir, 'endpoints.json'), 'utf-8'));
|
|
19
|
+
const auth = JSON.parse(fs.readFileSync(path.join(exploreDir, 'auth.json'), 'utf-8'));
|
|
20
|
+
const targetDir = opts.outDir ?? path.join(exploreDir, 'candidates');
|
|
21
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
22
|
+
const site = manifest.site;
|
|
23
|
+
const topN = opts.top ?? 5;
|
|
24
|
+
const candidates = [];
|
|
25
|
+
// Sort capabilities by confidence
|
|
26
|
+
const sortedCaps = [...capabilities]
|
|
27
|
+
.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0))
|
|
28
|
+
.slice(0, topN);
|
|
29
|
+
for (const cap of sortedCaps) {
|
|
30
|
+
// Find the matching endpoint for more detail
|
|
31
|
+
const endpoint = endpoints.find((ep) => ep.pattern === cap.endpoint) ??
|
|
32
|
+
endpoints[0];
|
|
33
|
+
const candidate = buildCandidateYaml(site, manifest, cap, endpoint);
|
|
34
|
+
const fileName = `${cap.name}.yaml`;
|
|
35
|
+
const filePath = path.join(targetDir, fileName);
|
|
36
|
+
fs.writeFileSync(filePath, yaml.dump(candidate.yaml, { sortKeys: false, lineWidth: 120 }));
|
|
37
|
+
candidates.push({
|
|
38
|
+
name: cap.name,
|
|
39
|
+
path: filePath,
|
|
40
|
+
strategy: cap.strategy,
|
|
41
|
+
endpoint: cap.endpoint,
|
|
42
|
+
confidence: cap.confidence,
|
|
43
|
+
columns: candidate.yaml.columns,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const index = {
|
|
47
|
+
site,
|
|
48
|
+
target_url: manifest.target_url,
|
|
49
|
+
generated_from: exploreDir,
|
|
50
|
+
candidate_count: candidates.length,
|
|
51
|
+
candidates,
|
|
52
|
+
};
|
|
53
|
+
fs.writeFileSync(path.join(targetDir, 'candidates.json'), JSON.stringify(index, null, 2));
|
|
54
|
+
return {
|
|
55
|
+
site,
|
|
56
|
+
explore_dir: exploreDir,
|
|
57
|
+
out_dir: targetDir,
|
|
58
|
+
candidate_count: candidates.length,
|
|
59
|
+
candidates,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Volatile params to strip from generated URLs */
|
|
63
|
+
const VOLATILE_PARAMS = new Set(['w_rid', 'wts', 'callback', '_', 'timestamp', 't', 'nonce', 'sign']);
|
|
64
|
+
const SEARCH_PARAM_NAMES = new Set(['q', 'query', 'keyword', 'search', 'wd', 'kw', 'w', 'search_query']);
|
|
65
|
+
const LIMIT_PARAM_NAMES = new Set(['ps', 'page_size', 'limit', 'count', 'per_page', 'size', 'num']);
|
|
66
|
+
const PAGE_PARAM_NAMES = new Set(['pn', 'page', 'page_num', 'offset', 'cursor']);
|
|
67
|
+
/**
|
|
68
|
+
* Build a clean templated URL from a raw API URL.
|
|
69
|
+
* - Strips volatile params (w_rid, wts, etc.)
|
|
70
|
+
* - Templates search, limit, and pagination params
|
|
71
|
+
* - Builds URL string manually to avoid URL encoding of ${{ }} expressions
|
|
72
|
+
*/
|
|
73
|
+
function buildTemplatedUrl(rawUrl, cap, endpoint) {
|
|
74
|
+
try {
|
|
75
|
+
const u = new URL(rawUrl);
|
|
76
|
+
const base = `${u.protocol}//${u.host}${u.pathname}`;
|
|
77
|
+
const params = [];
|
|
78
|
+
const hasKeyword = cap.recommendedArgs?.some((a) => a.name === 'keyword');
|
|
79
|
+
u.searchParams.forEach((v, k) => {
|
|
80
|
+
// Skip volatile params
|
|
81
|
+
if (VOLATILE_PARAMS.has(k))
|
|
82
|
+
return;
|
|
83
|
+
// Template known param types
|
|
84
|
+
if (hasKeyword && SEARCH_PARAM_NAMES.has(k)) {
|
|
85
|
+
params.push([k, '${{ args.keyword }}']);
|
|
86
|
+
}
|
|
87
|
+
else if (LIMIT_PARAM_NAMES.has(k)) {
|
|
88
|
+
params.push([k, '${{ args.limit | default(20) }}']);
|
|
89
|
+
}
|
|
90
|
+
else if (PAGE_PARAM_NAMES.has(k)) {
|
|
91
|
+
params.push([k, '${{ args.page | default(1) }}']);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
params.push([k, v]);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if (params.length === 0)
|
|
98
|
+
return base;
|
|
99
|
+
return base + '?' + params.map(([k, v]) => `${k}=${v}`).join('&');
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return rawUrl;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Build a YAML pipeline definition from a capability + endpoint.
|
|
107
|
+
*/
|
|
108
|
+
function buildCandidateYaml(site, manifest, cap, endpoint) {
|
|
109
|
+
const needsBrowser = cap.strategy !== 'public';
|
|
110
|
+
const pipeline = [];
|
|
111
|
+
// Step 1: Navigate (if browser-based)
|
|
112
|
+
if (needsBrowser) {
|
|
113
|
+
pipeline.push({ navigate: manifest.target_url });
|
|
114
|
+
}
|
|
115
|
+
// Step 2: Fetch the API — build a clean URL with templates
|
|
116
|
+
const rawUrl = endpoint?.url ?? manifest.target_url;
|
|
117
|
+
const fetchStep = { url: buildTemplatedUrl(rawUrl, cap, endpoint) };
|
|
118
|
+
pipeline.push({ fetch: fetchStep });
|
|
119
|
+
// Step 3: Select the item path
|
|
120
|
+
if (cap.itemPath) {
|
|
121
|
+
pipeline.push({ select: cap.itemPath });
|
|
122
|
+
}
|
|
123
|
+
// Step 4: Map fields to columns
|
|
124
|
+
const mapStep = {};
|
|
125
|
+
const columns = cap.recommendedColumns ?? ['title', 'url'];
|
|
126
|
+
// Add a rank column if not doing search
|
|
127
|
+
if (!cap.recommendedArgs?.some((a) => a.name === 'keyword')) {
|
|
128
|
+
mapStep['rank'] = '${{ index + 1 }}';
|
|
129
|
+
}
|
|
130
|
+
// Build field mappings from the endpoint's detected fields
|
|
131
|
+
const detectedFields = endpoint?.detectedFields ?? {};
|
|
132
|
+
for (const col of columns) {
|
|
133
|
+
const fieldPath = detectedFields[col];
|
|
134
|
+
if (fieldPath) {
|
|
135
|
+
mapStep[col] = `\${{ item.${fieldPath} }}`;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
mapStep[col] = `\${{ item.${col} }}`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
pipeline.push({ map: mapStep });
|
|
142
|
+
// Step 5: Limit
|
|
143
|
+
pipeline.push({ limit: '${{ args.limit | default(20) }}' });
|
|
144
|
+
// Build args definition
|
|
145
|
+
const argsDef = {};
|
|
146
|
+
for (const arg of cap.recommendedArgs ?? []) {
|
|
147
|
+
const def = { type: arg.type ?? 'str' };
|
|
148
|
+
if (arg.required)
|
|
149
|
+
def.required = true;
|
|
150
|
+
if (arg.default != null)
|
|
151
|
+
def.default = arg.default;
|
|
152
|
+
if (arg.name === 'keyword')
|
|
153
|
+
def.description = 'Search keyword';
|
|
154
|
+
else if (arg.name === 'limit')
|
|
155
|
+
def.description = 'Number of items to return';
|
|
156
|
+
else if (arg.name === 'page')
|
|
157
|
+
def.description = 'Page number';
|
|
158
|
+
argsDef[arg.name] = def;
|
|
159
|
+
}
|
|
160
|
+
// Ensure limit arg always exists
|
|
161
|
+
if (!argsDef['limit']) {
|
|
162
|
+
argsDef['limit'] = { type: 'int', default: 20, description: 'Number of items to return' };
|
|
163
|
+
}
|
|
164
|
+
const allColumns = Object.keys(mapStep);
|
|
165
|
+
return {
|
|
166
|
+
name: cap.name,
|
|
167
|
+
yaml: {
|
|
168
|
+
site,
|
|
169
|
+
name: cap.name,
|
|
170
|
+
description: `${site} ${cap.name} (auto-generated)`,
|
|
171
|
+
domain: manifest.final_url ? new URL(manifest.final_url).hostname : undefined,
|
|
172
|
+
strategy: cap.strategy,
|
|
173
|
+
browser: needsBrowser,
|
|
174
|
+
args: argsDef,
|
|
175
|
+
pipeline,
|
|
176
|
+
columns: allColumns,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
export function renderSynthesizeSummary(r) {
|
|
181
|
+
const lines = [
|
|
182
|
+
'opencli synthesize: OK',
|
|
183
|
+
`Site: ${r.site}`,
|
|
184
|
+
`Source: ${r.explore_dir}`,
|
|
185
|
+
`Candidates: ${r.candidate_count}`,
|
|
186
|
+
];
|
|
187
|
+
for (const c of r.candidates ?? []) {
|
|
188
|
+
lines.push(` • ${c.name} (${c.strategy}, ${(c.confidence * 100).toFixed(0)}% confidence) → ${c.path}`);
|
|
189
|
+
}
|
|
190
|
+
return lines.join('\n');
|
|
191
|
+
}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Validate CLI definitions. */
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
export function validateClisWithTarget(dirs, target) {
|
|
6
|
+
const results = [];
|
|
7
|
+
let errors = 0;
|
|
8
|
+
let warnings = 0;
|
|
9
|
+
let files = 0;
|
|
10
|
+
for (const dir of dirs) {
|
|
11
|
+
if (!fs.existsSync(dir))
|
|
12
|
+
continue;
|
|
13
|
+
for (const site of fs.readdirSync(dir)) {
|
|
14
|
+
if (target && site !== target && !target.startsWith(site + '/'))
|
|
15
|
+
continue;
|
|
16
|
+
const siteDir = path.join(dir, site);
|
|
17
|
+
if (!fs.statSync(siteDir).isDirectory())
|
|
18
|
+
continue;
|
|
19
|
+
for (const file of fs.readdirSync(siteDir)) {
|
|
20
|
+
if (!file.endsWith('.yaml') && !file.endsWith('.yml'))
|
|
21
|
+
continue;
|
|
22
|
+
if (target && target.includes('/') && !target.endsWith(file.replace(/\.(yaml|yml)$/, '')))
|
|
23
|
+
continue;
|
|
24
|
+
files++;
|
|
25
|
+
const filePath = path.join(siteDir, file);
|
|
26
|
+
const r = validateYamlFile(filePath);
|
|
27
|
+
results.push(r);
|
|
28
|
+
errors += r.errors.length;
|
|
29
|
+
warnings += r.warnings.length;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { ok: errors === 0, results, errors, warnings, files };
|
|
34
|
+
}
|
|
35
|
+
function validateYamlFile(filePath) {
|
|
36
|
+
const errors = [];
|
|
37
|
+
const warnings = [];
|
|
38
|
+
try {
|
|
39
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
40
|
+
const def = yaml.load(raw);
|
|
41
|
+
if (!def || typeof def !== 'object') {
|
|
42
|
+
errors.push('Not a valid YAML object');
|
|
43
|
+
return { path: filePath, errors, warnings };
|
|
44
|
+
}
|
|
45
|
+
if (!def.site)
|
|
46
|
+
errors.push('Missing "site"');
|
|
47
|
+
if (!def.name)
|
|
48
|
+
errors.push('Missing "name"');
|
|
49
|
+
if (def.pipeline && !Array.isArray(def.pipeline))
|
|
50
|
+
errors.push('"pipeline" must be an array');
|
|
51
|
+
if (def.columns && !Array.isArray(def.columns))
|
|
52
|
+
errors.push('"columns" must be an array');
|
|
53
|
+
if (def.args && typeof def.args !== 'object')
|
|
54
|
+
errors.push('"args" must be an object');
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
errors.push(`YAML parse error: ${e.message}`);
|
|
58
|
+
}
|
|
59
|
+
return { path: filePath, errors, warnings };
|
|
60
|
+
}
|
|
61
|
+
export function renderValidationReport(report) {
|
|
62
|
+
const lines = [`opencli validate: ${report.ok ? 'PASS' : 'FAIL'}`, `Checked ${report.results.length} CLI(s) in ${report.files} file(s)`, `Errors: ${report.errors} Warnings: ${report.warnings}`];
|
|
63
|
+
for (const r of report.results) {
|
|
64
|
+
if (r.errors.length > 0 || r.warnings.length > 0) {
|
|
65
|
+
lines.push(`\n${r.path}:`);
|
|
66
|
+
for (const e of r.errors)
|
|
67
|
+
lines.push(` ❌ ${e}`);
|
|
68
|
+
for (const w of r.warnings)
|
|
69
|
+
lines.push(` ⚠️ ${w}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return lines.join('\n');
|
|
73
|
+
}
|
package/dist/verify.d.ts
ADDED
package/dist/verify.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Verification: validate + smoke. */
|
|
2
|
+
import { validateClisWithTarget, renderValidationReport } from './validate.js';
|
|
3
|
+
export async function verifyClis(opts) {
|
|
4
|
+
const report = validateClisWithTarget([opts.builtinClis, opts.userClis], opts.target);
|
|
5
|
+
return { ok: report.ok, validation: report, smoke: null };
|
|
6
|
+
}
|
|
7
|
+
export function renderVerifyReport(report) {
|
|
8
|
+
return renderValidationReport(report.validation);
|
|
9
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jackwener/opencli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Make any website your CLI. AI-powered.",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "dist/main.js",
|
|
10
|
+
"bin": {
|
|
11
|
+
"opencli": "dist/main.js"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "tsx src/main.ts",
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"start": "node dist/main.js",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"lint": "tsc --noEmit",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"cli",
|
|
23
|
+
"browser",
|
|
24
|
+
"web",
|
|
25
|
+
"ai",
|
|
26
|
+
"playwright"
|
|
27
|
+
],
|
|
28
|
+
"author": "jackwener",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/jackwener/opencli.git"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"chalk": "^5.3.0",
|
|
36
|
+
"cli-table3": "^0.6.5",
|
|
37
|
+
"commander": "^13.1.0",
|
|
38
|
+
"js-yaml": "^4.1.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@playwright/mcp": "^0.0.68",
|
|
42
|
+
"@types/js-yaml": "^4.0.9",
|
|
43
|
+
"@types/node": "^22.13.10",
|
|
44
|
+
"tsx": "^4.19.3",
|
|
45
|
+
"typescript": "^5.8.2"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/bilibili.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bilibili shared helpers: WBI signing, authenticated fetch, nav data, UID resolution.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const MIXIN_KEY_ENC_TAB = [
|
|
6
|
+
46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,
|
|
7
|
+
33,9,42,19,29,28,14,39,12,38,41,13,37,48,7,16,24,55,40,
|
|
8
|
+
61,26,17,0,1,60,51,30,4,22,25,54,21,56,59,6,63,57,62,11,
|
|
9
|
+
36,20,34,44,52,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export function stripHtml(s: string): string {
|
|
13
|
+
return s.replace(/<[^>]+>/g, '').replace(/&[a-z]+;/gi, ' ').trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function payloadData(payload: any): any {
|
|
17
|
+
return payload?.data ?? payload;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function getNavData(page: any): Promise<any> {
|
|
21
|
+
return page.evaluate(`
|
|
22
|
+
async () => {
|
|
23
|
+
const res = await fetch('https://api.bilibili.com/x/web-interface/nav', { credentials: 'include' });
|
|
24
|
+
return await res.json();
|
|
25
|
+
}
|
|
26
|
+
`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function getWbiKeys(page: any): Promise<{ imgKey: string; subKey: string }> {
|
|
30
|
+
const nav = await getNavData(page);
|
|
31
|
+
const wbiImg = nav?.data?.wbi_img ?? {};
|
|
32
|
+
const imgUrl = wbiImg.img_url ?? '';
|
|
33
|
+
const subUrl = wbiImg.sub_url ?? '';
|
|
34
|
+
const imgKey = imgUrl.split('/').pop()?.split('.')[0] ?? '';
|
|
35
|
+
const subKey = subUrl.split('/').pop()?.split('.')[0] ?? '';
|
|
36
|
+
return { imgKey, subKey };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getMixinKey(imgKey: string, subKey: string): string {
|
|
40
|
+
const raw = imgKey + subKey;
|
|
41
|
+
return MIXIN_KEY_ENC_TAB.map(i => raw[i] || '').join('').slice(0, 32);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function md5(text: string): Promise<string> {
|
|
45
|
+
const { createHash } = await import('node:crypto');
|
|
46
|
+
return createHash('md5').update(text).digest('hex');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function wbiSign(
|
|
50
|
+
page: any,
|
|
51
|
+
params: Record<string, any>,
|
|
52
|
+
): Promise<Record<string, string>> {
|
|
53
|
+
const { imgKey, subKey } = await getWbiKeys(page);
|
|
54
|
+
const mixinKey = getMixinKey(imgKey, subKey);
|
|
55
|
+
const wts = Math.floor(Date.now() / 1000);
|
|
56
|
+
const sorted: Record<string, string> = {};
|
|
57
|
+
const allParams = { ...params, wts: String(wts) };
|
|
58
|
+
for (const key of Object.keys(allParams).sort()) {
|
|
59
|
+
sorted[key] = String(allParams[key]).replace(/[!'()*]/g, '');
|
|
60
|
+
}
|
|
61
|
+
const query = new URLSearchParams(sorted).toString();
|
|
62
|
+
const wRid = await md5(query + mixinKey);
|
|
63
|
+
sorted.w_rid = wRid;
|
|
64
|
+
return sorted;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function apiGet(
|
|
68
|
+
page: any,
|
|
69
|
+
path: string,
|
|
70
|
+
opts: { params?: Record<string, any>; signed?: boolean } = {},
|
|
71
|
+
): Promise<any> {
|
|
72
|
+
const baseUrl = 'https://api.bilibili.com';
|
|
73
|
+
let params = opts.params ?? {};
|
|
74
|
+
if (opts.signed) {
|
|
75
|
+
params = await wbiSign(page, params);
|
|
76
|
+
}
|
|
77
|
+
const qs = new URLSearchParams(
|
|
78
|
+
Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
|
|
79
|
+
);
|
|
80
|
+
const url = `${baseUrl}${path}?${qs}`;
|
|
81
|
+
return fetchJson(page, url);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function fetchJson(page: any, url: string): Promise<any> {
|
|
85
|
+
const escapedUrl = url.replace(/"/g, '\\"');
|
|
86
|
+
return page.evaluate(`
|
|
87
|
+
async () => {
|
|
88
|
+
const res = await fetch("${escapedUrl}", { credentials: "include" });
|
|
89
|
+
return await res.json();
|
|
90
|
+
}
|
|
91
|
+
`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function getSelfUid(page: any): Promise<string> {
|
|
95
|
+
const nav = await getNavData(page);
|
|
96
|
+
const mid = nav?.data?.mid;
|
|
97
|
+
if (!mid) throw new Error('Not logged in to Bilibili');
|
|
98
|
+
return String(mid);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function resolveUid(page: any, input: string): Promise<string> {
|
|
102
|
+
if (/^\d+$/.test(input)) return input;
|
|
103
|
+
// Search for user by name
|
|
104
|
+
const payload = await apiGet(page, '/x/web-interface/wbi/search/type', {
|
|
105
|
+
params: { search_type: 'bili_user', keyword: input },
|
|
106
|
+
signed: true,
|
|
107
|
+
});
|
|
108
|
+
const results = payload?.data?.result ?? [];
|
|
109
|
+
if (results.length > 0) return String(results[0].mid);
|
|
110
|
+
throw new Error(`Cannot resolve UID for: ${input}`);
|
|
111
|
+
}
|