Repository navigation
Expand file tree
/
Copy pathmain.js
More file actions
533 lines (448 loc) · 15 KB
/
Copy pathmain.js
File metadata and controls
533 lines (448 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
/**
* Clash Verge Rev 扩展脚本:美国 / 日本节点自动故障转移。
*
* 使用方式:将整个文件粘贴到 Clash Verge Rev 的“扩展脚本”中,
* 启用后刷新订阅。脚本只修改最终配置,不会改写订阅源。
*
* 全局模式可以直接选择下方生成的 fallback 组;规则模式则会把该组
* 接入主代理选择组,以及规则直接使用的其他代理选择组。
*/
const SETTINGS = {
// 自动生成的策略组名称。保持名称稳定,便于 Clash Verge 保留用户选择。
autoGroupName: "🇺🇸🇯🇵 美日自动切换",
// 精确填写主选择组名称时优先使用;留空则自动识别常见主选择组。
targetGroupName: "",
targetGroupCandidates: [
"🚀 节点选择",
"节点选择",
"🚀 代理选择",
"代理选择",
"手动选择",
"Proxy",
"PROXY",
],
testUrl: "https://cp.cloudflare.com/generate_204",
expectedStatus: 204,
intervalSeconds: 60,
timeoutMilliseconds: 3000,
maxFailedTimes: 2,
// 留空代表使用配置中的全部 proxy-provider,再通过白名单过滤其节点。
providerNames: [],
};
// 这些表达式由 Mihomo 用于过滤 proxy-provider 中的节点。
// 使用正向白名单:未明确识别为美国或日本的节点不进入自动组。
const PROVIDER_INCLUDE_PATTERN =
"(?i)(🇺🇸|🇯🇵|美国|美國|日本|United\\s*States|Japan|Tokyo|Osaka|Kyoto|Los\\s*Angeles|San\\s*Jose|New\\s*York|Seattle|Chicago|Dallas|(^|[^A-Za-z0-9])(US|USA|JP|JPN)([^A-Za-z0-9]|$))";
// 排除规则优先于包含规则,避免“US-HK”等混合名称误入自动组。
const PROVIDER_EXCLUDE_PATTERN =
"(?i)(🇭🇰|香港|Hong\\s*Kong|(^|[^A-Za-z0-9])(HK|HKG)([^A-Za-z0-9]|$)|剩余|流量|套餐|官网|客服|到期|过期|失效|邀请|倍率)";
const BUILTIN_POLICIES = Object.freeze({
DIRECT: true,
REJECT: true,
"REJECT-DROP": true,
PASS: true,
COMPATIBLE: true,
});
function main(config) {
if (!config || typeof config !== "object") {
throw new Error("扩展脚本没有收到有效的 Clash 配置对象");
}
const originalGroups = Array.isArray(config["proxy-groups"])
? config["proxy-groups"]
: [];
// 移除上次生成的自动组及旧引用,保证重复执行不会产生重复项。
const groups = [];
for (const originalGroup of originalGroups) {
if (!originalGroup || originalGroup.name === SETTINGS.autoGroupName) continue;
const group = Object.assign({}, originalGroup);
if (Array.isArray(originalGroup.proxies)) {
group.proxies = uniqueStrings(
originalGroup.proxies.filter(
(name) => name !== SETTINGS.autoGroupName,
),
);
}
groups.push(group);
}
config["proxy-groups"] = groups;
const rawNodeNames = collectAllowedRawNodes(config.proxies);
const providerNames = collectProviderNames(config["proxy-providers"]);
const autoGroup = buildAutoGroup(rawNodeNames, providerNames);
// 自动组保持为顶层可见组,方便在全局模式中直接选择。
groups.push(autoGroup);
// 少数配置会让规则直接指向某个具体节点,而不是策略组。仅当该节点本来
// 就属于美日白名单时,才把规则安全地改为指向自动组。
if (Array.isArray(config.rules)) {
config.rules = rewriteDirectNodeRuleTargets(config.rules, rawNodeNames);
}
const allRawNodeNames = collectAllRawNodeNames(config.proxies);
const targetGroup = findTargetGroup(config, groups, allRawNodeNames);
if (targetGroup) {
// 主选择组将自动组放在首位:新配置默认使用自动切换;
// Mihomo 已保存的用户选择仍会被保留,不会被脚本强行覆盖。
addProxyToGroup(targetGroup, SETTINGS.autoGroupName, true);
}
// 规则可能直接指向多个独立的 select 组。对于不经过主选择组、且确实
// 能连接代理节点的组,额外加入自动组选项;DIRECT/REJECT-only 组不改动。
attachAutoGroupToRuleTargets(
config.rules,
groups,
targetGroup,
allRawNodeNames,
);
return config;
}
function buildAutoGroup(rawNodeNames, providerNames) {
const group = {
name: SETTINGS.autoGroupName,
type: "fallback",
url: SETTINGS.testUrl,
"expected-status": SETTINGS.expectedStatus,
interval: SETTINGS.intervalSeconds,
timeout: SETTINGS.timeoutMilliseconds,
"max-failed-times": SETTINGS.maxFailedTimes,
lazy: false,
hidden: false,
// 所有美日节点均不可用时阻断流量,不回退到香港或 DIRECT。
"empty-fallback": "REJECT",
};
if (rawNodeNames.length > 0) group.proxies = rawNodeNames;
if (providerNames.length > 0) {
group.use = providerNames;
group.filter = PROVIDER_INCLUDE_PATTERN;
group["exclude-filter"] = PROVIDER_EXCLUDE_PATTERN;
}
// 没有节点来源时仍生成安全、可诊断的组,而不是生成无效配置。
if (rawNodeNames.length === 0 && providerNames.length === 0) {
group.proxies = ["REJECT"];
}
return group;
}
function collectAllowedRawNodes(proxies) {
if (!Array.isArray(proxies)) return [];
const names = [];
for (const proxy of proxies) {
if (!proxy || typeof proxy.name !== "string") continue;
if (isAllowedRegionNode(proxy.name)) names.push(proxy.name);
}
return uniqueStrings(names);
}
function collectAllRawNodeNames(proxies) {
if (!Array.isArray(proxies)) return [];
const names = [];
for (const proxy of proxies) {
if (proxy && typeof proxy.name === "string") names.push(proxy.name);
}
return uniqueStrings(names);
}
function collectProviderNames(proxyProviders) {
if (!proxyProviders || typeof proxyProviders !== "object") return [];
const availableNames = Object.keys(proxyProviders);
if (
!Array.isArray(SETTINGS.providerNames) ||
SETTINGS.providerNames.length === 0
) {
return availableNames;
}
return SETTINGS.providerNames.filter(
(name) => availableNames.indexOf(name) !== -1,
);
}
function isAllowedRegionNode(name) {
if (isDeniedNodeName(name)) return false;
const upperName = name.toUpperCase();
if (
name.indexOf("🇺🇸") !== -1 ||
name.indexOf("🇯🇵") !== -1 ||
name.indexOf("美国") !== -1 ||
name.indexOf("美國") !== -1 ||
name.indexOf("日本") !== -1
) {
return true;
}
if (/(^|[^A-Z0-9])(US|USA|JP|JPN)([^A-Z0-9]|$)/.test(upperName)) {
return true;
}
return /(UNITED\s*STATES|JAPAN|TOKYO|OSAKA|KYOTO|LOS\s*ANGELES|SAN\s*JOSE|NEW\s*YORK|SEATTLE|CHICAGO|DALLAS)/i.test(
name,
);
}
function isDeniedNodeName(name) {
const upperName = name.toUpperCase();
if (
name.indexOf("🇭🇰") !== -1 ||
name.indexOf("香港") !== -1 ||
/HONG\s*KONG/i.test(name) ||
/(^|[^A-Z0-9])(HK|HKG)([^A-Z0-9]|$)/.test(upperName)
) {
return true;
}
return /(剩余|流量|套餐|官网|客服|到期|过期|失效|邀请|倍率)/.test(name);
}
function findTargetGroup(config, groups, rawNodeNames) {
const selectableGroups = groups.filter(
(group) =>
group &&
group.type === "select" &&
group.name !== SETTINGS.autoGroupName,
);
if (selectableGroups.length === 0) return null;
if (SETTINGS.targetGroupName) {
const exactTarget = selectableGroups.find(
(group) => group.name === SETTINGS.targetGroupName,
);
if (exactTarget) return exactTarget;
}
// 先精确匹配,再忽略图标、空格和标点匹配,兼容订阅使用不同 emoji 前缀。
for (const candidate of SETTINGS.targetGroupCandidates) {
const matched = selectableGroups.find(
(group) => group.name === candidate,
);
if (matched) return matched;
}
const normalizedCandidates = SETTINGS.targetGroupCandidates.map(
normalizeGroupName,
);
for (const group of selectableGroups) {
if (
normalizedCandidates.indexOf(normalizeGroupName(group.name)) !== -1
) {
return group;
}
}
// 未使用常见名称时,通过“被其他策略组引用次数 + 被规则引用次数”
// 找到主代理入口。这样不会只依赖最后一条 MATCH/FINAL 规则。
const groupMap = createGroupMap(groups);
const rawNodeSet = createStringSet(rawNodeNames);
const ruleTargets = collectRuleTargetNames(config.rules);
const scoredGroups = selectableGroups
.map((group, index) => {
let score = 0;
for (const parent of selectableGroups) {
if (
parent !== group &&
Array.isArray(parent.proxies) &&
parent.proxies.indexOf(group.name) !== -1
) {
score += 10;
}
}
for (const targetName of ruleTargets) {
if (targetName === group.name) score += 3;
}
if (
isProxyCapableGroup(
group,
groupMap,
rawNodeSet,
Object.create(null),
)
) {
score += 1;
} else {
score -= 1000;
}
return { group, index, score };
})
.sort((left, right) => right.score - left.score || left.index - right.index);
if (scoredGroups.length > 0 && scoredGroups[0].score > -1000) {
return scoredGroups[0].group;
}
// 最后的保守回退:MATCH/FINAL 指向的可选择组。
const finalRuleTarget = findFinalRuleTarget(config.rules);
if (finalRuleTarget) {
const matched = selectableGroups.find(
(group) => group.name === finalRuleTarget,
);
if (matched) return matched;
}
const visibleGroup = selectableGroups.find(
(group) => group.hidden !== true,
);
return visibleGroup || selectableGroups[0] || null;
}
function attachAutoGroupToRuleTargets(
rules,
groups,
targetGroup,
rawNodeNames,
) {
const ruleTargets = collectRuleTargetNames(rules);
if (ruleTargets.length === 0) return;
const groupMap = createGroupMap(groups);
const rawNodeSet = createStringSet(rawNodeNames);
const targetName = targetGroup ? targetGroup.name : "";
for (const targetNameFromRule of ruleTargets) {
const group = groupMap[targetNameFromRule];
if (
!group ||
group.type !== "select" ||
group.name === SETTINGS.autoGroupName ||
group.name === targetName
) {
continue;
}
// 已经会经过主选择组的规则组无需重复显示同一个选项。
if (
targetName &&
groupDependsOn(
group,
targetName,
groupMap,
Object.create(null),
)
) {
continue;
}
if (
!isProxyCapableGroup(
group,
groupMap,
rawNodeSet,
Object.create(null),
)
) {
continue;
}
// 保持独立规则组原来的默认选项,只在末尾提供自动切换入口。
addProxyToGroup(group, SETTINGS.autoGroupName, false);
}
}
function collectRuleTargetNames(rules) {
if (!Array.isArray(rules)) return [];
const names = [];
for (const rule of rules) {
const targetName = getRuleTargetName(rule);
if (targetName && !isBuiltinPolicy(targetName)) names.push(targetName);
}
return uniqueStrings(names);
}
function rewriteDirectNodeRuleTargets(rules, allowedNodeNames) {
if (!Array.isArray(rules) || allowedNodeNames.length === 0) return rules;
const allowedNodeSet = createStringSet(allowedNodeNames);
return rules.map((rule) => {
if (typeof rule !== "string") return rule;
const parts = rule.split(",").map((part) => part.trim());
if (parts.length < 2) return rule;
const targetIndex = getRuleTargetIndex(parts);
if (targetIndex <= 0 || !allowedNodeSet[parts[targetIndex]]) return rule;
parts[targetIndex] = SETTINGS.autoGroupName;
return parts.join(",");
});
}
function getRuleTargetName(rule) {
if (typeof rule !== "string") return "";
const parts = rule.split(",").map((part) => part.trim());
if (parts.length < 2) return "";
const targetIndex = getRuleTargetIndex(parts);
return targetIndex > 0 ? parts[targetIndex] : "";
}
function getRuleTargetIndex(parts) {
let targetIndex = parts.length - 1;
if (
targetIndex >= 0 &&
parts[targetIndex].toLowerCase() === "no-resolve"
) {
targetIndex -= 1;
}
return targetIndex;
}
function findFinalRuleTarget(rules) {
if (!Array.isArray(rules)) return "";
for (let index = rules.length - 1; index >= 0; index -= 1) {
const rule = rules[index];
if (typeof rule !== "string") continue;
const parts = rule.split(",").map((part) => part.trim());
if (
parts.length >= 2 &&
(parts[0].toUpperCase() === "MATCH" ||
parts[0].toUpperCase() === "FINAL")
) {
return parts[1];
}
}
return "";
}
function addProxyToGroup(group, proxyName, prepend) {
const proxies = Array.isArray(group.proxies) ? group.proxies : [];
const withoutProxy = proxies.filter((name) => name !== proxyName);
group.proxies = prepend
? [proxyName].concat(withoutProxy)
: withoutProxy.concat([proxyName]);
}
function groupDependsOn(group, targetName, groupMap, visiting) {
if (!group || !Array.isArray(group.proxies)) return false;
if (visiting[group.name]) return false;
visiting[group.name] = true;
for (const proxyName of group.proxies) {
if (proxyName === targetName) return true;
const childGroup = groupMap[proxyName];
if (
childGroup &&
groupDependsOn(childGroup, targetName, groupMap, visiting)
) {
return true;
}
}
return false;
}
function isProxyCapableGroup(group, groupMap, rawNodeSet, visiting) {
if (!group || visiting[group.name]) return false;
visiting[group.name] = true;
if (
(Array.isArray(group.use) && group.use.length > 0) ||
group["include-all-proxies"] === true ||
group["include-all-providers"] === true
) {
return true;
}
if (!Array.isArray(group.proxies)) return false;
for (const proxyName of group.proxies) {
if (rawNodeSet[proxyName]) return true;
if (isBuiltinPolicy(proxyName)) continue;
const childGroup = groupMap[proxyName];
if (
childGroup &&
isProxyCapableGroup(childGroup, groupMap, rawNodeSet, visiting)
) {
return true;
}
}
return false;
}
function createGroupMap(groups) {
const map = Object.create(null);
for (const group of groups) {
if (group && typeof group.name === "string") map[group.name] = group;
}
return map;
}
function createStringSet(values) {
const set = Object.create(null);
for (const value of values) set[value] = true;
return set;
}
function isBuiltinPolicy(name) {
return typeof name === "string" && BUILTIN_POLICIES[name.toUpperCase()] === true;
}
function normalizeGroupName(name) {
if (typeof name !== "string") return "";
// \p{L}/\p{N} 保留所有语言的字母和数字,去掉 emoji、空格及标点。
try {
const nonLetterOrNumber = new RegExp("[^\\p{L}\\p{N}]+", "gu");
return name.toLowerCase().replace(nonLetterOrNumber, "");
} catch (_) {
// 兼容不支持 Unicode property escapes 的旧版 JS 运行时。
return name.toLowerCase().replace(/[\s\W_]+/g, "");
}
}
function uniqueStrings(values) {
const seen = Object.create(null);
const result = [];
for (const value of values) {
if (typeof value !== "string" || seen[value]) continue;
seen[value] = true;
result.push(value);
}
return result;
}