#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Wpyw 网速测速(命令行版)v1.0 ================================ 零依赖网速测试工具,针对中国大陆宽带(联通 / 电信 / 移动)优化: 多线程 HTTP 下载 + 流式上传 + ICMP 延迟/抖动/丢包,全部节点在运行时实测优选。 用法: python wpyw-speedtest.py # 标准测速(约 25-40 秒) python wpyw-speedtest.py --quick # 快速测速(约 15 秒) python wpyw-speedtest.py --full # 完整测速(更准,约 60 秒) python wpyw-speedtest.py --json out.json python wpyw-speedtest.py --help """ from __future__ import annotations import argparse import ctypes import http.client import json import os import platform import random import re import shutil import socket import ssl import statistics import subprocess import sys import threading import time import urllib.parse import urllib.request from datetime import datetime VERSION = "1.0" UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" # SSL:测速节点常见自签/链不全,这里不做证书校验(只测速度,不传敏感数据) SSL_CTX = ssl.create_default_context() SSL_CTX.check_hostname = False SSL_CTX.verify_mode = ssl.CERT_NONE # ──────────────────────────────────────────────────────────────────────────── # 终端 UI # ──────────────────────────────────────────────────────────────────────────── class C: """ANSI 颜色。--no-color / 非终端时自动降级为空串。""" on = True R = B = DIM = "" RED = GRN = YEL = BLU = CYA = MAG = GRY = WHT = "" @classmethod def setup(cls, enabled: bool) -> None: cls.on = enabled if not enabled: cls.R = cls.B = cls.DIM = "" cls.RED = cls.GRN = cls.YEL = cls.BLU = cls.CYA = cls.MAG = cls.GRY = cls.WHT = "" return cls.R = "\x1b[0m" cls.B = "\x1b[1m" cls.DIM = "\x1b[2m" cls.RED = "\x1b[38;5;203m" cls.GRN = "\x1b[38;5;114m" cls.YEL = "\x1b[38;5;221m" cls.BLU = "\x1b[38;5;75m" cls.CYA = "\x1b[38;5;80m" cls.MAG = "\x1b[38;5;176m" cls.GRY = "\x1b[38;5;245m" cls.WHT = "\x1b[38;5;231m" SPARK = "▁▂▃▄▅▆▇█" FILL, EMPTY = "█", "░" def enable_vt() -> bool: """在 Windows 控制台打开 ANSI 转义支持。""" if os.name != "nt": return sys.stdout.isatty() try: k = ctypes.windll.kernel32 for std in (-11, -12): # STD_OUTPUT_HANDLE, STD_ERROR_HANDLE h = k.GetStdHandle(std) mode = ctypes.c_uint32() if k.GetConsoleMode(h, ctypes.byref(mode)): k.SetConsoleMode(h, mode.value | 0x0004) return bool(sys.stdout.isatty()) except Exception: return False LIVE_OK = True # 非终端(重定向/管道)时不刷进度行 def term_width(default: int = 100) -> int: if not LIVE_OK: return min(default, 96) try: return max(60, min(160, shutil.get_terminal_size((default, 25)).columns)) except Exception: return default def bar(pct: float, width: int = 26, color: str = "") -> str: n = int(round(width * max(0.0, min(100.0, pct)) / 100.0)) s = FILL * n + EMPTY * (width - n) return f"{color}{s}{C.R}" if color else s def sparkline(samples, width: int = 40) -> str: if not samples: return "" step = max(1, len(samples) // width) pts = samples[::step][-width:] hi = max(pts) or 1.0 return "".join(SPARK[min(7, int(p * 8 / hi))] for p in pts) def fmt_speed(mbps: float) -> str: if mbps >= 1000: return f"{mbps/1000:.2f} Gbit/s" if mbps >= 1: return f"{mbps:.2f} Mbit/s" return f"{mbps*1000:.1f} Kbit/s" def rule(title: str = "", color: str = "") -> None: w = term_width() if not title: print(f"{C.GRY}{'─' * w}{C.R}") return head = f"── {title} " pad = max(0, w - len(head) - 2) col = color or C.CYA print(f"\n{col}{C.B}{head}{'─' * pad}{C.R}") def row(label: str, value: str, note: str = "", lw: int = 12) -> None: pad = " " * max(1, lw - disp_len(label)) print(f" {C.GRY}{label}{C.R}{pad}{value}" + (f" {C.GRY}{note}{C.R}" if note else "")) def disp_len(s: str) -> int: """粗略的显示宽度(中日韩字符算 2 列)。""" n = 0 for ch in s: n += 2 if ("\u1100" <= ch <= "\u115f" or "\u2e80" <= ch <= "\ua4cf" or "\uac00" <= ch <= "\ud7a3" or "\uf900" <= ch <= "\ufaff" or "\ufe30" <= ch <= "\ufe6f" or "\uff00" <= ch <= "\uff60") else 1 return n def live(text: str) -> None: """原地刷新一行(仅交互终端)。""" if not LIVE_OK: return w = term_width() line = text # 按显示宽度截断,避免折行 if disp_len(re.sub(r"\x1b\[[0-9;]*m", "", line)) > w - 1: plain = re.sub(r"\x1b\[[0-9;]*m", "", line) line = plain[: w - 1] sys.stdout.write("\r\x1b[2K" + line) sys.stdout.flush() # ──────────────────────────────────────────────────────────────────────────── # 本机 / 线路信息 # ──────────────────────────────────────────────────────────────────────────── def clear_line() -> None: if LIVE_OK: sys.stdout.write("\r\x1b[2K") sys.stdout.flush() def local_ip() -> str: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect(("223.5.5.5", 80)) return s.getsockname()[0] except Exception: return "未知" finally: s.close() def default_gateway() -> str | None: """从 route print 解析默认网关(不需要管理员权限)。""" try: out = subprocess.run(["route", "print", "-4"], capture_output=True, timeout=8).stdout text = out.decode("gbk", "replace") if isinstance(out, bytes) else str(out) for line in text.splitlines(): parts = line.split() if len(parts) >= 3 and parts[0] == "0.0.0.0" and parts[1] == "0.0.0.0": return parts[2] except Exception: pass return None def local_dns() -> str | None: """从 ipconfig /all 里取本机实际使用的第一个 IPv4 DNS(通常是运营商的)。""" try: out = subprocess.run(["ipconfig", "/all"], capture_output=True, timeout=8).stdout text = out.decode("gbk", "replace") if isinstance(out, bytes) else str(out) hits = [] for line in text.splitlines(): if re.search(r"DNS\s*Servers|DNS 服务器", line, re.I): for ip in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", line.split(":", 1)[-1].split(":", 1)[-1]): if not ip.startswith(("127.", "0.")): hits.append(ip) elif hits and re.match(r"^\s{20,}((?:\d{1,3}\.){3}\d{1,3})\s*$", line): hits.append(line.strip()) return hits[0] if hits else None except Exception: return None def public_ip_info(timeout: float = 3.0): """尽力而为地取公网 IP 与运营商(国内优先)。""" result = {"ip": None, "isp": None, "region": None, "source": None} def try_ipip(): req = urllib.request.Request("https://myip.ipip.net", headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=timeout, context=SSL_CTX) as r: body = r.read(300).decode("utf-8", "replace") m = re.search(r"IP[::]\s*([0-9a-fA-F:.]+)", body) loc = re.search(r"来自于[::]\s*(.+)", body) return {"ip": m.group(1) if m else None, "region": loc.group(1).strip() if loc else None, "isp": (loc.group(1).strip() if loc else None), "source": "ipip.net"} def try_ipapi(): req = urllib.request.Request( "http://ip-api.com/json/?lang=zh-CN&fields=query,country,regionName,city,isp,as", headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=timeout) as r: d = json.loads(r.read().decode("utf-8", "replace")) if d.get("status") == "success" or d.get("query"): return {"ip": d.get("query"), "region": " ".join(x for x in [d.get("country"), d.get("regionName"), d.get("city")] if x), "isp": d.get("isp"), "source": "ip-api.com"} def try_ipinfo(): req = urllib.request.Request("https://ipinfo.io/json", headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=timeout, context=SSL_CTX) as r: d = json.loads(r.read().decode("utf-8", "replace")) return {"ip": d.get("ip"), "region": " ".join(x for x in [d.get("country"), d.get("region"), d.get("city")] if x), "isp": d.get("org"), "source": "ipinfo.io"} box: list = [] lock = threading.Lock() def run(fn): try: r = fn() if r and r.get("ip"): with lock: box.append(r) except Exception: pass threads = [threading.Thread(target=run, args=(f,), daemon=True) for f in (try_ipip, try_ipapi, try_ipinfo)] for t in threads: t.start() for t in threads: t.join(timeout + 2) if box: # 优先国内来源,其次取到运营商名字的那个 box.sort(key=lambda r: 0 if r.get("source") == "ipip.net" else 1) result = box[0] return result def isp_short(text: str | None) -> str: if not text: return "未知" for key in ("联通", "电信", "移动", "广电", "教育网", "Unicom", "Telecom", "Mobile"): if key in text: return {"Unicom": "联通", "Telecom": "电信", "Mobile": "移动"}.get(key, key) return "未知" # ──────────────────────────────────────────────────────────────────────────── # 延迟测试(ICMP) # ──────────────────────────────────────────────────────────────────────────── PING_TIME = re.compile(r"(?:time|时间)\s*[=<]\s*([\d.]+)\s*ms", re.I) PING_LT = re.compile(r"(?:time|时间)\s*<\s*1\s*ms", re.I) def ping_once_stats(host: str, count: int = 4, timeout_ms: int = 1500) -> dict: """调用系统 ping,返回 min/avg/max/jitter/loss。""" t0 = time.perf_counter() try: p = subprocess.run(["ping", "-n", str(count), "-w", str(timeout_ms), host], capture_output=True, timeout=count * (timeout_ms / 1000.0) + 8) raw = p.stdout or b"" except Exception: return {"host": host, "avg": None, "loss": 100.0, "samples": [], "elapsed": time.perf_counter() - t0} text = None for enc in ("utf-8", "gbk", "latin-1"): try: text = raw.decode(enc) break except Exception: continue if text is None: text = raw.decode("latin-1", "replace") times, replies = [], 0 for line in text.splitlines(): if re.search(r"TTL\s*=", line, re.I): replies += 1 if PING_LT.search(line): times.append(0.5) continue m = PING_TIME.search(line) if m: times.append(float(m.group(1))) loss = max(0.0, (count - replies) * 100.0 / count) if not times: return {"host": host, "avg": None, "loss": loss, "samples": [], "elapsed": time.perf_counter() - t0} jitter = 0.0 if len(times) > 1: jitter = statistics.mean(abs(b - a) for a, b in zip(times, times[1:])) return {"host": host, "avg": statistics.mean(times), "min": min(times), "max": max(times), "jitter": jitter, "loss": loss, "samples": times, "elapsed": time.perf_counter() - t0} def tcp_latency(host: str, port: int, tries: int = 3) -> float | None: vals = [] for _ in range(tries): try: t0 = time.perf_counter() s = socket.create_connection((host, port), timeout=3) vals.append((time.perf_counter() - t0) * 1000) s.close() except Exception: pass return min(vals) if vals else None def phase_latency(hosts, gateway, quick: bool, do_tcp: str | None) -> dict: rule("① 延迟 / 抖动 / 丢包", C.BLU) count = 3 if quick else 5 results, lock = [], threading.Lock() def run(host, label, kind="dom", note=""): r = ping_once_stats(host, count) r.update({"label": label, "kind": kind, "note": note}) with lock: results.append(r) threads = [] if gateway: threads.append(threading.Thread(target=run, args=(gateway, "默认网关", "gw"), daemon=True)) for item in hosts: host, label = item[0], item[1] kind = item[2] if len(item) > 2 else "dom" note = item[3] if len(item) > 3 else "" threads.append(threading.Thread(target=run, args=(host, label, kind, note), daemon=True)) for t in threads: t.start() for t in threads: t.join(count * 3 + 12) labels = ["默认网关"] + [h[1] for h in hosts] order = {lbl: i for i, lbl in enumerate(labels)} results.sort(key=lambda r: order.get(r["label"], 99)) print() for r in results: if r["avg"] is None: hint = r.get("note") or ("该目标屏蔽 ICMP,不代表你的网络有问题" if r.get("kind") != "intl" else "国际方向不通") print(f" {C.GRY}{r['label']:<15}{C.R}{C.YEL}不通 / 全部超时{C.R} {C.GRY}{hint}{C.R}") continue ms = r["avg"] abroad = r.get("kind") == "intl" col = C.GRY if abroad else (C.GRN if ms < 30 else (C.YEL if ms < 80 else C.RED)) jit = f" 抖动 {r['jitter']:.1f} ms" if r.get("jitter") else "" loss = f" 丢包 {r['loss']:.0f}%" if r["loss"] else "" lenbar = "▎" * max(1, min(20, int(ms / 5) + 1)) note = f" {C.GRY}{r['note']}{C.R}" if r.get("note") else "" print(f" {r['label']:<15}{col}{ms:7.1f} ms{C.R} {C.GRY}{lenbar}{jit}{loss}{C.R}{note}") if do_tcp: host, port = do_tcp.split(":") ms = tcp_latency(host, int(port)) if ms is not None: print(f" {'测速节点 TCP':<13}{C.CYA}{ms:7.1f} ms{C.R} {C.GRY}到测速节点的纯网络往返(最优值){C.R}") # 汇总:只统计国内目标(国际出口常被限速/屏蔽,参考价值不同) dom = [r for r in results if r["avg"] is not None and r.get("kind") != "intl" and r["label"] != "默认网关"] base = dom or [r for r in results if r["avg"] is not None and r["label"] != "默认网关"] avg = statistics.mean(r["avg"] for r in base) if base else None loss = max((r["loss"] for r in base), default=0.0) jit = statistics.mean([r["jitter"] for r in base if r.get("jitter")]) if base else 0.0 intl = next((r for r in results if r.get("kind") == "intl" and r["avg"] is not None), None) return {"avg": avg, "jitter": jit, "loss": loss, "rows": results, "intl_avg": intl["avg"] if intl else None, "intl_loss": intl["loss"] if intl else None} # ──────────────────────────────────────────────────────────────────────────── # 下载测速 # ──────────────────────────────────────────────────────────────────────────── DL_NODES = [ ("腾讯云镜像", "深圳", "https://mirrors.cloud.tencent.com/ubuntu/ls-lR.gz"), ("华为云镜像", "贵州", "https://mirrors.huaweicloud.com/ubuntu/ls-lR.gz"), ("阿里云镜像", "杭州", "https://mirrors.aliyun.com/ubuntu/ls-lR.gz"), ("上海交大镜像", "上海", "https://mirror.sjtu.edu.cn/ubuntu/ls-lR.gz"), ("北大镜像", "北京", "https://mirrors.pku.edu.cn/ubuntu/ls-lR.gz"), ("中科大镜像", "合肥", "https://mirrors.ustc.edu.cn/ubuntu/ls-lR.gz"), ("清华 TUNA", "北京", "https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ls-lR.gz"), ("微信 CDN", "腾讯", "https://dldir1.qq.com/weixin/Windows/WeChatSetup.exe"), ] class DownloadTest: def __init__(self, url: str, conns: int, seconds: float): self.url = url self.conns = conns self.seconds = seconds self.total = 0 self.lock = threading.Lock() self.stop = threading.Event() self.samples: list[float] = [] self.errors = 0 def _worker(self, size: int): while not self.stop.is_set(): try: start = random.randint(0, max(0, size - (1 << 20))) if size > (2 << 20) else 0 req = urllib.request.Request(self.url, headers={ "User-Agent": UA, "Range": f"bytes={start}-", "Accept-Encoding": "identity", "Cache-Control": "no-cache", }) with urllib.request.urlopen(req, timeout=8, context=SSL_CTX) as r: while not self.stop.is_set(): b = r.read(1 << 16) if not b: break with self.lock: self.total += len(b) except Exception: with self.lock: self.errors += 1 if self.stop.is_set(): return time.sleep(0.05) def _size(self) -> int: try: req = urllib.request.Request(self.url, headers={"User-Agent": UA, "Range": "bytes=0-0"}) with urllib.request.urlopen(req, timeout=6, context=SSL_CTX) as r: cr = r.headers.get("Content-Range") or "" m = re.search(r"/(\d+)$", cr) if m: return int(m.group(1)) cl = r.headers.get("Content-Length") return int(cl) if cl else 0 except Exception: return 0 def run(self, live_draw=None) -> dict: size = self._size() workers = [threading.Thread(target=self._worker, args=(size,), daemon=True) for _ in range(self.conns)] t0 = time.perf_counter() for w in workers: w.start() last_total, last_t = 0, t0 ramp_bytes, ramp_t = 0, None peak = 0.0 while True: time.sleep(0.1) now = time.perf_counter() with self.lock: cur = self.total inst = (cur - last_total) * 8 / max(1e-6, now - last_t) / 1e6 last_total, last_t = cur, now self.samples.append(inst) elapsed = now - t0 if elapsed > 1.2: # 跳过慢启动 if ramp_t is None: ramp_bytes, ramp_t = cur, now peak = max(peak, inst) if live_draw: live_draw(inst, elapsed) if elapsed >= self.seconds: break self.stop.set() end = time.perf_counter() for w in workers: w.join(1.5) total = self.total overall = total * 8 / max(1e-6, end - t0) / 1e6 if ramp_t is not None: steady = (total - ramp_bytes) * 8 / max(1e-6, end - ramp_t) / 1e6 else: steady = overall return {"mbps": max(steady, 0.0), "overall": overall, "peak": peak, "mb": total / 1e6, "seconds": end - t0, "samples": list(self.samples), "errors": self.errors, "size": size} def pick_download_node(quick: bool, conns: int, verbose=True): """实测优选下载节点:按顺序短测,够快就停。""" budget = 2.0 if quick else 2.5 max_try = 2 if quick else 4 best = None tried = 0 for name, city, url in DL_NODES: if tried >= max_try: break tried += 1 if verbose: live(f" {C.GRY}试节点 {name}({city})…{C.R}") t = DownloadTest(url, max(2, conns // 2), budget) r = t.run() if verbose: clear_line() ok = r["mbps"] > 0.5 if verbose: col = C.GRN if r["mbps"] >= 50 else (C.YEL if r["mbps"] >= 5 else C.RED) print(f" {C.GRY}节点试测 {name:<10}{C.R}{col}{r['mbps']:8.1f} Mbit/s{C.R}" f" {C.GRY}{city} · {conns // 2} 连接 / {budget:.0f}s{C.R}") if ok and (best is None or r["mbps"] > best[3]["mbps"]): best = (name, city, url, r) if ok and r["mbps"] >= 300: # 已足够快,不再浪费时间 break if best is None: raise RuntimeError("所有下载节点均不可用,请检查网络连接") return best def phase_download(quick: bool, dl_seconds: float, conns: int) -> dict: rule("② 下载速度", C.GRN) name, city, url, probe = pick_download_node(quick, conns) if quick: seconds, threads = 5.0, max(4, conns) else: seconds, threads = dl_seconds, conns t = DownloadTest(url, threads, seconds) w = term_width() def draw(inst, elapsed): left = f" {C.GRY}下载{C.R} {C.B}{C.GRN}{fmt_speed(inst):>13}{C.R}" right = f"{C.GRY}{elapsed:4.1f}/{seconds:.0f}s {threads} 连接 {name}{C.R}" live(f"{left} {right}") r = t.run(draw) clear_line() print(f" 节点 {C.WHT}{name}({city}){C.R} {C.GRY}{threads} 连接 · {r['seconds']:.1f}s · " f"共 {r['mb']:.0f} MB{C.R}") print(f" 实测 {C.B}{C.GRN}{fmt_speed(r['mbps'])}{C.R}" f" = {C.WHT}{r['mbps']/8:.1f} MB/s{C.R} {C.GRY}峰值 {fmt_speed(r['peak'])}{C.R}") sp = sparkline(r["samples"], min(48, w - 20)) print(f" 曲线 {C.GRN}{sp}{C.R} {C.GRY}(左→右 {r['seconds']:.0f}s){C.R}") if r["errors"]: print(f" {C.YEL}注意:{r['errors']} 次连接重试(轻微丢包/节点限速){C.R}") r.update({"node": f"{name}({city})", "url": url}) return r # ──────────────────────────────────────────────────────────────────────────── # 上传测速 # ──────────────────────────────────────────────────────────────────────────── def ookla_cn_upload_targets(timeout: float = 6.0): """从 speedtest.net 取中国境内节点,构造上传地址(联通节点优先)。""" out = [] try: url = "https://www.speedtest.net/api/js/servers?engine=js&limit=20&https=true" req = urllib.request.Request(url, headers={"User-Agent": UA}) with urllib.request.urlopen(req, timeout=timeout, context=SSL_CTX) as r: servers = json.loads(r.read().decode("utf-8", "replace")) except Exception: return out for s in servers: if "China" not in (s.get("country") or ""): continue host = (s.get("host") or "").split(":")[0] port = s.get("port") or 8080 if not host or str(port) == "None": continue sponsor = s.get("sponsor") or "" city = s.get("name") or "" prio = 0 if ("nicom" in sponsor or "联通" in sponsor) else (1 if ("obile" in sponsor or "移动" in sponsor) else 2) out.append((prio, f"{sponsor}({city})", f"http://{host}:{port}/speedtest/upload.php")) out.sort(key=lambda x: x[0]) return [(n, u) for _, n, u in out] UL_FALLBACK = [ ("Cloudflare 全球节点", "https://speed.cloudflare.com/__up"), ] class UploadTest: """流式上传测速。 每轮发送一个固定大小(默认 4MB)的请求,正常收尾再开下一轮 —— 比「发一半直接掐断」稳得多,也不会在测速节点上留下半开连接拖慢后续测量。 只有恰好卡在截止时刻的那一轮会被中断。 """ BODY = 4 << 20 def __init__(self, url: str, conns: int, seconds: float, body: int | None = None): self.url = url self.conns = conns self.seconds = seconds self.body = body or self.BODY self.total = 0 self.lock = threading.Lock() self.stop = threading.Event() self.errors = 0 self.samples: list[float] = [] p = urllib.parse.urlsplit(url) self.https = p.scheme == "https" self.host = p.hostname self.port = p.port or (443 if self.https else 80) self.path = p.path or "/" self.mb = 0.0 def _conn(self): if self.https: return http.client.HTTPSConnection(self.host, self.port, timeout=8, context=SSL_CTX) return http.client.HTTPConnection(self.host, self.port, timeout=8) def _worker(self): chunk = b"\x00" * (1 << 18) while not self.stop.is_set(): c = None try: c = self._conn() c.putrequest("POST", self.path, skip_accept_encoding=True) c.putheader("User-Agent", UA) c.putheader("Content-Type", "application/x-www-form-urlencoded") c.putheader("Content-Length", str(self.body)) c.endheaders() left = self.body clean = True while left > 0: if self.stop.is_set(): clean = False break n = min(left, len(chunk)) c.send(chunk[:n]) left -= n with self.lock: self.total += n if clean: c.getresponse().read() # 正常收尾,不留半开连接 except Exception: with self.lock: self.errors += 1 if self.stop.is_set(): return time.sleep(0.05) finally: try: if c: c.close() except Exception: pass def run(self, live_draw=None) -> dict: workers = [threading.Thread(target=self._worker, daemon=True) for _ in range(self.conns)] t0 = time.perf_counter() for w in workers: w.start() last_total, last_t = 0, t0 peak = 0.0 ramp_bytes, ramp_t = 0, None while True: time.sleep(0.1) now = time.perf_counter() with self.lock: cur = self.total inst = (cur - last_total) * 8 / max(1e-6, now - last_t) / 1e6 last_total, last_t = cur, now self.samples.append(inst) elapsed = now - t0 if elapsed > 1.2: if ramp_t is None: ramp_bytes, ramp_t = cur, now peak = max(peak, inst) if live_draw: live_draw(inst, elapsed) if elapsed >= self.seconds: break self.stop.set() end = time.perf_counter() for w in workers: w.join(1.5) total = self.total overall = total * 8 / max(1e-6, end - t0) / 1e6 steady = (total - ramp_bytes) * 8 / max(1e-6, end - ramp_t) / 1e6 if ramp_t else overall return {"mbps": max(steady, 0.0), "overall": overall, "peak": peak, "mb": total / 1e6, "seconds": end - t0, "samples": list(self.samples), "errors": self.errors} def phase_upload(quick: bool, ul_seconds: float) -> dict | None: rule("③ 上传速度", C.MAG) live(f" {C.GRY}寻找可用上传节点…{C.R}") targets = [] try: targets = ookla_cn_upload_targets() except Exception: pass targets = targets[:2] + UL_FALLBACK budget = 2.5 if quick else 3.0 best = None for name, url in targets[: (2 if quick else 3)]: t = UploadTest(url, 2, budget, body=2 << 20) try: r = t.run() except Exception: continue clear_line() col = C.GRN if r["mbps"] >= 20 else (C.YEL if r["mbps"] >= 3 else C.RED) print(f" {C.GRY}节点试测 {name:<24}{C.R}{col}{r['mbps']:8.1f} Mbit/s{C.R}") if r["mbps"] > 0.3 and (best is None or r["mbps"] > best[2]["mbps"]): best = (name, url, r) if best and best[2]["mbps"] >= 40: break if best is None: print(f" {C.YEL}未找到可用上传测速节点,跳过上传测试{C.R}") return None name, url, _ = best seconds = 4.0 if quick else ul_seconds conns = 4 t = UploadTest(url, conns, seconds) def draw(inst, elapsed): live(f" {C.GRY}上传{C.R} {C.B}{C.MAG}{fmt_speed(inst):>13}{C.R}" f" {C.GRY}{elapsed:4.1f}/{seconds:.0f}s {conns} 连接 {name}{C.R}") r = t.run(draw) clear_line() print(f" 节点 {C.WHT}{name}{C.R} {C.GRY}{conns} 连接 · {r['seconds']:.1f}s · 共 {r['mb']:.0f} MB{C.R}") print(f" 实测 {C.B}{C.MAG}{fmt_speed(r['mbps'])}{C.R}" f" = {C.WHT}{r['mbps']/8:.1f} MB/s{C.R} {C.GRY}峰值 {fmt_speed(r['peak'])}{C.R}") sp = sparkline(r["samples"], min(48, term_width() - 20)) print(f" 曲线 {C.MAG}{sp}{C.R}") if r["peak"] > max(1.0, r["mbps"]) * 2.2: print(f" {C.YEL}说明:上行波动较大(峰值 {fmt_speed(r['peak'])}),公共测速节点常有排队限速," f"结果偏保守{C.R}") r.update({"node": name, "url": url}) return r # ──────────────────────────────────────────────────────────────────────────── # 评级与结论 # ──────────────────────────────────────────────────────────────────────────── TIERS = [2000, 1000, 500, 300, 200, 100, 50, 30, 20, 10, 4] def tier_of(mbps: float) -> str: for t in TIERS: if mbps >= t * 0.8: return f"{t}M 档" if t < 1000 else f"{t//1000}G 档" return f"{mbps:.1f}M(低于 4M)" def grade_latency(ms: float | None, loss: float, jitter: float) -> tuple[str, str, int]: if ms is None: return "不可用", C.RED, 0 if loss >= 20: return "差(丢包严重)", C.RED, 1 if ms < 15 and jitter < 5: return "极佳", C.GRN, 5 if ms < 35 and jitter < 12: return "优秀", C.GRN, 4 if ms < 70: return "良好", C.YEL, 3 if ms < 120: return "一般", C.YEL, 2 return "较差", C.RED, 1 def grade_speed(down: float, up: float | None) -> tuple[str, str]: if up is None: # 只测了下行,评级不虚高 if down >= 300: return "A(上行未测)", C.GRN if down >= 100: return "B(上行未测)", C.YEL if down >= 30: return "C(上行未测)", C.YEL return "D(上行未测)", C.RED score = 0 if down >= 300: score += 3 elif down >= 100: score += 1 if up >= 30: score += 3 elif up >= 10: score += 1 if score >= 6: return "A+", C.GRN if score >= 4: return "A", C.GRN if score >= 2: return "B", C.YEL if score >= 1: return "C", C.YEL return "D", C.RED def stars(n: int) -> str: return "★" * n + "☆" * (5 - n) def advice(down: float, up: float | None, lat: float | None, loss: float) -> list[str]: tips = [] if down >= 300: tips.append(f"下载 {down/8:.0f} MB/s:4K 在线视频可同时跑 {max(1, int(down/25))} 路,游戏更新/大文件秒级") elif down >= 100: tips.append(f"下载 {down/8:.0f} MB/s:日常 4K 流媒体、多人同时在线都没问题") elif down >= 30: tips.append(f"下载 {down/8:.0f} MB/s:1080P/部分 4K 够用,大型下载偏慢") else: tips.append(f"下载仅 {down/8:.1f} MB/s:偏低,建议检查是否被限速/路由器瓶颈或 WiFi 信号") if up is not None: if up >= 30: tips.append(f"上传 {up/8:.1f} MB/s:可稳定 4K 直播推流、大文件快速上传") elif up >= 10: tips.append(f"上传 {up/8:.1f} MB/s:1080P 直播/视频会议够用,网盘上传偏慢") else: tips.append(f"上传 {up/8:.1f} MB/s:偏慢,直播推流会卡,建议确认宽带套餐的上行档位") if lat is not None: if lat < 20: tips.append(f"延迟 {lat:.0f} ms:电竞级,网游/远程桌面手感好") elif lat < 60: tips.append(f"延迟 {lat:.0f} ms:网游正常水平") else: tips.append(f"延迟 {lat:.0f} ms:偏高,检查是否走了 WiFi 或路由器开了 QoS/限速") if loss > 1: tips.append(f"丢包 {loss:.1f}%:有丢包,检查网线/水晶头、路由器负载或联系运营商") if down > 300 and (up or 0) < 10: tips.append("下行很快但上行很慢 —— 这是家用宽带的典型不对称,属正常现象") return tips # ──────────────────────────────────────────────────────────────────────────── # 主流程 # ──────────────────────────────────────────────────────────────────────────── def banner() -> None: w = min(term_width() - 2, 74) title = f" Wpyw 网速测速 · 命令行版 v{VERSION} " print(f"{C.CYA}╭{'─' * (w - 2)}╮{C.R}") pad = " " * max(0, w - 4 - disp_len(title)) print(f"{C.CYA}│{C.R} {C.B}{C.WHT}{title}{C.R}{pad} {C.CYA}│{C.R}") sub = "联通 / 电信 / 移动宽带 · 多线程测速 · 节点实测优选" pad2 = " " * max(0, w - 4 - disp_len(sub)) print(f"{C.CYA}│{C.R} {C.GRY}{sub}{C.R}{pad2} {C.CYA}│{C.R}") print(f"{C.CYA}╰{'─' * (w - 2)}╯{C.R}") def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="wpyw-speedtest", description="Wpyw 网速测速(命令行版)· 零依赖,针对中国大陆宽带优化", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="示例:\n python wpyw-speedtest.py --quick\n python wpyw-speedtest.py --json result.json\n" " python wpyw-speedtest.py --no-upload --duration 12\n") p.add_argument("--quick", action="store_true", help="快速模式(约 15 秒,精度略低)") p.add_argument("--full", action="store_true", help="完整模式(约 60 秒,更接近峰值)") p.add_argument("--duration", type=float, default=8.0, help="下载测试时长秒数(默认 8)") p.add_argument("--upload-duration", type=float, default=6.0, help="上传测试时长秒数(默认 6)") p.add_argument("--conns", type=int, default=8, help="下载并发连接数(默认 8,跑满千兆可设 12-16)") p.add_argument("--no-upload", action="store_true", help="跳过上传测试") p.add_argument("--no-ping", action="store_true", help="跳过延迟测试") p.add_argument("--json", metavar="文件", help="把结果写入 JSON 文件(- 表示打印到标准输出)") p.add_argument("--no-color", action="store_true", help="关闭彩色输出") p.add_argument("--ascii", action="store_true", help="用纯 ASCII 符号(老终端/重定向时更安全)") p.add_argument("--version", action="version", version=f"Wpyw 网速测速 v{VERSION}") return p def main(argv=None) -> int: args = build_parser().parse_args(argv) global FILL, EMPTY, SPARK, LIVE_OK if args.ascii: FILL, EMPTY, SPARK = "#", ".", "_.-~=+*#" try: LIVE_OK = bool(sys.stdout.isatty()) except Exception: LIVE_OK = False try: sys.stdout.reconfigure(errors="replace") # 老代码页下也不因生僻字崩掉 except Exception: pass color_ok = (not args.no_color) and (enable_vt() or os.environ.get("FORCE_COLOR") == "1") if os.environ.get("FORCE_COLOR") == "1": color_ok = True C.setup(color_ok) quick = args.quick and not args.full banner() # ── 基本信息(并发取 IP/运营商,最多 3 秒)──────────────────────────── ip_box: dict = {} gw_box: dict = {} def _ip(): ip_box.update(public_ip_info()) def _gw(): gw_box["gw"] = default_gateway() t1 = threading.Thread(target=_ip, daemon=True) t2 = threading.Thread(target=_gw, daemon=True) t1.start() t2.start() mode = "快速" if quick else ("完整" if args.full else "标准") t_start = time.time() print() row("开始时间", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) row("测试模式", f"{mode} {C.GRY}下载 {args.duration if not quick else 5:.0f}s / " f"{args.conns} 连接 上传 {0 if args.no_upload else (args.upload_duration if not quick else 4):.0f}s{C.R}") row("本机地址", local_ip()) t1.join(4) t2.join(4) gw = gw_box.get("gw") pub = ip_box if pub.get("ip"): row("公网地址", f"{C.WHT}{pub['ip']}{C.R}" + (f" {C.GRY}{pub.get('region') or ''}{C.R}" if pub.get("region") else "")) isp = isp_short(pub.get("isp") or pub.get("region")) col = C.GRN if isp == "联通" else (C.CYA if isp in ("电信", "移动") else C.YEL) row("运营商", f"{col}{C.B}{isp}{C.R}" + (f" {C.GRY}{(pub.get('isp') or '').strip()}{C.R}" if pub.get("isp") else "")) else: row("公网地址", f"{C.GRY}查询失败(不影响测速){C.R}") if gw: row("默认网关", gw) result: dict = { "version": VERSION, "time": datetime.now().isoformat(timespec="seconds"), "mode": mode, "host": platform.node(), "local_ip": local_ip(), "public_ip": pub.get("ip"), "isp": isp_short(pub.get("isp") or pub.get("region")), "region": pub.get("region"), "gateway": gw, } # ── ① 延迟 ────────────────────────────────────────────────────────── lat = None if not args.no_ping: dns = local_dns() hosts = [] if dns: hosts.append((dns, "本机 DNS", "dom", "运营商分配的解析服务器")) hosts += [ ("223.5.5.5", "阿里云 DNS", "dom", ""), ("119.29.29.29", "腾讯 DNS", "dom", ""), ("180.76.76.76", "百度 DNS", "dom", ""), ("8.8.8.8", "国际出口", "intl", "国际线路,仅供参考(常被限速)"), ] if quick: keep = ("本机 DNS", "阿里云 DNS", "国际出口") hosts = [h for h in hosts if h[1] in keep] if not any(h[1] == "阿里云 DNS" for h in hosts): hosts.insert(0, ("223.5.5.5", "阿里云 DNS", "dom", "")) lat = phase_latency(hosts, gw, quick, "mirrors.cloud.tencent.com:443") col = C.GRN if (lat["avg"] or 999) < 35 else (C.YEL if (lat["avg"] or 999) < 80 else C.RED) g, gc, gs = grade_latency(lat["avg"], lat["loss"], lat["jitter"]) avg_txt = f"{lat['avg']:.1f} ms" if lat["avg"] is not None else "N/A" print() print(f" 国内平均延迟 {col}{C.B}{avg_txt}{C.R}" f" 抖动 {C.WHT}{lat['jitter']:.1f} ms{C.R}" f" 丢包 {C.WHT}{lat['loss']:.1f}%{C.R}" f" {gc}{stars(gs)} {g}{C.R}") if lat.get("intl_avg") is not None: print(f" {C.GRY}国际出口 8.8.8.8 {lat['intl_avg']:.0f} ms" f"(丢包 {lat['intl_loss']:.0f}%)—— 国际方向,不计入上面的均值{C.R}") result["latency"] = {k: v for k, v in lat.items() if k != "rows"} result["latency"]["rows"] = [{k: r.get(k) for k in ("label", "avg", "jitter", "loss", "kind")} for r in lat["rows"]] # ── ② 下载 ────────────────────────────────────────────────────────── try: dl = phase_download(quick, args.duration, max(2, args.conns)) except Exception as e: print(f"\n {C.RED}下载测速失败:{e}{C.R}") return 2 result["download"] = {k: v for k, v in dl.items() if k != "samples"} result["download"]["samples"] = [round(x, 2) for x in dl["samples"]] # ── ③ 上传 ────────────────────────────────────────────────────────── ul = None if not args.no_upload: try: ul = phase_upload(quick, args.upload_duration) except Exception as e: print(f" {C.YEL}上传测速失败:{e}{C.R}") if ul: result["upload"] = {k: v for k, v in ul.items() if k != "samples"} result["upload"]["samples"] = [round(x, 2) for x in ul["samples"]] # ── ④ 结论 ────────────────────────────────────────────────────────── rule("④ 结论", C.YEL) down = dl["mbps"] up = ul["mbps"] if ul else None lat_avg = lat["avg"] if lat else None loss = lat["loss"] if lat else 0.0 t_down, t_up = tier_of(down), (tier_of(up) if up else "未测") line = (f" 下载 {C.B}{C.GRN}{fmt_speed(down)}{C.R} {C.GRY}(≈{t_down}){C.R}" f" 上传 {C.B}{C.MAG}{(fmt_speed(up) if up else '未测')}{C.R} {C.GRY}(≈{t_up}){C.R}") print("\n" + line) if lat_avg is not None: print(f" 延迟 {C.B}{C.WHT}{lat_avg:.1f} ms{C.R} 抖动 {C.WHT}{lat['jitter']:.1f} ms{C.R}" f" 丢包 {C.WHT}{loss:.1f}%{C.R}") print(f" 下载等效 {C.WHT}{down/8:.1f} MB/s{C.R}" + (f" 上传等效 {C.WHT}{up/8:.1f} MB/s{C.R}" if up else "")) g, gc = grade_speed(down, up) print(f"\n 综合评级 {gc}{C.B}{g}{C.R} " f"{C.GRY}(依据:下行 {down:.0f} Mbps / 上行 {(up or 0):.0f} Mbps / 延迟 " f"{(f'{lat_avg:.0f} ms' if lat_avg is not None else '未测')}){C.R}") print() for tip in advice(down, up, lat_avg, loss): print(f" {C.CYA}·{C.R} {tip}") result["tier"] = {"download": t_down, "upload": t_up} result["grade"] = g result["advice"] = advice(down, up, lat_avg, loss) result["elapsed"] = round(time.time() - t_start, 1) print() rule() print(f" {C.GRY}总耗时 {result['elapsed']:.0f} 秒 · 测速节点与结果已记录" f"{' · 结果已写入 ' + args.json if args.json and args.json != '-' else ''}{C.R}") print() if args.json: payload = json.dumps(result, ensure_ascii=False, indent=2) if args.json == "-": # 机器可读输出固定用 UTF-8,不受控制台代码页影响 try: sys.stdout.flush() sys.stdout.buffer.write(payload.encode("utf-8") + b"\n") sys.stdout.buffer.flush() except Exception: print(payload) else: try: with open(args.json, "w", encoding="utf-8") as f: f.write(payload) print(f" {C.GRN}JSON 已保存:{os.path.abspath(args.json)}{C.R}\n") except Exception as e: print(f" {C.RED}JSON 写入失败:{e}{C.R}\n") return 0 if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: clear_line() print(f"\n{C.YEL}已取消{C.R}") sys.exit(130)