@wwkit/cft 1.0.28 → 1.0.29
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 +20 -20
- package/README.md +67 -67
- package/bin/index.js +74 -74
- package/package.json +3 -3
- package/scripts/postinstall.js +49 -49
- package/src/config.js +54 -54
- package/src/config.json5 +4 -4
- package/src/depchecker.js +175 -175
- package/src/env.js +84 -84
- package/src/exefinder.js +109 -109
- package/src/index.js +3 -3
- package/src/installer.js +368 -368
- package/src/platform.js +31 -31
package/src/installer.js
CHANGED
|
@@ -1,369 +1,369 @@
|
|
|
1
|
-
import fs from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import https from 'node:https'
|
|
4
|
-
import http from 'node:http'
|
|
5
|
-
|
|
6
|
-
import extractZip from 'extract-zip'
|
|
7
|
-
|
|
8
|
-
import { getOs, VALID_OS_TYPES } from './platform.js'
|
|
9
|
-
import { find as findExe } from './exefinder.js'
|
|
10
|
-
import { check as checkDeps, mapToPackages, install as installDeps, isSupported as depsSupported, detectPackageManager } from './depchecker.js'
|
|
11
|
-
import { getVersion, getRegistry, getProxy, getLocation, copyBuiltinConfig } from './config.js'
|
|
12
|
-
import { parseProxy, openTunnel } from '@wwkit/shared'
|
|
13
|
-
|
|
14
|
-
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 cft-installer'
|
|
15
|
-
const MANUAL_DOWNLOAD_HINT = '资源下载失败,请在 http://deepbluenet.com/webwork/#/mirror 手动下载资源,并将资源解压到 ~/.config/cft 目录,然后将地址添加到 ~/.config/webwork/webwork.json5 中的 env.SELENIUM_CFT_DIR 属性上'
|
|
16
|
-
|
|
17
|
-
function log(msg) {
|
|
18
|
-
console.log(` ${msg}`)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function sendRequest(url, proxyValue, cb, method = 'GET') {
|
|
22
|
-
const proxy = parseProxy(proxyValue)
|
|
23
|
-
const target = new URL(url)
|
|
24
|
-
const headers = { 'user-agent': USER_AGENT }
|
|
25
|
-
const onError = (e) => cb(e, null)
|
|
26
|
-
|
|
27
|
-
if (!proxy) {
|
|
28
|
-
const proto = target.protocol === 'https:' ? https : http
|
|
29
|
-
const req = proto.request(url, { headers, method }, (res) => cb(null, res))
|
|
30
|
-
req.on('error', onError)
|
|
31
|
-
req.end()
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
if (proxy.protocol === 'http:' && target.protocol === 'http:') {
|
|
36
|
-
const req = http.request({
|
|
37
|
-
host: proxy.hostname,
|
|
38
|
-
port: Number(proxy.port) || 80,
|
|
39
|
-
path: url,
|
|
40
|
-
method,
|
|
41
|
-
headers: { ...headers, host: target.host },
|
|
42
|
-
}, (res) => cb(null, res))
|
|
43
|
-
req.on('error', onError)
|
|
44
|
-
req.end()
|
|
45
|
-
return
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
openTunnel(proxy, target).then((socket) => {
|
|
49
|
-
const proto = target.protocol === 'https:' ? https : http
|
|
50
|
-
const req = proto.request({
|
|
51
|
-
method,
|
|
52
|
-
hostname: target.hostname,
|
|
53
|
-
port: Number(target.port) || (target.protocol === 'https:' ? 443 : 80),
|
|
54
|
-
path: target.pathname + target.search,
|
|
55
|
-
headers: { ...headers, host: target.host },
|
|
56
|
-
agent: false,
|
|
57
|
-
createConnection: () => socket,
|
|
58
|
-
}, (res) => cb(null, res))
|
|
59
|
-
req.on('error', onError)
|
|
60
|
-
socket.on('error', onError)
|
|
61
|
-
req.end()
|
|
62
|
-
}).catch(onError)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* 访问前预检完整路径是否可达(HEAD 请求,非 2xx/3xx 视为不可达)
|
|
67
|
-
* @param {string} url - 完整下载 URL
|
|
68
|
-
* @param {string} [proxyValue] - 代理地址
|
|
69
|
-
* @returns {Promise<boolean>}
|
|
70
|
-
*/
|
|
71
|
-
function checkUrlReachable(url, proxyValue) {
|
|
72
|
-
return new Promise((resolve) => {
|
|
73
|
-
sendRequest(url, proxyValue, (err, response) => {
|
|
74
|
-
if (err) { resolve(false); return }
|
|
75
|
-
response.resume()
|
|
76
|
-
if (response.statusCode >= 200 && response.statusCode < 400) {
|
|
77
|
-
resolve(true)
|
|
78
|
-
} else if (response.statusCode === 405) {
|
|
79
|
-
// 405 Method Not Allowed — 服务器已响应,仅不支持 HEAD
|
|
80
|
-
resolve(true)
|
|
81
|
-
} else {
|
|
82
|
-
resolve(false)
|
|
83
|
-
}
|
|
84
|
-
}, 'HEAD')
|
|
85
|
-
})
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* 下载失败时的统一终止提示
|
|
90
|
-
* @param {Error} [cause] - 原始错误
|
|
91
|
-
*/
|
|
92
|
-
function downloadError(cause) {
|
|
93
|
-
const err = new Error(MANUAL_DOWNLOAD_HINT)
|
|
94
|
-
if (cause) err.cause = cause
|
|
95
|
-
return err
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function downloadFile(url, destPath, proxyValue, redirects = 0) {
|
|
99
|
-
return new Promise((resolve, reject) => {
|
|
100
|
-
if (redirects > 5) {
|
|
101
|
-
reject(new Error(`Too many redirects: ${url}`))
|
|
102
|
-
return
|
|
103
|
-
}
|
|
104
|
-
const filename = path.basename(destPath)
|
|
105
|
-
log(`下载 ${filename} ...`)
|
|
106
|
-
|
|
107
|
-
sendRequest(url, proxyValue, (err, response) => {
|
|
108
|
-
if (err) { reject(err); return }
|
|
109
|
-
if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308) {
|
|
110
|
-
const location = response.headers.location
|
|
111
|
-
response.resume()
|
|
112
|
-
if (!location) {
|
|
113
|
-
reject(new Error(`Redirect without Location header: ${url}`))
|
|
114
|
-
return
|
|
115
|
-
}
|
|
116
|
-
downloadFile(location, destPath, proxyValue, redirects + 1).then(resolve).catch(reject)
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
if (response.statusCode !== 200) {
|
|
120
|
-
reject(new Error(`HTTP ${response.statusCode}: ${url}`))
|
|
121
|
-
return
|
|
122
|
-
}
|
|
123
|
-
const total = parseInt(response.headers['content-length'] || '0', 10)
|
|
124
|
-
let downloaded = 0
|
|
125
|
-
let lastLog = 0
|
|
126
|
-
response.on('data', (chunk) => {
|
|
127
|
-
downloaded += chunk.length
|
|
128
|
-
if (total > 0) {
|
|
129
|
-
const pct = Math.floor(downloaded * 100 / total)
|
|
130
|
-
if (pct >= lastLog + 10 || downloaded === total) {
|
|
131
|
-
log(`[${filename}] ${pct}% (${Math.floor(downloaded / 1048576)}MB / ${Math.floor(total / 1048576)}MB)`)
|
|
132
|
-
lastLog = pct
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
})
|
|
136
|
-
const file = fs.createWriteStream(destPath)
|
|
137
|
-
response.pipe(file)
|
|
138
|
-
file.on('finish', () => {
|
|
139
|
-
file.close()
|
|
140
|
-
resolve()
|
|
141
|
-
})
|
|
142
|
-
file.on('error', reject)
|
|
143
|
-
response.on('error', reject)
|
|
144
|
-
})
|
|
145
|
-
})
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
async function downloadExtract(url, saveDir, filename, extractDir, proxyValue) {
|
|
149
|
-
const filePath = path.join(saveDir, filename)
|
|
150
|
-
await downloadFile(url, filePath, proxyValue)
|
|
151
|
-
|
|
152
|
-
log(`解压到 ${path.basename(extractDir)}`)
|
|
153
|
-
if (fs.existsSync(extractDir)) {
|
|
154
|
-
fs.rmSync(extractDir, { recursive: true, force: true })
|
|
155
|
-
}
|
|
156
|
-
await extractZip(filePath, { dir: saveDir })
|
|
157
|
-
fs.unlinkSync(filePath)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function chmodExec(filePath) {
|
|
161
|
-
if (filePath && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
162
|
-
try { fs.chmodSync(filePath, 0o755) } catch {}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function chmodExecDir(directory) {
|
|
167
|
-
if (!directory || !fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) return
|
|
168
|
-
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
169
|
-
const fullPath = path.join(directory, entry.name)
|
|
170
|
-
if (entry.isDirectory()) {
|
|
171
|
-
chmodExecDir(fullPath)
|
|
172
|
-
} else {
|
|
173
|
-
try { fs.chmodSync(fullPath, 0o755) } catch {}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function prepareDir(location) {
|
|
179
|
-
if (!fs.existsSync(location)) {
|
|
180
|
-
fs.mkdirSync(location, { recursive: true })
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
const entries = fs.readdirSync(location)
|
|
184
|
-
if (!entries.length) return
|
|
185
|
-
log(`清空安装目录: ${location}`)
|
|
186
|
-
for (const name of entries) {
|
|
187
|
-
if (name === 'config.json5') continue
|
|
188
|
-
const itemPath = path.join(location, name)
|
|
189
|
-
if (fs.statSync(itemPath).isFile()) {
|
|
190
|
-
fs.unlinkSync(itemPath)
|
|
191
|
-
} else {
|
|
192
|
-
fs.rmSync(itemPath, { recursive: true, force: true })
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function readInstalledInfo(location) {
|
|
198
|
-
const jsonPath = path.join(location, 'installed.json')
|
|
199
|
-
if (!fs.existsSync(jsonPath)) return null
|
|
200
|
-
try {
|
|
201
|
-
return JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
|
202
|
-
} catch {
|
|
203
|
-
return null
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function checkInstalled(version, location, osType) {
|
|
208
|
-
const info = readInstalledInfo(location)
|
|
209
|
-
if (!info) return { installed: false, info: {} }
|
|
210
|
-
const chromeDir = path.join(location, `chrome-${osType}`)
|
|
211
|
-
const driverDir = path.join(location, `chromedriver-${osType}`)
|
|
212
|
-
if (info.version === version && info.os === osType &&
|
|
213
|
-
fs.existsSync(chromeDir) && fs.existsSync(driverDir)) {
|
|
214
|
-
return { installed: true, info }
|
|
215
|
-
}
|
|
216
|
-
return { installed: false, info: {} }
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function saveInstallInfo(location, info) {
|
|
220
|
-
const jsonPath = path.join(location, 'installed.json')
|
|
221
|
-
fs.writeFileSync(jsonPath, JSON.stringify(info, null, 2), 'utf-8')
|
|
222
|
-
log(`安装信息已保存到 ${jsonPath}`)
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function checkSystemDeps(chromeExe, driverExe) {
|
|
226
|
-
if (!depsSupported()) return
|
|
227
|
-
const packageManager = detectPackageManager()
|
|
228
|
-
for (const [label, exePath] of [['Chrome', chromeExe], ['ChromeDriver', driverExe]]) {
|
|
229
|
-
if (!exePath || !fs.existsSync(exePath)) continue
|
|
230
|
-
const missing = checkDeps(exePath)
|
|
231
|
-
if (!missing.length) continue
|
|
232
|
-
if (!packageManager) {
|
|
233
|
-
log(`${label}: 缺少系统依赖 ${missing.join(', ')},未检测到 apt-get/dnf,请手动安装`)
|
|
234
|
-
continue
|
|
235
|
-
}
|
|
236
|
-
const { packages, unknown } = mapToPackages(missing, packageManager)
|
|
237
|
-
if (packages.length) {
|
|
238
|
-
log(`${label}: 自动安装系统依赖 ${packages.join(', ')}`)
|
|
239
|
-
installDeps(packages, packageManager)
|
|
240
|
-
}
|
|
241
|
-
if (unknown.length) {
|
|
242
|
-
log(`${label}: 无法自动映射的库 ${unknown.join(', ')},请手动安装`)
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* 安装 CFT(Chrome + ChromeDriver)
|
|
249
|
-
* @param {object} [opts]
|
|
250
|
-
* @param {string} [opts.proxy] - 显式代理地址(socks5://host:port 等);传入则直接使用,不读配置
|
|
251
|
-
* @returns {Promise<object>}
|
|
252
|
-
*/
|
|
253
|
-
export async function install(opts = {}) {
|
|
254
|
-
copyBuiltinConfig()
|
|
255
|
-
const version = getVersion()
|
|
256
|
-
const registry = getRegistry()
|
|
257
|
-
const location = getLocation()
|
|
258
|
-
const osType = getOs()
|
|
259
|
-
const proxy = opts.proxy || getProxy()
|
|
260
|
-
|
|
261
|
-
if (!VALID_OS_TYPES.includes(osType)) {
|
|
262
|
-
throw new Error(`Unsupported OS: ${osType}`)
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const { installed, info } = checkInstalled(version, location, osType)
|
|
266
|
-
if (installed) {
|
|
267
|
-
log(`CFT ${version} 已安装,跳过`)
|
|
268
|
-
return info
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
prepareDir(location)
|
|
272
|
-
|
|
273
|
-
const chromeUrl = `${registry}/${version}/${osType}/chrome-${osType}.zip`
|
|
274
|
-
const driverUrl = `${registry}/${version}/${osType}/chromedriver-${osType}.zip`
|
|
275
|
-
const chromeDir = path.join(location, `chrome-${osType}`)
|
|
276
|
-
const driverDir = path.join(location, `chromedriver-${osType}`)
|
|
277
|
-
|
|
278
|
-
log(`从网络下载 CFT ${version} (${osType})`)
|
|
279
|
-
if (proxy) log(`使用代理: ${proxy}`)
|
|
280
|
-
|
|
281
|
-
for (const url of [chromeUrl, driverUrl]) {
|
|
282
|
-
const ok = await checkUrlReachable(url, proxy)
|
|
283
|
-
if (ok) {
|
|
284
|
-
log(`[OK] 资源可达: ${url}`)
|
|
285
|
-
} else {
|
|
286
|
-
log(`[FAIL] 资源不可达: ${url}`)
|
|
287
|
-
throw downloadError()
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
try {
|
|
292
|
-
await downloadExtract(chromeUrl, location, `chrome-${osType}.zip`, chromeDir, proxy)
|
|
293
|
-
await downloadExtract(driverUrl, location, `chromedriver-${osType}.zip`, driverDir, proxy)
|
|
294
|
-
} catch (cause) {
|
|
295
|
-
throw downloadError(cause)
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const chromeExe = findExe(chromeDir, 'chrome')
|
|
299
|
-
const driverExe = findExe(driverDir, 'chromedriver')
|
|
300
|
-
|
|
301
|
-
chmodExecDir(chromeDir)
|
|
302
|
-
chmodExecDir(driverDir)
|
|
303
|
-
chmodExec(chromeExe)
|
|
304
|
-
chmodExec(driverExe)
|
|
305
|
-
|
|
306
|
-
checkSystemDeps(chromeExe, driverExe)
|
|
307
|
-
|
|
308
|
-
const installInfo = {
|
|
309
|
-
location,
|
|
310
|
-
version,
|
|
311
|
-
os: osType,
|
|
312
|
-
chrome_path: chromeExe,
|
|
313
|
-
driver_path: driverExe,
|
|
314
|
-
}
|
|
315
|
-
saveInstallInfo(location, installInfo)
|
|
316
|
-
log(`Chrome 路径: ${chromeExe}`)
|
|
317
|
-
log(`ChromeDriver 路径: ${driverExe}`)
|
|
318
|
-
return installInfo
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export function status() {
|
|
322
|
-
const location = getLocation()
|
|
323
|
-
const info = readInstalledInfo(location)
|
|
324
|
-
if (!info) {
|
|
325
|
-
console.log('CFT 未安装,请运行: cft install')
|
|
326
|
-
return null
|
|
327
|
-
}
|
|
328
|
-
const chromeOk = fs.existsSync(info.chrome_path || '')
|
|
329
|
-
const driverOk = fs.existsSync(info.driver_path || '')
|
|
330
|
-
console.log(`版本: ${info.version || 'unknown'} (os: ${info.os || 'unknown'})`)
|
|
331
|
-
console.log(`Chrome: ${info.chrome_path || ''} ${chromeOk ? 'OK' : '文件不存在'}`)
|
|
332
|
-
console.log(`ChromeDriver: ${info.driver_path || ''} ${driverOk ? 'OK' : '文件不存在'}`)
|
|
333
|
-
return info
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
export function clear() {
|
|
337
|
-
const location = getLocation()
|
|
338
|
-
if (!fs.existsSync(location)) {
|
|
339
|
-
console.log(`安装目录不存在: ${location}`)
|
|
340
|
-
return false
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const info = readInstalledInfo(location)
|
|
344
|
-
const label = info ? `CFT ${info.version || ''} (${info.os || ''})` : 'CFT'
|
|
345
|
-
|
|
346
|
-
// only remove installation artifacts, keep config.json5
|
|
347
|
-
const artifacts = []
|
|
348
|
-
if (info?.os) {
|
|
349
|
-
artifacts.push(`chrome-${info.os}`, `chromedriver-${info.os}`)
|
|
350
|
-
}
|
|
351
|
-
artifacts.push('installed.json')
|
|
352
|
-
|
|
353
|
-
let removed = false
|
|
354
|
-
for (const name of artifacts) {
|
|
355
|
-
const p = path.join(location, name)
|
|
356
|
-
if (fs.existsSync(p)) {
|
|
357
|
-
fs.rmSync(p, { recursive: true, force: true })
|
|
358
|
-
removed = true
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
if (removed) {
|
|
363
|
-
console.log(` ✓ 已删除 ${label}`)
|
|
364
|
-
console.log(` ${location}`)
|
|
365
|
-
} else {
|
|
366
|
-
console.log(` 无安装产物可删除`)
|
|
367
|
-
}
|
|
368
|
-
return removed
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import https from 'node:https'
|
|
4
|
+
import http from 'node:http'
|
|
5
|
+
|
|
6
|
+
import extractZip from 'extract-zip'
|
|
7
|
+
|
|
8
|
+
import { getOs, VALID_OS_TYPES } from './platform.js'
|
|
9
|
+
import { find as findExe } from './exefinder.js'
|
|
10
|
+
import { check as checkDeps, mapToPackages, install as installDeps, isSupported as depsSupported, detectPackageManager } from './depchecker.js'
|
|
11
|
+
import { getVersion, getRegistry, getProxy, getLocation, copyBuiltinConfig } from './config.js'
|
|
12
|
+
import { parseProxy, openTunnel } from '@wwkit/shared'
|
|
13
|
+
|
|
14
|
+
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 cft-installer'
|
|
15
|
+
const MANUAL_DOWNLOAD_HINT = '资源下载失败,请在 http://deepbluenet.com/webwork/#/mirror 手动下载资源,并将资源解压到 ~/.config/cft 目录,然后将地址添加到 ~/.config/webwork/webwork.json5 中的 env.SELENIUM_CFT_DIR 属性上'
|
|
16
|
+
|
|
17
|
+
function log(msg) {
|
|
18
|
+
console.log(` ${msg}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sendRequest(url, proxyValue, cb, method = 'GET') {
|
|
22
|
+
const proxy = parseProxy(proxyValue)
|
|
23
|
+
const target = new URL(url)
|
|
24
|
+
const headers = { 'user-agent': USER_AGENT }
|
|
25
|
+
const onError = (e) => cb(e, null)
|
|
26
|
+
|
|
27
|
+
if (!proxy) {
|
|
28
|
+
const proto = target.protocol === 'https:' ? https : http
|
|
29
|
+
const req = proto.request(url, { headers, method }, (res) => cb(null, res))
|
|
30
|
+
req.on('error', onError)
|
|
31
|
+
req.end()
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (proxy.protocol === 'http:' && target.protocol === 'http:') {
|
|
36
|
+
const req = http.request({
|
|
37
|
+
host: proxy.hostname,
|
|
38
|
+
port: Number(proxy.port) || 80,
|
|
39
|
+
path: url,
|
|
40
|
+
method,
|
|
41
|
+
headers: { ...headers, host: target.host },
|
|
42
|
+
}, (res) => cb(null, res))
|
|
43
|
+
req.on('error', onError)
|
|
44
|
+
req.end()
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
openTunnel(proxy, target).then((socket) => {
|
|
49
|
+
const proto = target.protocol === 'https:' ? https : http
|
|
50
|
+
const req = proto.request({
|
|
51
|
+
method,
|
|
52
|
+
hostname: target.hostname,
|
|
53
|
+
port: Number(target.port) || (target.protocol === 'https:' ? 443 : 80),
|
|
54
|
+
path: target.pathname + target.search,
|
|
55
|
+
headers: { ...headers, host: target.host },
|
|
56
|
+
agent: false,
|
|
57
|
+
createConnection: () => socket,
|
|
58
|
+
}, (res) => cb(null, res))
|
|
59
|
+
req.on('error', onError)
|
|
60
|
+
socket.on('error', onError)
|
|
61
|
+
req.end()
|
|
62
|
+
}).catch(onError)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 访问前预检完整路径是否可达(HEAD 请求,非 2xx/3xx 视为不可达)
|
|
67
|
+
* @param {string} url - 完整下载 URL
|
|
68
|
+
* @param {string} [proxyValue] - 代理地址
|
|
69
|
+
* @returns {Promise<boolean>}
|
|
70
|
+
*/
|
|
71
|
+
function checkUrlReachable(url, proxyValue) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
sendRequest(url, proxyValue, (err, response) => {
|
|
74
|
+
if (err) { resolve(false); return }
|
|
75
|
+
response.resume()
|
|
76
|
+
if (response.statusCode >= 200 && response.statusCode < 400) {
|
|
77
|
+
resolve(true)
|
|
78
|
+
} else if (response.statusCode === 405) {
|
|
79
|
+
// 405 Method Not Allowed — 服务器已响应,仅不支持 HEAD
|
|
80
|
+
resolve(true)
|
|
81
|
+
} else {
|
|
82
|
+
resolve(false)
|
|
83
|
+
}
|
|
84
|
+
}, 'HEAD')
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 下载失败时的统一终止提示
|
|
90
|
+
* @param {Error} [cause] - 原始错误
|
|
91
|
+
*/
|
|
92
|
+
function downloadError(cause) {
|
|
93
|
+
const err = new Error(MANUAL_DOWNLOAD_HINT)
|
|
94
|
+
if (cause) err.cause = cause
|
|
95
|
+
return err
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function downloadFile(url, destPath, proxyValue, redirects = 0) {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
if (redirects > 5) {
|
|
101
|
+
reject(new Error(`Too many redirects: ${url}`))
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const filename = path.basename(destPath)
|
|
105
|
+
log(`下载 ${filename} ...`)
|
|
106
|
+
|
|
107
|
+
sendRequest(url, proxyValue, (err, response) => {
|
|
108
|
+
if (err) { reject(err); return }
|
|
109
|
+
if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307 || response.statusCode === 308) {
|
|
110
|
+
const location = response.headers.location
|
|
111
|
+
response.resume()
|
|
112
|
+
if (!location) {
|
|
113
|
+
reject(new Error(`Redirect without Location header: ${url}`))
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
downloadFile(location, destPath, proxyValue, redirects + 1).then(resolve).catch(reject)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (response.statusCode !== 200) {
|
|
120
|
+
reject(new Error(`HTTP ${response.statusCode}: ${url}`))
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
const total = parseInt(response.headers['content-length'] || '0', 10)
|
|
124
|
+
let downloaded = 0
|
|
125
|
+
let lastLog = 0
|
|
126
|
+
response.on('data', (chunk) => {
|
|
127
|
+
downloaded += chunk.length
|
|
128
|
+
if (total > 0) {
|
|
129
|
+
const pct = Math.floor(downloaded * 100 / total)
|
|
130
|
+
if (pct >= lastLog + 10 || downloaded === total) {
|
|
131
|
+
log(`[${filename}] ${pct}% (${Math.floor(downloaded / 1048576)}MB / ${Math.floor(total / 1048576)}MB)`)
|
|
132
|
+
lastLog = pct
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
const file = fs.createWriteStream(destPath)
|
|
137
|
+
response.pipe(file)
|
|
138
|
+
file.on('finish', () => {
|
|
139
|
+
file.close()
|
|
140
|
+
resolve()
|
|
141
|
+
})
|
|
142
|
+
file.on('error', reject)
|
|
143
|
+
response.on('error', reject)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function downloadExtract(url, saveDir, filename, extractDir, proxyValue) {
|
|
149
|
+
const filePath = path.join(saveDir, filename)
|
|
150
|
+
await downloadFile(url, filePath, proxyValue)
|
|
151
|
+
|
|
152
|
+
log(`解压到 ${path.basename(extractDir)}`)
|
|
153
|
+
if (fs.existsSync(extractDir)) {
|
|
154
|
+
fs.rmSync(extractDir, { recursive: true, force: true })
|
|
155
|
+
}
|
|
156
|
+
await extractZip(filePath, { dir: saveDir })
|
|
157
|
+
fs.unlinkSync(filePath)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function chmodExec(filePath) {
|
|
161
|
+
if (filePath && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
162
|
+
try { fs.chmodSync(filePath, 0o755) } catch {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function chmodExecDir(directory) {
|
|
167
|
+
if (!directory || !fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) return
|
|
168
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
169
|
+
const fullPath = path.join(directory, entry.name)
|
|
170
|
+
if (entry.isDirectory()) {
|
|
171
|
+
chmodExecDir(fullPath)
|
|
172
|
+
} else {
|
|
173
|
+
try { fs.chmodSync(fullPath, 0o755) } catch {}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function prepareDir(location) {
|
|
179
|
+
if (!fs.existsSync(location)) {
|
|
180
|
+
fs.mkdirSync(location, { recursive: true })
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
const entries = fs.readdirSync(location)
|
|
184
|
+
if (!entries.length) return
|
|
185
|
+
log(`清空安装目录: ${location}`)
|
|
186
|
+
for (const name of entries) {
|
|
187
|
+
if (name === 'config.json5') continue
|
|
188
|
+
const itemPath = path.join(location, name)
|
|
189
|
+
if (fs.statSync(itemPath).isFile()) {
|
|
190
|
+
fs.unlinkSync(itemPath)
|
|
191
|
+
} else {
|
|
192
|
+
fs.rmSync(itemPath, { recursive: true, force: true })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function readInstalledInfo(location) {
|
|
198
|
+
const jsonPath = path.join(location, 'installed.json')
|
|
199
|
+
if (!fs.existsSync(jsonPath)) return null
|
|
200
|
+
try {
|
|
201
|
+
return JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
|
202
|
+
} catch {
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function checkInstalled(version, location, osType) {
|
|
208
|
+
const info = readInstalledInfo(location)
|
|
209
|
+
if (!info) return { installed: false, info: {} }
|
|
210
|
+
const chromeDir = path.join(location, `chrome-${osType}`)
|
|
211
|
+
const driverDir = path.join(location, `chromedriver-${osType}`)
|
|
212
|
+
if (info.version === version && info.os === osType &&
|
|
213
|
+
fs.existsSync(chromeDir) && fs.existsSync(driverDir)) {
|
|
214
|
+
return { installed: true, info }
|
|
215
|
+
}
|
|
216
|
+
return { installed: false, info: {} }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function saveInstallInfo(location, info) {
|
|
220
|
+
const jsonPath = path.join(location, 'installed.json')
|
|
221
|
+
fs.writeFileSync(jsonPath, JSON.stringify(info, null, 2), 'utf-8')
|
|
222
|
+
log(`安装信息已保存到 ${jsonPath}`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function checkSystemDeps(chromeExe, driverExe) {
|
|
226
|
+
if (!depsSupported()) return
|
|
227
|
+
const packageManager = detectPackageManager()
|
|
228
|
+
for (const [label, exePath] of [['Chrome', chromeExe], ['ChromeDriver', driverExe]]) {
|
|
229
|
+
if (!exePath || !fs.existsSync(exePath)) continue
|
|
230
|
+
const missing = checkDeps(exePath)
|
|
231
|
+
if (!missing.length) continue
|
|
232
|
+
if (!packageManager) {
|
|
233
|
+
log(`${label}: 缺少系统依赖 ${missing.join(', ')},未检测到 apt-get/dnf,请手动安装`)
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
const { packages, unknown } = mapToPackages(missing, packageManager)
|
|
237
|
+
if (packages.length) {
|
|
238
|
+
log(`${label}: 自动安装系统依赖 ${packages.join(', ')}`)
|
|
239
|
+
installDeps(packages, packageManager)
|
|
240
|
+
}
|
|
241
|
+
if (unknown.length) {
|
|
242
|
+
log(`${label}: 无法自动映射的库 ${unknown.join(', ')},请手动安装`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 安装 CFT(Chrome + ChromeDriver)
|
|
249
|
+
* @param {object} [opts]
|
|
250
|
+
* @param {string} [opts.proxy] - 显式代理地址(socks5://host:port 等);传入则直接使用,不读配置
|
|
251
|
+
* @returns {Promise<object>}
|
|
252
|
+
*/
|
|
253
|
+
export async function install(opts = {}) {
|
|
254
|
+
copyBuiltinConfig()
|
|
255
|
+
const version = getVersion()
|
|
256
|
+
const registry = getRegistry()
|
|
257
|
+
const location = getLocation()
|
|
258
|
+
const osType = getOs()
|
|
259
|
+
const proxy = opts.proxy || getProxy()
|
|
260
|
+
|
|
261
|
+
if (!VALID_OS_TYPES.includes(osType)) {
|
|
262
|
+
throw new Error(`Unsupported OS: ${osType}`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const { installed, info } = checkInstalled(version, location, osType)
|
|
266
|
+
if (installed) {
|
|
267
|
+
log(`CFT ${version} 已安装,跳过`)
|
|
268
|
+
return info
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
prepareDir(location)
|
|
272
|
+
|
|
273
|
+
const chromeUrl = `${registry}/${version}/${osType}/chrome-${osType}.zip`
|
|
274
|
+
const driverUrl = `${registry}/${version}/${osType}/chromedriver-${osType}.zip`
|
|
275
|
+
const chromeDir = path.join(location, `chrome-${osType}`)
|
|
276
|
+
const driverDir = path.join(location, `chromedriver-${osType}`)
|
|
277
|
+
|
|
278
|
+
log(`从网络下载 CFT ${version} (${osType})`)
|
|
279
|
+
if (proxy) log(`使用代理: ${proxy}`)
|
|
280
|
+
|
|
281
|
+
for (const url of [chromeUrl, driverUrl]) {
|
|
282
|
+
const ok = await checkUrlReachable(url, proxy)
|
|
283
|
+
if (ok) {
|
|
284
|
+
log(`[OK] 资源可达: ${url}`)
|
|
285
|
+
} else {
|
|
286
|
+
log(`[FAIL] 资源不可达: ${url}`)
|
|
287
|
+
throw downloadError()
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
await downloadExtract(chromeUrl, location, `chrome-${osType}.zip`, chromeDir, proxy)
|
|
293
|
+
await downloadExtract(driverUrl, location, `chromedriver-${osType}.zip`, driverDir, proxy)
|
|
294
|
+
} catch (cause) {
|
|
295
|
+
throw downloadError(cause)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const chromeExe = findExe(chromeDir, 'chrome')
|
|
299
|
+
const driverExe = findExe(driverDir, 'chromedriver')
|
|
300
|
+
|
|
301
|
+
chmodExecDir(chromeDir)
|
|
302
|
+
chmodExecDir(driverDir)
|
|
303
|
+
chmodExec(chromeExe)
|
|
304
|
+
chmodExec(driverExe)
|
|
305
|
+
|
|
306
|
+
checkSystemDeps(chromeExe, driverExe)
|
|
307
|
+
|
|
308
|
+
const installInfo = {
|
|
309
|
+
location,
|
|
310
|
+
version,
|
|
311
|
+
os: osType,
|
|
312
|
+
chrome_path: chromeExe,
|
|
313
|
+
driver_path: driverExe,
|
|
314
|
+
}
|
|
315
|
+
saveInstallInfo(location, installInfo)
|
|
316
|
+
log(`Chrome 路径: ${chromeExe}`)
|
|
317
|
+
log(`ChromeDriver 路径: ${driverExe}`)
|
|
318
|
+
return installInfo
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function status() {
|
|
322
|
+
const location = getLocation()
|
|
323
|
+
const info = readInstalledInfo(location)
|
|
324
|
+
if (!info) {
|
|
325
|
+
console.log('CFT 未安装,请运行: cft install')
|
|
326
|
+
return null
|
|
327
|
+
}
|
|
328
|
+
const chromeOk = fs.existsSync(info.chrome_path || '')
|
|
329
|
+
const driverOk = fs.existsSync(info.driver_path || '')
|
|
330
|
+
console.log(`版本: ${info.version || 'unknown'} (os: ${info.os || 'unknown'})`)
|
|
331
|
+
console.log(`Chrome: ${info.chrome_path || ''} ${chromeOk ? 'OK' : '文件不存在'}`)
|
|
332
|
+
console.log(`ChromeDriver: ${info.driver_path || ''} ${driverOk ? 'OK' : '文件不存在'}`)
|
|
333
|
+
return info
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function clear() {
|
|
337
|
+
const location = getLocation()
|
|
338
|
+
if (!fs.existsSync(location)) {
|
|
339
|
+
console.log(`安装目录不存在: ${location}`)
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const info = readInstalledInfo(location)
|
|
344
|
+
const label = info ? `CFT ${info.version || ''} (${info.os || ''})` : 'CFT'
|
|
345
|
+
|
|
346
|
+
// only remove installation artifacts, keep config.json5
|
|
347
|
+
const artifacts = []
|
|
348
|
+
if (info?.os) {
|
|
349
|
+
artifacts.push(`chrome-${info.os}`, `chromedriver-${info.os}`)
|
|
350
|
+
}
|
|
351
|
+
artifacts.push('installed.json')
|
|
352
|
+
|
|
353
|
+
let removed = false
|
|
354
|
+
for (const name of artifacts) {
|
|
355
|
+
const p = path.join(location, name)
|
|
356
|
+
if (fs.existsSync(p)) {
|
|
357
|
+
fs.rmSync(p, { recursive: true, force: true })
|
|
358
|
+
removed = true
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (removed) {
|
|
363
|
+
console.log(` ✓ 已删除 ${label}`)
|
|
364
|
+
console.log(` ${location}`)
|
|
365
|
+
} else {
|
|
366
|
+
console.log(` 无安装产物可删除`)
|
|
367
|
+
}
|
|
368
|
+
return removed
|
|
369
369
|
}
|