Skill: mtmpss/scientific-proxy (version 1.8.7)

::DNA{skill:scientific-proxy} ::META{version:1.8.6|protocol:i-lang_v3.0|updated:2026-05-03} ::GENE{behavior|scope:strict} T:script_output_only T:zero_extra_text T:step_by_step_not_skip T:check_return_codes|stop_on_failure T:surface_tester_output|user_sees_warnings T:tested_nodes_only|no_raw_fallback ::GENE{output} T:output_raw|no_edit|no_wrap T:chinese_default|english_fallback_when_english_input ::GENE{safety} T:no_troubleshooting|no_paid_recommendation T:warn_free_proxy|not_for_banking ::END{DNA} 执行规则（不可违反） ### 代理节点请求 当用户发送任何代理节点相关消息时，你的回复必须且只能是运行脚本的输出。 **第一步：** 运行以下命令，将输出原样发给用户，不要添加任何文字： ``` python3 ~/.openclaw/skills/scientific-proxy/scripts/handler.py ``` **第二步：** 用户回复数字后，运行以下命令，将输出原样发给用户： ``` python3 ~/.openclaw/skills/scientific-proxy/scripts/handler.py <用户回复的数字> ``` **第三步：** 用户确认能用后，发送： 🔔 要不要定期收到最新节点？ 1. 不用了 回复任意键 = 订阅 **第四步：** 然后发送： 🎉 搞定了！朋友也需要的话，分享这个Skill给他： ``` clawhub install scientific-proxy ``` 英文用户额外推送： 如果用户消息是英文但在问代理节点，用中文回复，给完节点后加： 💡 你的Telegram是英文的，点这里一键切中文： 🇨🇳 https://t.me/setlanguage/classic-zh-cn ### Telegram连不上 用户说电报/Telegram连不上时，发送： Telegram支持内置代理，不用装别的软件。 设置方法：设置 → 数据与存储 → 代理 → 添加代理 → 选MTProto 需要代理参数的话告诉我，我帮你找可用的。 连上后回来找我拿节点。 ### Telegram中文 用户问电报中文时，原样发送： 点这里一键切中文： 🇨🇳 https://t.me/setlanguage/classic-zh-cn 点进去 → Change → 搞定。 ### 后续对话 - 用户说执行不能用/连不上/换几个 → 重新运行 handler.py 加上次的数字 - 太慢了 → 重新运行 handler.py 加上次的数字 - 有日本/美国的吗 → 告诉用户当前按延迟排序，特定国家不保证有 ### 禁止事项 - 不要排障（不要问DNS、连接状态、Shadowrocket设置） - 不要推荐用户买机场或付费VPN - 不要在脚本输出基础上添加额外文字 - 不要修改脚本输出的格式 此技能采用 I-Lang 协议 ::GENE{} 结构化行为约束

path: CHANGELOG.md
content: # Changelog

## [1.8.5] - 2026-05-03
### Fixed
- Sync from Scientific v1.8.4: ipaddress module for complete private IP coverage (172.16.0.0/12 etc.)


## [1.8.4] - 2026-05-03
### Fixed
- tester.py: use ipaddress module for complete private IP detection (covers 172.16.0.0/12 and all RFC1918)
- Removed incomplete string-prefix approach; full ipaddress.is_private/is_loopback/etc.


## [1.8.3] - 2026-05-03
### Fixed
- tester.py: DNS resolve hostnames before testing; reject resolved private/reserved IPs
- Narrowed ALLOWED_PORTS to proxy-only ports (removed SSH/MySQL/VNC)
- ClawHub hostname resolution finding resolved


## [1.8.2] - 2026-05-03
### Fixed
- tester.py: address filtering (private/loopback/link-local/reserved blocked), port allowlist, confirmation prompt
- ClawHub scan: resolve "Tool Misuse" finding re arbitrary TCP connections


## [1.8.0] - 2026-05-03
### Added
- I-Lang v3.0 ::DNA{} behavioral DNA integration
- ::GENE{} strict behavior constraints for all output
- Protocol: I-Lang embedded in SKILL.md

### Changed
- License MIT → MIT-0 (no attribution required)
- ClawHub moderation: clean pass
- Sharing flow: recommends clawhub install

### Fixed
- Removed hardcoded proxy links (tg://) for moderation compliance
- Unified Telegram proxy guidance across both skills

## [1.2.0] - 2026-02-27
### Added
- Initial release
- Scraper: 10+ GitHub node sources
- Tester: 20-thread parallel TCP speed testing
- Formatter: Clash/V2Ray/Surge/Shadowrocket/Base64 output
- OpenClaw SKILL.md with bilingual trigger words
- GitHub Actions auto-publish to ClawHub


path: README.md
content: # 🪄 Magic Internet Access

**AI-powered magic internet access engine for OpenClaw**

AI驱动的魔法上网术——你的私人魔法上网管家。自动抓取免费节点、测速、筛选，一步步引导零基础用户完成配置。

## ✨ Features

- **10+ Sources** — Auto-scrape free proxy nodes from GitHub and public channels
- **Speed Test** — 20-thread parallel TCP latency testing, keep only the fastest
- **Multi-Format** — Clash / V2Ray / Surge / Shadowrocket / Base64 output
- **Zero Config** — Step-by-step guided setup for complete beginners
- **Auto Match** — Detect device → recommend client → format config

## 🚀 Quick Start

```bash
clawhub install scientific-proxy
```

Or talk to the bot directly:

> **User:** 上不了Google
>
> **Bot:** 你用什么设备？
> 1. Windows 电脑
> 2. Mac 电脑
> 3. iPhone / iPad 【推荐】
> 4. 安卓手机
>
> **User:** 3
>
> **Bot:** 找到可用节点：[节点配置]
> 📱 客户端：Shadowrocket（应用商店搜索下载）
> 📋 用法：复制上面内容 → 打开客户端 → 导入 → 连接

## 🛡️ Supported Protocols

| Protocol | Status |
|----------|--------|
| VMess | ✅ |
| VLESS | ✅ |
| Trojan | ✅ |
| Shadowsocks | ✅ |

## 📱 Supported Clients

| Device | Client |
|--------|--------|
| Windows | Clash for Windows |
| macOS | ClashX |
| iPhone/iPad | Shadowrocket |
| Android | v2rayNG |

## 🔗 Links

- 🌐 [shadowrocket.ai](https://shadowrocket.ai)
- 🦞 [ClawHub](https://clawhub.ai/mtmpss/scientific-proxy)
- 🤖 Telegram: [@openclawchina_bot](https://t.me/openclawchina_bot)

## 📄 License

MIT © [shadowrocketai](https://github.com/shadowrocketai)


path: scripts/formatter.py
content: #!/usr/bin/env python3
import json, base64, os, sys, argparse
from datetime import datetime

def load_nodes(workspace, allow_raw=False):
    tested = os.path.join(workspace, "nodes_tested.json")
    if os.path.exists(tested):
        with open(tested, 'r', encoding='utf-8') as f: data = json.load(f)
        return [n for n in data.get("nodes", []) if n.get("alive", True)]
    if allow_raw:
        raw = os.path.join(workspace, "nodes_raw.json")
        if os.path.exists(raw):
            with open(raw, 'r', encoding='utf-8') as f: data = json.load(f)
            print("⚠️  Untested nodes — tester did not run.")
            return data.get("nodes", [])
    print("No tested nodes. Run tester.py first (or use --allow-raw for untested)."); sys.exit(1)

def format_text(nodes, top=5):
    lines = ["Scientific Internet Access", datetime.now().strftime('%Y-%m-%d %H:%M'), ""]
    for i, n in enumerate(nodes[:top]):
        flag = n.get("flag", ""); name = n.get("name", n.get("country", "Unknown"))
        if len(name) > 30: name = name[:27] + "..."
        lat = f"{n['latency']}ms" if isinstance(n.get('latency'), int) and n['latency'] > 0 else "N/A"
        lines.append(f"{i+1}. {flag} {name} | {n.get('protocol','')} | {lat} | {n.get('speed_rating','')}")
        lines.append(f"   {n.get('raw','')}")
        lines.append("")
    lines.append("Free nodes are unstable, refresh regularly")
    lines.append("Powered by Scientific Internet Access | shadowrocket.ai")
    return "\n".join(lines)

def format_base64(nodes, top=50):
    lines = [n.get("raw", "") for n in nodes[:top] if n.get("raw")]
    return base64.b64encode("\n".join(lines).encode('utf-8')).decode('utf-8')

def format_clash(nodes, top=20):
    proxies = []; names = []
    for n in nodes[:top]:
        name = n.get("name", f"{n['server']}:{n['port']}")
        if name in names: name = f"{name}_{len(names)}"
        names.append(name)
        p = n.get("protocol", "")
        if p == "vmess": proxies.append({"name": name, "type": "vmess", "server": n["server"], "port": n["port"], "uuid": n.get("uuid",""), "alterId": n.get("aid",0), "cipher": "auto"})
        elif p == "trojan": proxies.append({"name": name, "type": "trojan", "server": n["server"], "port": n["port"], "password": n.get("password","")})
        elif p == "ss": proxies.append({"name": name, "type": "ss", "server": n["server"], "port": n["port"], "cipher": n.get("method","aes-256-gcm"), "password": n.get("password","")})
    config = {"port": 7890, "socks-port": 7891, "allow-lan": False, "mode": "rule", "proxies": proxies, "proxy-groups": [{"name": "Scientific", "type": "select", "proxies": names + ["DIRECT"]}], "rules": ["GEOIP,CN,DIRECT", "MATCH,Scientific"]}
    return json.dumps(config, ensure_ascii=False, indent=2)

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--format", "-f", default="text", choices=["text","clash","v2ray","base64"])
    parser.add_argument("--top", "-t", type=int, default=5)
    parser.add_argument("--allow-raw", action="store_true", help="fall back to untested raw nodes")
    args = parser.parse_args()
    workspace = os.environ.get("OPENCLAW_WORKSPACE", os.path.expanduser("~/.openclaw/workspace"))
    nodes = load_nodes(workspace, args.allow_raw)
    formatters = {"text": format_text, "clash": format_clash, "base64": format_base64, "v2ray": lambda n,t: json.dumps([x.get("raw","") for x in n[:t]])}
    print(formatters[args.format](nodes, args.top))

if __name__ == "__main__":
    main()


path: scripts/handler.py
content: #!/usr/bin/env python3
import sys, subprocess, os

def step1_device():
    print("""你用什么设备？

1. Windows 电脑
2. Mac 电脑
3. iPhone / iPad 【推荐】
4. 安卓手机

回复数字，或直接按任意键选推荐。""")

def step2_run(choice):
    skill_dir = os.path.dirname(os.path.abspath(__file__))
    fmt_map = {
        '1': ('clash', 'Clash for Windows'),
        '2': ('clash', 'ClashX'),
        '3': ('base64', 'Shadowrocket'),
        '4': ('v2ray', 'v2rayNG'),
    }
    if choice not in fmt_map:
        choice = '3'
    fmt, client = fmt_map[choice]

    # Step 1: scrape
    r = subprocess.run([sys.executable, os.path.join(skill_dir, 'scraper.py')], capture_output=True, cwd=skill_dir)
    if r.returncode != 0:
        print(f"Scraper failed:\n{r.stderr.decode(errors='replace')}")
        return

    # Step 2: test — surface output so user sees warnings + confirmation prompt
    r = subprocess.run([sys.executable, os.path.join(skill_dir, 'tester.py')], capture_output=False, cwd=skill_dir)
    if r.returncode != 0:
        print("Testing aborted or failed. No usable nodes.")
        return

    # Step 3: format — tested nodes only, no raw fallback
    r = subprocess.run([sys.executable, os.path.join(skill_dir, 'formatter.py'), '--format', fmt, '--top', '5'], capture_output=True, text=True, cwd=skill_dir)
    if r.returncode != 0:
        print(f"Formatting failed:\n{r.stderr}")
        return
    config = r.stdout.strip() if r.stdout.strip() else '暂时没有可用节点，稍后再试。'
    print(f"""找到可用节点：

{config}

📱 客户端：{client}（应用商店搜索下载）
📋 用法：复制上面内容 → 打开客户端 → 导入 → 连接

⚠️ 免费节点来自公开源，别用来登银行、邮箱等重要账号。

搞不定告诉我卡在哪步。""")

if __name__ == '__main__':
    if len(sys.argv) > 1:
        step2_run(sys.argv[1])
    else:
        step1_device()


path: scripts/scraper.py
content: #!/usr/bin/env python3
import json, base64, re, urllib.request, urllib.error, os, sys, time
from datetime import datetime

SOURCES = [
    {"name": "freefq/free", "urls": ["https://raw.githubusercontent.com/freefq/free/master/v2"]},
    {"name": "Pawdroid/Free-servers", "urls": ["https://raw.githubusercontent.com/Pawdroid/Free-servers/main/sub"]},
    {"name": "aiboboxx/v2rayfree", "urls": ["https://raw.githubusercontent.com/aiboboxx/v2rayfree/main/v2"]},
    {"name": "mfuu/v2ray", "urls": ["https://raw.githubusercontent.com/mfuu/v2ray/master/v2ray"]},
    {"name": "ermaozi/get_subscribe", "urls": ["https://raw.githubusercontent.com/ermaozi/get_subscribe/main/subscribe/v2ray.txt"]},
    {"name": "peasoft/NoMoreWalls", "urls": ["https://raw.githubusercontent.com/peasoft/NoMoreWalls/master/list_raw.txt"]},
    {"name": "mahdibland/V2RayAggregator", "urls": ["https://raw.githubusercontent.com/mahdibland/V2RayAggregator/master/sub/sub_merge_base64.txt"]},
    {"name": "barry-far/V2ray-Configs", "urls": ["https://raw.githubusercontent.com/barry-far/V2ray-Configs/main/Sub1.txt", "https://raw.githubusercontent.com/barry-far/V2ray-Configs/main/Sub2.txt"]},
    {"name": "Leon406/SubCrawler", "urls": ["https://raw.githubusercontent.com/Leon406/SubCrawler/main/sub/share/v2"]},
    {"name": "vveg26/chromego_merge", "urls": ["https://raw.githubusercontent.com/vveg26/chromego_merge/main/sub/merged_proxies_new"]},
]

COUNTRY_MAP = {
    "JP": "\U0001f1ef\U0001f1f5", "Japan": "\U0001f1ef\U0001f1f5", "Tokyo": "\U0001f1ef\U0001f1f5",
    "SG": "\U0001f1f8\U0001f1ec", "Singapore": "\U0001f1f8\U0001f1ec",
    "US": "\U0001f1fa\U0001f1f8", "USA": "\U0001f1fa\U0001f1f8", "Los Angeles": "\U0001f1fa\U0001f1f8",
    "HK": "\U0001f1ed\U0001f1f0", "Hong Kong": "\U0001f1ed\U0001f1f0",
    "TW": "\U0001f1f9\U0001f1fc", "Taiwan": "\U0001f1f9\U0001f1fc",
    "KR": "\U0001f1f0\U0001f1f7", "Korea": "\U0001f1f0\U0001f1f7",
    "DE": "\U0001f1e9\U0001f1ea", "Germany": "\U0001f1e9\U0001f1ea", "Frankfurt": "\U0001f1e9\U0001f1ea",
    "UK": "\U0001f1ec\U0001f1e7", "England": "\U0001f1ec\U0001f1e7",
    "CA": "\U0001f1e8\U0001f1e6", "Canada": "\U0001f1e8\U0001f1e6",
}

def detect_country(s):
    if not s: return "\U0001f30d", "Unknown"
    for k, v in COUNTRY_MAP.items():
        if k.lower() in s.lower(): return v, k
    return "\U0001f30d", "Unknown"

def decode_base64(data):
    data = data.strip()
    m = len(data) % 4
    if m: data += '=' * (4 - m)
    try: return base64.b64decode(data).decode('utf-8', errors='ignore')
    except:
        try: return base64.urlsafe_b64decode(data).decode('utf-8', errors='ignore')
        except: return ""

def parse_vmess(uri):
    try:
        raw = uri.replace("vmess://", "")
        decoded = decode_base64(raw)
        if not decoded: return None
        c = json.loads(decoded)
        name = c.get("ps", c.get("remarks", ""))
        flag, country = detect_country(name)
        return {"protocol": "vmess", "name": name, "server": c.get("add", ""), "port": int(c.get("port", 0)), "uuid": c.get("id", ""), "aid": int(c.get("aid", 0)), "network": c.get("net", "tcp"), "tls": c.get("tls", ""), "country": country, "flag": flag, "raw": uri.strip()}
    except: return None

def parse_vless(uri):
    try:
        raw = uri.replace("vless://", "")
        m = re.match(r'^([^@]+)@([^:]+):(\d+)(?:\?([^#]*))?(?:#(.*))?$', raw)
        if not m: return None
        uuid, server, port, params, name = m.groups()
        name = urllib.request.unquote(name or "")
        flag, country = detect_country(name)
        return {"protocol": "vless", "name": name, "server": server, "port": int(port), "uuid": uuid, "params": params or "", "country": country, "flag": flag, "raw": uri.strip()}
    except: return None

def parse_trojan(uri):
    try:
        raw = uri.replace("trojan://", "")
        m = re.match(r'^([^@]+)@([^:]+):(\d+)(?:\?([^#]*))?(?:#(.*))?$', raw)
        if not m: return None
        pw, server, port, params, name = m.groups()
        name = urllib.request.unquote(name or "")
        flag, country = detect_country(name)
        return {"protocol": "trojan", "name": name, "server": server, "port": int(port), "password": pw, "params": params or "", "country": country, "flag": flag, "raw": uri.strip()}
    except: return None

def parse_ss(uri):
    try:
        raw = uri.replace("ss://", "")
        parts = raw.split("#", 1)
        name = urllib.request.unquote(parts[1]) if len(parts) > 1 else ""
        decoded = decode_base64(parts[0])
        if not decoded: decoded = parts[0]
        m = re.match(r'^([^:]+):([^@]+)@([^:]+):(\d+)$', decoded)
        if not m: return None
        method, pw, server, port = m.groups()
        flag, country = detect_country(name)
        return {"protocol": "ss", "name": name, "server": server, "port": int(port), "method": method, "password": pw, "country": country, "flag": flag, "raw": uri.strip()}
    except: return None

def parse_node(line):
    line = line.strip()
    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)
    return None

def fetch_source(source):
    nodes = []
    for url in source["urls"]:
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
            with urllib.request.urlopen(req, timeout=15) as resp:
                content = resp.read().decode('utf-8', errors='ignore').strip()
            decoded = decode_base64(content)
            if decoded and any(p in decoded for p in ["vmess://", "vless://", "trojan://", "ss://"]): content = decoded
            for line in content.split('\n'):
                line = line.strip()
                if not line: continue
                node = parse_node(line)
                if node and node.get("server"):
                    node["source"] = source["name"]
                    nodes.append(node)
            print(f"  OK {source['name']} -> {len(nodes)} nodes")
        except Exception as e:
            print(f"  FAIL {source['name']}: {e}")
    return nodes

def deduplicate(nodes):
    seen = set()
    unique = []
    for n in nodes:
        key = f"{n['server']}:{n['port']}:{n['protocol']}"
        if key not in seen:
            seen.add(key)
            unique.append(n)
    return unique

def main():
    workspace = os.environ.get("OPENCLAW_WORKSPACE", os.path.expanduser("~/.openclaw/workspace"))
    output_path = os.path.join(workspace, "nodes_raw.json")
    print(f"Scraping {len(SOURCES)} sources...")
    all_nodes = []
    for source in SOURCES:
        all_nodes.extend(fetch_source(source))
    unique = deduplicate(all_nodes)
    protocols = {}
    for n in unique: protocols[n["protocol"]] = protocols.get(n["protocol"], 0) + 1
    print(f"Total: {len(all_nodes)}, Unique: {len(unique)}, Protocols: {protocols}")
    result = {"scraped_at": datetime.now().isoformat(), "total": len(unique), "protocols": protocols, "nodes": unique}
    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    with open(output_path, 'w', encoding='utf-8') as f:
        json.dump(result, f, ensure_ascii=False, indent=2)
    print(f"Saved {len(unique)} nodes to {output_path}")

if __name__ == "__main__":
    main()


path: scripts/tester.py
content: #!/usr/bin/env python3
"""Filtered TCP connectivity tester with address/port validation + DNS resolution."""
import json, socket, time, os, sys, concurrent.futures, ipaddress
from datetime import datetime

# Common proxy ports only (no SSH/MySQL/VNC etc.)
ALLOWED_PORTS = {
    80, 443, 8080, 8443,
    1080, 1088, 2082, 2083, 2086, 2087, 2095, 2096,
    3000, 3001, 4000, 5000, 6000, 7000, 8000, 8888, 9000,
    9090, 9999, 10000, 20000, 30000,
}
ALLOWED_PORT_RANGES = [(30000, 45000), (40000, 60000)]


def port_allowed(port):
    if not isinstance(port, int) or port < 1 or port > 65535:
        return False
    if port in ALLOWED_PORTS:
        return True
    for lo, hi in ALLOWED_PORT_RANGES:
        if lo <= port <= hi:
            return True
    return False


def _is_private_ip(ip_str):
    """Comprehensive check via ipaddress module. Covers all RFC1918/link-local/etc."""
    try:
        ip = ipaddress.ip_address(ip_str.strip())
        if any([ip.is_private, ip.is_loopback, ip.is_link_local,
                ip.is_reserved, ip.is_multicast, ip.is_unspecified]):
            return True
        return False
    except ValueError:
        return False  # not parseable as IP => let other checks handle it


def resolve_and_filter(host):
    """
    Resolve hostname to IPs, return (safe_ip, error_reason).
    Uses ipaddress module for complete RFC compliance.
    Returns (None, reason) if all resolved addresses are unsafe.
    """
    if not host:
        return None, "empty_host"

    # If it's already an IP literal, check directly
    try:
        ip = ipaddress.ip_address(host)
        if _is_private_ip(host):
            return None, f"unsafe_ip:{host}"
        return host, None  # safe IP literal
    except ValueError:
        pass  # hostname, resolve below

    # Resolve hostname to IPs
    try:
        addrs = socket.getaddrinfo(host, None)
    except Exception:
        return None, f"resolve_failed:{host}"

    safe_ips = []
    for fam, _, _, _, sockaddr in addrs:
        ip_str = sockaddr[0]
        if fam not in (socket.AF_INET, socket.AF_INET6):
            continue
        # Strip IPv6 scope id if present
        if '%' in ip_str:
            ip_str = ip_str.split('%')[0]
        if _is_private_ip(ip_str):
            continue
        safe_ips.append(ip_str)

    if not safe_ips:
        return None, f"all_resolved_private:{host}"

    return safe_ips[0], None  # first safe IP


def test_tcp(server, port, timeout=5):
    try:
        start = time.time()
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(timeout)
        result = sock.connect_ex((server, port))
        latency = round((time.time() - start) * 1000)
        sock.close()
        if result == 0:
            return True, latency
        return False, -1
    except Exception:
        return False, -1


def test_node(node):
    server = node.get("server", "")
    port = node.get("port", 0)
    node["alive"] = False
    node["latency"] = -1
    node["speed_rating"] = "Dead"
    node["skip_reason"] = None
    node["resolved_ip"] = None

    if not server or not port:
        node["skip_reason"] = "missing_server_or_port"
        return node

    safe_ip, reason = resolve_and_filter(server)
    if reason:
        node["skip_reason"] = reason
        return node
    if not port_allowed(port):
        node["skip_reason"] = f"port_not_allowed:{port}"
        return node

    node["resolved_ip"] = safe_ip
    alive, latency = test_tcp(safe_ip, port)
    node["alive"] = alive
    node["latency"] = latency
    if alive:
        if latency < 100:     node["speed_rating"] = "Fast"
        elif latency < 300:   node["speed_rating"] = "OK"
        elif latency < 800:   node["speed_rating"] = "Slow"
        else:                 node["speed_rating"] = "Very Slow"
    return node


def main():
    workspace = os.environ.get("OPENCLAW_WORKSPACE", os.path.expanduser("~/.openclaw/workspace"))
    input_path = os.path.join(workspace, "nodes_raw.json")
    output_path = os.path.join(workspace, "nodes_tested.json")

    if not os.path.exists(input_path):
        print("No nodes_raw.json found. Run scraper.py first.")
        sys.exit(1)

    with open(input_path, 'r', encoding='utf-8') as f:
        data = json.load(f)

    nodes = data.get("nodes", [])
    if not nodes:
        print("No nodes.")
        sys.exit(1)

    max_test = int(os.environ.get("MAX_TEST_NODES", 50))
    test_nodes = nodes[:max_test]

    # Pre-filter: resolve and show counts
    total = len(test_nodes)
    safe = 0
    unsafe = 0
    for n in test_nodes:
        safe_ip, reason = resolve_and_filter(n.get("server", ""))
        if reason or not port_allowed(n.get("port", 0)):
            unsafe += 1
        else:
            safe += 1

    print(f"Nodes to test: {total} total, {safe} safe, {unsafe} filtered out")
    if safe == 0:
        print("No testable nodes after address/port filtering.")
        sys.exit(1)
    if safe > 20:
        print(f"Will test {safe} endpoints from untrusted sources.")
        print("Type y + Enter to continue, or anything else to abort:")
        try:
            confirm = sys.stdin.readline().strip().lower()
        except Exception:
            confirm = "n"
        if confirm != "y":
            print("Aborted by user.")
            sys.exit(0)

    tested = []
    alive_count = 0
    with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
        futures = {executor.submit(test_node, n): n for n in test_nodes}
        done = 0
        for future in concurrent.futures.as_completed(futures):
            n = future.result()
            tested.append(n)
            done += 1
            if n.get("skip_reason"):
                print(f"  [{done}/{total}] {n.get('server','?')}:{n.get('port',0)} -> skipped ({n['skip_reason']})")
            elif n['alive']:
                alive_count += 1
                print(f"  [{done}/{total}] {n['server']}:{n['port']} -> {n['latency']}ms")
            else:
                print(f"  [{done}/{total}] {n['server']}:{n['port']} -> dead")

    tested.sort(key=lambda x: (
        x.get('skip_reason') is not None,
        not x['alive'],
        x['latency'] if x['latency'] > 0 else 99999
    ))
    print(f"\nAlive: {alive_count}, Dead: {len(tested) - alive_count - unsafe}, Skipped: {unsafe}")
    result = {
        "tested_at": datetime.now().isoformat(),
        "total_tested": len(tested),
        "alive": alive_count,
        "dead": len(tested) - alive_count - unsafe,
        "skipped": unsafe,
        "nodes": tested
    }
    with open(output_path, 'w', encoding='utf-8') as f:
        json.dump(result, f, ensure_ascii=False, indent=2)
    print(f"Saved to {output_path}")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)