Python urljoin 教程:URL 路径拼接与相对路径处理完整指南

urljoin 是 Python 爬虫处理相对路径拼接的核心工具。本文从 URL 结构讲起,覆盖相对路径、绝对路径、./、../、urlparse、urlunparse 在爬虫中的实战用法,附 6 个实战案例与常见报错解决方案。

6 分钟阅读 绝对路径相对路径URLurljoinurllib

为什么爬虫要处理 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 结构规则来处理路径拼接,不是简单的字符串连接。

学完这篇,你能够:

  1. 理解 URL 各组成部分(scheme、netloc、path、query、fragment)
  2. 掌握 urljoin 的路径拼接规则(相对路径、./../、绝对路径)
  3. 区分 urljoin 和字符串拼接的本质差异
  4. 在爬虫里正确拼接相对路径,提取完整资源 URL
  5. 避开 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

为什么会是这个结果?

  1. base 的 path 是 /a/b/c
  2. c 被识别为文件名,而非目录
  3. 当前目录 = /a/b/
  4. 拼接 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

计算过程:

  1. 当前目录 = /a/b/
  2. .. 退一层 → /a/
  3. 再拼上 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 结构。urljoinURL 路径拼接,理解 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 的核心逻辑其实很简单:

  1. 相对路径(无 / 开头):以 base 的「当前目录」(即去掉文件名后的路径)为基准,往下拼接
  2. ./:明确指向当前目录
  3. ../:向上回退一层目录
  4. 绝对路径/ 开头):从网站根目录开始,忽略 base 的路径
  5. 完整 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

读完这一节,去靶场里验证一下。

去挑战广场练习