前言
这篇讲的是页面解析的技术面:给定一个内容里含有ip:port的页面,怎么把地址抽出来。这是一个很典型的「结构化抽取」练习,和「去哪找代理」是两个问题。标题里说「爬取代理IP」,很容易让人以为重点是「找到免费代理站然后抓下来」——本文不这么做,也不会给出任何真实站点的地址:示例里的 HTML 全部是我自己构造的本地字符串,你在自己的编辑器里就能跑。
有必要把来源问题说清楚,因为它直接决定这篇要不要写。公开的「免费代理」来源不可信,原因不是「质量差」这么简单:
- 你根本不知道流量经过谁。一个代理的运营者可以看到你通过它发出的全部明文请求,包括你带的 Cookie、Token、以及请求内容,这等于把一个陌生人插进了你的请求链路。
- 有些「免费代理」本身就是蜜罐,专门用来收集谁在用代理、以及通过代理访问了什么。
- 有些地址根本不做转发,而是把请求直接发出去再回来,你的真实来源 IP 就这么泄露了。
- 还有一类会在响应里注入内容,做中间人式的篡改。
所以即便你只是想练手,也请把练习范围限定在自己构造的页面上;真要用于生产,来源必须是你有权使用的、可以追溯的(自建代理、公司出口、付费代理服务)。
本机没有 Python 解释器,也没有装requests等第三方库,本文代码未在本机运行验证,只作逐行人工推演;用到的标准库(html.parser、re、ipaddress)都是随 Python 一起分发的,第三方库的部分会注明需要先pip install。
一、先定任务边界
先说清楚这篇的输入输出:
| 项 | 内容 |
|---|
| 输入 | 一段含有地址列表的 HTML 文本(本文中为本地字符串) |
| 输出 | 一个去重后的host:port字符串列表 |
| 不做的事 | 不做真实网络请求、不访问任何真实站点、不校验地址是否可用 |
| 前提 | 页面来源是你有权解析的内容 |
这里要特别强调「不做的事」:本文只到「把文本里的地址抽出来」为止。抽出来的地址一律视为不可信,能不能用、要不要用,是另一个必须单独决策的问题。
二、用标准库html.parser解析本地 HTML
标准库自带一个 HTML 解析器html.parser,它的用法是:继承HTMLParser,重写handle_starttag、handle_data、handle_endtag这几个钩子,解析器在扫到对应结构时回调你。它不需要装任何东西,这是它最大的优点。
先构造一段页面。这是我自己写的演示 HTML,表格里混了正常地址和几个不合法的值,用来检验后面抽取逻辑的健壮性:
# 适用于 Python 3.8+
DEMO_HTML = """
<html><body>
<table>
<tr><th>IP</th><th>端口</th><th>位置</th></tr>
<tr><td>192.0.2.10</td><td>8080</td><td>演示机房 A</td></tr>
<tr><td>192.0.2.11</td><td>3128</td><td>演示机房 B</td></tr>
<tr><td>198.51.100.7</td><td>8888</td><td>演示机房 C</td></tr>
<tr><td>999.1.1.1</td><td>80</td><td>非法 IP,应被丢弃</td></tr>
<tr><td>203.0.113.9</td><td>70000</td><td>端口越界,应被丢弃</td></tr>
</table>
</body></html>
"""192.0.2.0/24、198.51.100.0/24、203.0.113.0/24是 RFC 5737 专门保留给文档示例的网段,不会指向真实主机——这正是你写示例时该用的地址段。
接下来写解析器,把表格的每一行读出来:
# 适用于 Python 3.8+
from html.parser import HTMLParser
class TableRows(HTMLParser):
"""把表格解析成 [[单元格, 单元格, ...], ...]"""
def __init__(self):
super().__init__()
self.rows = []
self._row = None
self._cell = None
def handle_starttag(self, tag, attrs):
if tag == "tr":
self._row = []
elif tag in ("td", "th") and self._row is not None:
self._cell = []
def handle_data(self, data):
if self._cell is not None:
self._cell.append(data)
def handle_endtag(self, tag):
if tag in ("td", "th") and self._cell is not None:
if self._row is not None:
self._row.append("".join(self._cell).strip())
self._cell = None
elif tag == "tr" and self._row is not None:
self.rows.append(self._row)
self._row = None
parser = TableRows()
parser.feed(DEMO_HTML) # 解析本地字符串,没有任何网络请求
parser.close()
for row in parser.rows:
print(row)这里有几个容易踩的点,都在代码里处理了:td出现在tr之外时self._row是None,所以每次用到它都先判空;handle_data可能被调用多次(单元格里嵌了别的标签时,文字会被拆成几段回调),所以单元格内容要先收集到一个列表再join,不能直接赋值覆盖;strip()必须在join之后做,否则拿到的可能是带换行的碎片。
feed()只负责喂数据,建议最后调用一次close(),让解析器把缓冲里的内容处理完。
三、用ipaddress校验,而不是只靠正则
表格只是搬运工,真正的校验在抽取这一步。最直观的做法是用正则匹配ip:port:
# 适用于 Python 3.8+
import ipaddress
import re
ADDR_RE = re.compile(r"(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})")
def to_endpoint(host, port_text):
"""校验并规范化一个 host:port,非法则返回 None"""
try:
ipaddress.IPv4Address(host) # 非法 IPv4 会抛 AddressValueError
except ipaddress.AddressValueError:
return None
try:
port = int(port_text)
except ValueError:
return None
if not (1 <= port <= 65535):
return None
return f"{host}:{port}"要理解正则和校验的分工:正则只负责「找出长得像的片段」,ipaddress才负责判断「它是不是合法的 IPv4」。上面那个\d{1,3}允许999.1.1.1,正则完全无法识别;而ipaddress.IPv4Address("999.1.1.1")会直接抛AddressValueError,把它挡掉。端口同理——70000能通过正则的\d{1,5},但超出1..65535的范围,必须自己检查。
把两段接起来,从解析出的表格行里抽地址:
# 适用于 Python 3.8+
# ADDR_RE 与 to_endpoint 沿用上一段代码里的定义
def extract_endpoints(rows):
seen = set()
result = []
for row in rows:
if len(row) < 2:
continue
# 演示页面把 IP 和端口放在两列里,先用冒号拼回 "ip:port" 再匹配
joined = ":".join(row[:2])
m = ADDR_RE.fullmatch(joined)
if not m:
continue
endpoint = to_endpoint(m.group(1), m.group(2))
if endpoint and endpoint not in seen:
seen.add(endpoint)
result.append(endpoint)
return result
print(extract_endpoints(parser.rows))
# 预期只保留 3 条合法地址,非法 IP 与越界端口各被丢一条这里用的是fullmatch而不是search:因为joined已经被我们拼成了恰好两段,用fullmatch要求整串都符合ip:port的形状,能挡掉「IP 列里混了别的东西」的情况。如果页面里是「一格里写着1.2.3.4:8080」这种形态,直接用search找子串更合适——用哪个取决于文本的形状。
用set去重是必要的:同一批来源里重复地址很常见,不去重会让下游的校验做无用功。
四、第三方解析器:思路与注意事项
如果你的页面结构比表格复杂(嵌套层很深、有大量属性、需要按 CSS 选择器或 XPath 定位),标准库的html.parser会写得很啰嗦,这时通常会用lxml或BeautifulSoup(bs4)。它们是第三方库,需要先pip install lxml/pip install beautifulsoup4,本机没有安装。
用它们的思路是一模一样的,只是把「手写状态机」换成「选择器」:定位到承载地址的那批节点,取出文本,再走上面同一套正则加ipaddress的校验。本机既没有这些库,我也无法运行验证,所以这里不写出具体到参数名的调用——find_all也好、xpath也好,参数名和返回类型请以这两个库的官方文档为准,不要照抄任何你没核对过的签名。
还有个层次问题值得单独提醒:requests也是第三方库(pip install requests),它负责把页面取回来。但本文的所有示例都刻意不做网络请求——把「取」和「解析」分开,既方便测试(解析函数可以喂本地字符串),也避免让示例默认指向某个真实站点。
另外,如果你要解析的是 HTML 里嵌的ip:port列表,还要注意不要用正则去解析整个 HTML 文档。HTML 不是正则语言,一个跨标签的正则匹配很容易在复杂页面上失控(回溯、误匹配、把注释里的内容也抓出来)。正则只应该作用在已经由解析器取出的文本节点上,这是上面流程里先解析、后正则的原因。
五、抽出来之后:一律当作不可信
抽到地址不等于能用。至少要再走这几步,且每一步都要意识到「这些地址可能本身就是陷阱」:
- 去重(上面已经做了);
- 逐个校验(另写一个真实探测环节,探测目标必须是你自己控制、且允许被探测的服务);
- 加来源标记,记录每个地址来自哪一批数据,出问题时能追溯;
- 绝不把来源不明的地址用于携带 Cookie、Token 或任何身份凭据的请求——这等于把凭据交给一个陌生人。
第 4 条最要紧。用免费代理去访问需要登录的站点,等于把你自己的会话凭据直接送给了代理运营者。
常见坑点
- ❌ 直接对一整篇 HTML 用正则匹配
ip:port,跳过解析器。
✅ 先用html.parser之类的解析器取出文本节点,再对文本节点用正则;HTML 不是正则语言。
- ❌ 只用正则
\d{1,3}(\.\d{1,3}){3}判断地址合法性,于是999.1.1.1被当成有效地址。
✅ 正则找候选,ipaddress.IPv4Address做合法性校验,端口再单独检查1..65535。
- ❌ 在
handle_data里直接self._cell = data,结果一段文字被拆成多次回调时只剩最后一片。
✅ 先把片段收集进列表,handle_endtag时再join并strip。
- ❌ 把
td标签写在tr外面,代码里self._row还是None就直接append,抛AttributeError。
✅ 每次使用前判空,self._row is not None才写入。
- ❌ 从公开免费代理站抓地址,直接拿来用,还带着自己的登录 Cookie。
✅ 公开免费代理可能是蜜罐或中间人;绝不用来源不明的代理发送任何凭据,来源必须可追溯。
- ❌ 相信「地址在页面上出现过就是可用的」,不做去重也不做校验。
✅ 去重、记录来源、独立校验三步都要做;抽出来只是候选,不是可用资源。
- ❌ 为了解析复杂页面,凭印象写一个
bs4或lxml的调用,参数名都没核对。
✅ 这些是第三方库,需先安装;接口以各自官方文档为准,不确定就不要写出具体参数名。
- ❌ 在示例里写死一个真实站点地址,读者一运行就去打了别人的服务器。
✅ 示例一律用具名保留网段(如192.0.2.0/24)和本地构造的 HTML 字符串。
总结
| 步骤 | 用什么 | 关键点 |
|---|
| 准备输入 | 自己构造的本地 HTML 字符串 | 用 RFC 5737 保留网段,不指向真实站点 |
| 解析结构 | html.parser.HTMLParser | 处理tr/td嵌套、多次handle_data、上下文判空 |
| 抽取候选 | re正则 | 只对文本节点匹配,不在整篇文档上跑 |
| 校验 | ipaddress.IPv4Address+ 端口范围 | 正则管「像」,ipaddress管「是不是合法」 |
| 收尾 | set去重、来源标记 | 抽出来的地址一律视为不可信 |
「爬取代理IP」这件事,技术上的难点其实很有限——解析一段结构化的 HTML、抽字段、做校验,都是基础操作。真正需要投入判断力的是来源和用途:这段 HTML 从哪来、抽出的地址能不能信、拿它们去干什么。示例用本地字符串,不只是因为本机没有网络环境,更是因为在教学场景里,让读者把注意力放在解析逻辑而不是「哪个站还能抓」上,才是对的。
参考:html.parser、re、ipaddress的接口以 Python 官方文档为准;lxml、BeautifulSoup、requests为第三方库,需另行安装,接口以其官方文档为准;本文代码未在本机运行,仅作人工推演。