Files
wpyw-speedtest/wpyw-speedtest.py
T

1078 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)