@xcanwin/manyoyo 8.3.0 → 8.3.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/README.en.md +1 -1
- package/README.md +1 -1
- package/bin/manyoyo.js +70 -164
- package/docker/manyoyo.Dockerfile +16 -4
- package/docker/res/playwright/browser.json +28 -0
- package/docker/res/playwright/playwright-cli.sh +26 -0
- package/docker/res/playwright/stealth.init.js +14 -0
- package/lib/global-config.js +8 -33
- package/lib/init-config.js +11 -2
- package/lib/json5-text-edit.js +6 -3
- package/lib/plugin/fingerprint.js +176 -0
- package/lib/plugin/index.js +62 -47
- package/lib/plugin/playwright-assets/vnc-entry.sh +21 -0
- package/lib/plugin/playwright-extensions.js +299 -0
- package/lib/plugin/playwright-probe.js +53 -0
- package/lib/plugin/playwright-relay.js +88 -0
- package/lib/plugin/playwright-server.js +23 -0
- package/lib/plugin/playwright.js +731 -1852
- package/lib/web/index.html +1 -1
- package/lib/web/server.js +27 -3
- package/manyoyo.example.json +16 -28
- package/package.json +5 -5
- package/docker/res/playwright/cli-cont-headless.init.js +0 -12
- package/docker/res/playwright/cli-cont-headless.json +0 -37
- package/lib/plugin/playwright-assets/compose-headed.yaml +0 -19
- package/lib/plugin/playwright-assets/compose-headless.yaml +0 -11
- package/lib/plugin/playwright-assets/headed.Dockerfile +0 -15
- package/lib/plugin/playwright-assets/init-headed.sh +0 -17
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 浏览器指纹的唯一数据源:宿主机生成的配置与镜像内的 docker/res/playwright/* 都从这里生成。
|
|
4
|
+
// 原则是各项信号自洽:UA、Client Hints、平台、字体、WebGL 一律用浏览器真实值,不做自相矛盾的伪装;
|
|
5
|
+
// 只固定语言、时区、窗口形态与自动化痕迹。
|
|
6
|
+
|
|
7
|
+
const CONTAINER_CONFIG_DIR = '/run/manyoyo-playwright';
|
|
8
|
+
const CONTAINER_CONFIG_PATH = `${CONTAINER_CONFIG_DIR}/config.json`;
|
|
9
|
+
const CONTAINER_INIT_SCRIPT_PATH = `${CONTAINER_CONFIG_DIR}/stealth.init.js`;
|
|
10
|
+
const CONTAINER_OUTPUT_DIR = '/tmp/.playwright-cli';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PROFILE = {
|
|
13
|
+
locale: 'en-US',
|
|
14
|
+
timezoneId: 'UTC',
|
|
15
|
+
navigatorPlatform: '',
|
|
16
|
+
disableWebRTC: false
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function resolveProfile(options = {}) {
|
|
20
|
+
const text = (value, fallback) => String(value || '').trim() || fallback;
|
|
21
|
+
return {
|
|
22
|
+
locale: text(options.locale, DEFAULT_PROFILE.locale),
|
|
23
|
+
timezoneId: text(options.timezoneId, DEFAULT_PROFILE.timezoneId),
|
|
24
|
+
navigatorPlatform: text(options.navigatorPlatform, DEFAULT_PROFILE.navigatorPlatform),
|
|
25
|
+
disableWebRTC: options.disableWebRTC === true
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function acceptLanguage(locale) {
|
|
30
|
+
const language = String(locale).split('-')[0];
|
|
31
|
+
return language === locale ? `${locale};q=0.9` : `${locale},${language};q=0.9`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 宿主机的时区与系统语言,作为默认值
|
|
35
|
+
function detectHostProfile() {
|
|
36
|
+
const resolved = Intl.DateTimeFormat().resolvedOptions();
|
|
37
|
+
return { locale: resolved.locale, timezoneId: resolved.timeZone };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// window: 'virtual' 为容器内 1920x1080 虚拟屏;'native' 为真实桌面,最大化即可
|
|
41
|
+
function buildLaunchArgs(options = {}, window = 'native') {
|
|
42
|
+
const profile = resolveProfile(options);
|
|
43
|
+
const args = [
|
|
44
|
+
`--lang=${profile.locale}`,
|
|
45
|
+
window === 'virtual' ? '--window-size=1920,1080' : '--start-maximized',
|
|
46
|
+
'--disable-blink-features=AutomationControlled',
|
|
47
|
+
'--force-webrtc-ip-handling-policy=disable_non_proxied_udp'
|
|
48
|
+
];
|
|
49
|
+
if (profile.disableWebRTC) {
|
|
50
|
+
args.push('--disable-webrtc');
|
|
51
|
+
}
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 有头浏览器不设置 viewport / screen,让窗口尺寸与屏幕尺寸像真实窗口一样不同
|
|
56
|
+
function buildContextOptions(options = {}) {
|
|
57
|
+
const profile = resolveProfile(options);
|
|
58
|
+
return {
|
|
59
|
+
locale: profile.locale,
|
|
60
|
+
timezoneId: profile.timezoneId,
|
|
61
|
+
viewport: null,
|
|
62
|
+
extraHTTPHeaders: {
|
|
63
|
+
'Accept-Language': acceptLanguage(profile.locale)
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildInitScript(options = {}) {
|
|
69
|
+
const profile = resolveProfile(options);
|
|
70
|
+
// 首个页面加载时窗口尚未显示,outerWidth/outerHeight 会读到 0(无头浏览器的典型特征);
|
|
71
|
+
// 只在读到 0 时用 inner 尺寸加上工具栏高度补上,真实窗口的值原样返回
|
|
72
|
+
const lines = [
|
|
73
|
+
"'use strict';",
|
|
74
|
+
'(function () {',
|
|
75
|
+
' try {',
|
|
76
|
+
' const toolbar = 87;',
|
|
77
|
+
' const patch = (name, fallback, getter) => {',
|
|
78
|
+
' const original = Object.getOwnPropertyDescriptor(window, name);',
|
|
79
|
+
' if (!original || !original.get) return;',
|
|
80
|
+
' const real = original.get;',
|
|
81
|
+
' Object.defineProperty(window, name, { ...original, get: getter(function () { const value = real.call(this); return value === 0 ? fallback() : value; }) });',
|
|
82
|
+
' };',
|
|
83
|
+
" patch('outerWidth', () => window.innerWidth, fn => Object.getOwnPropertyDescriptor({ get outerWidth() { return fn.call(this); } }, 'outerWidth').get);",
|
|
84
|
+
" patch('outerHeight', () => window.innerHeight + toolbar, fn => Object.getOwnPropertyDescriptor({ get outerHeight() { return fn.call(this); } }, 'outerHeight').get);",
|
|
85
|
+
' } catch (_) {}'
|
|
86
|
+
];
|
|
87
|
+
if (profile.navigatorPlatform) {
|
|
88
|
+
lines.push(
|
|
89
|
+
' try {',
|
|
90
|
+
' const navProto = Object.getPrototypeOf(navigator);',
|
|
91
|
+
" Object.defineProperty(navProto, 'platform', {",
|
|
92
|
+
' configurable: true,',
|
|
93
|
+
` get: () => ${JSON.stringify(profile.navigatorPlatform)}`,
|
|
94
|
+
' });',
|
|
95
|
+
' } catch (_) {}'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (profile.disableWebRTC) {
|
|
99
|
+
lines.push(
|
|
100
|
+
' try {',
|
|
101
|
+
" const blocked = ['RTCPeerConnection', 'webkitRTCPeerConnection', 'RTCIceCandidate', 'RTCRtpSender', 'RTCRtpReceiver', 'RTCRtpTransceiver', 'RTCDataChannel'];",
|
|
102
|
+
' for (const name of blocked) {',
|
|
103
|
+
' Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: undefined });',
|
|
104
|
+
' }',
|
|
105
|
+
' if (navigator.mediaDevices) {',
|
|
106
|
+
" Object.defineProperty(navigator.mediaDevices, 'getUserMedia', {",
|
|
107
|
+
' configurable: true,',
|
|
108
|
+
' writable: true,',
|
|
109
|
+
" value: async () => { throw new DOMException('WebRTC is disabled', 'NotAllowedError'); }",
|
|
110
|
+
' });',
|
|
111
|
+
' }',
|
|
112
|
+
' } catch (_) {}'
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
lines.push('})();', '');
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 容器内 playwright-cli 读取的配置。
|
|
120
|
+
// default: 容器内 Xvfb 里的有头浏览器;ws: 附着宿主机 launch-server;cdp: 附着用户自己的 Chrome(不注入指纹)
|
|
121
|
+
function buildContainerConfig(kind, options = {}) {
|
|
122
|
+
const base = { outputDir: CONTAINER_OUTPUT_DIR };
|
|
123
|
+
if (kind === 'cdp') {
|
|
124
|
+
return { ...base, browser: { cdpEndpoint: options.endpoint, cdpTimeout: 60000 } };
|
|
125
|
+
}
|
|
126
|
+
const fingerprint = {
|
|
127
|
+
initScript: [CONTAINER_INIT_SCRIPT_PATH],
|
|
128
|
+
contextOptions: buildContextOptions(options)
|
|
129
|
+
};
|
|
130
|
+
if (kind === 'ws') {
|
|
131
|
+
// launch-server 不预创建 context,必须 isolated 才会自建
|
|
132
|
+
return { ...base, browser: { remoteEndpoint: options.endpoint, isolated: true, ...fingerprint } };
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
...base,
|
|
136
|
+
browser: {
|
|
137
|
+
// 容器内是 root,Chromium 不允许 root 开沙箱
|
|
138
|
+
chromiumSandbox: false,
|
|
139
|
+
browserName: 'chromium',
|
|
140
|
+
...fingerprint,
|
|
141
|
+
launchOptions: {
|
|
142
|
+
channel: 'chromium',
|
|
143
|
+
headless: false,
|
|
144
|
+
args: buildLaunchArgs(options, 'virtual')
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 宿主机 playwright launch-server 的配置
|
|
151
|
+
function buildServerConfig({ host, port, wsPath, headless = false, window = 'native', chromiumSandbox = true, extensionArgs = [], ...profileOptions }) {
|
|
152
|
+
return {
|
|
153
|
+
host,
|
|
154
|
+
port,
|
|
155
|
+
wsPath,
|
|
156
|
+
headless,
|
|
157
|
+
channel: 'chromium',
|
|
158
|
+
chromiumSandbox,
|
|
159
|
+
args: [...buildLaunchArgs(profileOptions, window), ...extensionArgs]
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
CONTAINER_CONFIG_DIR,
|
|
165
|
+
CONTAINER_CONFIG_PATH,
|
|
166
|
+
CONTAINER_INIT_SCRIPT_PATH,
|
|
167
|
+
DEFAULT_PROFILE,
|
|
168
|
+
resolveProfile,
|
|
169
|
+
detectHostProfile,
|
|
170
|
+
acceptLanguage,
|
|
171
|
+
buildLaunchArgs,
|
|
172
|
+
buildContextOptions,
|
|
173
|
+
buildInitScript,
|
|
174
|
+
buildContainerConfig,
|
|
175
|
+
buildServerConfig
|
|
176
|
+
};
|
package/lib/plugin/index.js
CHANGED
|
@@ -2,67 +2,82 @@
|
|
|
2
2
|
|
|
3
3
|
const { PlaywrightPlugin } = require('./playwright');
|
|
4
4
|
|
|
5
|
-
const AVAILABLE_PLUGINS = ['playwright'];
|
|
6
|
-
|
|
7
5
|
function asObject(value) {
|
|
8
|
-
|
|
9
|
-
return {};
|
|
10
|
-
}
|
|
11
|
-
return value;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function getPluginConfigs(pluginName, globalConfig, runConfig) {
|
|
15
|
-
const globalPlugins = asObject(asObject(globalConfig).plugins);
|
|
16
|
-
const runPlugins = asObject(asObject(runConfig).plugins);
|
|
17
|
-
return {
|
|
18
|
-
globalPluginConfig: asObject(globalPlugins[pluginName]),
|
|
19
|
-
runPluginConfig: asObject(runPlugins[pluginName])
|
|
20
|
-
};
|
|
6
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
21
7
|
}
|
|
22
8
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const cfg = getPluginConfigs(pluginName, options.globalConfig, options.runConfig);
|
|
9
|
+
// 全局配置与 runs.<name> 里的 plugins.playwright 分别传入,后者覆盖前者
|
|
10
|
+
function createPlaywrightPlugin(options = {}) {
|
|
11
|
+
const pluginConfig = config => asObject(asObject(asObject(config).plugins).playwright);
|
|
29
12
|
return new PlaywrightPlugin({
|
|
30
|
-
projectRoot: options.projectRoot,
|
|
31
13
|
stdout: options.stdout,
|
|
32
14
|
stderr: options.stderr,
|
|
33
|
-
|
|
34
|
-
|
|
15
|
+
runtime: options.runtime,
|
|
16
|
+
homeDir: options.homeDir,
|
|
17
|
+
globalConfig: pluginConfig(options.globalConfig),
|
|
18
|
+
runConfig: pluginConfig(options.runConfig),
|
|
35
19
|
rootGlobalConfig: asObject(options.globalConfig),
|
|
36
20
|
rootRunConfig: asObject(options.runConfig)
|
|
37
21
|
});
|
|
38
22
|
}
|
|
39
23
|
|
|
40
|
-
async function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (action === 'ls') {
|
|
44
|
-
const plugin = createPlugin('playwright', options);
|
|
45
|
-
return await plugin.run({ action: 'ls' });
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const pluginName = String(request && request.pluginName || '').trim();
|
|
49
|
-
if (!pluginName) {
|
|
50
|
-
throw new Error('plugin 名称不能为空');
|
|
51
|
-
}
|
|
24
|
+
async function runPlaywrightCommand(request, options = {}) {
|
|
25
|
+
return await createPlaywrightPlugin(options).run(request);
|
|
26
|
+
}
|
|
52
27
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
28
|
+
// 新建会话容器时要加的参数:CLI 的 run 与 Web 建会话共用这一个入口。
|
|
29
|
+
// 返回扁平的 run 参数数组(--env / --volume / 其余),以及要给用户看的警告;不会抛错。
|
|
30
|
+
async function buildContainerIntegration(options = {}) {
|
|
31
|
+
const plugin = createPlaywrightPlugin(options);
|
|
32
|
+
const integration = await plugin.buildContainerIntegration({
|
|
33
|
+
runtimeCommand: options.runtimeCommand,
|
|
34
|
+
envEntries: options.envEntries || [],
|
|
35
|
+
dryRun: options.dryRun === true
|
|
61
36
|
});
|
|
37
|
+
return {
|
|
38
|
+
envArgs: integration.env.flatMap(entry => ['--env', entry]),
|
|
39
|
+
volumeArgs: integration.volumes.flatMap(volume => ['--volume', volume]),
|
|
40
|
+
extraArgs: integration.extraArgs,
|
|
41
|
+
warning: integration.warning
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 把集成参数并进已有的扁平参数数组:已有的 env 不覆盖(NO_PROXY 由集成在原值上追加,故替换),volume 与其余参数去重
|
|
46
|
+
function mergeIntegration(runtime, integration) {
|
|
47
|
+
const keyOf = entry => String(entry).split('=')[0];
|
|
48
|
+
const proxyKeys = new Set(['NO_PROXY', 'no_proxy']);
|
|
49
|
+
const pairs = args => {
|
|
50
|
+
const out = [];
|
|
51
|
+
for (let i = 0; i + 1 < args.length; i += 2) {
|
|
52
|
+
out.push([args[i], args[i + 1]]);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
};
|
|
56
|
+
const existingEnv = pairs(runtime.containerEnvs || []);
|
|
57
|
+
const kept = existingEnv.filter(([head, value]) => !(head === '--env' && proxyKeys.has(keyOf(value))));
|
|
58
|
+
const present = new Set(kept.filter(([head]) => head === '--env').map(([, value]) => keyOf(value)));
|
|
59
|
+
const added = pairs(integration.envArgs).filter(([, value]) => proxyKeys.has(keyOf(value)) || !present.has(keyOf(value)));
|
|
60
|
+
const append = (base, extra) => {
|
|
61
|
+
const seen = new Set(pairs(base).map(pair => pair.join('\u0000')));
|
|
62
|
+
const out = [...base];
|
|
63
|
+
for (const pair of pairs(extra)) {
|
|
64
|
+
if (!seen.has(pair.join('\u0000'))) {
|
|
65
|
+
seen.add(pair.join('\u0000'));
|
|
66
|
+
out.push(...pair);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
containerEnvs: [...kept, ...added].flat(),
|
|
73
|
+
containerVolumes: append(runtime.containerVolumes || [], integration.volumeArgs),
|
|
74
|
+
containerExtraArgs: append(runtime.containerExtraArgs || [], integration.extraArgs)
|
|
75
|
+
};
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
module.exports = {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
79
|
+
buildContainerIntegration,
|
|
80
|
+
mergeIntegration,
|
|
81
|
+
createPlaywrightPlugin,
|
|
82
|
+
runPlaywrightCommand
|
|
68
83
|
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# 在 Xvfb 里跑有头浏览器服务,并用 x11vnc + noVNC 让用户在网页上观看
|
|
3
|
+
set -eu
|
|
4
|
+
|
|
5
|
+
export DISPLAY=:99
|
|
6
|
+
: "${VNC_PASSWORD:?VNC_PASSWORD is required}"
|
|
7
|
+
|
|
8
|
+
rm -f /tmp/.X99-lock
|
|
9
|
+
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
|
|
10
|
+
i=0
|
|
11
|
+
while [ ! -S /tmp/.X11-unix/X99 ] && [ "$i" -lt 50 ]; do
|
|
12
|
+
sleep 0.1
|
|
13
|
+
i=$((i + 1))
|
|
14
|
+
done
|
|
15
|
+
fluxbox >/tmp/fluxbox.log 2>&1 &
|
|
16
|
+
x11vnc -display :99 -forever -shared -rfbport 5900 -passwd "$VNC_PASSWORD" >/tmp/x11vnc.log 2>&1 &
|
|
17
|
+
websockify --web=/usr/share/novnc/ 6080 localhost:5900 >/tmp/websockify.log 2>&1 &
|
|
18
|
+
|
|
19
|
+
exec node /run/manyoyo-playwright-vnc/playwright-server.js \
|
|
20
|
+
/usr/local/lib/node_modules/@playwright/cli/node_modules/playwright-core \
|
|
21
|
+
/run/manyoyo-playwright-vnc/server.json
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 浏览器扩展:下载 CRX 并转成可加载的目录,以及把目录解析成 Chromium 启动参数。
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const EXTENSIONS = [
|
|
11
|
+
['ublock-origin-lite', 'ddkjiahejlhfcafbddmgiahcphecmpfh'],
|
|
12
|
+
['adguard', 'bgnkhhnnamicmpeenaelnjfhikgbkllg'],
|
|
13
|
+
['privacy-badger', 'pkehgijcmpdhfbdbbnkijodmdjhbjlgp'],
|
|
14
|
+
['webrtc-leak-shield', 'bppamachkoflopbagkdoflbgfjflfnfl'],
|
|
15
|
+
['webgl-fingerprint-defender', 'olnbjpaejebpnokblkepbphhembdicik']
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function isHostPermission(value) {
|
|
19
|
+
if (value === '<all_urls>') {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return /^(?:\*|http|https|file|ftp):\/\//.test(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function scriptSourcesFromHtml(htmlFile) {
|
|
26
|
+
const content = fs.readFileSync(htmlFile, { encoding: 'utf8' });
|
|
27
|
+
const scripts = [...content.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)].map(m => m[1]);
|
|
28
|
+
return scripts.filter(src => !/^(?:https?:)?\/\//.test(src));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function convertManifestV2ToV3(extDir) {
|
|
32
|
+
const manifestFile = path.join(extDir, 'manifest.json');
|
|
33
|
+
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
|
34
|
+
if (manifest.manifest_version !== 2) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
manifest.manifest_version = 3;
|
|
39
|
+
|
|
40
|
+
if (manifest.browser_action && !manifest.action) {
|
|
41
|
+
manifest.action = manifest.browser_action;
|
|
42
|
+
delete manifest.browser_action;
|
|
43
|
+
}
|
|
44
|
+
if (manifest.page_action && !manifest.action) {
|
|
45
|
+
manifest.action = manifest.page_action;
|
|
46
|
+
delete manifest.page_action;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const background = manifest.background;
|
|
50
|
+
if (background && typeof background === 'object' && !Array.isArray(background)) {
|
|
51
|
+
let scripts = [];
|
|
52
|
+
if (Array.isArray(background.scripts)) {
|
|
53
|
+
scripts = background.scripts.filter(s => typeof s === 'string');
|
|
54
|
+
} else if (typeof background.page === 'string') {
|
|
55
|
+
const pagePath = path.join(extDir, background.page);
|
|
56
|
+
if (fs.existsSync(pagePath)) {
|
|
57
|
+
scripts = scriptSourcesFromHtml(pagePath);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (scripts.length > 0) {
|
|
62
|
+
const swName = 'generated_background_sw.js';
|
|
63
|
+
const swFile = path.join(extDir, swName);
|
|
64
|
+
const swLines = [
|
|
65
|
+
'// Auto-generated by manyoyo playwright ext-download for MV3.',
|
|
66
|
+
`importScripts(${scripts.map(s => JSON.stringify(s)).join(', ')});`,
|
|
67
|
+
''
|
|
68
|
+
];
|
|
69
|
+
fs.writeFileSync(swFile, swLines.join('\n'), 'utf8');
|
|
70
|
+
manifest.background = { service_worker: swName };
|
|
71
|
+
} else {
|
|
72
|
+
delete manifest.background;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof manifest.content_security_policy === 'string') {
|
|
77
|
+
manifest.content_security_policy = { extension_pages: manifest.content_security_policy };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (Array.isArray(manifest.permissions)) {
|
|
81
|
+
const hostPermissions = Array.isArray(manifest.host_permissions) ? [...manifest.host_permissions] : [];
|
|
82
|
+
const keptPermissions = [];
|
|
83
|
+
|
|
84
|
+
for (const perm of manifest.permissions) {
|
|
85
|
+
if (typeof perm === 'string' && isHostPermission(perm)) {
|
|
86
|
+
if (!hostPermissions.includes(perm)) {
|
|
87
|
+
hostPermissions.push(perm);
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
keptPermissions.push(perm);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
manifest.permissions = keptPermissions;
|
|
95
|
+
if (hostPermissions.length > 0) {
|
|
96
|
+
manifest.host_permissions = hostPermissions;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const war = manifest.web_accessible_resources;
|
|
101
|
+
if (Array.isArray(war) && war.length > 0 && war.every(v => typeof v === 'string')) {
|
|
102
|
+
manifest.web_accessible_resources = [
|
|
103
|
+
{
|
|
104
|
+
resources: war,
|
|
105
|
+
matches: ['<all_urls>']
|
|
106
|
+
}
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildCrxUrl(extId, prodversion) {
|
|
115
|
+
return (
|
|
116
|
+
'https://clients2.google.com/service/update2/crx' +
|
|
117
|
+
`?response=redirect&prodversion=${prodversion}` +
|
|
118
|
+
'&acceptformat=crx2,crx3' +
|
|
119
|
+
`&x=id%3D${extId}%26installsource%3Dondemand%26uc`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function crxZipOffset(data) {
|
|
124
|
+
if (data.subarray(0, 4).toString('ascii') !== 'Cr24') {
|
|
125
|
+
throw new Error('not a CRX file');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const version = data.readUInt32LE(4);
|
|
129
|
+
if (version === 2) {
|
|
130
|
+
const pubLen = data.readUInt32LE(8);
|
|
131
|
+
const sigLen = data.readUInt32LE(12);
|
|
132
|
+
return 16 + pubLen + sigLen;
|
|
133
|
+
}
|
|
134
|
+
if (version === 3) {
|
|
135
|
+
const headerLen = data.readUInt32LE(8);
|
|
136
|
+
return 12 + headerLen;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`unsupported CRX version: ${version}`);
|
|
139
|
+
}
|
|
140
|
+
const CONTAINER_EXTENSION_ROOT = '/app/extensions';
|
|
141
|
+
|
|
142
|
+
function sleep(ms) {
|
|
143
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extensionDirPath(pluginRoot) {
|
|
147
|
+
return path.join(pluginRoot, 'extensions');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveExtensionPaths(inputs) {
|
|
151
|
+
const uniquePaths = [];
|
|
152
|
+
const add = value => {
|
|
153
|
+
if (!uniquePaths.includes(value)) {
|
|
154
|
+
uniquePaths.push(value);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
for (const item of inputs) {
|
|
159
|
+
const absPath = path.resolve(item);
|
|
160
|
+
if (absPath.includes(',')) {
|
|
161
|
+
throw new Error(`扩展路径不能包含逗号: ${absPath}`);
|
|
162
|
+
}
|
|
163
|
+
if (!fs.existsSync(absPath)) {
|
|
164
|
+
throw new Error(`扩展路径不存在: ${absPath}`);
|
|
165
|
+
}
|
|
166
|
+
if (!fs.statSync(absPath).isDirectory()) {
|
|
167
|
+
throw new Error(`扩展路径必须是目录: ${absPath}`);
|
|
168
|
+
}
|
|
169
|
+
if (fs.existsSync(path.join(absPath, 'manifest.json'))) {
|
|
170
|
+
add(absPath);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const children = fs.readdirSync(absPath, { withFileTypes: true })
|
|
174
|
+
.filter(dirent => dirent.isDirectory())
|
|
175
|
+
.map(dirent => path.join(absPath, dirent.name))
|
|
176
|
+
.filter(child => fs.existsSync(path.join(child, 'manifest.json')));
|
|
177
|
+
if (children.length === 0) {
|
|
178
|
+
throw new Error(`目录下未找到扩展(manifest.json): ${absPath}`);
|
|
179
|
+
}
|
|
180
|
+
children.forEach(add);
|
|
181
|
+
}
|
|
182
|
+
return uniquePaths;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --ext-path 给目录,--ext-name 给 ~/.manyoyo/plugin/playwright/extensions/ 下的目录名
|
|
186
|
+
function resolveExtensionInputs(pluginRoot, { extensionPaths = [], extensionNames = [] } = {}) {
|
|
187
|
+
const extensionRoot = path.resolve(extensionDirPath(pluginRoot));
|
|
188
|
+
const named = extensionNames.map(name => {
|
|
189
|
+
if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
|
|
190
|
+
throw new Error(`扩展名称无效: ${name}`);
|
|
191
|
+
}
|
|
192
|
+
return path.join(extensionRoot, name);
|
|
193
|
+
});
|
|
194
|
+
return resolveExtensionPaths([...extensionPaths, ...named]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function buildExtensionLaunchArgs(extensionPaths) {
|
|
198
|
+
if (extensionPaths.length === 0) {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
const joined = extensionPaths.join(',');
|
|
202
|
+
return [`--disable-extensions-except=${joined}`, `--load-extension=${joined}`];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 容器里看到的扩展目录:${CONTAINER_EXTENSION_ROOT}/ext-<序号>-<目录名>
|
|
206
|
+
function buildContainerExtensionMounts(extensionPaths) {
|
|
207
|
+
const containerPaths = [];
|
|
208
|
+
const volumes = [];
|
|
209
|
+
extensionPaths.forEach((hostPath, idx) => {
|
|
210
|
+
const safeName = path.basename(hostPath).replace(/[^A-Za-z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'ext';
|
|
211
|
+
const containerPath = path.posix.join(CONTAINER_EXTENSION_ROOT, `ext-${idx + 1}-${safeName}`);
|
|
212
|
+
containerPaths.push(containerPath);
|
|
213
|
+
volumes.push(`${hostPath}:${containerPath}:ro`);
|
|
214
|
+
});
|
|
215
|
+
return { containerPaths, volumes };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function curlDownload(url, output, retries = 3, timeoutSec = 60) {
|
|
219
|
+
let lastError = null;
|
|
220
|
+
for (let i = 1; i <= retries; i += 1) {
|
|
221
|
+
const result = spawnSync('curl', [
|
|
222
|
+
'--fail', '--location', '--silent', '--show-error',
|
|
223
|
+
'--connect-timeout', String(timeoutSec), '--max-time', String(timeoutSec),
|
|
224
|
+
'--output', output, url
|
|
225
|
+
], { encoding: 'utf8' });
|
|
226
|
+
if (!result.error && result.status === 0) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
lastError = result.error || new Error(result.stderr || `curl failed with exit code ${result.status}`);
|
|
230
|
+
}
|
|
231
|
+
throw new Error(`download failed after ${retries} attempts: ${url}; ${String(lastError)}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function extractCrx(crxFile, outDir, log) {
|
|
235
|
+
const data = fs.readFileSync(crxFile);
|
|
236
|
+
const tempZip = path.join(os.tmpdir(), `manyoyo-playwright-ext-${process.pid}-${Date.now()}.zip`);
|
|
237
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
238
|
+
fs.writeFileSync(tempZip, data.subarray(crxZipOffset(data)));
|
|
239
|
+
const result = spawnSync('unzip', ['-oq', tempZip, '-d', outDir], { encoding: 'utf8' });
|
|
240
|
+
fs.rmSync(tempZip, { force: true });
|
|
241
|
+
if (result.error) {
|
|
242
|
+
throw result.error;
|
|
243
|
+
}
|
|
244
|
+
if (result.status !== 0) {
|
|
245
|
+
throw new Error(result.stderr || `unzip failed with exit code ${result.status}`);
|
|
246
|
+
}
|
|
247
|
+
if (!fs.existsSync(path.join(outDir, 'manifest.json'))) {
|
|
248
|
+
throw new Error(`${crxFile} extracted but manifest.json missing`);
|
|
249
|
+
}
|
|
250
|
+
if (convertManifestV2ToV3(outDir)) {
|
|
251
|
+
log(`[manifest] upgraded to MV3: ${path.basename(outDir)}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function downloadExtensions({ pluginRoot, prodversion, log, logError }) {
|
|
256
|
+
if (spawnSync('unzip', ['-v'], { stdio: 'ignore' }).status !== 0) {
|
|
257
|
+
logError('[ext-download] failed: unzip command not found.');
|
|
258
|
+
return 1;
|
|
259
|
+
}
|
|
260
|
+
const extDir = path.resolve(extensionDirPath(pluginRoot));
|
|
261
|
+
const tmpDir = path.resolve(pluginRoot, 'tmp-crx');
|
|
262
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
263
|
+
fs.mkdirSync(extDir, { recursive: true });
|
|
264
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
265
|
+
try {
|
|
266
|
+
log(`[info] ext dir: ${extDir}`);
|
|
267
|
+
for (const [name, extId] of EXTENSIONS) {
|
|
268
|
+
const crxFile = path.join(tmpDir, `${name}.crx`);
|
|
269
|
+
const outDir = path.join(extDir, name);
|
|
270
|
+
log(`[download] ${name}`);
|
|
271
|
+
curlDownload(buildCrxUrl(extId, prodversion), crxFile);
|
|
272
|
+
log(`[extract] ${name}`);
|
|
273
|
+
// 先解到临时目录再替换,解压失败不会丢掉原来能用的扩展
|
|
274
|
+
const stagingDir = path.join(tmpDir, `${name}.staged`);
|
|
275
|
+
extractCrx(crxFile, stagingDir, log);
|
|
276
|
+
fs.rmSync(outDir, { recursive: true, force: true });
|
|
277
|
+
fs.renameSync(stagingDir, outDir);
|
|
278
|
+
await sleep(0);
|
|
279
|
+
}
|
|
280
|
+
} finally {
|
|
281
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
282
|
+
}
|
|
283
|
+
log(`[done] all extensions are ready: ${extDir}`);
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
EXTENSIONS,
|
|
289
|
+
CONTAINER_EXTENSION_ROOT,
|
|
290
|
+
buildContainerExtensionMounts,
|
|
291
|
+
buildCrxUrl,
|
|
292
|
+
buildExtensionLaunchArgs,
|
|
293
|
+
convertManifestV2ToV3,
|
|
294
|
+
crxZipOffset,
|
|
295
|
+
downloadExtensions,
|
|
296
|
+
extensionDirPath,
|
|
297
|
+
resolveExtensionInputs,
|
|
298
|
+
resolveExtensionPaths
|
|
299
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 真实探测:连上浏览器、新开页面、打开 data: 页面并读回内容。
|
|
4
|
+
// 作为子进程运行,父进程负责超时;端点(含 token)只经环境变量 MANYOYO_PROBE 传入。
|
|
5
|
+
// MANYOYO_PROBE = { core, kind: 'ws' | 'cdp', endpoint, timeout }
|
|
6
|
+
|
|
7
|
+
async function probe({ core, kind, endpoint, timeout }) {
|
|
8
|
+
const { chromium } = require(core);
|
|
9
|
+
const started = Date.now();
|
|
10
|
+
const browser = kind === 'cdp'
|
|
11
|
+
? await chromium.connectOverCDP(endpoint, { timeout })
|
|
12
|
+
: await chromium.connect(endpoint, { timeout });
|
|
13
|
+
try {
|
|
14
|
+
let context;
|
|
15
|
+
let ownContext = false;
|
|
16
|
+
if (kind === 'cdp') {
|
|
17
|
+
context = browser.contexts()[0] || await browser.newContext();
|
|
18
|
+
} else {
|
|
19
|
+
context = await browser.newContext();
|
|
20
|
+
ownContext = true;
|
|
21
|
+
}
|
|
22
|
+
const page = await context.newPage();
|
|
23
|
+
try {
|
|
24
|
+
await page.goto('data:text/html,<title>manyoyo-ok</title>', { timeout });
|
|
25
|
+
const title = await page.title();
|
|
26
|
+
if (title !== 'manyoyo-ok') {
|
|
27
|
+
throw new Error(`页面内容不符: ${title}`);
|
|
28
|
+
}
|
|
29
|
+
} finally {
|
|
30
|
+
await page.close().catch(() => {});
|
|
31
|
+
if (ownContext) {
|
|
32
|
+
await context.close().catch(() => {});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} finally {
|
|
36
|
+
await browser.close().catch(() => {});
|
|
37
|
+
}
|
|
38
|
+
return Date.now() - started;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (require.main === module) {
|
|
42
|
+
probe(JSON.parse(process.env.MANYOYO_PROBE))
|
|
43
|
+
.then(ms => {
|
|
44
|
+
console.log(JSON.stringify({ ok: true, ms }));
|
|
45
|
+
process.exit(0);
|
|
46
|
+
})
|
|
47
|
+
.catch(error => {
|
|
48
|
+
console.log(JSON.stringify({ ok: false, error: String(error && error.message || error).split('\n')[0] }));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { probe };
|