说实话,“用Python扫描周围WiFi”这个需求,我最早是在办公室被逼出来的。当时工位离路由器远,WiFi信号常年两格,我想知道附近到底有哪些网络、哪个信号更好、哪个频段更空,好决定是自己加路由还是蹭楼下那个信号还行的5G频段。结果一搜,网上全是“破解WiFi密码”之类的教程,正经讲扫描识别的少之又少,还动不动就要装各种驱动级库。后来我把Windows、macOS、Linux三套系统都试了一遍,踩了不少坑,才攒出这套稳定可靠的跨平台方案。
这篇内容适合这几类朋友:想用Python做网络自动化运维的、在搞物联网或室内定位项目的、以及纯粹好奇自己周围无线环境到底是什么样的人。读完你不仅能拿到可直接运行的跨平台扫描器代码,还能理解它背后的原理——比如为什么没有现成的Python库、系统命令输出的差异在哪、信号强度怎么换算成距离。不碰密码、不碰攻击,只聊合法的WiFi环境感知。
1. 为什么没有现成的Python库?从底层到系统工具的思路拆解
1.1 Python里为什么找不到“扫描WiFi”的库
先说结论:Python标准库里确实没有扫描WiFi的模块,第三方库也几乎没有完全跨平台且稳定好用的。这不是Python生态不行,而是WiFi扫描这件事本身就绕不开操作系统。
WiFi扫描的本质,是无线网卡切换到监听模式,主动发送Probe Request或被动接收各AP发出的Beacon帧,然后把结果上报给驱动,驱动再交给操作系统的网络管理服务。从网卡到驱动到系统服务,每一层都是C语言写的,接口各不相同。Windows有WlanAPI,macOS有CoreWLAN,Linux有nl80211,Python要直接调这些接口,要么写C扩展,要么用ctypes去怼,工作量巨大不说,还容易因为权限和系统版本差异直接崩掉。
所以业内最常见的做法,不是去硬调底层API,而是“曲线救国”:调用系统自带的命令行工具,解析它们的文本输出。命令行工具是官方维护的,系统版本更新它们也跟着更新,稳定性和兼容性都由系统厂商保证。Python只需要负责拼命令、读输出、写正则解析,干净又省事。
1.2 三大平台的“原生”扫描命令
各平台自带的WiFi扫描命令如下,记住这三个,后面全是在这三条命令基础上做文章。
| 平台 | 命令 | 输出格式 | 是否需管理员权限 |
|---|---|---|---|
| Windows | netsh wlan show networks mode=bssid | 文本表格 | 否,普通用户可执行 |
| macOS | /System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -s | 文本表格 | 否,但终端需授权位置权限 |
| Linux | nmcli -f IN-USE,SSID,BSSID,SIGNAL,SECURITY dev wifi list | 文本表格 | 否,需要NetworkManager服务 |
| Linux | iw dev wlan0 scan | 原始字段块 | 是,需要root |
这里有个关键选择逻辑:能用普通权限完成的事,绝不往sudo上靠。因为一旦依赖管理员权限,你的脚本在别人机器上就跑不起来了,自动化运维的通用性就没了。Windows的netsh和Linux的nmcli都不需要额外权限,macOS的airport虽然路径藏在私有框架里,但也不需要sudo,只是首次运行要给你的终端App开“定位服务”权限——这个坑后面细说。
2. 平台命令逐一拆解:输出长什么样、怎么解析最稳
2.1 Windows:netsh输出的中英文坑
Windows上跑netsh wlan show networks mode=bssid,输出会按“网络”分组,每个网络下面列出BSSID、信号、认证方式等。英文系统的字段名是Signal,中文系统是信号,这个差异足以让新手懵半小时。
我的做法是尽可能不依赖字段名,而是按“SSID+冒号”定位。一条记录以SSID x : name开头,紧接着的几行都是它的属性。解析时先切块,再用正则匹配每个块的BSSID和信号字段。
import subprocess import re def scan_windows(): result = subprocess.run( ["netsh", "wlan", "show", "networks", "mode=bssid"], capture_output=True, text=True, encoding="gbk", errors="replace" ) output = result.stdout # 按SSID行切分 blocks = re.split(r"SSID \d+ :", output)[1:] nets = [] for block in blocks: block_lines = block.strip().splitlines() ssid_line = block_lines[0].strip() if block_lines else "" # 注意中文系统会输出“信号 : 80%”,英文输出“Signal : 80%” signal_match = re.search(r"(?:信号|Signal)\s*:\s*(\d+)", block) bssid_match = re.search(r"BSSID\s*\d*\s*:\s*([0-9a-fA-F:]+)", block) security_match = re.search(r"(?:身份验证|Authentication)\s*:\s*(.+)", block) nets.append({ "ssid": ssid_line, "signal": signal_match.group(1) if signal_match else None, "bssid": bssid_match.group(1) if bssid_match else None, "security": security_match.group(1).strip() if security_match else None, }) return nets编码这里我用了gbk加errors="replace"。Windows中文版的控制台默认编码是GBK,不指定的话subprocess拿到的字节流按UTF-8解码会乱码。但也不能写死,有些环境可能是cp936,所以errors="replace"保证即使遇到个别特殊字符也不会整个脚本崩溃。
还有个细节:netsh的扫描结果是系统缓存的,不是实时扫描。调用后系统会发起一次扫描,但结果有一定延迟。实测刚开机或刚重新连接WiFi后立刻查,经常是空列表,等几秒再查就出来了。
2.2 macOS:airport命令与那恼人的权限
macOS的WiFi工具藏在私有框架里,正确路径是:
/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -s这条命令打印的列包括SSID、BSSID、RSSI、CHANNEL、HT、CC、SECURITY。RSSI是负数,比如-45 dBm,数字越大(越接近0)信号越强。这和Windows用百分比表示完全是两套体系,跨平台时一定要统一换算。
import subprocess import re AIRPORT = ("/System/Library/PrivateFrameworks/Apple80211.framework/" "Versions/Current/Resources/airport") def scan_macos(): result = subprocess.run([AIRPORT, "-s"], capture_output=True, text=True, timeout=15) lines = result.stdout.strip().splitlines()[1:] # 跳过表头 nets = [] for line in lines: parts = line.split() if len(parts) < 3: continue # SSID可能含空格,需要从前面找;最后一列是安全类型 security = parts[-1] ssid = " ".join(parts[:-6]) if len(parts) > 6 else parts[0] bssid = parts[-6] rssi = parts[-5] channel = parts[-4] nets.append({ "ssid": ssid, "bssid": bssid, "rssi": rssi, "channel": channel, "security": security, }) return netsmacOS解析里最阴间的就是SSID带空格。比如TP-LINK_5G 2.4G这种名字,split()会把单词全切开,如果你只取固定下标,SSID永远是残缺的。所以要从右往左定位固定列:倒数第一个是SECURITY,倒数第二个是CC,倒数第三个是HT,倒数第四是CHANNEL,倒数第五是RSSI,倒数第六是BSSID,剩下的全是SSID。倒着解析,这是处理不定长SSID的唯一稳妥姿势。
权限问题很隐蔽:新版macOS(大概是Catalina之后)要求调用airport的终端进程必须有“位置服务”权限。如果不授权,命令不会报错,只是输出为空,或者只显示当前连接的网络。解决方法是去系统设置 -> 隐私与安全性 -> 定位服务,找到你的终端App(比如iTerm、Terminal),把开关打开。这个坑不写出来,你能排查一下午。
2.3 Linux:nmcli与iw的双方案
Linux是三个平台里扫描选项最丰富的,但也是坑最多的。桌面发行版一般都有NetworkManager,用nmcli最省事:
nmcli -f IN-USE,SSID,BSSID,SIGNAL,SECURITY,CHAN dev wifi list输出是整齐的表格,SSID带空格也不影响,因为这是通过列宽对齐的。SIGNAL是0-100的百分比,和Windows一致;IN-USE里带*表示当前正在连接的网络。
import subprocess def scan_linux_nmcli(): result = subprocess.run( ["nmcli", "-f", "IN-USE,SSID,BSSID,SIGNAL,SECURITY,CHAN", "dev", "wifi", "list"], capture_output=True, text=True, timeout=15 ) lines = result.stdout.strip().splitlines()[1:] nets = [] for line in lines: # 用真正意义上的空白分割,先处理表头行格式 parts = line.split() if len(parts) < 4: continue in_use = "*" in parts[0] ssid = parts[1] bssid = parts[2] if len(parts) > 2 else None signal = parts[3] if len(parts) > 3 else None security = parts[-2] if len(parts) > 4 else None chan = parts[-1] if len(parts) > 5 else None nets.append({ "ssid": ssid, "bssid": bssid, "signal": signal, "security": security, "channel": chan, "in_use": in_use, }) return nets但服务器或精简系统上可能没有NetworkManager,这时候用iw:
sudo iw dev wlan0 scan | grep -E "SSID|signal:|freq:"iw的输出是字段块,一行BSS 44:44:44:xx:xx开头,下面跟着freq:、signal:、SSID:等。它的SSID是十六进制编码的,如果SSID包含不可打印字符,会显示成SSID: 5c5450...这种Hex串,解析时需要注意解码。
nmcli和iw怎么选?我的建议:优先nmcli,普通用户权限,输出规整,字段齐全。iw留给需要频段、速率等底层信息的场景。还有一点,用iw时别在脚本里直接sudo,应该让脚本运行者用sudo执行整个脚本,或者在systemd服务里配好权限,否则每次调用都弹密码框,自动化直接废了。
3. 写一个跨平台扫描器:统一接口封装
3.1 数据模型:一条扫描记录包含什么
三个平台输出的字段不完全一样:Windows有百分比信号没有RSSI,macOS有RSSI没有百分比,Linux的nmcli两者都变相给了(SIGNAL百分比),iw给出的是RSSI。要统一,先定数据模型。
from dataclasses import dataclass, field from typing import Optional @dataclass class WifiScanResult: ssid: Optional[str] = None bssid: Optional[str] = None signal_percent: Optional[int] = None # 统一换算成0-100 rssi: Optional[int] = None # 保留原始dBm数值 channel: Optional[int] = None security: Optional[str] = None in_use: bool = False @property def signal_level(self) -> str: """把信号强度映射为直观等级,方便人看""" if self.signal_percent is not None: if self.signal_percent >= 70: return "强" elif self.signal_percent >= 40: return "中" elif self.signal_percent >= 20: return "弱" return "极弱" if self.rssi is not None: if self.rssi >= -50: return "强" elif self.rssi >= -65: return "中" elif self.rssi >= -80: return "弱" return "极弱" return "未知"双字段设计是为了保留原始信息。signal_percent用于跨平台排序比较,rssi用于需要精确信号强度的场景,比如后面的距离估算。换算规则很简单:RSSI范围一般在-30到-95 dBm之间,把-30映射为100,-95映射为0即可:
def rssi_to_percent(rssi: int) -> int: """粗略把RSSI(dBm)映射为0-100百分比""" clamped = max(-95, min(-30, rssi)) return int((rssi + 95) / 65 * 100)这是个工程近似,不是标准定义,但用来排序足够了。
3.2 抽象接口与各平台实现
有了数据模型,再定义一个抽象基类,让每个平台实现自己的扫描逻辑:
import platform from abc import ABC, abstractmethod class WifiScanner(ABC): @abstractmethod def scan(self) -> list[WifiScanResult]: """扫描周围WiFi,返回结果列表""" class WindowsScanner(WifiScanner): def scan(self): raw = scan_windows() # 复用上一节函数 return [WifiScanResult( ssid=n["ssid"], bssid=n["bssid"], signal_percent=int(n["signal"]) if n["signal"] else None, security=n["security"], ) for n in raw] class MacOSScanner(WifiScanner): def scan(self): raw = scan_macos() results = [] for n in raw: try: rssi = int(n["rssi"]) percent = rssi_to_percent(rssi) except (TypeError, ValueError): rssi, percent = None, None results.append(WifiScanResult( ssid=n["ssid"], bssid=n["bssid"], rssi=rssi, signal_percent=percent, channel=int(n["channel"]) if n["channel"] else None, security=n["security"], )) return results class LinuxScanner(WifiScanner): def scan(self): raw = scan_linux_nmcli() return [WifiScanResult( ssid=n["ssid"], bssid=n["bssid"], signal_percent=int(n["signal"]) if n["signal"] else None, channel=int(n["channel"]) if n["channel"] else None, security=n["security"], in_use=n["in_use"], ) for n in raw]抽象层的价值在于,业务代码不需要关心平台差异。比如你想打印所有信号强度在70以上的网络,只需要对着WifiScanResult操作,不用写三套if-else。
3.3 工厂函数与模拟器
选择实现类的逻辑,扔进一个工厂函数:
def get_scanner() -> WifiScanner: system = platform.system() if system == "Windows": return WindowsScanner() elif system == "Darwin": return MacOSScanner() elif system == "Linux": return LinuxScanner() else: raise RuntimeError(f"不支持的平台: {system}")但这里有个实际问题:你要是手上没有对应平台的机器,代码根本没法调试。所以我强烈建议顺手写一个MockScanner,生成一份模拟数据。
import random import time class MockScanner(WifiScanner): """随机生成模拟WiFi数据,方便在没有硬件环境时测试""" def scan(self): fake_bssids = [f"AA:BB:CC:{i:02X}:{i+1:02X}:{i+2:02X}" for i in range(8)] fake_ssids = ["OfficeWiFi", "Home_5G", "TP-LINK_1234", "Xiaomi_ABCD", "ChinaNet-8888", "Guest-free", "WorkLab", "RouterA"] results = [] for i, (ssid, bssid) in enumerate(zip(fake_ssids, fake_bssids)): results.append(WifiScanResult( ssid=ssid, bssid=bssid, signal_percent=random.randint(15, 95), channel=random.choice([1, 6, 11, 36, 149]), security=random.choice(["WPA2", "WPA3", "OPEN", "WPA2-Enterprise"]), )) return results这样在没有无线网卡的服务器上,也能跑通整条处理链路,等部署到目标机器时再把工厂函数切换回真实扫描器。这个习惯帮我省了不知道多少远程调试的时间。
4. 进阶玩法:信号换算、自动选网、可视化和Web展示
4.1 从信号强度估算距离:对数路径损耗模型
扫描到的是信号强度RSSI,但很多时候你更想知道“这个路由器离我多远”。信号强度与距离之间有近似关系,最常用的是对数距离路径损耗模型:
[ RSSI = P_{tx} - 10 \times n \times \log_{10}(d) ]
其中 (P_{tx}) 是1米处的参考信号强度(通常取-40 dBm到-45 dBm之间),(n) 是路径损耗指数(室内通常取2.5到3.5之间,空旷环境取2左右)。反过来,由RSSI推算距离:
[ d = 10^{\frac{P_{tx} - RSSI}{10 \times n}} ]
写成Python:
import math def rssi_to_distance(rssi: int, tx_power: float = -40, n: float = 2.8) -> float: """将RSSI(dBm)估算为距离(米)""" if rssi == 0: return -1 return round(10 ** ((tx_power - rssi) / (10 * n)), 2)这个公式算出来是参考值,误差通常在30%以上。因为实际信号还受墙壁、人体、金属物体遮挡影响,同距离下路由器天线方向不同,RSSI能差出10dBm。所以它只适合做粗粒度判断:比如“哪个AP离我更近”“我走到哪片区域信号更好”,不适合做精确定位。要精确定位得用指纹定位法,那是另一个大工程,后面有机会单独写。
4.2 自动筛选并连接最强的WiFi:合法合理的使用场景
扫描结果有了,你可能会想:能不能让它自动连上信号最强的那个网络?可以,前提是只在你自己的网络、或你有权管理的网络范围内用,比如让家里老人用的电脑开机自动连信号最好的那台路由器。
Windows连接命令:
netsh wlan connect ssid=名称 name=名称macOS连接命令:
networksetup -setairportnetwork en0 "SSID" "密码"Linux连接命令:
nmcli dev wifi connect "SSID" password "密码"Python里直接subprocess调用这些命令,配合前面扫描出来的最强信号,就能实现“自动切换”。我实际做过的场景是:办公室有两台AP,一个叫Office-2.4G,一个叫Office-5G,5G信号弱但速度快,2.4G信号强但速度慢。脚本每5分钟扫描一次,如果5G信号低于-75 dBm就切到2.4G,恢复正常再切回来。省事又稳定。
接口上可以在WifiScanner下再加一个连接方法:
class WindowsScanner(WifiScanner): def connect(self, ssid: str) -> bool: cmd = ["netsh", "wlan", "connect", f"ssid={ssid}", f"name={ssid}"] result = subprocess.run(cmd, capture_output=True, text=True) return result.returncode == 0不过连接操作对权限和系统状态的依赖更大,Windows某些精简系统会拒绝netsh直接连接,macOS还会弹系统授权框,没法全自动。别指望做成完全无人值守,半自动就够了。
4.3 信号可视化:用散点图直接“看”到环境
文字列表不直观,我习惯把扫描结果画成图。横轴是信道,纵轴是信号强度,每个点是一个AP,点的大小代表信号强度。这样一眼就能看出哪个信道拥挤、哪个信道干净,对选路由器信道特别有用。
import matplotlib.pyplot as plt def plot_wifi_scan(results: list[WifiScanResult], save_path="wifi_scan.png"): fig, ax = plt.subplots(figsize=(10, 6)) for r in results: color = "green" if r.signal_percent and r.signal_percent >= 60 else \ "orange" if r.signal_percent and r.signal_percent >= 35 else "red" ax.scatter(r.channel or 0, r.signal_percent or 0, s=(r.signal_percent or 10) * 5, alpha=0.7, color=color) ax.annotate(r.ssid or "<hidden>", (r.channel or 0, r.signal_percent or 0), xytext=(5, 5), textcoords="offset points", fontsize=8) ax.set_xlabel("信道") ax.set_ylabel("信号强度 (%)") ax.set_title("周边WiFi扫描可视化") ax.grid(True, linestyle="--", alpha=0.5) plt.tight_layout() plt.savefig(save_path, dpi=150)如果你还想做“走出房间看信号变化”,可以配合手动记录坐标点,把结果叠加成热力图。那种情况用folium画地图热力层更合适,适合户外的WiFi测绘,室内精度不行。
4.4 局域网Web展示:让手机也能看扫描结果
不想在电脑前看?用Flask搭一个小服务,把扫描结果以JSON接口暴露出来,前端页面定时刷新。手机上打开同一局域网的地址就能看。写这个不是为了炫技,而是因为很多时候人坐在工位上,WiFi信息在另一台台式机上,直接开网页方便多了。
from flask import Flask, jsonify, render_template_string import threading app = Flask(__name__) scanner = get_scanner() HTML = """ <!DOCTYPE html> <html> <head><title>WiFi Scanner</title> <meta charset="utf-8"></head> <body> <h2>周围WiFi</h2> <table border="1" cellpadding="6" id="tbl"> <tr><th>SSID</th><th>信号</th><th>信道</th><th>安全</th></tr> </table> <script> function refresh() { fetch('/api/wifi').then(r => r.json()).then(data => { const tb = document.getElementById('tbl'); tb.innerHTML = '<tr><th>SSID</th><th>信号</th><th>信道</th><th>安全</th></tr>'; data.forEach(n => { const tr = document.createElement('tr'); tr.innerHTML = `<td>${n.ssid||'(隐藏)'}</td><td>${n.signal_percent||'-'}</td><td>${n.channel||'-'}</td><td>${n.security||'-'}</td>`; tb.appendChild(tr); }); }); } setInterval(refresh, 5000); refresh(); </script> </body> </html> """ @app.route("/") def index(): return render_template_string(HTML) @app.route("/api/wifi") def api_wifi(): results = scanner.scan() return jsonify([r.__dict__ for r in results]) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)注意把host设为0.0.0.0,这样局域网内其他设备才能访问。如果不想引入Flask依赖,也可以用Python内置的http.server自己拼JSON,但代码会更啰嗦,Flask确实是最省事的选择。
5. 那些文档里不会写的坑:权限、编码、静默失败
5.1 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| Windows扫描结果为空 | 系统未发起扫描或网卡被禁用 | 先手动在系统设置里关闭再打开WiFi,等3秒再跑脚本 |
| macOS输出为空但命令能执行 | 终端没有位置服务权限 | 系统设置 -> 隐私与安全性 -> 定位服务,给终端开权限 |
| Linux nmcli提示“Unknown command” | NetworkManager未安装或未运行 | systemctl start NetworkManager,或改用iw方案 |
| 解析的SSID乱码 | Windows控制台编码不是GBK | subprocess加errors="replace",或先转成UTF-8 |
| 信号跳变剧烈 | WiFi扫描是瞬时结果,受干扰大 | 连续扫描3次取中位数,不要用单次值决策 |
| macOS升级后airport路径失效 | 苹果每年都在调整私有框架 | 升级后先验证路径,失效就用wdutil info替代 |
这里最值得展开的是“信号跳变”。WiFi信号本身就是波动的,站在同一个位置,隔几秒扫描一次,RSSI波动5-10dBm是家常便饭。如果你做自动切换网络,单次低于阈值就切换,会反复“横跳”。解决办法是连续采集几次取中位数,或者用滞回区间——比如低于-75才切走,高于-70才切回来,中间地带维持现状。这个经验是从实际自动漫游脚本里踩出来的,一开始没加滞回逻辑,结果整个办公室的人看我在那反复重连WiFi。
5.2 几个独家避坑心得
第一,别用高频扫描。有些新手会把扫描套进一个while True里,每秒钟刷一次。无线网卡在扫描时会短暂脱离当前连接去监听其他信道,扫描太频繁会导致当前连接卡顿甚至掉线。实测下来,扫描间隔最小建议5秒,做持续性监控用10-30秒更合理。
第二,注意隐藏SSID。很多路由器会开启“隐藏网络”,这类AP在扫描结果里SSID显示为空。如果你把SSID作为字典的键,这些网络会互相覆盖,最后只剩一个。处理办法是优先用BSSID做标识,SSID只做展示。
第三,安全类型字段别轻视。扫描结果里security显示为OPEN的热点,不代表可以随便连,很多是商场的开放认证网关,连上后会跳转网页给你限速和广告。区分“开放但需Web认证”和“真正无限制”需要额外探测,这个水比较深,用到再说。
第四,关于一堆“工具箱”“破解字典”类内容,我的态度很明确:扫描识别是合法且有用的网络管理手段,但基于扫描去做暴力破解、未授权连接,既违反法律也违背技术伦理。写工具时心里要有边界,只对自己拥有或有权测试的网络做操作。本文所有连接示例都建立在你有合法凭证的前提下。
再说一个跨平台开发的小习惯:把平台相关的命令字符串全部集中放在一个配置模块里,不要散落在业务代码中。我吃过一次亏,在Windows上开发调试好的脚本,部署到Linux服务器上,结果所有命令散在七八个函数里,花了一下午才改完。
一点收尾的体会
这套跨平台WiFi扫描器写完之后,我在三个平台分别跑了一段时间,最大的感受是:代码本身不难,难的是对不同系统“脾性”的理解。netsh的中文输出、airport的私有路径更新、Linux的NetworkManager依赖,每一个都是只有实际踩过才知道的经验。
后来我又在这个基础上扩展了日志记录功能:每次扫描结果追加到SQLite,积累一周数据就能看出办公室AP的信号变化规律,哪个时间段信道拥堵、什么时候网速最差,一目了然。如果你也想做类似的事情,建议从简单地把结果存成JSON开始,先跑通链路,再去琢磨复杂的分析功能。
这个内容后续还可以往两个方向扩展:一是把扫描器做成常驻服务,通过消息队列把数据推给监控平台;二是接入室内定位,用多个AP的信号强度做指纹匹配,解决“找不到工位”这种小问题。哪个方向更值得研究,看你自己的使用场景,扫描数据这块的基础打牢了,后面怎么做都顺。