前言:本文使用 uv 进行 Python 环境管理,简述爬取B站视频的核心要点,打包爬取B站视频的命令行工具,并上传至 PyPI,然后自己安装使用
针对 Python 的导包机制由浅入深进行讲解,给出七个针对性问题及解答,在文末提出推荐解决方案
Python 环境管理的几种方案
Python 环境管理有多种方案,在数据科学、机器学习方面,我们一般都会使用 conda。但是由于它的体积过于庞大,有时我们只是想要一个可以跑小项目的 Python 环境,venv 也不失为一个不错的选择。poetry 和 Hatch 也可以用来管理 Python 环境。现在还有用 Rust 编写的 uv。
Windows 系统安装 uv 命令
1 | powershell -executionpolicy bypass -c "irm https://astral.sh/uv/install.ps1 | iex" |
基于 AI 生成的比较几种 Python 环境管理工具的优缺点整理(2025-1-31):
- pip:生态齐全。但解析依赖不稳定,无
lock
文件。 - poetry:速度较慢,解析基于
toml
文件,支持poetry.lock
文件,内置venv
管理,支持发布到poetry publish
,更适合项目管理和发布 Python 包。 - uv:速度最快,基于 Rust,支持
pyproject.toml
和requirements.txt
文件,不支持pip install
以外的高级功能,可以创建虚拟环境,但不支持发布 PyPI(可以借助其他工具实现),没有lock
文件。如果只想快速安装和管理 Python 依赖,不需要复杂功能,可以在小项目里使用。 - 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 | //book 选择文档中所有 <book> 元素 |
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 | [project] |
对以上内容的解读:
[project] 定义了项目的基本信息,包括名称、版本、描述、作者、readme等
[build-system] 定义构建系统
[project.scripts] 定义了项目的命令行工具,这里定义了一个名为bvget
的命令,对应bvget.py
中的main()
函数
在项目根目录执行:
1 | uv pip compile --all |
构建完成后,dist
目录下会生成一个 .whl
文件,.whl
文件是可安装的 Python 包,可以直接使用 pip
安装。.tar.gz
文件是源码包,可以用于源码安装。
上传至 PyPI
uv
不支持直接发布 Python 包到 PyPI,但可以借助 twine
工具来实现。
首先需要注册一个 PyPI 账户,然后在项目根目录下执行:
1 | uv add twine |
之后你就可以在任何 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 | |-project |
每个文件中均有一个 _fun()
函数。
相对导入基准路径为当前模块所在包。比如 C.py
的基准路径是 sub1
,A.py
的基准路径是 project
。import ..A
这样的隐式相对导入已被 PEP 328 禁止,后文所说的相对导入均指的是显式相对导入。
问题一:python filename.py
和 python -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 | import sys |
命令1执行结果:
1 | $ python ./sub1/C.py -- INSERT -- |
命令2执行结果:
1 | $ python -m sub1.C -- INSERT -- |
- 第一个输出发现我在 VSCode 中项目根目录是
project
,而命令1sys.path
里的路径只有sub1
目录 - 第二个输出表明
C.py
作为顶级脚本被加载,__name__
的值为__main__
,顶级脚本会失去正常名称,而被改为__main__
- 第三个输出
__package__
不是当前模块所在的包名称吗?为什么它的值会是None
呢
疑惑将在问题三得到解答
问题二:if __name__ == '__main__':
语句的作用是什么
加载 Python 文件有两种方式:作为顶级脚本(一次只能有一个)或者作为模块。
- 作为顶级脚本:直接运行
python filename.py
或者python -m modulename
,会将该文件作为顶级脚本加载 - 作为模块:当在其他文件中遇到
import
语句时,它会作为模块加载
在 C.py
中有如下代码:
1 | import sys |
执行 python ./sub1/C.py
或者 python -m sub1.C
输出结果:
1 | $ python ./sub1/C.py -- INSERT -- |
接着我们保留 C.py
中代码,C.py
中依然有 if __name__ == "__main__":
语句,我们在 A.py
中导入 C.py
在 A.py
中有如下代码:
1 | from sub1 import C |
执行 python ./A.py
或者 python -m A
输出结果在最后一行略有不同:
1 | $ python ./A.py -- INSERT -- |
从输出中可以看出 C.py
中的 if __name__ == "__main__":
语句未被执行,__name__
的值为 sub1.C
。
所以问题二的答案:if __name__ == '__main__':
语句的作用是只有当该模块作为顶级脚本时才会执行后面的语句,如果该模块是被 import
进来的,则不会执行。
问题三:如何在 B.py
中导入 A.py
1 | from . import A |
对上面错误做法原因分析:
相对导入使用模块的名称(即 __name__
)来确定它在包层次结构中的位置。
当你使用相对导入时,如 from .. import A
,点表示在 package 层次结构中增加一些级别。
如果当前模块名称是 project.sub1.C
,则 ..A
表示 project.A
。
要使 from .. import
正常工作,模块名称必须至少包含 import
语句中的点数。
但是,如果模块名称是 __main__
,它的名称里没有点,就好像该模块是顶级模块一样,不管该模块在文件系统上的实际位置如何,其都没有更往上一层的包,因此不能在里面使用 from . import A
这种相对导入。
理解了问题三错误做法错误的原因,我们再回看问题一。
命令1执行结果:
1 | $ python ./sub1/C.py -- INSERT -- |
命令2执行结果:
1 | $ python -m sub1.C -- INSERT -- |
从 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 | from .. import A |
问题五:如何在 D.py
中导入 C.py
1 | from ..sub1 import C |
问题六:如何在 E.py
中导入 D.py
1 | from . 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 | from setuptools import setup, find_packages |
运行:pip install -e .
或者使用 uv
进行可编辑安装,进入 project
目录(要求底下有 setup.py
文件或者 pyproject.toml
文件),执行:uv pip install -e .
。
.pth
文件:Python 在启动时会自动读取site-packages
目录下的.pth
文件,并将其中的路径添加到sys.path
- 安装为包:将自定义模块打包并安装到 Python 环境中,使其可以被直接导入,该方法在 bvget 项目中已经提供具体做法