Python urljoin 教程:URL 路径拼接与相对路径处理完整指南
urljoin 是 Python 爬虫处理相对路径拼接的核心工具。本文从 URL 结构讲起,覆盖相对路径、绝对路径、./、../、urlparse、urlunparse 在爬虫中的实战用法,附 6 个实战案例与常见报错解决方案。
为什么爬虫要处理 URL 路径拼接?
写爬虫时,你会经常遇到这类问题:
- 从页面 HTML 里拿到相对路径
/img/logo.png,不知道怎样拼成完整 URL - 用
urljoin拼出来结果和想的不一样,不知道哪里出了问题 - base URL 带了查询参数
?x=1&b=2和锚点#hash,担心会被一起带进结果里
如果直接用字符串拼接:
base = "https://example.com/a/b/c?x=1&b=2#hash"
result = base + "/img/logo.png"
# ❌ https://example.com/a/b/c?x=1&b=2#hash/img/logo.png
这显然不对。正确做法是使用 Python 标准库的 urljoin,它按 URL 结构规则来处理路径拼接,不是简单的字符串连接。
学完这篇,你能够:
- 理解 URL 各组成部分(scheme、netloc、path、query、fragment)
- 掌握
urljoin的路径拼接规则(相对路径、./、../、绝对路径) - 区分 urljoin 和字符串拼接的本质差异
- 在爬虫里正确拼接相对路径,提取完整资源 URL
- 避开 urljoin 的常见误区
URL 结构:urljoin 到底在处理什么?
urljoin 拼的不是字符串,而是 URL 结构。
先把 base URL 拆解成 5 个组成部分:
| 组成部分 | 含义 | 示例值 |
|---|---|---|
| scheme | 协议 | https |
| netloc | 域名 | example.com |
| path | 路径 | /a/b/c |
| query | 查询参数 | x=1&b=2 |
| fragment | 锚点 | hash |
from urllib.parse import urlparse
base = "https://example.com/a/b/c?x=1&b=2#hash"
parsed = urlparse(base)
print(parsed.scheme) # https
print(parsed.netloc) # example.com
print(parsed.path) # /a/b/c
print(parsed.query) # x=1&b=2
print(parsed.fragment) # hash
关键提醒:
urljoin 只处理 path,base URL 的 query(
?后面的部分)和 fragment(#后面的部分)会被直接丢弃。也就是说,无论 base 带了多么复杂的查询参数,拼接结果都不会包含它们。只有 path 会被用来参与路径计算。
urljoin 基础:相对路径怎么拼?
相对路径:以「当前目录」为基准
from urllib.parse import urljoin
base = "https://example.com/a/b/c?x=1&b=2#hash"
result = urljoin(base, "img/1.jpg")
print(result)
# https://example.com/a/b/img/1.jpg
为什么会是这个结果?
- base 的 path 是
/a/b/c c被识别为文件名,而非目录- 当前目录 =
/a/b/ - 拼接
img/1.jpg→/a/b/img/1.jpg
这就是 urljoin 的核心规则:以 base path 的「当前目录」为基准,往下拼接相对路径。
./:明确表示"当前目录"
print(urljoin(base, "./path"))
# https://example.com/a/b/path
./ 的效果和上面一样,只是更明确地告诉你:以当前目录为起点。
../:向上回退一层目录
print(urljoin(base, "../css/main.css"))
# https://example.com/a/css/main.css
计算过程:
- 当前目录 =
/a/b/ ..退一层 →/a/- 再拼上
css/main.css→/a/css/main.css
有几个
..,就退几层目录。
# 退两层
print(urljoin(base, "../../css/main.css"))
# https://example.com/css/main.css
urljoin 进阶:绝对路径怎么处理?
/ 开头:从网站根目录算
print(urljoin(base, "/static/app.js"))
# https://example.com/static/app.js
关键区别:
- 相对路径(无
/开头):以 base 的当前目录为基准 - 绝对路径(有
/开头):从域名根目录开始,忽略 base 的路径
完整 URL:直接覆盖
# 第二个参数是完整 URL,直接返回它
print(urljoin(base, "https://other.com/path"))
# https://other.com/path
urljoin vs 字符串拼接:本质区别在哪里?
| 对比项 | 字符串拼接 | urljoin |
|---|---|---|
| 处理方式 | 字符连接 | URL 结构解析 |
遇到 / 时的行为 |
产生双斜杠 // |
自动规整 |
处理 ../ |
当普通字符 | 正确回退目录 |
| 处理 query/fragment | 原样保留 | 丢弃(除非相对路径本身含) |
| 跨协议/域名 | 全部拼接 | 正确替换 |
字符串拼接的坑:
base = "https://example.com/a/b/"
path = "/img/logo.png"
print(base + path)
# ❌ https://example.com/a/b//img/logo.png(双斜杠)
print(base + path.lstrip('/'))
# ⚠️ 可以,但你自己处理了,urljoin 更安全
urljoin 的优势:
from urllib.parse import urljoin
base = "https://example.com/a/b/"
path = "/img/logo.png"
print(urljoin(base, path))
# ✅ https://example.com/img/logo.png(自动处理)
实战:爬虫里怎么用 urljoin 拼接图片链接?
场景:从 HTML 里提取相对路径,拼接完整 URL
from urllib.parse import urljoin
from lxml import etree
import requests
# 目标页面
url = "https://example.com/article/python-intro"
html = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}).text
selector = etree.HTML(html)
# 提取所有图片的 src(相对路径)
img_srcs = selector.xpath('//img[@src]/@src')
for src in img_srcs:
full_url = urljoin(url, src)
print(full_url)
运行结果示例:
https://example.com/article/python-intro
https://example.com/article/img/cover.jpg # 相对路径 → 完整 URL
https://example.com/static/logo.png # / 开头 → 根目录
https://other.com/banner.png # 完整 URL → 直接保留
场景:批量拼接资源 URL 并下载
from urllib.parse import urljoin
from lxml import etree
import requests
def download_resources(page_url, css_selector):
"""下载页面指定选择器对应的所有资源"""
html = requests.get(page_url, headers={"User-Agent": "Mozilla/5.0"}).text
selector = etree.HTML(html)
resources = selector.xpath(f'{css_selector}/@href | {css_selector}/@src')
for resource in resources:
# 拼接完整 URL
resource_url = urljoin(page_url, resource)
# 下载(省略异常处理)
r = requests.get(resource_url)
filename = resource_url.split('/')[-1]
with open(filename, 'wb') as f:
f.write(r.content)
print(f"Downloaded: {resource_url}")
场景:拼接分页 URL
from urllib.parse import urljoin
base = "https://example.com/list/page1"
page_count = 5
for i in range(1, page_count + 1):
# 方法 1:用 urljoin 拼接
page_url = urljoin(base, f"page{i}")
print(page_url)
# https://example.com/list/page1 → https://example.com/list/page2 → ...
# 方法 2:用 format 直接生成(更直观)
for i in range(1, page_count + 1):
print(f"https://example.com/list/page{i}")
urljoin 常见报错与处理
报错 1:拼接结果包含了旧的 query 参数
from urllib.parse import urljoin
base = "https://example.com/a/b?keyword=python"
result = urljoin(base, "img/logo.png")
print(result)
# https://example.com/a/img/logo.png
# ✅ query 参数被正确丢弃了(这不是报错,是 urljoin 的标准行为)
如果你的本意是要保留 query 参数,需要手动处理:
from urllib.parse import urlparse, urlunparse
base = "https://example.com/a/b?keyword=python"
new_path = "img/logo.png"
parsed = urlparse(base)
new_url = urlunparse((
parsed.scheme,
parsed.netloc,
new_path,
'', # params(很少用)
parsed.query, # 保留 query
'' # fragment
))
print(new_url)
# https://example.com/img/logo.png?keyword=python
报错 2:相对路径带空格导致拼接失败
# 相对路径里带空格,必须编码
src = "/img/my photo.jpg"
# ❌ urljoin 会把空格当成路径分隔符,导致奇怪结果
from urllib.parse import quote
safe_src = quote(src, safe='/')
full_url = urljoin(base, safe_src)
# ✅ https://example.com/img/my%20photo.jpg
报错 3:base URL 末尾有 / 跟没有,结果不一样
from urllib.parse import urljoin
base_with_slash = "https://example.com/a/b/"
base_no_slash = "https://example.com/a/b"
print(urljoin(base_with_slash, "c"))
# https://example.com/a/b/c
print(urljoin(base_no_slash, "c"))
# https://example.com/a/c ← 注意这里!b 被当成文件名替换了
处理方法:
# 保证 base URL 末尾有 /
import urllib.parse
def ensure_trailing_slash(url):
parsed = urllib.parse.urlparse(url)
if not parsed.path.endswith('/'):
return url.rstrip('/') + '/'
return url
base = ensure_trailing_slash("https://example.com/a/b")
print(urljoin(base, "c"))
# https://example.com/a/b/c ✅
报错 4:协议相对 URL(//开头)
print(urljoin("https://example.com/", "//cdn.com/logo.png"))
# ✅ https://cdn.com/logo.png(自动补全 https:)
这其实是 urljoin 的正常行为,但有时会引发问题,需要注意。
urljoin 与 urlparse、urlunparse 的关系
urllib.parse 模块里三个函数配合使用:
from urllib.parse import urljoin, urlparse, urlunparse
url = "https://example.com/a/b/c?x=1#section"
# 1. 解析:拆解 URL 结构
parsed = urlparse(url)
print(parsed)
# ParseResult(scheme='https', netloc='example.com', path='/a/b/c', query='x=1', fragment='section')
# 2. 修改:替换某个组成部分
modified = parsed._replace(path="/new/path")
print(urlunparse(modified))
# https://example.com/new/path?x=1#section
# 3. 拼接:用 urljoin
result = urljoin(url, "../css/style.css")
print(result)
# https://example.com/a/css/style.css
三者的分工:
| 函数 | 作用 | 场景 |
|---|---|---|
urlparse |
拆分 URL 结构 | 分析、修改 URL 某部分 |
urljoin |
拼接相对路径 | 爬虫提取资源、生成完整 URL |
urlunparse |
组合成新 URL | 修改后重新组装 |
常见问题
urljoin 和 os.path.join 有什么区别?
os.path.join 是文件系统路径拼接,不管 URL 结构。urljoin 是URL 路径拼接,理解 URL 结构规则。两者不能混用:
import os
from urllib.parse import urljoin
# os.path.join 用在文件系统路径上
print(os.path.join("/a/b", "c"))
# ✅ /a/b/c
# urljoin 用在 URL 上
print(urljoin("https://example.com/a/b", "c"))
# ✅ https://example.com/a/c
base URL 末尾的 / 重要吗?
非常重要。
from urllib.parse import urljoin
# 有 /
urljoin("https://example.com/a/b/", "c")
# https://example.com/a/b/c
# 没有 /
urljoin("https://example.com/a/b", "c")
# https://example.com/a/c ← 完全不同的结果!
爬虫里处理相对路径时,建议先对 base URL 做规范化处理:
def normalize_base_url(url):
from urllib.parse import urlparse
p = urlparse(url)
if not p.path.endswith('/'):
return f"{p.scheme}://{p.netloc}{p.path}/"
return url
urljoin 能处理锚点(#hash)吗?
不能直接保留。 urljoin 会丢弃 base 的 fragment:
print(urljoin("https://example.com/a#section", "b"))
# https://example.com/a ← #section 丢了
如果需要保留锚点到新页面:
def join_with_fragment(base, path):
from urllib.parse import urlparse
frag = urlparse(base).fragment
joined = urljoin(base, path)
return f"{joined}#{frag}" if frag else joined
协议相对 URL(以 // 开头)怎么处理?
有些资源链接写成 //cdn.example.com/logo.png,这是协议相对 URL:
print(urljoin("https://example.com/", "//cdn.example.com/logo.png"))
# ✅ https://cdn.example.com/logo.png
urljoin 会自动补全 scheme,但如果需要在没有协议的上下文中处理它,需要注意这一点。
总结
urljoin 的核心逻辑其实很简单:
- 相对路径(无
/开头):以 base 的「当前目录」(即去掉文件名后的路径)为基准,往下拼接 ./:明确指向当前目录../:向上回退一层目录- 绝对路径(
/开头):从网站根目录开始,忽略 base 的路径 - 完整 URL:直接替换 base,不做任何拼接
爬虫里用 urljoin 的最佳实践:
- 提取页面资源时,先用
urljoin(base, relative_path)拼接成完整 URL 再请求 - 用
urlparse分析 URL 结构,用urljoin拼接相对路径,用urlunparse重组修改后的 URL - 拼接前先规范化 base URL(检查末尾
/),避免目录层级判断错误 - 用
quote()编码含空格或特殊字符的路径
相关知识
想深入理解 URL 处理,推荐阅读:
FAQ
Q:urljoin 和字符串拼接 + 哪个更好?
A:urljoin 更好。它按 URL 结构规则处理路径拼接,能自动处理 / 和 ../ 等特殊符号,而字符串拼接会产生双斜杠或错误的路径。Python 的 urllib.parse 就是为这个设计的,不要自己写字符串拼接。
Q:base URL 带查询参数,urljoin 会保留吗?
A:不会。urljoin 只处理 path,base 的 query 参数和 fragment 会被丢弃。如果需要保留 query 参数,需要用 urlparse + urlunparse 手动处理。
Q:相对路径开头有 ./ 和没有有区别吗?
A:在 urljoin 里基本没有区别。urljoin(base, "img/1.jpg") 和 urljoin(base, "./img/1.jpg") 结果完全一样。./ 的作用是明确告诉阅读者这是当前目录下的路径,在 urljoin 解析层面是一样的。
Q:urljoin 会不会产生双斜杠 //?
A:不会。urljoin 会自动规整路径,消除多余的 /。但字符串拼接 base + "/path" 会产生 //(虽然大多数服务器能处理,但不符合规范)。
Q:爬虫里提取到相对路径,什么时候用 urljoin,什么时候不需要?
A:如果相对路径是完整的 URL(以 http:// 或 https:// 开头),不需要 urljoin。如果是以 /、./、../ 开头,或者没有任何 / 的纯路径,就必须用 urljoin 拼接成完整 URL。
Practice