前言

又回到最初的起点,呆呆地站在镜子前

前文所述,Mac 上的代理工具实在都是一坨狗屎,天天断网真的难受啊家人们,裸核跑 mihomo 照样有时候断网。好在被我找到了原因:

  • macOS 上的 dns-hijack 只能处理已经进入 TUN 的数据包
  • macOS 系统解析器有时会把 DNS 查询直接绑定到物理网卡,或者发往局域网网关 DNS,使数据包根本不经过 TUN

这是 mihomo 官方文档的原文:

dns 劫持,将匹配到的连接导入内部 dns 模块,不书写协议则为 udp://

  • MacOS/Windows 无法自动劫持发往局域网的 dns 请求
  • Android 如开启 私人dns 则无法自动劫持 dns 请求

这是 Clash Verge 给出的解决方案:

开启 Tun 模式系统 DNS 被修改

Verge 会在启动 Tun 时修改系统 DNS 为 223.6.6.6,以保证 Tun 正常工作。关闭 Tun 即恢复原来的系统 DNS。Tun 下 DNS 由 mihomo 核心代理,因此这个 DNS 并没有其它意义。

解决方案

所以解决方案就是把系统 DNS 修改为公网 DNS,这样 mihomo 内核就能正确将 DNS 劫持进 Tun 了,但是这样子我还是会偶尔断网。然后我发现 sing-box 官方 macOS 客户端通过 Apple 的 NetworkExtension 创建 TUN,而不是单纯用 root 创建一个 utun。这种方式可以由系统 VPN 框架同时配置隧道路由和 DNS resolver。

Service

SFI/SFM/SFT allows you to run sing-box through NetworkExtension with Application Extension or System Extension.

TUN

SFI/SFM/SFT provides an unprivileged TUN implementation through NetworkExtension.

TUN inbound option Available Note
interface_name Managed by Darwin
inet4_address /
inet6_address /
mtu /
gso Not implemented
auto_route /
strict_route Not implemented
inet4_route_address /
inet6_route_address /
inet4_route_exclude_address /
inet6_route_exclude_address /
endpoint_independent_nat /
stack /
include_interface Not implemented
exclude_interface Not implemented
include_uid Not implemented
exclude_uid Not implemented
include_android_user Not implemented
include_package Not implemented
exclude_package Not implemented
platform /
Route/DNS rule option Available Note
process_name Only supported in the macOS standalone and iOS jailbreak versions
process_path Only supported in the macOS standalone and iOS jailbreak versions
process_path_regex Only supported in the macOS standalone and iOS jailbreak versions
package_name /
package_name_regex /
user Only supported in the macOS standalone and iOS jailbreak versions
user_id Only supported in the macOS standalone and iOS jailbreak versions
wifi_ssid Only supported on iOS
wifi_bssid Only supported on iOS

迁移至 sing-box

  1. 安装

    1
    brew install sfm
  2. 将 Clash 规则转换成 sing-box 规则,这个让 AI 写一下就行了,参考一下我的

    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
    name: Build sing-box SRS rules

    on:
    push:
    branches:
    - main
    paths:
    - "Rule/Block.txt"
    - "Rule/Direct.txt"
    - "Rule/GFW.txt"
    - ".github/workflows/build-sing-box-rules.yml"

    schedule:
    # 每 6 小时检查一次远程规则;UTC 时间
    - cron: "17 */6 * * *"

    workflow_dispatch:

    permissions:
    contents: write

    concurrency:
    group: build-sing-box-rules-${{ github.ref }}
    cancel-in-progress: false

    jobs:
    build:
    runs-on: [self-hosted, linux, ARM64]

    env:
    SING_BOX_VERSION: "1.13.14"

    steps:
    - name: Checkout repository
    uses: actions/checkout@v4
    with:
    fetch-depth: 0

    - name: Set up Python
    uses: actions/setup-python@v5
    with:
    python-version: "3.x"

    - name: Install Python dependencies
    run: python -m pip install --disable-pip-version-check requests PyYAML

    - name: Download sing-box
    shell: bash
    run: |
    set -euo pipefail

    case "$(uname -m)" in
    x86_64|amd64)
    sing_box_arch="amd64"
    ;;
    aarch64|arm64)
    sing_box_arch="arm64"
    ;;
    *)
    echo "::error::Unsupported architecture: $(uname -m)"
    exit 1
    ;;
    esac

    echo "Runner architecture: $(uname -m)"
    echo "sing-box architecture: ${sing_box_arch}"

    archive="sing-box-${SING_BOX_VERSION}-linux-${sing_box_arch}.tar.gz"
    directory="sing-box-${SING_BOX_VERSION}-linux-${sing_box_arch}"
    url="https://github.com/SagerNet/sing-box/releases/download/v${SING_BOX_VERSION}/${archive}"

    curl -fsSL \
    --retry 5 \
    --retry-all-errors \
    "$url" \
    -o "$archive"

    tar -xzf "$archive"

    mkdir -p .bin

    install -m 0755 \
    "${directory}/sing-box" \
    .bin/sing-box

    echo "$PWD/.bin" >> "$GITHUB_PATH"

    file .bin/sing-box
    .bin/sing-box version

    - name: Convert local TXT and remote YAML rules
    shell: python
    run: |
    import json
    import re
    import time
    from pathlib import Path

    import requests
    import yaml
    from requests.adapters import HTTPAdapter
    from urllib3.util.retry import Retry

    RULE_SET_VERSION = 4

    LOCAL_RULES = {
    "Liqiye_Block": Path("Rule/Block.txt"),
    "Liqiye_Direct": Path("Rule/Direct.txt"),
    "Liqiye_GFW": Path("Rule/GFW.txt"),
    }

    # (输出文件名, 远程地址)
    REMOTE_RULES = {
    "LAN_SPLITTER": "https://kelee.one/Tool/Clash/Rule/LAN_SPLITTER.yaml",
    "Direct": "https://kelee.one/Tool/Clash/Rule/Direct.yaml",
    "Proxy": "https://kelee.one/Tool/Clash/Rule/Proxy.yaml",
    "ChinaDownloadCDN": "https://kelee.one/Tool/Clash/Rule/ChinaDownloadCDN.yaml",
    "InternationalDownloadCDN": "https://kelee.one/Tool/Clash/Rule/InternationalDownloadCDN.yaml",
    "Bahamut": "https://rule.kelee.one/Clash/Bahamut.yaml",
    "Spotify": "https://rule.kelee.one/Clash/Spotify.yaml",
    "AI": "https://kelee.one/Tool/Clash/Rule/AI.yaml",
    "Steam": "https://rule.kelee.one/Clash/Steam.yaml",
    "Game": "https://kelee.one/Tool/Clash/Rule/Game.yaml",
    "iCloudChina": "https://kelee.one/Tool/Clash/Rule/iCloudChina.yaml",
    "ApplePushNotificationService": "https://kelee.one/Tool/Clash/Rule/ApplePushNotificationService.yaml",
    "AppleSoftwareUpdates": "https://kelee.one/Tool/Clash/Rule/AppleSoftwareUpdates.yaml",
    "AppleAccount": "https://kelee.one/Tool/Clash/Rule/AppleAccount.yaml",
    "AppStore": "https://kelee.one/Tool/Clash/Rule/AppStore.yaml",
    "TestFlight": "https://rule.kelee.one/Clash/TestFlight.yaml",
    "ESET_China": "https://kelee.one/Tool/Clash/Rule/ESET_China.yaml",
    }

    FIELD_MAP = {
    "DOMAIN": "domain",
    "DOMAIN-SUFFIX": "domain_suffix",
    "DOMAIN-KEYWORD": "domain_keyword",
    "DOMAIN-REGEX": "domain_regex",
    "IP-CIDR": "ip_cidr",
    "IP-CIDR6": "ip_cidr",
    "SRC-IP-CIDR": "source_ip_cidr",
    "PROCESS-NAME": "process_name",
    "PROCESS-PATH": "process_path",
    "PROCESS-PATH-REGEX": "process_path_regex",
    "PACKAGE-NAME": "package_name",
    }

    build_dir = Path("build/sing-box-rules")
    download_dir = Path("build/remote-yaml")
    build_dir.mkdir(parents=True, exist_ok=True)
    download_dir.mkdir(parents=True, exist_ok=True)

    def make_session():
    retry = Retry(
    total=4,
    connect=4,
    read=4,
    status=4,
    backoff_factor=2,
    status_forcelist=(408, 425, 429, 500, 502, 503, 504),
    allowed_methods=frozenset(("GET",)),
    raise_on_status=False,
    )
    session = requests.Session()
    session.mount("https://", HTTPAdapter(max_retries=retry))
    session.headers.update({
    "User-Agent": "clash.meta/1.19.0",
    "Accept": "application/yaml,text/yaml,text/plain,*/*",
    "Referer": "https://kelee.one/",
    })
    return session

    session = make_session()

    def split_option(text):
    """移除 Clash 规则末尾常见的 no-resolve 参数。"""
    value = text.strip()
    if value.lower().endswith(",no-resolve"):
    value = value[: -len(",no-resolve")].rstrip()
    return value

    def parse_ports(value, source):
    ports = []
    ranges = []

    for item in re.split(r"[/|]", value):
    item = item.strip()
    if not item:
    continue

    if "-" in item or ":" in item:
    item = item.replace("-", ":")
    if not re.fullmatch(r"\d*:\d*", item):
    raise ValueError(f"{source}: invalid port range: {item}")
    ranges.append(item)
    else:
    try:
    port = int(item)
    except ValueError as error:
    raise ValueError(
    f"{source}: invalid port: {item}"
    ) from error

    if not 0 <= port <= 65535:
    raise ValueError(f"{source}: port out of range: {port}")
    ports.append(port)

    if not ports and not ranges:
    raise ValueError(f"{source}: empty port rule")

    return ports, ranges

    def parse_rule(raw_rule, source):
    if not isinstance(raw_rule, str):
    raise TypeError(f"{source}: rule is not a string: {raw_rule!r}")

    text = raw_rule.strip()
    if not text or text.startswith(("#", ";", "//")):
    return None

    if "," not in text:
    raise ValueError(f"{source}: invalid classical rule: {text}")

    rule_type, value = text.split(",", 1)
    rule_type = rule_type.strip().upper()

    # sing-box 无法按 HTTP User-Agent 路由。
    # 只明确跳过该类型,其他未知类型继续报错,避免静默生成错误规则。
    if rule_type == "USER-AGENT":
    print(f"Skipped unsupported USER-AGENT rule: {source}")
    return None

    value = split_option(value)

    if not value:
    raise ValueError(f"{source}: empty rule value: {text}")

    if rule_type in FIELD_MAP:
    return {FIELD_MAP[rule_type]: [value]}

    if rule_type == "NETWORK":
    network = value.lower()
    if network not in {"tcp", "udp", "icmp"}:
    raise ValueError(f"{source}: unsupported network: {value}")
    return {"network": [network]}

    if rule_type in {"DST-PORT", "SRC-PORT"}:
    ports, ranges = parse_ports(value, source)
    result = {}

    if rule_type == "DST-PORT":
    if ports:
    result["port"] = ports
    if ranges:
    result["port_range"] = ranges
    else:
    if ports:
    result["source_port"] = ports
    if ranges:
    result["source_port_range"] = ranges

    return result

    raise ValueError(
    f"{source}: unsupported Clash rule type {rule_type!r}. "
    "The workflow stops instead of silently producing an incorrect SRS."
    )

    def build_source(name, raw_rules):
    rules = []
    seen = set()

    for line_number, raw_rule in enumerate(raw_rules, start=1):
    rule = parse_rule(raw_rule, f"{name}:{line_number}")
    if rule is None:
    continue

    key = json.dumps(rule, ensure_ascii=False, sort_keys=True)
    if key in seen:
    continue

    seen.add(key)
    rules.append(rule)

    if not rules:
    raise ValueError(f"{name}: no usable rules found")

    output = build_dir / f"{name}.json"
    output.write_text(
    json.dumps(
    {"version": RULE_SET_VERSION, "rules": rules},
    ensure_ascii=False,
    indent=2,
    ) + "\n",
    encoding="utf-8",
    )
    print(f"Generated {output}: {len(rules)} rules")

    # 转换仓库内的三个 TXT 文件
    for output_name, input_path in LOCAL_RULES.items():
    lines = input_path.read_text(encoding="utf-8-sig").splitlines()
    build_source(output_name, lines)

    # 下载并转换远程 Clash classical YAML
    for output_name, url in REMOTE_RULES.items():
    print(f"Downloading {url}")
    response = session.get(url, timeout=(20, 180))
    response.raise_for_status()

    downloaded_file = download_dir / f"{output_name}.yaml"
    downloaded_file.write_bytes(response.content)

    try:
    document = yaml.safe_load(response.content.decode("utf-8-sig"))
    except (UnicodeDecodeError, yaml.YAMLError) as error:
    raise ValueError(f"{url}: invalid YAML") from error

    if not isinstance(document, dict):
    raise ValueError(f"{url}: YAML root must be an object")

    payload = document.get("payload")
    if not isinstance(payload, list):
    raise ValueError(f"{url}: missing YAML payload list")

    build_source(output_name, payload)
    time.sleep(1)

    output_names = [*LOCAL_RULES.keys(), *REMOTE_RULES.keys()]
    Path("build/output-names.txt").write_text(
    "\n".join(output_names) + "\n",
    encoding="utf-8",
    )

    - name: Compile SRS files
    shell: bash
    run: |
    set -euo pipefail

    mkdir -p sing-box/Rule

    while IFS= read -r name; do
    [ -n "$name" ] || continue

    sing-box rule-set compile \
    --output "sing-box/Rule/${name}.srs" \
    "build/sing-box-rules/${name}.json"
    done < build/output-names.txt

    ls -lh sing-box/Rule/*.srs

    - name: Commit generated SRS files
    shell: bash
    run: |
    set -euo pipefail

    git config user.name "github-actions[bot]"
    git config user.email \
    "41898282+github-actions[bot]@users.noreply.github.com"

    while IFS= read -r name; do
    [ -n "$name" ] || continue
    git add "sing-box/Rule/${name}.srs"
    done < build/output-names.txt

    if git diff --cached --quiet; then
    echo "SRS files are already up to date."
    exit 0
    fi

    git commit -m "chore: update sing-box rule sets"

    # 定时任务执行期间仓库可能出现新提交,先同步再推送。
    git pull --rebase origin main
    git push origin HEAD:main
  3. 由于 sing-box 不支持订阅,所以我们先写个不带出站的模版配置,我的配置是纯 IPv4 + Tun + RealIP。不用 IPv6 除了分流覆盖不全外,也是排除一切可能让我断网的可能

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # 禁用 Wi-Fi IPv6
    sudo networksetup -setv6off Wi-Fi

    # 禁用 以太网 IPv6
    sudo networksetup -setv6off Ethernet

    # 启用 Wi-Fi IPv6
    sudo networksetup -setv6automatic Wi-Fi

    # 启用 以太网 IPv6
    sudo networksetup -setv6automatic Ethernet
  4. 订阅的话我们就用 Sub-Store

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    services:
    sub-store:
    image: xream/sub-store:http-meta
    container_name: sub-store
    restart: always
    network_mode: host
    environment:
    SUB_STORE_BACKEND_API_HOST: 0.0.0.0
    SUB_STORE_BACKEND_API_PORT: 3001
    SUB_STORE_BACKEND_MERGE: true
    SUB_STORE_FRONTEND_BACKEND_PATH: /2cXaAxRGfddmGz2yx1wA
    # HTTP-META 的, 一般不用改
    PORT: 9876
    HOST: 0.0.0.0
    volumes:
    - ./data:/opt/app/data
  5. 「文件」中将模板配置复制进去,订阅用大括号表示即可:{订阅当中的名称},比如:

    1
    2
    3
    "outbounds": [
    {"type": "direct", "tag": "direct"},
    {"type": "selector", "tag": "📡 节点选择", "outbounds": ["{订阅当中的名称}", "👆 手动选择", "🇭🇰 香港节点", "🇯🇵 日本节点", "🇸🇬 狮城节点", "🇺🇸 美国节点"]},
  6. 「操作」「脚本操作」,填入以下转换脚本

    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
    const scope = "SING-BOX-CONFIG";
    const source =
    typeof $content === "string" && $content.trim()
    ? $content
    : typeof $files !== "undefined" && Array.isArray($files)
    ? $files.find((file) => typeof file === "string" && file.trim())
    : undefined;

    if (!source) {
    throw new Error(`[${scope}] 没有读到模板内容`);
    }

    const parser =
    typeof ProxyUtils !== "undefined" && ProxyUtils.JSON5
    ? ProxyUtils.JSON5
    : JSON;

    let config;
    try {
    config = parser.parse(source.replace(/^\uFEFF/, ""));
    } catch (error) {
    throw new Error(`[${scope}] 模板不是有效的 JSON/JSON5: ${error.message}`);
    }

    if (!Array.isArray(config.outbounds)) {
    throw new Error(`[${scope}] 模板缺少 outbounds 数组`);
    }

    const placeholderPattern = /^\{([^{}]+)\}$/;
    const subscriptionNames = [];

    for (const outbound of config.outbounds) {
    if (!Array.isArray(outbound.outbounds)) continue;

    for (const item of outbound.outbounds) {
    if (typeof item !== "string") continue;
    const match = item.match(placeholderPattern);
    if (match && !subscriptionNames.includes(match[1])) {
    subscriptionNames.push(match[1]);
    }
    }
    }

    if (!subscriptionNames.length) {
    throw new Error(`[${scope}] 模板中没有找到 {订阅名} 占位符`);
    }

    const produced = await Promise.all(
    subscriptionNames.map(async (name) => {
    let outbounds;
    try {
    outbounds = await produceArtifact({
    type: "subscription",
    name,
    platform: "sing-box",
    produceType: "internal",
    });
    } catch (error) {
    throw new Error(
    `[${scope}] 读取订阅 "${name}" 失败: ${error.message}`,
    );
    }

    if (!Array.isArray(outbounds) || !outbounds.length) {
    throw new Error(`[${scope}] 订阅 "${name}" 没有可用节点`);
    }

    return [name, outbounds.map((outbound) => ({ ...outbound }))];
    }),
    );

    const templateTags = new Set(
    config.outbounds
    .map((outbound) => outbound && outbound.tag)
    .filter((tag) => typeof tag === "string" && tag),
    );
    const usedTags = new Set(templateTags);
    const subscriptionTags = new Map();
    const subscriptionNodes = [];

    function uniqueTag(tag, subscriptionName) {
    if (!usedTags.has(tag)) return tag;

    const qualified = `[${subscriptionName}] ${tag}`;
    if (!usedTags.has(qualified)) return qualified;

    let index = 2;
    while (usedTags.has(`${qualified} ${index}`)) index += 1;
    return `${qualified} ${index}`;
    }

    for (const [subscriptionName, nodes] of produced) {
    const renameMap = new Map();

    for (const node of nodes) {
    if (!node || typeof node !== "object") {
    throw new Error(`[${scope}] 订阅 "${subscriptionName}" 包含无效节点`);
    }
    if (typeof node.tag !== "string" || !node.tag.trim()) {
    throw new Error(
    `[${scope}] 订阅 "${subscriptionName}" 存在没有 tag 的节点`,
    );
    }

    const originalTag = node.tag;
    const tag = uniqueTag(originalTag, subscriptionName);
    renameMap.set(originalTag, tag);
    node.tag = tag;
    usedTags.add(tag);
    }

    for (const node of nodes) {
    if (typeof node.detour === "string" && renameMap.has(node.detour)) {
    node.detour = renameMap.get(node.detour);
    }
    }

    subscriptionTags.set(
    subscriptionName,
    nodes.map((node) => node.tag),
    );
    subscriptionNodes.push(...nodes);
    }

    function applyFilters(tags, filters, selectorTag) {
    if (!Array.isArray(filters) || !filters.length) return tags;

    let result = [...tags];
    for (const filter of filters) {
    const keywords = Array.isArray(filter.keywords) ? filter.keywords : [];
    const patterns = keywords.map((keyword) => {
    try {
    return new RegExp(keyword, "i");
    } catch (error) {
    throw new Error(
    `[${scope}] 策略组 "${selectorTag}" 的过滤正则无效: ${keyword}`,
    );
    }
    });

    if (!patterns.length) continue;
    if (filter.action === "include") {
    result = result.filter((tag) =>
    patterns.some((pattern) => pattern.test(tag)),
    );
    } else if (filter.action === "exclude") {
    result = result.filter(
    (tag) => !patterns.some((pattern) => pattern.test(tag)),
    );
    }
    }

    // Keep the selector valid when a region/filter has no matching nodes.
    return result.length ? result : tags;
    }

    const fixedOutbounds = [];
    const selectors = [];

    for (const outbound of config.outbounds) {
    if (outbound.type !== "selector") {
    fixedOutbounds.push(outbound);
    continue;
    }

    const expanded = [];
    for (const item of outbound.outbounds || []) {
    const match = typeof item === "string" && item.match(placeholderPattern);
    if (!match) {
    expanded.push(item);
    continue;
    }

    const tags = subscriptionTags.get(match[1]);
    if (!tags) {
    throw new Error(
    `[${scope}] 策略组 "${outbound.tag}" 引用了未知订阅 "${match[1]}"`,
    );
    }
    expanded.push(...applyFilters(tags, outbound.filter, outbound.tag));
    }

    outbound.outbounds = [...new Set(expanded)];
    delete outbound.filter;
    if (!outbound.outbounds.length) {
    throw new Error(`[${scope}] 策略组 "${outbound.tag}" 没有可用出站`);
    }
    selectors.push(outbound);
    }

    config.outbounds = [...fixedOutbounds, ...subscriptionNodes, ...selectors];

    const finalTags = new Set(config.outbounds.map((outbound) => outbound.tag));
    for (const selector of selectors) {
    const missing = selector.outbounds.filter((tag) => !finalTags.has(tag));
    if (missing.length) {
    throw new Error(
    `[${scope}] 策略组 "${selector.tag}" 引用了不存在的出站: ${missing.join(
    ", ",
    )}`,
    );
    }
    }

    console.log(
    `[${scope}] INFO: 已注入 ${subscriptionNodes.length} 个节点,来源: ${subscriptionNames.join(
    ", ",
    )}`,
    );
    $content = JSON.stringify(config, null, 2);

  7. 分享出去即可拿到链接