爬B工程及 Python 导包机制

  • ~10.38K 字
  1. 1. Python 环境管理的几种方案
  2. 2. 爬取B站单个视频
    1. 2.1. 选用 lxml 而不是 BeautifulSoup 的理由
      1. 2.1.1. 简述 XPath
      2. 2.1.2. lxml.html 和 lxml.etree.HTML 的区别
    2. 2.2. 数据分析–单个视频中的 playinfo 信息
  3. 3. 将 bvget 命令写入系统路径
    1. 3.1. 上传至 PyPI
  4. 4. Python 导包机制
    1. 4.1. 相关概念
    2. 4.2. 问题场景
    3. 4.3. 推荐使用方式

前言:本文使用 uv 进行 Python 环境管理,简述爬取B站视频的核心要点,打包爬取B站视频的命令行工具,并上传至 PyPI,然后自己安装使用
针对 Python 的导包机制由浅入深进行讲解,给出七个针对性问题及解答,在文末提出推荐解决方案

Python 环境管理的几种方案

Python 环境管理有多种方案,在数据科学、机器学习方面,我们一般都会使用 conda。但是由于它的体积过于庞大,有时我们只是想要一个可以跑小项目的 Python 环境,venv 也不失为一个不错的选择。poetryHatch 也可以用来管理 Python 环境。现在还有用 Rust 编写的 uv

Windows 系统安装 uv 命令

1
powershell -executionpolicy bypass -c "irm https://astral.sh/uv/install.ps1 | iex"

基于 AI 生成的比较几种 Python 环境管理工具的优缺点整理(2025-1-31):

  1. pip:生态齐全。但解析依赖不稳定,无 lock 文件。
  2. poetry:速度较慢,解析基于 toml 文件,支持 poetry.lock 文件,内置 venv 管理,支持发布到 poetry publish,更适合项目管理和发布 Python 包。
  3. uv:速度最快,基于 Rust,支持 pyproject.tomlrequirements.txt 文件,不支持 pip install 以外的高级功能,可以创建虚拟环境,但不支持发布 PyPI(可以借助其他工具实现),没有 lock 文件。如果只想快速安装和管理 Python 依赖,不需要复杂功能,可以在小项目里使用。
  4. Hatch:支持依赖管理、虚拟环境、多环境管理、版本控制和PyPI 发布。它的设计目标是比 Poetry 更快、更灵活,适用于复杂项目、自动化任务和 CI/CD 环境。

爬取B站单个视频

选用 lxml 而不是 BeautifulSoup 的理由

  • lxml 支持 XPath 语法, 而 BeautifulSoup 不支持
  • lxml 要求所爬取的数据结构规范,所有标签闭合,而 BeautifulSoup 则不一定
  • lxml 的解析方式为直接基于 ElementTree 解析,而 BeautifulSoup 则是以 Python 对象方式操作
  • lxml.html.fromstring() 和 BeautifulSoup 同样支持 cssselect,不需要另外安装 cssselect 库

简述 XPath

  • XML 和 HTML 都是一种标记语言而不是编程语言
  • XML 用于存储、传输数据,而 HTML 专门用于展示网页内容,HTML 文档也叫做网页,HTML 相当于基于 XML 的规范衍生出的一套标记语言,XML 更加通用
  • XML 语法严格(要求标签必须闭合,区分大小写),而 HTML 语法松散(标签可以不闭合,不区分大小写)
  • XPath 是一门在 XML 文档中定位元素的语言

给出一些常用的 XPath 表达式:

1
2
3
4
5
6
7
8
9
10
11
12
//book    选择文档中所有 <book> 元素
/bookstore/book 选择根节点 <bookstore> 下的所有 <book> 元素
/bookstore/book[@category] 选择 <bookstore> 下所有带有 category 属性的 <book> 元素
/bookstore/book[@category='cooking'] 选择 <bookstore> 下 category 属性值为 cooking 的 <book> 元素
//* 选择文档中的所有元素
/bookstore/book/title 选择 <bookstore> 下每个 <book> 元素的 <title> 子元素
string_length(/bookstore/book/title) 返回 <title> 元素中字符串长度
sum(/bookstore/book/price) 返回 <price> 元素的和
contains(/bookstore/book/title, 'Python') 检查 <title> 元素是否包含字符串 'Python'
//title[@lang='en'] 选择所有 lang 属性值为 'en' 的 <title> 元素
/bookstore/book[price>30.00] 选择所有 price 元素的值大于 30.00 的 <book> 元素
/bookstore/book/title/text() 返回所有 <title> 元素的文本内容

lxml.html 和 lxml.etree.HTML 的区别

两种方法都可以用于解析 HTML,但是 lxml.html 具有自动修正 HTML 结构的特点,返回 lxml.html.HtmlElement 对象,比 ElementTree 提供更多 HTML 相关功能,因此更加适用于网页爬取。

数据分析–单个视频中的 playinfo 信息

B站大多单个视频在 开发者工具->Network->刷新后的第一条信息(对视频播放信息的请求)->Response 中 html->head->第四个script中保存了 window.__playinfo__ 信息,其中包含了视频播放所需的详细信息。
在 data->dash->video/audio 中分别保存了视频和音频的储存地址,包括 baseUrl,base_url,backupUrl(2个),backup_url(2个)

bvget 命令写入系统路径

可以手动添加到系统路径中:sudo mv bvget.py /usr/local/bin/bvget

也可以使用 setup.py 进行安装

但是从 PEP 518 开始就有了 pyproject.toml 文件,提供了一个更加现代化、声明式的项目配置

下面提供一个使用 uv 构建的简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
[project]
name = "bvget"
version = "0.1.0"
description = "A simple command line tool to download Bilibili videos"
authors = [{name = "poem", email = "poem@example.com"}]
readme = "README.md"

[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[project.scripts]
bvget = "bvget:main"

对以上内容的解读:

[project] 定义了项目的基本信息,包括名称、版本、描述、作者、readme等
[build-system] 定义构建系统
[project.scripts] 定义了项目的命令行工具,这里定义了一个名为 bvget 的命令,对应 bvget.py 中的 main() 函数

在项目根目录执行:

1
2
uv pip compile --all
uv build

构建完成后,dist 目录下会生成一个 .whl 文件,.whl 文件是可安装的 Python 包,可以直接使用 pip 安装。.tar.gz 文件是源码包,可以用于源码安装。

上传至 PyPI

uv 不支持直接发布 Python 包到 PyPI,但可以借助 twine 工具来实现。
首先需要注册一个 PyPI 账户,然后在项目根目录下执行:

1
2
3
4
uv add twine
uv publish --username pypi_username --password pypi_password
# 或者
twine upload dist/*

之后你就可以在任何 Python 环境中安装 bvget 了:

1
pip install bvget

Python 导包机制

提醒:环境为 Arch Linux + VSCode
Pycharm 有自己的想法(听说是会帮你自动添加路径,如果项目上线就会报各种导入失败的错误)且我没怎么用过,故本文不做讨论
为方便后续查看 sys.path 内容,建议添加 from rich import print 来增强可读性
所有代码执行的根目录为 project

相关概念

  • package:包,一个包含模块的目录,包含 __init__.py 文件,可以包含子目录
  • module:模块,一个 .py 文件,包含 Python 代码
  • __name__当前模块名称(此项与后面解释息息相关)
  • __package__当前模块所在的包名称(此项与后面解释息息相关)
  • sys.path:模块搜索路径列表,也即绝对导入搜索路径
  • __init__.py:模块的初始化文件,在导入包时,会自动执行该文件
  • __path__:当前模块的搜索路径
  • __file__:当前模块的文件路径
  • __loader__:模块的加载器,指向加载该模块的加载器对象
  • __spec__:存储模块的导入信息(PEP 451)
  • PEP:Python enhancement proposal,Python 增强建议,是 Python 社区提出的一些改进建议
  • top-level package:所执行的 package 中最高的一层

问题场景

有如下目录结构:

1
2
3
4
5
6
7
8
|-project
| |-sub1
| | |-C.py
| |-sub2
| | |-D.py
| | |-E.py
| |-A.py
| |-B.py

每个文件中均有一个 _fun() 函数。
相对导入基准路径为当前模块所在包。比如 C.py 的基准路径是 sub1A.py 的基准路径是 project
import ..A 这样的隐式相对导入已被 PEP 328 禁止,后文所说的相对导入均指的是显式相对导入。

问题一:python filename.pypython -m modulename 的区别

  • 命令1:python sub1/C.py
  • 命令2:python -m sub1.C

补充说明:

  • VSCode 里直接点击小三角运行 Python 文件同命令1原理
  • 命令2由 PEP 338 规定
  • 把这个问题放在第一个是为了让读者知道作者是如何运行 Python 文件的,关于命令执行结果的区别,将在问题三中说明
  • 后文为简化,类似于命令1中的操作均称呼为”命令1”,类似于命令2中的操作均称呼为”命令2”

C.py 中有如下代码:

1
2
3
4
5
6
import sys
from rich import print

print(sys.path)
print("C __name__:", __name__)
print("C __package__:", __package__)

命令1执行结果:

1
2
3
4
5
6
7
$ python ./sub1/C.py                             -- INSERT --
[
'/home/poem/app/vscode/Python/project/sub1',
# 省略部分
]
C __name__: __main__
C __package__: None

命令2执行结果:

1
2
3
4
5
6
7
$ python -m sub1.C                               -- INSERT --
[
'/home/poem/app/vscode/Python/project',
# 省略部分
]
C __name__: __main__
C __package__: sub1
  • 第一个输出发现我在 VSCode 中项目根目录是 project,而命令1 sys.path 里的路径只有 sub1 目录
  • 第二个输出表明 C.py 作为顶级脚本被加载,__name__ 的值为 __main__,顶级脚本会失去正常名称,而被改为 __main__
  • 第三个输出 __package__ 不是当前模块所在的包名称吗?为什么它的值会是 None
    疑惑将在问题三得到解答

问题二:if __name__ == '__main__': 语句的作用是什么

加载 Python 文件有两种方式:作为顶级脚本(一次只能有一个)或者作为模块。

  • 作为顶级脚本:直接运行 python filename.py 或者 python -m modulename,会将该文件作为顶级脚本加载
  • 作为模块:当在其他文件中遇到 import 语句时,它会作为模块加载

C.py 中有如下代码:

1
2
3
4
5
6
7
8
9
10
import sys

print("C __name__:", __name__)
print("C __package__:", __package__)

def c_fun():
print("I am c_fun")

if __name__ == "__main__":
c_fun()

执行 python ./sub1/C.py 或者 python -m sub1.C 输出结果:

1
2
3
4
$ python ./sub1/C.py                 -- INSERT --
C __name__: __main__
C __package__: None # 后者这里输出 sub1
I am c_fun

接着我们保留 C.py 中代码,C.py 中依然有 if __name__ == "__main__": 语句,我们在 A.py 中导入 C.py

A.py 中有如下代码:

1
2
3
4
5
6
7
8
9
10
11
from sub1 import C

def a_fun():
print("I am a_fun")

if __name__ == "__main__":
a_fun()
C.c_fun()

print("A __name__:", __name__)
print("A __package__:", __package__)

执行 python ./A.py 或者 python -m A 输出结果在最后一行略有不同:

1
2
3
4
5
6
7
$ python ./A.py                      -- INSERT --
C __name__: sub1.C
C __package__: sub1
I am a_fun
I am c_fun
A __name__: __main__
A __package__: None # 如果这里执行的是 `python -m A`,则输出为空

从输出中可以看出 C.py 中的 if __name__ == "__main__": 语句未被执行,__name__ 的值为 sub1.C

所以问题二的答案:if __name__ == '__main__': 语句的作用是只有当该模块作为顶级脚本时才会执行后面的语句,如果该模块是被 import 进来的,则不会执行。

问题三:如何在 B.py 中导入 A.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from . import A
# python ./B.py || python -m B
# ImportError: attempted relative import with no known parent package
# . 代表 project 目录

# cd .. && python -m project.B
# 成功

from project import A
# ModuleNotFoundError: No module named 'project'
# 如果 print(sys.path) 会发现 project 目录是第一条

# cd .. && python -m project.B
# 成功

import A
# 成功

对上面错误做法原因分析:
相对导入使用模块的名称(即 __name__)来确定它在包层次结构中的位置。
当你使用相对导入时,如 from .. import A,点表示在 package 层次结构中增加一些级别。
如果当前模块名称是 project.sub1.C,则 ..A 表示 project.A
要使 from .. import 正常工作,模块名称必须至少包含 import 语句中的点数。

但是,如果模块名称是 __main__,它的名称里没有点,就好像该模块是顶级模块一样,不管该模块在文件系统上的实际位置如何,其都没有更往上一层的包,因此不能在里面使用 from . import A 这种相对导入。

理解了问题三错误做法错误的原因,我们再回看问题一。

命令1执行结果:

1
2
3
4
5
6
7
$ python ./sub1/C.py                             -- INSERT --
[
'/home/poem/app/vscode/Python/project/sub1',
# 省略部分
]
C __name__: __main__
C __package__: None

命令2执行结果:

1
2
3
4
5
6
7
$ python -m sub1.C                               -- INSERT --
[
'/home/poem/app/vscode/Python/project',
# 省略部分
]
C __name__: __main__
C __package__: sub1

从 Python 2.6 开始,用于包解析目的的模块的名称不仅由其 __name__ 属性决定,还由 __package__ 属性决定。
模块的名称实际上是 __package__ + '.' + __name__,如果 __package__None,则只是__name__
根据对问题三错误做法的分析,我们知道,两种命令都会让 C.py 作为顶级脚本被加载,这样的话都无法识别上层包,之所以命令2的 __package__ 值为 sub1,是因为命令中有 sub1.C 告诉了 Python 加载 C.py 所在的包 sub1

问题四:如何在 C.py 中导入 A.py

常见于 sub1 目录为一个 test 目录
以下问题解决办法均只展示部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from .. import A
# 如果你在 project 目录下运行 python ./sub1/C.py
# ImportError: attempted relative import with no known parent package

# cd .. && python -m project.sub1.C
# 成功

from ...project import A
# ImportError: attempted relative import with no known parent package
# emmmmm 你应该试试 cd ... && python -m Python.project.sub1.C

import os
import sys
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
# 效果等同于 sys.path.append('/home/poem/app/vscode/Python/project')
# 或者你在运行时添加 PYTHONPATH=$(pwd) python ./sub1/C.py
import A

问题五:如何在 D.py 中导入 C.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from ..sub1 import C
# 如果你在 project 目录下运行 python ./sub2/D.py
# ImportError: attempted relative import with no known parent package

# 如果你在 project 目录下运行 python -m sub2.D
# ModuleNotFoundError: No module named 'project'

# cd .. && python -m project.sub2.D
# 成功

from project.sub1 import C
# cd .. && python -m project.sub2.D
# 成功

from sub1 import C
# python -m sub2.D
# 成功

问题六:如何在 E.py 中导入 D.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from . import D
# python ./sub2/E.py
# ImportError: attempted relative import with no known parent package

# python -m sub2.E
# 成功

from sub2 import D
# python ./sub2/E.py
# ModuleNotFoundError: No module named 'sub2'

# python -m sub2.E
# 成功

import D
# 成功

问题七:__init__.py 文件的作用是什么

__init__.py 文件是 Python 包的初始化文件,它会在包被导入时自动执行。
但自从 PEP 420 开始(即 Python 3.3),一个目录不需要 __init__.py 文件也可以自动成为包。
也就是说一般情况下加不加 __init__.py 文件无所谓。

推荐使用方式

通过 sys.path.append() 的方式可以解决燃眉之急,但这种做法并不在大型项目中推荐,因为它会导致项目的依赖关系变得复杂,且不利于代码的维护。
同理,使用环境变量 PYTHONPATH 来指定搜索路径也是相当于手动修改 sys.path,也不推荐。

以下是几种替代方案:

  • 可编辑安装:setuptools + editable install
    project 下创建 setup.py 文件,内容如下:
1
2
3
4
5
6
7
from setuptools import setup, find_packages

setup(
name="bvget",
version="0.1.0",
packages=find_packages(),
)

运行:pip install -e .

或者使用 uv 进行可编辑安装,进入 project 目录(要求底下有 setup.py 文件或者 pyproject.toml 文件),执行:uv pip install -e .

  • .pth 文件:Python 在启动时会自动读取 site-packages 目录下的 .pth 文件,并将其中的路径添加到 sys.path
  • 安装为包:将自定义模块打包并安装到 Python 环境中,使其可以被直接导入,该方法在 bvget 项目中已经提供具体做法

参考:
清华酒井24爬虫讲义
StackOverflow 上关于相对导入的讨论