Просмотр исходного кода

Initial commit: working v2ray subscription to Clash Meta & Singbox config generator with docs, templates, and robust CLI script

Mohammad Reza Mokhtarabadi 6 месяцев назад
Сommit
0ec302e6e2
13 измененных файлов с 907 добавлено и 0 удалено
  1. 7 0
      .gitignore
  2. 8 0
      .idea/.gitignore
  3. 5 0
      .idea/ChatHistory_schema_v2.xml
  4. 8 0
      .idea/compiler.xml
  5. 6 0
      .idea/misc.xml
  6. 8 0
      .idea/modules.xml
  7. 9 0
      .idea/v2ray-to-subs.iml
  8. 4 0
      .idea/vcs.xml
  9. 51 0
      README.md
  10. 298 0
      config.yaml
  11. 3 0
      requirements.txt
  12. 135 0
      singbox.json
  13. 365 0
      sub2clash_singbox.py

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+docs/
+venv/
+result_clash.yaml
+result_singbox.json
+__pycache__/
+*.pyc
+firebender.json

+ 8 - 0
.idea/.gitignore

@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml

Разница между файлами не показана из-за своего большого размера
+ 5 - 0
.idea/ChatHistory_schema_v2.xml


+ 8 - 0
.idea/compiler.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="CompilerConfiguration">
+    <annotationProcessing>
+      <profile default="true" name="Default" enabled="true" />
+    </annotationProcessing>
+  </component>
+</project>

+ 6 - 0
.idea/misc.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
+    <output url="file://$PROJECT_DIR$/out" />
+  </component>
+</project>

+ 8 - 0
.idea/modules.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectModuleManager">
+    <modules>
+      <module fileurl="file://$PROJECT_DIR$/.idea/v2ray-to-subs.iml" filepath="$PROJECT_DIR$/.idea/v2ray-to-subs.iml" />
+    </modules>
+  </component>
+</project>

+ 9 - 0
.idea/v2ray-to-subs.iml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="JAVA_MODULE" version="4">
+  <component name="NewModuleRootManager" inherit-compiler-output="true">
+    <exclude-output />
+    <content url="file://$MODULE_DIR$" />
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>

+ 4 - 0
.idea/vcs.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings" defaultProject="true" />
+</project>

+ 51 - 0
README.md

@@ -0,0 +1,51 @@
+# v2ray Subscription to Clash Meta & Singbox Config Generator
+
+This Python project converts a v2rayNG-style subscription link (vmess/vless/ss/trojan/socks) directly to two config
+formats:
+
+- A Clash Meta YAML config (`config.yaml` style)
+- A Singbox JSON config (`signbox.json` style)
+
+Both outputs retain advanced template features (DNS, rules, anti-DNS-hijack, groups) as provided in your base configs.
+
+## Features
+
+- Parses all links in a v2ray subscription (vmess, vless, trojan, ss, socks)
+- Injects nodes into both configs per your template structure
+- Preserves all routing, geoip, dns, and proxy group tricks (Iran/anti-hijack optimized)
+- Outputs ready-to-use configs for both Clash Meta and Singbox
+
+## Requirements
+
+Setup and usage is simple:
+
+```
+python3 -m venv venv
+source venv/bin/activate
+pip install -r requirements.txt
+```
+
+## Usage
+
+```
+python sub2clash_singbox.py <subs_url> config.yaml singbox.json result_clash.yaml result_singbox.json
+```
+
+Where:
+
+- `subs_url` is the HTTP/HTTPS v2ray subscription (
+  e.g. https://raw.githubusercontent.com/sakha1370/OpenRay/refs/heads/main/output_iran/iran_top100_checked.txt)
+- `config.yaml` is your base Clash Meta template
+- `singbox.json` is your base Singbox template
+- Outputs are written to result_clash.yaml and result_singbox.json respectively
+
+## Notes
+
+- All existing template proxies are replaced; proxy groups, rules, and DNS remain as in your base config.
+- If your template has hardcoded outbounds (e.g. "warp", "us") they will appear in Singbox unless removed from the
+  template.
+- Fully robust to mix of protocols or large sub lists (tested on 100+ node links).
+
+## License
+
+MIT

+ 298 - 0
config.yaml

@@ -0,0 +1,298 @@
+# Clash Meta Final Configuration for Iran 🇮🇷
+# WORKING configuration with metacubexd dashboard + DNS through proxy
+# Optimized for Iran with ISP DNS hijacking prevention
+
+# Global Settings
+mixed-port: 7892
+allow-lan: true
+bind-address: "*"
+mode: rule
+log-level: info
+ipv6: false
+
+# External Controller & Dashboard (metacubexd) 🎛️
+external-controller: 0.0.0.0:9090
+external-ui: ui
+external-ui-url: "https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip"
+secret: ""
+
+# Performance Settings
+unified-delay: true
+tcp-concurrent: true
+
+# Profile Settings
+profile:
+  store-selected: true
+  store-fake-ip: true
+
+# TUN Configuration for Iran
+tun:
+  enable: true
+  stack: system
+  auto-route: true
+  auto-redirect: true
+  auto-detect-interface: true
+  dns-hijack:
+    - any:53
+    - tcp://any:53
+  device: utun0
+  mtu: 9000
+  strict-route: true
+  gso: true
+  gso-max-size: 65536
+
+# DNS Configuration - CRITICAL for Iran 🇮🇷
+# This configuration routes DNS through proxy to prevent ISP hijacking
+dns:
+  enable: true
+  listen: 0.0.0.0:1053
+  ipv6: false
+  
+  # Enable fake-ip for better performance and privacy
+  enhanced-mode: fake-ip
+  fake-ip-range: 198.18.0.1/16
+  fake-ip-filter-mode: blacklist
+  fake-ip-filter:
+    - "*.lan"
+    - "*.local"
+    - "*.direct"
+    - "*.ir"  # All Iranian domains
+    - "*.msftconnecttest.com"
+    - "*.msftncsi.com"
+    # Popular Iranian services
+    - "+.aparat.com"
+    - "+.digikala.com"
+    - "+.eitaa.com"
+    - "+.rubika.ir"
+    - "+.snapp.ir"
+    - "+.zarinpal.com"
+    - "+.parsian.com"
+    - "+.mellat.ir"
+  
+  # 🔑 KEY FEATURE: Route DNS through proxy to bypass ISP filtering
+  respect-rules: true
+  
+  # Security settings
+  use-hosts: true
+  use-system-hosts: false
+  
+  # Default nameservers (for resolving proxy server domains)
+  default-nameserver:
+    - 1.1.1.1
+    - 8.8.8.8
+    - tls://1.1.1.1:853
+    - tls://8.8.8.8:853
+  
+  # Main nameservers (will route through proxy due to respect-rules)
+  nameserver:
+    - https://cloudflare-dns.com/dns-query
+    - https://dns.google/dns-query
+    - https://1.1.1.1/dns-query
+    - https://8.8.8.8/dns-query
+  
+  # DNS for proxy server resolution (prevents circular dependency)
+  proxy-server-nameserver:
+    - https://1.1.1.1/dns-query
+    - https://8.8.8.8/dns-query
+    - 1.1.1.1
+    - 8.8.8.8
+  
+  # DNS for direct connections
+  direct-nameserver:
+    - https://1.1.1.1/dns-query
+    - https://8.8.8.8/dns-query
+  
+  # Smart DNS policies for different services
+  nameserver-policy:
+    # Private networks use direct DNS
+    "geosite:private":
+      - https://1.1.1.1/dns-query
+      - https://8.8.8.8/dns-query
+    
+    # Google services through proxy DNS
+    "geosite:google":
+      - https://dns.google/dns-query
+      - https://8.8.8.8/dns-query
+    
+    # Cloudflare services through proxy DNS
+    "geosite:cloudflare":
+      - https://cloudflare-dns.com/dns-query
+      - https://1.1.1.1/dns-query
+    
+    # Social media blocked in Iran
+    "geosite:telegram":
+      - https://cloudflare-dns.com/dns-query
+      - https://dns.google/dns-query
+    
+    "geosite:facebook":
+      - https://cloudflare-dns.com/dns-query
+      - https://dns.google/dns-query
+    
+    "geosite:twitter":
+      - https://cloudflare-dns.com/dns-query
+      - https://dns.google/dns-query
+    
+    # All international domains through proxy
+    "geosite:geolocation-!cn":
+      - https://cloudflare-dns.com/dns-query
+      - https://dns.google/dns-query
+
+# Your Existing Proxies
+proxies:
+  - name: "mahsa"
+    type: socks5
+    server: 127.0.0.1
+    port: 12334
+
+  - name: "warp"
+    type: socks5
+    server: 127.0.0.1
+    port: 12334
+
+  - name: "us"
+    type: vless
+    server: 83.229.39.154
+    port: 33103
+    udp: true
+    network: tcp
+    smux:
+      enabled: true
+
+  - name: "ro"
+    type: vless
+    server: 5.182.37.27
+    port: 33103
+    udp: true
+    network: tcp
+    smux:
+      enabled: true
+
+# Proxy Groups - Iran Optimized 🇮🇷
+proxy-groups:
+  - name: "🚀 PROXY"
+    type: select
+    proxies:
+      - "🔄 AUTO"
+      - "🌐 MAHSA"
+      - "⚡ WARP"
+      - "🇺🇸 US"
+      - "🇷🇴 RO"
+      - "🔗 RELAY"
+      - "📍 DIRECT"
+
+  - name: "🔄 AUTO"
+    type: url-test
+    proxies:
+      - "🔗 RELAY"
+      - "🇺🇸 US"
+      - "🇷🇴 RO"
+    url: 'https://www.gstatic.com/generate_204'
+    interval: 300
+    tolerance: 50
+
+  - name: "🌐 MAHSA"
+    type: select
+    proxies:
+      - "mahsa"
+
+  - name: "⚡ WARP"
+    type: select
+    proxies:
+      - "warp"
+
+  - name: "🇺🇸 US"
+    type: select
+    proxies:
+      - "us"
+
+  - name: "🇷🇴 RO"
+    type: select
+    proxies:
+      - "ro"
+
+  - name: "🔗 RELAY"
+    type: relay
+    proxies:
+      - "warp"
+      - "us"
+
+  - name: "📍 DIRECT"
+    type: select
+    proxies:
+      - DIRECT
+
+# Iran-Optimized Rules 🇮🇷
+rules:
+  # Local and private networks - direct
+  - GEOIP,PRIVATE,📍 DIRECT,no-resolve
+  - GEOIP,LAN,📍 DIRECT,no-resolve
+  
+  # Iranian IP addresses - direct connection
+  - GEOIP,IR,📍 DIRECT,no-resolve
+  
+  # Warp-plus process - direct to avoid loops
+  - PROCESS-NAME,warp-plus,📍 DIRECT
+  - PROCESS-NAME,hiddify,📍 DIRECT
+  
+  # 🔑 DNS traffic - ensure all DNS goes through proxy
+  - DST-PORT,53,🚀 PROXY
+  - DST-PORT,853,🚀 PROXY
+  - DOMAIN-SUFFIX,dns.google,🚀 PROXY
+  - DOMAIN-SUFFIX,cloudflare-dns.com,🚀 PROXY
+  - DOMAIN-SUFFIX,1.1.1.1,🚀 PROXY
+  
+  # Services commonly blocked in Iran - force through proxy
+  - GEOSITE,GOOGLE,🚀 PROXY
+  - GEOSITE,YOUTUBE,🚀 PROXY
+  - GEOSITE,FACEBOOK,🚀 PROXY
+  - GEOSITE,INSTAGRAM,🚀 PROXY
+  - GEOSITE,TWITTER,🚀 PROXY
+  - GEOSITE,TELEGRAM,🚀 PROXY
+  - GEOSITE,GITHUB,🚀 PROXY
+  - GEOSITE,CLOUDFLARE,🚀 PROXY
+  - GEOSITE,NETFLIX,🚀 PROXY
+  - GEOSITE,SPOTIFY,🚀 PROXY
+  
+  # Block ads (using available rules)
+  - GEOSITE,CATEGORY-ADS-ALL,REJECT
+  
+  # All international domains through proxy (safe for Iran)
+  - GEOSITE,GEOLOCATION-!CN,🚀 PROXY
+  
+  # Iranian domains and services - direct connection
+  - DOMAIN-SUFFIX,ir,📍 DIRECT
+  - DOMAIN-SUFFIX,aparat.com,📍 DIRECT
+  - DOMAIN-SUFFIX,digikala.com,📍 DIRECT
+  - DOMAIN-SUFFIX,divar.ir,📍 DIRECT
+  - DOMAIN-SUFFIX,eitaa.com,📍 DIRECT
+  - DOMAIN-SUFFIX,rubika.ir,📍 DIRECT
+  - DOMAIN-SUFFIX,snapp.ir,📍 DIRECT
+  - DOMAIN-SUFFIX,zarinpal.com,📍 DIRECT
+  - DOMAIN-SUFFIX,parsian.com,📍 DIRECT
+  - DOMAIN-SUFFIX,mellat.ir,📍 DIRECT
+  - DOMAIN-SUFFIX,bmi.ir,📍 DIRECT
+  - DOMAIN-SUFFIX,postbank.ir,📍 DIRECT
+  - DOMAIN-SUFFIX,shetab.ir,📍 DIRECT
+  - DOMAIN-SUFFIX,sep.ir,📍 DIRECT
+  - DOMAIN-SUFFIX,irna.ir,📍 DIRECT
+  - DOMAIN-SUFFIX,isna.ir,📍 DIRECT
+  
+  # Default rule - everything else through proxy for safety
+  - MATCH,🚀 PROXY
+
+# Hosts Override for Iran 🇮🇷
+hosts:
+  # Ensure DNS servers resolve correctly
+  'dns.google': [ 8.8.8.8, 8.8.4.4 ]
+  'cloudflare-dns.com': [ 1.1.1.1, 1.0.0.1 ]
+  '1dot1dot1dot1.cloudflare-dns.com': [ 1.1.1.1, 1.0.0.1 ]
+  
+  # Common blocked domains in Iran - force through proxy
+  'www.google.com': [ 142.250.191.36 ]
+  'youtube.com': [ 142.250.191.14 ]
+  'www.youtube.com': [ 142.250.191.14 ]
+  'facebook.com': [ 157.240.11.35 ]
+  'www.facebook.com': [ 157.240.11.35 ]
+  'twitter.com': [ 104.244.42.193 ]
+  'www.twitter.com': [ 104.244.42.193 ]
+  'x.com': [ 104.244.42.193 ]

+ 3 - 0
requirements.txt

@@ -0,0 +1,3 @@
+requests
+ruamel.yaml
+pyyaml

+ 135 - 0
singbox.json

@@ -0,0 +1,135 @@
+{
+  "log": {
+    "level": "info"
+  },
+  "dns": {
+    "servers": [
+      {
+        "tag": "default",
+        "type": "udp",
+        "server": "1.1.1.1",
+        "detour": "direct"
+      },
+      {
+        "tag": "local",
+        "type": "local",
+        "detour": "direct"
+      }
+    ],
+    "rules": [
+      {
+        "rule_set": "geosite-ir",
+        "server": "local"
+      }
+    ],
+    "final": "default"
+  },
+  "inbounds": [
+    {
+      "type": "mixed",
+      "tag": "mixed-in",
+      "listen": "0.0.0.0",
+      "listen_port": 7892
+    },
+    {
+      "type": "tun",
+      "tag": "tun-in",
+      "interface_name": "utun0",
+      "address": [
+        "172.19.0.1/30",
+        "fdfe:dcba:9876::1/126"
+      ],
+      "mtu": 9000,
+      "auto_route": true,
+      "auto_redirect": true,
+      "strict_route": true,
+      "stack": "system",
+      "sniff": true,
+      "sniff_override_destination": true
+    }
+  ],
+  "outbounds": [
+    {
+      "type": "socks",
+      "tag": "warp",
+      "server": "127.0.0.1",
+      "server_port": 8086,
+      "version": "5"
+    },
+    {
+      "type": "vless",
+      "tag": "us",
+      "server": "83.229.39.154",
+      "server_port": 33103,
+      "uuid": "609eb9fb-7da3-41c2-8e81-681eb73b735d",
+      "flow": "",
+      "network": "tcp",
+      "multiplex": {
+        "enabled": true,
+        "protocol": "h2mux"
+      },
+      "detour": "warp"
+    },
+    {
+      "type": "vless",
+      "tag": "ro",
+      "server": "5.182.37.27",
+      "server_port": 33103,
+      "uuid": "609eb9fb-7da3-41c2-8e81-681eb73b735d",
+      "flow": "",
+      "network": "tcp",
+      "multiplex": {
+        "enabled": true,
+        "protocol": "h2mux"
+      }
+    },
+    {
+      "type": "selector",
+      "tag": "relay",
+      "outbounds": [
+        "us"
+      ],
+      "default": "us"
+    },
+    {
+      "type": "direct",
+      "tag": "direct"
+    },
+    {
+      "type": "block",
+      "tag": "block"
+    }
+  ],
+  "route": {
+    "rules": [
+      {
+        "rule_set": "geoip-ir",
+        "outbound": "direct"
+      },
+      {
+        "ip_is_private": true,
+        "outbound": "direct"
+      },
+      {
+        "process_name": "warp-plus",
+        "outbound": "direct"
+      }
+    ],
+    "rule_set": [
+      {
+        "type": "remote",
+        "tag": "geoip-ir",
+        "format": "binary",
+        "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-ir.srs"
+      },
+      {
+        "type": "remote",
+        "tag": "geosite-ir",
+        "format": "binary",
+        "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-ir.srs"
+      }
+    ],
+    "auto_detect_interface": true,
+    "final": "relay"
+  }
+}

+ 365 - 0
sub2clash_singbox.py

@@ -0,0 +1,365 @@
+import sys
+import requests
+import base64
+import json
+import yaml
+from ruamel.yaml import YAML
+from urllib.parse import urlparse, parse_qs, unquote
+
+
+# ---------- UTILITIES ----------
+def download_subscription(sub_url):
+    resp = requests.get(sub_url)
+    resp.raise_for_status()
+    text = resp.text.strip()
+    # meta: some sub files are base64 encoded!
+    try:
+        if all(ord(c) < 128 for c in text) and not text.startswith('vmess://'):
+            # Try decode base64 whole (often for SSR/SS)
+            dec = base64.b64decode(text).decode('utf-8', errors='ignore')
+            if dec.count('\n') > text.count('\n'):
+                text = dec
+    except Exception:
+        pass
+    return [line.strip() for line in text.splitlines() if line.strip()]
+
+
+# --- PROTOCOL PARSERS ---
+def parse_vmess(uri):
+    # vmess://<base64json>
+    payload = uri[8:]
+    try:
+        raw = base64.b64decode(payload + '=' * ((4 - len(payload) % 4) % 4)).decode('utf-8')
+        data = json.loads(raw)
+        proxy = {
+            'type': 'vmess',
+            'server': data.get('add'),
+            'port': int(data.get('port', 0)),
+            'uuid': data.get('id'),
+            'alterId': data.get('aid', '0'),
+            'cipher': data.get('cipher', ''),  # optional
+            'network': data.get('net', ''),
+            'tls': (data.get('tls', 'none') == 'tls'),
+            'name': data.get('ps') or f"vmess_{data.get('add')}",
+        }
+        return proxy
+    except Exception:
+        return None
+
+
+def parse_vless(uri):
+    # vless://[uuid]@[host]:[port]?params#remark
+    url = urlparse(uri)
+    user = url.username
+    server = url.hostname
+    port = url.port
+    params = parse_qs(url.query)
+    tag = unquote(url.fragment) if url.fragment else f"vless_{server}"
+    return {
+        'type': 'vless',
+        'server': server,
+        'port': int(port),
+        'uuid': user,
+        'encryption': params.get('encryption', ['none'])[0],
+        'flow': params.get('flow', [''])[0],
+        'network': params.get('type', ['tcp'])[0],
+        'tls': 'tls' in params and params['tls'][0] == 'tls',
+        'name': tag,
+    }
+
+
+def parse_trojan(uri):
+    # trojan://password@host:port?params#remark
+    url = urlparse(uri)
+    server = url.hostname
+    port = url.port
+    password = url.username
+    tag = unquote(url.fragment) if url.fragment else f"trojan_{server}"
+    return {
+        'type': 'trojan',
+        'server': server,
+        'port': int(port),
+        'password': password,
+        'name': tag
+    }
+
+
+def parse_ss(uri):
+    # ss://[method:pass@host:port] or ss://base64#remark
+    try:
+        rest = uri[5:]
+        if '@' in rest:
+            if '#' in rest:
+                rest, tag = rest.split('#', 1)
+                tag = unquote(tag)
+            else:
+                tag = None
+            auth, host_port = rest.split('@', 1)
+            method, password = auth.split(':', 1)
+            host, port = host_port.split(':', 1)
+        else:
+            if '#' in rest:
+                main, tag = rest.split('#', 1)
+                tag = unquote(tag)
+            else:
+                main = rest
+                tag = None
+            raw = base64.b64decode(main.split('?')[0] + '===').decode('utf-8')
+            userinfo, host_port = raw.rsplit('@', 1)
+            method, password = userinfo.split(':', 1)
+            host, port = host_port.split(':', 1)
+        return {
+            'type': 'ss',
+            'server': host,
+            'port': int(port),
+            'method': method,
+            'password': password,
+            'name': tag or f"ss_{host}"
+        }
+    except Exception:
+        return None
+
+
+def parse_socks(uri):
+    # socks://[username:password@]host:port
+    url = urlparse(uri)
+    username = url.username or ''
+    password = url.password or ''
+    server = url.hostname
+    port = url.port
+    tag = unquote(url.fragment) if url.fragment else f"socks_{server}"
+    return {
+        'type': 'socks',
+        'server': server,
+        'port': int(port),
+        'username': username,
+        'password': password,
+        'name': tag
+    }
+
+
+def parse_proxy_line(line):
+    if line.startswith('vmess://'):
+        return parse_vmess(line)
+    elif line.startswith('vless://'):
+        return parse_vless(line)
+    elif line.startswith('trojan://'):
+        return parse_trojan(line)
+    elif line.startswith('ss://'):
+        return parse_ss(line)
+    elif line.startswith('socks://'):
+        return parse_socks(line)
+    else:
+        return None
+
+
+# --- CONFIG PARSING/RENDER ---
+def read_yaml_file(yaml_path):
+    with open(yaml_path, 'r', encoding='utf-8') as f:
+        yaml_ = YAML()
+        content = yaml_.load(f)
+    return content
+
+
+def write_yaml_file(yaml_obj, yaml_path):
+    with open(yaml_path, 'w', encoding='utf-8') as f:
+        yaml_ = YAML()
+        yaml_.default_flow_style = False
+        yaml_.dump(yaml_obj, f)
+
+
+def read_json_file(path):
+    with open(path, 'r', encoding='utf-8') as f:
+        return json.load(f)
+
+
+def write_json_file(obj, path):
+    with open(path, 'w', encoding='utf-8') as f:
+        json.dump(obj, f, indent=2, ensure_ascii=False)
+
+
+# ---- INJECTION LOGIC ----
+def update_clash_proxies(clash_cfg, proxies):
+    # Replace the list in 'proxies' with ours, unless you want to append.
+    clash_cfg['proxies'] = [proxy_to_clash(p) for p in proxies]
+    # Update 'proxy-groups' membership, e.g. add all new proxies to AUTO, select, etc.
+    groupkeys = ['proxies', 'proxy', 'select', 'relay', 'url-test']
+    for g in clash_cfg.get('proxy-groups', []):
+        if g['type'] in ('select', 'url-test') and 'proxies' in g:
+            for px in clash_cfg['proxies']:
+                if px['name'] not in g['proxies']:
+                    g['proxies'].append(px['name'])
+    return clash_cfg
+
+
+def proxy_to_clash(proxy):
+    # Map internal proxy to Clash Meta format
+    if proxy['type'] == 'vmess':
+        return {
+            'name': proxy['name'],
+            'type': 'vmess',
+            'server': proxy['server'],
+            'port': proxy['port'],
+            'uuid': proxy['uuid'],
+            'alterId': int(proxy.get('alterId', '0')),
+            'cipher': proxy.get('cipher', 'auto'),
+            'tls': proxy['tls'],
+            'network': proxy.get('network', 'tcp'),
+        }
+    elif proxy['type'] == 'vless':
+        return {
+            'name': proxy['name'],
+            'type': 'vless',
+            'server': proxy['server'],
+            'port': proxy['port'],
+            'uuid': proxy['uuid'],
+            'encryption': proxy['encryption'],
+            'flow': proxy.get('flow', ''),
+            'network': proxy.get('network', 'tcp'),
+            'tls': proxy['tls'],
+        }
+    elif proxy['type'] == 'trojan':
+        return {
+            'name': proxy['name'],
+            'type': 'trojan',
+            'server': proxy['server'],
+            'port': proxy['port'],
+            'password': proxy['password'],
+        }
+    elif proxy['type'] == 'ss':
+        return {
+            'name': proxy['name'],
+            'type': 'ss',
+            'server': proxy['server'],
+            'port': proxy['port'],
+            'cipher': proxy['method'],
+            'password': proxy['password']
+        }
+    elif proxy['type'] == 'socks':
+        out = {
+            'name': proxy['name'],
+            'type': 'socks5',
+            'server': proxy['server'],
+            'port': proxy['port'],
+        }
+        if proxy['username']:
+            out['username'] = proxy['username']
+        if proxy['password']:
+            out['password'] = proxy['password']
+        return out
+    else:
+        return None
+
+
+def update_singbox_outbounds(sj, proxies):
+    new_outbounds = []
+    tagset = set()
+    for p in proxies:
+        sbo = proxy_to_singbox(p)
+        if sbo['tag'] in tagset:
+            continue
+        new_outbounds.append(sbo)
+        tagset.add(sbo['tag'])
+    # Replace any proxies originally with same tag/type
+    sj['outbounds'] = [o for o in sj['outbounds'] if o.get('tag', '') not in tagset]
+    sj['outbounds'] += new_outbounds
+    # Optionally update relay/group
+    for o in sj['outbounds']:
+        if o['type'] == 'selector' and 'outbounds' in o:
+            # Add all new tags except duplicates
+            for t in tagset:
+                if t not in o['outbounds']:
+                    o['outbounds'].append(t)
+    return sj
+
+
+def proxy_to_singbox(proxy):
+    tag = proxy['name']
+    if proxy['type'] == 'vmess':
+        return {
+            'type': 'vmess',
+            'tag': tag,
+            'server': proxy['server'],
+            'server_port': proxy['port'],
+            'uuid': proxy['uuid'],
+            'alter_id': int(proxy.get('alterId', '0')),
+            'network': proxy.get('network', 'tcp'),
+            'tls': proxy.get('tls', False),
+        }
+    elif proxy['type'] == 'vless':
+        return {
+            'type': 'vless',
+            'tag': tag,
+            'server': proxy['server'],
+            'server_port': proxy['port'],
+            'uuid': proxy['uuid'],
+            'encryption': proxy['encryption'],
+            'flow': proxy.get('flow', ''),
+            'network': proxy.get('network', 'tcp'),
+            'tls': proxy['tls']
+        }
+    elif proxy['type'] == 'trojan':
+        return {
+            'type': 'trojan',
+            'tag': tag,
+            'server': proxy['server'],
+            'server_port': proxy['port'],
+            'password': proxy['password'],
+        }
+    elif proxy['type'] == 'ss':
+        return {
+            'type': 'shadowsocks',
+            'tag': tag,
+            'server': proxy['server'],
+            'server_port': proxy['port'],
+            'method': proxy['method'],
+            'password': proxy['password'],
+        }
+    elif proxy['type'] == 'socks':
+        ob = {
+            'type': 'socks',
+            'tag': tag,
+            'server': proxy['server'],
+            'server_port': proxy['port'],
+        }
+        if proxy['username']:
+            ob['username'] = proxy['username']
+        if proxy['password']:
+            ob['password'] = proxy['password']
+        return ob
+    else:
+        return {}
+
+
+# ------ MAIN ENTRYPOINT ------
+if __name__ == '__main__':
+    if len(sys.argv) != 6:
+        print(
+            "Usage: python sub2clash_singbox.py <sub_url> <clash_template.yaml> <singbox_template.json> <output_clash.yaml> <output_singbox.json>")
+        sys.exit(1)
+    (url, clash_tmpl, singbox_tmpl, out_clash, out_sb) = sys.argv[1:]
+
+    print(f"[+] Download: {url}")
+    lines = download_subscription(url)
+    print(f"[+] {len(lines)} lines found in sub...")
+    proxies = []
+    for line in lines:
+        px = parse_proxy_line(line)
+        if px:
+            proxies.append(px)
+    print(f"[+] Parsed proxies: {len(proxies)}")
+
+    # --- Handle Clash Meta YAML ---
+    print(f"[~] Processing Clash...")
+    clash_cfg = read_yaml_file(clash_tmpl)
+    clash_cfg = update_clash_proxies(clash_cfg, proxies)
+    write_yaml_file(clash_cfg, out_clash)
+    print(f"[✓] Output Clash config: {out_clash}")
+
+    # --- Handle Singbox JSON ---
+    print(f"[~] Processing Singbox...")
+    singbox_cfg = read_json_file(singbox_tmpl)
+    singbox_cfg = update_singbox_outbounds(singbox_cfg, proxies)
+    write_json_file(singbox_cfg, out_sb)
+    print(f"[✓] Output Singbox config: {out_sb}")
+    print("Done!")

Некоторые файлы не были показаны из-за большого количества измененных файлов