前言「把网页里所有链接提出来」听起来是一句话的需求真写起来会连着冒出一串问题href/about这种相对路径怎么变成完整地址javascript:void(0)、#、mailto:这些算不算链接同一个地址在一页里出现十次要不要去重怎么区分站内链接和站外链接提取出来之后要不要一个个去访问看它们还通不通本文用BeautifulSoup把这条链路走完抽取、补全、分类、去重最后讲一条最重要的——检查链接可用性时的礼貌做法。很多教程到「把 href 打出来」就结束了那只是起点不是终点。前置说明本文示例代码为逐行推演未经运行本机无 Python 解释器、无第三方库。演示用的 HTML 为本地构造的字符串请求目标是 RFC 2606 保留的域名。做链接检查时务必只检查公开页面遵守目标站点的 robots.txt带可识别的 User-Agent 并限速不要拿并发去压对方的链接。顺带交代一个版本差异它正是链接处理中最容易踩的urljoin、urlparse这些函数在 Python 2 里位于urlparse模块Python 3 把相关功能整合进了urllib.parse。Python 2.7 已于 2020 年 1 月 1 日停止维护本文一律写 Python 3 的导入路径from urllib.parse import ...你若见到import urlparse的老代码那已经是不能直接跑的写法了。一、把链接抽出来先用find_all(a)拿到所有链接标签再取href属性# 适用于 Python 3.8请先自行 pip 安装 beautifulsoup4from bs4 import BeautifulSoupHTML div idcontenta href/about关于/aa hrefhttps://docs.example.org/guide外部文档/aa href#top顶部/aa hrefjavascript:void(0)按钮/aa无地址/a/divsoup BeautifulSoup(HTML, html.parser)raw [a[href] for a in soup.find_all(a) if a.has_attr(href)]print(raw)注意两个防御点a.has_attr(href)过滤掉a无href的情况例如纯锚点占位避免a[href]抛KeyErrorfind_all(a)返回的是一个ResultSet类似列表可以直接遍历。二、用 urljoin 补全相对路径href/about单独拿出来没有意义必须配上页面自身的地址才能还原成完整 URL。标准库urllib.parse.urljoin就是干这个的# 适用于 Python 3.8from urllib.parse import urljoinBASE https://www.example.com/blog/post/1print(urljoin(BASE, /about)) # https://www.example.com/aboutprint(urljoin(BASE, next.html)) # https://www.example.com/blog/post/next.htmlprint(urljoin(BASE, https://other.example.org)) # https://other.example.org它的规则是如果第二个参数本身就是绝对 URL直接返回它否则按第一个参数基准地址解析相对路径。所以补全后就不必再担心./、../这些写法。urljoin(base, url, allow_fragmentsTrue)还有第三个参数设为False时会丢弃#后面的片段。值得一提的是官方文档特别提醒不要用urljoin去拼接不可信的用户输入因为如果用户提交的是绝对 URL结果会跳出你预期的域名。三、区分内链与外链判断内链外链的思路是把补全后的 URL 解析出主机名netloc和本站主机名比较。# 适用于 Python 3.8from urllib.parse import urlparse, urljoindef classify(raw_href, base_url):返回 (完整URL, 类型)类型为 inner / outer / other。full urljoin(base_url, raw_href)parsed urlparse(full)if parsed.scheme not in (http, https):return full, other # mailto:、javascript:、tel: 等if parsed.netloc urlparse(base_url).netloc:return full, innerreturn full, outerurlparse返回一个具名元组常用字段是scheme协议、netloc主机名可能含端口、path、query、fragment。用netloc比较是站内站外最直接的判据。一个容易忽略的细节同一站点可能有example.com和www.example.com两个主机名靠字符串比较会被判成外链。严谨起见应该先做规范化把www.去掉、统一小写再做比较。四、去重与规范化同一页里同一个链接出现多次是常态导航、正文、页脚各来一遍。去重要注意要处理的情况建议内容相同但顺序不同的查询参数归一化后再比只差#fragment通常可忽略片段后去重只差尾部斜杠视站点约定选一种保留大小写不同的主机名主机名统一小写同一路径的 http 与 https视需求一般保留 https一个简单的去重实现保留首次出现的顺序# 适用于 Python 3.8from urllib.parse import urldefragdef dedup(urls):去掉 URL 片段后去重保留出现顺序。seen, out set(), []for u in urls:no_frag, _ urldefrag(u) # 返回 (去掉片段的部分, 片段)if no_frag in seen:continueseen.add(no_frag)out.append(no_frag)return outurldefrag把 URL 拆成「去掉#片段的部分」和「片段本身」两部分。用set判重、用list保序是保留顺序去重的标准写法。五、链接可用性的礼貌检查提取完之后很多人想「顺手验证一下这些链接通不通」。这一步最容易出合规问题必须先立规矩只检查公开页面不碰登录墙、付费墙、验证码。遵守 robots.txt如果目标站禁止抓取就不要检查。带可识别的 User-Agent。串行 限速绝不并发压站。优先用HEAD请求只拿状态码不下载整个页面正文。# 适用于 Python 3.8import timeimport requestsHEADERS {User-Agent: LinkCheckBot/1.0 (contact: youexample.com)}def check(url, timeout8):返回状态码只发 HEAD失败再降级为 GET。try:resp requests.head(url, headersHEADERS, timeouttimeout,allow_redirectsTrue)if resp.status_code 405: # 有些服务器不允许 HEADresp requests.get(url, headersHEADERS, timeouttimeout)return resp.status_codeexcept requests.RequestException as exc:return str(exc)def check_all(urls, delay1.5):report {}for u in urls:report[u] check(u)time.sleep(delay) # 串行 限速别给对方压力return reportallow_redirectsTrue让重定向自动跟着走HEAD默认不跟随状态码 405 表示服务器不接受HEAD此时降级为GET是常见兼容手段但要注意这会更重限速间隔也应相应加长。常见坑点a[href]直接取没有 href 就崩❌[a[href] for a in soup.find_all(a)]遇到无href的a抛KeyError。✅ 先a.has_attr(href)过滤或用a.get(href)。把相对路径当完整 URL 用❌ 拿到/about就去发请求requests报MissingSchema。✅ 先urljoin(base_url, href)补全。忘了排除非 HTTP 协议❌mailto:、javascript:void(0)、tel:混进链接列表后面请求全报错。✅ 用urlparse检查scheme是否为http/https。用urljoin处理不可信输入❌ 把用户提交的字符串直接urljoin对方给绝对 URL 就能把结果指到别的域名。✅ 拼接后校验域名不可信输入不要直接参与拼接。不去重同一个链接处理十几遍❌ 对find_all(a)的结果直接循环导航页脚造成大量重复。✅set判重如用urldefrag去掉#片段后再比。检查链接时并发猛敲❌ 开线程池同时请求上百个链接把对方当压测对象。✅ 串行 time.sleep并遵守 robots.txt。只比较字符串判断站内站外❌href.startswith(http)就算外链www与非www混用时会判错。✅ 比较urlparse(url).netloc必要时先规范化主机名。总结步骤关键点抽取find_all(a)has_attr(href)补全urljoin(base, href)分类urlparse(url).netloc对比排除非 HTTP 协议去重set判重 list保序常用urldefrag检查只查公开页、HEAD优先、串行限速、带 UA链接提取真正的难点不在find_all而在补全和检查这两步补全决定了你拿到的地址能不能直接用检查的礼貌程度决定了你的程序是不是一个合格的网络公民。把这两个环节做对这条链路才算完整。