diff --git a/.gitattributes b/.gitattributes
index 3ab78cc..e69de29 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,3 +0,0 @@
-*.js linguist-language=python
-*.css linguist-language=python
-*.html linguist-language=python
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..eb7b21e
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+github: NanmiCoder
\ No newline at end of file
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..eece8af
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,64 @@
+# 构建 VitePress 站点并将其部署到 GitHub Pages 的示例工作流程
+#
+name: Deploy VitePress site to Pages
+
+on:
+ # 在针对 `main` 分支的推送上运行。如果你
+ # 使用 `master` 分支作为默认分支,请将其更改为 `master`
+ push:
+ branches: [main]
+
+ # 允许你从 Actions 选项卡手动运行此工作流程
+ workflow_dispatch:
+
+# 设置 GITHUB_TOKEN 的权限,以允许部署到 GitHub Pages
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# 只允许同时进行一次部署,跳过正在运行和最新队列之间的运行队列
+# 但是,不要取消正在进行的运行,因为我们希望允许这些生产部署完成
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ # 构建工作
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # 如果未启用 lastUpdated,则不需要
+ # - uses: pnpm/action-setup@v3 # 如果使用 pnpm,请取消注释
+ # - uses: oven-sh/setup-bun@v1 # 如果使用 Bun,请取消注释
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm # 或 pnpm / yarn
+ - name: Setup Pages
+ uses: actions/configure-pages@v4
+ - name: Install dependencies
+ run: npm ci # 或 pnpm install / yarn install / bun install
+ - name: Build with VitePress
+ run: npm run docs:build # 或 pnpm docs:build / yarn docs:build / bun run docs:build
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: docs/.vitepress/dist
+
+ # 部署工作
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ needs: build
+ runs-on: ubuntu-latest
+ name: Deploy
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 5246620..9f0a50d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -161,3 +161,5 @@ cython_debug/
.idea
+node_modules
+docs/.vitepress/cache
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..cc26f98
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,45 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## 项目概述
+
+这是一个 Python 爬虫教程仓库,包含从入门到高级的爬虫技术教学内容。作者是 MediaCrawler 开源项目的作者。
+
+## 常用命令
+
+```bash
+# 安装依赖
+npm install
+
+# 启动文档开发服务器
+npm run docs:dev
+
+# 构建文档
+npm run docs:build
+
+# 预览构建后的文档
+npm run docs:preview
+```
+
+## 项目结构
+
+- `docs/` - VitePress 文档源文件(Markdown 格式)
+ - `docs/.vitepress/` - VitePress 配置和主题
+ - `docs/爬虫入门/` - 入门教程文档
+ - `docs/爬虫进价/` - 进阶教程文档(待完善)
+ - `docs/高级爬虫/` - 高级教程文档(待完善)
+- `源代码/` - 教程对应的 Python 示例代码
+ - 每个实战章节有独立目录,包含同步和异步两种实现版本
+
+## Python 示例代码依赖
+
+示例代码使用以下主要库:
+- `httpx` - HTTP 请求库
+- `aiomysql` - 异步 MySQL 客户端
+- `aiofiles` - 异步文件操作
+- `pydantic` - 数据验证
+
+## 文档站点
+
+在线文档: https://nanmicoder.github.io/CrawlerTutorial/
diff --git a/README.md b/README.md
index c61eda8..54d3111 100644
--- a/README.md
+++ b/README.md
@@ -1,76 +1,55 @@
-## 关于作者
+# 关于作者
大家好,我是程序员阿江-Relakkes,近期我会给大家出一些爬虫方面的教程,爬虫入门、进阶、高级都有,有需要的朋友,star仓库并持续关注本仓库的更新。
-### 基本信息
-- [Github万星爬虫仓库作者](https://github.com/NanmiCoder/MediaCrawler)
+
+- [Github万星开源自媒体爬虫仓库MediaCrawler作者](https://github.com/NanmiCoder/MediaCrawler)
- 全栈程序员,熟悉Python、Golang、JavaScript,工作中主要用Golang。
- 曾经主导并参与过百万级爬虫采集系统架构设计与编码
- 爬虫是一种技术兴趣爱好,参与爬虫有一种对抗的感觉,越难越兴奋。
-### 视频教程
-> 自媒体账号名: 程序员阿江-Relakkes
-- B站:https://space.bilibili.com/434377496
-- 小红书:https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753
-- 抖音:https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE
-
-### 怎么联系我?
-- Email:relakkes@gmail.com
-- Wechat:yzglan
-- QQ: 524134442
-
-### 支持我
-> 现在工作中基本都是面向GPT编程了,大家帮我注册一下,你们也可以每天获得免费GPT聊天次数。
-
-通过注册这个款免费的GPT助手,帮我获取GPT4额度作为支持。也是我每天在用的一款chrome AI助手插件
-
-
+## 查看教程
+在线链接:https://nanmicoder.github.io/CrawlerTutorial/
+对应的视频链接近期也会同步更新出来,[查看B站合集地址](https://space.bilibili.com/434377496/channel/collectiondetail?sid=4035213&ctype=0)
## 爬虫入门
-### 爬虫入门教程目录大纲
-- [x] [01_为什么要写这个爬虫教程](爬虫入门/01_为什么要写这个爬虫教程.md)
-- [x] [02_个人学会爬虫能赚钱吗](爬虫入门/02_个人学会爬虫能赚钱吗.md)
-- [x] [03_网络爬虫到底是什么](爬虫入门/03_网络爬虫到底是什么.md)
-- [x] [04_爬虫的基本工作原理](爬虫入门/04_爬虫的基本工作原理.md)
-- [x] [05_常用的抓包工具有那些](爬虫入门/05_常用的抓包工具有那些.md)
-- [x] [06_为什么说用Python写爬虫有天生优势](爬虫入门/06_为什么说用Python写爬虫有天生优势.md)
-- [x] [07_Python常见的网络请求库](爬虫入门/07_Python常见的网络请求库.md)
-- [x] [08_爬虫入门实战1_静态网页数据提取](爬虫入门/08_爬虫入门实战1_静态网页数据提取.md)
-- [x] [09_爬虫入门实战2_动态数据提取](爬虫入门/09_爬虫入门实战2_动态数据提取.md)
-- [ ] [10_爬虫入门实战3_数据存储实现](爬虫入门/10_爬虫入门实战3_数据存储实现.md)
-- [ ] [11_爬虫入门实战4_高效率的爬虫实现](爬虫入门/11_爬虫入门实战4_高效率的爬虫实现.md)
-- [ ] [12_爬虫入门实战5_编写易于维护的爬虫代码](爬虫入门/12_爬虫入门实战5_编写易于维护的爬虫代码.md)
+- [✔] [01_为什么要写这个爬虫教程](docs/爬虫入门/01_为什么要写这个爬虫教程.md)
+- [✔] [02_个人学会爬虫能赚钱吗](docs/爬虫入门/02_个人学会爬虫能赚钱吗.md)
+- [✔] [03_网络爬虫到底是什么](docs/爬虫入门/03_网络爬虫到底是什么.md)
+- [✔] [04_爬虫的基本工作原理](docs/爬虫入门/04_爬虫的基本工作原理.md)
+- [✔] [05_常用的抓包工具有那些](docs/爬虫入门/05_常用的抓包工具有那些.md)
+- [✔] [06_为什么说用Python写爬虫有天生优势](docs/爬虫入门/06_为什么说用Python写爬虫有天生优势.md)
+- [✔] [07_Python常见的网络请求库](docs/爬虫入门/07_Python常见的网络请求库.md)
+- [✔] [08_爬虫入门实战1_静态网页数据提取](docs/爬虫入门/08_爬虫入门实战1_静态网页数据提取.md)
+- [✔] [09_爬虫入门实战2_动态数据提取](docs/爬虫入门/09_爬虫入门实战2_动态数据提取.md)
+- [✔] [10_爬虫入门实战3_数据存储实现](docs/爬虫入门/10_爬虫入门实战3_数据存储实现.md)
+- [✔] [11_爬虫入门实战4_高效率的爬虫实现](docs/爬虫入门/11_爬虫入门实战4_高效率的爬虫实现.md)
-## 打赏
-免费开源不易,如果项目帮到你了,可以给我打赏哦,您的支持就是我最大的动力!
-
-

-

-
-
-## 爬虫教程交流群:
-> 7天有效期,自动更新
-
-
-### 爬虫入门教程源代码
-
## 爬虫进阶
-### 爬虫进阶教程目录大纲
-todo
-### 爬虫进阶教程源代码
-todo
+- [✔] [01_工程化爬虫开发规范](docs/爬虫进价/01_工程化爬虫开发规范.md)
+- [✔] [02_反爬虫对抗基础_请求伪装](docs/爬虫进价/02_反爬虫对抗基础_请求伪装.md)
+- [✔] [03_代理IP的使用与管理](docs/爬虫进价/03_代理IP的使用与管理.md)
+- [✔] [04_Playwright浏览器自动化入门](docs/爬虫进价/04_Playwright浏览器自动化入门.md)
+- [✔] [05_Playwright进阶_反检测与性能优化](docs/爬虫进价/05_Playwright进阶_反检测与性能优化.md)
+- [✔] [06_登录认证_Cookie与Session管理](docs/爬虫进价/06_登录认证_Cookie与Session管理.md)
+- [✔] [07_登录认证_扫码与短信登录实现](docs/爬虫进价/07_登录认证_扫码与短信登录实现.md)
+- [✔] [08_验证码识别与处理](docs/爬虫进价/08_验证码识别与处理.md)
+- [✔] [09_数据清洗与预处理](docs/爬虫进价/09_数据清洗与预处理.md)
+- [✔] [10_数据分析与可视化](docs/爬虫进价/10_数据分析与可视化.md)
+- [✔] [11_进阶综合实战项目](docs/爬虫进价/11_进阶综合实战项目.md)
+## 高级爬虫
+- [✖] 待更新...
+## 爬虫交流群
+扫码加作者企微拉进群,备注来自github爬虫教程
-## 高级爬虫
-### 高级爬虫教程目录大纲
-todo
-### 高级爬虫教程源代码
-todo
+
## 免责声明
>本仓库的所有内容仅供学习和参考之用,禁止用于商业用途。任何人或组织不得将本仓库的内容用于非法用途或侵犯他人合法权益。本仓库所涉及的爬虫技术仅用于学习和研究,不得用于对其他平台进行大规模爬虫或其他非法行为。对于因使用本仓库内容而引起的任何法律责任,本仓库不承担任何责任。使用本仓库的内容即表示您同意本免责声明的所有条款和条件。
+
## Star History
-[](https://star-history.com/#NanmiCoder/CrawlerTutorial&Date)
\ No newline at end of file
+[](https://star-history.com/#NanmiCoder/CrawlerTutorial&Date)
diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs
new file mode 100644
index 0000000..b9ad440
--- /dev/null
+++ b/docs/.vitepress/config.mjs
@@ -0,0 +1,92 @@
+import {defineConfig} from 'vitepress'
+import {withMermaid} from 'vitepress-plugin-mermaid'
+
+// https://vitepress.dev/reference/site-config
+export default withMermaid(defineConfig({
+ base: '/CrawlerTutorial/',
+ title: "程序员阿江-Relakkes的爬虫教程",
+ description: "程序员阿江-Relakkes的爬虫教程",
+ lastUpdated: true,
+ head: [
+ [
+ 'script',
+ {async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-B5H6D2HDGK'}
+ ],
+ [
+ 'script',
+ {},
+ `window.dataLayer = window.dataLayer || [];
+ function gtag(){dataLayer.push(arguments);}
+ gtag('js', new Date());
+ gtag('config', 'G-B5H6D2HDGK');`
+ ],
+ [
+ 'script',
+ {async: '', src: 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5210914487984731', crossorigin: 'anonymous'},
+ ]
+ ],
+ themeConfig: {
+ editLink: {
+ pattern: 'https://github.com/NanmiCoder/CrawlerTutorial/tree/main/docs/:path'
+ },
+ search: {
+ provider: 'local'
+ },
+ // https://vitepress.dev/reference/default-theme-config
+ nav: [
+ {text: '首页', link: '/'},
+ {text: 'B站视频课程', link: 'https://space.bilibili.com/434377496/channel/collectiondetail?sid=4035213&ctype=0'},
+ {text: '联系作者', link: 'https://nanmicoder.github.io/MediaCrawler/%E4%BD%9C%E8%80%85%E4%BB%8B%E7%BB%8D.html'},
+ {text: '支持作者', link: 'https://nanmicoder.github.io/MediaCrawler/%E7%9F%A5%E8%AF%86%E4%BB%98%E8%B4%B9%E4%BB%8B%E7%BB%8D.html'},
+ ],
+
+ sidebar: [
+ {
+ text: 'Python爬虫入门',
+ collapsed: false,
+ items: [
+ {text: '01_为什么要写这个爬虫教程', link: '/爬虫入门/01_为什么要写这个爬虫教程'},
+ {text: '02_个人学会爬虫能赚钱吗', link: '/爬虫入门/02_个人学会爬虫能赚钱吗'},
+ {text: '03_网络爬虫到底是什么', link: '/爬虫入门/03_网络爬虫到底是什么'},
+ {text: '04_爬虫的基本工作原理', link: '/爬虫入门/04_爬虫的基本工作原理'},
+ {text: '05_常用的抓包工具有那些', link: '/爬虫入门/05_常用的抓包工具有那些'},
+ {
+ text: '06_Python写爬虫的优势',
+ link: '/爬虫入门/06_为什么说用Python写爬虫有天生优势'
+ },
+ {text: '07_Python常见的网络请求库', link: '/爬虫入门/07_Python常见的网络请求库'},
+ {text: '08_入门实战1_静态网页数据提取', link: '/爬虫入门/08_爬虫入门实战1_静态网页数据提取'},
+ {text: '09_入门实战2_动态数据提取', link: '/爬虫入门/09_爬虫入门实战2_动态数据提取'},
+ {text: '10_入门实战3_数据存储实现', link: '/爬虫入门/10_爬虫入门实战3_数据存储实现'},
+ {text: '11_入门实战4_高效率的爬虫实现', link: '/爬虫入门/11_爬虫入门实战4_高效率的爬虫实现'},
+ {
+ text: '12_入门实战5_编写易于维护的代码',
+ link: '/爬虫入门/12_爬虫入门实战5_编写易于维护的爬虫代码'
+ }
+ ]
+ },
+ {
+ text: 'Python爬虫进阶',
+ collapsed: false,
+ items: [
+ {text: '01_工程化爬虫开发规范', link: '/爬虫进价/01_工程化爬虫开发规范'},
+ {text: '02_反爬虫对抗基础_请求伪装', link: '/爬虫进价/02_反爬虫对抗基础_请求伪装'},
+ {text: '03_代理IP的使用与管理', link: '/爬虫进价/03_代理IP的使用与管理'},
+ {text: '04_Playwright浏览器自动化入门', link: '/爬虫进价/04_Playwright浏览器自动化入门'},
+ {text: '05_Playwright进阶_反检测与性能优化', link: '/爬虫进价/05_Playwright进阶_反检测与性能优化'},
+ {text: '06_登录认证_Cookie与Session管理', link: '/爬虫进价/06_登录认证_Cookie与Session管理'},
+ {text: '07_登录认证_扫码与短信登录实现', link: '/爬虫进价/07_登录认证_扫码与短信登录实现'},
+ {text: '08_验证码识别与处理', link: '/爬虫进价/08_验证码识别与处理'},
+ {text: '09_数据清洗与预处理', link: '/爬虫进价/09_数据清洗与预处理'},
+ {text: '10_数据分析与可视化', link: '/爬虫进价/10_数据分析与可视化'},
+ {text: '11_进阶综合实战项目', link: '/爬虫进价/11_进阶综合实战项目'}
+ ]
+ }
+ ],
+
+ socialLinks: [
+ {icon: 'github', link: 'https://github.com/NanmiCoder/CrawlerTutorial'}
+ ]
+ },
+
+}))
diff --git a/docs/.vitepress/theme/DynamicAds.vue b/docs/.vitepress/theme/DynamicAds.vue
new file mode 100644
index 0000000..3a3cbeb
--- /dev/null
+++ b/docs/.vitepress/theme/DynamicAds.vue
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
diff --git a/docs/.vitepress/theme/MyLayout.vue b/docs/.vitepress/theme/MyLayout.vue
new file mode 100644
index 0000000..517f3b3
--- /dev/null
+++ b/docs/.vitepress/theme/MyLayout.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css
new file mode 100644
index 0000000..3179728
--- /dev/null
+++ b/docs/.vitepress/theme/custom.css
@@ -0,0 +1,10 @@
+/* .vitepress/theme/custom.css */
+/**
+ * Component: Sidebar
+ * -------------------------------------------------------------------------- */
+
+:root {
+ --vp-sidebar-width: 285px;
+ --vp-sidebar-bg-color: var(--vp-c-bg-alt);
+ --vp-aside-width: 300px;
+}
diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js
new file mode 100644
index 0000000..d3eb3e7
--- /dev/null
+++ b/docs/.vitepress/theme/index.js
@@ -0,0 +1,9 @@
+// .vitepress/theme/index.js
+import DefaultTheme from 'vitepress/theme'
+import MyLayout from './MyLayout.vue'
+
+export default {
+ extends: DefaultTheme,
+ // 使用注入插槽的包装组件覆盖 Layout
+ Layout: MyLayout
+}
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..e08ace8
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,54 @@
+# 关于作者
+大家好,我是程序员阿江-Relakkes,近期我会给大家出一些爬虫方面的教程,爬虫入门、进阶、高级都有,有需要的朋友,star仓库并持续关注本仓库的更新。
+
+- [Github万星开源自媒体爬虫仓库MediaCrawler作者](https://github.com/NanmiCoder/MediaCrawler)
+- 全栈程序员,熟悉Python、Golang、JavaScript,工作中主要用Golang。
+- 曾经主导并参与过百万级爬虫采集系统架构设计与编码
+- 爬虫是一种技术兴趣爱好,参与爬虫有一种对抗的感觉,越难越兴奋。
+
+## 爬虫入门
+### 爬虫入门教程目录大纲
+- [✔] [01_为什么要写这个爬虫教程](爬虫入门/01_为什么要写这个爬虫教程.md)
+- [✔] [02_个人学会爬虫能赚钱吗](爬虫入门/02_个人学会爬虫能赚钱吗.md)
+- [✔] [03_网络爬虫到底是什么](爬虫入门/03_网络爬虫到底是什么.md)
+- [✔] [04_爬虫的基本工作原理](爬虫入门/04_爬虫的基本工作原理.md)
+- [✔] [05_常用的抓包工具有那些](爬虫入门/05_常用的抓包工具有那些.md)
+- [✔] [06_Python写爬虫的优势](爬虫入门/06_为什么说用Python写爬虫有天生优势.md)
+- [✔] [07_Python常见的网络请求库](爬虫入门/07_Python常见的网络请求库.md)
+- [✔] [08_入门实战1_静态网页数据提取](爬虫入门/08_爬虫入门实战1_静态网页数据提取.md)
+- [✔] [09_入门实战2_动态数据提取](爬虫入门/09_爬虫入门实战2_动态数据提取.md)
+- [✔] [10_入门实战3_数据存储实现](爬虫入门/10_爬虫入门实战3_数据存储实现.md)
+- [✔] [11_入门实战4_高效率的爬虫实现](爬虫入门/11_爬虫入门实战4_高效率的爬虫实现.md)
+- [✖] [12_入门实战5_编写易于维护的爬虫代码](爬虫入门/12_爬虫入门实战5_编写易于维护的爬虫代码.md)
+
+## 爬虫进阶
+### 爬虫进阶教程目录大纲
+- [✔] [01_工程化爬虫开发规范](爬虫进价/01_工程化爬虫开发规范.md)
+- [✔] [02_反爬虫对抗基础_请求伪装](爬虫进价/02_反爬虫对抗基础_请求伪装.md)
+- [✔] [03_代理IP的使用与管理](爬虫进价/03_代理IP的使用与管理.md)
+- [✔] [04_Playwright浏览器自动化入门](爬虫进价/04_Playwright浏览器自动化入门.md)
+- [✔] [05_Playwright进阶_反检测与性能优化](爬虫进价/05_Playwright进阶_反检测与性能优化.md)
+- [✔] [06_登录认证_Cookie与Session管理](爬虫进价/06_登录认证_Cookie与Session管理.md)
+- [✔] [07_登录认证_扫码与短信登录实现](爬虫进价/07_登录认证_扫码与短信登录实现.md)
+- [✔] [08_验证码识别与处理](爬虫进价/08_验证码识别与处理.md)
+- [✔] [09_数据清洗与预处理](爬虫进价/09_数据清洗与预处理.md)
+- [✔] [10_数据分析与可视化](爬虫进价/10_数据分析与可视化.md)
+- [✔] [11_进阶综合实战项目](爬虫进价/11_进阶综合实战项目.md)
+
+## 高级爬虫
+### 高级爬虫教程目录大纲
+- [✖] 待更新...
+
+## 爬虫交流群
+扫码加作者企微拉进群,备注来自github爬虫教程
+
+
+
+
+## 免责声明
+>本仓库的所有内容仅供学习和参考之用,禁止用于商业用途。任何人或组织不得将本仓库的内容用于非法用途或侵犯他人合法权益。本仓库所涉及的爬虫技术仅用于学习和研究,不得用于对其他平台进行大规模爬虫或其他非法行为。对于因使用本仓库内容而引起的任何法律责任,本仓库不承担任何责任。使用本仓库的内容即表示您同意本免责声明的所有条款和条件。
+
+
+## Star History
+
+[](https://star-history.com/#NanmiCoder/CrawlerTutorial&Date)
diff --git a/static/images/100000001.png b/docs/static/images/100000001.png
similarity index 100%
rename from static/images/100000001.png
rename to docs/static/images/100000001.png
diff --git a/static/images/1000000010.png b/docs/static/images/1000000010.png
similarity index 100%
rename from static/images/1000000010.png
rename to docs/static/images/1000000010.png
diff --git a/static/images/1000000011.png b/docs/static/images/1000000011.png
similarity index 100%
rename from static/images/1000000011.png
rename to docs/static/images/1000000011.png
diff --git a/static/images/1000000012.png b/docs/static/images/1000000012.png
similarity index 100%
rename from static/images/1000000012.png
rename to docs/static/images/1000000012.png
diff --git a/docs/static/images/1000000013.png b/docs/static/images/1000000013.png
new file mode 100644
index 0000000..b3e82e1
Binary files /dev/null and b/docs/static/images/1000000013.png differ
diff --git a/docs/static/images/1000000014.png b/docs/static/images/1000000014.png
new file mode 100644
index 0000000..8d9b293
Binary files /dev/null and b/docs/static/images/1000000014.png differ
diff --git a/static/images/100000002.png b/docs/static/images/100000002.png
similarity index 100%
rename from static/images/100000002.png
rename to docs/static/images/100000002.png
diff --git a/static/images/100000003.png b/docs/static/images/100000003.png
similarity index 100%
rename from static/images/100000003.png
rename to docs/static/images/100000003.png
diff --git a/static/images/100000004.png b/docs/static/images/100000004.png
similarity index 100%
rename from static/images/100000004.png
rename to docs/static/images/100000004.png
diff --git a/static/images/100000005.png b/docs/static/images/100000005.png
similarity index 100%
rename from static/images/100000005.png
rename to docs/static/images/100000005.png
diff --git a/static/images/100000006.png b/docs/static/images/100000006.png
similarity index 100%
rename from static/images/100000006.png
rename to docs/static/images/100000006.png
diff --git a/static/images/100000007.png b/docs/static/images/100000007.png
similarity index 100%
rename from static/images/100000007.png
rename to docs/static/images/100000007.png
diff --git a/static/images/100000008.png b/docs/static/images/100000008.png
similarity index 100%
rename from static/images/100000008.png
rename to docs/static/images/100000008.png
diff --git a/static/images/100000009.png b/docs/static/images/100000009.png
similarity index 100%
rename from static/images/100000009.png
rename to docs/static/images/100000009.png
diff --git a/docs/static/images/QIWEI.png b/docs/static/images/QIWEI.png
new file mode 100644
index 0000000..4c33fba
Binary files /dev/null and b/docs/static/images/QIWEI.png differ
diff --git a/docs/static/images/qrcode/1.JPG b/docs/static/images/qrcode/1.JPG
new file mode 100644
index 0000000..6f88a2a
Binary files /dev/null and b/docs/static/images/qrcode/1.JPG differ
diff --git a/static/images/rumen_08/img.png b/docs/static/images/rumen_08/img.png
similarity index 100%
rename from static/images/rumen_08/img.png
rename to docs/static/images/rumen_08/img.png
diff --git a/static/images/rumen_08/img_1.png b/docs/static/images/rumen_08/img_1.png
similarity index 100%
rename from static/images/rumen_08/img_1.png
rename to docs/static/images/rumen_08/img_1.png
diff --git a/static/images/rumen_08/img_10.png b/docs/static/images/rumen_08/img_10.png
similarity index 100%
rename from static/images/rumen_08/img_10.png
rename to docs/static/images/rumen_08/img_10.png
diff --git a/static/images/rumen_08/img_11.png b/docs/static/images/rumen_08/img_11.png
similarity index 100%
rename from static/images/rumen_08/img_11.png
rename to docs/static/images/rumen_08/img_11.png
diff --git a/static/images/rumen_08/img_12.png b/docs/static/images/rumen_08/img_12.png
similarity index 100%
rename from static/images/rumen_08/img_12.png
rename to docs/static/images/rumen_08/img_12.png
diff --git a/static/images/rumen_08/img_2.png b/docs/static/images/rumen_08/img_2.png
similarity index 100%
rename from static/images/rumen_08/img_2.png
rename to docs/static/images/rumen_08/img_2.png
diff --git a/static/images/rumen_08/img_3.png b/docs/static/images/rumen_08/img_3.png
similarity index 100%
rename from static/images/rumen_08/img_3.png
rename to docs/static/images/rumen_08/img_3.png
diff --git a/static/images/rumen_08/img_4.png b/docs/static/images/rumen_08/img_4.png
similarity index 100%
rename from static/images/rumen_08/img_4.png
rename to docs/static/images/rumen_08/img_4.png
diff --git a/static/images/rumen_08/img_5.png b/docs/static/images/rumen_08/img_5.png
similarity index 100%
rename from static/images/rumen_08/img_5.png
rename to docs/static/images/rumen_08/img_5.png
diff --git a/static/images/rumen_08/img_6.png b/docs/static/images/rumen_08/img_6.png
similarity index 100%
rename from static/images/rumen_08/img_6.png
rename to docs/static/images/rumen_08/img_6.png
diff --git a/static/images/rumen_08/img_7.png b/docs/static/images/rumen_08/img_7.png
similarity index 100%
rename from static/images/rumen_08/img_7.png
rename to docs/static/images/rumen_08/img_7.png
diff --git a/static/images/rumen_08/img_8.png b/docs/static/images/rumen_08/img_8.png
similarity index 100%
rename from static/images/rumen_08/img_8.png
rename to docs/static/images/rumen_08/img_8.png
diff --git a/static/images/rumen_08/img_9.png b/docs/static/images/rumen_08/img_9.png
similarity index 100%
rename from static/images/rumen_08/img_9.png
rename to docs/static/images/rumen_08/img_9.png
diff --git a/static/images/wechat_pay.jpeg b/docs/static/images/wechat_pay.jpeg
similarity index 100%
rename from static/images/wechat_pay.jpeg
rename to docs/static/images/wechat_pay.jpeg
diff --git a/static/images/zfb_pay.png b/docs/static/images/zfb_pay.png
similarity index 100%
rename from static/images/zfb_pay.png
rename to docs/static/images/zfb_pay.png
diff --git "a/docs/static/images/\347\237\245\350\257\206\346\230\237\347\220\203.png" "b/docs/static/images/\347\237\245\350\257\206\346\230\237\347\220\203.png"
new file mode 100644
index 0000000..05a73b1
Binary files /dev/null and "b/docs/static/images/\347\237\245\350\257\206\346\230\237\347\220\203.png" differ
diff --git "a/\347\210\254\350\231\253\345\205\245\351\227\250/01_\344\270\272\344\273\200\344\271\210\350\246\201\345\206\231\350\277\231\344\270\252\347\210\254\350\231\253\346\225\231\347\250\213.md" "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/01_\344\270\272\344\273\200\344\271\210\350\246\201\345\206\231\350\277\231\344\270\252\347\210\254\350\231\253\346\225\231\347\250\213.md"
similarity index 98%
rename from "\347\210\254\350\231\253\345\205\245\351\227\250/01_\344\270\272\344\273\200\344\271\210\350\246\201\345\206\231\350\277\231\344\270\252\347\210\254\350\231\253\346\225\231\347\250\213.md"
rename to "docs/\347\210\254\350\231\253\345\205\245\351\227\250/01_\344\270\272\344\273\200\344\271\210\350\246\201\345\206\231\350\277\231\344\270\252\347\210\254\350\231\253\346\225\231\347\250\213.md"
index ec197fb..4c15483 100644
--- "a/\347\210\254\350\231\253\345\205\245\351\227\250/01_\344\270\272\344\273\200\344\271\210\350\246\201\345\206\231\350\277\231\344\270\252\347\210\254\350\231\253\346\225\231\347\250\213.md"
+++ "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/01_\344\270\272\344\273\200\344\271\210\350\246\201\345\206\231\350\277\231\344\270\252\347\210\254\350\231\253\346\225\231\347\250\213.md"
@@ -1,4 +1,4 @@
-## 为什么写这个爬虫教程?
+# 为什么写这个爬虫教程?
- 满足需求:我的[自媒体平台爬虫](https://github.com/NanmiCoder/MediaCrawler)爆火了之后,越来越多人私信我都是问想要学习爬虫时,我意识到了有很多人对这个爬虫领域感兴趣,但却不知道从何入手。因此决定撰写这个爬虫教程。
- 分享经验:首先我这个人虽然技术一般,但是真的很喜欢分享,平时工作中我学到了什么新知识都迫不及待的想要跟身边同事去分享,所以既然大家有需要,那么我会分享自己在爬虫领域的一些经验知识和见解。
diff --git "a/\347\210\254\350\231\253\345\205\245\351\227\250/02_\344\270\252\344\272\272\345\255\246\344\274\232\347\210\254\350\231\253\350\203\275\350\265\232\351\222\261\345\220\227.md" "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/02_\344\270\252\344\272\272\345\255\246\344\274\232\347\210\254\350\231\253\350\203\275\350\265\232\351\222\261\345\220\227.md"
similarity index 95%
rename from "\347\210\254\350\231\253\345\205\245\351\227\250/02_\344\270\252\344\272\272\345\255\246\344\274\232\347\210\254\350\231\253\350\203\275\350\265\232\351\222\261\345\220\227.md"
rename to "docs/\347\210\254\350\231\253\345\205\245\351\227\250/02_\344\270\252\344\272\272\345\255\246\344\274\232\347\210\254\350\231\253\350\203\275\350\265\232\351\222\261\345\220\227.md"
index 18112da..f897710 100644
--- "a/\347\210\254\350\231\253\345\205\245\351\227\250/02_\344\270\252\344\272\272\345\255\246\344\274\232\347\210\254\350\231\253\350\203\275\350\265\232\351\222\261\345\220\227.md"
+++ "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/02_\344\270\252\344\272\272\345\255\246\344\274\232\347\210\254\350\231\253\350\203\275\350\265\232\351\222\261\345\220\227.md"
@@ -1,4 +1,4 @@
-## 个人学会爬虫能赚钱吗?
+# 个人学会爬虫能赚钱吗?
**先说结论**
>肯定可以赚钱,关键在于挣得多还是少。掌握任何一门技术,都有赚钱的机会,因为技术需求一直存在。
@@ -13,7 +13,7 @@
爬虫挣钱的分类我基于我个人的经验总结了一下,大致分为三类,`兼职小活`、`长期雇佣关系`、`专职爬虫`,下面对这三类都做一个简单介绍
-### 1、兼职小活
+## 1、兼职小活
我们经常能在各种卖爬虫课直播间看到一些主播晒关于接单成交截图之类,其实这些截图我认为有真有假,可能大部分都是真的,因为市场是一个供需关系来的,有需求自然就有提供方,爬虫这个也不例外。
在现如今这个数据为王的时代。做事儿都需要有强有力的数据做支撑然后去做决策,而不是盲目的去做。我打个比方:
@@ -24,7 +24,7 @@
> 这类活我们可以把它统称为:兼职小活,特点:`客单价低`,`大多是一次性需求`,`花的时间相对少`。
-### 2、长期雇佣关系
+## 2、长期雇佣关系
长期雇佣关系这个从字面意思就比较好理解了,基于某一类固定需求,长期给需求方提供服务,这里我举一个我之前兼职遇到的客户吧
她是一个女生,她们公式是xxx相关的业务,她接到了领导的一个需求,需要定期出一份她们公司所在省份的xxx业务数据。
@@ -39,10 +39,10 @@
> 这类活我们可以把它统称为:长期雇佣关系, 特点:`客单价有高有低`,`大多为数据导出清洗需求`,`回购 or 复购`。
-### 3、专职爬虫
-这一类就比较直观了,就是找一个`爬虫工程师`的工作,工作内容一般为:数据采集、数据清晰、爬虫系统设计与架构。
+## 3、专职爬虫
+这一类就比较直观了,就是找一个`爬虫工程师`的工作,工作内容一般为:数据采集、数据清洗、爬虫系统设计与架构。
薪水的话拿一线城市举个例子吧,初级的话大概在8-14k 中高级 15 - 30k,当然这是一个参考区间,有的可能更高,有的可能稍微少点。
> 这一类专职爬虫,通常对技术要求比较高,需要对技术知识积累覆盖面光,前端、后端、数据库、安全、逆向、网络都要去涉及。
-> 当你覆盖了不同的技术知识积累,在面对不对的case时都能很快的给出解决方案,那么恭喜你你的薪水可能就会涨的很快。
\ No newline at end of file
+> 当你覆盖了不同的技术知识积累,在面对不对的case时都能很快的给出解决方案,那么恭喜你你的薪水可能就会涨的很快。
diff --git "a/\347\210\254\350\231\253\345\205\245\351\227\250/03_\347\275\221\347\273\234\347\210\254\350\231\253\345\210\260\345\272\225\346\230\257\344\273\200\344\271\210.md" "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/03_\347\275\221\347\273\234\347\210\254\350\231\253\345\210\260\345\272\225\346\230\257\344\273\200\344\271\210.md"
similarity index 91%
rename from "\347\210\254\350\231\253\345\205\245\351\227\250/03_\347\275\221\347\273\234\347\210\254\350\231\253\345\210\260\345\272\225\346\230\257\344\273\200\344\271\210.md"
rename to "docs/\347\210\254\350\231\253\345\205\245\351\227\250/03_\347\275\221\347\273\234\347\210\254\350\231\253\345\210\260\345\272\225\346\230\257\344\273\200\344\271\210.md"
index a447b68..d69762b 100644
--- "a/\347\210\254\350\231\253\345\205\245\351\227\250/03_\347\275\221\347\273\234\347\210\254\350\231\253\345\210\260\345\272\225\346\230\257\344\273\200\344\271\210.md"
+++ "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/03_\347\275\221\347\273\234\347\210\254\350\231\253\345\210\260\345\272\225\346\230\257\344\273\200\344\271\210.md"
@@ -1,7 +1,7 @@
-## 网络爬虫到底是什么?
+# 网络爬虫到底是什么?

-### 1、看看维基百科的定义
+## 1、看看维基百科的定义
> 网络爬虫(英语:web crawler),也叫网络蜘蛛(spider),是一种用来自动浏览万维网的网络机器人。其目的一般为编纂网络索引。
> 网络搜索引擎等站点通过爬虫软件更新自身的网站内容或其对其他网站的索引。网络爬虫可以将自己所访问的页面保存下来,以便搜索引擎事后生成索引供用户搜索。
> 爬虫访问网站的过程会消耗目标系统资源。不少网络系统并不默许爬虫工作。因此在访问大量页面时,爬虫需要考虑到规划、负载,还需要讲“礼貌”。 不愿意被爬虫访问、被爬虫主人知晓的公开站点可以使用robots.txt文件之类的方法避免访问。这个文件可以要求机器人只对网站的一部分进行索引,或完全不作处理。
@@ -9,7 +9,7 @@
> 爬虫还可以验证超链接和HTML代码,用于网络抓取
-### 2、Google和百度搜索引擎是如何工作的呢?
+## 2、Google和百度搜索引擎是如何工作的呢?
- 首先,网络爬虫持续抓取网站内容,将其存储在搜索引擎的数据库中。
- 紧接着,索引程序对数据库中的网页进行整理,创建倒排索引。
- 最后,当用户输入查询关键词时,搜索程序会在索引中查找相关内容,并通过排序算法(例如Pagerank)将最相关的结果展现给用户。
@@ -22,22 +22,22 @@
为了解决这一问题,搜索引擎和网站之间形成了一种默契——robots.txt协议。网站通过这个文件指明哪些内容可以被爬虫抓取,哪些不可以;同时,搜索引擎在访问网站时会通过User-Agent标识自己的身份(如Googlebot、Baiduspider),以此保持双方的和平共处和互利共赢。
-### 3、爬虫Crawler的职责
-#### 抓取页面(Fetching)
+## 3、爬虫Crawler的职责
+### 抓取页面(Fetching)
网络爬虫会按照一定的规则和算法,访问网站上的页面并下载页面内容。这个过程需要考虑页面的深度、频率、并发请求数量等因素,以确保高效地获取数据。
-#### 解析页面(Parsing)
+### 解析页面(Parsing)
爬虫需要解析下载的页面内容,提取其中的文本、链接、图像等信息。通过解析页面,爬虫可以识别页面结构、内容特征以及与其他页面的关联。
-#### 处理链接(Link Handling)
+### 处理链接(Link Handling)
爬虫在解析页面时会提取页面中的链接,然后根据一定的策略处理这些链接。这包括去重、筛选、调度等操作,以确保爬虫系统能够高效地覆盖目标网站的内容。
-#### 存储数据(Storing)
+### 存储数据(Storing)
爬虫需要将抓取到的数据进行存储,通常存储在数据库或索引中。这样可以为后续的数据处理、索引建立和搜索提供支持。
-#### 遵守规则(Respect Robots.txt)
+### 遵守规则(Respect Robots.txt)
:爬虫需要遵守网站所有者制定的规则,比如robots.txt文件中定义的爬取限制。爬虫需要尊重网站的爬取策略,以避免对目标网站造成不必要的干扰。
-### 4、总结
+## 4、总结
网络爬虫在信息检索系统中扮演着数据搜集和处理的关键角色。通过抓取、解析、处理链接、存储数据、更新索引等一系列操作,网络爬虫为搜索引擎提供了高效的数据支持,帮助用户快速准确地获取所需信息。
\ No newline at end of file
diff --git "a/\347\210\254\350\231\253\345\205\245\351\227\250/04_\347\210\254\350\231\253\347\232\204\345\237\272\346\234\254\345\267\245\344\275\234\345\216\237\347\220\206.md" "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/04_\347\210\254\350\231\253\347\232\204\345\237\272\346\234\254\345\267\245\344\275\234\345\216\237\347\220\206.md"
similarity index 100%
rename from "\347\210\254\350\231\253\345\205\245\351\227\250/04_\347\210\254\350\231\253\347\232\204\345\237\272\346\234\254\345\267\245\344\275\234\345\216\237\347\220\206.md"
rename to "docs/\347\210\254\350\231\253\345\205\245\351\227\250/04_\347\210\254\350\231\253\347\232\204\345\237\272\346\234\254\345\267\245\344\275\234\345\216\237\347\220\206.md"
diff --git "a/\347\210\254\350\231\253\345\205\245\351\227\250/05_\345\270\270\347\224\250\347\232\204\346\212\223\345\214\205\345\267\245\345\205\267\346\234\211\351\202\243\344\272\233.md" "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/05_\345\270\270\347\224\250\347\232\204\346\212\223\345\214\205\345\267\245\345\205\267\346\234\211\351\202\243\344\272\233.md"
similarity index 98%
rename from "\347\210\254\350\231\253\345\205\245\351\227\250/05_\345\270\270\347\224\250\347\232\204\346\212\223\345\214\205\345\267\245\345\205\267\346\234\211\351\202\243\344\272\233.md"
rename to "docs/\347\210\254\350\231\253\345\205\245\351\227\250/05_\345\270\270\347\224\250\347\232\204\346\212\223\345\214\205\345\267\245\345\205\267\346\234\211\351\202\243\344\272\233.md"
index 1c62bca..aac86ef 100644
--- "a/\347\210\254\350\231\253\345\205\245\351\227\250/05_\345\270\270\347\224\250\347\232\204\346\212\223\345\214\205\345\267\245\345\205\267\346\234\211\351\202\243\344\272\233.md"
+++ "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/05_\345\270\270\347\224\250\347\232\204\346\212\223\345\214\205\345\267\245\345\205\267\346\234\211\351\202\243\344\272\233.md"
@@ -32,7 +32,7 @@ Charles是一款广受欢迎的跨平台抓包工具,它可以作为代理服
Charles强大之处在于它能够修改请求或响应,实现更深入的测试和分析。
## 使用Fiddler
-> 这款工具在windows平台很火,我之前在上一家公式做一些爬虫的小需求有用过,整体尚可,但是好像已经开始收费了,免费版的功能又受限。
+> 这款工具在windows平台很火,我之前在上一家公司做一些爬虫的小需求有用过,整体尚可,但是好像已经开始收费了,免费版的功能又受限。
> 具体安装和使用示例可以看这篇文章,写的比较细:[Fiddler安装入门使用示例](https://blog.csdn.net/FourAu/article/details/136479512)
Fiddler同样是一款功能强大的网络请求分析工具,它也可以捕获计算机上的HTTP/HTTPS请求。
diff --git "a/\347\210\254\350\231\253\345\205\245\351\227\250/06_\344\270\272\344\273\200\344\271\210\350\257\264\347\224\250Python\345\206\231\347\210\254\350\231\253\346\234\211\345\244\251\347\224\237\344\274\230\345\212\277.md" "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/06_\344\270\272\344\273\200\344\271\210\350\257\264\347\224\250Python\345\206\231\347\210\254\350\231\253\346\234\211\345\244\251\347\224\237\344\274\230\345\212\277.md"
similarity index 100%
rename from "\347\210\254\350\231\253\345\205\245\351\227\250/06_\344\270\272\344\273\200\344\271\210\350\257\264\347\224\250Python\345\206\231\347\210\254\350\231\253\346\234\211\345\244\251\347\224\237\344\274\230\345\212\277.md"
rename to "docs/\347\210\254\350\231\253\345\205\245\351\227\250/06_\344\270\272\344\273\200\344\271\210\350\257\264\347\224\250Python\345\206\231\347\210\254\350\231\253\346\234\211\345\244\251\347\224\237\344\274\230\345\212\277.md"
diff --git "a/\347\210\254\350\231\253\345\205\245\351\227\250/07_Python\345\270\270\350\247\201\347\232\204\347\275\221\347\273\234\350\257\267\346\261\202\345\272\223.md" "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/07_Python\345\270\270\350\247\201\347\232\204\347\275\221\347\273\234\350\257\267\346\261\202\345\272\223.md"
similarity index 96%
rename from "\347\210\254\350\231\253\345\205\245\351\227\250/07_Python\345\270\270\350\247\201\347\232\204\347\275\221\347\273\234\350\257\267\346\261\202\345\272\223.md"
rename to "docs/\347\210\254\350\231\253\345\205\245\351\227\250/07_Python\345\270\270\350\247\201\347\232\204\347\275\221\347\273\234\350\257\267\346\261\202\345\272\223.md"
index 053ecfd..af1ef51 100644
--- "a/\347\210\254\350\231\253\345\205\245\351\227\250/07_Python\345\270\270\350\247\201\347\232\204\347\275\221\347\273\234\350\257\267\346\261\202\345\272\223.md"
+++ "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/07_Python\345\270\270\350\247\201\347\232\204\347\275\221\347\273\234\350\257\267\346\261\202\345\272\223.md"
@@ -1,4 +1,4 @@
-### 1. 常用的网络请求库
+# 1. 常用的网络请求库
在Python中,进行网络请求的库主要分为同步和异步两大类。
@@ -10,7 +10,7 @@
- `aiohttp`: 支持异步请求的库,使用`asyncio`进行网络通信,适合处理高并发需求。
- `httpx`: 是一个全功能的HTTP客户端,支持HTTP/1.1和HTTP/2,并且同时支持同步和异步接口。
-### 2. 优缺点及适用场景
+## 2. 优缺点及适用场景
- `urllib`:
- **优点**: 标准库,不需要额外安装。
@@ -32,7 +32,7 @@
- **缺点**: 相对较新,社区支持和稳定性正在增强中。
- **适用场景**: 需要同时使用同步和异步请求,或需要HTTP/2支持的应用。
-### 3. Requests和httpx的使用
+## 3. Requests和httpx的使用
> headers、cookies、auth、proxy这几种是我们日常爬虫过程中,经常需要使用的,下面分别基于request和httpx来展示如何使用
- **Requests**:
@@ -86,7 +86,7 @@
httpx 的设计灵感来源于 requests,因此两者在用法上有很多相似之处。这是因为 httpx 的开发者希望提供一个类似于 requests 的简洁、易用的接口,同时又能够支持更多的功能和特性,比如对异步请求的支持以及对 HTTP/2 的原生支持。因此,如果您熟悉 requests 的用法,那么学习和使用 httpx 会变得非常容易和顺畅。
-### 4. 入门示例
+## 4. 入门示例
- **Requests示例**:
@@ -106,7 +106,7 @@ response = httpx.get('https://httpbin.org/get')
print(response.json())
```
-### 5. 实际业务逻辑示例
+## 5. 实际业务逻辑示例
假设我们需要实现一个功能:向`httpbin.org/post`发送POST请求,提交一些数据,并接收响应。
@@ -135,10 +135,10 @@ async def post_data():
asyncio.run(post_data())
```
-### 6. 异步爬虫的趋势
+## 6. 异步爬虫的趋势
Python3.7之后,随着`asyncio`库的成熟和普及,异步编程在Python中变得更加容易实现。异步爬虫可以同时发起和管理成百上千的网络请求,而不会阻塞主线程。这使得编写高性能的爬虫代码不再是难事,尤其是在数据采集、实时数据处理等领域,异步爬虫将会成为一种趋势。
-### 7. 总结
+## 7. 总结
在选择合适的网络请求库时,应考虑实际应用的需求:对于简单或不频繁的网络请求,可以选择`urllib`或`requests`;而在需要处理大量并发连接的场景下,则应考虑使用`aiohttp`或`httpx`。随着Python异步编程的发展,未来异步爬虫无疑会在性能和效率上带来更多优势。
\ No newline at end of file
diff --git "a/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226.md" "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226.md"
similarity index 97%
rename from "\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226.md"
rename to "docs/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226.md"
index c641646..3b56f2e 100644
--- "a/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226.md"
+++ "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226.md"
@@ -1,16 +1,16 @@
-## 爬虫静态网页数据提取
+# 爬虫静态网页数据提取
> 为了照顾一些新入门的朋友,本篇的内容html内容解析会用两个库来完成,一个是`BeautifulSoup` 另一个是我比较喜欢用的`parsel`. 大多数新入门朋友可能学习爬虫的时候,都是从BeautifulSoup这个库开始的。
-### 什么是静态网页
+## 什么是静态网页
静态网页是指内容固定不变的网页,它的内容是直接写在 HTML 文件中的,不会因为用户的请求或者其他因素而改变。静态网页的内容通常由 HTML、CSS 和 JavaScript 组成,服务器只需要将这些文件发送给浏览器,浏览器就可以直接解析并显示网页内容。
-### 静态网页工作原理
+## 静态网页工作原理
>当用户在浏览器中输入一个静态网页的 URL 时,浏览器会向服务器发送一个 HTTP 请求,请求获取该 URL 对应的 HTML 文件。服务器接收到请求后,会在服务器上查找对应的 HTML 文件,并将其内容发送给浏览器。浏览器接收到 HTML 文件后,会解析其中的 HTML、CSS 和 JavaScript 代码,并根据这些代码渲染出网页内容。

-### 爬取静态网页一般需要那些技术
+## 爬取静态网页一般需要那些技术
- 会一点点前端的三件套(html、css、js)不会的朋友可以去菜鸟教程上面看一看,只需要简单的入门,知道html标签的一个结构,css选择器的简单用法,js的话暂时不太需要。
- 会使用网络请求库,比如requests、httpx等
- 会使用html解析库,比如BeautifulSoup、parsel等
@@ -485,6 +485,9 @@ if __name__ == '__main__':
> 存储实现我们留在第10讲再去实现吧
+### 源代码
+[08_爬虫入门实战1_静态网页数据提取 - 章节源代码地址](https://github.com/NanmiCoder/CrawlerTutorial/tree/main/%E6%BA%90%E4%BB%A3%E7%A0%81/%E7%88%AC%E8%99%AB%E5%85%A5%E9%97%A8/08_%E7%88%AC%E8%99%AB%E5%85%A5%E9%97%A8%E5%AE%9E%E6%88%981_%E9%9D%99%E6%80%81%E7%BD%91%E9%A1%B5%E6%95%B0%E6%8D%AE%E6%8F%90%E5%8F%96)
+
### 其他
> 不知不觉的这一篇教程从晚上9点开始的,写到了晚上2.27,存储实现还没写玩,之前写前几篇帖子没什么感觉,到了实战帖子之后,感觉花费的时间多了很多很多。
diff --git "a/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226.md" "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226.md"
similarity index 99%
rename from "\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226.md"
rename to "docs/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226.md"
index eb706cc..36acabf 100644
--- "a/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226.md"
+++ "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226.md"
@@ -2,11 +2,11 @@
在爬虫入门实战1中,我们学习了如何从静态网页中提取数据。这一节,我们将探索动态网页的数据提取,这是爬虫技术中更为高级也更为常见的一个环节。
-# 什么是动态网页
+## 什么是动态网页
动态网页与静态网页不同,其内容是可以根据用户操作、请求参数或者是服务器端的数据变化而变化的。动态网页的内容通常是通过客户端的JavaScrip发起异步请求,由服务端动态返回的数据(json、html)
-# 动态网页工作原理
+## 动态网页工作原理
动态网页的数据加载通常有两种方式:
@@ -15,14 +15,14 @@

-# 爬取动态网页需要的技术
+## 爬取动态网页需要的技术
- 理解AJAX和API请求:动态网页往往通过AJAX请求获取数据,了解这一点对于数据提取至关重要。
- 使用浏览器开发者工具:通过分析网络请求,找出数据加载的具体过程。
- 使用适合动态网页的请求库,如requests、httpx、aiohttp等。
- 使用适合动态网页的驱动库,如Selenium、Puppeteer、Playwirght等,模拟浏览器行为获取数据。
- 学习JavaScript基础,有助于理解网页是如何动态加载数据的。
-# 实战示例1:爬取雅虎财经网站的数字加密货币数据
+## 实战示例1:爬取雅虎财经网站的数字加密货币数据
## 任务需求描述
目标站点URL:https://finance.yahoo.com/crypto
@@ -525,3 +525,5 @@ if __name__ == '__main__':
```
+### 源代码地址
+[09_爬虫入门实战2_动态数据提取 - 章节源代码地址](https://github.com/NanmiCoder/CrawlerTutorial/tree/main/%E6%BA%90%E4%BB%A3%E7%A0%81/%E7%88%AC%E8%99%AB%E5%85%A5%E9%97%A8/09_%E7%88%AC%E8%99%AB%E5%85%A5%E9%97%A8%E5%AE%9E%E6%88%982_%E5%8A%A8%E6%80%81%E6%95%B0%E6%8D%AE%E6%8F%90%E5%8F%96)
diff --git "a/docs/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260.md" "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260.md"
new file mode 100644
index 0000000..83e3594
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260.md"
@@ -0,0 +1,566 @@
+# 爬虫入门实战3 数据存储实现
+数据存储,顾名思义,将我们爬取的数据存储下来,这一章将带着大家一步步的实现一个数据存储的过程
+我们将会使用到两种存储方式,一种是存储到本地文件,另一种是存储到数据库中。
+本地文件又可以分为2种,一种是存储到csv文件,另一种是存储到json文件。
+
+> 你不妨思考一下,如果是你,你会怎么设计你的爬虫程序支持不同的存储方式呢?
+
+## 前置准备
+在正式讨论数据存储之前,我们先来回顾一下在 [动态数据这一章](09_爬虫入门实战2_动态数据提取.md) 是如何获取动态数据。
+
+在动态数据获取这一章,我们是不是爬取了一个雅虎财经方面的加密货币数据,然后将数据保存到CSV文件中了,当时对数据存储没有做过的介绍。
+
+这一章,我们将会详细的讲解数据存储的过程,以及如何将数据存储到数据库中。
+
+回顾上一章我们存储动态数据的容器类定义:
+```python
+from typing import List
+
+class SymbolContent:
+ symbol: str = ""
+ name: str = ""
+ price: str = "" # 价格(盘中)
+ change_price: str = "" # 跌涨价格
+ change_percent: str = "" # 跌涨幅
+ market_price: str = "" # 市值
+
+ @classmethod
+ def get_fields(cls) -> List[str]:
+ return [key for key in cls.__dict__.keys() if not key.startswith('__') and key != "get_fields"]
+
+ def __str__(self):
+ return f"""
+Symbol: {self.symbol}
+Name: {self.name}
+Price: {self.price}
+Change Price: {self.change_price}
+Change Percent: {self.change_percent}
+Market Price: {self.market_price}
+"""
+```
+从上面的容器类定义可以看见,我们已经将爬取到的数据有意识的转换成一个python结构化数据了,但是呢,这个数据是存储在内存中的,我们需要将这个数据存储到文件或者数据库中,这样我们才能更好的利用这些数据。
+
+那么问题来了,当前的SymbolContent类,要支持存储到文件或db中,要做一转换工作,打个比方,如何转成json格式,如何转成python的dict,这些都是我们需要考虑的问题。
+
+幸好的是,在python这边,有一个库,它非常适合做这件事情,那就是[Pydantic](https://docs.pydantic.dev/latest/)库,这个库可以帮助我们将python的数据结构转换成json格式,dict格式,有了dict和json,其实存储CSV、存储数据库都是非常方便的了。
+
+## Pydantic的基本使用介绍
+
+[Pydantic](https://docs.pydantic.dev/latest/)是一个数据验证和序列化库,它可以帮助我们定义数据模型,然后将数据模型转换成json格式,dict格式,这样我们就可以很方便的将数据存储到文件或者数据库中。
+
+使用Pydantic最简单的demo
+```python
+# 导入Pydantic库的BaseModel基类
+from pydantic import BaseModel
+
+# 定义你的模型类
+class User(BaseModel):
+ id: int
+ name: str
+ age: int
+
+# 实例化你的模型类
+user = User(id=1, name="小明", age=18)
+# 将模型类转换成dict
+user_dict = user.model_dump()
+print(type(user_dict), user_dict) # {'id': 1, 'name': '小明', 'age': 18}
+
+# 将模型类转换成json
+user_json = user.model_dump_json()
+print(type(user_json), user_json) # {"id": 1, "name": "小明", "age": 18}
+
+```
+
+相信你从上面例子中可以看出,Pydantic的使用是非常简单的,只需要定义你的模型类,然后实例化你的模型类,然后调用model_dump()方法就可以将模型类转换成dict格式,调用model_dump_json()方法就可以将模型类转换成json格式。
+
+pydantic真的非常非常好用,尤其是在python这门动态类型的脚本语言,使用pydantic能够加强你代码的类型检查,让你的代码更加健壮。更多的使用方法,可以参考[Pydantic官方文档](https://docs.pydantic.dev/latest/)
+
+如果python是你的主力语言的话, 强力推荐!
+
+## 学习目标
+- 设计一个数据存储抽象类
+- 将数据存储到CSV文件
+- 将数据存储到JSON文件
+- 封装一个mysql数据库操作类
+- 将数据存储到数据库
+- 改造动态数据章节的存储代码,支持不同类型存储
+
+
+## 设计原理
+## 流程设计
+
+
+## 存储层类图设计
+> 类图是软件工程的统一建模语言一种静态结构图,该图描述了系统的类集合,类的属性和类之间的关系。
+>
+> 类图是面向对象式的建模。他们一般都被用于概念建模(conceptual modelling)的系统分类的应用程序,并可将模型建模转译成代码。
+>
+> 一个类有三个区域
+> - 最上面是类名称
+> - 中间部分包含类的属性
+> - 底部部分包含类的方法
+> - 为了进一步描述系统的行为,这些类图可以辅之以状态图或UML状态机。
+
+
+
+
+## 代码实现过程
+> 后面源码都用python的异步编程来写吧,这样可以更好的提高代码的执行效率,提高爬虫的效率。而且我比较擅长异步编程,哈哈哈
+
+## SymbolContent类转换成Pydantic模型类
+```python
+from pydantic import BaseModel, Field
+
+class SymbolContent(BaseModel):
+ symbol: str = Field(default="", title="Symbol")
+ name: str = Field(default="", title="Name")
+ price: str = Field(default="", title="价格盘中")
+ change_price: str = Field(default="", title="跌涨价格")
+ change_percent: str = Field(default="", title="跌涨幅")
+ market_price: str = Field(default="", title="市值")
+```
+可以看到 `SymbolContent` 模型类,相较于原来的变化不是很大,只是增加了一些Pydantic的Field字段,这些字段可以帮助我们定义字段的默认值,字段的标题等信息。
+另外差异比较大的是现在的 `SymbolContent` 类,继承了 `BaseModel` 类,这样我们就可以使用Pydantic的一些方法了。
+
+## 设计存储抽象类
+> 抽象的好处:
+>
+> 一个好的抽象可以将大量实现细节隐藏在一个干净,简单易懂的外观下面。一个好的抽象也可以广泛用于各类不同应用。比起重复造很多轮子,重用抽象不仅更有效率,而且有助于开发高质量的软件。抽象组件的质量改进将使所有使用它的应用受益。
+>
+> 例如,高级编程语言是一种抽象,隐藏了机器码、CPU 寄存器和系统调用。SQL 也是一种抽象,隐藏了复杂的磁盘 / 内存数据结构、来自其他客户端的并发请求、崩溃后的不一致性。当然在用高级语言编程时,我们仍然用到了机器码;只不过没有 直接(directly) 使用罢了,正是因为编程语言的抽象,我们才不必去考虑这些实现细节。
+>
+> 抽象可以帮助我们将系统的复杂度控制在可管理的水平,不过,找到好的抽象是非常困难的。在分布式系统领域虽然有许多好的算法,但我们并不清楚它们应该打包成什么样抽象。
+>
+> from ddia
+
+从类图的我们很轻松就能把存储抽象类给定义出来,代码定义如下:
+```python
+from abc import ABC, abstractmethod
+
+from common import SymbolContent
+
+
+class AbstractStore(ABC):
+ @abstractmethod
+ async def save(self, save_item: SymbolContent):
+ """
+ 存储数据
+ :param save_item:
+ :return:
+ """
+ raise NotImplementedError
+```
+其中save方法就是抽象方法,我们需要在子类中实现这个方法,这样我们就可以将数据存储到不同的地方了。
+
+这里多说一点关于python这边的抽象类,
+- python这边提供了一个abc模块,我们可以使用这个模块来定义抽象类,这样我们就可以在子类中实现抽象方法了。
+- @abstractmethod装饰器是一个抽象方法的标志,如果一个类中有抽象方法,那么这个类就是一个抽象类,抽象类不能被实例化,只能被继承。
+- raise NotImplementedError是一个异常,如果子类没有实现抽象方法,那么就会抛出这个异常。
+
+
+## 存储到CSV文件
+首先,从类图看,我们需要实现一个存储到CSV文件的类,这个类需要继承我们的抽象类,然后实现抽象方法,代码如下:
+```python
+import csv
+import pathlib
+import time
+from typing import Dict
+
+import aiofiles
+
+from abstract_store import AbstractStore
+from common import SymbolContent
+
+class CsvStoreImpl(AbstractStore):
+
+ def __init__(self):
+ self.csv_store_path = "data/csv"
+
+ def make_save_file_name(self) -> str:
+ """
+ make save file name
+ :return:
+ """
+ return f"{self.csv_store_path}/symbol_content_{int(time.time())}.csv"
+
+ async def save(self, save_item: SymbolContent):
+ """
+ save data to csv
+ :param save_item:
+ :return:
+ """
+ pathlib.Path(self.csv_store_path).mkdir(parents=True, exist_ok=True)
+ save_file_name = self.make_save_file_name()
+ async with aiofiles.open(save_file_name, mode='a+', encoding="utf-8-sig", newline="") as f:
+ f.fileno()
+ writer = csv.writer(f)
+ save_item_dict: Dict = save_item.model_dump()
+ if await f.tell() == 0:
+ await writer.writerow(save_item_dict.keys())
+ await writer.writerow(save_item_dict.values())
+```
+这个类的实现非常简单,首先我们需要实现save方法,这个方法是抽象方法,我们需要将数据存储到CSV文件中,这里我们使用了aiofiles库来异步写文件,这样可以提高写文件的效率。
+
+另外可以看到函数签名中`save_item`的这个参数的类型是`SymbolContent`,这个是我们定义的数据模型类,这个类是我们爬取到的数据,我们需要将这个数据存储到CSV文件中。
+
+我们是用pydantic的model_dump()方法将数据转换成dict格式,然后使用csv库将数据写入到CSV文件中。使用起来非常的方便。
+
+## 存储到JSON文件
+存储到JSON文件的实现和存储到CSV文件的实现非常类似,只是存储的格式不同,代码如下:
+```python
+import json
+import os
+import pathlib
+import time
+
+
+import aiofiles
+from abstract_store import AbstractStore
+
+from common import SymbolContent
+
+
+class JsonStoreImpl(AbstractStore):
+
+ def __init__(self):
+ self.json_store_path = "data/json"
+
+ def make_save_file_name(self) -> str:
+ """
+ make save file name
+ :return:
+ """
+ return f"{self.json_store_path}/symbol_content_{int(time.time())}.json"
+
+ async def save(self, save_item: SymbolContent):
+ """
+ save data to json
+ :param save_item:
+ :return:
+ """
+ pathlib.Path(self.json_store_path).mkdir(parents=True, exist_ok=True)
+ save_file_name = self.make_save_file_name()
+ save_data_list = []
+ # todo 如果这里涉及并发写入,需要加锁, 可以查看MediaCrawler项目中的实现方式
+ # 先判断文件是否存在,如果存在则读取文件内容放到save_data_list中,然后再将新的数据添加到save_data_list中
+ if os.path.exists(save_file_name):
+ async with aiofiles.open(save_file_name, 'r', encoding='utf-8') as file:
+ save_data_list = json.loads(await file.read())
+ save_data_list.append(save_item.model_dump())
+
+ # 将数据写入到文件中
+ async with aiofiles.open(save_file_name, 'w', encoding='utf-8') as file:
+ await file.write(json.dumps(save_data_list, ensure_ascii=False))
+```
+存储数据到json的实现思路是每一次存储数据的时候,我们都将数据读取出来,然后将新的数据添加到数据中,然后再将数据写入到文件中,这样可以保证数据不会丢失。
+(因为json特殊的数据结构的原因,它无法像存储csv那样可以在文件末尾行追加写入)
+
+## 存储到数据库
+### 封装一个mysql数据库操作类
+> 与mysql数据库交互,我们需要使用到一个库,这个库就是aiomysql,这个库是一个异步的mysql库,可以帮助我们异步的操作mysql数据库,这样可以提高数据库的操作效率。
+>
+> 由于aiomyql提供的方法比较偏底层,并且在我们连接数据库查询到数据之后,我们还要进行部分数据转换,所以为了使用方面,我们可以封装一些基础的数据库操作方法,这样我们在存储数据的时候,就可以直接调用这些方法,而不用关心底层的实现。
+>
+> 主要是增、删、改、查,下面给出我用了几年的一个封装aiomysql代码,供大家参考。
+```python
+# -*- coding: utf-8 -*-
+# @Author : relakkes@gmail.com
+# @Name : 程序员阿江-Relakkes
+# @Time : 2024/6/7 17:08
+# @Desc :
+
+import os
+from typing import Any, Dict, List, Optional, Union
+
+import aiomysql
+
+
+class AsyncMysqlDB:
+ def __init__(self, pool: aiomysql.Pool) -> None:
+ self.__pool = pool
+
+ async def query(self, sql: str, *args: Union[str, int]) -> List[Dict[str, Any]]:
+ """
+ 从给定的 SQL 中查询记录,返回的是一个列表
+ :param sql: 查询的sql
+ :param args: sql中传递动态参数列表
+ :return:
+ """
+ async with self.__pool.acquire() as conn:
+ async with conn.cursor(aiomysql.DictCursor) as cur:
+ await cur.execute(sql, args)
+ data = await cur.fetchall()
+ return data or []
+
+ async def get_first(self, sql: str, *args: Union[str, int]) -> Union[Dict[str, Any], None]:
+ """
+ 从给定的 SQL 中查询记录,返回的是符合条件的第一个结果
+ :param sql: 查询的sql
+ :param args:sql中传递动态参数列表
+ :return:
+ """
+ async with self.__pool.acquire() as conn:
+ async with conn.cursor(aiomysql.DictCursor) as cur:
+ await cur.execute(sql, args)
+ data = await cur.fetchone()
+ return data
+
+ async def item_to_table(self, table_name: str, item: Dict[str, Any]) -> int:
+ """
+ 表中插入数据
+ :param table_name: 表名
+ :param item: 一条记录的字典信息
+ :return:
+ """
+ fields = list(item.keys())
+ values = list(item.values())
+ fields = [f'`{field}`' for field in fields]
+ fieldstr = ','.join(fields)
+ valstr = ','.join(['%s'] * len(item))
+ sql = "INSERT INTO %s (%s) VALUES(%s)" % (table_name, fieldstr, valstr)
+ async with self.__pool.acquire() as conn:
+ async with conn.cursor(aiomysql.DictCursor) as cur:
+ await cur.execute(sql, values)
+ lastrowid = cur.lastrowid
+ return lastrowid
+
+ async def update_table(self, table_name: str, updates: Dict[str, Any], field_where: str,
+ value_where: Union[str, int, float]) -> int:
+ """
+ 更新指定表的记录
+ :param table_name: 表名
+ :param updates: 需要更新的字段和值的 key - value 映射
+ :param field_where: update 语句 where 条件中的字段名
+ :param value_where: update 语句 where 条件中的字段值
+ :return:
+ """
+ upsets = []
+ values = []
+ for k, v in updates.items():
+ s = '`%s`=%%s' % k
+ upsets.append(s)
+ values.append(v)
+ upsets = ','.join(upsets)
+ sql = 'UPDATE %s SET %s WHERE %s="%s"' % (
+ table_name,
+ upsets,
+ field_where, value_where,
+ )
+ async with self.__pool.acquire() as conn:
+ async with conn.cursor() as cur:
+ rows = await cur.execute(sql, values)
+ return rows
+
+ async def execute(self, sql: str, *args: Union[str, int]) -> int:
+ """
+ 需要更新、写入等操作的 excute 执行语句
+ :param sql:
+ :param args:
+ :return:
+ """
+ async with self.__pool.acquire() as conn:
+ async with conn.cursor() as cur:
+ rows = await cur.execute(sql, args)
+ return rows
+
+
+class MysqlConnect:
+ _instance = None
+
+ def __new__(cls, *args, **kwargs):
+ if cls._instance is None:
+ cls._instance = super(MysqlConnect, cls).__new__(cls, *args, **kwargs)
+ return cls._instance
+
+ def __init__(self):
+ self.db: Optional[AsyncMysqlDB] = None
+
+ async def async_init(self):
+ if not hasattr(self, 'db') or self.db is None:
+ pool = await aiomysql.create_pool(
+ **self.mysql_conn_config,
+ autocommit=True,
+ )
+ self.db: AsyncMysqlDB = AsyncMysqlDB(pool)
+ return self
+
+ @property
+ def mysql_conn_config(self) -> Dict[str, str]:
+ return {
+ "host": os.getenv("MYSQL_HOST", "localhost"),
+ "port": int(os.getenv("MYSQL_PORT", 3306)),
+ "user": os.getenv("MYSQL_USER", "root"),
+ "password": os.getenv("MYSQL_PASSWORD", "123456"),
+ "db": os.getenv("MYSQL_DB", "crawler_turorial"),
+ }
+
+ def get_db(self) -> AsyncMysqlDB:
+ return self.db
+
+
+```
+从`AsyncMysqlDB`这个类的构造函数__init__看,它接收一个aiomysql.Pool对象,这个对象是一个连接池对象,我们可以通过这个连接池对象来获取数据库连接,然后执行我们的sql语句。 这样做的好处是,我们创建了一个连接池,可以减少数据库连接的开销,提高数据库的操作效率。
+
+这个类中有几个方法,分别是query、get_first、item_to_table、update_table、execute,这几个方法分别是查询、查询第一个、插入、更新、执行sql语句的方法,这几个方法是我们在存储数据的时候经常会用到的方法,我们可以直接调用这些方法,而不用关心底层的实现。
+
+`MysqlConnect` 这个类是一个单例模式,它的作用是创建一个数据库连接对象,我们可以通过这个对象来操作数据库,这样可以减少数据库连接的开销。
+
+如果想查看AsyncMysqlDB的更多用法,可以查看我在这里写的使用示例:[test_mysql_async_db.py](https://github.com/NanmiCoder/python_common_libs/blob/main/test/test_mysql_async_db.py)
+
+
+### DB存储数据实现
+
+存储到数据库的实现和存储到文件的实现有一些不同,我们需要先将数据存储到内存中(save_item这个模型类),然后再将数据存储到数据库中,代码如下:
+```python
+from typing import Optional
+
+from abstract_store import AbstractStore
+from async_db import MysqlConnect, AsyncMysqlDB
+from common import SymbolContent
+
+
+class DbStoreImpl(AbstractStore):
+ def __init__(self):
+ self.db: Optional[AsyncMysqlDB] = None
+
+ async def save(self, save_item: SymbolContent):
+ """
+ save data to db
+ :param save_item:
+ :return:
+ """
+ self.db = (await MysqlConnect().async_init()).get_db()
+ from sqls import (insert_symbol_content,
+ query_symbol_content_by_symbol,
+ update_symbol_content)
+
+ # 查询是否存在
+ exist_item = await query_symbol_content_by_symbol(self.db, save_item.symbol)
+ if exist_item.symbol:
+ # 更新
+ await update_symbol_content(self.db, save_item)
+ else:
+ # 插入
+ await insert_symbol_content(self.db, save_item)
+```
+从 `DbStoreImpl` 类的save方法的逻辑看,其实非常的简单,就是先查询数据库中是否存在这个数据,如果存在则更新,如果不存在则插入,这样就可以保证数据不会重复。
+
+sqls文件定义:
+```python
+# -*- coding: utf-8 -*-
+# @Author : relakkes@gmail.com
+# @Name : 程序员阿江-Relakkes
+# @Time : 2024/6/7 17:09
+# @Desc :
+
+from async_db import AsyncMysqlDB
+from common import SymbolContent
+
+
+async def insert_symbol_content(db: AsyncMysqlDB, symbol_content: SymbolContent) -> int:
+ """
+ 插入数据
+ :param db:
+ :param symbol_content:
+ :return:
+ """
+ item = symbol_content.model_dump()
+ return await db.item_to_table("symbol_content", item)
+
+
+async def update_symbol_content(db: AsyncMysqlDB, symbol_content: SymbolContent) -> int:
+ """
+ 更新数据
+ :param db:
+ :param symbol_content:
+ :return:
+ """
+ item = symbol_content.model_dump()
+ return await db.update_table("symbol_content", item, "symbol", symbol_content.symbol)
+
+
+async def query_symbol_content_by_symbol(db: AsyncMysqlDB, symbol: str) -> SymbolContent:
+ """
+ 查询数据
+ :param db:
+ :param symbol:
+ :return:
+ """
+ sql = f"select * from symbol_content where symbol = '{symbol}'"
+ rows = await db.query(sql)
+ if len(rows) > 0:
+ return SymbolContent(**rows[0])
+ return SymbolContent()
+```
+存储数据到数据库的好处太多了,比如数据的持久化,数据的查询,数据的分析等等,这些都是存储到文件无法实现的。
+
+所以在我们平时做数据爬取的时候,我建议大家将数据存储到数据库中,这样可以更好的利用数据。
+
+## 动态数据爬虫章节主代码修改
+> 修改动态数据章节的代码,支持将加密货币数据存储到不同的地方
+
+代码改动在`run_crawler`中, 我们从网页中抓取到数据后,遍历每一个数据,然后将数据存储到指定的存储介质中,代码如下
+
+```python
+async def run_crawler(data_save_type: str) -> None:
+ """
+ 爬虫主流程
+ :param data_save_type: 数据存储的类型,支持csv、json、db
+ :return:
+ """
+ # step1 获取最大数据总量
+ max_total: int = await get_max_total_count()
+ # step2 遍历每一页数据并解析存储到数据容器中
+ data_list: List[SymbolContent] = await fetch_currency_data_list(max_total)
+ # step3 将数据保存到指定存储介质中
+ for data_item in data_list:
+ await StoreFactory.get_store(data_save_type).save(data_item)
+
+
+if __name__ == '__main__':
+ _data_save_type = "csv" # 可选配置(csv、json、db)
+ asyncio.run(run_crawler(_data_save_type))
+
+```
+从使用层面看,我们指定了存储类型,根据存储工厂类的设计,我们可以很方便的切换存储类型,这样我们就可以将数据存储到不同的地方了。
+下面给出存储工厂类的实现(设计模式中的简单工厂):
+```python
+class StoreFactory:
+ @staticmethod
+ def get_store(store_type: str) -> AbstractStore:
+ if store_type == "csv":
+ return CsvStoreImpl()
+ elif store_type == "json":
+ return JsonStoreImpl()
+ elif store_type == "db":
+ return DbStoreImpl()
+ else:
+ raise ValueError(f"Unknown store type: {store_type}")
+```
+大家有没有发现一问题,从09章动态数据提取到这一章,我们的代码是不是越来越规范了,越来越好维护了
+
+这就是我们学习的意义,良好的抽象能力,良好的设计能力,能够让我们的代码更加的优雅,更加的易维护。
+
+## 依赖安装 & 代码运行
+### 依赖安装
+```shell
+# 依赖安装,进入到`10_爬虫入门实战3_数据存储实现`目录下,运行以下命令
+# 创建python虚拟环境
+python3 -m venv venv
+# 激活虚拟环境
+source venv/bin/activate
+# 安装依赖
+pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+```
+
+### 代码运行
+```shell
+# 如果是保存的mysql中的话,需要创建数据库和表结构,可以查看源代码目录下的sql文件
+python main.py
+```
+- 如果是以`csv`的方式存储数据,可以查看`data/csv`目录下的文件,
+- 如果是`json`的方式存储数据,可以查看`data/json`目录下的文件,
+- 如果是`db`的方式存储数据,可以查看数据库中的数据。
+
+### 源代码地址
+[10_爬虫入门实战3_数据存储实现 - 章节源代码地址](https://github.com/NanmiCoder/CrawlerTutorial/tree/main/%E6%BA%90%E4%BB%A3%E7%A0%81/%E7%88%AC%E8%99%AB%E5%85%A5%E9%97%A8/10_%E7%88%AC%E8%99%AB%E5%85%A5%E9%97%A8%E5%AE%9E%E6%88%983_%E6%95%B0%E6%8D%AE%E5%AD%98%E5%82%A8%E5%AE%9E%E7%8E%B0)
diff --git "a/docs/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260.md" "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260.md"
new file mode 100644
index 0000000..e327a8d
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260.md"
@@ -0,0 +1,640 @@
+# 爬虫入门实战4_高效率的爬虫实现
+
+在现代爬虫开发中,提高爬取效率是一个永恒的话题。本文将深入探讨Python中的多进程、多线程和协程三种并发编程方式,分析它们在爬虫开发中的应用,并通过实际案例比较它们的性能表现。
+
+## 1. 并发编程概述
+
+### 1.1 多进程(Multiprocessing)
+
+多进程是指在操作系统中同时运行多个独立的进程。每个进程都有自己的内存空间和系统资源。
+
+优点:
+- 可以充分利用多核CPU
+- 绕过Python的全局解释器锁(GIL)
+- 进程间内存隔离,更安全
+
+缺点:
+- 进程创建和切换开销大
+- 进程间通信相对复杂
+- 占用较多系统资源
+
+### 1.2 多线程(Multithreading)
+
+多线程是在同一进程内创建多个线程,共享进程的内存空间。
+
+优点:
+- 资源占用相对较少
+- 线程间共享内存,通信方便
+- 适合I/O密集型任务
+
+缺点:
+- 受Python GIL限制,难以充分利用多核CPU
+- 需要考虑线程安全问题
+- 调试相对困难
+
+### 1.3 协程(Coroutine)
+
+协程是一种用户态的轻量级线程,通过协作式多任务实现并发。
+
+优点:
+- 极低的系统开销
+- 高效处理I/O密集型任务
+- 编程模型简单,易于理解
+
+缺点:
+- 不适合CPU密集型任务
+- 需要特定的库支持(如asyncio)
+- 对于长时间运行的I/O操作可能会阻塞事件循环
+
+## 2. 并发编程的演变
+> 最新的3.13已经支持编译一个无gil版本的python,后面可能python真的要起飞🛫了
+
+Python并发编程的演变历程:
+1. 早期:单线程同步编程
+2. Python 2.x:引入threading模块,支持多线程
+3. Python 2.6+:引入multiprocessing模块,支持多进程
+4. Python 3.4+:引入asyncio模块,支持协程
+5. Python 3.5+:引入async/await语法,简化协程编写
+
+这种演变反映了开发者对更高效、更易用的并发编程方式的不懈追求。
+
+## 3. 最简单的基本示例
+
+### 3.1 多进程示例
+
+```python
+import multiprocessing
+import time
+
+def worker(num):
+ print(f"Worker {num} started")
+ time.sleep(2)
+ print(f"Worker {num} finished")
+
+if __name__ == "__main__":
+ processes = []
+ for i in range(5):
+ p = multiprocessing.Process(target=worker, args=(i,))
+ processes.append(p)
+ p.start()
+
+ for p in processes:
+ p.join()
+
+ print("All processes completed")
+```
+
+### 3.2 多线程示例
+
+```python
+import threading
+import time
+
+def worker(num):
+ print(f"Thread {num} started")
+ time.sleep(2)
+ print(f"Thread {num} finished")
+
+threads = []
+for i in range(5):
+ t = threading.Thread(target=worker, args=(i,))
+ threads.append(t)
+ t.start()
+
+for t in threads:
+ t.join()
+
+print("All threads completed")
+```
+
+### 3.3 协程示例
+
+```python
+import asyncio
+
+async def worker(num):
+ print(f"Coroutine {num} started")
+ await asyncio.sleep(2)
+ print(f"Coroutine {num} finished")
+
+async def main():
+ tasks = [asyncio.create_task(worker(i)) for i in range(5)]
+ await asyncio.gather(*tasks)
+
+asyncio.run(main())
+print("All coroutines completed")
+```
+
+上述的三个示例,在时间上都是2秒,但是在内存上有所不同,多进程的内存占用最大,多线程次之,协程最小。
+
+## 4. 爬虫中的应用
+
+在爬虫开发中,这三种并发方式各有其适用场景:
+
+- 多进程:适合需要绕过GIL、利用多核CPU的场景,如大规模数据处理。
+- 多线程:适合I/O密集型任务,如同时爬取多个网页。
+- 协程:最适合大量并发I/O操作,如高并发的网络请求。
+
+## 5. 实战对比
+
+我们将使用多进程、多线程和协程三种方式实现同一个爬虫任务:爬取Yahoo Finance的加密货币数据。我们会比较它们的性能表现。
+
+如果不熟悉 Yahoo Finance 的加密货币数据 可以回过头去看 [09_爬虫入门实战2_动态数据提取.md](09_爬虫入门实战2_动态数据提取.md) 章节
+
+
+### 5.1 多进程版本实现
+```python
+# -*- coding: utf-8 -*-
+import csv
+import time
+from typing import Any, Dict, List
+from multiprocessing import Pool, cpu_count
+
+import requests
+from common import SymbolContent, make_req_params_and_headers
+
+HOST = "https://query1.finance.yahoo.com"
+SYMBOL_QUERY_API_URI = "/v1/finance/screener"
+PAGE_SIZE = 100 # 可选配置(25, 50, 100)
+
+
+def parse_symbol_content(quote_item: Dict) -> SymbolContent:
+ """
+ 数据提取
+ :param quote_item:
+ :return:
+ """
+ symbol_content = SymbolContent()
+ symbol_content.symbol = quote_item["symbol"]
+ symbol_content.name = quote_item["shortName"]
+ symbol_content.price = quote_item["regularMarketPrice"]["fmt"]
+ symbol_content.change_price = quote_item["regularMarketChange"]["fmt"]
+ symbol_content.change_percent = quote_item["regularMarketChangePercent"]["fmt"]
+ symbol_content.market_price = quote_item["marketCap"]["fmt"]
+ return symbol_content
+
+
+def send_request(page_start: int, page_size: int) -> Dict[str, Any]:
+ """
+ 公共的发送请求的函数
+ :param page_start: 分页起始位置
+ :param page_size: 每一页的长度
+ :return:
+ """
+ # print(f"[send_request] page_start:{page_start}")
+ req_url = HOST + SYMBOL_QUERY_API_URI
+ common_params, headers, common_payload_data = make_req_params_and_headers()
+ # 修改分页变动参数
+ common_payload_data["offset"] = page_start
+ common_payload_data["size"] = page_size
+
+ response = requests.post(url=req_url, params=common_params, json=common_payload_data, headers=headers)
+ if response.status_code != 200:
+ raise Exception("发起请求时发生异常,请求发生错误,原因:", response.text)
+ try:
+ response_dict: Dict = response.json()
+ return response_dict
+ except Exception as e:
+ raise e
+
+
+def fetch_currency_data_single(page_start: int) -> List[SymbolContent]:
+ """
+ Fetch currency data for a single page.
+ :param page_start: Page start index.
+ :return: List of SymbolContent for the page.
+ """
+ try:
+ response_dict: Dict = send_request(page_start=page_start, page_size=PAGE_SIZE)
+ symbol_data_list: List[SymbolContent] = [
+ parse_symbol_content(quote) for quote in response_dict["finance"]["result"][0]["quotes"]
+ ]
+ return symbol_data_list
+ except Exception as e:
+ print(f"Error fetching data for page_start={page_start}: {e}")
+ return []
+
+
+def fetch_currency_data_list(max_total_count: int) -> List[SymbolContent]:
+ """
+ Fetch currency data using multiprocessing.
+ :param max_total_count: Maximum total count of currencies.
+ :return: List of all SymbolContent.
+ """
+ with Pool(processes=cpu_count()) as pool:
+ page_starts = list(range(0, max_total_count, PAGE_SIZE))
+ print(f"总共发起: {len(page_starts)} 次网络请求")
+ results = pool.map(fetch_currency_data_single, page_starts)
+
+ # Flatten the list of lists into a single list
+ return [item for sublist in results for item in sublist]
+
+
+def get_max_total_count() -> int:
+ """
+ 获取所有币种总数量
+ :return:
+ """
+ print("开始获取最大的币种数量")
+ try:
+ response_dict: Dict = send_request(page_start=0, page_size=PAGE_SIZE)
+ total_num: int = response_dict["finance"]["result"][0]["total"]
+ print(f"获取到 {total_num} 种币种")
+ return total_num
+ except Exception as e:
+ print("错误信息:", e)
+ return 0
+
+
+def save_data_to_csv(save_file_name: str, currency_data_list: List[SymbolContent]) -> None:
+ """
+ 保存数据存储到CSV文件中
+ :param save_file_name: 保存的文件名
+ :param currency_data_list:
+ :return:
+ """
+ with open(save_file_name, mode='w', newline='', encoding='utf-8') as file:
+ writer = csv.writer(file)
+ # 写入标题行
+ writer.writerow(SymbolContent.get_fields())
+ # 遍历数据列表,并将每个币种的名称写入CSV
+ for symbol in currency_data_list:
+ writer.writerow([symbol.symbol, symbol.name, symbol.price, symbol.change_price, symbol.change_percent,
+ symbol.market_price])
+
+
+def run_crawler_mp(save_file_name: str) -> None:
+ """
+ 爬虫主流程(多进程版本)
+ :param save_file_name:
+ :return:
+ """
+ # step1 获取最大数据总量
+ max_total: int = get_max_total_count()
+ # step2 遍历每一页数据并解析存储到数据容器中
+ data_list: List[SymbolContent] = fetch_currency_data_list(max_total)
+ # step3 将数据容器中的数据保存csv
+ save_data_to_csv(save_file_name, data_list)
+
+
+if __name__ == '__main__':
+ start_time = time.time()
+ save_csv_file_name = f"symbol_data_{int(start_time)}.csv"
+ run_crawler_mp(save_csv_file_name)
+ end_time = time.time()
+ print(f"多进程执行程序耗时: {end_time - start_time} 秒")
+```
+
+### 5.2 多线程版本
+```python
+# -*- coding: utf-8 -*-
+import csv
+import time
+from os import cpu_count
+from typing import Any, Dict, List
+from concurrent.futures import ThreadPoolExecutor
+
+import requests
+from common import SymbolContent, make_req_params_and_headers
+
+HOST = "https://query1.finance.yahoo.com"
+SYMBOL_QUERY_API_URI = "/v1/finance/screener"
+PAGE_SIZE = 100 # 可选配置(25, 50, 100)
+
+
+def parse_symbol_content(quote_item: Dict) -> SymbolContent:
+ """
+ 数据提取
+ :param quote_item:
+ :return:
+ """
+ symbol_content = SymbolContent()
+ symbol_content.symbol = quote_item["symbol"]
+ symbol_content.name = quote_item["shortName"]
+ symbol_content.price = quote_item["regularMarketPrice"]["fmt"]
+ symbol_content.change_price = quote_item["regularMarketChange"]["fmt"]
+ symbol_content.change_percent = quote_item["regularMarketChangePercent"]["fmt"]
+ symbol_content.market_price = quote_item["marketCap"]["fmt"]
+ return symbol_content
+
+
+def send_request(page_start: int, page_size: int) -> Dict[str, Any]:
+ """
+ 公共的发送请求的函数
+ :param page_start: 分页起始位置
+ :param page_size: 每一页的长度
+ :return:
+ """
+ # print(f"[send_request] page_start:{page_start}")
+ req_url = HOST + SYMBOL_QUERY_API_URI
+ common_params, headers, common_payload_data = make_req_params_and_headers()
+ # 修改分页变动参数
+ common_payload_data["offset"] = page_start
+ common_payload_data["size"] = page_size
+
+ response = requests.post(url=req_url, params=common_params, json=common_payload_data, headers=headers)
+ if response.status_code != 200:
+ raise Exception("发起请求时发生异常,请求发生错误,原因:", response.text)
+ try:
+ response_dict: Dict = response.json()
+ return response_dict
+ except Exception as e:
+ raise e
+
+
+def fetch_currency_data_single(page_start: int) -> List[SymbolContent]:
+ """
+ Fetch currency data for a single page.
+ :param page_start: Page start index.
+ :return: List of SymbolContent for the page.
+ """
+ try:
+ response_dict: Dict = send_request(page_start=page_start, page_size=PAGE_SIZE)
+ return [
+ parse_symbol_content(quote) for quote in response_dict["finance"]["result"][0]["quotes"]
+ ]
+ except Exception as e:
+ print(f"Error fetching data for page_start={page_start}: {e}")
+ return []
+
+
+def fetch_currency_data_list(max_total_count: int) -> List[SymbolContent]:
+ """
+ Fetch currency data using multithreading.
+ :param max_total_count: Maximum total count of currencies.
+ :return: List of all SymbolContent.
+ """
+ with ThreadPoolExecutor(max_workers=cpu_count() * 2) as executor:
+ page_starts = list(range(0, max_total_count, PAGE_SIZE))
+ print(f"总共发起: {len(page_starts)} 次网络请求")
+
+ # 使用 map 方法
+ results = list(executor.map(fetch_currency_data_single, page_starts))
+
+ # 扁平化结果列表
+ return [item for sublist in results for item in sublist]
+
+
+def get_max_total_count() -> int:
+ """
+ 获取所有币种总数量
+ :return:
+ """
+ print("开始获取最大的币种数量")
+ try:
+ response_dict: Dict = send_request(page_start=0, page_size=PAGE_SIZE)
+ total_num: int = response_dict["finance"]["result"][0]["total"]
+ print(f"获取到 {total_num} 种币种")
+ return total_num
+ except Exception as e:
+ print("错误信息:", e)
+ return 0
+
+
+def save_data_to_csv(save_file_name: str, currency_data_list: List[SymbolContent]) -> None:
+ """
+ 保存数据存储到CSV文件中
+ :param save_file_name: 保存的文件名
+ :param currency_data_list:
+ :return:
+ """
+ with open(save_file_name, mode='w', newline='', encoding='utf-8') as file:
+ writer = csv.writer(file)
+ # 写入标题行
+ writer.writerow(SymbolContent.get_fields())
+ # 遍历数据列表,并将每个币种的名称写入CSV
+ for symbol in currency_data_list:
+ writer.writerow([symbol.symbol, symbol.name, symbol.price, symbol.change_price, symbol.change_percent,
+ symbol.market_price])
+
+
+def run_crawler_mt(save_file_name: str) -> None:
+ """
+ 爬虫主流程(多线程版本)
+ :param save_file_name:
+ :return:
+ """
+ # step1 获取最大数据总量
+ max_total: int = get_max_total_count()
+ # step2 遍历每一页数据并解析存储到数据容器中
+ data_list: List[SymbolContent] = fetch_currency_data_list(max_total)
+ # step3 将数据容器中的数据保存csv
+ save_data_to_csv(save_file_name, data_list)
+
+
+if __name__ == '__main__':
+ start_time = time.time()
+ save_csv_file_name = f"symbol_data_{int(start_time)}.csv"
+ run_crawler_mt(save_csv_file_name)
+ end_time = time.time()
+ print(f"多线程执行程序耗时: {end_time - start_time} 秒")
+```
+
+### 5.3 协程版本实现
+```python
+# -*- coding: utf-8 -*-
+import asyncio
+import csv
+import time
+from typing import Any, Dict, List
+
+import aiofiles
+import httpx
+from common import SymbolContent, make_req_params_and_headers
+
+HOST = "https://query1.finance.yahoo.com"
+SYMBOL_QUERY_API_URI = "/v1/finance/screener"
+PAGE_SIZE = 100 # 可选配置(25, 50, 100)
+
+
+def parse_symbol_content(quote_item: Dict) -> SymbolContent:
+ """
+ 数据提取
+ :param quote_item:
+ :return:
+ """
+ symbol_content = SymbolContent()
+ symbol_content.symbol = quote_item["symbol"]
+ symbol_content.name = quote_item["shortName"]
+ symbol_content.price = quote_item["regularMarketPrice"]["fmt"]
+ symbol_content.change_price = quote_item["regularMarketChange"]["fmt"]
+ symbol_content.change_percent = quote_item["regularMarketChangePercent"]["fmt"]
+ symbol_content.market_price = quote_item["marketCap"]["fmt"]
+ return symbol_content
+
+
+async def send_request(page_start: int, page_size: int) -> Dict[str, Any]:
+ """
+ 公共的发送请求的函数
+ :param page_start: 分页起始位置
+ :param page_size: 每一页的长度
+ :return:
+ """
+ # print(f"[send_request] page_start:{page_start}")
+ req_url = HOST + SYMBOL_QUERY_API_URI
+ common_params, headers, common_payload_data = make_req_params_and_headers()
+ # 修改分页变动参数
+ common_payload_data["offset"] = page_start
+ common_payload_data["size"] = page_size
+
+ async with httpx.AsyncClient() as client:
+ response = await client.post(url=req_url, params=common_params, json=common_payload_data, headers=headers,
+ timeout=30)
+ if response.status_code != 200:
+ raise Exception("发起请求时发生异常,请求发生错误,原因:", response.text)
+ try:
+ response_dict: Dict = response.json()
+ return response_dict
+ except Exception as e:
+ raise e
+
+
+async def fetch_currency_data_single(page_start: int) -> List[SymbolContent]:
+ """
+ Fetch currency data for a single page.
+ :param page_start: Page start index.
+ :return: List of SymbolContent for the page.
+ """
+ try:
+ response_dict: Dict = await send_request(page_start=page_start, page_size=PAGE_SIZE)
+ return [
+ parse_symbol_content(quote) for quote in response_dict["finance"]["result"][0]["quotes"]
+ ]
+ except Exception as e:
+ print(f"Error fetching data for page_start={page_start}: {e}")
+ return []
+
+
+async def fetch_currency_data_list(max_total_count: int) -> List[SymbolContent]:
+ """
+ Fetch currency data using asyncio.
+ :param max_total_count: Maximum total count of currencies.
+ :return: List of all SymbolContent.
+ """
+ page_starts = list(range(0, max_total_count, PAGE_SIZE))
+ print(f"总共发起: {len(page_starts)} 次网络请求")
+
+ tasks = [fetch_currency_data_single(page_start) for page_start in page_starts]
+ results = await asyncio.gather(*tasks)
+
+ # 扁平化结果列表
+ return [item for sublist in results for item in sublist]
+
+
+async def get_max_total_count() -> int:
+ """
+ 获取所有币种总数量
+ :return:
+ """
+ print("开始获取最大的币种数量")
+ try:
+ response_dict: Dict = await send_request(page_start=0, page_size=PAGE_SIZE)
+ total_num: int = response_dict["finance"]["result"][0]["total"]
+ print(f"获取到 {total_num} 种币种")
+ return total_num
+ except Exception as e:
+ print("错误信息:", e)
+ return 0
+
+
+async def save_data_to_csv(save_file_name: str, currency_data_list: List[SymbolContent]) -> None:
+ """
+ 保存数据存储到CSV文件中
+ :param save_file_name: 保存的文件名
+ :param currency_data_list:
+ :return:
+ """
+ async with aiofiles.open(save_file_name, mode='w', newline='', encoding='utf-8') as file:
+ writer = csv.writer(file)
+ # 写入标题行
+ await file.write(','.join(SymbolContent.get_fields()) + '\n')
+ # 遍历数据列表,并将每个币种的名称写入CSV
+ for symbol in currency_data_list:
+ await file.write(f"{symbol.symbol},{symbol.name},{symbol.price},{symbol.change_price},{symbol.change_percent},{symbol.market_price}\n")
+
+
+async def run_crawler_async(save_file_name: str) -> None:
+ """
+ 爬虫主流程(异步并发版本)
+ :param save_file_name:
+ :return:
+ """
+ # step1 获取最大数据总量
+ max_total: int = await get_max_total_count()
+ # step2 遍历每一页数据并解析存储到数据容器中
+ data_list: List[SymbolContent] = await fetch_currency_data_list(max_total)
+ # step3 将数据容器中的数据保存csv
+ await save_data_to_csv(save_file_name, data_list)
+
+async def main():
+ """
+ 主函数
+ :return:
+ """
+ start_time = time.time()
+ save_csv_file_name = f"symbol_data_{int(start_time)}.csv"
+ await run_crawler_async(save_csv_file_name)
+ end_time = time.time()
+ print(f"asyncio调度协程执行程序耗时: {end_time - start_time} 秒")
+
+
+if __name__ == '__main__':
+ asyncio.run(main())
+
+
+```
+
+> 上述源代码路径:[11_爬虫入门实战4_高效率的爬虫实现](https://github.com/NanmiCoder/CrawlerTutorial/tree/main/%E6%BA%90%E4%BB%A3%E7%A0%81/%E7%88%AC%E8%99%AB%E5%85%A5%E9%97%A8/11_%E7%88%AC%E8%99%AB%E5%85%A5%E9%97%A8%E5%AE%9E%E6%88%984_%E9%AB%98%E6%95%88%E7%8E%87%E7%9A%84%E7%88%AC%E8%99%AB%E5%AE%9E%E7%8E%B0)
+
+### 5.4 执行耗时
+
+
+#### 5.4.1 多线程执行耗时
+开始获取最大的币种数量
+获取到 9967 种币种
+总共发起: 100 次网络请求
+多线程执行程序耗时: 7.992658853530884 秒
+
+
+#### 5.4.2 多进程执行耗时
+开始获取最大的币种数量
+获取到 9967 种币种
+总共发起: 100 次网络请求
+多进程执行程序耗时: 17.447596073150635 秒
+
+
+#### 5.4.3 协程执行耗时
+开始获取最大的币种数量
+获取到 9967 种币种
+总共发起: 100 次网络请求
+asyncio调度协程执行程序耗时: 4.690491199493408 秒
+
+
+## 6. 上述代码总结
+> 我比较喜欢使用异步协程,因为编程风格很像同步代码,并且还能带来高效率的表现。
+
+### 6.1. 多线程(run_crawler_multi_thread.py)
+适用场景:适合I/O密集型任务,如网络请求,因为线程在等待I/O操作(如网络响应)时可以让出CPU给其他线程。
+实现逻辑:
+- 使用ThreadPoolExecutor来管理线程池。
+- 将任务(获取单页货币数据)分配给线程池中的线程执行。
+- 使用executor.map来并行处理多个页面的数据获取,这个方法会自动处理任务的分配和结果的收集。
+
+### 6.2. 多进程(run_crawler_multi_process.py)
+适用场景:适合CPU密集型任务,但在这个案例中,它用于处理I/O密集型任务,这通常不是最佳选择,因为进程间通信成本较高。
+实现逻辑:
+- 使用multiprocessing.Pool来创建进程池。
+- 类似于多线程,使用pool.map来并行处理多个页面的数据获取。
+- 进程间的数据传递通过序列化和反序列化实现,这可能会引入额外的开销。
+
+### 6.3. 协程(run_crawler_multi_coroutine.py)
+适用场景:非常适合I/O密集型任务,如网络请求。协程通过事件循环和非阻塞I/O操作提高程序的执行效率。
+实现逻辑:
+- 使用asyncio库来管理协程。
+- 使用httpx.AsyncClient进行异步HTTP请求,这允许在等待网络响应时不阻塞程序的其他部分。
+- 使用asyncio.gather来并发执行多个协程,这些协程分别处理不同页面的数据获取。
+
+总结
+- 多线程和多进程都可以处理并发任务,但在处理大量的网络I/O操作时,它们可能不如协程高效。
+- 协程提供了最高的效率和最佳的资源利用率,特别是在处理网络I/O密集型任务时。
+- 在选择并发策略时,应考虑任务的类型(CPU密集型还是I/O密集型)、系统的资源(如CPU核心数)以及程序的复杂性。
\ No newline at end of file
diff --git "a/\347\210\254\350\231\253\345\205\245\351\227\250/12_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2305_\347\274\226\345\206\231\346\230\223\344\272\216\347\273\264\346\212\244\347\232\204\347\210\254\350\231\253\344\273\243\347\240\201.md" "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/12_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2305_\347\274\226\345\206\231\346\230\223\344\272\216\347\273\264\346\212\244\347\232\204\347\210\254\350\231\253\344\273\243\347\240\201.md"
similarity index 100%
rename from "\347\210\254\350\231\253\345\205\245\351\227\250/12_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2305_\347\274\226\345\206\231\346\230\223\344\272\216\347\273\264\346\212\244\347\232\204\347\210\254\350\231\253\344\273\243\347\240\201.md"
rename to "docs/\347\210\254\350\231\253\345\205\245\351\227\250/12_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2305_\347\274\226\345\206\231\346\230\223\344\272\216\347\273\264\346\212\244\347\232\204\347\210\254\350\231\253\344\273\243\347\240\201.md"
diff --git "a/\347\210\254\350\231\253\345\205\245\351\227\250/README.md" "b/docs/\347\210\254\350\231\253\345\205\245\351\227\250/README.md"
similarity index 100%
rename from "\347\210\254\350\231\253\345\205\245\351\227\250/README.md"
rename to "docs/\347\210\254\350\231\253\345\205\245\351\227\250/README.md"
diff --git "a/docs/\347\210\254\350\231\253\350\277\233\344\273\267/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203.md" "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203.md"
new file mode 100644
index 0000000..bbee559
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203.md"
@@ -0,0 +1,1013 @@
+# 工程化爬虫开发规范
+
+> 在入门教程中,我们学会了如何写一个能用的爬虫。但在实际生产环境中,一个"能用"的爬虫远远不够。本章将带你从工程化的角度重新审视爬虫开发,让你的爬虫代码更加健壮、可维护、易于调试。
+
+> **学习目标**:掌握日志系统、配置管理、异常处理和项目结构设计,为后续进阶学习打下坚实基础。
+
+## 为什么需要工程化
+
+当你的爬虫从简单的脚本演变为需要长期运行、多人协作的项目时,以下问题会逐渐浮现:
+
+- **调试困难**:程序出错了,但不知道错在哪里
+- **配置混乱**:API密钥、数据库连接等硬编码在代码中
+- **脆弱性高**:网络波动就导致整个程序崩溃
+- **可读性差**:几个月后自己都看不懂当初写的代码
+
+工程化开发规范正是为了解决这些问题而存在的。本章我们将重点学习:
+
+1. 使用 `loguru` 构建专业的日志系统
+2. 使用 `pydantic-settings` 进行配置管理
+3. 实现统一的异常处理和重试机制
+4. 项目目录结构的最佳实践
+
+### 工程化核心模块
+
+```mermaid
+graph TB
+ subgraph 工程化核心
+ log[日志系统
loguru]
+ config[配置管理
pydantic-settings]
+ exception[异常处理
自定义异常+重试]
+ structure[项目结构
模块化设计]
+ end
+
+ subgraph 解决的问题
+ p1[调试困难]
+ p2[配置混乱]
+ p3[脆弱性高]
+ p4[可读性差]
+ end
+
+ log -->|解决| p1
+ config -->|解决| p2
+ exception -->|解决| p3
+ structure -->|解决| p4
+```
+
+---
+
+## 日志系统设计
+
+### 为什么需要专业的日志系统
+
+在入门教程中,我们大量使用 `print()` 来输出信息。这种方式在调试时很方便,但存在以下问题:
+
+- 无法区分信息的重要程度(调试信息和错误信息混在一起)
+- 无法持久化保存日志
+- 无法在生产环境中动态调整日志级别
+- 无法记录时间、文件位置等上下文信息
+
+`loguru` 是一个优秀的 Python 日志库,它用极简的 API 解决了以上所有问题。
+
+### loguru 基本使用
+
+首先安装 loguru:
+
+```bash
+pip install loguru
+```
+
+最简单的使用方式:
+
+```python
+from loguru import logger
+
+logger.debug("这是一条调试信息")
+logger.info("这是一条普通信息")
+logger.warning("这是一条警告信息")
+logger.error("这是一条错误信息")
+logger.critical("这是一条严重错误信息")
+```
+
+运行后你会看到彩色的、带时间戳和文件位置的日志输出:
+
+```
+2024-03-28 10:30:00.123 | DEBUG | __main__::3 - 这是一条调试信息
+2024-03-28 10:30:00.124 | INFO | __main__::4 - 这是一条普通信息
+2024-03-28 10:30:00.125 | WARNING | __main__::5 - 这是一条警告信息
+2024-03-28 10:30:00.126 | ERROR | __main__::6 - 这是一条错误信息
+2024-03-28 10:30:00.127 | CRITICAL | __main__::7 - 这是一条严重错误信息
+```
+
+### 日志分级策略
+
+在爬虫项目中,建议按以下方式使用日志级别:
+
+```mermaid
+graph LR
+ subgraph 日志级别从低到高
+ DEBUG[DEBUG
调试信息] --> INFO[INFO
正常信息]
+ INFO --> WARNING[WARNING
警告信息]
+ WARNING --> ERROR[ERROR
错误信息]
+ ERROR --> CRITICAL[CRITICAL
致命错误]
+ end
+
+ subgraph 爬虫使用场景
+ d1[请求参数/响应片段] -.-> DEBUG
+ d2[爬取第N页/保存N条] -.-> INFO
+ d3[请求超时重试] -.-> WARNING
+ d4[单页解析失败] -.-> ERROR
+ d5[数据库连接失败] -.-> CRITICAL
+ end
+```
+
+| 级别 | 使用场景 | 爬虫示例 |
+|------|----------|----------|
+| DEBUG | 详细的调试信息,如请求参数、响应内容片段 | 请求URL、响应状态码、响应内容片段 |
+| INFO | 正常的运行信息,如"开始爬取第N页"、"成功保存N条数据" | 爬取进度、数据保存成功 |
+| WARNING | 可恢复的异常,如"请求超时,准备重试" | 请求超时、触发频率限制、需要重试 |
+| ERROR | 严重错误但程序可继续,如"单个页面解析失败" | 单个页面解析失败、数据格式异常 |
+| CRITICAL | 致命错误,程序无法继续,如"数据库连接失败" | 数据库连接失败、认证失效 |
+
+### 日志持久化和轮转
+
+在生产环境中,我们需要将日志保存到文件以便后续分析。loguru 提供了强大的日志文件管理功能:
+
+```python
+from loguru import logger
+import sys
+
+# 移除默认的控制台输出(可选)
+logger.remove()
+
+# 添加控制台输出,只显示 INFO 及以上级别
+logger.add(
+ sys.stderr,
+ level="INFO",
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}"
+)
+
+# 添加文件输出,记录所有级别,按日期轮转
+logger.add(
+ "logs/crawler_{time:YYYY-MM-DD}.log",
+ rotation="00:00", # 每天午夜轮转
+ retention="7 days", # 保留7天的日志
+ compression="zip", # 旧日志压缩
+ level="DEBUG",
+ encoding="utf-8"
+)
+
+# 单独的错误日志文件
+logger.add(
+ "logs/error_{time:YYYY-MM-DD}.log",
+ rotation="00:00",
+ retention="30 days",
+ level="ERROR",
+ encoding="utf-8"
+)
+```
+
+### 在爬虫中使用日志的最佳实践
+
+#### 通用示例
+
+```python
+from loguru import logger
+
+async def fetch_page(client, url: str) -> str:
+ """获取页面内容"""
+ logger.debug(f"准备请求: {url}")
+
+ try:
+ response = await client.get(url)
+ response.raise_for_status()
+ logger.info(f"请求成功: {url}, 状态码: {response.status_code}")
+ return response.text
+ except httpx.TimeoutException:
+ logger.warning(f"请求超时: {url}")
+ raise
+ except httpx.HTTPStatusError as e:
+ logger.error(f"HTTP错误: {url}, 状态码: {e.response.status_code}")
+ raise
+ except Exception as e:
+ logger.exception(f"未知错误: {url}") # exception() 会自动记录堆栈信息
+ raise
+```
+
+#### 爬虫日志实战示例
+
+以下是一个名言网站爬虫的日志使用示例:
+
+```python
+# quotes_crawler.py - 名言网站爬虫日志示例
+from loguru import logger
+import httpx
+
+async def fetch_quotes(client: httpx.AsyncClient, page: int = 1):
+ """
+ 获取名言列表
+
+ 展示在实际爬虫中如何使用日志
+ """
+ url = f"https://quotes.toscrape.com/page/{page}/"
+
+ logger.debug(f"[QuotesCrawler] 请求参数: page={page}")
+
+ try:
+ response = await client.get(url)
+
+ # HTTP状态码处理
+ if response.status_code == 200:
+ logger.info(f"[QuotesCrawler] 获取成功: 第{page}页")
+ return response.text
+ elif response.status_code == 404:
+ logger.warning(f"[QuotesCrawler] 页面不存在: 第{page}页")
+ return None
+ elif response.status_code == 429:
+ logger.warning(f"[QuotesCrawler] 触发频率限制,需要等待")
+ raise Exception("触发频率限制")
+ else:
+ logger.error(f"[QuotesCrawler] HTTP错误: status_code={response.status_code}")
+ return None
+
+ except httpx.TimeoutException:
+ logger.warning(f"[QuotesCrawler] 请求超时: {url}")
+ raise
+ except Exception as e:
+ logger.exception(f"[QuotesCrawler] 未知错误: {e}")
+ raise
+```
+
+> **日志前缀约定**:在爬虫项目中,我们使用 `[模块名]` 作为日志前缀,便于在日志文件中快速定位问题来源,如 `[QuotesCrawler]`、`[DataParser]`、`[DataStore]`。
+
+---
+
+## 配置管理
+
+### 为什么需要配置管理
+
+硬编码的配置散落在代码各处是一种糟糕的实践:
+
+```python
+# 反面示例 - 不要这样做
+client = httpx.AsyncClient(timeout=30)
+db_url = "mysql://root:password123@localhost/crawler"
+API_KEY = "sk-xxxxxxxxxxxx"
+```
+
+这种做法的问题:
+- 敏感信息(密码、API密钥)暴露在代码中
+- 不同环境(开发、测试、生产)需要修改代码
+- 配置分散,难以统一管理
+
+### pydantic-settings 简介
+
+`pydantic-settings` 是 Pydantic 的扩展,专门用于处理应用配置。它支持:
+
+- 从环境变量读取配置
+- 从 `.env` 文件读取配置
+- 配置值的类型验证
+- 敏感信息的安全处理
+
+安装:
+
+```bash
+pip install pydantic-settings
+```
+
+### 配置管理流程
+
+```mermaid
+flowchart LR
+ subgraph 配置来源
+ env[环境变量
CRAWLER_XXX]
+ dotenv[.env 文件
敏感信息]
+ default[代码默认值]
+ end
+
+ subgraph pydantic-settings
+ validate[类型验证]
+ merge[配置合并]
+ end
+
+ subgraph 使用
+ settings[settings 单例]
+ app[应用程序]
+ end
+
+ env --> validate
+ dotenv --> validate
+ default --> validate
+ validate --> merge
+ merge --> settings
+ settings --> app
+```
+
+### 基本使用
+
+创建配置类:
+
+```python
+# config/settings.py
+from pydantic_settings import BaseSettings
+from pydantic import Field
+from typing import Optional
+
+
+class CrawlerSettings(BaseSettings):
+ """爬虫配置"""
+
+ # 基础配置
+ debug: bool = Field(default=False, description="调试模式")
+ log_level: str = Field(default="INFO", description="日志级别")
+
+ # 请求配置
+ request_timeout: int = Field(default=30, description="请求超时时间(秒)")
+ max_retries: int = Field(default=3, description="最大重试次数")
+ retry_delay: float = Field(default=1.0, description="重试延迟(秒)")
+
+ # 并发配置
+ max_concurrent: int = Field(default=10, description="最大并发数")
+
+ # 数据库配置
+ db_host: str = Field(default="localhost", description="数据库主机")
+ db_port: int = Field(default=3306, description="数据库端口")
+ db_user: str = Field(default="root", description="数据库用户")
+ db_password: str = Field(default="", description="数据库密码")
+ db_name: str = Field(default="crawler", description="数据库名")
+
+ # 代理配置
+ proxy_url: Optional[str] = Field(default=None, description="代理地址")
+
+ class Config:
+ env_file = ".env"
+ env_file_encoding = "utf-8"
+ # 环境变量前缀,如 CRAWLER_DEBUG=true
+ env_prefix = "CRAWLER_"
+
+
+# 全局配置实例
+settings = CrawlerSettings()
+```
+
+### 爬虫配置实战示例
+
+以下是名言网站爬虫的配置示例,展示如何组织配置文件:
+
+```python
+# config/settings.py - 爬虫配置
+from pydantic_settings import BaseSettings
+from pydantic import Field
+from typing import Optional
+from enum import Enum
+
+
+class StorageType(str, Enum):
+ """存储类型"""
+ JSON = "json"
+ CSV = "csv"
+
+
+class CrawlerSettings(BaseSettings):
+ """爬虫配置"""
+
+ # 基础配置
+ app_name: str = "QuotesCrawler"
+ debug: bool = False
+ log_level: str = "INFO"
+
+ # 请求配置
+ request_timeout: int = 30
+ max_retries: int = 3
+ retry_delay: float = 1.0
+
+ # 爬虫配置
+ base_url: str = "https://quotes.toscrape.com"
+ max_pages: int = 10
+ max_concurrency: int = 3
+ crawl_delay_min: float = 0.5
+ crawl_delay_max: float = 1.5
+
+ # 存储配置
+ storage_type: StorageType = StorageType.JSON
+ output_dir: str = "./output"
+
+ class Config:
+ env_file = ".env"
+ env_prefix = "CRAWLER_"
+
+
+# 全局配置实例
+settings = CrawlerSettings()
+```
+
+```python
+# config/constants.py - 常量配置
+"""爬虫常量配置"""
+
+# 默认请求头
+DEFAULT_HEADERS = {
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/120.0.0.0 Safari/537.36",
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+}
+
+# HTTP 状态码
+HTTP_OK = 200
+HTTP_NOT_FOUND = 404
+HTTP_TOO_MANY_REQUESTS = 429
+HTTP_SERVER_ERROR = 500
+```
+
+### 使用 .env 文件
+
+创建 `.env` 文件(记得添加到 `.gitignore`):
+
+```env
+# .env
+CRAWLER_DEBUG=true
+CRAWLER_LOG_LEVEL=DEBUG
+CRAWLER_REQUEST_TIMEOUT=60
+CRAWLER_DB_PASSWORD=your_secret_password
+CRAWLER_PROXY_URL=http://127.0.0.1:7890
+```
+
+在代码中使用配置:
+
+```python
+from config.settings import settings
+from loguru import logger
+
+# 根据配置设置日志级别
+logger.add("logs/app.log", level=settings.log_level)
+
+# 使用配置创建客户端
+client = httpx.AsyncClient(
+ timeout=settings.request_timeout,
+ proxies=settings.proxy_url
+)
+
+logger.info(f"爬虫启动,调试模式: {settings.debug}")
+```
+
+### 多环境配置
+
+对于需要区分开发、测试、生产环境的项目,可以这样组织:
+
+```python
+# config/settings.py
+import os
+from pydantic_settings import BaseSettings
+from pydantic import Field
+
+
+class BaseConfig(BaseSettings):
+ """基础配置"""
+ env: str = Field(default="development", description="运行环境")
+ debug: bool = False
+ log_level: str = "INFO"
+
+ class Config:
+ env_file = ".env"
+ env_prefix = "CRAWLER_"
+
+
+class DevelopmentConfig(BaseConfig):
+ """开发环境配置"""
+ debug: bool = True
+ log_level: str = "DEBUG"
+
+
+class ProductionConfig(BaseConfig):
+ """生产环境配置"""
+ debug: bool = False
+ log_level: str = "WARNING"
+
+
+def get_settings() -> BaseConfig:
+ """根据环境变量返回对应的配置"""
+ env = os.getenv("CRAWLER_ENV", "development")
+ config_map = {
+ "development": DevelopmentConfig,
+ "production": ProductionConfig,
+ }
+ config_class = config_map.get(env, DevelopmentConfig)
+ return config_class()
+
+
+settings = get_settings()
+```
+
+---
+
+## 异常处理与重试机制
+
+### 异常处理流程
+
+```mermaid
+flowchart TD
+ request[发起请求] --> response{响应状态}
+
+ response -->|成功| parse[解析数据]
+ response -->|超时| retry{重试?}
+ response -->|HTTP错误| check_code{检查错误码}
+
+ retry -->|是| request
+ retry -->|否| fail[记录失败]
+
+ check_code -->|429| rate_limit[触发限流
等待后重试]
+ check_code -->|412| risk_control[触发风控
切换IP/降速]
+ check_code -->|401/403| auth_error[认证失败
重新登录]
+ check_code -->|其他| fail
+
+ parse --> save[保存数据]
+
+ rate_limit --> request
+ risk_control --> request
+```
+
+### 自定义异常类
+
+为爬虫项目定义专门的异常类,便于区分和处理不同类型的错误:
+
+```python
+# exceptions.py
+
+class CrawlerException(Exception):
+ """爬虫基础异常类"""
+ pass
+
+
+class RequestException(CrawlerException):
+ """请求相关异常"""
+ pass
+
+
+class ParseException(CrawlerException):
+ """解析相关异常"""
+ pass
+
+
+class StorageException(CrawlerException):
+ """存储相关异常"""
+ pass
+
+
+class RateLimitException(RequestException):
+ """触发速率限制"""
+ pass
+
+
+class IPBlockedException(RequestException):
+ """IP被封禁"""
+ pass
+
+
+class LoginRequiredException(CrawlerException):
+ """需要登录"""
+ pass
+```
+
+### 爬虫异常定义实战
+
+以下是通用爬虫的异常类设计示例:
+
+```python
+# exceptions/crawler_exceptions.py
+"""爬虫异常类"""
+
+
+class CrawlerException(Exception):
+ """爬虫基础异常"""
+ pass
+
+
+class RequestException(CrawlerException):
+ """请求相关异常"""
+ pass
+
+
+class ParseException(CrawlerException):
+ """解析相关异常"""
+ pass
+
+
+class StorageException(CrawlerException):
+ """存储相关异常"""
+ pass
+
+
+class RateLimitException(RequestException):
+ """触发速率限制"""
+ pass
+
+
+class IPBlockedException(RequestException):
+ """IP被封禁"""
+ pass
+
+
+class PageNotFoundException(RequestException):
+ """页面不存在"""
+ pass
+
+
+# HTTP 状态码映射
+HTTP_ERROR_MESSAGES = {
+ 400: "请求参数错误",
+ 401: "未授权访问",
+ 403: "禁止访问",
+ 404: "页面不存在",
+ 429: "请求过于频繁",
+ 500: "服务器内部错误",
+ 502: "网关错误",
+ 503: "服务暂时不可用",
+}
+```
+
+### 使用 tenacity 实现重试
+
+`tenacity` 是一个强大的重试库,比手写重试逻辑更加优雅和可靠。
+
+安装:
+
+```bash
+pip install tenacity
+```
+
+基本使用:
+
+```python
+from tenacity import (
+ retry,
+ stop_after_attempt,
+ wait_exponential,
+ retry_if_exception_type,
+ before_sleep_log
+)
+from loguru import logger
+import httpx
+
+from exceptions import RequestException, RateLimitException
+
+
+@retry(
+ stop=stop_after_attempt(3), # 最多重试3次
+ wait=wait_exponential(multiplier=1, max=10), # 指数退避,最长等待10秒
+ retry=retry_if_exception_type(( # 只对特定异常重试
+ httpx.TimeoutException,
+ httpx.ConnectError,
+ RequestException
+ )),
+ before_sleep=before_sleep_log(logger, "WARNING") # 重试前记录日志
+)
+async def fetch_with_retry(client: httpx.AsyncClient, url: str) -> str:
+ """带重试的请求函数"""
+ response = await client.get(url)
+
+ # 检查是否触发速率限制
+ if response.status_code == 429:
+ raise RateLimitException("触发速率限制")
+
+ response.raise_for_status()
+ return response.text
+```
+
+### 更复杂的重试策略
+
+```python
+from tenacity import (
+ retry,
+ stop_after_attempt,
+ wait_random_exponential,
+ retry_if_exception_type,
+ RetryError
+)
+
+
+def create_retry_decorator(max_attempts: int = 3, max_wait: int = 60):
+ """创建可配置的重试装饰器"""
+ return retry(
+ stop=stop_after_attempt(max_attempts),
+ wait=wait_random_exponential(multiplier=1, max=max_wait),
+ retry=retry_if_exception_type((
+ httpx.TimeoutException,
+ httpx.ConnectError,
+ httpx.HTTPStatusError,
+ )),
+ reraise=True # 重试用尽后重新抛出异常
+ )
+
+
+# 使用
+@create_retry_decorator(max_attempts=5, max_wait=30)
+async def fetch_important_data(client, url):
+ """重要数据获取,使用更多重试次数"""
+ response = await client.get(url)
+ response.raise_for_status()
+ return response.json()
+```
+
+### 全局异常处理
+
+在爬虫主程序中实现全局异常处理:
+
+```python
+import asyncio
+from loguru import logger
+from exceptions import CrawlerException, IPBlockedException
+
+
+async def run_crawler():
+ """爬虫主程序"""
+ try:
+ # 爬虫逻辑
+ await crawl_all_pages()
+ except IPBlockedException as e:
+ logger.critical(f"IP被封禁,程序终止: {e}")
+ # 可以在这里触发告警通知
+ raise
+ except CrawlerException as e:
+ logger.error(f"爬虫异常: {e}")
+ raise
+ except asyncio.CancelledError:
+ logger.warning("任务被取消")
+ raise
+ except Exception as e:
+ logger.exception(f"未预期的异常: {e}")
+ raise
+ finally:
+ logger.info("爬虫任务结束,执行清理工作...")
+ # 清理资源
+ await cleanup()
+
+
+if __name__ == "__main__":
+ try:
+ asyncio.run(run_crawler())
+ except KeyboardInterrupt:
+ logger.info("用户中断程序")
+ except Exception as e:
+ logger.critical(f"程序异常退出: {e}")
+ exit(1)
+```
+
+---
+
+## 项目目录结构
+
+### 项目架构图
+
+```mermaid
+graph TB
+ subgraph 入口层
+ main[main.py
程序入口]
+ end
+
+ subgraph 核心模块
+ config[config/
配置管理]
+ crawler[crawler/
爬虫逻辑]
+ store[store/
数据存储]
+ end
+
+ subgraph 功能模块
+ login[login/
登录认证]
+ client[client/
API客户端]
+ analysis[analysis/
数据分析]
+ end
+
+ subgraph 基础模块
+ core[core/
浏览器管理]
+ tools[tools/
工具函数]
+ models[models/
数据模型]
+ exceptions[exceptions/
异常定义]
+ end
+
+ main --> config
+ main --> crawler
+ main --> store
+ main --> analysis
+
+ crawler --> login
+ crawler --> client
+
+ login --> core
+ client --> tools
+ tools --> models
+```
+
+### 推荐的目录结构
+
+一个工程化的爬虫项目应该有清晰的目录结构:
+
+```
+my_crawler/
+├── config/ # 配置模块
+│ ├── __init__.py
+│ └── settings.py # 配置定义
+├── core/ # 核心模块
+│ ├── __init__.py
+│ ├── client.py # HTTP客户端封装
+│ └── retry.py # 重试策略
+├── crawler/ # 爬虫模块
+│ ├── __init__.py
+│ ├── base.py # 爬虫基类
+│ └── xxx_crawler.py # 具体爬虫实现
+├── parser/ # 解析模块
+│ ├── __init__.py
+│ └── xxx_parser.py # 页面解析器
+├── store/ # 存储模块
+│ ├── __init__.py
+│ ├── base.py # 存储基类
+│ ├── mysql.py # MySQL存储
+│ └── json_store.py # JSON文件存储
+├── models/ # 数据模型
+│ ├── __init__.py
+│ └── xxx_model.py # Pydantic模型定义
+├── exceptions/ # 异常定义
+│ ├── __init__.py
+│ └── crawler_exceptions.py
+├── utils/ # 工具函数
+│ ├── __init__.py
+│ └── helpers.py
+├── logs/ # 日志目录
+├── data/ # 数据输出目录
+├── tests/ # 测试目录
+│ └── test_xxx.py
+├── .env # 环境变量(不提交到git)
+├── .env.example # 环境变量示例
+├── .gitignore
+├── requirements.txt # 依赖列表
+├── main.py # 程序入口
+└── README.md # 项目说明
+```
+
+### 模块划分原则
+
+1. **单一职责**:每个模块只负责一件事
+2. **高内聚低耦合**:相关代码放在一起,模块间依赖最小化
+3. **抽象与实现分离**:定义基类/接口,方便扩展和替换
+
+---
+
+## 实战案例:工程化改造
+
+让我们将入门教程中的爬虫进行工程化改造。
+
+### 改造前(入门教程版本)
+
+```python
+# 入门教程的简单版本
+import httpx
+from parsel import Selector
+
+async def crawl():
+ async with httpx.AsyncClient() as client:
+ response = await client.get("https://example.com")
+ print(f"状态码: {response.status_code}")
+ # 解析...
+ # 存储...
+```
+
+### 改造后(工程化版本)
+
+项目结构:
+
+```
+refactored_crawler/
+├── config/
+│ ├── __init__.py
+│ └── settings.py
+├── exceptions.py
+├── logger_config.py
+├── client.py
+├── crawler.py
+└── main.py
+```
+
+核心代码示例:
+
+```python
+# logger_config.py
+import sys
+from loguru import logger
+from config.settings import settings
+
+
+def setup_logger():
+ """配置日志系统"""
+ # 移除默认处理器
+ logger.remove()
+
+ # 控制台输出
+ logger.add(
+ sys.stderr,
+ level=settings.log_level,
+ format="{time:YYYY-MM-DD HH:mm:ss} | "
+ "{level: <8} | "
+ "{name}:{function}:{line} - "
+ "{message}"
+ )
+
+ # 文件输出
+ logger.add(
+ f"logs/crawler_{settings.env}.log",
+ rotation="00:00",
+ retention="7 days",
+ level="DEBUG",
+ encoding="utf-8"
+ )
+
+ return logger
+```
+
+```python
+# client.py
+import httpx
+from tenacity import retry, stop_after_attempt, wait_exponential
+from loguru import logger
+from config.settings import settings
+from exceptions import RequestException
+
+
+class CrawlerClient:
+ """封装的HTTP客户端"""
+
+ def __init__(self):
+ self.client = httpx.AsyncClient(
+ timeout=settings.request_timeout,
+ headers={
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
+ }
+ )
+
+ @retry(
+ stop=stop_after_attempt(settings.max_retries),
+ wait=wait_exponential(multiplier=1, max=10)
+ )
+ async def get(self, url: str) -> httpx.Response:
+ """发送GET请求,带重试"""
+ logger.debug(f"请求: {url}")
+ response = await self.client.get(url)
+ response.raise_for_status()
+ logger.info(f"请求成功: {url}")
+ return response
+
+ async def close(self):
+ """关闭客户端"""
+ await self.client.aclose()
+ logger.debug("HTTP客户端已关闭")
+```
+
+```python
+# main.py
+import asyncio
+from loguru import logger
+from logger_config import setup_logger
+from crawler import BBSCrawler
+from config.settings import settings
+
+
+async def main():
+ """主程序入口"""
+ # 初始化日志
+ setup_logger()
+
+ logger.info(f"爬虫启动 - 环境: {settings.env}, 调试模式: {settings.debug}")
+
+ crawler = BBSCrawler()
+ try:
+ await crawler.run()
+ except Exception as e:
+ logger.exception(f"爬虫运行异常: {e}")
+ raise
+ finally:
+ await crawler.cleanup()
+ logger.info("爬虫运行结束")
+
+
+if __name__ == "__main__":
+ try:
+ asyncio.run(main())
+ except KeyboardInterrupt:
+ logger.info("用户中断")
+```
+
+---
+
+## 本章小结
+
+本章我们学习了爬虫工程化开发的核心内容:
+
+1. **日志系统**:使用 loguru 替代 print,实现分级日志、日志轮转和持久化
+2. **配置管理**:使用 pydantic-settings 统一管理配置,支持环境变量和 .env 文件
+3. **异常处理**:自定义异常类,使用 tenacity 实现优雅的重试机制
+4. **项目结构**:遵循单一职责原则,合理划分模块
+
+这些工程化实践将贯穿整个进阶教程,是后续学习的基础。
+
+---
+
+## 下一章预告
+
+下一章我们将学习「反爬虫对抗基础:请求伪装」。主要内容包括:
+
+- 常见的反爬虫检测手段
+- User-Agent 随机轮换
+- 完整的请求头伪装
+- 使用 curl_cffi 模拟浏览器指纹
+- 智能速率控制
+
+这将是我们与反爬虫斗争的第一步,让我们的爬虫不再那么容易被识别和封禁。
diff --git "a/docs/\347\210\254\350\231\253\350\277\233\344\273\267/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205.md" "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205.md"
new file mode 100644
index 0000000..7b73e7b
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205.md"
@@ -0,0 +1,1009 @@
+# 反爬虫对抗基础:请求伪装
+
+> 当你开始爬取一些有价值的网站时,很快就会发现:不是所有网站都欢迎爬虫。反爬虫与反反爬虫的对抗,是爬虫工程师必须面对的课题。本章我们将学习最基础也最重要的反爬对抗技术——请求伪装。
+
+> **学习目标**:掌握 User-Agent 轮换、请求头伪装、TLS 指纹模拟和速率控制等核心技术,让你的爬虫不易被识别。
+
+## 反爬虫机制概述
+
+### 反爬检测流程
+
+```mermaid
+flowchart TD
+ request[爬虫发起请求] --> check1{User-Agent检测}
+ check1 -->|异常UA| block1[直接拒绝]
+ check1 -->|正常UA| check2{请求头检测}
+
+ check2 -->|缺少必要头| block2[返回错误]
+ check2 -->|请求头正常| check3{频率检测}
+
+ check3 -->|频率过高| block3[触发限流 429]
+ check3 -->|频率正常| check4{Cookie检测}
+
+ check4 -->|无Cookie/过期| block4[要求登录 401]
+ check4 -->|Cookie有效| check5{其他检测}
+
+ check5 -->|检测失败| block5[返回 403]
+ check5 -->|检测通过| success[返回数据]
+
+ style block1 fill:#f66
+ style block2 fill:#f66
+ style block3 fill:#f96
+ style block4 fill:#f96
+ style block5 fill:#f66
+ style success fill:#6f6
+```
+
+### 为什么网站要反爬虫
+
+在开始学习反爬技术之前,我们需要理解网站为什么要反爬虫:
+
+1. **保护数据资产**:数据是有价值的,网站不希望被批量获取
+2. **保护服务器资源**:爬虫会消耗服务器带宽和计算资源
+3. **防止恶意行为**:如价格监控、竞品分析、数据倒卖等
+4. **合规要求**:某些数据有法律保护要求
+
+### 常见的反爬虫检测手段
+
+```mermaid
+graph LR
+ subgraph 低难度
+ ua[UA检测]
+ headers[请求头检测]
+ end
+
+ subgraph 中难度
+ freq[频率检测]
+ cookie[Cookie检测]
+ sign[API签名检测]
+ end
+
+ subgraph 高难度
+ tls[TLS指纹检测]
+ js[JS环境检测]
+ captcha[验证码]
+ end
+
+ ua --> headers --> freq --> cookie --> sign --> tls --> js --> captcha
+```
+
+| 检测类型 | 检测方式 | 难度 | 常见应对 |
+|---------|---------|------|---------|
+| 请求特征检测 | User-Agent、请求头完整性 | 低 | 伪装请求头 |
+| 行为特征检测 | 访问频率、访问路径 | 中 | 速率控制、随机延迟 |
+| API签名检测 | 参数签名验证 | 中 | 逆向签名算法 |
+| Cookie检测 | 登录状态验证 | 中 | 登录获取Cookie |
+| 浏览器指纹检测 | JS 环境、Canvas、WebGL | 高 | 使用真实浏览器 |
+| 验证码检测 | 图片验证码、滑块验证码 | 高 | OCR、打码平台 |
+
+本章主要聚焦于**请求特征检测**的对抗,这是最基础的反爬手段。
+
+---
+
+## User-Agent 策略
+
+### 什么是 User-Agent
+
+User-Agent(简称 UA)是 HTTP 请求头中的一个字段,用于标识发起请求的客户端类型。例如:
+
+```
+Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
+```
+
+这个字符串包含了:
+- 浏览器类型(Chrome)
+- 浏览器版本(120.0.0.0)
+- 操作系统(Windows 10 64位)
+- 渲染引擎(AppleWebKit/537.36)
+
+### 为什么要轮换 User-Agent
+
+如果你的爬虫始终使用同一个 UA,会有以下风险:
+
+1. **特征明显**:Python 默认的 UA 是 `python-requests/2.x.x`,一眼就能识别
+2. **容易被追踪**:同一 UA 的大量请求会被关联分析
+3. **容易被封禁**:一旦被识别,可以按 UA 封禁
+
+### 实现 User-Agent 轮换
+
+#### 方法一:手动维护 UA 列表
+
+```python
+import random
+
+# 桌面浏览器 UA 列表
+DESKTOP_USER_AGENTS = [
+ # Chrome Windows
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
+ # Chrome Mac
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ # Firefox Windows
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
+ # Firefox Mac
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0",
+ # Safari Mac
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
+ # Edge Windows
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
+]
+
+# 移动端 UA 列表
+MOBILE_USER_AGENTS = [
+ # iPhone Safari
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1",
+ # Android Chrome
+ "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
+ # Android Samsung
+ "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
+]
+
+
+def get_random_ua(mobile: bool = False) -> str:
+ """获取随机 User-Agent"""
+ ua_list = MOBILE_USER_AGENTS if mobile else DESKTOP_USER_AGENTS
+ return random.choice(ua_list)
+```
+
+#### 方法二:使用 fake-useragent 库
+
+```bash
+pip install fake-useragent
+```
+
+```python
+from fake_useragent import UserAgent
+
+# 创建 UserAgent 对象
+ua = UserAgent()
+
+# 获取随机 UA
+print(ua.random) # 完全随机
+print(ua.chrome) # 随机 Chrome UA
+print(ua.firefox) # 随机 Firefox UA
+print(ua.safari) # 随机 Safari UA
+```
+
+#### 实现 UA 轮换器
+
+```python
+import random
+from typing import List, Optional
+from fake_useragent import UserAgent
+
+
+class UARotator:
+ """User-Agent 轮换器"""
+
+ def __init__(self, use_fake_ua: bool = True, custom_uas: Optional[List[str]] = None):
+ """
+ 初始化 UA 轮换器
+
+ Args:
+ use_fake_ua: 是否使用 fake-useragent 库
+ custom_uas: 自定义 UA 列表
+ """
+ self.use_fake_ua = use_fake_ua
+ self.custom_uas = custom_uas or []
+
+ if use_fake_ua:
+ try:
+ self._fake_ua = UserAgent()
+ except Exception:
+ self.use_fake_ua = False
+
+ def get_random(self) -> str:
+ """获取随机 UA"""
+ # 优先使用自定义列表
+ if self.custom_uas:
+ return random.choice(self.custom_uas)
+
+ # 使用 fake-useragent
+ if self.use_fake_ua:
+ return self._fake_ua.random
+
+ # 默认返回 Chrome UA
+ return (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/120.0.0.0 Safari/537.36"
+ )
+
+ def get_chrome(self) -> str:
+ """获取 Chrome UA"""
+ if self.use_fake_ua:
+ return self._fake_ua.chrome
+ return self.get_random()
+
+ def get_mobile(self) -> str:
+ """获取移动端 UA"""
+ mobile_uas = [
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15",
+ "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36",
+ ]
+ return random.choice(mobile_uas)
+```
+
+---
+
+## 请求头完整伪装
+
+### 为什么仅有 User-Agent 不够
+
+很多网站不仅检测 UA,还会检测其他请求头字段。真实浏览器的请求头是非常丰富的:
+
+```http
+GET /api/data HTTP/1.1
+Host: www.example.com
+User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
+Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
+Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
+Accept-Encoding: gzip, deflate, br
+Connection: keep-alive
+Upgrade-Insecure-Requests: 1
+Sec-Fetch-Dest: document
+Sec-Fetch-Mode: navigate
+Sec-Fetch-Site: none
+Sec-Fetch-User: ?1
+Cache-Control: max-age=0
+```
+
+而 Python 默认的请求头非常简陋,很容易被识别。
+
+### 构建完整的请求头
+
+```python
+from typing import Dict, Optional
+
+
+class HeadersBuilder:
+ """请求头构建器"""
+
+ # 基础请求头模板
+ BASE_HEADERS = {
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Connection": "keep-alive",
+ "Upgrade-Insecure-Requests": "1",
+ "Sec-Fetch-Dest": "document",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Site": "none",
+ "Sec-Fetch-User": "?1",
+ "Cache-Control": "max-age=0",
+ }
+
+ # API 请求头模板
+ API_HEADERS = {
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Connection": "keep-alive",
+ "Sec-Fetch-Dest": "empty",
+ "Sec-Fetch-Mode": "cors",
+ "Sec-Fetch-Site": "same-origin",
+ }
+
+ def __init__(self, ua_rotator: Optional['UARotator'] = None):
+ self.ua_rotator = ua_rotator or UARotator()
+
+ def build(
+ self,
+ referer: Optional[str] = None,
+ origin: Optional[str] = None,
+ extra_headers: Optional[Dict[str, str]] = None,
+ is_api: bool = False
+ ) -> Dict[str, str]:
+ """
+ 构建请求头
+
+ Args:
+ referer: Referer 地址
+ origin: Origin 地址
+ extra_headers: 额外的请求头
+ is_api: 是否是 API 请求
+
+ Returns:
+ 完整的请求头字典
+ """
+ # 选择基础模板
+ headers = self.API_HEADERS.copy() if is_api else self.BASE_HEADERS.copy()
+
+ # 添加 User-Agent
+ headers["User-Agent"] = self.ua_rotator.get_random()
+
+ # 添加 Referer
+ if referer:
+ headers["Referer"] = referer
+ # 如果有 Referer,通常 Sec-Fetch-Site 应该是 same-origin 或 cross-site
+ headers["Sec-Fetch-Site"] = "same-origin"
+
+ # 添加 Origin(通常用于 POST 请求或 CORS 请求)
+ if origin:
+ headers["Origin"] = origin
+
+ # 合并额外请求头
+ if extra_headers:
+ headers.update(extra_headers)
+
+ return headers
+
+ def build_for_ajax(
+ self,
+ referer: str,
+ x_requested_with: bool = True
+ ) -> Dict[str, str]:
+ """
+ 构建 AJAX 请求头
+
+ Args:
+ referer: Referer 地址
+ x_requested_with: 是否添加 X-Requested-With 头
+
+ Returns:
+ AJAX 请求头
+ """
+ headers = self.build(referer=referer, is_api=True)
+
+ if x_requested_with:
+ headers["X-Requested-With"] = "XMLHttpRequest"
+
+ return headers
+```
+
+### Referer 的正确设置
+
+Referer 表示当前请求是从哪个页面发起的。正确设置 Referer 很重要:
+
+```python
+async def crawl_with_referer(client, list_url: str, detail_urls: list):
+ """演示正确设置 Referer"""
+ headers_builder = HeadersBuilder()
+
+ # 访问列表页
+ list_headers = headers_builder.build()
+ response = await client.get(list_url, headers=list_headers)
+
+ # 访问详情页时,Referer 应该是列表页
+ for detail_url in detail_urls:
+ detail_headers = headers_builder.build(referer=list_url)
+ response = await client.get(detail_url, headers=detail_headers)
+```
+
+### 请求头实战配置
+
+以下是一个通用的请求头构建器,可以根据目标网站进行定制:
+
+```python
+# headers_builder.py - 通用请求头构建器
+"""通用请求头配置"""
+
+class SiteHeadersBuilder:
+ """站点请求头构建器"""
+
+ def __init__(self, base_url: str):
+ """
+ 初始化构建器
+
+ Args:
+ base_url: 目标网站基础URL
+ """
+ self.base_url = base_url
+ self.base_headers = {
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/120.0.0.0 Safari/537.36",
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Connection": "keep-alive",
+ "Sec-Fetch-Dest": "document",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Site": "none",
+ }
+
+ def build_for_page(self, referer: str = "") -> dict:
+ """
+ 构建页面请求头
+
+ Args:
+ referer: Referer 地址
+
+ Returns:
+ 完整的请求头字典
+ """
+ headers = self.base_headers.copy()
+ if referer:
+ headers["Referer"] = referer
+ headers["Sec-Fetch-Site"] = "same-origin"
+ return headers
+
+ def build_for_api(self, referer: str = "") -> dict:
+ """构建 API 请求头"""
+ headers = self.base_headers.copy()
+ headers["Accept"] = "application/json, text/plain, */*"
+ headers["Sec-Fetch-Dest"] = "empty"
+ headers["Sec-Fetch-Mode"] = "cors"
+ if referer:
+ headers["Referer"] = referer
+ headers["Sec-Fetch-Site"] = "same-origin"
+ return headers
+```
+
+> **请求头配置要点**:
+> 1. **Referer**:很多网站会检查,应设置为合理的来源页面
+> 2. **Sec-Fetch 系列**:现代浏览器标准头,建议保持完整
+> 3. **Accept**:页面请求和 API 请求的 Accept 头不同
+
+---
+
+## 使用 curl_cffi 模拟浏览器指纹
+
+### 什么是 TLS 指纹
+
+除了 HTTP 请求头,服务器还可以通过 TLS(HTTPS 握手)的特征来识别客户端。不同的客户端(浏览器、Python requests、curl 等)在 TLS 握手时会展现不同的特征:
+
+- 支持的加密套件顺序
+- 支持的 TLS 扩展
+- 椭圆曲线参数
+
+Python 的 `requests` 和 `httpx` 使用的 TLS 指纹与真实浏览器差异很大,容易被识别。
+
+### curl_cffi 简介
+
+`curl_cffi` 是一个可以模拟各种浏览器 TLS 指纹的 HTTP 客户端库。
+
+安装:
+
+```bash
+pip install curl_cffi
+```
+
+### 基本使用
+
+```python
+from curl_cffi import requests
+
+# 模拟 Chrome 浏览器
+response = requests.get(
+ "https://tls.browserleaks.com/json",
+ impersonate="chrome120" # 模拟 Chrome 120
+)
+print(response.json())
+
+# 支持的浏览器指纹
+# chrome99, chrome100, chrome101, ..., chrome120
+# chrome99_android
+# edge99, edge101
+# safari15_3, safari15_5, safari17_0
+```
+
+### 异步使用
+
+```python
+from curl_cffi.requests import AsyncSession
+
+async def fetch_with_curl_cffi():
+ """使用 curl_cffi 的异步请求"""
+ async with AsyncSession(impersonate="chrome120") as session:
+ response = await session.get("https://httpbin.org/headers")
+ print(response.json())
+```
+
+### 封装 curl_cffi 客户端
+
+```python
+from curl_cffi.requests import AsyncSession
+from typing import Optional, Dict, Any
+import random
+
+
+class BrowserClient:
+ """
+ 模拟浏览器的 HTTP 客户端
+
+ 使用 curl_cffi 模拟真实浏览器的 TLS 指纹
+ """
+
+ BROWSER_VERSIONS = [
+ "chrome119",
+ "chrome120",
+ "edge99",
+ "edge101",
+ "safari15_5",
+ "safari17_0",
+ ]
+
+ def __init__(
+ self,
+ impersonate: Optional[str] = None,
+ proxy: Optional[str] = None,
+ timeout: int = 30
+ ):
+ """
+ 初始化客户端
+
+ Args:
+ impersonate: 模拟的浏览器,如 "chrome120",None 表示随机
+ proxy: 代理地址
+ timeout: 超时时间
+ """
+ self.impersonate = impersonate
+ self.proxy = proxy
+ self.timeout = timeout
+ self._session: Optional[AsyncSession] = None
+
+ async def __aenter__(self):
+ await self.start()
+ return self
+
+ async def __aexit__(self, *args):
+ await self.close()
+
+ async def start(self):
+ """启动会话"""
+ browser = self.impersonate or random.choice(self.BROWSER_VERSIONS)
+ self._session = AsyncSession(
+ impersonate=browser,
+ proxy=self.proxy,
+ timeout=self.timeout
+ )
+
+ async def close(self):
+ """关闭会话"""
+ if self._session:
+ await self._session.close()
+ self._session = None
+
+ async def get(
+ self,
+ url: str,
+ headers: Optional[Dict[str, str]] = None,
+ **kwargs
+ ) -> Any:
+ """发送 GET 请求"""
+ if not self._session:
+ await self.start()
+ return await self._session.get(url, headers=headers, **kwargs)
+
+ async def post(
+ self,
+ url: str,
+ data: Optional[Dict] = None,
+ json: Optional[Dict] = None,
+ headers: Optional[Dict[str, str]] = None,
+ **kwargs
+ ) -> Any:
+ """发送 POST 请求"""
+ if not self._session:
+ await self.start()
+ return await self._session.post(
+ url, data=data, json=json, headers=headers, **kwargs
+ )
+```
+
+---
+
+## 速率控制
+
+### 为什么需要速率控制
+
+即使你的请求伪装得再好,如果访问频率过高,也会触发反爬机制。人类的浏览行为是有一定节奏的,而机器的请求往往过于规律或过于密集。
+
+### 基本延迟策略
+
+```python
+import asyncio
+import random
+
+
+async def crawl_with_delay(urls: list, min_delay: float = 1.0, max_delay: float = 3.0):
+ """
+ 带随机延迟的爬取
+
+ Args:
+ urls: URL 列表
+ min_delay: 最小延迟(秒)
+ max_delay: 最大延迟(秒)
+ """
+ for url in urls:
+ # 发送请求
+ response = await fetch(url)
+
+ # 随机延迟
+ delay = random.uniform(min_delay, max_delay)
+ await asyncio.sleep(delay)
+```
+
+### 令牌桶限速器
+
+令牌桶算法是一种更精确的限速方式,它允许一定程度的突发请求,同时保持长期平均速率。
+
+```python
+import asyncio
+import time
+from typing import Optional
+
+
+class TokenBucket:
+ """
+ 令牌桶限速器
+
+ 工作原理:
+ - 桶有固定容量(最大令牌数)
+ - 以固定速率向桶中添加令牌
+ - 每次请求消耗一个令牌
+ - 桶空时请求需要等待
+ """
+
+ def __init__(
+ self,
+ rate: float,
+ capacity: Optional[int] = None
+ ):
+ """
+ 初始化令牌桶
+
+ Args:
+ rate: 每秒添加的令牌数(即每秒最多请求数)
+ capacity: 桶容量,默认等于 rate
+ """
+ self.rate = rate
+ self.capacity = capacity or int(rate)
+ self.tokens = self.capacity
+ self.last_time = time.monotonic()
+ self._lock = asyncio.Lock()
+
+ async def acquire(self, tokens: int = 1) -> float:
+ """
+ 获取令牌
+
+ Args:
+ tokens: 需要的令牌数
+
+ Returns:
+ 实际等待的时间
+ """
+ async with self._lock:
+ now = time.monotonic()
+
+ # 计算从上次到现在应该添加的令牌数
+ elapsed = now - self.last_time
+ self.tokens = min(
+ self.capacity,
+ self.tokens + elapsed * self.rate
+ )
+ self.last_time = now
+
+ # 如果令牌不足,计算需要等待的时间
+ if self.tokens < tokens:
+ wait_time = (tokens - self.tokens) / self.rate
+ await asyncio.sleep(wait_time)
+ self.tokens = 0
+ return wait_time
+ else:
+ self.tokens -= tokens
+ return 0
+
+ async def __aenter__(self):
+ await self.acquire()
+ return self
+
+ async def __aexit__(self, *args):
+ pass
+
+
+# 使用示例
+async def crawl_with_rate_limit():
+ """使用令牌桶限速"""
+ # 每秒最多 2 个请求
+ limiter = TokenBucket(rate=2.0)
+
+ urls = ["https://example.com/page/{}".format(i) for i in range(10)]
+
+ for url in urls:
+ async with limiter: # 自动限速
+ response = await fetch(url)
+ print(f"Fetched: {url}")
+```
+
+### 使用 asyncio.Semaphore 控制并发
+
+```python
+import asyncio
+
+
+class ConcurrencyLimiter:
+ """并发限制器"""
+
+ def __init__(self, max_concurrent: int = 10):
+ """
+ 初始化并发限制器
+
+ Args:
+ max_concurrent: 最大并发数
+ """
+ self.semaphore = asyncio.Semaphore(max_concurrent)
+
+ async def run_with_limit(self, coro):
+ """在并发限制下执行协程"""
+ async with self.semaphore:
+ return await coro
+
+
+async def crawl_concurrent(urls: list, max_concurrent: int = 5):
+ """
+ 带并发限制的批量爬取
+
+ Args:
+ urls: URL 列表
+ max_concurrent: 最大并发数
+ """
+ semaphore = asyncio.Semaphore(max_concurrent)
+
+ async def fetch_with_limit(url):
+ async with semaphore:
+ return await fetch(url)
+
+ # 并发执行,但同时最多 max_concurrent 个请求
+ tasks = [fetch_with_limit(url) for url in urls]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+
+ return results
+```
+
+---
+
+## HTTP 错误处理
+
+### HTTP 状态码处理
+
+在爬取网站时,需要处理各种 HTTP 状态码:
+
+```mermaid
+flowchart LR
+ response[HTTP响应] --> check{检查状态码}
+
+ check -->|200| success[成功
正常处理数据]
+ check -->|401| login[需要认证
登录获取凭证]
+ check -->|403| forbidden[禁止访问
检查请求头]
+ check -->|429| ratelimit[频率限制
降低速率]
+ check -->|404| notfound[资源不存在]
+
+ login --> relogin[获取认证凭证]
+ ratelimit --> wait[等待后重试]
+ forbidden --> fixheaders[修复请求头]
+```
+
+```python
+# error_handler.py - HTTP错误处理
+from loguru import logger
+
+
+async def handle_response(response, url: str):
+ """
+ 处理 HTTP 响应
+
+ Args:
+ response: HTTP 响应对象
+ url: 请求的 URL
+ """
+ status_code = response.status_code
+
+ if status_code == 200:
+ logger.info(f"请求成功: {url}")
+ return response
+
+ elif status_code == 401:
+ logger.warning(f"需要认证: {url}")
+ raise Exception("需要登录或认证凭证已过期")
+
+ elif status_code == 403:
+ logger.error(f"禁止访问: {url}")
+ raise Exception("访问被禁止,请检查请求头配置")
+
+ elif status_code == 429:
+ logger.warning(f"触发频率限制: {url}")
+ raise Exception("请求过于频繁,请降低访问速率")
+
+ elif status_code == 404:
+ logger.warning(f"资源不存在: {url}")
+ return None
+
+ else:
+ logger.error(f"HTTP错误 {status_code}: {url}")
+ raise Exception(f"HTTP错误: {status_code}")
+```
+
+---
+
+## 实战案例:完整的请求伪装爬虫
+
+让我们把本章学到的技术整合成一个完整的通用爬虫示例:
+
+```python
+# -*- coding: utf-8 -*-
+"""
+完整的请求伪装爬虫示例
+结合 UA 轮换、请求头伪装、速率控制
+"""
+
+import asyncio
+import random
+from typing import List, Dict, Optional
+from loguru import logger
+
+# 如果安装了 curl_cffi,优先使用
+try:
+ from curl_cffi.requests import AsyncSession
+ USE_CURL_CFFI = True
+except ImportError:
+ import httpx
+ USE_CURL_CFFI = False
+
+
+class AntiDetectionCrawler:
+ """反检测爬虫"""
+
+ DESKTOP_UAS = [
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
+ ]
+
+ def __init__(
+ self,
+ max_concurrent: int = 5,
+ min_delay: float = 1.0,
+ max_delay: float = 3.0
+ ):
+ self.max_concurrent = max_concurrent
+ self.min_delay = min_delay
+ self.max_delay = max_delay
+ self.semaphore = asyncio.Semaphore(max_concurrent)
+ self._session = None
+
+ async def __aenter__(self):
+ await self.start()
+ return self
+
+ async def __aexit__(self, *args):
+ await self.close()
+
+ async def start(self):
+ """启动客户端"""
+ if USE_CURL_CFFI:
+ self._session = AsyncSession(impersonate="chrome120")
+ else:
+ self._session = httpx.AsyncClient(timeout=30)
+ logger.info(f"客户端启动 (curl_cffi: {USE_CURL_CFFI})")
+
+ async def close(self):
+ """关闭客户端"""
+ if self._session:
+ await self._session.close() if USE_CURL_CFFI else await self._session.aclose()
+
+ def _build_headers(self, referer: Optional[str] = None) -> Dict[str, str]:
+ """构建请求头"""
+ headers = {
+ "User-Agent": random.choice(self.DESKTOP_UAS),
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Connection": "keep-alive",
+ "Upgrade-Insecure-Requests": "1",
+ }
+ if referer:
+ headers["Referer"] = referer
+ return headers
+
+ async def fetch(
+ self,
+ url: str,
+ referer: Optional[str] = None
+ ) -> Optional[str]:
+ """
+ 获取页面内容
+
+ Args:
+ url: 目标 URL
+ referer: Referer 地址
+
+ Returns:
+ 页面内容或 None
+ """
+ async with self.semaphore:
+ try:
+ headers = self._build_headers(referer)
+
+ logger.debug(f"请求: {url}")
+ response = await self._session.get(url, headers=headers)
+
+ if USE_CURL_CFFI:
+ response.raise_for_status()
+ content = response.text
+ else:
+ response.raise_for_status()
+ content = response.text
+
+ logger.info(f"成功: {url}")
+
+ # 随机延迟
+ delay = random.uniform(self.min_delay, self.max_delay)
+ await asyncio.sleep(delay)
+
+ return content
+
+ except Exception as e:
+ logger.error(f"失败: {url} - {e}")
+ return None
+
+ async def crawl_batch(
+ self,
+ urls: List[str],
+ referer: Optional[str] = None
+ ) -> List[Optional[str]]:
+ """
+ 批量爬取
+
+ Args:
+ urls: URL 列表
+ referer: Referer 地址
+
+ Returns:
+ 内容列表
+ """
+ tasks = [self.fetch(url, referer) for url in urls]
+ return await asyncio.gather(*tasks)
+
+
+async def main():
+ """主函数"""
+ logger.remove()
+ logger.add(lambda m: print(m, end=""), level="DEBUG")
+
+ urls = [
+ "https://httpbin.org/headers",
+ "https://httpbin.org/user-agent",
+ "https://httpbin.org/ip",
+ ]
+
+ async with AntiDetectionCrawler(max_concurrent=2, min_delay=1, max_delay=2) as crawler:
+ results = await crawler.crawl_batch(urls)
+ for url, content in zip(urls, results):
+ if content:
+ print(f"\n{'='*50}")
+ print(f"URL: {url}")
+ print(content[:500])
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+---
+
+## 本章小结
+
+本章我们学习了反爬虫对抗的基础技术——请求伪装:
+
+1. **User-Agent 轮换**:使用真实浏览器 UA,随机轮换避免被追踪
+2. **请求头完整伪装**:构建与真实浏览器一致的完整请求头
+3. **TLS 指纹模拟**:使用 curl_cffi 模拟浏览器的 TLS 指纹
+4. **速率控制**:使用随机延迟和令牌桶算法控制请求频率
+5. **HTTP 错误处理**:正确处理各种 HTTP 状态码
+
+这些技术可以应对大部分基于请求特征的反爬检测。
+
+---
+
+## 下一章预告
+
+下一章我们将学习「代理 IP 的使用与管理」。主要内容包括:
+
+- 代理 IP 的类型和选择
+- 代理池的设计与实现
+- 代理的有效性检测和淘汰机制
+- 代理与爬虫的集成
+
+代理 IP 是突破 IP 封禁的重要手段,也是大规模爬虫必不可少的基础设施。
diff --git "a/docs/\347\210\254\350\231\253\350\277\233\344\273\267/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206.md" "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206.md"
new file mode 100644
index 0000000..cd42ebc
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206.md"
@@ -0,0 +1,1139 @@
+# 代理 IP 的使用与管理
+
+> 当你的爬虫遭遇 IP 封禁时,代理 IP 就成了必不可少的工具。本章将深入讲解代理 IP 的原理、类型选择,以及如何设计和实现一个实用的代理池管理系统。
+
+## 代理 IP 基础
+
+### 什么是代理 IP
+
+代理服务器(Proxy Server)是一种位于客户端和目标服务器之间的中间服务器。当你通过代理发送请求时:
+
+1. 你的请求先发送到代理服务器
+2. 代理服务器代替你向目标服务器发送请求
+3. 目标服务器将响应返回给代理服务器
+4. 代理服务器再将响应转发给你
+
+这样,目标服务器看到的是代理服务器的 IP,而不是你的真实 IP。
+
+### 代理类型详解
+
+#### 按协议分类
+
+| 类型 | 特点 | 使用场景 |
+|------|------|----------|
+| HTTP 代理 | 只支持 HTTP 协议 | 普通网页爬取 |
+| HTTPS 代理 | 支持 HTTPS 协议 | 加密网站爬取 |
+| SOCKS4 代理 | 支持 TCP 连接 | 需要更底层控制 |
+| SOCKS5 代理 | 支持 TCP/UDP,可认证 | 最灵活的代理类型 |
+
+#### 按匿名度分类
+
+| 类型 | 特点 | 识别难度 |
+|------|------|----------|
+| 透明代理 | 目标服务器能看到你的真实 IP | 容易被识别 |
+| 匿名代理 | 隐藏真实 IP,但暴露代理身份 | 中等 |
+| 高匿代理 | 完全隐藏真实 IP 和代理身份 | 较难识别 |
+
+对于爬虫来说,**高匿代理**是首选。
+
+#### 按来源分类
+
+| 类型 | 优点 | 缺点 |
+|------|------|------|
+| 免费代理 | 成本为零 | 不稳定、速度慢、可用率低 |
+| 付费代理 | 稳定、速度快、可用率高 | 需要成本 |
+| 自建代理 | 完全可控 | 需要服务器资源 |
+
+### 代理提供商选择指南
+
+选择代理提供商时需要考虑:
+
+1. **IP 质量**:是否是高匿代理,是否已被目标网站封禁
+2. **IP 数量**:IP 池的规模
+3. **地理分布**:是否覆盖目标地区
+4. **稳定性**:连接成功率和响应速度
+5. **价格**:按流量计费还是按 IP 数计费
+6. **API 支持**:是否提供便捷的 API 获取代理
+
+常见的代理类型:
+
+- **API 提取型**:通过 API 获取代理 IP 列表
+- **隧道代理型**:固定入口,自动轮换 IP
+- **动态转发型**:每次请求自动更换 IP
+
+---
+
+## 代理池设计
+
+### 为什么需要代理池
+
+直接使用单个代理存在以下问题:
+
+- 代理可能随时失效
+- 单个 IP 容易被封禁
+- 无法动态切换和管理
+
+代理池可以解决这些问题:
+
+- 统一管理多个代理
+- 自动检测代理有效性
+- 智能分配和轮换代理
+- 记录代理质量评分
+
+### 代理池架构设计
+
+```mermaid
+graph TB
+ subgraph ProxyPoolManager["代理池管理器"]
+ direction TB
+
+ subgraph Components["核心组件"]
+ direction LR
+ Fetcher["代理获取器
(Fetcher)"]
+ Checker["代理检测器
(Checker)"]
+ Allocator["代理分配器
(Allocator)"]
+ end
+
+ Storage[("代理存储
(内存 / Redis)")]
+
+ Fetcher --> Storage
+ Checker --> Storage
+ Allocator --> Storage
+ end
+
+ style ProxyPoolManager fill:#e8f4f8,stroke:#0288d1
+ style Storage fill:#fff3e0,stroke:#ff9800
+```
+
+**代理池工作流程**:
+
+```mermaid
+flowchart LR
+ subgraph 获取阶段
+ API["代理API"] --> Fetcher["获取器"]
+ Free["免费代理"] --> Fetcher
+ end
+
+ subgraph 检测阶段
+ Fetcher --> Checker["检测器"]
+ Checker -->|有效| Pool[("代理池")]
+ Checker -->|无效| Discard["丢弃"]
+ end
+
+ subgraph 分配阶段
+ Pool --> Allocator["分配器"]
+ Allocator --> Crawler["爬虫请求"]
+ Crawler -->|成功| Score["评分+1"]
+ Crawler -->|失败| Penalty["评分-1"]
+ Score --> Pool
+ Penalty --> Pool
+ end
+
+ style Pool fill:#c8e6c9,stroke:#4caf50
+ style Discard fill:#ffcdd2,stroke:#f44336
+```
+
+### 核心接口设计
+
+```python
+from abc import ABC, abstractmethod
+from typing import Optional, List
+from dataclasses import dataclass
+from enum import Enum
+
+
+class ProxyProtocol(Enum):
+ """代理协议"""
+ HTTP = "http"
+ HTTPS = "https"
+ SOCKS5 = "socks5"
+
+
+@dataclass
+class ProxyInfo:
+ """代理信息"""
+ host: str
+ port: int
+ protocol: ProxyProtocol = ProxyProtocol.HTTP
+ username: Optional[str] = None
+ password: Optional[str] = None
+
+ # 质量指标
+ success_count: int = 0
+ fail_count: int = 0
+ avg_response_time: float = 0.0
+ last_check_time: float = 0.0
+
+ @property
+ def url(self) -> str:
+ """构建代理 URL"""
+ auth = ""
+ if self.username and self.password:
+ auth = f"{self.username}:{self.password}@"
+ return f"{self.protocol.value}://{auth}{self.host}:{self.port}"
+
+ @property
+ def score(self) -> float:
+ """计算代理评分"""
+ total = self.success_count + self.fail_count
+ if total == 0:
+ return 0.5 # 未测试的代理给中等分数
+ success_rate = self.success_count / total
+ # 考虑响应时间,越快分数越高
+ time_score = max(0, 1 - self.avg_response_time / 10)
+ return success_rate * 0.7 + time_score * 0.3
+
+
+class IProxyFetcher(ABC):
+ """代理获取器接口"""
+
+ @abstractmethod
+ async def fetch(self) -> List[ProxyInfo]:
+ """获取代理列表"""
+ pass
+
+
+class IProxyChecker(ABC):
+ """代理检测器接口"""
+
+ @abstractmethod
+ async def check(self, proxy: ProxyInfo) -> bool:
+ """检测代理是否可用"""
+ pass
+
+
+class IProxyPool(ABC):
+ """代理池接口"""
+
+ @abstractmethod
+ async def get_proxy(self) -> Optional[ProxyInfo]:
+ """获取一个可用代理"""
+ pass
+
+ @abstractmethod
+ async def return_proxy(self, proxy: ProxyInfo, success: bool):
+ """归还代理并报告使用结果"""
+ pass
+
+ @abstractmethod
+ async def add_proxy(self, proxy: ProxyInfo):
+ """添加代理"""
+ pass
+
+ @abstractmethod
+ async def remove_proxy(self, proxy: ProxyInfo):
+ """移除代理"""
+ pass
+```
+
+---
+
+## 代理获取器实现
+
+### 免费代理获取(仅供学习)
+
+```python
+import httpx
+from typing import List
+from loguru import logger
+
+
+class FreeProxyFetcher(IProxyFetcher):
+ """
+ 免费代理获取器
+
+ 注意:免费代理质量较差,仅供学习测试使用
+ """
+
+ async def fetch(self) -> List[ProxyInfo]:
+ """从免费代理网站获取代理"""
+ proxies = []
+
+ # 示例:从 API 获取(这里用一个示例 API)
+ try:
+ async with httpx.AsyncClient(timeout=10) as client:
+ # 这是一个示例 URL,实际使用时替换为真实的代理 API
+ response = await client.get(
+ "https://api.proxyscrape.com/v2/"
+ "?request=getproxies&protocol=http&timeout=10000&country=all"
+ )
+
+ if response.status_code == 200:
+ lines = response.text.strip().split("\n")
+ for line in lines:
+ try:
+ host, port = line.strip().split(":")
+ proxies.append(ProxyInfo(
+ host=host,
+ port=int(port),
+ protocol=ProxyProtocol.HTTP
+ ))
+ except ValueError:
+ continue
+
+ logger.info(f"获取到 {len(proxies)} 个免费代理")
+
+ except Exception as e:
+ logger.error(f"获取免费代理失败: {e}")
+
+ return proxies
+```
+
+### API 代理获取器
+
+```python
+class APIProxyFetcher(IProxyFetcher):
+ """
+ API 代理获取器
+
+ 从付费代理服务商的 API 获取代理
+ """
+
+ def __init__(
+ self,
+ api_url: str,
+ api_key: Optional[str] = None,
+ count: int = 10
+ ):
+ """
+ 初始化 API 代理获取器
+
+ Args:
+ api_url: API 地址
+ api_key: API 密钥
+ count: 每次获取数量
+ """
+ self.api_url = api_url
+ self.api_key = api_key
+ self.count = count
+
+ async def fetch(self) -> List[ProxyInfo]:
+ """从 API 获取代理"""
+ proxies = []
+
+ try:
+ params = {"num": self.count}
+ if self.api_key:
+ params["key"] = self.api_key
+
+ async with httpx.AsyncClient(timeout=10) as client:
+ response = await client.get(self.api_url, params=params)
+ data = response.json()
+
+ # 根据实际 API 返回格式解析
+ # 这里假设返回 {"data": [{"ip": "x.x.x.x", "port": 8080}, ...]}
+ for item in data.get("data", []):
+ proxies.append(ProxyInfo(
+ host=item["ip"],
+ port=item["port"],
+ protocol=ProxyProtocol(item.get("protocol", "http"))
+ ))
+
+ logger.info(f"从 API 获取到 {len(proxies)} 个代理")
+
+ except Exception as e:
+ logger.error(f"从 API 获取代理失败: {e}")
+
+ return proxies
+```
+
+---
+
+## 代理检测器实现
+
+```python
+import time
+import httpx
+from typing import Optional
+
+
+class ProxyChecker(IProxyChecker):
+ """
+ 代理检测器
+
+ 检测代理的可用性和响应速度
+ """
+
+ # 用于检测的 URL 列表
+ CHECK_URLS = [
+ "https://httpbin.org/ip",
+ "https://api.ipify.org?format=json",
+ ]
+
+ def __init__(self, timeout: int = 10):
+ """
+ 初始化检测器
+
+ Args:
+ timeout: 检测超时时间
+ """
+ self.timeout = timeout
+
+ async def check(self, proxy: ProxyInfo) -> bool:
+ """
+ 检测代理是否可用
+
+ Args:
+ proxy: 代理信息
+
+ Returns:
+ 代理是否可用
+ """
+ start_time = time.time()
+
+ try:
+ async with httpx.AsyncClient(
+ proxies=proxy.url,
+ timeout=self.timeout
+ ) as client:
+ for url in self.CHECK_URLS:
+ try:
+ response = await client.get(url)
+ if response.status_code == 200:
+ # 更新响应时间
+ response_time = time.time() - start_time
+ proxy.avg_response_time = (
+ proxy.avg_response_time * 0.7 +
+ response_time * 0.3
+ )
+ proxy.last_check_time = time.time()
+ logger.debug(
+ f"代理可用: {proxy.host}:{proxy.port}, "
+ f"响应时间: {response_time:.2f}s"
+ )
+ return True
+ except Exception:
+ continue
+
+ except Exception as e:
+ logger.debug(f"代理检测失败: {proxy.host}:{proxy.port} - {e}")
+
+ return False
+
+ async def check_batch(
+ self,
+ proxies: List[ProxyInfo],
+ concurrency: int = 20
+ ) -> List[ProxyInfo]:
+ """
+ 批量检测代理
+
+ Args:
+ proxies: 代理列表
+ concurrency: 并发数
+
+ Returns:
+ 可用的代理列表
+ """
+ import asyncio
+
+ semaphore = asyncio.Semaphore(concurrency)
+ valid_proxies = []
+
+ async def check_one(proxy: ProxyInfo):
+ async with semaphore:
+ if await self.check(proxy):
+ valid_proxies.append(proxy)
+
+ tasks = [check_one(p) for p in proxies]
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+ logger.info(f"检测完成: {len(valid_proxies)}/{len(proxies)} 可用")
+ return valid_proxies
+```
+
+---
+
+## 代理池实现
+
+```python
+import asyncio
+import random
+import time
+from typing import Optional, List, Dict
+from collections import defaultdict
+from loguru import logger
+
+
+class ProxyPool(IProxyPool):
+ """
+ 代理池实现
+
+ 特性:
+ - 自动获取和检测代理
+ - 基于评分的智能分配
+ - 自动淘汰失效代理
+ - 支持代理预热
+ """
+
+ def __init__(
+ self,
+ fetcher: IProxyFetcher,
+ checker: IProxyChecker,
+ min_proxies: int = 10,
+ max_proxies: int = 100,
+ check_interval: int = 300,
+ max_fail_count: int = 3
+ ):
+ """
+ 初始化代理池
+
+ Args:
+ fetcher: 代理获取器
+ checker: 代理检测器
+ min_proxies: 最小代理数量
+ max_proxies: 最大代理数量
+ check_interval: 检测间隔(秒)
+ max_fail_count: 最大失败次数
+ """
+ self.fetcher = fetcher
+ self.checker = checker
+ self.min_proxies = min_proxies
+ self.max_proxies = max_proxies
+ self.check_interval = check_interval
+ self.max_fail_count = max_fail_count
+
+ # 代理存储
+ self._proxies: Dict[str, ProxyInfo] = {}
+ self._lock = asyncio.Lock()
+
+ # 后台任务
+ self._refresh_task: Optional[asyncio.Task] = None
+ self._running = False
+
+ def _proxy_key(self, proxy: ProxyInfo) -> str:
+ """生成代理唯一标识"""
+ return f"{proxy.host}:{proxy.port}"
+
+ async def start(self):
+ """启动代理池"""
+ self._running = True
+
+ # 初始获取代理
+ await self._refresh_proxies()
+
+ # 启动后台刷新任务
+ self._refresh_task = asyncio.create_task(self._refresh_loop())
+
+ logger.info("代理池已启动")
+
+ async def stop(self):
+ """停止代理池"""
+ self._running = False
+
+ if self._refresh_task:
+ self._refresh_task.cancel()
+ try:
+ await self._refresh_task
+ except asyncio.CancelledError:
+ pass
+
+ logger.info("代理池已停止")
+
+ async def _refresh_loop(self):
+ """后台刷新循环"""
+ while self._running:
+ try:
+ await asyncio.sleep(self.check_interval)
+ await self._refresh_proxies()
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.error(f"代理刷新异常: {e}")
+
+ async def _refresh_proxies(self):
+ """刷新代理"""
+ async with self._lock:
+ # 检查是否需要补充代理
+ if len(self._proxies) >= self.min_proxies:
+ return
+
+ logger.info(f"代理不足 ({len(self._proxies)}/{self.min_proxies}),开始获取...")
+
+ # 获取新代理
+ new_proxies = await self.fetcher.fetch()
+
+ # 检测代理
+ valid_proxies = await self.checker.check_batch(new_proxies)
+
+ # 添加到池中
+ for proxy in valid_proxies:
+ key = self._proxy_key(proxy)
+ if key not in self._proxies and len(self._proxies) < self.max_proxies:
+ self._proxies[key] = proxy
+
+ logger.info(f"代理池更新完成,当前数量: {len(self._proxies)}")
+
+ async def get_proxy(self) -> Optional[ProxyInfo]:
+ """
+ 获取一个可用代理
+
+ 使用加权随机选择,评分高的代理被选中概率更大
+ """
+ async with self._lock:
+ if not self._proxies:
+ logger.warning("代理池为空")
+ return None
+
+ # 计算权重
+ proxies = list(self._proxies.values())
+ weights = [max(p.score, 0.1) for p in proxies]
+
+ # 加权随机选择
+ selected = random.choices(proxies, weights=weights, k=1)[0]
+
+ logger.debug(f"分配代理: {selected.host}:{selected.port} (评分: {selected.score:.2f})")
+ return selected
+
+ async def return_proxy(self, proxy: ProxyInfo, success: bool):
+ """
+ 归还代理并报告使用结果
+
+ Args:
+ proxy: 代理信息
+ success: 使用是否成功
+ """
+ async with self._lock:
+ key = self._proxy_key(proxy)
+
+ if key not in self._proxies:
+ return
+
+ stored_proxy = self._proxies[key]
+
+ if success:
+ stored_proxy.success_count += 1
+ else:
+ stored_proxy.fail_count += 1
+
+ # 检查是否需要淘汰
+ if stored_proxy.fail_count >= self.max_fail_count:
+ total = stored_proxy.success_count + stored_proxy.fail_count
+ if total > 5 and stored_proxy.score < 0.3:
+ del self._proxies[key]
+ logger.info(f"淘汰低质量代理: {proxy.host}:{proxy.port}")
+
+ async def add_proxy(self, proxy: ProxyInfo):
+ """添加代理"""
+ async with self._lock:
+ key = self._proxy_key(proxy)
+ if key not in self._proxies and len(self._proxies) < self.max_proxies:
+ self._proxies[key] = proxy
+
+ async def remove_proxy(self, proxy: ProxyInfo):
+ """移除代理"""
+ async with self._lock:
+ key = self._proxy_key(proxy)
+ if key in self._proxies:
+ del self._proxies[key]
+
+ @property
+ def size(self) -> int:
+ """代理池大小"""
+ return len(self._proxies)
+
+ def get_stats(self) -> Dict:
+ """获取统计信息"""
+ if not self._proxies:
+ return {"total": 0}
+
+ proxies = list(self._proxies.values())
+ scores = [p.score for p in proxies]
+
+ return {
+ "total": len(proxies),
+ "avg_score": sum(scores) / len(scores),
+ "max_score": max(scores),
+ "min_score": min(scores),
+ }
+```
+
+---
+
+## 代理与爬虫集成
+
+### 使用 httpx 设置代理
+
+```python
+import httpx
+
+async def fetch_with_proxy(url: str, proxy_url: str) -> str:
+ """使用代理发送请求"""
+ async with httpx.AsyncClient(proxies=proxy_url, timeout=30) as client:
+ response = await client.get(url)
+ return response.text
+
+
+# 使用示例
+proxy = "http://user:pass@127.0.0.1:8080"
+content = await fetch_with_proxy("https://httpbin.org/ip", proxy)
+```
+
+### 集成代理池的爬虫
+
+```python
+class ProxiedCrawler:
+ """
+ 集成代理池的爬虫
+
+ 自动管理代理的获取、轮换和报告
+ """
+
+ def __init__(self, proxy_pool: ProxyPool):
+ self.proxy_pool = proxy_pool
+
+ async def fetch(self, url: str) -> Optional[str]:
+ """使用代理获取页面"""
+ proxy = await self.proxy_pool.get_proxy()
+
+ if not proxy:
+ logger.warning("无可用代理")
+ return None
+
+ try:
+ async with httpx.AsyncClient(
+ proxies=proxy.url,
+ timeout=30
+ ) as client:
+ response = await client.get(url)
+ response.raise_for_status()
+
+ # 报告成功
+ await self.proxy_pool.return_proxy(proxy, success=True)
+ return response.text
+
+ except Exception as e:
+ logger.warning(f"请求失败: {url} - {e}")
+ # 报告失败
+ await self.proxy_pool.return_proxy(proxy, success=False)
+ return None
+```
+
+---
+
+## 隧道代理使用
+
+隧道代理是一种特殊的代理模式,你只需连接到固定的代理入口,每次请求自动分配不同的 IP。
+
+```python
+class TunnelProxyClient:
+ """
+ 隧道代理客户端
+
+ 特点:固定入口,自动轮换 IP
+ """
+
+ def __init__(
+ self,
+ host: str,
+ port: int,
+ username: str,
+ password: str
+ ):
+ self.proxy_url = f"http://{username}:{password}@{host}:{port}"
+
+ async def get(self, url: str, **kwargs) -> httpx.Response:
+ """发送请求(自动使用隧道代理)"""
+ async with httpx.AsyncClient(
+ proxies=self.proxy_url,
+ timeout=30
+ ) as client:
+ return await client.get(url, **kwargs)
+
+
+# 使用示例
+tunnel = TunnelProxyClient(
+ host="tunnel.example.com",
+ port=12345,
+ username="your_username",
+ password="your_password"
+)
+
+# 每次请求自动使用不同 IP
+response1 = await tunnel.get("https://httpbin.org/ip")
+response2 = await tunnel.get("https://httpbin.org/ip")
+```
+
+---
+
+## 代理使用最佳实践
+
+### IP 封禁机制分析
+
+大多数网站都有反爬虫的 IP 封禁机制,常见的触发条件和处理方式:
+
+```mermaid
+flowchart TD
+ Request["发起请求"] --> RateCheck{"频率检测"}
+ RateCheck -->|正常| UACheck{"UA检测"}
+ RateCheck -->|过快| Block429["429 限流"]
+
+ UACheck -->|正常| BehaviorCheck{"行为检测"}
+ UACheck -->|异常| Block412["412 风控"]
+
+ BehaviorCheck -->|正常| Success["正常响应"]
+ BehaviorCheck -->|异常| Block403["403 封禁"]
+
+ Block429 --> IPMark["IP标记"]
+ Block412 --> IPMark
+ Block403 --> IPMark
+
+ IPMark --> BlackList["IP黑名单"]
+
+ style Success fill:#c8e6c9,stroke:#4caf50
+ style Block429 fill:#fff3e0,stroke:#ff9800
+ style Block412 fill:#ffcdd2,stroke:#f44336
+ style Block403 fill:#ffcdd2,stroke:#f44336
+ style BlackList fill:#ffcdd2,stroke:#f44336
+```
+
+**常见 IP 封禁特点**:
+
+| 触发条件 | 响应码 | 封禁时长 | 解封方式 |
+|---------|--------|---------|---------|
+| 请求频率过高 | 429 | 几分钟~1小时 | 降低频率后自动解封 |
+| 风控检测触发 | 403 | 数小时~1天 | 需更换IP |
+| 严重违规 | 403/IP拉黑 | 数天~永久 | 需更换IP |
+
+### 代理有效性检测器
+
+使用 httpbin.org 等测试服务来验证代理的可用性:
+
+```python
+import time
+import httpx
+from typing import Optional
+from loguru import logger
+
+
+class SiteProxyChecker(IProxyChecker):
+ """
+ 通用代理检测器
+
+ 使用 httpbin.org 检测代理可用性和匿名度
+ """
+
+ # 使用 httpbin.org 检测代理IP
+ CHECK_URL = "https://httpbin.org/ip"
+
+ # 通用请求头
+ HEADERS = {
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/131.0.0.0 Safari/537.36",
+ "Accept": "application/json"
+ }
+
+ def __init__(self, timeout: int = 10):
+ self.timeout = timeout
+
+ async def check(self, proxy: ProxyInfo) -> bool:
+ """
+ 检测代理是否可用
+
+ 判断标准:
+ - 请求成功(状态码200)
+ - 响应包含有效JSON
+ - 返回的IP与代理IP一致(验证代理生效)
+ """
+ start_time = time.time()
+
+ try:
+ async with httpx.AsyncClient(
+ proxies=proxy.url,
+ timeout=self.timeout,
+ headers=self.HEADERS
+ ) as client:
+ response = await client.get(self.CHECK_URL)
+
+ if response.status_code != 200:
+ logger.debug(f"代理状态码异常: {proxy.host}:{proxy.port} - {response.status_code}")
+ return False
+
+ data = response.json()
+
+ # 验证返回的IP(httpbin.org 返回 {"origin": "x.x.x.x"})
+ origin_ip = data.get("origin", "")
+ if not origin_ip:
+ logger.debug(f"代理响应异常: {proxy.host}:{proxy.port}")
+ return False
+
+ # 更新响应时间
+ response_time = time.time() - start_time
+ proxy.avg_response_time = (
+ proxy.avg_response_time * 0.7 + response_time * 0.3
+ )
+ proxy.last_check_time = time.time()
+
+ logger.debug(
+ f"代理可用: {proxy.host}:{proxy.port}, "
+ f"出口IP: {origin_ip}, 响应时间: {response_time:.2f}s"
+ )
+ return True
+
+ except Exception as e:
+ logger.debug(f"代理检测失败: {proxy.host}:{proxy.port} - {e}")
+ return False
+```
+
+### 代理爬虫完整示例
+
+下面展示一个完整的代理爬虫示例,使用 httpbin.org 作为测试目标:
+
+```python
+import asyncio
+import httpx
+from typing import Optional, Dict, Any
+from loguru import logger
+from dataclasses import dataclass
+
+
+@dataclass
+class ProxyCrawlerConfig:
+ """代理爬虫配置"""
+ # 代理池配置
+ min_proxies: int = 10
+ max_proxies: int = 50
+
+ # 请求配置
+ request_timeout: int = 30
+ max_retries: int = 3
+ retry_delay: float = 1.0
+
+ # 频率控制
+ request_interval: float = 0.5 # 请求间隔(秒)
+
+
+class ProxyCrawler:
+ """
+ 代理爬虫
+
+ 特性:
+ - 自动代理轮换
+ - 智能重试
+ - 频率控制
+ - 错误处理
+ """
+
+ # 通用请求头
+ DEFAULT_HEADERS = {
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/131.0.0.0 Safari/537.36",
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ }
+
+ def __init__(
+ self,
+ proxy_pool: ProxyPool,
+ config: Optional[ProxyCrawlerConfig] = None
+ ):
+ self.proxy_pool = proxy_pool
+ self.config = config or ProxyCrawlerConfig()
+ self._last_request_time = 0.0
+
+ async def _wait_for_rate_limit(self):
+ """频率控制"""
+ import time
+ elapsed = time.time() - self._last_request_time
+ if elapsed < self.config.request_interval:
+ await asyncio.sleep(self.config.request_interval - elapsed)
+ self._last_request_time = time.time()
+
+ async def _request(
+ self,
+ url: str,
+ params: Optional[Dict] = None,
+ headers: Optional[Dict] = None
+ ) -> Optional[Dict[str, Any]]:
+ """
+ 发送带代理的请求
+
+ 自动处理代理轮换和重试
+ """
+ await self._wait_for_rate_limit()
+
+ merged_headers = {**self.DEFAULT_HEADERS, **(headers or {})}
+
+ for attempt in range(self.config.max_retries):
+ proxy = await self.proxy_pool.get_proxy()
+ if not proxy:
+ logger.warning("无可用代理,使用直连")
+ proxy_url = None
+ else:
+ proxy_url = proxy.url
+
+ try:
+ async with httpx.AsyncClient(
+ proxies=proxy_url,
+ timeout=self.config.request_timeout,
+ headers=merged_headers
+ ) as client:
+ response = await client.get(url, params=params)
+
+ # 处理响应
+ if response.status_code == 200:
+ if proxy:
+ await self.proxy_pool.return_proxy(proxy, success=True)
+ return response.json()
+
+ # HTTP错误
+ if response.status_code == 429:
+ logger.warning("请求频率过高,等待后重试")
+ if proxy:
+ await self.proxy_pool.return_proxy(proxy, success=False)
+ await asyncio.sleep(self.config.retry_delay * 2)
+ continue
+
+ if response.status_code in (403, 412):
+ logger.warning(f"IP被封禁 ({response.status_code}),切换代理")
+ if proxy:
+ await self.proxy_pool.return_proxy(proxy, success=False)
+ continue
+
+ # 其他错误
+ logger.warning(f"HTTP错误: {response.status_code}")
+ if proxy:
+ await self.proxy_pool.return_proxy(proxy, success=True)
+ return None
+
+ except httpx.TimeoutException:
+ logger.warning(f"请求超时,切换代理重试 (尝试 {attempt + 1})")
+ if proxy:
+ await self.proxy_pool.return_proxy(proxy, success=False)
+ except Exception as e:
+ logger.error(f"请求异常: {e}")
+ if proxy:
+ await self.proxy_pool.return_proxy(proxy, success=False)
+
+ logger.error(f"请求失败,已达最大重试次数: {url}")
+ return None
+
+ async def get_with_proxy(self, url: str) -> Optional[Dict[str, Any]]:
+ """
+ 使用代理获取URL
+
+ Args:
+ url: 目标URL
+
+ Returns:
+ 响应数据
+ """
+ return await self._request(url)
+
+
+# 使用示例
+async def main():
+ # 创建代理获取器
+ fetcher = APIProxyFetcher(
+ api_url="https://your-proxy-api.com/get",
+ api_key="your_api_key",
+ count=20
+ )
+
+ # 创建代理检测器
+ checker = SiteProxyChecker(timeout=10)
+
+ # 创建代理池
+ pool = ProxyPool(
+ fetcher=fetcher,
+ checker=checker,
+ min_proxies=10,
+ max_proxies=50
+ )
+
+ # 启动代理池
+ await pool.start()
+
+ try:
+ # 创建爬虫
+ crawler = ProxyCrawler(pool)
+
+ # 测试请求(使用 httpbin.org 验证代理生效)
+ result = await crawler.get_with_proxy("https://httpbin.org/ip")
+ if result:
+ print(f"当前出口IP: {result.get('origin')}")
+
+ # 测试 headers
+ result = await crawler.get_with_proxy("https://httpbin.org/headers")
+ if result:
+ headers = result.get("headers", {})
+ print(f"User-Agent: {headers.get('User-Agent', 'N/A')}")
+
+ # 获取代理池统计
+ stats = pool.get_stats()
+ logger.info(f"代理池统计: {stats}")
+
+ finally:
+ await pool.stop()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+### 代理使用最佳实践
+
+代理使用的一些通用建议:
+
+```mermaid
+graph LR
+ subgraph 代理选择
+ A1["高匿代理"] --> A2["住宅IP优先"]
+ A2 --> A3["国内节点"]
+ end
+
+ subgraph 请求策略
+ B1["控制频率"] --> B2["随机延迟"]
+ B2 --> B3["失败轮换"]
+ end
+
+ subgraph 风控规避
+ C1["完整请求头"] --> C2["Cookie携带"]
+ C2 --> C3["行为模拟"]
+ end
+
+ A3 --> B1
+ B3 --> C1
+
+ style A1 fill:#e3f2fd,stroke:#2196f3
+ style B1 fill:#fff3e0,stroke:#ff9800
+ style C1 fill:#e8f5e9,stroke:#4caf50
+```
+
+**关键建议**:
+
+1. **代理类型**:大型网站对代理检测严格,推荐使用高匿住宅代理
+2. **请求频率**:单IP建议 0.5-1 秒/请求,避免触发频率限制
+3. **完整请求头**:必须携带 User-Agent、Accept 等头信息
+4. **Cookie 携带**:部分 API 需要登录态,代理请求也要携带 Cookie
+5. **失败处理**:遇到 403/429 立即切换代理,避免 IP 被永久封禁
+
+---
+
+## 本章小结
+
+本章我们学习了代理 IP 的完整知识体系:
+
+1. **代理基础**:代理类型、匿名度、来源选择
+2. **代理池设计**:获取器、检测器、分配器的接口设计
+3. **核心实现**:代理获取、有效性检测、智能分配
+4. **爬虫集成**:httpx 代理设置、自动轮换、隧道代理
+5. **最佳实践**:代理检测、错误处理、使用建议
+
+代理 IP 是大规模爬虫的基础设施,合理使用可以有效应对 IP 封禁。
+
+---
+
+## 下一章预告
+
+下一章我们将学习「Playwright 浏览器自动化入门」。主要内容包括:
+
+- Playwright 的安装和基本使用
+- 页面导航和元素定位
+- 等待策略和超时处理
+- 截图和 PDF 导出
+- 爬取 JavaScript 渲染的页面
+
+浏览器自动化是应对复杂反爬的利器,让我们一起探索!
diff --git "a/docs/\347\210\254\350\231\253\350\277\233\344\273\267/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250.md" "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250.md"
new file mode 100644
index 0000000..dbb598d
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250.md"
@@ -0,0 +1,969 @@
+# Playwright 浏览器自动化入门
+
+> 当网页内容需要 JavaScript 渲染,或者需要模拟复杂的用户交互时,传统的 HTTP 请求方式就无能为力了。这时候,浏览器自动化工具就成了我们的利器。本章将带你入门 Playwright——一个现代、强大的浏览器自动化库。
+
+## 为什么选择 Playwright
+
+### 浏览器自动化工具对比
+
+| 特性 | Selenium | Puppeteer | Playwright |
+|------|----------|-----------|------------|
+| 语言支持 | 多语言 | JavaScript/TypeScript | 多语言 |
+| 浏览器支持 | 多浏览器 | 仅 Chromium | Chromium/Firefox/WebKit |
+| 自动等待 | 手动 | 部分自动 | 完全自动 |
+| 性能 | 较慢 | 快 | 快 |
+| API 设计 | 较旧 | 现代 | 最现代 |
+| 维护状态 | 活跃 | 活跃 | 微软维护,非常活跃 |
+
+### Playwright 的优势
+
+1. **跨浏览器支持**:一套代码支持 Chromium、Firefox、WebKit
+2. **自动等待机制**:智能等待元素可交互,减少 flaky 测试
+3. **网络拦截**:可以拦截和修改网络请求
+4. **移动端模拟**:内置多种设备配置
+5. **同步/异步 API**:Python 版本同时支持同步和异步模式
+
+---
+
+## 安装与配置
+
+### 安装 Playwright
+
+```bash
+pip install playwright
+```
+
+### 安装浏览器
+
+```bash
+# 安装所有支持的浏览器
+playwright install
+
+# 或者只安装 Chromium
+playwright install chromium
+
+# 安装 Firefox
+playwright install firefox
+
+# 安装 WebKit(Safari 内核)
+playwright install webkit
+```
+
+### 验证安装
+
+```python
+from playwright.sync_api import sync_playwright
+
+with sync_playwright() as p:
+ browser = p.chromium.launch(headless=False)
+ page = browser.new_page()
+ page.goto("https://example.com")
+ print(page.title())
+ browser.close()
+```
+
+---
+
+## 核心概念
+
+### Browser/Context/Page 三层模型
+
+```mermaid
+graph TB
+ subgraph Browser["Browser 浏览器实例"]
+ direction TB
+
+ subgraph Context1["Browser Context 1
(登录态环境)"]
+ Page1["Page 1
网站首页"]
+ Page2["Page 2
详情页面"]
+ end
+
+ subgraph Context2["Browser Context 2
(匿名环境)"]
+ Page3["Page 3
搜索页面"]
+ end
+ end
+
+ style Browser fill:#e3f2fd,stroke:#1976d2
+ style Context1 fill:#e8f5e9,stroke:#4caf50
+ style Context2 fill:#fff3e0,stroke:#ff9800
+ style Page1 fill:#f3e5f5,stroke:#9c27b0
+ style Page2 fill:#f3e5f5,stroke:#9c27b0
+ style Page3 fill:#f3e5f5,stroke:#9c27b0
+```
+
+**三层架构说明**:
+
+```mermaid
+flowchart LR
+ subgraph Browser层
+ B["Browser
浏览器进程"]
+ end
+
+ subgraph Context层
+ C1["Context
Cookie/Storage"]
+ C2["Context
独立环境"]
+ end
+
+ subgraph Page层
+ P1["Page
页面操作"]
+ P2["Page
DOM/Network"]
+ end
+
+ B --> C1 & C2
+ C1 --> P1
+ C2 --> P2
+
+ style B fill:#e3f2fd,stroke:#2196f3
+ style C1 fill:#e8f5e9,stroke:#4caf50
+ style C2 fill:#e8f5e9,stroke:#4caf50
+ style P1 fill:#fff3e0,stroke:#ff9800
+ style P2 fill:#fff3e0,stroke:#ff9800
+```
+
+- **Browser**:浏览器实例,对应一个浏览器进程
+- **Browser Context**:独立的浏览器环境,有独立的 Cookie、localStorage 等
+- **Page**:一个标签页,在这里进行实际的页面操作
+
+> **应用场景**:可以用不同 Context 分别处理登录态和匿名访问,互不干扰。
+
+### 同步 vs 异步 API
+
+Playwright Python 版本提供两套 API:
+
+```python
+# 同步 API
+from playwright.sync_api import sync_playwright
+
+with sync_playwright() as p:
+ browser = p.chromium.launch()
+ page = browser.new_page()
+ page.goto("https://example.com")
+ browser.close()
+
+# 异步 API
+from playwright.async_api import async_playwright
+import asyncio
+
+async def main():
+ async with async_playwright() as p:
+ browser = await p.chromium.launch()
+ page = await browser.new_page()
+ await page.goto("https://example.com")
+ await browser.close()
+
+asyncio.run(main())
+```
+
+对于爬虫项目,推荐使用**异步 API**,可以更好地与其他异步代码配合。
+
+---
+
+## 页面导航
+
+### 基本导航
+
+```python
+# 打开页面
+await page.goto("https://example.com")
+
+# 等待特定状态
+await page.goto("https://example.com", wait_until="domcontentloaded") # DOM 加载完成
+await page.goto("https://example.com", wait_until="load") # 页面完全加载
+await page.goto("https://example.com", wait_until="networkidle") # 网络空闲
+
+# 刷新页面
+await page.reload()
+
+# 前进/后退
+await page.go_back()
+await page.go_forward()
+```
+
+### wait_until 参数说明
+
+| 值 | 说明 | 使用场景 |
+|---|------|---------|
+| `domcontentloaded` | DOM 解析完成 | 快速获取页面结构 |
+| `load` | 所有资源加载完成 | 需要图片等资源 |
+| `networkidle` | 网络空闲(500ms 无请求) | SPA 应用,动态加载 |
+| `commit` | 收到第一个响应字节 | 最快,不常用 |
+
+---
+
+## 元素定位
+
+Playwright 提供多种元素定位方式,推荐优先使用语义化的定位器。
+
+### 推荐:使用 Locator
+
+```python
+# 通过文本定位
+page.get_by_text("登录")
+page.get_by_text("登录", exact=True) # 精确匹配
+
+# 通过角色定位(推荐用于按钮、链接等)
+page.get_by_role("button", name="提交")
+page.get_by_role("link", name="首页")
+page.get_by_role("textbox", name="用户名")
+
+# 通过标签定位
+page.get_by_label("用户名")
+page.get_by_label("密码")
+
+# 通过占位符定位
+page.get_by_placeholder("请输入用户名")
+
+# 通过 test-id 定位(需要在 HTML 中添加 data-testid 属性)
+page.get_by_test_id("submit-button")
+```
+
+### CSS 选择器
+
+```python
+# CSS 选择器
+page.locator("div.container")
+page.locator("#login-form")
+page.locator("input[type='text']")
+page.locator("div.item:nth-child(2)")
+```
+
+### XPath
+
+```python
+# XPath
+page.locator("//div[@class='container']")
+page.locator("//button[contains(text(), '提交')]")
+page.locator("//input[@id='username']")
+```
+
+### 组合定位
+
+```python
+# 链式定位
+page.locator("div.container").locator("button.submit")
+
+# 过滤
+page.locator("div.item").filter(has_text="特价")
+page.locator("div.item").filter(has=page.get_by_role("button"))
+
+# 第 N 个元素
+page.locator("div.item").nth(0) # 第一个
+page.locator("div.item").first # 第一个
+page.locator("div.item").last # 最后一个
+```
+
+---
+
+## 交互操作
+
+### 点击
+
+```python
+# 基本点击
+await page.click("button.submit")
+await page.locator("button.submit").click()
+
+# 双击
+await page.dblclick("div.item")
+
+# 右键点击
+await page.click("div.item", button="right")
+
+# 点击位置
+await page.click("div.map", position={"x": 100, "y": 200})
+
+# 强制点击(忽略可见性检查)
+await page.click("button.hidden", force=True)
+```
+
+### 输入
+
+```python
+# 输入文本
+await page.fill("input#username", "myuser")
+await page.locator("input#password").fill("mypassword")
+
+# 逐字符输入(模拟真实打字)
+await page.type("input#search", "hello world", delay=100)
+
+# 清空后输入
+await page.fill("input#search", "")
+await page.fill("input#search", "new text")
+```
+
+### 选择
+
+```python
+# 下拉选择(select 元素)
+await page.select_option("select#country", "china")
+await page.select_option("select#country", value="cn")
+await page.select_option("select#country", label="中国")
+
+# 多选
+await page.select_option("select#tags", ["tag1", "tag2"])
+
+# 复选框/单选框
+await page.check("input#agree")
+await page.uncheck("input#newsletter")
+await page.set_checked("input#agree", True)
+```
+
+### 键盘操作
+
+```python
+# 按键
+await page.keyboard.press("Enter")
+await page.keyboard.press("Tab")
+await page.keyboard.press("Control+A")
+await page.keyboard.press("Control+C")
+
+# 输入文本
+await page.keyboard.type("Hello World")
+
+# 组合键
+await page.keyboard.down("Shift")
+await page.keyboard.press("ArrowDown")
+await page.keyboard.up("Shift")
+```
+
+### 鼠标操作
+
+```python
+# 移动鼠标
+await page.mouse.move(100, 200)
+
+# 点击
+await page.mouse.click(100, 200)
+await page.mouse.dblclick(100, 200)
+
+# 拖拽
+await page.mouse.move(100, 200)
+await page.mouse.down()
+await page.mouse.move(300, 400)
+await page.mouse.up()
+
+# 悬停
+await page.hover("div.dropdown")
+```
+
+---
+
+## 等待策略
+
+### 自动等待
+
+Playwright 的一大优势是**自动等待**。当你执行操作时,它会自动等待元素满足条件:
+
+```python
+# 点击时自动等待元素可见、可交互
+await page.click("button.submit")
+
+# fill 时自动等待元素可编辑
+await page.fill("input#name", "value")
+```
+
+### 显式等待
+
+有时需要显式等待某些条件:
+
+```python
+# 等待元素出现
+await page.wait_for_selector("div.content")
+await page.wait_for_selector("div.content", state="visible")
+await page.wait_for_selector("div.content", state="hidden")
+
+# 等待页面状态
+await page.wait_for_load_state("networkidle")
+await page.wait_for_load_state("domcontentloaded")
+
+# 等待 URL 变化
+await page.wait_for_url("**/success")
+await page.wait_for_url(lambda url: "success" in url)
+
+# 等待特定时间
+await page.wait_for_timeout(1000) # 等待 1 秒(尽量避免使用)
+
+# 等待函数返回 True
+await page.wait_for_function("document.querySelector('.loaded') !== null")
+```
+
+### 超时设置
+
+```python
+# 全局超时
+browser = await p.chromium.launch()
+context = await browser.new_context()
+page = await context.new_page()
+page.set_default_timeout(30000) # 30 秒
+
+# 单次操作超时
+await page.click("button.submit", timeout=5000)
+await page.wait_for_selector("div.result", timeout=10000)
+```
+
+---
+
+## 页面内容提取
+
+### 获取文本
+
+```python
+# 获取元素文本
+text = await page.locator("h1.title").text_content()
+texts = await page.locator("div.item").all_text_contents()
+
+# 获取可见文本
+visible_text = await page.locator("div.content").inner_text()
+```
+
+### 获取属性
+
+```python
+# 获取属性
+href = await page.locator("a.link").get_attribute("href")
+src = await page.locator("img.avatar").get_attribute("src")
+
+# 获取多个元素的属性
+hrefs = await page.locator("a").evaluate_all("els => els.map(el => el.href)")
+```
+
+### 获取 HTML
+
+```python
+# 获取元素 HTML
+html = await page.locator("div.content").inner_html()
+
+# 获取整个页面 HTML
+full_html = await page.content()
+```
+
+### 执行 JavaScript
+
+```python
+# 执行 JS 并获取返回值
+title = await page.evaluate("document.title")
+
+# 传递参数
+result = await page.evaluate("(x, y) => x + y", 1, 2)
+
+# 在元素上执行 JS
+text = await page.locator("div.item").evaluate("el => el.textContent")
+```
+
+---
+
+## 网络请求拦截
+
+Playwright 可以拦截和修改网络请求,这对于爬虫非常有用。
+
+### 监听请求
+
+```python
+# 监听请求
+def on_request(request):
+ print(f"Request: {request.method} {request.url}")
+
+page.on("request", on_request)
+
+# 监听响应
+def on_response(response):
+ print(f"Response: {response.status} {response.url}")
+
+page.on("response", on_response)
+
+# 等待特定请求
+async with page.expect_request("**/api/data") as request_info:
+ await page.click("button.load")
+request = await request_info.value
+
+# 等待特定响应
+async with page.expect_response("**/api/data") as response_info:
+ await page.click("button.load")
+response = await response_info.value
+data = await response.json()
+```
+
+### 拦截请求
+
+```python
+# 拦截并修改请求
+async def handle_route(route):
+ # 修改请求头
+ headers = {**route.request.headers, "X-Custom": "value"}
+ await route.continue_(headers=headers)
+
+await page.route("**/*", handle_route)
+
+# 阻止某些资源加载(提高性能)
+await page.route("**/*.{png,jpg,jpeg,gif}", lambda route: route.abort())
+await page.route("**/analytics.js", lambda route: route.abort())
+
+# 返回模拟响应
+async def mock_api(route):
+ await route.fulfill(
+ status=200,
+ content_type="application/json",
+ body='{"data": "mocked"}'
+ )
+
+await page.route("**/api/data", mock_api)
+```
+
+---
+
+## 截图与 PDF
+
+### 截图
+
+```python
+# 页面截图
+await page.screenshot(path="screenshot.png")
+
+# 全页面截图
+await page.screenshot(path="full.png", full_page=True)
+
+# 元素截图
+await page.locator("div.chart").screenshot(path="chart.png")
+
+# 截图选项
+await page.screenshot(
+ path="screenshot.png",
+ type="png", # png 或 jpeg
+ quality=80, # jpeg 质量(0-100)
+ full_page=True, # 全页面
+ clip={"x": 0, "y": 0, "width": 800, "height": 600} # 裁剪区域
+)
+```
+
+### 导出 PDF
+
+```python
+# 导出 PDF(仅 Chromium 支持)
+await page.pdf(path="page.pdf")
+
+# PDF 选项
+await page.pdf(
+ path="page.pdf",
+ format="A4",
+ print_background=True,
+ margin={"top": "1cm", "bottom": "1cm"}
+)
+```
+
+---
+
+## 浏览器配置
+
+### 有头/无头模式
+
+```python
+# 有头模式(显示浏览器窗口)
+browser = await p.chromium.launch(headless=False)
+
+# 无头模式(不显示窗口,默认)
+browser = await p.chromium.launch(headless=True)
+```
+
+### 视口设置
+
+```python
+# 设置视口大小
+context = await browser.new_context(
+ viewport={"width": 1920, "height": 1080}
+)
+
+# 模拟移动设备
+from playwright.async_api import async_playwright
+
+async with async_playwright() as p:
+ iphone = p.devices["iPhone 13"]
+ browser = await p.webkit.launch()
+ context = await browser.new_context(**iphone)
+```
+
+### 代理设置
+
+```python
+# 设置代理
+browser = await p.chromium.launch(
+ proxy={
+ "server": "http://proxy.example.com:8080",
+ "username": "user",
+ "password": "pass"
+ }
+)
+```
+
+### 用户数据目录
+
+```python
+# 使用持久化的用户数据目录(保存 Cookie 等)
+context = await p.chromium.launch_persistent_context(
+ user_data_dir="./user_data",
+ headless=False
+)
+```
+
+---
+
+## Playwright 实战演练
+
+### 为什么需要浏览器自动化
+
+当网页使用 JavaScript 动态渲染内容时,传统的 HTTP 请求无法获取渲染后的数据。Playwright 可以完整模拟浏览器行为,等待页面渲染完成后再提取数据。
+
+```mermaid
+flowchart TD
+ subgraph 适用场景
+ SPA["SPA应用
动态路由"]
+ Lazy["懒加载
滚动触发"]
+ JS["JS渲染
动态内容"]
+ end
+
+ subgraph Playwright优势
+ Wait["智能等待
自动判断"]
+ Interact["模拟交互
点击/输入"]
+ Network["网络拦截
获取API数据"]
+ end
+
+ SPA --> Wait
+ Lazy --> Interact
+ JS --> Network
+
+ style SPA fill:#e3f2fd,stroke:#2196f3
+ style Lazy fill:#fff3e0,stroke:#ff9800
+ style JS fill:#e8f5e9,stroke:#4caf50
+```
+
+### 实战:爬取 JS 渲染的名言网站
+
+quotes.toscrape.com/js 是一个专门用于学习的网站,其内容通过 JavaScript 动态渲染,非常适合作为 Playwright 入门练习。
+
+```python
+# -*- coding: utf-8 -*-
+"""
+使用 Playwright 爬取 JS 渲染的名言网站
+"""
+
+import asyncio
+from playwright.async_api import async_playwright
+from loguru import logger
+from typing import List, Dict, Any
+
+
+async def scrape_quotes_js() -> List[Dict[str, Any]]:
+ """爬取 JS 渲染的名言网站"""
+ async with async_playwright() as p:
+ # 启动浏览器
+ browser = await p.chromium.launch(headless=True)
+
+ # 创建上下文,设置视口
+ context = await browser.new_context(
+ viewport={"width": 1920, "height": 1080},
+ locale="en-US"
+ )
+ page = await context.new_page()
+
+ try:
+ logger.info("正在访问名言网站...")
+ await page.goto(
+ "https://quotes.toscrape.com/js/",
+ wait_until="networkidle"
+ )
+
+ # 等待名言加载(JS 渲染需要时间)
+ await page.wait_for_selector(".quote", timeout=10000)
+
+ # 提取名言
+ quote_elements = await page.locator(".quote").all()
+ logger.info(f"找到 {len(quote_elements)} 条名言")
+
+ results = []
+ for quote_el in quote_elements:
+ try:
+ # 提取名言文本
+ text_el = quote_el.locator(".text")
+ text = await text_el.text_content()
+
+ # 提取作者
+ author_el = quote_el.locator(".author")
+ author = await author_el.text_content()
+
+ # 提取标签
+ tag_elements = await quote_el.locator(".tag").all()
+ tags = [await tag.text_content() for tag in tag_elements]
+
+ results.append({
+ "text": text.strip() if text else "",
+ "author": author.strip() if author else "",
+ "tags": tags
+ })
+ except Exception as e:
+ logger.debug(f"提取名言失败: {e}")
+ continue
+
+ # 输出结果
+ logger.info(f"成功提取 {len(results)} 条名言")
+ return results
+
+ finally:
+ await browser.close()
+
+
+async def main():
+ quotes = await scrape_quotes_js()
+
+ print("\n=== JS 渲染页面爬取结果 ===\n")
+ for i, quote in enumerate(quotes[:5], 1):
+ print(f"{i}. {quote['text'][:60]}...")
+ print(f" 作者: {quote['author']}")
+ print(f" 标签: {', '.join(quote['tags'])}")
+ print()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+### 实战:分页爬取与滚动加载
+
+```python
+# -*- coding: utf-8 -*-
+"""
+使用 Playwright 处理分页和滚动加载
+"""
+
+import asyncio
+from playwright.async_api import async_playwright
+from loguru import logger
+from typing import List, Dict, Any
+
+
+async def scrape_multiple_pages(max_pages: int = 3) -> List[Dict[str, Any]]:
+ """
+ 爬取多页名言
+
+ Args:
+ max_pages: 最大页数
+
+ Returns:
+ 名言列表
+ """
+ all_quotes = []
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ context = await browser.new_context(
+ viewport={"width": 1920, "height": 1080}
+ )
+ page = await context.new_page()
+
+ try:
+ for page_num in range(1, max_pages + 1):
+ url = f"https://quotes.toscrape.com/js/page/{page_num}/"
+ logger.info(f"正在爬取第 {page_num} 页...")
+
+ await page.goto(url, wait_until="networkidle")
+ await page.wait_for_selector(".quote", timeout=10000)
+
+ # 提取当前页名言
+ quote_elements = await page.locator(".quote").all()
+
+ for quote_el in quote_elements:
+ try:
+ text = await quote_el.locator(".text").text_content()
+ author = await quote_el.locator(".author").text_content()
+
+ all_quotes.append({
+ "text": text.strip() if text else "",
+ "author": author.strip() if author else "",
+ "page": page_num
+ })
+ except Exception as e:
+ logger.debug(f"提取失败: {e}")
+ continue
+
+ logger.info(f"第 {page_num} 页爬取完成,当前共 {len(all_quotes)} 条")
+
+ # 适当延迟,避免请求过快
+ await page.wait_for_timeout(500)
+
+ finally:
+ await browser.close()
+
+ return all_quotes
+
+
+async def main():
+ quotes = await scrape_multiple_pages(max_pages=3)
+
+ print(f"\n=== 共爬取 {len(quotes)} 条名言 ===\n")
+ for i, quote in enumerate(quotes[:10], 1):
+ print(f"{i}. [{quote['page']}页] {quote['text'][:50]}...")
+ print(f" 作者: {quote['author']}")
+ print()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+### 实战:拦截网络请求
+
+Playwright 的网络拦截功能可以直接获取 API 返回的 JSON 数据,比解析 DOM 更高效:
+
+```python
+# -*- coding: utf-8 -*-
+"""
+使用 Playwright 拦截网络请求
+"""
+
+import asyncio
+from playwright.async_api import async_playwright
+from loguru import logger
+from typing import List, Dict, Any
+
+
+async def intercept_api_requests() -> List[Dict[str, Any]]:
+ """
+ 拦截 API 请求获取数据
+
+ 通过网络拦截直接获取 JSON 数据
+ """
+ api_responses = []
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ context = await browser.new_context()
+ page = await context.new_page()
+
+ # 监听所有响应
+ async def handle_response(response):
+ url = response.url
+ # 过滤 API 请求
+ if response.request.resource_type == "xhr" or response.request.resource_type == "fetch":
+ try:
+ if response.status == 200:
+ content_type = response.headers.get("content-type", "")
+ if "json" in content_type:
+ data = await response.json()
+ api_responses.append({
+ "url": url,
+ "data": data
+ })
+ logger.info(f"拦截到 API 响应: {url[:60]}...")
+ except Exception as e:
+ logger.debug(f"解析响应失败: {e}")
+
+ page.on("response", handle_response)
+
+ # 访问页面触发请求
+ await page.goto("https://quotes.toscrape.com/js/", wait_until="networkidle")
+
+ # 等待数据拦截完成
+ await page.wait_for_timeout(2000)
+
+ await browser.close()
+
+ return api_responses
+
+
+async def demo_block_resources():
+ """演示阻止资源加载以提升性能"""
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ # 阻止图片和字体加载
+ await page.route("**/*.{png,jpg,jpeg,gif,svg}", lambda route: route.abort())
+ await page.route("**/*.{woff,woff2,ttf}", lambda route: route.abort())
+
+ logger.info("已设置资源拦截,图片和字体将不会加载")
+
+ await page.goto("https://quotes.toscrape.com/")
+ await page.wait_for_selector(".quote")
+
+ quotes_count = await page.locator(".quote").count()
+ logger.info(f"页面加载完成,找到 {quotes_count} 条名言(无图片模式)")
+
+ await browser.close()
+
+
+async def main():
+ print("=== 拦截 API 请求示例 ===")
+ responses = await intercept_api_requests()
+ print(f"共拦截到 {len(responses)} 个 API 响应\n")
+
+ print("=== 阻止资源加载示例 ===")
+ await demo_block_resources()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+### Playwright 使用注意事项
+
+```mermaid
+graph LR
+ subgraph 反检测
+ A1["隐藏自动化特征"]
+ A2["模拟真实UA"]
+ A3["设置合理视口"]
+ end
+
+ subgraph 性能优化
+ B1["禁用图片加载"]
+ B2["拦截无关资源"]
+ B3["复用浏览器实例"]
+ end
+
+ subgraph 稳定性
+ C1["适当等待时间"]
+ C2["处理弹窗"]
+ C3["异常重试机制"]
+ end
+
+ A1 --> B1 --> C1
+
+ style A1 fill:#e3f2fd,stroke:#2196f3
+ style B1 fill:#fff3e0,stroke:#ff9800
+ style C1 fill:#e8f5e9,stroke:#4caf50
+```
+
+**关键注意点**:
+
+1. **反自动化检测**:部分网站会检测 `navigator.webdriver`,需要使用反检测技术(下一章详解)
+2. **请求频率**:避免频繁刷新页面,建议间隔 1-2 秒
+3. **Cookie 管理**:登录态通过 Context 的 `storage_state` 保存和恢复
+4. **资源优化**:禁用图片/字体加载可大幅提升速度
+5. **网络拦截**:直接拦截 API 响应比解析 DOM 更稳定高效
+
+---
+
+## 本章小结
+
+本章我们学习了 Playwright 浏览器自动化的基础知识:
+
+1. **核心概念**:Browser/Context/Page 三层模型,同步/异步 API
+2. **页面导航**:goto、等待策略、导航控制
+3. **元素定位**:语义化定位器、CSS、XPath
+4. **交互操作**:点击、输入、选择、键盘、鼠标
+5. **等待策略**:自动等待、显式等待、超时设置
+6. **内容提取**:文本、属性、HTML、执行 JavaScript
+7. **网络拦截**:监听请求响应、拦截修改请求
+8. **实战演练**:爬取 JS 渲染页面、分页处理、资源拦截
+
+---
+
+## 下一章预告
+
+下一章我们将学习「Playwright 进阶:反检测与性能优化」。主要内容包括:
+
+- 浏览器指纹检测原理
+- stealth.min.js 反检测注入
+- CDP 模式的使用
+- 性能优化技巧(禁用资源、复用上下文)
+- 异常处理和资源管理
+
+这些进阶技巧将帮助你的爬虫更好地应对严格的反爬检测。
diff --git "a/docs/\347\210\254\350\231\253\350\277\233\344\273\267/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226.md" "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226.md"
new file mode 100644
index 0000000..4f549cb
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226.md"
@@ -0,0 +1,958 @@
+# Playwright 进阶:反检测与性能优化
+
+> 掌握了 Playwright 的基础操作后,你会发现在实际爬虫场景中,很多网站能够检测到自动化行为并进行封禁。本章将深入讲解浏览器指纹检测原理,以及如何使用 stealth.js 等技术绕过检测,同时介绍性能优化技巧。
+
+## 浏览器指纹检测原理
+
+### 什么是浏览器指纹
+
+浏览器指纹是通过收集浏览器的各种特征(如 User-Agent、屏幕分辨率、插件列表、字体列表等)来唯一标识一个浏览器的技术。即使没有 Cookie,网站也可以通过指纹追踪用户。
+
+### 自动化浏览器的检测点
+
+```mermaid
+flowchart TD
+ subgraph 检测层级
+ L1["Level 1: 基础检测"]
+ L2["Level 2: 特征检测"]
+ L3["Level 3: 行为检测"]
+ end
+
+ subgraph 基础检测项
+ D1["navigator.webdriver"]
+ D2["window.chrome"]
+ D3["navigator.plugins"]
+ end
+
+ subgraph 特征检测项
+ D4["Canvas指纹"]
+ D5["WebGL指纹"]
+ D6["Audio指纹"]
+ end
+
+ subgraph 行为检测项
+ D7["鼠标轨迹"]
+ D8["键盘节奏"]
+ D9["页面停留"]
+ end
+
+ L1 --> D1 & D2 & D3
+ L2 --> D4 & D5 & D6
+ L3 --> D7 & D8 & D9
+
+ style L1 fill:#e8f5e9,stroke:#4caf50
+ style L2 fill:#fff3e0,stroke:#ff9800
+ style L3 fill:#ffebee,stroke:#f44336
+```
+
+| 检测类型 | 检测方法 | 说明 |
+|---------|---------|------|
+| WebDriver 标志 | `navigator.webdriver` | Playwright 默认为 true |
+| Chrome 特征 | `window.chrome` | 自动化环境可能缺失 |
+| 插件检测 | `navigator.plugins` | 自动化环境插件列表异常 |
+| 语言检测 | `navigator.languages` | 配置不当可能暴露 |
+| 权限 API | `navigator.permissions` | 行为与真实浏览器不同 |
+| Canvas 指纹 | `canvas.toDataURL()` | 渲染结果可能有差异 |
+| WebGL 指纹 | WebGL 参数 | 可能暴露虚拟化环境 |
+| 时区/语言 | 系统设置 | 与声称的地区不符 |
+
+### 常见的检测脚本
+
+```javascript
+// 检测 WebDriver
+if (navigator.webdriver) {
+ console.log("检测到自动化浏览器");
+}
+
+// 检测 Chrome 特征
+if (!window.chrome) {
+ console.log("可能是自动化环境");
+}
+
+// 检测 plugins
+if (navigator.plugins.length === 0) {
+ console.log("插件列表为空,可能是自动化");
+}
+
+// 检测 permissions
+navigator.permissions.query({name: "notifications"}).then(result => {
+ if (result.state === "prompt") {
+ // 正常行为
+ }
+});
+```
+
+---
+
+## stealth.js 反检测技术
+
+### 什么是 stealth.js
+
+`stealth.js` 是 puppeteer-extra-plugin-stealth 项目提取出的反检测脚本,它通过修改浏览器的各种属性来伪装自动化浏览器,使其看起来像真实用户操作的浏览器。
+
+这也是 MediaCrawler 项目使用的核心反检测技术。
+
+### stealth.min.js 的工作原理
+
+stealth.js 主要做以下修改:
+
+1. **隐藏 WebDriver 标志**:将 `navigator.webdriver` 设置为 `undefined`
+2. **模拟 Chrome 特征**:添加 `window.chrome` 对象
+3. **修改 plugins**:模拟正常的插件列表
+4. **修改 permissions**:使权限 API 行为正常
+5. **修复 iframe 检测**:处理 contentWindow 问题
+6. **其他特征修复**:语言、时区等
+
+### 在 Playwright 中使用 stealth.js
+
+```python
+import asyncio
+from playwright.async_api import async_playwright
+
+# stealth.min.js 脚本内容(实际使用时从文件加载)
+STEALTH_JS = """
+// 隐藏 webdriver 标志
+Object.defineProperty(navigator, 'webdriver', {
+ get: () => undefined
+});
+
+// 模拟 chrome 对象
+window.chrome = {
+ runtime: {}
+};
+
+// 模拟 plugins
+Object.defineProperty(navigator, 'plugins', {
+ get: () => [1, 2, 3, 4, 5]
+});
+
+// 模拟 languages
+Object.defineProperty(navigator, 'languages', {
+ get: () => ['zh-CN', 'zh', 'en']
+});
+
+// 修复 permissions
+const originalQuery = window.navigator.permissions.query;
+window.navigator.permissions.query = (parameters) => (
+ parameters.name === 'notifications' ?
+ Promise.resolve({ state: Notification.permission }) :
+ originalQuery(parameters)
+);
+"""
+
+
+async def create_stealth_page(browser):
+ """创建带反检测的页面"""
+ context = await browser.new_context()
+
+ # 在每个新页面创建时注入 stealth 脚本
+ await context.add_init_script(STEALTH_JS)
+
+ page = await context.new_page()
+ return page
+
+
+async def main():
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await create_stealth_page(browser)
+
+ # 测试反检测效果
+ await page.goto("https://bot.sannysoft.com/")
+ await page.screenshot(path="stealth_test.png", full_page=True)
+
+ await browser.close()
+
+
+asyncio.run(main())
+```
+
+### 完整的 stealth.min.js
+
+实际项目中,建议使用完整的 stealth.min.js 文件。你可以从以下来源获取:
+
+1. [puppeteer-extra-plugin-stealth](https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth)
+2. MediaCrawler 项目中的 stealth.min.js
+
+加载方式:
+
+```python
+async def create_stealth_context(browser, stealth_js_path: str):
+ """创建带完整反检测的上下文"""
+ context = await browser.new_context()
+
+ # 从文件加载 stealth.js
+ with open(stealth_js_path, "r") as f:
+ stealth_js = f.read()
+
+ await context.add_init_script(stealth_js)
+ return context
+```
+
+---
+
+## CDP 模式
+
+### 什么是 CDP
+
+CDP(Chrome DevTools Protocol)是 Chrome 浏览器提供的调试协议,允许程序直接与浏览器通信。Playwright 内部就是使用 CDP 与 Chromium 通信的。
+
+### 标准模式 vs CDP 模式
+
+| 特性 | 标准模式 | CDP 模式 |
+|------|---------|---------|
+| API | Playwright 封装的 API | 原始 CDP 命令 |
+| 功能 | 常用功能 | 完整的浏览器控制 |
+| 复杂度 | 简单 | 较复杂 |
+| 使用场景 | 大多数场景 | 需要底层控制时 |
+
+### 使用 CDP 连接已有浏览器
+
+```python
+import asyncio
+from playwright.async_api import async_playwright
+
+
+async def connect_existing_browser():
+ """连接到已经运行的浏览器"""
+ async with async_playwright() as p:
+ # 首先启动一个带调试端口的浏览器
+ # chrome --remote-debugging-port=9222
+
+ # 通过 CDP 连接
+ browser = await p.chromium.connect_over_cdp(
+ "http://localhost:9222"
+ )
+
+ # 获取所有页面
+ contexts = browser.contexts
+ if contexts:
+ pages = contexts[0].pages
+ if pages:
+ page = pages[0]
+ print(f"当前页面: {page.url}")
+
+ await browser.close()
+```
+
+### 使用 CDP 命令
+
+```python
+async def use_cdp_commands(page):
+ """直接使用 CDP 命令"""
+ # 获取 CDP session
+ client = await page.context.new_cdp_session(page)
+
+ # 执行 CDP 命令
+ # 例如:获取性能指标
+ metrics = await client.send("Performance.getMetrics")
+ print(metrics)
+
+ # 例如:模拟网络条件
+ await client.send("Network.emulateNetworkConditions", {
+ "offline": False,
+ "downloadThroughput": 1000000, # 1 MB/s
+ "uploadThroughput": 500000,
+ "latency": 100
+ })
+
+ # 例如:截取完整页面
+ result = await client.send("Page.captureScreenshot", {
+ "format": "png",
+ "captureBeyondViewport": True
+ })
+```
+
+---
+
+## 性能优化
+
+### 禁用不必要的资源加载
+
+加载图片、字体、CSS 等资源会消耗大量时间和带宽。对于只需要获取数据的爬虫,可以禁用这些资源:
+
+```python
+async def create_optimized_context(browser):
+ """创建优化性能的上下文"""
+ context = await browser.new_context()
+
+ # 拦截并阻止不必要的资源
+ await context.route("**/*.{png,jpg,jpeg,gif,svg,ico}", lambda route: route.abort())
+ await context.route("**/*.{woff,woff2,ttf,otf}", lambda route: route.abort())
+ await context.route("**/*.css", lambda route: route.abort())
+
+ # 阻止分析和追踪脚本
+ await context.route("**/analytics.js", lambda route: route.abort())
+ await context.route("**/gtag/**", lambda route: route.abort())
+ await context.route("**/facebook.com/**", lambda route: route.abort())
+
+ return context
+
+
+async def selective_blocking(page):
+ """选择性阻止资源"""
+ async def handle_route(route):
+ resource_type = route.request.resource_type
+
+ # 只允许 document、script、xhr、fetch
+ if resource_type in ["document", "script", "xhr", "fetch"]:
+ await route.continue_()
+ else:
+ await route.abort()
+
+ await page.route("**/*", handle_route)
+```
+
+### 浏览器上下文复用
+
+创建新的浏览器上下文比创建新页面消耗更多资源。合理复用上下文可以提高性能:
+
+```python
+class BrowserPool:
+ """浏览器上下文池"""
+
+ def __init__(self, browser, pool_size: int = 5):
+ self.browser = browser
+ self.pool_size = pool_size
+ self._contexts = []
+ self._available = asyncio.Queue()
+
+ async def initialize(self):
+ """初始化上下文池"""
+ for _ in range(self.pool_size):
+ context = await self.browser.new_context()
+ self._contexts.append(context)
+ await self._available.put(context)
+
+ async def get_context(self):
+ """获取可用上下文"""
+ return await self._available.get()
+
+ async def return_context(self, context):
+ """归还上下文"""
+ # 清理 cookies 和 storage
+ await context.clear_cookies()
+ await self._available.put(context)
+
+ async def close_all(self):
+ """关闭所有上下文"""
+ for context in self._contexts:
+ await context.close()
+```
+
+### 多页面并发管理
+
+```python
+import asyncio
+
+
+class ConcurrentCrawler:
+ """并发爬虫"""
+
+ def __init__(self, browser, max_concurrent: int = 5):
+ self.browser = browser
+ self.max_concurrent = max_concurrent
+ self.semaphore = asyncio.Semaphore(max_concurrent)
+
+ async def crawl_url(self, context, url: str) -> dict:
+ """爬取单个 URL"""
+ async with self.semaphore:
+ page = await context.new_page()
+ try:
+ await page.goto(url, wait_until="domcontentloaded")
+ title = await page.title()
+ return {"url": url, "title": title, "success": True}
+ except Exception as e:
+ return {"url": url, "error": str(e), "success": False}
+ finally:
+ await page.close()
+
+ async def crawl_batch(self, urls: list) -> list:
+ """批量爬取"""
+ context = await self.browser.new_context()
+ try:
+ tasks = [self.crawl_url(context, url) for url in urls]
+ return await asyncio.gather(*tasks)
+ finally:
+ await context.close()
+```
+
+### 内存和资源监控
+
+```python
+async def monitor_resources(browser):
+ """监控浏览器资源使用"""
+ # 获取所有上下文
+ contexts = browser.contexts
+ print(f"活跃上下文数: {len(contexts)}")
+
+ total_pages = 0
+ for ctx in contexts:
+ pages = ctx.pages
+ total_pages += len(pages)
+ for page in pages:
+ print(f" 页面: {page.url[:50]}...")
+
+ print(f"总页面数: {total_pages}")
+
+
+async def cleanup_stale_pages(context, max_age_seconds: int = 300):
+ """清理闲置页面"""
+ # 这里需要自己记录页面的创建时间
+ # 示例仅展示逻辑
+ for page in context.pages:
+ # 关闭空白页或闲置页
+ if page.url == "about:blank":
+ await page.close()
+```
+
+---
+
+## 异常处理
+
+### 页面崩溃检测和恢复
+
+```python
+async def safe_navigate(page, url: str, max_retries: int = 3):
+ """安全的页面导航,带重试"""
+ for attempt in range(max_retries):
+ try:
+ await page.goto(url, timeout=30000)
+ return True
+ except Exception as e:
+ error_msg = str(e).lower()
+
+ if "crash" in error_msg or "target closed" in error_msg:
+ # 页面崩溃,需要重新创建
+ print(f"页面崩溃,尝试恢复 (尝试 {attempt + 1}/{max_retries})")
+ # 这里应该创建新页面
+ continue
+
+ elif "timeout" in error_msg:
+ # 超时,可以重试
+ print(f"超时,重试 (尝试 {attempt + 1}/{max_retries})")
+ continue
+
+ else:
+ # 其他错误
+ print(f"导航错误: {e}")
+ raise
+
+ return False
+```
+
+### 浏览器进程管理
+
+```python
+import signal
+import atexit
+
+
+class BrowserManager:
+ """浏览器进程管理器"""
+
+ def __init__(self):
+ self._browser = None
+ self._playwright = None
+
+ # 注册清理函数
+ atexit.register(self._cleanup_sync)
+ signal.signal(signal.SIGTERM, self._signal_handler)
+ signal.signal(signal.SIGINT, self._signal_handler)
+
+ async def start(self, playwright):
+ """启动浏览器"""
+ self._playwright = playwright
+ self._browser = await playwright.chromium.launch(headless=True)
+ return self._browser
+
+ async def stop(self):
+ """停止浏览器"""
+ if self._browser:
+ await self._browser.close()
+ self._browser = None
+
+ def _cleanup_sync(self):
+ """同步清理"""
+ if self._browser:
+ # 强制关闭
+ try:
+ import asyncio
+ loop = asyncio.get_event_loop()
+ loop.run_until_complete(self.stop())
+ except Exception:
+ pass
+
+ def _signal_handler(self, signum, frame):
+ """信号处理"""
+ print(f"收到信号 {signum},正在清理...")
+ self._cleanup_sync()
+ exit(0)
+```
+
+---
+
+## 反检测实战
+
+### 网站的反自动化检测机制
+
+大型网站通常有较为完善的反自动化检测:
+
+```mermaid
+flowchart LR
+ subgraph 检测机制
+ Check1["WebDriver检测"]
+ Check2["请求头验证"]
+ Check3["行为分析"]
+ Check4["频率限制"]
+ end
+
+ subgraph 检测结果
+ Pass["正常访问"]
+ Block["触发风控"]
+ Captcha["触发验证码"]
+ end
+
+ Check1 -->|通过| Check2
+ Check1 -->|失败| Block
+ Check2 -->|通过| Check3
+ Check2 -->|异常| Block
+ Check3 -->|正常| Pass
+ Check3 -->|异常| Captcha
+ Check4 -->|超限| Block
+
+ style Pass fill:#c8e6c9,stroke:#4caf50
+ style Block fill:#ffcdd2,stroke:#f44336
+ style Captcha fill:#fff3e0,stroke:#ff9800
+```
+
+### 完整反检测配置
+
+```python
+# -*- coding: utf-8 -*-
+"""
+Playwright 反检测配置
+"""
+
+import asyncio
+from playwright.async_api import async_playwright, Browser, BrowserContext, Page
+from loguru import logger
+from typing import Optional
+
+
+# 通用 stealth 脚本
+STEALTH_JS = """
+// 隐藏 webdriver 标志
+Object.defineProperty(navigator, 'webdriver', {
+ get: () => undefined
+});
+
+// 模拟 Chrome 对象
+window.chrome = {
+ runtime: {},
+ loadTimes: function() {},
+ csi: function() {},
+ app: {}
+};
+
+// 模拟正常的插件列表
+Object.defineProperty(navigator, 'plugins', {
+ get: () => {
+ const plugins = [
+ {
+ name: 'Chrome PDF Plugin',
+ description: 'Portable Document Format',
+ filename: 'internal-pdf-viewer'
+ },
+ {
+ name: 'Chrome PDF Viewer',
+ description: '',
+ filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai'
+ },
+ {
+ name: 'Native Client',
+ description: '',
+ filename: 'internal-nacl-plugin'
+ }
+ ];
+ plugins.item = (i) => plugins[i];
+ plugins.namedItem = (name) => plugins.find(p => p.name === name);
+ plugins.refresh = () => {};
+ return plugins;
+ }
+});
+
+// 模拟语言设置
+Object.defineProperty(navigator, 'languages', {
+ get: () => ['zh-CN', 'zh', 'en-US', 'en']
+});
+
+// 修复 permissions API
+const originalQuery = window.navigator.permissions.query;
+window.navigator.permissions.query = (parameters) => (
+ parameters.name === 'notifications'
+ ? Promise.resolve({ state: Notification.permission })
+ : originalQuery(parameters)
+);
+
+// 模拟硬件并发数
+Object.defineProperty(navigator, 'hardwareConcurrency', {
+ get: () => 8
+});
+
+// 模拟设备内存
+Object.defineProperty(navigator, 'deviceMemory', {
+ get: () => 8
+});
+
+// 隐藏自动化相关属性
+delete window.cdc_adoQpoasnfa76pfcZLmcfl_Array;
+delete window.cdc_adoQpoasnfa76pfcZLmcfl_Promise;
+delete window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol;
+"""
+
+
+class StealthBrowser:
+ """
+ 反检测浏览器封装
+
+ 特性:
+ - 自动注入 stealth 脚本
+ - 模拟真实浏览器环境
+ - 支持资源优化
+ - Cookie 管理
+ """
+
+ def __init__(self, headless: bool = True):
+ self.headless = headless
+ self._playwright = None
+ self._browser: Optional[Browser] = None
+ self._context: Optional[BrowserContext] = None
+
+ async def start(self) -> BrowserContext:
+ """启动浏览器并创建反检测上下文"""
+ from playwright.async_api import async_playwright
+
+ self._playwright = await async_playwright().start()
+
+ # 启动浏览器,添加反检测参数
+ self._browser = await self._playwright.chromium.launch(
+ headless=self.headless,
+ args=[
+ "--disable-blink-features=AutomationControlled",
+ "--disable-dev-shm-usage",
+ "--no-sandbox",
+ "--disable-setuid-sandbox",
+ "--disable-infobars",
+ "--window-size=1920,1080",
+ "--start-maximized",
+ ]
+ )
+
+ # 创建上下文
+ self._context = await self._browser.new_context(
+ viewport={"width": 1920, "height": 1080},
+ locale="zh-CN",
+ timezone_id="Asia/Shanghai",
+ user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/131.0.0.0 Safari/537.36",
+ extra_http_headers={
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ }
+ )
+
+ # 注入 stealth 脚本
+ await self._context.add_init_script(STEALTH_JS)
+
+ logger.info("反检测浏览器已启动")
+ return self._context
+
+ async def create_optimized_page(self) -> Page:
+ """创建性能优化的页面"""
+ if not self._context:
+ raise RuntimeError("浏览器未启动,请先调用 start()")
+
+ page = await self._context.new_page()
+
+ # 禁用不必要的资源
+ await page.route("**/*.{png,jpg,jpeg,gif,webp,svg,ico}", lambda r: r.abort())
+ await page.route("**/*.{woff,woff2,ttf,otf,eot}", lambda r: r.abort())
+ await page.route("**/analytics**", lambda r: r.abort())
+ await page.route("**/tracking**", lambda r: r.abort())
+
+ return page
+
+ async def save_cookies(self, path: str):
+ """保存 Cookie 到文件"""
+ if self._context:
+ await self._context.storage_state(path=path)
+ logger.info(f"Cookie 已保存到: {path}")
+
+ async def load_cookies(self, path: str):
+ """从文件加载 Cookie"""
+ import os
+ if os.path.exists(path):
+ # 需要重新创建 context
+ if self._context:
+ await self._context.close()
+
+ self._context = await self._browser.new_context(
+ storage_state=path,
+ viewport={"width": 1920, "height": 1080},
+ locale="zh-CN"
+ )
+ await self._context.add_init_script(STEALTH_JS)
+ logger.info(f"Cookie 已从 {path} 加载")
+
+ async def stop(self):
+ """关闭浏览器"""
+ if self._context:
+ await self._context.close()
+ if self._browser:
+ await self._browser.close()
+ if self._playwright:
+ await self._playwright.stop()
+ logger.info("浏览器已关闭")
+
+
+async def test_stealth_browser():
+ """测试反检测效果(使用 bot.sannysoft.com)"""
+ browser = StealthBrowser(headless=True)
+ context = await browser.start()
+
+ try:
+ page = await browser.create_optimized_page()
+
+ # 访问 WebDriver 检测网站
+ logger.info("访问 bot.sannysoft.com 测试反检测效果...")
+ await page.goto("https://bot.sannysoft.com/", wait_until="networkidle")
+
+ # 检查反检测效果
+ webdriver = await page.evaluate("navigator.webdriver")
+ chrome = await page.evaluate("!!window.chrome")
+ plugins = await page.evaluate("navigator.plugins.length")
+
+ logger.info(f"反检测检查:")
+ logger.info(f" - navigator.webdriver: {webdriver}")
+ logger.info(f" - window.chrome 存在: {chrome}")
+ logger.info(f" - plugins 数量: {plugins}")
+
+ # 截图保存测试结果
+ await page.screenshot(path="stealth_test.png", full_page=True)
+ logger.info("截图已保存到 stealth_test.png")
+
+ finally:
+ await browser.stop()
+
+
+if __name__ == "__main__":
+ asyncio.run(test_stealth_browser())
+```
+
+### 性能优化爬虫配置
+
+```python
+# -*- coding: utf-8 -*-
+"""
+Playwright 性能优化配置
+"""
+
+import asyncio
+from playwright.async_api import async_playwright, Route
+from loguru import logger
+from typing import Set
+
+
+class OptimizedCrawler:
+ """
+ 性能优化爬虫
+
+ 优化策略:
+ - 禁用图片/字体/CSS加载
+ - 拦截广告和追踪脚本
+ - 复用浏览器上下文
+ - 智能等待策略
+ """
+
+ # 需要阻止的资源类型
+ BLOCKED_RESOURCE_TYPES: Set[str] = {
+ "image",
+ "font",
+ "stylesheet",
+ "media",
+ }
+
+ # 需要阻止的URL模式
+ BLOCKED_URL_PATTERNS = [
+ "**/analytics**",
+ "**/tracking**",
+ "**/ads**",
+ "**/*.gif",
+ "**/*.png",
+ "**/*.jpg",
+ "**/*.jpeg",
+ "**/*.webp",
+ ]
+
+ def __init__(self, headless: bool = True):
+ self.headless = headless
+ self._browser = None
+ self._context = None
+
+ async def _route_handler(self, route: Route):
+ """路由处理器 - 决定是否阻止请求"""
+ request = route.request
+
+ # 检查资源类型
+ if request.resource_type in self.BLOCKED_RESOURCE_TYPES:
+ await route.abort()
+ return
+
+ # 检查URL模式(广告和追踪)
+ url = request.url
+ for pattern in ["analytics", "tracking", "ads"]:
+ if pattern in url:
+ await route.abort()
+ return
+
+ await route.continue_()
+
+ async def start(self):
+ """启动优化后的浏览器"""
+ self._playwright = await async_playwright().start()
+
+ self._browser = await self._playwright.chromium.launch(
+ headless=self.headless,
+ args=["--disable-blink-features=AutomationControlled"]
+ )
+
+ self._context = await self._browser.new_context(
+ viewport={"width": 1920, "height": 1080},
+ locale="zh-CN"
+ )
+
+ # 设置路由拦截
+ await self._context.route("**/*", self._route_handler)
+
+ logger.info("性能优化浏览器已启动")
+
+ async def crawl_page(self, url: str) -> dict:
+ """
+ 爬取页面
+
+ Args:
+ url: 目标URL
+
+ Returns:
+ 页面信息
+ """
+ page = await self._context.new_page()
+
+ try:
+ logger.info(f"爬取页面: {url}")
+
+ # 访问页面
+ await page.goto(url, wait_until="domcontentloaded")
+
+ # 获取页面标题
+ title = await page.title()
+
+ # 获取页面内容
+ content = await page.content()
+
+ return {
+ "url": url,
+ "title": title.strip() if title else "",
+ "content_length": len(content)
+ }
+
+ finally:
+ await page.close()
+
+ async def stop(self):
+ """关闭浏览器"""
+ if self._context:
+ await self._context.close()
+ if self._browser:
+ await self._browser.close()
+ if self._playwright:
+ await self._playwright.stop()
+
+
+async def benchmark_optimization():
+ """性能对比测试"""
+ import time
+
+ # 测试URL列表
+ urls = [
+ "https://quotes.toscrape.com/",
+ "https://quotes.toscrape.com/page/2/",
+ "https://quotes.toscrape.com/page/3/",
+ ]
+
+ crawler = OptimizedCrawler(headless=True)
+ await crawler.start()
+
+ try:
+ start = time.time()
+
+ for url in urls:
+ result = await crawler.crawl_page(url)
+ logger.info(f"页面: {result['title']} | 大小: {result['content_length']} bytes")
+
+ elapsed = time.time() - start
+ logger.info(f"总耗时: {elapsed:.2f}s | 平均: {elapsed/len(urls):.2f}s/页")
+
+ finally:
+ await crawler.stop()
+
+
+if __name__ == "__main__":
+ asyncio.run(benchmark_optimization())
+```
+
+### 反检测效果验证流程
+
+```mermaid
+flowchart LR
+ Start["启动浏览器"] --> Inject["注入stealth.js"]
+ Inject --> Visit["访问测试网站"]
+ Visit --> Check{"检测验证"}
+
+ Check -->|webdriver=undefined| Pass1["✓ 通过"]
+ Check -->|chrome存在| Pass2["✓ 通过"]
+ Check -->|plugins正常| Pass3["✓ 通过"]
+
+ Pass1 & Pass2 & Pass3 --> Result["反检测成功"]
+ Result --> Crawl["正常爬取"]
+
+ style Result fill:#c8e6c9,stroke:#4caf50
+ style Crawl fill:#e3f2fd,stroke:#2196f3
+```
+
+---
+
+## 本章小结
+
+本章我们学习了 Playwright 的进阶技术:
+
+1. **浏览器指纹检测**:了解网站如何检测自动化浏览器
+2. **stealth.js 反检测**:使用脚本注入绕过检测
+3. **CDP 模式**:直接使用 Chrome DevTools Protocol
+4. **性能优化**:禁用资源加载、上下文复用、并发管理
+5. **异常处理**:页面崩溃恢复、资源清理
+6. **实战演练**:反检测配置、性能优化爬虫
+
+---
+
+## 下一章预告
+
+下一章我们将学习「登录认证:Cookie 与 Session 管理」。主要内容包括:
+
+- Cookie 和 Session 的深入理解
+- Cookie 的提取、存储和注入
+- 登录状态检测和自动刷新
+- 多账号 Cookie 轮换
+
+这些技术是爬取需要登录的网站的基础。
diff --git "a/docs/\347\210\254\350\231\253\350\277\233\344\273\267/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206.md" "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206.md"
new file mode 100644
index 0000000..a6e4b69
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206.md"
@@ -0,0 +1,1245 @@
+# 06_登录认证:Cookie 与 Session 管理
+
+在爬虫开发中,很多数据需要登录后才能获取。本章将深入讲解 Cookie 和 Session 的工作机制,以及如何在爬虫中高效管理登录状态。
+
+## 一、认证机制深入理解
+
+### 1.1 Cookie 的工作原理
+
+Cookie 是服务器发送给浏览器的小型数据片段,浏览器会保存并在后续请求中自动携带。
+
+```mermaid
+sequenceDiagram
+ participant Client as 客户端
+ participant Server as 服务器
+
+ Client->>Server: HTTP 请求 (首次访问)
+ Server-->>Client: Set-Cookie: session=xxx
+ Note over Client: 保存 Cookie 到本地
+
+ Client->>Server: HTTP 请求 + Cookie: session=xxx
+ Server-->>Client: 返回用户数据
+ Note over Server: 识别用户身份
+```
+
+### 1.2 Cookie 属性详解
+
+每个 Cookie 都有多个属性,理解这些属性对于正确使用 Cookie 至关重要:
+
+| 属性 | 说明 | 爬虫影响 |
+|-----|------|---------|
+| **Name/Value** | Cookie 的名称和值 | 核心数据,必须正确提取 |
+| **Domain** | Cookie 生效的域名 | 决定请求哪些域名时携带 |
+| **Path** | Cookie 生效的路径 | 决定请求哪些路径时携带 |
+| **Expires/Max-Age** | 过期时间 | 决定 Cookie 何时失效 |
+| **HttpOnly** | 禁止 JavaScript 访问 | 只能通过 HTTP 响应获取 |
+| **Secure** | 仅 HTTPS 传输 | 注意协议匹配 |
+| **SameSite** | 跨站请求限制 | 影响第三方请求 |
+
+```python
+# Cookie 属性示例
+cookie_example = {
+ "name": "session_id",
+ "value": "abc123xyz",
+ "domain": ".example.com", # 包含子域名
+ "path": "/", # 所有路径
+ "expires": 1735689600, # Unix 时间戳
+ "httpOnly": True, # 不能通过 JS 访问
+ "secure": True, # 仅 HTTPS
+ "sameSite": "Lax" # 跨站限制
+}
+```
+
+### 1.3 Session 机制
+
+Session 是服务端的会话状态存储机制,通常通过 Cookie 中的 Session ID 关联:
+
+```python
+# 服务端 Session 工作流程(伪代码)
+class SessionFlow:
+ """
+ 1. 用户登录 -> 服务器创建 Session
+ 2. 服务器返回 Session ID (通过 Set-Cookie)
+ 3. 浏览器携带 Session ID 访问
+ 4. 服务器根据 Session ID 查找用户状态
+ """
+
+ def login(self, username, password):
+ if self.verify(username, password):
+ session_id = generate_session_id()
+ self.sessions[session_id] = {
+ "user_id": user.id,
+ "login_time": time.time(),
+ "expires": time.time() + 3600 * 24
+ }
+ return session_id
+ return None
+```
+
+### 1.4 Token 认证机制
+
+现代应用越来越多使用 Token 认证(如 JWT),与 Cookie/Session 有所不同:
+
+| 特性 | Cookie/Session | Token (JWT) |
+|-----|----------------|-------------|
+| 状态存储 | 服务端 | 客户端(Token 自包含) |
+| 跨域支持 | 受限(SameSite) | 天然支持 |
+| 传输方式 | Cookie 头 | Authorization 头 |
+| 服务端开销 | 需要存储 Session | 无状态 |
+
+```python
+# Token 认证示例
+import httpx
+
+headers = {
+ "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+}
+
+async with httpx.AsyncClient() as client:
+ resp = await client.get("https://api.example.com/data", headers=headers)
+```
+
+## 二、Cookie 的提取与存储
+
+### 2.1 从浏览器手动提取
+
+最简单的方式是从浏览器开发者工具中手动复制 Cookie:
+
+1. 打开浏览器,登录目标网站
+2. 按 F12 打开开发者工具
+3. 切换到 Application/Storage -> Cookies
+4. 复制需要的 Cookie 值
+
+```python
+# 手动提取的 Cookie 使用示例
+import httpx
+
+cookies = {
+ "session_id": "abc123",
+ "user_token": "xyz789",
+ "preferences": "theme=dark"
+}
+
+async with httpx.AsyncClient(cookies=cookies) as client:
+ resp = await client.get("https://example.com/user/profile")
+```
+
+### 2.2 使用 Playwright 自动提取
+
+Playwright 可以自动化登录流程并提取 Cookie:
+
+```python
+from playwright.async_api import async_playwright
+import json
+
+async def extract_cookies_after_login():
+ """登录后自动提取 Cookie"""
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=False) # 有头模式便于手动登录
+ context = await browser.new_context()
+ page = await context.new_page()
+
+ # 导航到登录页
+ await page.goto("https://example.com/login")
+
+ # 等待用户手动登录(或自动填写表单)
+ # await page.fill("#username", "your_username")
+ # await page.fill("#password", "your_password")
+ # await page.click("#login-button")
+
+ # 等待登录成功(检测特定元素或 URL 变化)
+ await page.wait_for_url("**/dashboard**", timeout=120000)
+
+ # 提取所有 Cookie
+ cookies = await context.cookies()
+
+ # 保存到文件
+ with open("cookies.json", "w") as f:
+ json.dump(cookies, f, indent=2)
+
+ print(f"提取了 {len(cookies)} 个 Cookie")
+
+ await browser.close()
+ return cookies
+```
+
+### 2.3 Cookie 序列化格式
+
+Cookie 可以用多种格式存储,常见的有 JSON 和 Netscape 格式:
+
+```python
+import json
+from http.cookiejar import MozillaCookieJar
+from datetime import datetime
+
+class CookieSerializer:
+ """Cookie 序列化工具"""
+
+ @staticmethod
+ def to_json(cookies: list, filepath: str):
+ """保存为 JSON 格式"""
+ with open(filepath, "w", encoding="utf-8") as f:
+ json.dump(cookies, f, indent=2, ensure_ascii=False)
+
+ @staticmethod
+ def from_json(filepath: str) -> list:
+ """从 JSON 加载"""
+ with open(filepath, "r", encoding="utf-8") as f:
+ return json.load(f)
+
+ @staticmethod
+ def to_netscape(cookies: list, filepath: str):
+ """保存为 Netscape 格式(兼容 curl/wget)"""
+ lines = ["# Netscape HTTP Cookie File"]
+ for c in cookies:
+ # 格式: domain, flag, path, secure, expiry, name, value
+ domain = c.get("domain", "")
+ flag = "TRUE" if domain.startswith(".") else "FALSE"
+ path = c.get("path", "/")
+ secure = "TRUE" if c.get("secure", False) else "FALSE"
+ expiry = str(int(c.get("expires", 0)))
+ name = c.get("name", "")
+ value = c.get("value", "")
+
+ lines.append(f"{domain}\t{flag}\t{path}\t{secure}\t{expiry}\t{name}\t{value}")
+
+ with open(filepath, "w") as f:
+ f.write("\n".join(lines))
+
+ @staticmethod
+ def to_dict(cookies: list) -> dict:
+ """转换为简单字典格式(name: value)"""
+ return {c["name"]: c["value"] for c in cookies}
+```
+
+### 2.4 Cookie 加密存储
+
+对于敏感的 Cookie,建议加密存储:
+
+```python
+import json
+import base64
+from cryptography.fernet import Fernet
+
+class SecureCookieStorage:
+ """加密 Cookie 存储"""
+
+ def __init__(self, key: bytes = None):
+ # 如果没有提供密钥,生成新密钥
+ self.key = key or Fernet.generate_key()
+ self.cipher = Fernet(self.key)
+
+ def save_key(self, filepath: str):
+ """保存密钥(请妥善保管)"""
+ with open(filepath, "wb") as f:
+ f.write(self.key)
+
+ @classmethod
+ def load_key(cls, filepath: str) -> "SecureCookieStorage":
+ """从文件加载密钥"""
+ with open(filepath, "rb") as f:
+ return cls(f.read())
+
+ def encrypt_cookies(self, cookies: list, filepath: str):
+ """加密并保存 Cookie"""
+ data = json.dumps(cookies).encode("utf-8")
+ encrypted = self.cipher.encrypt(data)
+ with open(filepath, "wb") as f:
+ f.write(encrypted)
+
+ def decrypt_cookies(self, filepath: str) -> list:
+ """解密并加载 Cookie"""
+ with open(filepath, "rb") as f:
+ encrypted = f.read()
+ decrypted = self.cipher.decrypt(encrypted)
+ return json.loads(decrypted.decode("utf-8"))
+```
+
+## 三、Cookie 注入与使用
+
+### 3.1 httpx 中使用 Cookie
+
+httpx 提供了灵活的 Cookie 管理方式:
+
+```python
+import httpx
+
+# 方式1: 直接传入字典
+cookies_dict = {"session": "abc123", "token": "xyz"}
+async with httpx.AsyncClient(cookies=cookies_dict) as client:
+ resp = await client.get("https://example.com/api")
+
+# 方式2: 使用 Cookies 对象
+from httpx import Cookies
+
+cookies = Cookies()
+cookies.set("session", "abc123", domain="example.com")
+cookies.set("token", "xyz", domain="example.com")
+
+async with httpx.AsyncClient(cookies=cookies) as client:
+ resp = await client.get("https://example.com/api")
+
+# 方式3: 从响应中自动获取
+async with httpx.AsyncClient() as client:
+ # 登录请求会设置 Cookie
+ login_resp = await client.post(
+ "https://example.com/login",
+ data={"username": "user", "password": "pass"}
+ )
+ # 后续请求自动携带 Cookie
+ profile_resp = await client.get("https://example.com/profile")
+```
+
+### 3.2 Playwright 中注入 Cookie
+
+Playwright 可以在访问页面前注入 Cookie:
+
+```python
+from playwright.async_api import async_playwright
+import json
+
+async def inject_cookies_and_browse():
+ """注入 Cookie 并访问页面"""
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ context = await browser.new_context()
+
+ # 从文件加载 Cookie
+ with open("cookies.json", "r") as f:
+ cookies = json.load(f)
+
+ # 注入 Cookie
+ await context.add_cookies(cookies)
+
+ # 现在可以直接访问需要登录的页面
+ page = await context.new_page()
+ await page.goto("https://example.com/dashboard")
+
+ # 检查是否登录成功
+ if await page.locator(".user-avatar").is_visible():
+ print("Cookie 注入成功,已登录")
+ else:
+ print("Cookie 可能已过期")
+
+ await browser.close()
+```
+
+### 3.3 跨工具共享 Cookie
+
+将 Playwright 提取的 Cookie 转换为 httpx 可用的格式:
+
+```python
+def playwright_cookies_to_httpx(playwright_cookies: list) -> dict:
+ """
+ 将 Playwright 格式的 Cookie 转换为 httpx 格式
+
+ Playwright 格式: [{"name": "x", "value": "y", "domain": "...", ...}, ...]
+ httpx 格式: {"name": "value", ...}
+ """
+ return {c["name"]: c["value"] for c in playwright_cookies}
+
+def httpx_cookies_to_playwright(cookies_dict: dict, domain: str) -> list:
+ """
+ 将简单字典格式转换为 Playwright 格式
+ """
+ return [
+ {
+ "name": name,
+ "value": value,
+ "domain": domain,
+ "path": "/"
+ }
+ for name, value in cookies_dict.items()
+ ]
+```
+
+### 3.4 多账号 Cookie 轮换
+
+在需要多账号爬取的场景,可以实现 Cookie 轮换:
+
+```python
+import asyncio
+import random
+from typing import Dict, List, Optional
+from dataclasses import dataclass, field
+from datetime import datetime
+
+@dataclass
+class AccountCookie:
+ """账号 Cookie 信息"""
+ account_id: str
+ cookies: dict
+ last_used: Optional[datetime] = None
+ use_count: int = 0
+ is_valid: bool = True
+
+class CookieRotator:
+ """Cookie 轮换器"""
+
+ def __init__(self, min_interval: float = 5.0):
+ self._accounts: Dict[str, AccountCookie] = {}
+ self._min_interval = min_interval # 同一账号最小使用间隔(秒)
+ self._lock = asyncio.Lock()
+
+ def add_account(self, account_id: str, cookies: dict):
+ """添加账号"""
+ self._accounts[account_id] = AccountCookie(
+ account_id=account_id,
+ cookies=cookies
+ )
+
+ async def get_cookies(self) -> Optional[dict]:
+ """获取一个可用的 Cookie"""
+ async with self._lock:
+ now = datetime.now()
+ available = []
+
+ for acc in self._accounts.values():
+ if not acc.is_valid:
+ continue
+
+ # 检查使用间隔
+ if acc.last_used:
+ elapsed = (now - acc.last_used).total_seconds()
+ if elapsed < self._min_interval:
+ continue
+
+ available.append(acc)
+
+ if not available:
+ return None
+
+ # 选择使用次数最少的账号(负载均衡)
+ selected = min(available, key=lambda x: x.use_count)
+ selected.last_used = now
+ selected.use_count += 1
+
+ return selected.cookies
+
+ def mark_invalid(self, cookies: dict):
+ """标记 Cookie 失效"""
+ for acc in self._accounts.values():
+ if acc.cookies == cookies:
+ acc.is_valid = False
+ break
+
+ @property
+ def valid_count(self) -> int:
+ """有效账号数量"""
+ return sum(1 for acc in self._accounts.values() if acc.is_valid)
+```
+
+## 四、登录状态管理
+
+### 4.1 登录状态检测
+
+检测 Cookie 是否仍然有效是关键步骤:
+
+```python
+import httpx
+from typing import Callable, Awaitable
+from loguru import logger
+
+class LoginStateChecker:
+ """登录状态检测器"""
+
+ def __init__(
+ self,
+ check_url: str,
+ success_indicator: Callable[[httpx.Response], bool]
+ ):
+ """
+ Args:
+ check_url: 用于检测登录状态的 URL
+ success_indicator: 判断是否登录成功的函数
+ """
+ self.check_url = check_url
+ self.success_indicator = success_indicator
+
+ async def is_logged_in(self, cookies: dict) -> bool:
+ """检查是否已登录"""
+ try:
+ async with httpx.AsyncClient(cookies=cookies) as client:
+ resp = await client.get(self.check_url, timeout=10)
+ return self.success_indicator(resp)
+ except Exception as e:
+ logger.warning(f"登录状态检测失败: {e}")
+ return False
+
+ @classmethod
+ def create_json_checker(cls, check_url: str, success_field: str) -> "LoginStateChecker":
+ """
+ 创建 JSON 响应检测器
+
+ Args:
+ check_url: API 地址
+ success_field: 成功时 JSON 中存在的字段
+ """
+ def indicator(resp: httpx.Response) -> bool:
+ try:
+ data = resp.json()
+ return success_field in data
+ except:
+ return False
+
+ return cls(check_url, indicator)
+
+ @classmethod
+ def create_redirect_checker(cls, check_url: str, login_url: str) -> "LoginStateChecker":
+ """
+ 创建重定向检测器
+
+ Args:
+ check_url: 需要登录的页面
+ login_url: 登录页 URL(被重定向到此说明未登录)
+ """
+ def indicator(resp: httpx.Response) -> bool:
+ # 如果没有被重定向到登录页,说明已登录
+ return login_url not in str(resp.url)
+
+ return cls(check_url, indicator)
+```
+
+### 4.2 Cookie 过期检测
+
+监控 Cookie 的过期时间:
+
+```python
+import time
+from datetime import datetime, timedelta
+from typing import List, Tuple
+
+class CookieExpiryMonitor:
+ """Cookie 过期监控"""
+
+ @staticmethod
+ def check_expiry(cookies: list) -> Tuple[bool, List[dict]]:
+ """
+ 检查 Cookie 是否过期
+
+ Returns:
+ (是否全部有效, 已过期的 Cookie 列表)
+ """
+ now = time.time()
+ expired = []
+
+ for cookie in cookies:
+ expires = cookie.get("expires", 0)
+ if expires > 0 and expires < now:
+ expired.append(cookie)
+
+ return len(expired) == 0, expired
+
+ @staticmethod
+ def get_earliest_expiry(cookies: list) -> datetime:
+ """获取最早过期的时间"""
+ expiry_times = [
+ c.get("expires", float("inf"))
+ for c in cookies
+ if c.get("expires", 0) > 0
+ ]
+
+ if not expiry_times:
+ return datetime.max
+
+ return datetime.fromtimestamp(min(expiry_times))
+
+ @staticmethod
+ def will_expire_soon(cookies: list, threshold_hours: int = 1) -> bool:
+ """检查 Cookie 是否即将过期"""
+ earliest = CookieExpiryMonitor.get_earliest_expiry(cookies)
+ return earliest < datetime.now() + timedelta(hours=threshold_hours)
+```
+
+### 4.3 完整的 Cookie 管理器
+
+整合以上功能的完整管理器:
+
+```python
+import json
+import asyncio
+from pathlib import Path
+from typing import Optional, Callable, Awaitable
+from datetime import datetime
+from loguru import logger
+
+class CookieManager:
+ """完整的 Cookie 管理器"""
+
+ def __init__(
+ self,
+ storage_path: str,
+ check_url: str,
+ login_checker: Callable[[dict], Awaitable[bool]],
+ auto_refresh_callback: Optional[Callable[[], Awaitable[dict]]] = None
+ ):
+ """
+ Args:
+ storage_path: Cookie 存储路径
+ check_url: 登录状态检测 URL
+ login_checker: 登录状态检测函数
+ auto_refresh_callback: 自动刷新回调(如重新登录)
+ """
+ self.storage_path = Path(storage_path)
+ self.check_url = check_url
+ self.login_checker = login_checker
+ self.auto_refresh_callback = auto_refresh_callback
+
+ self._cookies: Optional[list] = None
+ self._last_check: Optional[datetime] = None
+ self._check_interval = 300 # 5分钟检测一次
+
+ async def load(self) -> bool:
+ """加载 Cookie"""
+ if not self.storage_path.exists():
+ logger.warning(f"Cookie 文件不存在: {self.storage_path}")
+ return False
+
+ try:
+ with open(self.storage_path, "r") as f:
+ self._cookies = json.load(f)
+ logger.info(f"加载了 {len(self._cookies)} 个 Cookie")
+ return True
+ except Exception as e:
+ logger.error(f"加载 Cookie 失败: {e}")
+ return False
+
+ async def save(self):
+ """保存 Cookie"""
+ if self._cookies:
+ self.storage_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(self.storage_path, "w") as f:
+ json.dump(self._cookies, f, indent=2)
+ logger.info(f"保存了 {len(self._cookies)} 个 Cookie")
+
+ def update(self, cookies: list):
+ """更新 Cookie"""
+ self._cookies = cookies
+ self._last_check = datetime.now()
+
+ async def get_valid_cookies(self) -> Optional[dict]:
+ """获取有效的 Cookie(自动检测和刷新)"""
+ # 首次加载
+ if self._cookies is None:
+ await self.load()
+
+ if not self._cookies:
+ if self.auto_refresh_callback:
+ await self._refresh_cookies()
+ return None
+
+ # 检查是否需要验证
+ if self._need_check():
+ is_valid = await self._validate()
+ if not is_valid:
+ if self.auto_refresh_callback:
+ await self._refresh_cookies()
+ else:
+ return None
+
+ # 返回简单字典格式
+ return {c["name"]: c["value"] for c in self._cookies}
+
+ def _need_check(self) -> bool:
+ """是否需要检测"""
+ if self._last_check is None:
+ return True
+ elapsed = (datetime.now() - self._last_check).total_seconds()
+ return elapsed > self._check_interval
+
+ async def _validate(self) -> bool:
+ """验证 Cookie 是否有效"""
+ logger.debug("验证 Cookie 有效性...")
+ cookies_dict = {c["name"]: c["value"] for c in self._cookies}
+ is_valid = await self.login_checker(cookies_dict)
+ self._last_check = datetime.now()
+
+ if is_valid:
+ logger.info("Cookie 验证通过")
+ else:
+ logger.warning("Cookie 已失效")
+
+ return is_valid
+
+ async def _refresh_cookies(self):
+ """刷新 Cookie"""
+ if not self.auto_refresh_callback:
+ return
+
+ logger.info("开始刷新 Cookie...")
+ try:
+ new_cookies = await self.auto_refresh_callback()
+ if new_cookies:
+ self._cookies = new_cookies
+ await self.save()
+ logger.info("Cookie 刷新成功")
+ except Exception as e:
+ logger.error(f"Cookie 刷新失败: {e}")
+```
+
+## 五、B站 Cookie 管理实战
+
+### 5.1 B站 Cookie 结构分析
+
+B站使用多个关键 Cookie 来管理用户登录状态:
+
+```mermaid
+graph TB
+ subgraph B站核心Cookie
+ SESSDATA["SESSDATA
会话凭证"]
+ DedeUserID["DedeUserID
用户ID"]
+ bili_jct["bili_jct
CSRF Token"]
+ end
+
+ subgraph 辅助Cookie
+ buvid3["buvid3
设备标识"]
+ buvid4["buvid4
设备标识"]
+ sid["sid
短会话ID"]
+ end
+
+ SESSDATA --> API["API请求认证"]
+ DedeUserID --> API
+ bili_jct --> POST["POST请求CSRF"]
+
+ style SESSDATA fill:#e8f5e9,stroke:#4caf50
+ style DedeUserID fill:#e8f5e9,stroke:#4caf50
+ style bili_jct fill:#fff3e0,stroke:#ff9800
+```
+
+| Cookie 名称 | 作用 | 有效期 | 说明 |
+|------------|------|-------|------|
+| **SESSDATA** | 会话凭证 | ~1个月 | 最重要的登录凭证 |
+| **DedeUserID** | 用户ID | ~1个月 | 用于部分API请求 |
+| **bili_jct** | CSRF Token | ~1个月 | POST请求必需 |
+| buvid3 | 设备标识 | ~1年 | 追踪设备 |
+| buvid4 | 设备标识 | ~1年 | 追踪设备 |
+| sid | 短会话 | 会话 | 短期会话标识 |
+
+### 5.2 B站 Cookie 提取器
+
+```python
+# -*- coding: utf-8 -*-
+"""
+B站 Cookie 提取和管理
+"""
+
+import json
+import asyncio
+from pathlib import Path
+from typing import Optional, Dict, List
+from dataclasses import dataclass
+from datetime import datetime
+from loguru import logger
+
+
+@dataclass
+class BilibiliCookies:
+ """B站 Cookie 数据类"""
+ sessdata: str
+ dede_user_id: str
+ bili_jct: str
+ buvid3: str = ""
+ buvid4: str = ""
+ sid: str = ""
+ raw_cookies: List[dict] = None
+
+ @classmethod
+ def from_playwright_cookies(cls, cookies: List[dict]) -> "BilibiliCookies":
+ """从 Playwright 格式的 Cookie 创建"""
+ cookie_dict = {c["name"]: c["value"] for c in cookies}
+
+ return cls(
+ sessdata=cookie_dict.get("SESSDATA", ""),
+ dede_user_id=cookie_dict.get("DedeUserID", ""),
+ bili_jct=cookie_dict.get("bili_jct", ""),
+ buvid3=cookie_dict.get("buvid3", ""),
+ buvid4=cookie_dict.get("buvid4", ""),
+ sid=cookie_dict.get("sid", ""),
+ raw_cookies=cookies
+ )
+
+ @classmethod
+ def from_browser_string(cls, cookie_string: str) -> "BilibiliCookies":
+ """
+ 从浏览器复制的 Cookie 字符串创建
+
+ 格式: "SESSDATA=xxx; DedeUserID=xxx; bili_jct=xxx"
+ """
+ cookie_dict = {}
+ for item in cookie_string.split(";"):
+ item = item.strip()
+ if "=" in item:
+ key, value = item.split("=", 1)
+ cookie_dict[key.strip()] = value.strip()
+
+ return cls(
+ sessdata=cookie_dict.get("SESSDATA", ""),
+ dede_user_id=cookie_dict.get("DedeUserID", ""),
+ bili_jct=cookie_dict.get("bili_jct", ""),
+ buvid3=cookie_dict.get("buvid3", ""),
+ buvid4=cookie_dict.get("buvid4", ""),
+ sid=cookie_dict.get("sid", "")
+ )
+
+ def to_httpx_cookies(self) -> Dict[str, str]:
+ """转换为 httpx 可用的格式"""
+ cookies = {
+ "SESSDATA": self.sessdata,
+ "DedeUserID": self.dede_user_id,
+ "bili_jct": self.bili_jct,
+ }
+ if self.buvid3:
+ cookies["buvid3"] = self.buvid3
+ if self.buvid4:
+ cookies["buvid4"] = self.buvid4
+ if self.sid:
+ cookies["sid"] = self.sid
+ return cookies
+
+ def to_playwright_cookies(self, domain: str = ".bilibili.com") -> List[dict]:
+ """转换为 Playwright 可用的格式"""
+ if self.raw_cookies:
+ return self.raw_cookies
+
+ cookies = []
+ for name, value in self.to_httpx_cookies().items():
+ cookies.append({
+ "name": name,
+ "value": value,
+ "domain": domain,
+ "path": "/"
+ })
+ return cookies
+
+ def is_valid(self) -> bool:
+ """检查核心 Cookie 是否存在"""
+ return bool(self.sessdata and self.dede_user_id and self.bili_jct)
+
+ def to_header_string(self) -> str:
+ """转换为请求头格式"""
+ return "; ".join(f"{k}={v}" for k, v in self.to_httpx_cookies().items())
+
+
+class BilibiliCookieManager:
+ """
+ B站 Cookie 管理器
+
+ 功能:
+ - Cookie 加载/保存
+ - 登录状态检测
+ - Cookie 有效性验证
+ """
+
+ # 登录状态检测 API
+ CHECK_URL = "https://api.bilibili.com/x/web-interface/nav"
+
+ def __init__(self, storage_path: str = "bilibili_cookies.json"):
+ self.storage_path = Path(storage_path)
+ self._cookies: Optional[BilibiliCookies] = None
+ self._last_check: Optional[datetime] = None
+
+ async def load(self) -> bool:
+ """从文件加载 Cookie"""
+ if not self.storage_path.exists():
+ logger.warning(f"Cookie 文件不存在: {self.storage_path}")
+ return False
+
+ try:
+ with open(self.storage_path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+
+ if isinstance(data, list):
+ # Playwright 格式
+ self._cookies = BilibiliCookies.from_playwright_cookies(data)
+ elif isinstance(data, dict):
+ # 自定义格式
+ self._cookies = BilibiliCookies(
+ sessdata=data.get("SESSDATA", ""),
+ dede_user_id=data.get("DedeUserID", ""),
+ bili_jct=data.get("bili_jct", ""),
+ buvid3=data.get("buvid3", ""),
+ buvid4=data.get("buvid4", ""),
+ sid=data.get("sid", "")
+ )
+
+ logger.info(f"Cookie 加载成功,用户ID: {self._cookies.dede_user_id}")
+ return True
+
+ except Exception as e:
+ logger.error(f"加载 Cookie 失败: {e}")
+ return False
+
+ async def save(self, cookies: BilibiliCookies):
+ """保存 Cookie 到文件"""
+ self._cookies = cookies
+ self.storage_path.parent.mkdir(parents=True, exist_ok=True)
+
+ data = {
+ "SESSDATA": cookies.sessdata,
+ "DedeUserID": cookies.dede_user_id,
+ "bili_jct": cookies.bili_jct,
+ "buvid3": cookies.buvid3,
+ "buvid4": cookies.buvid4,
+ "sid": cookies.sid,
+ "save_time": datetime.now().isoformat()
+ }
+
+ with open(self.storage_path, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+
+ logger.info(f"Cookie 已保存到: {self.storage_path}")
+
+ async def verify(self) -> bool:
+ """验证 Cookie 是否有效"""
+ if not self._cookies or not self._cookies.is_valid():
+ return False
+
+ import httpx
+
+ try:
+ async with httpx.AsyncClient(
+ cookies=self._cookies.to_httpx_cookies(),
+ timeout=10
+ ) as client:
+ resp = await client.get(self.CHECK_URL)
+ data = resp.json()
+
+ if data.get("code") == 0:
+ user_info = data.get("data", {})
+ if user_info.get("isLogin"):
+ logger.info(f"Cookie 有效,用户: {user_info.get('uname')}")
+ self._last_check = datetime.now()
+ return True
+
+ logger.warning("Cookie 已失效")
+ return False
+
+ except Exception as e:
+ logger.error(f"验证 Cookie 失败: {e}")
+ return False
+
+ async def get_valid_cookies(self) -> Optional[BilibiliCookies]:
+ """获取有效的 Cookie"""
+ if self._cookies is None:
+ await self.load()
+
+ if self._cookies and await self.verify():
+ return self._cookies
+
+ return None
+
+ @property
+ def cookies(self) -> Optional[BilibiliCookies]:
+ """获取当前 Cookie(不验证)"""
+ return self._cookies
+```
+
+### 5.3 B站 Cookie 使用示例
+
+```python
+# -*- coding: utf-8 -*-
+"""
+B站 Cookie 使用示例
+"""
+
+import asyncio
+import httpx
+from loguru import logger
+
+
+async def demo_bilibili_cookie():
+ """演示如何使用 B站 Cookie"""
+
+ # 方式1: 从浏览器复制的字符串
+ cookie_string = "SESSDATA=xxx; DedeUserID=123456; bili_jct=xxx"
+ cookies = BilibiliCookies.from_browser_string(cookie_string)
+
+ # 方式2: 使用 Cookie 管理器
+ manager = BilibiliCookieManager("data/bilibili_cookies.json")
+ await manager.load()
+
+ if not await manager.verify():
+ logger.error("Cookie 无效,请重新登录")
+ return
+
+ cookies = manager.cookies
+
+ # 使用 Cookie 请求 API
+ async with httpx.AsyncClient(
+ cookies=cookies.to_httpx_cookies(),
+ headers={
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/131.0.0.0 Safari/537.36",
+ "Referer": "https://www.bilibili.com/"
+ }
+ ) as client:
+ # 获取用户信息
+ resp = await client.get("https://api.bilibili.com/x/web-interface/nav")
+ data = resp.json()
+
+ if data.get("code") == 0:
+ user = data.get("data", {})
+ print(f"用户名: {user.get('uname')}")
+ print(f"等级: {user.get('level_info', {}).get('current_level')}")
+ print(f"硬币: {user.get('money')}")
+
+ # 获取收藏夹(需要登录)
+ resp = await client.get(
+ "https://api.bilibili.com/x/v3/fav/folder/created/list-all",
+ params={"up_mid": cookies.dede_user_id}
+ )
+ fav_data = resp.json()
+
+ if fav_data.get("code") == 0:
+ folders = fav_data.get("data", {}).get("list", [])
+ print(f"\n收藏夹列表 ({len(folders)}个):")
+ for folder in folders[:5]:
+ print(f" - {folder.get('title')} ({folder.get('media_count')}个)")
+
+
+if __name__ == "__main__":
+ asyncio.run(demo_bilibili_cookie())
+```
+
+### 5.4 B站 Cookie 与 Playwright 集成
+
+```python
+# -*- coding: utf-8 -*-
+"""
+B站 Cookie 与 Playwright 集成
+"""
+
+import asyncio
+from playwright.async_api import async_playwright
+from loguru import logger
+
+
+async def bilibili_with_playwright():
+ """使用 Playwright 注入 B站 Cookie"""
+
+ # 加载 Cookie
+ manager = BilibiliCookieManager("data/bilibili_cookies.json")
+ await manager.load()
+
+ if not manager.cookies or not manager.cookies.is_valid():
+ logger.error("Cookie 无效")
+ return
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ context = await browser.new_context(
+ viewport={"width": 1920, "height": 1080},
+ locale="zh-CN"
+ )
+
+ # 注入 Cookie
+ await context.add_cookies(
+ manager.cookies.to_playwright_cookies()
+ )
+
+ page = await context.new_page()
+
+ # 访问个人主页
+ await page.goto("https://space.bilibili.com/", wait_until="networkidle")
+
+ # 检查是否登录成功
+ avatar = page.locator(".bili-avatar")
+ if await avatar.count() > 0:
+ logger.info("Cookie 注入成功,已登录")
+
+ # 获取用户名
+ username = await page.locator(".h-name").text_content()
+ print(f"当前用户: {username}")
+ else:
+ logger.warning("Cookie 可能已过期")
+
+ await browser.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(bilibili_with_playwright())
+```
+
+---
+
+## 六、完整的登录会话管理
+
+下面是一个完整的实战示例,展示如何在爬虫中管理登录状态:
+
+```python
+# session_crawler.py
+import asyncio
+import httpx
+from typing import Optional
+from loguru import logger
+
+from cookie_manager import CookieManager
+from login_state_checker import LoginStateChecker
+
+class SessionCrawler:
+ """带登录状态管理的爬虫"""
+
+ def __init__(self, base_url: str, cookie_path: str):
+ self.base_url = base_url
+ self.cookie_path = cookie_path
+
+ # 初始化登录检测器
+ self.checker = LoginStateChecker.create_json_checker(
+ check_url=f"{base_url}/api/user/info",
+ success_field="user_id"
+ )
+
+ # 初始化 Cookie 管理器
+ self.cookie_manager = CookieManager(
+ storage_path=cookie_path,
+ check_url=f"{base_url}/api/user/info",
+ login_checker=self.checker.is_logged_in
+ )
+
+ self._client: Optional[httpx.AsyncClient] = None
+
+ async def __aenter__(self):
+ await self.start()
+ return self
+
+ async def __aexit__(self, *args):
+ await self.close()
+
+ async def start(self):
+ """启动爬虫"""
+ cookies = await self.cookie_manager.get_valid_cookies()
+ if not cookies:
+ raise RuntimeError("无法获取有效的 Cookie,请先登录")
+
+ self._client = httpx.AsyncClient(
+ base_url=self.base_url,
+ cookies=cookies,
+ timeout=30
+ )
+ logger.info("爬虫启动成功")
+
+ async def close(self):
+ """关闭爬虫"""
+ if self._client:
+ await self._client.aclose()
+ await self.cookie_manager.save()
+ logger.info("爬虫已关闭")
+
+ async def fetch(self, endpoint: str) -> dict:
+ """获取数据"""
+ try:
+ resp = await self._client.get(endpoint)
+ resp.raise_for_status()
+ return resp.json()
+ except httpx.HTTPStatusError as e:
+ if e.response.status_code == 401:
+ logger.warning("登录状态失效,需要重新登录")
+ # 可以在这里触发重新登录逻辑
+ raise
+
+
+async def main():
+ """使用示例"""
+ async with SessionCrawler(
+ base_url="https://example.com",
+ cookie_path="data/cookies.json"
+ ) as crawler:
+ # 获取用户信息
+ user_info = await crawler.fetch("/api/user/info")
+ print(f"用户: {user_info.get('username')}")
+
+ # 获取数据列表
+ data_list = await crawler.fetch("/api/data/list")
+ print(f"获取了 {len(data_list)} 条数据")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+## 六、最佳实践
+
+### 6.1 Cookie 安全存储建议
+
+1. **不要硬编码 Cookie**:将 Cookie 存储在配置文件或环境变量中
+2. **敏感 Cookie 加密**:使用加密存储敏感的登录凭证
+3. **设置合适的文件权限**:Cookie 文件应限制访问权限
+4. **定期轮换**:定期重新登录获取新 Cookie
+
+### 6.2 登录状态管理建议
+
+1. **检测频率控制**:不要频繁检测,设置合理的检测间隔
+2. **优雅降级**:Cookie 失效时应有备用方案
+3. **多账号轮换**:大规模爬取时使用多账号
+4. **遵守频率限制**:避免触发反爬机制
+
+### 6.3 常见问题排查
+
+| 问题 | 可能原因 | 解决方案 |
+|-----|---------|---------|
+| Cookie 无效 | 已过期/被踢出 | 检查过期时间,重新登录 |
+| 请求被拒绝 | 缺少必要 Cookie | 检查 Cookie 完整性 |
+| 跨域问题 | Domain 不匹配 | 检查 Cookie 的 Domain 属性 |
+| HTTPS 问题 | Secure 属性 | 确保使用 HTTPS 协议 |
+
+## 本章小结
+
+本章深入讲解了 Cookie 和 Session 的工作机制,以及在爬虫中如何有效管理登录状态:
+
+1. **理解认证机制**:Cookie 属性、Session 原理、Token 认证
+2. **Cookie 提取存储**:手动提取、自动化提取、序列化格式、加密存储
+3. **Cookie 注入使用**:httpx 和 Playwright 中的使用方式、跨工具共享
+4. **登录状态管理**:状态检测、过期监控、自动刷新机制
+5. **B站实战**:Cookie 结构分析、专用管理器、验证与使用
+
+掌握这些技术后,你就能够处理大多数需要登录的爬虫场景。
+
+---
+
+## 与第11章实战项目的关联
+
+本章 Cookie 管理技术在第11章 B站综合实战项目中有核心应用:
+
+| 本章内容 | 第11章对应实现 | 文件位置 |
+|---------|--------------|---------|
+| BilibiliCookies 数据类 | Cookie 模型定义 | `models/cookies.py` |
+| BilibiliCookieManager | 登录管理器 | `login/auth.py` |
+| Cookie 验证逻辑 | 登录状态检测 | `login/auth.py` |
+| Playwright 集成 | 扫码登录实现 | `login/auth.py` |
+
+```mermaid
+graph LR
+ subgraph 本章知识点
+ A1["Cookie结构"]
+ A2["Cookie管理器"]
+ A3["登录验证"]
+ end
+
+ subgraph 第11章实战应用
+ B1["扫码登录"]
+ B2["登录态保持"]
+ B3["API请求认证"]
+ end
+
+ A1 --> B1
+ A2 --> B2
+ A3 --> B3
+
+ style A1 fill:#e3f2fd,stroke:#2196f3
+ style A2 fill:#e3f2fd,stroke:#2196f3
+ style A3 fill:#e3f2fd,stroke:#2196f3
+ style B1 fill:#c8e6c9,stroke:#4caf50
+ style B2 fill:#c8e6c9,stroke:#4caf50
+ style B3 fill:#c8e6c9,stroke:#4caf50
+```
+
+**学习建议**:
+
+1. 理解 B站三大核心 Cookie(SESSDATA、DedeUserID、bili_jct)的作用
+2. 掌握 Cookie 的提取、存储、验证完整流程
+3. 在第11章中,`login/auth.py` 是 Cookie 管理的核心实现
+
+## 下一章预告
+
+下一章我们将学习更复杂的登录方式——**扫码登录和短信验证码登录**的实现。我们会参考 MediaCrawler 的登录模块设计,学习如何处理二维码扫码流程、短信验证码接收等高级场景。
diff --git "a/docs/\347\210\254\350\231\253\350\277\233\344\273\267/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260.md" "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260.md"
new file mode 100644
index 0000000..ba042e8
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260.md"
@@ -0,0 +1,1313 @@
+# 07_登录认证:扫码与短信登录实现
+
+上一章我们学习了 Cookie 和 Session 的基础管理,本章将深入探讨更复杂的登录场景——扫码登录和短信验证码登录。这些登录方式在社交媒体、电商等平台中广泛使用,也是 MediaCrawler 等项目的核心技术之一。
+
+## 一、扫码登录原理
+
+### 1.1 扫码登录流程
+
+扫码登录是一种安全便捷的认证方式,其工作流程如下:
+
+```mermaid
+sequenceDiagram
+ participant PC as PC浏览器
+ participant Server as 服务器
+ participant APP as 手机APP
+
+ PC->>Server: 1. 请求二维码
+ Server-->>PC: 2. 返回二维码+UUID
+
+ loop 轮询登录状态
+ PC->>Server: 3. 查询状态
+ Server-->>PC: 状态: 待扫描
+ end
+
+ APP->>Server: 4. 用户扫码
+ Server-->>PC: 5. 状态: 已扫描
+
+ APP->>Server: 6. 用户确认
+ Server-->>PC: 7. 返回登录凭证(Cookie)
+
+ Note over PC: 8. 登录成功!
+```
+
+### 1.2 关键技术点
+
+1. **二维码生成**:服务器生成唯一 UUID,编码到二维码中
+2. **状态轮询**:PC 端定时查询登录状态(长轮询或 WebSocket)
+3. **状态流转**:待扫描 → 已扫描 → 已确认 → 已过期
+4. **凭证下发**:登录成功后通过 Cookie 或 Token 返回凭证
+
+### 1.3 常见扫码登录状态
+
+```python
+from enum import Enum
+
+class QRCodeStatus(Enum):
+ """二维码状态枚举"""
+ WAITING = "waiting" # 等待扫描
+ SCANNED = "scanned" # 已扫描,等待确认
+ CONFIRMED = "confirmed" # 已确认登录
+ EXPIRED = "expired" # 二维码已过期
+ CANCELED = "canceled" # 用户取消
+```
+
+## 二、Playwright 实现扫码登录
+
+### 2.1 基础框架
+
+```python
+import asyncio
+from playwright.async_api import async_playwright, Page, BrowserContext
+from typing import Optional, Callable, Awaitable
+from loguru import logger
+from enum import Enum
+
+class QRCodeLoginBase:
+ """扫码登录基础类"""
+
+ def __init__(
+ self,
+ timeout: int = 120,
+ poll_interval: float = 2.0,
+ on_status_change: Optional[Callable[[str], Awaitable[None]]] = None
+ ):
+ """
+ Args:
+ timeout: 登录超时时间(秒)
+ poll_interval: 状态轮询间隔(秒)
+ on_status_change: 状态变化回调
+ """
+ self.timeout = timeout
+ self.poll_interval = poll_interval
+ self.on_status_change = on_status_change
+
+ self._page: Optional[Page] = None
+ self._context: Optional[BrowserContext] = None
+ self._current_status: Optional[str] = None
+
+ async def _notify_status_change(self, status: str):
+ """通知状态变化"""
+ if status != self._current_status:
+ self._current_status = status
+ logger.info(f"登录状态变化: {status}")
+ if self.on_status_change:
+ await self.on_status_change(status)
+
+ async def get_qrcode_image(self) -> bytes:
+ """获取二维码图片,子类实现"""
+ raise NotImplementedError
+
+ async def check_login_status(self) -> str:
+ """检查登录状态,子类实现"""
+ raise NotImplementedError
+
+ async def extract_cookies(self) -> list:
+ """提取登录后的 Cookie"""
+ if self._context:
+ return await self._context.cookies()
+ return []
+```
+
+### 2.2 完整扫码登录实现
+
+以下是一个通用的扫码登录实现示例:
+
+```python
+import asyncio
+import base64
+from pathlib import Path
+from playwright.async_api import async_playwright, Page, BrowserContext
+from typing import Optional, Callable, Awaitable
+from loguru import logger
+
+class QRCodeLogin:
+ """通用扫码登录实现"""
+
+ def __init__(
+ self,
+ login_url: str,
+ qrcode_selector: str,
+ success_url_pattern: str,
+ timeout: int = 120,
+ poll_interval: float = 2.0
+ ):
+ """
+ Args:
+ login_url: 登录页面 URL
+ qrcode_selector: 二维码元素选择器
+ success_url_pattern: 登录成功后的 URL 特征
+ timeout: 超时时间
+ poll_interval: 轮询间隔
+ """
+ self.login_url = login_url
+ self.qrcode_selector = qrcode_selector
+ self.success_url_pattern = success_url_pattern
+ self.timeout = timeout
+ self.poll_interval = poll_interval
+
+ self._browser = None
+ self._context = None
+ self._page = None
+
+ async def start(self, playwright, headless: bool = False):
+ """启动浏览器"""
+ self._browser = await playwright.chromium.launch(headless=headless)
+ self._context = await self._browser.new_context()
+ self._page = await self._context.new_page()
+ logger.info("浏览器已启动")
+
+ async def close(self):
+ """关闭浏览器"""
+ if self._browser:
+ await self._browser.close()
+ logger.info("浏览器已关闭")
+
+ async def navigate_to_login(self):
+ """导航到登录页面"""
+ await self._page.goto(self.login_url, wait_until="networkidle")
+ logger.info(f"已打开登录页面: {self.login_url}")
+
+ async def save_qrcode(self, filepath: str = "qrcode.png"):
+ """保存二维码图片"""
+ # 等待二维码出现
+ await self._page.wait_for_selector(self.qrcode_selector, timeout=10000)
+
+ # 截取二维码
+ qrcode_element = self._page.locator(self.qrcode_selector)
+ await qrcode_element.screenshot(path=filepath)
+ logger.info(f"二维码已保存: {filepath}")
+ return filepath
+
+ async def wait_for_login(
+ self,
+ on_qrcode_ready: Optional[Callable[[str], Awaitable[None]]] = None
+ ) -> bool:
+ """
+ 等待用户扫码登录
+
+ Args:
+ on_qrcode_ready: 二维码准备好后的回调
+
+ Returns:
+ 是否登录成功
+ """
+ # 保存二维码
+ qrcode_path = await self.save_qrcode()
+
+ # 通知二维码已准备好
+ if on_qrcode_ready:
+ await on_qrcode_ready(qrcode_path)
+
+ # 等待登录成功(URL 变化或特定元素出现)
+ try:
+ await self._page.wait_for_url(
+ f"**{self.success_url_pattern}**",
+ timeout=self.timeout * 1000
+ )
+ logger.info("登录成功!")
+ return True
+ except Exception as e:
+ logger.warning(f"登录超时或失败: {e}")
+ return False
+
+ async def get_cookies(self) -> list:
+ """获取登录后的 Cookie"""
+ return await self._context.cookies()
+
+ async def login(
+ self,
+ on_qrcode_ready: Optional[Callable[[str], Awaitable[None]]] = None
+ ) -> Optional[list]:
+ """
+ 执行完整的扫码登录流程
+
+ Returns:
+ 成功返回 Cookie 列表,失败返回 None
+ """
+ await self.navigate_to_login()
+ success = await self.wait_for_login(on_qrcode_ready)
+
+ if success:
+ cookies = await self.get_cookies()
+ logger.info(f"获取到 {len(cookies)} 个 Cookie")
+ return cookies
+ return None
+```
+
+### 2.3 终端显示二维码
+
+为了在无头模式下也能扫码,可以在终端显示二维码:
+
+```python
+try:
+ import qrcode
+ HAS_QRCODE = True
+except ImportError:
+ HAS_QRCODE = False
+
+def display_qrcode_in_terminal(data: str):
+ """在终端显示二维码"""
+ if not HAS_QRCODE:
+ print("提示: 安装 qrcode 库可在终端显示二维码: pip install qrcode")
+ return
+
+ qr = qrcode.QRCode(
+ version=1,
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
+ box_size=1,
+ border=1
+ )
+ qr.add_data(data)
+ qr.make(fit=True)
+
+ # 使用字符绘制
+ qr.print_ascii(invert=True)
+
+def display_qrcode_image_in_terminal(image_path: str):
+ """
+ 将图片二维码转换为终端可显示的文本
+ 需要安装: pip install pillow
+ """
+ try:
+ from PIL import Image
+
+ img = Image.open(image_path)
+ img = img.convert('L') # 转为灰度
+
+ # 缩小图片
+ width = 60
+ ratio = width / img.width
+ height = int(img.height * ratio * 0.5) # 0.5 补偿终端字符高宽比
+ img = img.resize((width, height))
+
+ # 转换为 ASCII
+ chars = " .:-=+*#%@"
+ pixels = img.getdata()
+ ascii_img = ""
+ for i, pixel in enumerate(pixels):
+ if i > 0 and i % width == 0:
+ ascii_img += "\n"
+ ascii_img += chars[pixel * len(chars) // 256]
+
+ print(ascii_img)
+ except ImportError:
+ print(f"二维码已保存到: {image_path}")
+```
+
+## 三、短信验证码登录
+
+### 3.1 短信登录流程
+
+```mermaid
+sequenceDiagram
+ participant User as 用户
+ participant Frontend as 前端
+ participant Server as 服务器
+
+ User->>Frontend: 1. 输入手机号
+ Frontend->>Server: 2. 请求发送验证码
+ Server-->>Frontend: 3. 返回发送结果
+
+ User->>Frontend: 4. 输入验证码
+ Frontend->>Server: 5. 提交登录请求
+ Server-->>Frontend: 6. 返回登录结果+凭证
+
+ Note over User,Server: 登录成功,获取Cookie
+```
+
+### 3.2 Playwright 实现短信登录
+
+```python
+import asyncio
+from playwright.async_api import async_playwright, Page
+from typing import Optional, Callable, Awaitable
+from loguru import logger
+
+class SMSLogin:
+ """短信验证码登录"""
+
+ def __init__(
+ self,
+ login_url: str,
+ phone_input_selector: str,
+ send_code_btn_selector: str,
+ code_input_selector: str,
+ submit_btn_selector: str,
+ success_url_pattern: str
+ ):
+ """
+ Args:
+ login_url: 登录页 URL
+ phone_input_selector: 手机号输入框选择器
+ send_code_btn_selector: 发送验证码按钮选择器
+ code_input_selector: 验证码输入框选择器
+ submit_btn_selector: 登录按钮选择器
+ success_url_pattern: 登录成功 URL 特征
+ """
+ self.login_url = login_url
+ self.phone_input_selector = phone_input_selector
+ self.send_code_btn_selector = send_code_btn_selector
+ self.code_input_selector = code_input_selector
+ self.submit_btn_selector = submit_btn_selector
+ self.success_url_pattern = success_url_pattern
+
+ self._browser = None
+ self._context = None
+ self._page = None
+
+ async def start(self, playwright, headless: bool = False):
+ """启动浏览器"""
+ self._browser = await playwright.chromium.launch(headless=headless)
+ self._context = await self._browser.new_context()
+ self._page = await self._context.new_page()
+
+ async def close(self):
+ """关闭浏览器"""
+ if self._browser:
+ await self._browser.close()
+
+ async def input_phone(self, phone: str):
+ """输入手机号"""
+ await self._page.goto(self.login_url, wait_until="networkidle")
+ await self._page.fill(self.phone_input_selector, phone)
+ logger.info(f"已输入手机号: {phone[:3]}****{phone[-4:]}")
+
+ async def send_verification_code(self) -> bool:
+ """发送验证码"""
+ try:
+ await self._page.click(self.send_code_btn_selector)
+ logger.info("验证码发送请求已提交")
+ # 等待一小段时间确保请求发出
+ await asyncio.sleep(1)
+ return True
+ except Exception as e:
+ logger.error(f"发送验证码失败: {e}")
+ return False
+
+ async def input_code_and_login(
+ self,
+ code: str,
+ timeout: int = 30
+ ) -> bool:
+ """输入验证码并登录"""
+ try:
+ await self._page.fill(self.code_input_selector, code)
+ await self._page.click(self.submit_btn_selector)
+
+ # 等待登录成功
+ await self._page.wait_for_url(
+ f"**{self.success_url_pattern}**",
+ timeout=timeout * 1000
+ )
+ logger.info("登录成功!")
+ return True
+ except Exception as e:
+ logger.error(f"登录失败: {e}")
+ return False
+
+ async def get_cookies(self) -> list:
+ """获取 Cookie"""
+ return await self._context.cookies()
+
+ async def login_with_manual_code(
+ self,
+ phone: str,
+ get_code_callback: Callable[[], Awaitable[str]]
+ ) -> Optional[list]:
+ """
+ 使用手动输入验证码的方式登录
+
+ Args:
+ phone: 手机号
+ get_code_callback: 获取验证码的回调(如等待用户输入)
+
+ Returns:
+ 成功返回 Cookie,失败返回 None
+ """
+ await self.input_phone(phone)
+ await self.send_verification_code()
+
+ # 获取验证码(手动输入或从接码平台获取)
+ code = await get_code_callback()
+
+ if await self.input_code_and_login(code):
+ return await self.get_cookies()
+ return None
+```
+
+### 3.3 验证码获取方式
+
+```python
+import asyncio
+
+async def get_code_from_user() -> str:
+ """从控制台获取用户输入的验证码"""
+ print("\n请输入收到的验证码: ", end="", flush=True)
+ # 在异步环境中等待用户输入
+ loop = asyncio.get_event_loop()
+ code = await loop.run_in_executor(None, input)
+ return code.strip()
+
+
+class SMSCodeReceiver:
+ """
+ 短信接码平台接口(示意)
+
+ 注意:实际使用需要接入具体的接码平台 API
+ """
+
+ def __init__(self, api_key: str, api_url: str):
+ self.api_key = api_key
+ self.api_url = api_url
+
+ async def get_phone_number(self) -> str:
+ """获取手机号"""
+ # 调用接码平台 API 获取手机号
+ raise NotImplementedError("需要实现具体的接码平台接口")
+
+ async def wait_for_code(self, phone: str, timeout: int = 60) -> Optional[str]:
+ """等待接收验证码"""
+ # 轮询接码平台获取验证码
+ raise NotImplementedError("需要实现具体的接码平台接口")
+
+ async def release_phone(self, phone: str):
+ """释放手机号"""
+ raise NotImplementedError("需要实现具体的接码平台接口")
+```
+
+## 四、登录模块统一封装
+
+### 4.1 使用工厂模式
+
+```python
+from abc import ABC, abstractmethod
+from typing import Optional, Callable, Awaitable
+from enum import Enum
+
+class LoginMethod(Enum):
+ """登录方式枚举"""
+ QRCODE = "qrcode" # 扫码登录
+ SMS = "sms" # 短信验证码
+ PASSWORD = "password" # 账号密码
+ COOKIE = "cookie" # Cookie 注入
+
+class LoginResult:
+ """登录结果"""
+
+ def __init__(
+ self,
+ success: bool,
+ cookies: list = None,
+ error: str = None
+ ):
+ self.success = success
+ self.cookies = cookies or []
+ self.error = error
+
+class BaseLogin(ABC):
+ """登录基类"""
+
+ @abstractmethod
+ async def login(self) -> LoginResult:
+ """执行登录"""
+ pass
+
+ @abstractmethod
+ async def close(self):
+ """清理资源"""
+ pass
+
+class LoginFactory:
+ """登录工厂"""
+
+ _registry = {}
+
+ @classmethod
+ def register(cls, method: LoginMethod):
+ """注册登录实现"""
+ def decorator(login_class):
+ cls._registry[method] = login_class
+ return login_class
+ return decorator
+
+ @classmethod
+ def create(
+ cls,
+ method: LoginMethod,
+ **kwargs
+ ) -> BaseLogin:
+ """创建登录实例"""
+ if method not in cls._registry:
+ raise ValueError(f"不支持的登录方式: {method}")
+ return cls._registry[method](**kwargs)
+```
+
+### 4.2 注册具体实现
+
+```python
+@LoginFactory.register(LoginMethod.COOKIE)
+class CookieLogin(BaseLogin):
+ """Cookie 注入登录"""
+
+ def __init__(self, cookies: list, check_url: str = None):
+ self.cookies = cookies
+ self.check_url = check_url
+
+ async def login(self) -> LoginResult:
+ """Cookie 注入不需要实际登录,直接返回"""
+ return LoginResult(success=True, cookies=self.cookies)
+
+ async def close(self):
+ pass
+
+
+@LoginFactory.register(LoginMethod.QRCODE)
+class QRCodeLoginImpl(BaseLogin):
+ """扫码登录实现"""
+
+ def __init__(
+ self,
+ login_url: str,
+ qrcode_selector: str,
+ success_url_pattern: str,
+ on_qrcode_ready: Callable[[str], Awaitable[None]] = None,
+ **kwargs
+ ):
+ self.login_url = login_url
+ self.qrcode_selector = qrcode_selector
+ self.success_url_pattern = success_url_pattern
+ self.on_qrcode_ready = on_qrcode_ready
+ self._qrcode_login = None
+ self._playwright = None
+
+ async def login(self) -> LoginResult:
+ from playwright.async_api import async_playwright
+
+ self._playwright = await async_playwright().start()
+ self._qrcode_login = QRCodeLogin(
+ login_url=self.login_url,
+ qrcode_selector=self.qrcode_selector,
+ success_url_pattern=self.success_url_pattern
+ )
+
+ await self._qrcode_login.start(self._playwright, headless=False)
+
+ try:
+ cookies = await self._qrcode_login.login(self.on_qrcode_ready)
+ if cookies:
+ return LoginResult(success=True, cookies=cookies)
+ return LoginResult(success=False, error="登录超时")
+ except Exception as e:
+ return LoginResult(success=False, error=str(e))
+
+ async def close(self):
+ if self._qrcode_login:
+ await self._qrcode_login.close()
+ if self._playwright:
+ await self._playwright.stop()
+```
+
+### 4.3 统一登录管理器
+
+```python
+import json
+from pathlib import Path
+from loguru import logger
+
+class LoginManager:
+ """统一登录管理器"""
+
+ def __init__(
+ self,
+ platform: str,
+ cookie_path: str,
+ preferred_method: LoginMethod = LoginMethod.COOKIE
+ ):
+ """
+ Args:
+ platform: 平台名称
+ cookie_path: Cookie 存储路径
+ preferred_method: 首选登录方式
+ """
+ self.platform = platform
+ self.cookie_path = Path(cookie_path)
+ self.preferred_method = preferred_method
+ self._cookies: list = []
+
+ async def ensure_login(self, **login_kwargs) -> bool:
+ """
+ 确保已登录
+
+ 优先使用已保存的 Cookie,如果无效则使用指定方式登录
+ """
+ # 1. 尝试加载已保存的 Cookie
+ if await self._try_load_cookies():
+ logger.info(f"[{self.platform}] 使用已保存的 Cookie")
+ return True
+
+ # 2. 执行登录
+ logger.info(f"[{self.platform}] 开始 {self.preferred_method.value} 登录")
+ login = LoginFactory.create(self.preferred_method, **login_kwargs)
+
+ try:
+ result = await login.login()
+ if result.success:
+ self._cookies = result.cookies
+ await self._save_cookies()
+ logger.info(f"[{self.platform}] 登录成功")
+ return True
+ else:
+ logger.error(f"[{self.platform}] 登录失败: {result.error}")
+ return False
+ finally:
+ await login.close()
+
+ async def _try_load_cookies(self) -> bool:
+ """尝试加载 Cookie"""
+ if not self.cookie_path.exists():
+ return False
+
+ try:
+ with open(self.cookie_path, "r") as f:
+ self._cookies = json.load(f)
+
+ # TODO: 验证 Cookie 有效性
+ return len(self._cookies) > 0
+ except Exception as e:
+ logger.warning(f"加载 Cookie 失败: {e}")
+ return False
+
+ async def _save_cookies(self):
+ """保存 Cookie"""
+ self.cookie_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(self.cookie_path, "w") as f:
+ json.dump(self._cookies, f, indent=2)
+ logger.info(f"Cookie 已保存: {self.cookie_path}")
+
+ def get_cookies(self) -> list:
+ """获取 Cookie"""
+ return self._cookies
+
+ def get_cookies_dict(self) -> dict:
+ """获取字典格式的 Cookie"""
+ return {c["name"]: c["value"] for c in self._cookies}
+```
+
+## 五、实战示例
+
+### 5.1 完整的扫码登录示例
+
+```python
+import asyncio
+from playwright.async_api import async_playwright
+import json
+
+async def qrcode_login_demo():
+ """扫码登录完整示例"""
+
+ # 二维码准备好后的回调
+ async def on_qrcode_ready(path: str):
+ print(f"\n{'='*40}")
+ print(f"请使用手机扫描二维码: {path}")
+ print(f"{'='*40}\n")
+ # 可以在这里显示二维码到终端
+ display_qrcode_image_in_terminal(path)
+
+ async with async_playwright() as p:
+ # 创建扫码登录实例(以示例网站为例)
+ qr_login = QRCodeLogin(
+ login_url="https://example.com/login",
+ qrcode_selector="img.qrcode", # 二维码选择器
+ success_url_pattern="/dashboard",
+ timeout=120
+ )
+
+ await qr_login.start(p, headless=False)
+
+ try:
+ cookies = await qr_login.login(on_qrcode_ready)
+
+ if cookies:
+ # 保存 Cookie
+ with open("login_cookies.json", "w") as f:
+ json.dump(cookies, f, indent=2)
+ print(f"登录成功!获取到 {len(cookies)} 个 Cookie")
+ else:
+ print("登录失败或超时")
+ finally:
+ await qr_login.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(qrcode_login_demo())
+```
+
+### 5.2 多平台登录管理
+
+```python
+async def multi_platform_demo():
+ """多平台登录管理示例"""
+
+ # 配置多个平台
+ platforms = {
+ "platform_a": {
+ "cookie_path": "data/platform_a_cookies.json",
+ "login_url": "https://a.example.com/login",
+ "qrcode_selector": "#qrcode-img",
+ "success_url": "/home"
+ },
+ "platform_b": {
+ "cookie_path": "data/platform_b_cookies.json",
+ "login_url": "https://b.example.com/login",
+ "qrcode_selector": ".login-qrcode",
+ "success_url": "/dashboard"
+ }
+ }
+
+ for name, config in platforms.items():
+ print(f"\n处理平台: {name}")
+
+ manager = LoginManager(
+ platform=name,
+ cookie_path=config["cookie_path"],
+ preferred_method=LoginMethod.QRCODE
+ )
+
+ # 定义二维码回调
+ async def on_qrcode(path: str):
+ print(f"[{name}] 请扫描二维码: {path}")
+
+ success = await manager.ensure_login(
+ login_url=config["login_url"],
+ qrcode_selector=config["qrcode_selector"],
+ success_url_pattern=config["success_url"],
+ on_qrcode_ready=on_qrcode
+ )
+
+ if success:
+ cookies = manager.get_cookies_dict()
+ print(f"[{name}] 登录成功,Cookie 数量: {len(cookies)}")
+```
+
+## 六、最佳实践
+
+### 6.1 安全建议
+
+1. **不要频繁登录**:频繁登录可能触发风控
+2. **保护登录凭证**:Cookie 应加密存储
+3. **遵守服务条款**:了解平台的使用限制
+4. **处理敏感信息**:手机号等信息不要硬编码
+
+### 6.2 稳定性建议
+
+1. **超时处理**:设置合理的超时时间
+2. **重试机制**:登录失败时适当重试
+3. **状态监控**:监控登录状态,及时发现问题
+4. **优雅降级**:提供多种登录方式作为备选
+
+### 6.3 代码组织建议
+
+```
+login/
+├── __init__.py
+├── base.py # 基类定义
+├── factory.py # 登录工厂
+├── qrcode.py # 扫码登录实现
+├── sms.py # 短信登录实现
+├── cookie.py # Cookie 登录实现
+└── manager.py # 统一管理器
+```
+
+## 七、B站扫码登录实战
+
+本节以 B站 为实战平台,演示完整的扫码登录实现。B站 是国内最大的二次元视频平台,其登录系统具有代表性。
+
+### 7.1 B站扫码登录流程分析
+
+B站扫码登录涉及以下API:
+
+```mermaid
+sequenceDiagram
+ participant PC as PC浏览器
+ participant Server as B站服务器
+ participant APP as B站APP
+
+ PC->>Server: 1. GET /qrcode/generate
+ Server-->>PC: 2. 返回 qrcode_key + url
+
+ Note over PC: 生成二维码图片
+
+ loop 轮询登录状态 (间隔2秒)
+ PC->>Server: 3. GET /qrcode/poll?qrcode_key=xxx
+ Server-->>PC: 4. 返回状态码
+ Note right of Server: 86101=未扫描
86090=已扫描
86038=已过期
0=已确认
+ end
+
+ APP->>Server: 5. 用户扫码
+ Server-->>PC: 6. 状态: 86090 已扫描
+
+ APP->>Server: 7. 用户确认登录
+ Server-->>PC: 8. 状态: 0 + Set-Cookie
+
+ Note over PC: 9. 登录成功!保存Cookie
+```
+
+### 7.2 B站登录状态码
+
+| 状态码 | 含义 | 说明 |
+|-------|------|------|
+| 0 | 成功 | 登录成功,响应中包含Cookie |
+| 86101 | 未扫描 | 等待用户扫描二维码 |
+| 86090 | 已扫描 | 用户已扫描,等待确认 |
+| 86038 | 已过期 | 二维码已过期,需重新获取 |
+
+### 7.3 B站扫码登录完整实现
+
+```python
+import asyncio
+import httpx
+import qrcode
+from io import BytesIO
+from dataclasses import dataclass
+from typing import Optional, Callable, Awaitable
+from enum import IntEnum
+from loguru import logger
+
+
+class BilibiliQRStatus(IntEnum):
+ """B站扫码状态码"""
+ SUCCESS = 0 # 登录成功
+ NOT_SCANNED = 86101 # 未扫描
+ SCANNED = 86090 # 已扫描,待确认
+ EXPIRED = 86038 # 已过期
+
+
+@dataclass
+class BilibiliCookies:
+ """B站Cookie数据类"""
+ sessdata: str
+ dede_user_id: str
+ bili_jct: str
+ buvid3: str = ""
+ buvid4: str = ""
+ sid: str = ""
+
+ def to_dict(self) -> dict:
+ """转换为httpx可用的字典格式"""
+ return {
+ "SESSDATA": self.sessdata,
+ "DedeUserID": self.dede_user_id,
+ "bili_jct": self.bili_jct,
+ "buvid3": self.buvid3,
+ "buvid4": self.buvid4,
+ "sid": self.sid,
+ }
+
+
+class BilibiliQRCodeLogin:
+ """B站扫码登录实现"""
+
+ # B站登录相关API
+ QRCODE_GENERATE_URL = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate"
+ QRCODE_POLL_URL = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll"
+
+ def __init__(
+ self,
+ timeout: int = 180,
+ poll_interval: float = 2.0,
+ on_status_change: Optional[Callable[[int, str], Awaitable[None]]] = None
+ ):
+ """
+ Args:
+ timeout: 登录超时时间(秒)
+ poll_interval: 状态轮询间隔(秒)
+ on_status_change: 状态变化回调 (status_code, message)
+ """
+ self.timeout = timeout
+ self.poll_interval = poll_interval
+ self.on_status_change = on_status_change
+
+ self._client: Optional[httpx.AsyncClient] = None
+ self._qrcode_key: str = ""
+ self._current_status: int = -1
+
+ async def __aenter__(self):
+ """异步上下文管理器入口"""
+ self._client = httpx.AsyncClient(
+ headers={
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/120.0.0.0 Safari/537.36",
+ "Referer": "https://www.bilibili.com/",
+ }
+ )
+ return self
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
+ """异步上下文管理器出口"""
+ if self._client:
+ await self._client.aclose()
+
+ async def _notify_status(self, code: int, message: str):
+ """通知状态变化"""
+ if code != self._current_status:
+ self._current_status = code
+ logger.info(f"B站登录状态: {message} ({code})")
+ if self.on_status_change:
+ await self.on_status_change(code, message)
+
+ async def generate_qrcode(self) -> tuple[str, bytes]:
+ """
+ 生成登录二维码
+
+ Returns:
+ (qrcode_url, qrcode_image_bytes)
+ """
+ resp = await self._client.get(self.QRCODE_GENERATE_URL)
+ data = resp.json()
+
+ if data["code"] != 0:
+ raise Exception(f"获取二维码失败: {data['message']}")
+
+ self._qrcode_key = data["data"]["qrcode_key"]
+ qrcode_url = data["data"]["url"]
+
+ # 生成二维码图片
+ qr = qrcode.QRCode(
+ version=1,
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
+ box_size=10,
+ border=2
+ )
+ qr.add_data(qrcode_url)
+ qr.make(fit=True)
+
+ img = qr.make_image(fill_color="black", back_color="white")
+ buffer = BytesIO()
+ img.save(buffer, format="PNG")
+
+ logger.info("B站登录二维码已生成")
+ return qrcode_url, buffer.getvalue()
+
+ def print_qrcode_to_terminal(self, url: str):
+ """在终端打印二维码"""
+ qr = qrcode.QRCode(
+ version=1,
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
+ box_size=1,
+ border=1
+ )
+ qr.add_data(url)
+ qr.make(fit=True)
+ qr.print_ascii(invert=True)
+
+ async def poll_status(self) -> tuple[int, Optional[BilibiliCookies]]:
+ """
+ 轮询登录状态
+
+ Returns:
+ (status_code, cookies_if_success)
+ """
+ resp = await self._client.get(
+ self.QRCODE_POLL_URL,
+ params={"qrcode_key": self._qrcode_key}
+ )
+ data = resp.json()
+
+ code = data["data"]["code"]
+ message = data["data"]["message"]
+
+ await self._notify_status(code, message)
+
+ if code == BilibiliQRStatus.SUCCESS:
+ # 登录成功,从响应中提取Cookie
+ cookies = self._extract_cookies(resp)
+ return code, cookies
+
+ return code, None
+
+ def _extract_cookies(self, resp: httpx.Response) -> BilibiliCookies:
+ """从响应中提取B站Cookie"""
+ cookies = resp.cookies
+
+ # 同时从响应体获取refresh_token等信息
+ data = resp.json()["data"]
+
+ return BilibiliCookies(
+ sessdata=cookies.get("SESSDATA", ""),
+ dede_user_id=cookies.get("DedeUserID", ""),
+ bili_jct=cookies.get("bili_jct", ""),
+ buvid3=cookies.get("buvid3", ""),
+ buvid4=cookies.get("buvid4", ""),
+ sid=cookies.get("sid", ""),
+ )
+
+ async def login(
+ self,
+ save_qrcode_path: str = "bilibili_qrcode.png",
+ show_in_terminal: bool = True
+ ) -> Optional[BilibiliCookies]:
+ """
+ 执行完整的扫码登录流程
+
+ Args:
+ save_qrcode_path: 二维码图片保存路径
+ show_in_terminal: 是否在终端显示二维码
+
+ Returns:
+ 登录成功返回Cookie,失败返回None
+ """
+ # 1. 生成二维码
+ url, image_bytes = await self.generate_qrcode()
+
+ # 保存二维码图片
+ with open(save_qrcode_path, "wb") as f:
+ f.write(image_bytes)
+ logger.info(f"二维码已保存至: {save_qrcode_path}")
+
+ # 在终端显示
+ if show_in_terminal:
+ print("\n请使用B站APP扫描以下二维码登录:\n")
+ self.print_qrcode_to_terminal(url)
+ print(f"\n二维码图片也已保存至: {save_qrcode_path}\n")
+
+ # 2. 轮询登录状态
+ start_time = asyncio.get_event_loop().time()
+
+ while True:
+ elapsed = asyncio.get_event_loop().time() - start_time
+ if elapsed > self.timeout:
+ logger.warning("登录超时")
+ return None
+
+ code, cookies = await self.poll_status()
+
+ if code == BilibiliQRStatus.SUCCESS:
+ logger.info("B站登录成功!")
+ return cookies
+
+ if code == BilibiliQRStatus.EXPIRED:
+ logger.warning("二维码已过期")
+ return None
+
+ await asyncio.sleep(self.poll_interval)
+
+
+async def bilibili_qrcode_login_demo():
+ """B站扫码登录演示"""
+ import json
+ from pathlib import Path
+
+ # 状态变化回调
+ async def on_status(code: int, message: str):
+ status_emoji = {
+ BilibiliQRStatus.NOT_SCANNED: "⏳",
+ BilibiliQRStatus.SCANNED: "📱",
+ BilibiliQRStatus.SUCCESS: "✅",
+ BilibiliQRStatus.EXPIRED: "❌",
+ }
+ emoji = status_emoji.get(code, "❓")
+ print(f"{emoji} {message}")
+
+ async with BilibiliQRCodeLogin(
+ timeout=180,
+ poll_interval=2.0,
+ on_status_change=on_status
+ ) as login:
+ cookies = await login.login(
+ save_qrcode_path="bilibili_qrcode.png",
+ show_in_terminal=True
+ )
+
+ if cookies:
+ # 保存Cookie
+ cookie_path = Path("data/bilibili_cookies.json")
+ cookie_path.parent.mkdir(parents=True, exist_ok=True)
+
+ with open(cookie_path, "w") as f:
+ json.dump(cookies.to_dict(), f, indent=2, ensure_ascii=False)
+
+ print(f"\n登录成功!Cookie已保存至: {cookie_path}")
+ print(f"SESSDATA: {cookies.sessdata[:20]}...")
+ print(f"DedeUserID: {cookies.dede_user_id}")
+ else:
+ print("\n登录失败或超时")
+
+
+if __name__ == "__main__":
+ asyncio.run(bilibili_qrcode_login_demo())
+```
+
+### 7.4 使用已登录Cookie访问B站API
+
+```python
+import httpx
+import json
+from pathlib import Path
+
+
+async def use_bilibili_cookies():
+ """使用已保存的Cookie访问B站API"""
+
+ # 1. 加载Cookie
+ cookie_path = Path("data/bilibili_cookies.json")
+ if not cookie_path.exists():
+ print("请先执行扫码登录获取Cookie")
+ return
+
+ with open(cookie_path) as f:
+ cookies = json.load(f)
+
+ # 2. 创建带Cookie的客户端
+ async with httpx.AsyncClient(
+ cookies=cookies,
+ headers={
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/120.0.0.0 Safari/537.36",
+ "Referer": "https://www.bilibili.com/",
+ }
+ ) as client:
+ # 3. 获取用户信息
+ resp = await client.get(
+ "https://api.bilibili.com/x/web-interface/nav"
+ )
+ data = resp.json()
+
+ if data["code"] == 0:
+ user_info = data["data"]
+ print(f"登录用户: {user_info['uname']}")
+ print(f"用户ID: {user_info['mid']}")
+ print(f"等级: LV{user_info['level_info']['current_level']}")
+ print(f"硬币: {user_info['money']}")
+ else:
+ print(f"获取用户信息失败: {data['message']}")
+
+
+if __name__ == "__main__":
+ import asyncio
+ asyncio.run(use_bilibili_cookies())
+```
+
+### 7.5 Cookie有效性验证
+
+```python
+import httpx
+from typing import Optional
+
+
+async def verify_bilibili_cookies(cookies: dict) -> Optional[dict]:
+ """
+ 验证B站Cookie是否有效
+
+ Args:
+ cookies: Cookie字典
+
+ Returns:
+ 有效返回用户信息,无效返回None
+ """
+ async with httpx.AsyncClient(
+ cookies=cookies,
+ headers={
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
+ "Referer": "https://www.bilibili.com/",
+ }
+ ) as client:
+ resp = await client.get(
+ "https://api.bilibili.com/x/web-interface/nav"
+ )
+ data = resp.json()
+
+ if data["code"] == 0 and data["data"]["isLogin"]:
+ return {
+ "mid": data["data"]["mid"],
+ "uname": data["data"]["uname"],
+ "level": data["data"]["level_info"]["current_level"],
+ "vip_type": data["data"]["vipType"],
+ }
+
+ return None
+
+
+async def refresh_or_relogin():
+ """检查Cookie,过期则重新登录"""
+ import json
+ from pathlib import Path
+
+ cookie_path = Path("data/bilibili_cookies.json")
+
+ # 1. 尝试加载并验证现有Cookie
+ if cookie_path.exists():
+ with open(cookie_path) as f:
+ cookies = json.load(f)
+
+ user_info = await verify_bilibili_cookies(cookies)
+ if user_info:
+ print(f"Cookie有效,当前用户: {user_info['uname']}")
+ return cookies
+
+ print("Cookie已失效,需要重新登录")
+
+ # 2. 执行扫码登录
+ async with BilibiliQRCodeLogin() as login:
+ result = await login.login()
+ if result:
+ with open(cookie_path, "w") as f:
+ json.dump(result.to_dict(), f, indent=2)
+ print("重新登录成功")
+ return result.to_dict()
+
+ return None
+```
+
+---
+
+## 八、与第11章的关联
+
+本章介绍的B站扫码登录技术在第11章综合实战项目中有完整应用:
+
+### 代码位置
+- **登录模块**:`源代码/爬虫进阶/11_进阶综合实战项目/login/auth.py`
+- **Cookie管理**:`源代码/爬虫进阶/11_进阶综合实战项目/client/bilibili_client.py`
+
+### 技术要点对应
+| 本章内容 | 第11章实现 |
+|---------|-----------|
+| `BilibiliQRCodeLogin` 类 | `login/auth.py` 中的登录逻辑 |
+| `BilibiliCookies` 数据类 | `models/` 中的数据模型 |
+| Cookie有效性验证 | `client/` 中的请求拦截器 |
+| 状态码枚举 | `config/bilibili_config.py` 中的常量定义 |
+
+### 学习路径
+
+```mermaid
+graph LR
+ A[第06章
Cookie管理] --> B[第07章
扫码登录]
+ B --> C[第08章
验证码处理]
+ C --> D[第11章
综合实战]
+
+ style B fill:#e1f5fe
+ style D fill:#fff9c4
+```
+
+掌握本章的扫码登录技术后,你已经具备了B站爬虫的核心认证能力,可以直接应用到第11章的综合项目中。
+
+---
+
+## 本章小结
+
+本章深入讲解了扫码登录和短信验证码登录的实现:
+
+1. **扫码登录原理**:二维码生成、状态轮询、凭证下发的完整流程
+2. **Playwright 实现**:使用浏览器自动化实现扫码登录
+3. **短信登录实现**:手机号输入、验证码获取、登录提交
+4. **统一封装**:使用工厂模式封装多种登录方式
+5. **B站实战**:完整的B站扫码登录实现,包括二维码生成、状态轮询、Cookie提取
+
+掌握这些技术后,你可以应对大多数需要登录的爬虫场景。
+
+## 下一章预告
+
+下一章我们将学习**验证码识别与处理**,包括图片验证码 OCR 识别、滑块验证码轨迹模拟等技术。这些技术在登录和爬取过程中经常遇到,是爬虫进阶的重要内容。
diff --git "a/docs/\347\210\254\350\231\253\350\277\233\344\273\267/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206.md" "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206.md"
new file mode 100644
index 0000000..99685d5
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206.md"
@@ -0,0 +1,1180 @@
+# 08_验证码识别与处理
+
+验证码是网站用于区分人类和机器的重要手段。本章将介绍常见的验证码类型,以及如何在爬虫中处理这些验证码。
+
+## 一、验证码类型概览
+
+### 1.1 常见验证码类型
+
+| 类型 | 特点 | 难度 | 常见场景 |
+|-----|------|------|---------|
+| 图片字符验证码 | 数字/字母/汉字 | ⭐⭐ | 登录、注册 |
+| 数学运算验证码 | 简单算术题 | ⭐ | 评论、提交 |
+| 滑块拼图验证码 | 拖动滑块到缺口 | ⭐⭐⭐ | 登录、支付 |
+| 点选验证码 | 点击指定图片/文字 | ⭐⭐⭐⭐ | 登录、敏感操作 |
+| 行为验证码 | 分析用户行为 | ⭐⭐⭐⭐⭐ | 全站防护 |
+
+### 1.2 验证码的工作原理
+
+```mermaid
+sequenceDiagram
+ participant User as 用户
+ participant Frontend as 前端
+ participant Server as 服务器
+
+ User->>Frontend: 1. 请求验证码
+ Frontend->>Server: 2. 请求验证码
+ Server-->>Frontend: 3. 返回验证码图片+ID
+ Frontend-->>User: 4. 显示验证码
+
+ User->>Frontend: 5. 输入验证码
+ Frontend->>Server: 6. 提交验证
+ Server-->>Frontend: 7. 返回验证结果
+
+ Note over User,Server: 验证通过后继续操作
+```
+
+## 二、图片验证码识别
+
+### 2.1 ddddocr 库介绍
+
+ddddocr 是一个开源的 OCR 库,专门用于识别验证码:
+
+```bash
+pip install ddddocr
+```
+
+### 2.2 基本使用
+
+```python
+import ddddocr
+
+# 创建 OCR 实例
+ocr = ddddocr.DdddOcr()
+
+# 读取验证码图片
+with open("captcha.png", "rb") as f:
+ image_bytes = f.read()
+
+# 识别
+result = ocr.classification(image_bytes)
+print(f"识别结果: {result}")
+```
+
+### 2.3 图片预处理
+
+对于复杂验证码,预处理可以提高识别率:
+
+```python
+from PIL import Image
+import io
+
+class CaptchaPreprocessor:
+ """验证码图片预处理"""
+
+ @staticmethod
+ def to_grayscale(image_bytes: bytes) -> bytes:
+ """转为灰度图"""
+ img = Image.open(io.BytesIO(image_bytes))
+ gray = img.convert('L')
+
+ buffer = io.BytesIO()
+ gray.save(buffer, format='PNG')
+ return buffer.getvalue()
+
+ @staticmethod
+ def binarize(image_bytes: bytes, threshold: int = 127) -> bytes:
+ """
+ 二值化
+
+ Args:
+ image_bytes: 图片字节
+ threshold: 阈值,小于阈值变黑,大于变白
+ """
+ img = Image.open(io.BytesIO(image_bytes))
+ gray = img.convert('L')
+
+ # 二值化
+ binary = gray.point(lambda x: 255 if x > threshold else 0)
+
+ buffer = io.BytesIO()
+ binary.save(buffer, format='PNG')
+ return buffer.getvalue()
+
+ @staticmethod
+ def remove_noise(image_bytes: bytes) -> bytes:
+ """
+ 去噪点(简单的中值滤波)
+
+ 需要安装: pip install pillow
+ """
+ from PIL import ImageFilter
+
+ img = Image.open(io.BytesIO(image_bytes))
+ # 中值滤波去噪
+ denoised = img.filter(ImageFilter.MedianFilter(size=3))
+
+ buffer = io.BytesIO()
+ denoised.save(buffer, format='PNG')
+ return buffer.getvalue()
+
+ @staticmethod
+ def enhance_contrast(image_bytes: bytes, factor: float = 2.0) -> bytes:
+ """
+ 增强对比度
+
+ Args:
+ factor: 对比度因子,>1 增强,<1 降低
+ """
+ from PIL import ImageEnhance
+
+ img = Image.open(io.BytesIO(image_bytes))
+ enhancer = ImageEnhance.Contrast(img)
+ enhanced = enhancer.enhance(factor)
+
+ buffer = io.BytesIO()
+ enhanced.save(buffer, format='PNG')
+ return buffer.getvalue()
+```
+
+### 2.4 封装的 OCR 识别器
+
+```python
+import ddddocr
+from typing import Optional
+from loguru import logger
+
+class OCRCaptchaSolver:
+ """OCR 验证码识别器"""
+
+ def __init__(self, preprocess: bool = True):
+ """
+ Args:
+ preprocess: 是否进行预处理
+ """
+ self.ocr = ddddocr.DdddOcr(show_ad=False)
+ self.preprocess = preprocess
+ self.preprocessor = CaptchaPreprocessor()
+
+ def solve(self, image_bytes: bytes) -> Optional[str]:
+ """
+ 识别验证码
+
+ Args:
+ image_bytes: 图片字节
+
+ Returns:
+ 识别结果
+ """
+ try:
+ # 预处理
+ if self.preprocess:
+ image_bytes = self._preprocess(image_bytes)
+
+ # 识别
+ result = self.ocr.classification(image_bytes)
+ logger.debug(f"验证码识别结果: {result}")
+ return result
+
+ except Exception as e:
+ logger.error(f"验证码识别失败: {e}")
+ return None
+
+ def _preprocess(self, image_bytes: bytes) -> bytes:
+ """预处理流程"""
+ # 灰度化
+ image_bytes = self.preprocessor.to_grayscale(image_bytes)
+ # 增强对比度
+ image_bytes = self.preprocessor.enhance_contrast(image_bytes)
+ # 二值化
+ image_bytes = self.preprocessor.binarize(image_bytes, threshold=150)
+ return image_bytes
+
+ def solve_with_retry(
+ self,
+ image_bytes: bytes,
+ max_retries: int = 3
+ ) -> Optional[str]:
+ """
+ 带重试的识别
+
+ 尝试不同的预处理参数
+ """
+ # 尝试不同阈值
+ thresholds = [127, 100, 150]
+
+ for threshold in thresholds:
+ try:
+ processed = self.preprocessor.to_grayscale(image_bytes)
+ processed = self.preprocessor.binarize(processed, threshold)
+ result = self.ocr.classification(processed)
+
+ if result and len(result) >= 4: # 假设验证码至少 4 位
+ return result
+
+ except Exception:
+ continue
+
+ # 最后尝试原图
+ return self.ocr.classification(image_bytes)
+```
+
+## 三、滑块验证码处理
+
+滑块验证码需要识别缺口位置并模拟人类拖拽行为。
+
+### 3.1 缺口位置识别
+
+```python
+import cv2
+import numpy as np
+from typing import Tuple, Optional
+from loguru import logger
+
+class SliderGapDetector:
+ """滑块缺口位置检测器"""
+
+ @staticmethod
+ def detect_gap_by_edge(
+ background_bytes: bytes,
+ slider_bytes: bytes
+ ) -> Optional[int]:
+ """
+ 通过边缘检测找缺口位置
+
+ Args:
+ background_bytes: 背景图片字节
+ slider_bytes: 滑块图片字节
+
+ Returns:
+ 缺口 x 坐标
+ """
+ # 读取图片
+ bg = cv2.imdecode(
+ np.frombuffer(background_bytes, np.uint8),
+ cv2.IMREAD_COLOR
+ )
+ slider = cv2.imdecode(
+ np.frombuffer(slider_bytes, np.uint8),
+ cv2.IMREAD_COLOR
+ )
+
+ # 转灰度
+ bg_gray = cv2.cvtColor(bg, cv2.COLOR_BGR2GRAY)
+ slider_gray = cv2.cvtColor(slider, cv2.COLOR_BGR2GRAY)
+
+ # 边缘检测
+ bg_edges = cv2.Canny(bg_gray, 100, 200)
+ slider_edges = cv2.Canny(slider_gray, 100, 200)
+
+ # 模板匹配
+ result = cv2.matchTemplate(bg_edges, slider_edges, cv2.TM_CCOEFF_NORMED)
+ _, _, _, max_loc = cv2.minMaxLoc(result)
+
+ gap_x = max_loc[0]
+ logger.debug(f"检测到缺口位置: x={gap_x}")
+ return gap_x
+
+ @staticmethod
+ def detect_gap_by_color(
+ background_bytes: bytes,
+ target_color: Tuple[int, int, int] = (0, 0, 0)
+ ) -> Optional[int]:
+ """
+ 通过颜色差异找缺口位置
+
+ 某些滑块验证码的缺口有特定颜色
+
+ Args:
+ background_bytes: 背景图片
+ target_color: 缺口颜色 (B, G, R)
+
+ Returns:
+ 缺口 x 坐标
+ """
+ bg = cv2.imdecode(
+ np.frombuffer(background_bytes, np.uint8),
+ cv2.IMREAD_COLOR
+ )
+
+ # 转 HSV
+ hsv = cv2.cvtColor(bg, cv2.COLOR_BGR2HSV)
+
+ # 创建颜色掩码
+ lower = np.array([0, 0, 0])
+ upper = np.array([180, 255, 50]) # 暗色区域
+ mask = cv2.inRange(hsv, lower, upper)
+
+ # 找轮廓
+ contours, _ = cv2.findContours(
+ mask,
+ cv2.RETR_EXTERNAL,
+ cv2.CHAIN_APPROX_SIMPLE
+ )
+
+ if contours:
+ # 找最大轮廓
+ largest = max(contours, key=cv2.contourArea)
+ x, y, w, h = cv2.boundingRect(largest)
+ return x
+
+ return None
+```
+
+### 3.2 人类轨迹模拟
+
+模拟人类拖拽行为是绕过行为检测的关键:
+
+```python
+import random
+import math
+from typing import List, Tuple
+
+class HumanTrajectoryGenerator:
+ """人类轨迹生成器"""
+
+ @staticmethod
+ def generate_trajectory(
+ distance: int,
+ duration: float = 0.5
+ ) -> List[Tuple[int, int, float]]:
+ """
+ 生成模拟人类的拖拽轨迹
+
+ Args:
+ distance: 需要移动的距离
+ duration: 预期持续时间(秒)
+
+ Returns:
+ 轨迹点列表 [(x, y, time), ...]
+ """
+ trajectory = []
+ current_x = 0
+ current_time = 0
+
+ # 使用缓动函数模拟加速-匀速-减速
+ steps = random.randint(20, 30)
+ step_time = duration / steps
+
+ for i in range(steps):
+ # 使用 ease-out 缓动
+ progress = i / steps
+ eased = HumanTrajectoryGenerator._ease_out_quad(progress)
+
+ target_x = int(distance * eased)
+ move_x = target_x - current_x
+
+ # 添加随机偏移
+ offset_y = random.randint(-3, 3)
+
+ current_x = target_x
+ current_time += step_time + random.uniform(-0.01, 0.01)
+
+ trajectory.append((current_x, offset_y, current_time))
+
+ # 确保最后到达目标
+ trajectory.append((distance, 0, duration))
+
+ return trajectory
+
+ @staticmethod
+ def _ease_out_quad(t: float) -> float:
+ """二次缓出函数"""
+ return t * (2 - t)
+
+ @staticmethod
+ def _ease_out_cubic(t: float) -> float:
+ """三次缓出函数"""
+ return 1 - pow(1 - t, 3)
+
+ @staticmethod
+ def generate_bezier_trajectory(
+ distance: int,
+ duration: float = 0.5
+ ) -> List[Tuple[int, int, float]]:
+ """
+ 使用贝塞尔曲线生成更自然的轨迹
+
+ Args:
+ distance: 移动距离
+ duration: 持续时间
+
+ Returns:
+ 轨迹点列表
+ """
+ trajectory = []
+
+ # 控制点
+ p0 = (0, 0)
+ p1 = (distance * 0.3, random.randint(-10, 10))
+ p2 = (distance * 0.7, random.randint(-5, 5))
+ p3 = (distance, 0)
+
+ steps = random.randint(25, 35)
+
+ for i in range(steps + 1):
+ t = i / steps
+
+ # 三阶贝塞尔曲线
+ x = (1-t)**3 * p0[0] + 3*(1-t)**2*t * p1[0] + 3*(1-t)*t**2 * p2[0] + t**3 * p3[0]
+ y = (1-t)**3 * p0[1] + 3*(1-t)**2*t * p1[1] + 3*(1-t)*t**2 * p2[1] + t**3 * p3[1]
+
+ time_point = duration * t + random.uniform(-0.005, 0.005)
+ trajectory.append((int(x), int(y), max(0, time_point)))
+
+ return trajectory
+```
+
+### 3.3 Playwright 实现滑块拖拽
+
+```python
+import asyncio
+from playwright.async_api import Page
+from typing import List, Tuple
+from loguru import logger
+
+class SliderCaptchaSolver:
+ """滑块验证码解决器"""
+
+ def __init__(self, page: Page):
+ self.page = page
+ self.gap_detector = SliderGapDetector()
+ self.trajectory_generator = HumanTrajectoryGenerator()
+
+ async def solve(
+ self,
+ slider_selector: str,
+ background_selector: str,
+ slider_image_selector: str
+ ) -> bool:
+ """
+ 解决滑块验证码
+
+ Args:
+ slider_selector: 滑块按钮选择器
+ background_selector: 背景图选择器
+ slider_image_selector: 滑块图片选择器
+
+ Returns:
+ 是否成功
+ """
+ try:
+ # 获取背景和滑块图片
+ bg_element = self.page.locator(background_selector)
+ slider_element = self.page.locator(slider_image_selector)
+
+ bg_bytes = await bg_element.screenshot()
+ slider_bytes = await slider_element.screenshot()
+
+ # 检测缺口位置
+ gap_x = self.gap_detector.detect_gap_by_edge(bg_bytes, slider_bytes)
+ if not gap_x:
+ logger.error("无法检测缺口位置")
+ return False
+
+ logger.info(f"缺口位置: {gap_x}")
+
+ # 执行拖拽
+ await self._drag_slider(slider_selector, gap_x)
+
+ # 等待验证结果
+ await asyncio.sleep(1)
+
+ return True
+
+ except Exception as e:
+ logger.error(f"滑块验证码处理失败: {e}")
+ return False
+
+ async def _drag_slider(self, selector: str, distance: int):
+ """
+ 执行拖拽操作
+
+ Args:
+ selector: 滑块选择器
+ distance: 拖拽距离
+ """
+ slider = self.page.locator(selector)
+ box = await slider.bounding_box()
+
+ if not box:
+ raise Exception("无法获取滑块位置")
+
+ # 起始位置(滑块中心)
+ start_x = box['x'] + box['width'] / 2
+ start_y = box['y'] + box['height'] / 2
+
+ # 生成轨迹
+ trajectory = self.trajectory_generator.generate_bezier_trajectory(distance)
+
+ # 鼠标移动到滑块
+ await self.page.mouse.move(start_x, start_y)
+ await asyncio.sleep(random.uniform(0.1, 0.2))
+
+ # 按下鼠标
+ await self.page.mouse.down()
+ await asyncio.sleep(random.uniform(0.05, 0.1))
+
+ # 沿轨迹移动
+ last_time = 0
+ for x, y, time_point in trajectory:
+ # 计算时间间隔
+ delay = time_point - last_time
+ if delay > 0:
+ await asyncio.sleep(delay)
+ last_time = time_point
+
+ # 移动鼠标
+ await self.page.mouse.move(start_x + x, start_y + y)
+
+ # 松开鼠标
+ await asyncio.sleep(random.uniform(0.05, 0.1))
+ await self.page.mouse.up()
+
+ logger.info(f"滑块拖拽完成,距离: {distance}px")
+
+
+# 需要导入 random
+import random
+```
+
+## 四、第三方打码平台
+
+对于复杂验证码,可以使用第三方打码平台。
+
+### 4.1 打码平台接口封装
+
+```python
+import httpx
+import asyncio
+from abc import ABC, abstractmethod
+from typing import Optional
+from loguru import logger
+
+class CaptchaServiceBase(ABC):
+ """打码平台基类"""
+
+ @abstractmethod
+ async def solve_image(self, image_bytes: bytes) -> Optional[str]:
+ """识别图片验证码"""
+ pass
+
+ @abstractmethod
+ async def report_error(self, task_id: str):
+ """报告识别错误(退款)"""
+ pass
+
+ @abstractmethod
+ async def get_balance(self) -> float:
+ """获取账户余额"""
+ pass
+
+
+class GenericCaptchaService(CaptchaServiceBase):
+ """
+ 通用打码平台接口
+
+ 注意:这是一个示例实现,实际使用需要根据具体平台的 API 调整
+ """
+
+ def __init__(
+ self,
+ api_key: str,
+ api_url: str,
+ timeout: int = 30
+ ):
+ """
+ Args:
+ api_key: API 密钥
+ api_url: API 地址
+ timeout: 超时时间
+ """
+ self.api_key = api_key
+ self.api_url = api_url
+ self.timeout = timeout
+ self._last_task_id: Optional[str] = None
+
+ async def solve_image(
+ self,
+ image_bytes: bytes,
+ captcha_type: str = "default"
+ ) -> Optional[str]:
+ """
+ 识别图片验证码
+
+ Args:
+ image_bytes: 图片字节
+ captcha_type: 验证码类型
+
+ Returns:
+ 识别结果
+ """
+ import base64
+
+ try:
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
+ # 提交任务
+ image_base64 = base64.b64encode(image_bytes).decode()
+
+ resp = await client.post(
+ f"{self.api_url}/create_task",
+ json={
+ "api_key": self.api_key,
+ "image": image_base64,
+ "type": captcha_type
+ }
+ )
+
+ data = resp.json()
+ task_id = data.get("task_id")
+
+ if not task_id:
+ logger.error(f"创建任务失败: {data}")
+ return None
+
+ self._last_task_id = task_id
+
+ # 轮询获取结果
+ result = await self._poll_result(client, task_id)
+ return result
+
+ except Exception as e:
+ logger.error(f"打码平台请求失败: {e}")
+ return None
+
+ async def _poll_result(
+ self,
+ client: httpx.AsyncClient,
+ task_id: str,
+ max_attempts: int = 30
+ ) -> Optional[str]:
+ """轮询获取结果"""
+ for _ in range(max_attempts):
+ try:
+ resp = await client.get(
+ f"{self.api_url}/get_result",
+ params={"task_id": task_id}
+ )
+
+ data = resp.json()
+ status = data.get("status")
+
+ if status == "ready":
+ return data.get("result")
+ elif status == "error":
+ logger.error(f"识别错误: {data.get('error')}")
+ return None
+
+ await asyncio.sleep(1)
+
+ except Exception as e:
+ logger.warning(f"轮询异常: {e}")
+ await asyncio.sleep(1)
+
+ logger.error("识别超时")
+ return None
+
+ async def report_error(self, task_id: str = None):
+ """报告识别错误"""
+ task_id = task_id or self._last_task_id
+ if not task_id:
+ return
+
+ try:
+ async with httpx.AsyncClient() as client:
+ await client.post(
+ f"{self.api_url}/report_error",
+ json={
+ "api_key": self.api_key,
+ "task_id": task_id
+ }
+ )
+ logger.info(f"已报告错误: {task_id}")
+ except Exception as e:
+ logger.warning(f"报告错误失败: {e}")
+
+ async def get_balance(self) -> float:
+ """获取余额"""
+ try:
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ f"{self.api_url}/balance",
+ params={"api_key": self.api_key}
+ )
+ data = resp.json()
+ return data.get("balance", 0.0)
+ except Exception as e:
+ logger.error(f"获取余额失败: {e}")
+ return 0.0
+```
+
+### 4.2 带重试的验证码处理
+
+```python
+from typing import Callable, Awaitable
+
+class CaptchaSolverWithRetry:
+ """带重试的验证码处理器"""
+
+ def __init__(
+ self,
+ primary_solver: Callable[[bytes], Awaitable[Optional[str]]],
+ fallback_solver: Callable[[bytes], Awaitable[Optional[str]]] = None,
+ max_retries: int = 3
+ ):
+ """
+ Args:
+ primary_solver: 主要识别方法
+ fallback_solver: 备用识别方法(如打码平台)
+ max_retries: 最大重试次数
+ """
+ self.primary_solver = primary_solver
+ self.fallback_solver = fallback_solver
+ self.max_retries = max_retries
+
+ async def solve(
+ self,
+ get_image: Callable[[], Awaitable[bytes]],
+ verify: Callable[[str], Awaitable[bool]]
+ ) -> Optional[str]:
+ """
+ 解决验证码
+
+ Args:
+ get_image: 获取验证码图片的方法
+ verify: 验证结果的方法
+
+ Returns:
+ 成功的验证码结果
+ """
+ for attempt in range(self.max_retries):
+ # 获取验证码图片
+ image = await get_image()
+
+ # 尝试主要识别方法
+ result = await self.primary_solver(image)
+ if result and await verify(result):
+ logger.info(f"验证码识别成功 (尝试 {attempt + 1})")
+ return result
+
+ # 尝试备用方法
+ if self.fallback_solver:
+ result = await self.fallback_solver(image)
+ if result and await verify(result):
+ logger.info(f"备用方法识别成功 (尝试 {attempt + 1})")
+ return result
+
+ logger.warning(f"验证码验证失败,重试 ({attempt + 1}/{self.max_retries})")
+
+ logger.error("验证码处理失败,已达最大重试次数")
+ return None
+```
+
+## 五、合规与伦理
+
+### 5.1 法律边界
+
+1. **遵守服务条款**:了解目标网站的使用条款
+2. **合理使用**:仅用于学习研究或授权的测试
+3. **不用于恶意目的**:不进行攻击、欺诈等行为
+4. **保护用户隐私**:不获取、存储他人隐私信息
+
+### 5.2 替代方案
+
+在某些场景下,可以考虑替代方案:
+
+1. **官方 API**:很多平台提供开放 API,无需处理验证码
+2. **登录态复用**:使用已登录的 Cookie,减少登录次数
+3. **降低频率**:控制请求频率,避免触发验证码
+4. **联系网站**:对于数据研究,可联系网站获取授权
+
+### 5.3 成本控制
+
+使用打码平台时的成本控制建议:
+
+```python
+class CostController:
+ """打码成本控制器"""
+
+ def __init__(
+ self,
+ daily_budget: float,
+ cost_per_captcha: float = 0.01
+ ):
+ self.daily_budget = daily_budget
+ self.cost_per_captcha = cost_per_captcha
+ self._daily_spent = 0.0
+ self._last_reset = None
+
+ def can_use_service(self) -> bool:
+ """是否可以使用打码服务"""
+ self._check_reset()
+ return self._daily_spent < self.daily_budget
+
+ def record_usage(self):
+ """记录一次使用"""
+ self._daily_spent += self.cost_per_captcha
+
+ def _check_reset(self):
+ """检查是否需要重置"""
+ from datetime import date
+ today = date.today()
+ if self._last_reset != today:
+ self._daily_spent = 0.0
+ self._last_reset = today
+
+ @property
+ def remaining_budget(self) -> float:
+ """剩余预算"""
+ self._check_reset()
+ return max(0, self.daily_budget - self._daily_spent)
+```
+
+## 六、实战示例
+
+### 6.1 完整的验证码处理流程
+
+```python
+import asyncio
+from playwright.async_api import async_playwright
+
+async def demo_captcha_solving():
+ """验证码处理完整演示"""
+
+ # 初始化 OCR 识别器
+ ocr_solver = OCRCaptchaSolver(preprocess=True)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=False)
+ page = await browser.new_page()
+
+ await page.goto("https://example.com/login")
+
+ # 获取验证码图片
+ captcha_element = page.locator("#captcha-image")
+ captcha_bytes = await captcha_element.screenshot()
+
+ # 识别验证码
+ code = ocr_solver.solve(captcha_bytes)
+ print(f"识别结果: {code}")
+
+ # 输入验证码
+ if code:
+ await page.fill("#captcha-input", code)
+ await page.click("#submit-btn")
+
+ await asyncio.sleep(3)
+ await browser.close()
+```
+
+## 七、验证码处理实战
+
+大型网站在特定场景下会触发验证码,本节介绍常见验证码的特点和处理方法。
+
+### 7.1 验证码触发场景
+
+```mermaid
+flowchart TD
+ request[发起请求] --> check{风控检测}
+ check -->|正常| success[返回数据]
+ check -->|异常| trigger{触发验证码}
+
+ trigger -->|高频请求| slider[滑块验证码]
+ trigger -->|IP异常| geetest[极验验证码]
+ trigger -->|敏感操作| click[点选验证码]
+
+ slider --> verify{验证}
+ geetest --> verify
+ click --> verify
+
+ verify -->|成功| success
+ verify -->|失败| block[临时封禁]
+```
+
+### 7.2 常见验证码类型
+
+| 场景 | 验证码类型 | 触发条件 | 处理难度 |
+|-----|-----------|---------|---------|
+| 登录保护 | 滑块验证码 | 异地登录、频繁登录 | ⭐⭐⭐ |
+| 接口防护 | 极验验证码 | 请求频率过高 | ⭐⭐⭐⭐ |
+| 敏感操作 | 点选验证码 | 短时间大量操作 | ⭐⭐⭐⭐⭐ |
+| 批量操作 | 简单确认 | 批量提交 | ⭐ |
+
+### 7.3 滑块验证码处理
+
+```python
+import asyncio
+import httpx
+from playwright.async_api import async_playwright, Page
+from loguru import logger
+
+
+class SliderCaptcha:
+ """滑块验证码处理器"""
+
+ def __init__(self, page: Page):
+ self.page = page
+
+ async def detect_and_solve(self) -> bool:
+ """
+ 检测并解决滑块验证码
+
+ Returns:
+ 是否成功解决
+ """
+ try:
+ # 检测是否出现滑块验证码
+ slider_frame = self.page.frame_locator("iframe[src*='captcha']")
+
+ # 等待滑块出现(最多5秒)
+ try:
+ await slider_frame.locator(".geetest_slider_button").wait_for(
+ timeout=5000
+ )
+ except Exception:
+ # 没有验证码,正常情况
+ return True
+
+ logger.info("检测到滑块验证码")
+
+ # 获取滑块和背景图
+ bg_element = slider_frame.locator(".geetest_canvas_bg")
+ slider_element = slider_frame.locator(".geetest_canvas_slice")
+
+ bg_bytes = await bg_element.screenshot()
+ slider_bytes = await slider_element.screenshot()
+
+ # 检测缺口位置
+ gap_x = self._detect_gap(bg_bytes, slider_bytes)
+
+ if not gap_x:
+ logger.error("无法检测缺口位置")
+ return False
+
+ # 执行拖拽
+ await self._drag_slider(slider_frame, gap_x)
+
+ # 等待验证结果
+ await asyncio.sleep(2)
+
+ # 检查是否成功
+ try:
+ await slider_frame.locator(".geetest_success").wait_for(
+ timeout=3000
+ )
+ logger.info("滑块验证码通过")
+ return True
+ except Exception:
+ logger.warning("滑块验证码验证失败")
+ return False
+
+ except Exception as e:
+ logger.error(f"滑块验证码处理异常: {e}")
+ return False
+
+ def _detect_gap(self, bg_bytes: bytes, slider_bytes: bytes) -> int:
+ """检测缺口位置"""
+ import cv2
+ import numpy as np
+
+ bg = cv2.imdecode(np.frombuffer(bg_bytes, np.uint8), cv2.IMREAD_COLOR)
+ slider = cv2.imdecode(np.frombuffer(slider_bytes, np.uint8), cv2.IMREAD_COLOR)
+
+ # 边缘检测
+ bg_edges = cv2.Canny(cv2.cvtColor(bg, cv2.COLOR_BGR2GRAY), 100, 200)
+ slider_edges = cv2.Canny(cv2.cvtColor(slider, cv2.COLOR_BGR2GRAY), 100, 200)
+
+ # 模板匹配
+ result = cv2.matchTemplate(bg_edges, slider_edges, cv2.TM_CCOEFF_NORMED)
+ _, _, _, max_loc = cv2.minMaxLoc(result)
+
+ return max_loc[0]
+
+ async def _drag_slider(self, frame, distance: int):
+ """拖拽滑块"""
+ import random
+
+ slider_btn = frame.locator(".geetest_slider_button")
+ box = await slider_btn.bounding_box()
+
+ if not box:
+ raise Exception("无法获取滑块位置")
+
+ start_x = box['x'] + box['width'] / 2
+ start_y = box['y'] + box['height'] / 2
+
+ # 生成人类轨迹
+ trajectory = self._generate_human_trajectory(distance)
+
+ await self.page.mouse.move(start_x, start_y)
+ await asyncio.sleep(random.uniform(0.1, 0.2))
+
+ await self.page.mouse.down()
+
+ for x, y, delay in trajectory:
+ await asyncio.sleep(delay)
+ await self.page.mouse.move(start_x + x, start_y + y)
+
+ await asyncio.sleep(random.uniform(0.05, 0.1))
+ await self.page.mouse.up()
+
+ def _generate_human_trajectory(self, distance: int):
+ """生成人类轨迹"""
+ import random
+
+ trajectory = []
+ current_x = 0
+ steps = random.randint(20, 30)
+
+ for i in range(steps):
+ progress = i / steps
+ # 缓动函数
+ eased = progress * (2 - progress)
+ target_x = int(distance * eased)
+
+ x = target_x
+ y = random.randint(-3, 3)
+ delay = random.uniform(0.01, 0.03)
+
+ trajectory.append((x, y, delay))
+
+ trajectory.append((distance, 0, 0.05))
+ return trajectory
+
+
+async def demo_captcha_handling():
+ """带验证码处理的页面访问示例"""
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=False)
+ context = await browser.new_context()
+ page = await context.new_page()
+
+ # 访问测试页面
+ await page.goto("https://example.com")
+
+ # 模拟一些操作...
+ # 如果触发验证码,自动处理
+ captcha_handler = SliderCaptcha(page)
+
+ # 在关键操作后检查验证码
+ success = await captcha_handler.detect_and_solve()
+
+ if success:
+ print("操作成功,无验证码或验证码已处理")
+ else:
+ print("验证码处理失败")
+
+ await browser.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(demo_captcha_handling())
+```
+
+### 7.4 避免触发验证码的策略
+
+在爬虫开发中,预防优于处理:
+
+```mermaid
+flowchart LR
+ subgraph 预防策略
+ A[控制请求频率] --> B[模拟真实行为]
+ B --> C[使用登录态]
+ C --> D[IP轮换]
+ end
+
+ subgraph 建议配置
+ E[每分钟<30请求]
+ F[随机延迟2-5秒]
+ G[保持Cookie有效]
+ H[高匿代理池]
+ end
+
+ A --> E
+ B --> F
+ C --> G
+ D --> H
+```
+
+```python
+import asyncio
+import random
+from typing import Optional
+
+
+class RateLimiter:
+ """请求频率控制器"""
+
+ def __init__(
+ self,
+ requests_per_minute: int = 20,
+ min_delay: float = 2.0,
+ max_delay: float = 5.0
+ ):
+ self.requests_per_minute = requests_per_minute
+ self.min_delay = min_delay
+ self.max_delay = max_delay
+ self._last_request_time: Optional[float] = None
+ self._request_count = 0
+ self._minute_start: Optional[float] = None
+
+ async def wait(self):
+ """等待直到可以发送下一个请求"""
+ now = asyncio.get_event_loop().time()
+
+ # 重置分钟计数
+ if self._minute_start is None or now - self._minute_start > 60:
+ self._minute_start = now
+ self._request_count = 0
+
+ # 检查是否超过频率限制
+ if self._request_count >= self.requests_per_minute:
+ wait_time = 60 - (now - self._minute_start)
+ if wait_time > 0:
+ await asyncio.sleep(wait_time)
+ self._minute_start = asyncio.get_event_loop().time()
+ self._request_count = 0
+
+ # 随机延迟
+ if self._last_request_time:
+ elapsed = now - self._last_request_time
+ if elapsed < self.min_delay:
+ delay = random.uniform(self.min_delay, self.max_delay)
+ await asyncio.sleep(delay - elapsed)
+
+ self._last_request_time = asyncio.get_event_loop().time()
+ self._request_count += 1
+
+
+# 使用示例
+rate_limiter = RateLimiter(
+ requests_per_minute=20,
+ min_delay=2.0,
+ max_delay=5.0
+)
+
+async def safe_request(client, url):
+ """安全的请求(带频率控制)"""
+ await rate_limiter.wait()
+ return await client.get(url)
+```
+
+---
+
+## 本章小结
+
+本章介绍了验证码识别与处理的核心技术:
+
+1. **验证码类型**:图片字符、滑块、点选等多种类型
+2. **OCR 识别**:使用 ddddocr 进行图片验证码识别
+3. **滑块处理**:缺口检测和人类轨迹模拟
+4. **打码平台**:第三方服务的接入和成本控制
+5. **合规考虑**:法律边界和替代方案
+6. **实战演练**:验证码触发场景和处理策略
+
+验证码处理是爬虫进阶的重要技能,但务必在合法合规的前提下使用。
+
+## 下一章预告
+
+下一章我们将学习**数据清洗与预处理**,包括文本清洗、正则表达式应用、数据去重等技术。爬取的数据往往需要清洗才能使用,这是数据处理流程中的关键环节。
diff --git "a/docs/\347\210\254\350\231\253\350\277\233\344\273\267/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206.md" "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206.md"
new file mode 100644
index 0000000..d0fade2
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206.md"
@@ -0,0 +1,1363 @@
+# 09_数据清洗与预处理
+
+爬取的原始数据往往包含噪声、冗余和格式不统一等问题。本章将介绍数据清洗的核心技术,帮助你将"脏数据"转换为可用的"干净数据"。
+
+## 一、数据清洗概述
+
+### 1.1 为什么需要数据清洗
+
+爬取的原始数据常见问题:
+
+| 问题类型 | 示例 | 影响 |
+|---------|------|------|
+| HTML 残留 | `文本
` | 数据不纯净 |
+| 空白字符 | 多余空格、换行 | 格式混乱 |
+| 编码问题 | 乱码、特殊字符 | 无法正常显示 |
+| 重复数据 | 相同内容多次出现 | 数据冗余 |
+| 格式不统一 | 日期格式各异 | 难以分析 |
+| 缺失值 | 空字段 | 数据不完整 |
+
+### 1.2 数据清洗流程
+
+```mermaid
+flowchart LR
+ raw[原始数据] --> format[格式清洗]
+ format --> content[内容清洗]
+ content --> dedup[去重处理]
+ dedup --> normalize[标准化]
+ normalize --> clean[干净数据]
+
+ subgraph 格式清洗
+ html[HTML标签移除]
+ encode[编码修复]
+ end
+
+ subgraph 内容清洗
+ whitespace[空白处理]
+ special[特殊字符]
+ end
+```
+
+### 1.3 数据质量评估
+
+在清洗前后,评估数据质量:
+
+```python
+from dataclasses import dataclass
+from typing import List, Dict, Any
+
+@dataclass
+class DataQualityReport:
+ """数据质量报告"""
+ total_records: int
+ valid_records: int
+ duplicate_count: int
+ missing_fields: Dict[str, int]
+ format_issues: Dict[str, int]
+
+ @property
+ def validity_rate(self) -> float:
+ """有效率"""
+ return self.valid_records / self.total_records if self.total_records > 0 else 0
+
+ @property
+ def duplicate_rate(self) -> float:
+ """重复率"""
+ return self.duplicate_count / self.total_records if self.total_records > 0 else 0
+
+ def __str__(self) -> str:
+ return f"""
+数据质量报告:
+ 总记录数: {self.total_records}
+ 有效记录: {self.valid_records} ({self.validity_rate:.1%})
+ 重复记录: {self.duplicate_count} ({self.duplicate_rate:.1%})
+ 缺失字段: {self.missing_fields}
+ 格式问题: {self.format_issues}
+ """.strip()
+```
+
+## 二、文本清洗
+
+### 2.1 HTML 标签移除
+
+```python
+import re
+from typing import Optional
+
+class HTMLCleaner:
+ """HTML 清洗器"""
+
+ # 需要完全移除的标签(包括内容)
+ REMOVE_TAGS = ['script', 'style', 'head', 'meta', 'link']
+
+ @staticmethod
+ def remove_tags(html: str) -> str:
+ """移除所有 HTML 标签"""
+ # 先移除特定标签及其内容
+ for tag in HTMLCleaner.REMOVE_TAGS:
+ pattern = f'<{tag}[^>]*>.*?{tag}>'
+ html = re.sub(pattern, '', html, flags=re.DOTALL | re.IGNORECASE)
+
+ # 移除所有标签
+ html = re.sub(r'<[^>]+>', '', html)
+
+ return html
+
+ @staticmethod
+ def remove_tags_keep_text(html: str) -> str:
+ """移除标签但保留文本内容"""
+ # 处理常见的块级元素,添加换行
+ html = re.sub(r'(p|div|br|li|tr|h[1-6])>', '\n', html, flags=re.IGNORECASE)
+ # 移除其他标签
+ html = re.sub(r'<[^>]+>', '', html)
+ return html
+
+ @staticmethod
+ def decode_entities(text: str) -> str:
+ """解码 HTML 实体"""
+ import html
+ return html.unescape(text)
+
+
+# 使用 BeautifulSoup(更可靠)
+def clean_html_with_bs4(html: str) -> str:
+ """使用 BeautifulSoup 清洗 HTML"""
+ try:
+ from bs4 import BeautifulSoup
+ soup = BeautifulSoup(html, 'html.parser')
+
+ # 移除脚本和样式
+ for script in soup(['script', 'style']):
+ script.decompose()
+
+ # 获取文本
+ text = soup.get_text(separator='\n')
+ return text
+ except ImportError:
+ return HTMLCleaner.remove_tags(html)
+```
+
+### 2.2 空白字符处理
+
+```python
+class WhitespaceCleaner:
+ """空白字符清洗器"""
+
+ @staticmethod
+ def normalize_whitespace(text: str) -> str:
+ """标准化空白字符"""
+ # 将所有空白字符转为普通空格
+ text = re.sub(r'[\t\r\f\v]', ' ', text)
+ # 合并多个空格
+ text = re.sub(r' +', ' ', text)
+ # 合并多个换行
+ text = re.sub(r'\n+', '\n', text)
+ # 去除首尾空白
+ return text.strip()
+
+ @staticmethod
+ def remove_all_whitespace(text: str) -> str:
+ """移除所有空白字符"""
+ return re.sub(r'\s+', '', text)
+
+ @staticmethod
+ def trim_lines(text: str) -> str:
+ """去除每行首尾空白"""
+ lines = text.split('\n')
+ return '\n'.join(line.strip() for line in lines)
+
+ @staticmethod
+ def remove_empty_lines(text: str) -> str:
+ """移除空行"""
+ lines = text.split('\n')
+ return '\n'.join(line for line in lines if line.strip())
+```
+
+### 2.3 特殊字符清理
+
+```python
+import unicodedata
+
+class SpecialCharCleaner:
+ """特殊字符清洗器"""
+
+ @staticmethod
+ def remove_control_chars(text: str) -> str:
+ """移除控制字符"""
+ return ''.join(
+ char for char in text
+ if unicodedata.category(char) != 'Cc'
+ )
+
+ @staticmethod
+ def normalize_unicode(text: str, form: str = 'NFKC') -> str:
+ """
+ Unicode 标准化
+
+ Args:
+ text: 输入文本
+ form: 标准化形式 (NFC/NFD/NFKC/NFKD)
+
+ Returns:
+ 标准化后的文本
+ """
+ return unicodedata.normalize(form, text)
+
+ @staticmethod
+ def remove_emojis(text: str) -> str:
+ """移除 emoji"""
+ emoji_pattern = re.compile(
+ "["
+ "\U0001F600-\U0001F64F" # 表情
+ "\U0001F300-\U0001F5FF" # 符号和象形文字
+ "\U0001F680-\U0001F6FF" # 交通和地图
+ "\U0001F1E0-\U0001F1FF" # 旗帜
+ "\U00002702-\U000027B0" # 装饰符号
+ "\U000024C2-\U0001F251" # 封闭字符
+ "]+",
+ flags=re.UNICODE
+ )
+ return emoji_pattern.sub('', text)
+
+ @staticmethod
+ def to_halfwidth(text: str) -> str:
+ """全角转半角"""
+ result = []
+ for char in text:
+ code = ord(char)
+ # 全角空格
+ if code == 0x3000:
+ result.append(' ')
+ # 其他全角字符
+ elif 0xFF01 <= code <= 0xFF5E:
+ result.append(chr(code - 0xFEE0))
+ else:
+ result.append(char)
+ return ''.join(result)
+```
+
+### 2.4 编码问题处理
+
+```python
+import chardet
+
+class EncodingFixer:
+ """编码问题修复器"""
+
+ @staticmethod
+ def detect_encoding(data: bytes) -> str:
+ """检测编码"""
+ result = chardet.detect(data)
+ return result['encoding'] or 'utf-8'
+
+ @staticmethod
+ def fix_encoding(text: str, source_encoding: str = None) -> str:
+ """修复编码问题"""
+ try:
+ # 如果是乱码,尝试重新编码
+ if source_encoding:
+ return text.encode('latin1').decode(source_encoding)
+ else:
+ # 尝试常见编码
+ for encoding in ['utf-8', 'gbk', 'gb2312', 'big5']:
+ try:
+ return text.encode('latin1').decode(encoding)
+ except (UnicodeDecodeError, UnicodeEncodeError):
+ continue
+ except Exception:
+ pass
+ return text
+
+ @staticmethod
+ def safe_decode(data: bytes, fallback: str = 'utf-8') -> str:
+ """安全解码"""
+ # 检测编码
+ detected = EncodingFixer.detect_encoding(data)
+ try:
+ return data.decode(detected)
+ except (UnicodeDecodeError, TypeError):
+ return data.decode(fallback, errors='ignore')
+```
+
+## 三、正则表达式高级应用
+
+### 3.1 常用提取模式
+
+```python
+class RegexPatterns:
+ """常用正则表达式模式"""
+
+ # 基础模式
+ CHINESE = r'[\u4e00-\u9fa5]+' # 中文
+ EMAIL = r'[\w.+-]+@[\w-]+\.[\w.-]+' # 邮箱
+ PHONE = r'1[3-9]\d{9}' # 中国手机号
+ URL = r'https?://[^\s<>"{}|\\^`\[\]]+' # URL
+ IP = r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}' # IP 地址
+
+ # 日期时间模式
+ DATE_YMD = r'\d{4}[-/年]\d{1,2}[-/月]\d{1,2}日?' # 年月日
+ TIME_HMS = r'\d{1,2}:\d{2}(:\d{2})?' # 时分秒
+ DATETIME = r'\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{2}(:\d{2})?'
+
+ # 数值模式
+ INTEGER = r'-?\d+'
+ FLOAT = r'-?\d+\.?\d*'
+ PRICE = r'[¥$¥]\s*\d+\.?\d*' # 价格
+ PERCENTAGE = r'\d+\.?\d*%' # 百分比
+
+
+class RegexExtractor:
+ """正则表达式提取器"""
+
+ @staticmethod
+ def extract_all(text: str, pattern: str) -> list:
+ """提取所有匹配"""
+ return re.findall(pattern, text)
+
+ @staticmethod
+ def extract_first(text: str, pattern: str) -> str:
+ """提取第一个匹配"""
+ match = re.search(pattern, text)
+ return match.group() if match else ''
+
+ @staticmethod
+ def extract_groups(text: str, pattern: str) -> dict:
+ """提取命名分组"""
+ match = re.search(pattern, text)
+ return match.groupdict() if match else {}
+
+ @staticmethod
+ def extract_between(text: str, start: str, end: str) -> list:
+ """提取两个标记之间的内容"""
+ pattern = f'{re.escape(start)}(.*?){re.escape(end)}'
+ return re.findall(pattern, text, re.DOTALL)
+```
+
+### 3.2 高级替换
+
+```python
+class RegexReplacer:
+ """正则表达式替换器"""
+
+ @staticmethod
+ def replace_with_callback(
+ text: str,
+ pattern: str,
+ callback
+ ) -> str:
+ """使用回调函数替换"""
+ return re.sub(pattern, callback, text)
+
+ @staticmethod
+ def mask_sensitive(text: str) -> str:
+ """脱敏处理"""
+ # 手机号脱敏
+ text = re.sub(
+ r'(1[3-9]\d)\d{4}(\d{4})',
+ r'\1****\2',
+ text
+ )
+ # 邮箱脱敏
+ text = re.sub(
+ r'([\w.+-]{1,3})[\w.+-]*(@[\w-]+\.[\w.-]+)',
+ r'\1***\2',
+ text
+ )
+ # 身份证脱敏
+ text = re.sub(
+ r'(\d{6})\d{8}(\d{4})',
+ r'\1********\2',
+ text
+ )
+ return text
+
+ @staticmethod
+ def clean_url_params(url: str, keep_params: list = None) -> str:
+ """清理 URL 参数"""
+ from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
+
+ parsed = urlparse(url)
+ params = parse_qs(parsed.query)
+
+ if keep_params:
+ params = {k: v for k, v in params.items() if k in keep_params}
+ else:
+ params = {}
+
+ new_query = urlencode(params, doseq=True)
+ return urlunparse(parsed._replace(query=new_query))
+```
+
+## 四、数据去重
+
+### 4.1 精确去重
+
+```python
+from typing import List, Dict, Any, Callable
+import hashlib
+
+class ExactDeduplicator:
+ """精确去重器"""
+
+ @staticmethod
+ def dedupe_list(items: List[str]) -> List[str]:
+ """列表去重(保持顺序)"""
+ seen = set()
+ result = []
+ for item in items:
+ if item not in seen:
+ seen.add(item)
+ result.append(item)
+ return result
+
+ @staticmethod
+ def dedupe_dicts(
+ items: List[Dict],
+ key_field: str
+ ) -> List[Dict]:
+ """字典列表去重"""
+ seen = set()
+ result = []
+ for item in items:
+ key = item.get(key_field)
+ if key not in seen:
+ seen.add(key)
+ result.append(item)
+ return result
+
+ @staticmethod
+ def dedupe_by_hash(
+ items: List[Dict],
+ fields: List[str]
+ ) -> List[Dict]:
+ """
+ 根据多个字段计算哈希去重
+
+ Args:
+ items: 数据列表
+ fields: 用于计算哈希的字段
+
+ Returns:
+ 去重后的列表
+ """
+ seen = set()
+ result = []
+
+ for item in items:
+ # 计算哈希
+ key_str = '|'.join(str(item.get(f, '')) for f in fields)
+ key_hash = hashlib.md5(key_str.encode()).hexdigest()
+
+ if key_hash not in seen:
+ seen.add(key_hash)
+ result.append(item)
+
+ return result
+```
+
+### 4.2 模糊去重
+
+```python
+class FuzzyDeduplicator:
+ """模糊去重器"""
+
+ @staticmethod
+ def levenshtein_distance(s1: str, s2: str) -> int:
+ """计算编辑距离"""
+ if len(s1) < len(s2):
+ return FuzzyDeduplicator.levenshtein_distance(s2, s1)
+
+ if len(s2) == 0:
+ return len(s1)
+
+ previous_row = range(len(s2) + 1)
+ for i, c1 in enumerate(s1):
+ current_row = [i + 1]
+ for j, c2 in enumerate(s2):
+ insertions = previous_row[j + 1] + 1
+ deletions = current_row[j] + 1
+ substitutions = previous_row[j] + (c1 != c2)
+ current_row.append(min(insertions, deletions, substitutions))
+ previous_row = current_row
+
+ return previous_row[-1]
+
+ @staticmethod
+ def similarity(s1: str, s2: str) -> float:
+ """计算相似度 (0-1)"""
+ if not s1 or not s2:
+ return 0.0
+ distance = FuzzyDeduplicator.levenshtein_distance(s1, s2)
+ max_len = max(len(s1), len(s2))
+ return 1 - distance / max_len
+
+ @staticmethod
+ def dedupe_fuzzy(
+ items: List[str],
+ threshold: float = 0.8
+ ) -> List[str]:
+ """
+ 模糊去重
+
+ Args:
+ items: 字符串列表
+ threshold: 相似度阈值
+
+ Returns:
+ 去重后的列表
+ """
+ if not items:
+ return []
+
+ result = [items[0]]
+
+ for item in items[1:]:
+ is_duplicate = False
+ for existing in result:
+ if FuzzyDeduplicator.similarity(item, existing) >= threshold:
+ is_duplicate = True
+ break
+ if not is_duplicate:
+ result.append(item)
+
+ return result
+```
+
+## 五、数据标准化
+
+### 5.1 日期时间标准化
+
+```python
+from datetime import datetime
+from typing import Optional
+
+class DateTimeNormalizer:
+ """日期时间标准化器"""
+
+ # 常见日期格式
+ DATE_FORMATS = [
+ '%Y-%m-%d',
+ '%Y/%m/%d',
+ '%Y年%m月%d日',
+ '%Y.%m.%d',
+ '%d-%m-%Y',
+ '%m/%d/%Y',
+ ]
+
+ # 常见日期时间格式
+ DATETIME_FORMATS = [
+ '%Y-%m-%d %H:%M:%S',
+ '%Y-%m-%d %H:%M',
+ '%Y/%m/%d %H:%M:%S',
+ '%Y年%m月%d日 %H:%M:%S',
+ '%Y年%m月%d日 %H时%M分',
+ ]
+
+ @classmethod
+ def parse_date(cls, text: str) -> Optional[datetime]:
+ """解析日期"""
+ text = text.strip()
+
+ for fmt in cls.DATE_FORMATS + cls.DATETIME_FORMATS:
+ try:
+ return datetime.strptime(text, fmt)
+ except ValueError:
+ continue
+
+ return None
+
+ @classmethod
+ def normalize_date(
+ cls,
+ text: str,
+ output_format: str = '%Y-%m-%d'
+ ) -> str:
+ """标准化日期格式"""
+ dt = cls.parse_date(text)
+ if dt:
+ return dt.strftime(output_format)
+ return text
+
+ @staticmethod
+ def parse_relative_time(text: str) -> Optional[datetime]:
+ """解析相对时间(如"3小时前")"""
+ import re
+ from datetime import timedelta
+
+ now = datetime.now()
+ patterns = [
+ (r'(\d+)\s*秒前', lambda m: now - timedelta(seconds=int(m.group(1)))),
+ (r'(\d+)\s*分钟前', lambda m: now - timedelta(minutes=int(m.group(1)))),
+ (r'(\d+)\s*小时前', lambda m: now - timedelta(hours=int(m.group(1)))),
+ (r'(\d+)\s*天前', lambda m: now - timedelta(days=int(m.group(1)))),
+ (r'刚刚', lambda m: now),
+ (r'昨天', lambda m: now - timedelta(days=1)),
+ (r'前天', lambda m: now - timedelta(days=2)),
+ ]
+
+ for pattern, handler in patterns:
+ match = re.search(pattern, text)
+ if match:
+ return handler(match)
+
+ return None
+```
+
+### 5.2 数值标准化
+
+```python
+class NumberNormalizer:
+ """数值标准化器"""
+
+ @staticmethod
+ def parse_number(text: str) -> float:
+ """
+ 解析数字(支持中文单位)
+
+ Examples:
+ "1.5万" -> 15000
+ "3.2亿" -> 320000000
+ "1,234.56" -> 1234.56
+ """
+ text = text.strip()
+
+ # 中文单位映射
+ units = {
+ '万': 10000,
+ '亿': 100000000,
+ 'k': 1000,
+ 'K': 1000,
+ 'm': 1000000,
+ 'M': 1000000,
+ 'b': 1000000000,
+ 'B': 1000000000,
+ }
+
+ multiplier = 1
+ for unit, value in units.items():
+ if unit in text:
+ multiplier = value
+ text = text.replace(unit, '')
+ break
+
+ # 移除逗号
+ text = text.replace(',', '')
+
+ # 提取数字
+ match = re.search(r'-?\d+\.?\d*', text)
+ if match:
+ return float(match.group()) * multiplier
+
+ return 0.0
+
+ @staticmethod
+ def format_number(
+ value: float,
+ precision: int = 2,
+ use_units: bool = True
+ ) -> str:
+ """格式化数字"""
+ if not use_units:
+ return f'{value:.{precision}f}'
+
+ if value >= 100000000:
+ return f'{value/100000000:.{precision}f}亿'
+ elif value >= 10000:
+ return f'{value/10000:.{precision}f}万'
+ else:
+ return f'{value:.{precision}f}'
+```
+
+### 5.3 文本标准化
+
+```python
+class TextNormalizer:
+ """文本标准化器"""
+
+ @staticmethod
+ def normalize(text: str) -> str:
+ """完整的文本标准化流程"""
+ # 1. Unicode 标准化
+ text = unicodedata.normalize('NFKC', text)
+ # 2. 全角转半角
+ text = SpecialCharCleaner.to_halfwidth(text)
+ # 3. 移除控制字符
+ text = SpecialCharCleaner.remove_control_chars(text)
+ # 4. 标准化空白
+ text = WhitespaceCleaner.normalize_whitespace(text)
+ return text
+
+ @staticmethod
+ def normalize_punctuation(text: str) -> str:
+ """标准化标点符号"""
+ # 中文标点转英文
+ mapping = {
+ ',': ', ',
+ '。': '. ',
+ '!': '! ',
+ '?': '? ',
+ ';': '; ',
+ ':': ': ',
+ '"': '"',
+ '"': '"',
+ ''': "'",
+ ''': "'",
+ }
+ for cn, en in mapping.items():
+ text = text.replace(cn, en)
+ return text
+```
+
+## 六、综合数据清洗器
+
+```python
+from dataclasses import dataclass, field
+from typing import List, Dict, Any, Callable
+
+@dataclass
+class CleaningConfig:
+ """清洗配置"""
+ remove_html: bool = True
+ normalize_whitespace: bool = True
+ normalize_unicode: bool = True
+ remove_emojis: bool = False
+ to_halfwidth: bool = True
+
+class DataCleaner:
+ """综合数据清洗器"""
+
+ def __init__(self, config: CleaningConfig = None):
+ self.config = config or CleaningConfig()
+
+ def clean_text(self, text: str) -> str:
+ """清洗文本"""
+ if not text:
+ return ''
+
+ # HTML 清洗
+ if self.config.remove_html:
+ text = HTMLCleaner.remove_tags(text)
+ text = HTMLCleaner.decode_entities(text)
+
+ # Unicode 标准化
+ if self.config.normalize_unicode:
+ text = SpecialCharCleaner.normalize_unicode(text)
+
+ # 全角转半角
+ if self.config.to_halfwidth:
+ text = SpecialCharCleaner.to_halfwidth(text)
+
+ # 移除 emoji
+ if self.config.remove_emojis:
+ text = SpecialCharCleaner.remove_emojis(text)
+
+ # 空白处理
+ if self.config.normalize_whitespace:
+ text = WhitespaceCleaner.normalize_whitespace(text)
+
+ return text
+
+ def clean_dict(self, data: Dict, text_fields: List[str] = None) -> Dict:
+ """清洗字典中的文本字段"""
+ result = data.copy()
+ text_fields = text_fields or list(data.keys())
+
+ for field in text_fields:
+ if field in result and isinstance(result[field], str):
+ result[field] = self.clean_text(result[field])
+
+ return result
+
+ def clean_list(
+ self,
+ items: List[Dict],
+ text_fields: List[str] = None
+ ) -> List[Dict]:
+ """清洗字典列表"""
+ return [self.clean_dict(item, text_fields) for item in items]
+```
+
+## 七、实战示例
+
+```python
+import asyncio
+
+async def demo_data_cleaning():
+ """数据清洗演示"""
+
+ # 模拟爬取的原始数据
+ raw_data = [
+ {
+ "title": " Python 爬虫教程
\n\n",
+ "content": "这是一篇&关于爬虫的教程。
",
+ "date": "2024年1月15日",
+ "price": "¥99.00",
+ "views": "1.5万"
+ },
+ {
+ "title": "Python 爬虫教程
", # 重复
+ "content": "这是另一篇关于爬虫的教程。
",
+ "date": "2024-01-15",
+ "price": "99元",
+ "views": "15000"
+ }
+ ]
+
+ print("原始数据:")
+ for item in raw_data:
+ print(f" {item}")
+
+ # 1. 文本清洗
+ cleaner = DataCleaner(CleaningConfig(
+ remove_html=True,
+ normalize_whitespace=True,
+ to_halfwidth=True
+ ))
+
+ cleaned_data = cleaner.clean_list(raw_data, ["title", "content"])
+
+ print("\n清洗后数据:")
+ for item in cleaned_data:
+ print(f" {item}")
+
+ # 2. 日期标准化
+ for item in cleaned_data:
+ item["date"] = DateTimeNormalizer.normalize_date(item["date"])
+
+ print("\n日期标准化后:")
+ for item in cleaned_data:
+ print(f" date: {item['date']}")
+
+ # 3. 数值标准化
+ for item in cleaned_data:
+ item["views_num"] = NumberNormalizer.parse_number(item["views"])
+
+ print("\n数值标准化后:")
+ for item in cleaned_data:
+ print(f" views: {item['views']} -> {item['views_num']}")
+
+ # 4. 去重
+ deduped = ExactDeduplicator.dedupe_by_hash(cleaned_data, ["title"])
+ print(f"\n去重后记录数: {len(deduped)}")
+
+
+if __name__ == "__main__":
+ asyncio.run(demo_data_cleaning())
+```
+
+## 八、数据清洗实战
+
+本节以网页数据为例,演示完整的数据清洗流程。
+
+### 8.1 数据特点分析
+
+```mermaid
+flowchart TD
+ subgraph 原始数据问题
+ title_em["标题含HTML标签
(搜索高亮)"]
+ view_unit["数值含单位
(1.5万、3.2亿)"]
+ duration_format["时长格式不一
(02:30、1:23:45)"]
+ pubdate_format["发布时间多样
(刚刚、3天前、2024-01-15)"]
+ desc_html["描述含HTML
(换行、链接)"]
+ end
+
+ subgraph 清洗目标
+ title_clean[纯文本标题]
+ view_num[数值]
+ duration_sec[秒数时长]
+ pubdate_std[标准日期]
+ desc_plain[纯文本描述]
+ end
+
+ title_em --> title_clean
+ view_unit --> view_num
+ duration_format --> duration_sec
+ pubdate_format --> pubdate_std
+ desc_html --> desc_plain
+```
+
+### 8.2 数据清洗器
+
+```python
+import re
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import Optional, List, Dict, Any
+from loguru import logger
+
+
+@dataclass
+class WebContent:
+ """网页内容数据模型(清洗后)"""
+ bvid: str # 视频BV号
+ title: str # 标题(纯文本)
+ description: str # 简介(纯文本)
+ owner_name: str # UP主名称
+ owner_mid: int # UP主ID
+ view_count: int # 播放量
+ like_count: int # 点赞数
+ coin_count: int # 投币数
+ favorite_count: int # 收藏数
+ share_count: int # 分享数
+ danmaku_count: int # 弹幕数
+ comment_count: int # 评论数
+ duration_seconds: int # 时长(秒)
+ publish_time: datetime # 发布时间
+ tags: List[str] # 标签列表
+
+
+class DataCleaner:
+ """网页数据清洗器"""
+
+ @staticmethod
+ def clean_title(title: str) -> str:
+ """
+ 清洗视频标题
+
+ 处理:
+ - 移除搜索高亮标签 ...
+ - 移除其他HTML标签
+ - 标准化空白字符
+ """
+ if not title:
+ return ""
+
+ # 移除 标签但保留内容
+ title = re.sub(r']*>([^<]*)', r'\1', title)
+ # 移除其他HTML标签
+ title = re.sub(r'<[^>]+>', '', title)
+ # HTML实体解码
+ import html
+ title = html.unescape(title)
+ # 标准化空白
+ title = re.sub(r'\s+', ' ', title).strip()
+
+ return title
+
+ @staticmethod
+ def clean_description(desc: str) -> str:
+ """
+ 清洗视频简介
+
+ 处理:
+ - 移除HTML标签
+ - 保留换行结构
+ - 移除过多空行
+ """
+ if not desc:
+ return ""
+
+ # 移除HTML标签
+ desc = re.sub(r'<[^>]+>', '', desc)
+ # HTML实体解码
+ import html
+ desc = html.unescape(desc)
+ # 合并多个换行
+ desc = re.sub(r'\n{3,}', '\n\n', desc)
+ # 去除首尾空白
+ desc = desc.strip()
+
+ return desc
+
+ @staticmethod
+ def parse_view_count(view_str: str) -> int:
+ """
+ 解析播放量
+
+ 支持格式:
+ - "15000" -> 15000
+ - "1.5万" -> 15000
+ - "3.2亿" -> 320000000
+ - "1,234,567" -> 1234567
+ """
+ if not view_str:
+ return 0
+
+ view_str = str(view_str).strip()
+
+ # 移除逗号
+ view_str = view_str.replace(',', '')
+
+ # 处理中文单位
+ if '亿' in view_str:
+ num = float(view_str.replace('亿', ''))
+ return int(num * 100000000)
+ elif '万' in view_str:
+ num = float(view_str.replace('万', ''))
+ return int(num * 10000)
+ else:
+ # 尝试直接转换
+ try:
+ return int(float(view_str))
+ except ValueError:
+ return 0
+
+ @staticmethod
+ def parse_duration(duration_str: str) -> int:
+ """
+ 解析视频时长为秒数
+
+ 支持格式:
+ - "02:30" -> 150
+ - "1:23:45" -> 5025
+ - 150 (已是秒数) -> 150
+ """
+ if not duration_str:
+ return 0
+
+ # 如果已经是数字,直接返回
+ if isinstance(duration_str, (int, float)):
+ return int(duration_str)
+
+ duration_str = str(duration_str).strip()
+
+ # 尝试直接转换(API返回的可能已是秒数)
+ try:
+ return int(duration_str)
+ except ValueError:
+ pass
+
+ # 解析时:分:秒格式
+ parts = duration_str.split(':')
+ try:
+ if len(parts) == 2:
+ # MM:SS
+ minutes, seconds = int(parts[0]), int(parts[1])
+ return minutes * 60 + seconds
+ elif len(parts) == 3:
+ # HH:MM:SS
+ hours, minutes, seconds = int(parts[0]), int(parts[1]), int(parts[2])
+ return hours * 3600 + minutes * 60 + seconds
+ except ValueError:
+ pass
+
+ return 0
+
+ @staticmethod
+ def parse_publish_time(pubdate: Any) -> Optional[datetime]:
+ """
+ 解析发布时间
+
+ 支持格式:
+ - Unix时间戳 (int)
+ - "刚刚", "3分钟前", "2小时前", "3天前"
+ - "2024-01-15"
+ - "2024-01-15 10:30:00"
+ - "2024年1月15日"
+ """
+ if not pubdate:
+ return None
+
+ # Unix时间戳
+ if isinstance(pubdate, (int, float)):
+ if pubdate > 1000000000000: # 毫秒
+ pubdate = pubdate / 1000
+ return datetime.fromtimestamp(pubdate)
+
+ pubdate_str = str(pubdate).strip()
+ now = datetime.now()
+
+ # 相对时间
+ relative_patterns = [
+ (r'刚刚', lambda m: now),
+ (r'(\d+)\s*秒前', lambda m: now - timedelta(seconds=int(m.group(1)))),
+ (r'(\d+)\s*分钟前', lambda m: now - timedelta(minutes=int(m.group(1)))),
+ (r'(\d+)\s*小时前', lambda m: now - timedelta(hours=int(m.group(1)))),
+ (r'(\d+)\s*天前', lambda m: now - timedelta(days=int(m.group(1)))),
+ (r'昨天', lambda m: now - timedelta(days=1)),
+ (r'前天', lambda m: now - timedelta(days=2)),
+ ]
+
+ for pattern, handler in relative_patterns:
+ match = re.search(pattern, pubdate_str)
+ if match:
+ return handler(match)
+
+ # 绝对时间格式
+ date_formats = [
+ '%Y-%m-%d %H:%M:%S',
+ '%Y-%m-%d %H:%M',
+ '%Y-%m-%d',
+ '%Y/%m/%d %H:%M:%S',
+ '%Y/%m/%d',
+ '%Y年%m月%d日 %H:%M',
+ '%Y年%m月%d日',
+ ]
+
+ for fmt in date_formats:
+ try:
+ return datetime.strptime(pubdate_str, fmt)
+ except ValueError:
+ continue
+
+ return None
+
+ @classmethod
+ def clean_video_data(cls, raw_data: Dict[str, Any]) -> Optional[WebContent]:
+ """
+ 清洗单条视频数据
+
+ Args:
+ raw_data: API返回的原始数据
+
+ Returns:
+ 清洗后的视频数据对象
+ """
+ try:
+ # 提取并清洗各字段
+ return WebContent(
+ bvid=raw_data.get('bvid', ''),
+ title=cls.clean_title(raw_data.get('title', '')),
+ description=cls.clean_description(raw_data.get('desc', '')),
+ owner_name=raw_data.get('owner', {}).get('name', ''),
+ owner_mid=raw_data.get('owner', {}).get('mid', 0),
+ view_count=cls.parse_view_count(
+ raw_data.get('stat', {}).get('view', 0)
+ ),
+ like_count=raw_data.get('stat', {}).get('like', 0),
+ coin_count=raw_data.get('stat', {}).get('coin', 0),
+ favorite_count=raw_data.get('stat', {}).get('favorite', 0),
+ share_count=raw_data.get('stat', {}).get('share', 0),
+ danmaku_count=raw_data.get('stat', {}).get('danmaku', 0),
+ comment_count=raw_data.get('stat', {}).get('reply', 0),
+ duration_seconds=cls.parse_duration(raw_data.get('duration', 0)),
+ publish_time=cls.parse_publish_time(raw_data.get('pubdate', 0)),
+ tags=raw_data.get('tags', []) if isinstance(raw_data.get('tags'), list) else [],
+ )
+ except Exception as e:
+ logger.error(f"清洗视频数据失败: {e}")
+ return None
+
+ @classmethod
+ def clean_video_list(
+ cls,
+ raw_list: List[Dict[str, Any]]
+ ) -> List[WebContent]:
+ """
+ 清洗视频列表数据
+
+ Args:
+ raw_list: 原始数据列表
+
+ Returns:
+ 清洗后的视频列表
+ """
+ results = []
+ for raw_data in raw_list:
+ video = cls.clean_video_data(raw_data)
+ if video:
+ results.append(video)
+ return results
+
+
+async def data_cleaning_demo():
+ """数据清洗演示"""
+
+ # 模拟API返回的原始数据
+ raw_videos = [
+ {
+ "bvid": "BV1xx411c7mD",
+ "title": "Python爬虫教程 - 从入门到精通",
+ "desc": "本视频介绍Python爬虫的基础知识。\n\n包含以下内容:\n1. 环境搭建\n2. 请求发送\n3. 数据解析",
+ "owner": {"name": "技术UP主", "mid": 12345678},
+ "stat": {
+ "view": "15.6万",
+ "like": 8500,
+ "coin": 3200,
+ "favorite": 12000,
+ "share": 450,
+ "danmaku": 2300,
+ "reply": 680
+ },
+ "duration": "15:30",
+ "pubdate": 1705286400 # Unix时间戳
+ },
+ {
+ "bvid": "BV1yy411c8nM",
+ "title": "数据分析实战 - Python项目",
+ "desc": "使用Python分析热门视频数据。",
+ "owner": {"name": "数据分析师", "mid": 87654321},
+ "stat": {
+ "view": "3.2万",
+ "like": 2100,
+ "coin": 890,
+ "favorite": 4500,
+ "share": 180,
+ "danmaku": 560,
+ "reply": 230
+ },
+ "duration": "1:05:20",
+ "pubdate": "2024-01-10"
+ }
+ ]
+
+ print("原始数据:")
+ for video in raw_videos:
+ print(f" 标题: {video['title']}")
+ print(f" 播放: {video['stat']['view']}")
+ print()
+
+ # 清洗数据
+ cleaner = DataCleaner()
+ cleaned_videos = cleaner.clean_video_list(raw_videos)
+
+ print("清洗后数据:")
+ for video in cleaned_videos:
+ print(f" BV号: {video.bvid}")
+ print(f" 标题: {video.title}")
+ print(f" 播放量: {video.view_count:,}")
+ print(f" 时长: {video.duration_seconds}秒")
+ print(f" 发布时间: {video.publish_time}")
+ print()
+
+
+if __name__ == "__main__":
+ import asyncio
+ asyncio.run(data_cleaning_demo())
+```
+
+### 8.3 数据去重
+
+```python
+import hashlib
+from typing import List, Set
+
+
+class DataDeduplicator:
+ """数据去重器"""
+
+ def __init__(self):
+ self._seen_bvids: Set[str] = set()
+ self._seen_hashes: Set[str] = set()
+
+ def is_duplicate_by_bvid(self, bvid: str) -> bool:
+ """通过BV号判断是否重复"""
+ if bvid in self._seen_bvids:
+ return True
+ self._seen_bvids.add(bvid)
+ return False
+
+ def is_duplicate_by_content(self, title: str, owner_mid: int) -> bool:
+ """
+ 通过内容哈希判断是否重复
+ (用于检测同一UP主发布的相似标题视频)
+ """
+ content = f"{title}|{owner_mid}"
+ content_hash = hashlib.md5(content.encode()).hexdigest()
+
+ if content_hash in self._seen_hashes:
+ return True
+ self._seen_hashes.add(content_hash)
+ return False
+
+ def dedupe_videos(
+ self,
+ videos: List[WebContent]
+ ) -> List[WebContent]:
+ """
+ 去重视频列表
+
+ 优先使用BV号去重,同时检测内容重复
+ """
+ results = []
+
+ for video in videos:
+ # BV号去重
+ if self.is_duplicate_by_bvid(video.bvid):
+ continue
+
+ # 内容去重(可选)
+ if self.is_duplicate_by_content(video.title, video.owner_mid):
+ continue
+
+ results.append(video)
+
+ return results
+
+ def reset(self):
+ """重置去重状态"""
+ self._seen_bvids.clear()
+ self._seen_hashes.clear()
+```
+
+### 8.4 数据质量报告
+
+```python
+from dataclasses import dataclass
+from typing import List
+
+
+@dataclass
+class DataQualityReport:
+ """数据质量报告"""
+ total_count: int # 总记录数
+ valid_count: int # 有效记录数
+ duplicate_count: int # 重复记录数
+ missing_title: int # 缺失标题数
+ missing_owner: int # 缺失UP主数
+ zero_views: int # 零播放数
+ invalid_duration: int # 无效时长数
+ invalid_pubdate: int # 无效发布时间数
+
+ @property
+ def valid_rate(self) -> float:
+ """有效率"""
+ return self.valid_count / self.total_count if self.total_count > 0 else 0
+
+ def __str__(self) -> str:
+ return f"""
+数据质量报告:
+ 总记录数: {self.total_count}
+ 有效记录数: {self.valid_count} ({self.valid_rate:.1%})
+ 重复记录: {self.duplicate_count}
+ 缺失标题: {self.missing_title}
+ 缺失UP主: {self.missing_owner}
+ 零播放: {self.zero_views}
+ 无效时长: {self.invalid_duration}
+ 无效发布时间: {self.invalid_pubdate}
+ """.strip()
+
+
+def generate_quality_report(videos: List[WebContent]) -> DataQualityReport:
+ """生成数据质量报告"""
+ missing_title = sum(1 for v in videos if not v.title)
+ missing_owner = sum(1 for v in videos if not v.owner_name)
+ zero_views = sum(1 for v in videos if v.view_count == 0)
+ invalid_duration = sum(1 for v in videos if v.duration_seconds <= 0)
+ invalid_pubdate = sum(1 for v in videos if v.publish_time is None)
+
+ valid_count = sum(
+ 1 for v in videos
+ if v.title and v.owner_name and v.view_count > 0
+ and v.duration_seconds > 0 and v.publish_time
+ )
+
+ return DataQualityReport(
+ total_count=len(videos),
+ valid_count=valid_count,
+ duplicate_count=0, # 去重后为0
+ missing_title=missing_title,
+ missing_owner=missing_owner,
+ zero_views=zero_views,
+ invalid_duration=invalid_duration,
+ invalid_pubdate=invalid_pubdate,
+ )
+```
+
+---
+
+## 本章小结
+
+本章介绍了数据清洗与预处理的核心技术:
+
+1. **文本清洗**:HTML 移除、空白处理、特殊字符、编码修复
+2. **正则表达式**:常用模式、提取与替换、脱敏处理
+3. **数据去重**:精确去重和模糊去重
+4. **数据标准化**:日期、数值、文本的统一格式
+5. **综合实战**:视频数据清洗、播放量解析、时长转换、发布时间标准化
+
+数据清洗是数据处理流程中的关键环节,干净的数据才能产生有价值的分析结果。
+
+## 下一章预告
+
+下一章我们将学习**数据分析与可视化**,包括使用 pandas 进行数据统计、生成词云、绑制图表等技术。这些技术可以帮助我们从爬取的数据中提取有价值的洞察。
diff --git "a/docs/\347\210\254\350\231\253\350\277\233\344\273\267/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226.md" "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226.md"
new file mode 100644
index 0000000..cd7c314
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226.md"
@@ -0,0 +1,1585 @@
+# 第十章:数据分析与可视化
+
+> 本章将学习如何使用 Python 数据分析和可视化工具处理爬取的数据。我们将掌握 pandas 进行数据统计分析、使用词云展示文本数据、利用 matplotlib 和 pyecharts 生成各类图表,最终实现自动化的数据分析报告生成。
+
+## 10.1 数据分析概述
+
+### 为什么需要数据分析
+
+爬虫获取的原始数据只是第一步,真正的价值在于从数据中提取有用的信息和洞察:
+
+- **发现数据规律**:了解数据的分布、趋势和特征
+- **验证假设**:用数据验证业务假设和决策
+- **支持决策**:为业务决策提供数据依据
+- **数据可视化**:将复杂数据转化为直观的图表
+
+### 数据分析流程
+
+```mermaid
+flowchart LR
+ subgraph 数据准备
+ A[原始数据] --> B[数据清洗]
+ B --> C[数据转换]
+ end
+
+ subgraph 数据分析
+ C --> D[统计分析]
+ C --> E[文本分析]
+ C --> F[时间序列]
+ end
+
+ subgraph 结果输出
+ D --> G[图表可视化]
+ E --> H[词云生成]
+ F --> G
+ G --> I[分析报告]
+ H --> I
+ end
+```
+
+### 主要工具介绍
+
+| 工具 | 用途 | 特点 |
+|------|------|------|
+| pandas | 数据处理和分析 | 功能强大,生态完善 |
+| jieba | 中文分词 | 准确率高,易于使用 |
+| wordcloud | 词云生成 | 可定制性强 |
+| matplotlib | 静态图表 | 功能全面,输出清晰 |
+| pyecharts | 交互式图表 | 效果炫酷,支持 Web |
+
+## 10.2 pandas 数据分析
+
+### DataFrame 基础操作
+
+pandas 是 Python 数据分析的核心库,DataFrame 是其最重要的数据结构:
+
+```python
+import pandas as pd
+
+# 从爬取的数据创建 DataFrame
+data = [
+ {"title": "Python 教程", "views": 15000, "likes": 320, "date": "2024-01-15"},
+ {"title": "爬虫入门", "views": 12000, "likes": 280, "date": "2024-01-16"},
+ {"title": "数据分析", "views": 18000, "likes": 450, "date": "2024-01-17"},
+]
+df = pd.DataFrame(data)
+
+# 基础信息
+print(df.info()) # 列类型和非空数量
+print(df.describe()) # 数值列统计摘要
+
+# 数据选择
+print(df['title']) # 选择单列
+print(df[['title', 'views']]) # 选择多列
+print(df[df['views'] > 14000]) # 条件筛选
+
+# 排序
+print(df.sort_values('views', ascending=False))
+
+# 新增列
+df['engagement'] = df['likes'] / df['views'] * 100
+```
+
+### 数据聚合与分组统计
+
+分组统计是数据分析中最常用的操作:
+
+```python
+# 假设有更多数据
+data = [
+ {"category": "技术", "title": "Python 教程", "views": 15000},
+ {"category": "技术", "title": "爬虫入门", "views": 12000},
+ {"category": "生活", "title": "美食推荐", "views": 8000},
+ {"category": "生活", "title": "旅行日记", "views": 10000},
+ {"category": "技术", "title": "数据分析", "views": 18000},
+]
+df = pd.DataFrame(data)
+
+# 按分类分组统计
+grouped = df.groupby('category')
+print(grouped['views'].sum()) # 各分类总浏览量
+print(grouped['views'].mean()) # 各分类平均浏览量
+print(grouped['views'].agg(['sum', 'mean', 'max', 'min'])) # 多个统计量
+
+# 多列聚合
+print(df.groupby('category').agg({
+ 'views': 'sum',
+ 'title': 'count'
+}).rename(columns={'title': 'article_count'}))
+```
+
+### 数据透视表
+
+数据透视表可以快速进行多维度分析:
+
+```python
+# 更复杂的数据
+data = [
+ {"date": "2024-01", "category": "技术", "platform": "PC", "views": 15000},
+ {"date": "2024-01", "category": "技术", "platform": "Mobile", "views": 8000},
+ {"date": "2024-01", "category": "生活", "platform": "PC", "views": 6000},
+ {"date": "2024-02", "category": "技术", "platform": "PC", "views": 18000},
+ {"date": "2024-02", "category": "生活", "platform": "Mobile", "views": 9000},
+]
+df = pd.DataFrame(data)
+
+# 创建透视表:按日期和分类统计各平台浏览量
+pivot = pd.pivot_table(
+ df,
+ values='views',
+ index='date',
+ columns='category',
+ aggfunc='sum',
+ fill_value=0
+)
+print(pivot)
+
+# 带边距的透视表
+pivot_with_margins = pd.pivot_table(
+ df,
+ values='views',
+ index='date',
+ columns='category',
+ aggfunc='sum',
+ margins=True,
+ margins_name='总计'
+)
+print(pivot_with_margins)
+```
+
+### 时间序列分析
+
+爬虫数据通常包含时间维度,时间序列分析很有价值:
+
+```python
+# 创建时间序列数据
+dates = pd.date_range('2024-01-01', periods=30, freq='D')
+views = [1000 + i * 50 + (i % 7) * 200 for i in range(30)]
+df = pd.DataFrame({'date': dates, 'views': views})
+df.set_index('date', inplace=True)
+
+# 滚动统计(7天移动平均)
+df['rolling_avg'] = df['views'].rolling(window=7).mean()
+
+# 按周统计
+weekly = df.resample('W').agg({
+ 'views': ['sum', 'mean']
+})
+print(weekly)
+
+# 环比增长率
+df['growth_rate'] = df['views'].pct_change() * 100
+```
+
+## 10.3 词云生成
+
+词云是展示文本数据的直观方式,在分析评论、标题等文本数据时非常有用。
+
+### jieba 中文分词
+
+jieba 是最流行的中文分词库:
+
+```python
+import jieba
+import jieba.analyse
+
+# 基础分词
+text = "Python爬虫教程帮助你快速入门数据采集技术"
+words = jieba.lcut(text)
+print(words) # ['Python', '爬虫', '教程', '帮助', '你', '快速', '入门', '数据', '采集', '技术']
+
+# 提取关键词(TF-IDF)
+keywords = jieba.analyse.extract_tags(
+ text,
+ topK=5,
+ withWeight=True
+)
+for word, weight in keywords:
+ print(f"{word}: {weight:.4f}")
+
+# 提取关键词(TextRank)
+keywords_tr = jieba.analyse.textrank(
+ text,
+ topK=5,
+ withWeight=True
+)
+```
+
+### 停用词过滤
+
+停用词是指对文本分析没有实际意义的词(如"的"、"是"、"在"等):
+
+```python
+# 常用中文停用词
+STOPWORDS = {
+ '的', '是', '在', '了', '和', '与', '或', '有', '个', '人',
+ '这', '那', '就', '都', '也', '为', '对', '到', '从', '把',
+ '被', '让', '给', '向', '往', '于', '及', '以', '等', '不',
+ '很', '会', '能', '可', '要', '我', '你', '他', '她', '它',
+}
+
+def filter_stopwords(words: list) -> list:
+ """过滤停用词和单字"""
+ return [
+ w for w in words
+ if w not in STOPWORDS and len(w) > 1
+ ]
+
+# 使用
+text = "这是一个关于Python爬虫的教程,帮助你快速入门"
+words = jieba.lcut(text)
+filtered = filter_stopwords(words)
+print(filtered) # ['Python', '爬虫', '教程', '帮助', '快速', '入门']
+```
+
+### 词云生成
+
+使用 wordcloud 库生成词云:
+
+```python
+from wordcloud import WordCloud
+import matplotlib.pyplot as plt
+
+def generate_wordcloud(
+ text: str,
+ output_path: str = "wordcloud.png",
+ width: int = 800,
+ height: int = 600,
+ background_color: str = "white",
+ font_path: str = None
+):
+ """
+ 生成词云图片
+
+ Args:
+ text: 空格分隔的词语文本
+ output_path: 输出图片路径
+ width: 图片宽度
+ height: 图片高度
+ background_color: 背景颜色
+ font_path: 中文字体路径(必须指定才能显示中文)
+ """
+ # 创建词云对象
+ wc = WordCloud(
+ width=width,
+ height=height,
+ background_color=background_color,
+ font_path=font_path, # 中文需要指定字体
+ max_words=200,
+ max_font_size=100,
+ random_state=42
+ )
+
+ # 生成词云
+ wc.generate(text)
+
+ # 保存图片
+ wc.to_file(output_path)
+
+ # 显示词云
+ plt.figure(figsize=(10, 8))
+ plt.imshow(wc, interpolation='bilinear')
+ plt.axis('off')
+ plt.tight_layout()
+ plt.savefig(output_path.replace('.png', '_display.png'), dpi=150)
+ plt.close()
+
+ return output_path
+
+
+# 完整流程:从文本到词云
+def text_to_wordcloud(texts: list, output_path: str, font_path: str = None):
+ """从文本列表生成词云"""
+ # 1. 分词
+ all_words = []
+ for text in texts:
+ words = jieba.lcut(text)
+ all_words.extend(words)
+
+ # 2. 过滤停用词
+ filtered_words = filter_stopwords(all_words)
+
+ # 3. 统计词频
+ word_freq = {}
+ for word in filtered_words:
+ word_freq[word] = word_freq.get(word, 0) + 1
+
+ # 4. 生成词云文本
+ word_text = ' '.join(filtered_words)
+
+ # 5. 生成词云
+ return generate_wordcloud(word_text, output_path, font_path=font_path)
+```
+
+### 自定义词云形状
+
+可以使用图片作为词云的形状蒙版:
+
+```python
+import numpy as np
+from PIL import Image
+from wordcloud import WordCloud, ImageColorGenerator
+
+def generate_shaped_wordcloud(
+ text: str,
+ mask_image_path: str,
+ output_path: str,
+ font_path: str = None,
+ use_mask_colors: bool = True
+):
+ """
+ 生成自定义形状的词云
+
+ Args:
+ text: 词语文本
+ mask_image_path: 形状蒙版图片路径
+ output_path: 输出路径
+ font_path: 字体路径
+ use_mask_colors: 是否使用蒙版图片的颜色
+ """
+ # 读取蒙版图片
+ mask = np.array(Image.open(mask_image_path))
+
+ # 创建词云
+ wc = WordCloud(
+ mask=mask,
+ background_color="white",
+ font_path=font_path,
+ max_words=500,
+ max_font_size=80,
+ random_state=42,
+ contour_width=1,
+ contour_color='steelblue'
+ )
+
+ wc.generate(text)
+
+ # 使用蒙版颜色
+ if use_mask_colors:
+ image_colors = ImageColorGenerator(mask)
+ wc.recolor(color_func=image_colors)
+
+ wc.to_file(output_path)
+ return output_path
+```
+
+## 10.4 数据可视化
+
+### matplotlib 基础图表
+
+matplotlib 是 Python 最基础的可视化库:
+
+```python
+import matplotlib.pyplot as plt
+import matplotlib
+matplotlib.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS']
+matplotlib.rcParams['axes.unicode_minus'] = False
+
+def plot_line_chart(
+ x_data: list,
+ y_data: list,
+ title: str,
+ xlabel: str,
+ ylabel: str,
+ output_path: str
+):
+ """绘制折线图"""
+ plt.figure(figsize=(10, 6))
+ plt.plot(x_data, y_data, marker='o', linewidth=2, markersize=6)
+ plt.title(title, fontsize=14)
+ plt.xlabel(xlabel, fontsize=12)
+ plt.ylabel(ylabel, fontsize=12)
+ plt.grid(True, alpha=0.3)
+ plt.tight_layout()
+ plt.savefig(output_path, dpi=150)
+ plt.close()
+
+
+def plot_bar_chart(
+ categories: list,
+ values: list,
+ title: str,
+ xlabel: str,
+ ylabel: str,
+ output_path: str,
+ horizontal: bool = False
+):
+ """绘制柱状图"""
+ plt.figure(figsize=(10, 6))
+
+ if horizontal:
+ plt.barh(categories, values, color='steelblue')
+ plt.xlabel(ylabel)
+ plt.ylabel(xlabel)
+ else:
+ plt.bar(categories, values, color='steelblue')
+ plt.xlabel(xlabel)
+ plt.ylabel(ylabel)
+
+ plt.title(title, fontsize=14)
+ plt.tight_layout()
+ plt.savefig(output_path, dpi=150)
+ plt.close()
+
+
+def plot_pie_chart(
+ labels: list,
+ sizes: list,
+ title: str,
+ output_path: str
+):
+ """绘制饼图"""
+ plt.figure(figsize=(10, 8))
+
+ # 突出最大的一块
+ max_idx = sizes.index(max(sizes))
+ explode = [0.05 if i == max_idx else 0 for i in range(len(sizes))]
+
+ plt.pie(
+ sizes,
+ labels=labels,
+ explode=explode,
+ autopct='%1.1f%%',
+ startangle=90,
+ colors=plt.cm.Set3.colors[:len(labels)]
+ )
+ plt.title(title, fontsize=14)
+ plt.axis('equal')
+ plt.tight_layout()
+ plt.savefig(output_path, dpi=150)
+ plt.close()
+
+
+def plot_multi_line_chart(
+ x_data: list,
+ y_data_dict: dict,
+ title: str,
+ xlabel: str,
+ ylabel: str,
+ output_path: str
+):
+ """绘制多条折线图"""
+ plt.figure(figsize=(12, 6))
+
+ for label, y_data in y_data_dict.items():
+ plt.plot(x_data, y_data, marker='o', label=label, linewidth=2)
+
+ plt.title(title, fontsize=14)
+ plt.xlabel(xlabel, fontsize=12)
+ plt.ylabel(ylabel, fontsize=12)
+ plt.legend(loc='best')
+ plt.grid(True, alpha=0.3)
+ plt.tight_layout()
+ plt.savefig(output_path, dpi=150)
+ plt.close()
+```
+
+### pyecharts 交互式图表
+
+pyecharts 可以生成交互式的 HTML 图表:
+
+```python
+from pyecharts.charts import Bar, Line, Pie, WordCloud as PyechartsWordCloud
+from pyecharts import options as opts
+from pyecharts.globals import ThemeType
+
+def create_bar_chart(
+ categories: list,
+ values: list,
+ title: str,
+ output_path: str
+):
+ """创建交互式柱状图"""
+ bar = (
+ Bar(init_opts=opts.InitOpts(theme=ThemeType.LIGHT))
+ .add_xaxis(categories)
+ .add_yaxis("数量", values)
+ .set_global_opts(
+ title_opts=opts.TitleOpts(title=title),
+ toolbox_opts=opts.ToolboxOpts(is_show=True),
+ datazoom_opts=opts.DataZoomOpts(is_show=True)
+ )
+ )
+ bar.render(output_path)
+ return output_path
+
+
+def create_line_chart(
+ x_data: list,
+ y_data_dict: dict,
+ title: str,
+ output_path: str
+):
+ """创建交互式折线图"""
+ line = Line(init_opts=opts.InitOpts(theme=ThemeType.LIGHT))
+ line.add_xaxis(x_data)
+
+ for name, y_data in y_data_dict.items():
+ line.add_yaxis(
+ name,
+ y_data,
+ is_smooth=True,
+ markpoint_opts=opts.MarkPointOpts(
+ data=[
+ opts.MarkPointItem(type_="max", name="最大值"),
+ opts.MarkPointItem(type_="min", name="最小值"),
+ ]
+ ),
+ markline_opts=opts.MarkLineOpts(
+ data=[opts.MarkLineItem(type_="average", name="平均值")]
+ )
+ )
+
+ line.set_global_opts(
+ title_opts=opts.TitleOpts(title=title),
+ tooltip_opts=opts.TooltipOpts(trigger="axis"),
+ toolbox_opts=opts.ToolboxOpts(is_show=True),
+ legend_opts=opts.LegendOpts(is_show=True)
+ )
+ line.render(output_path)
+ return output_path
+
+
+def create_pie_chart(
+ data: list,
+ title: str,
+ output_path: str
+):
+ """
+ 创建交互式饼图
+
+ Args:
+ data: [(name, value), ...] 格式的数据
+ title: 图表标题
+ output_path: 输出路径
+ """
+ pie = (
+ Pie(init_opts=opts.InitOpts(theme=ThemeType.LIGHT))
+ .add(
+ "",
+ data,
+ radius=["30%", "70%"],
+ rosetype="radius"
+ )
+ .set_global_opts(
+ title_opts=opts.TitleOpts(title=title),
+ legend_opts=opts.LegendOpts(
+ orient="vertical",
+ pos_top="15%",
+ pos_left="2%"
+ )
+ )
+ .set_series_opts(
+ label_opts=opts.LabelOpts(formatter="{b}: {d}%")
+ )
+ )
+ pie.render(output_path)
+ return output_path
+
+
+def create_wordcloud_chart(
+ words: list,
+ title: str,
+ output_path: str
+):
+ """
+ 创建交互式词云
+
+ Args:
+ words: [(word, count), ...] 格式的数据
+ title: 图表标题
+ output_path: 输出路径
+ """
+ wc = (
+ PyechartsWordCloud(init_opts=opts.InitOpts(theme=ThemeType.LIGHT))
+ .add(
+ "",
+ words,
+ word_size_range=[20, 100],
+ shape="circle"
+ )
+ .set_global_opts(
+ title_opts=opts.TitleOpts(title=title),
+ toolbox_opts=opts.ToolboxOpts(is_show=True)
+ )
+ )
+ wc.render(output_path)
+ return output_path
+```
+
+## 10.5 数据分析报告生成
+
+### Markdown 报告模板
+
+自动生成 Markdown 格式的分析报告:
+
+```python
+from datetime import datetime
+from typing import Dict, List, Any
+
+class ReportGenerator:
+ """数据分析报告生成器"""
+
+ def __init__(self, title: str, author: str = "数据分析团队"):
+ self.title = title
+ self.author = author
+ self.sections = []
+ self.images = []
+
+ def add_section(self, title: str, content: str):
+ """添加章节"""
+ self.sections.append({
+ "title": title,
+ "content": content
+ })
+
+ def add_image(self, path: str, caption: str):
+ """添加图片"""
+ self.images.append({
+ "path": path,
+ "caption": caption
+ })
+
+ def add_table(self, headers: List[str], rows: List[List[Any]], title: str = ""):
+ """添加表格"""
+ content = ""
+ if title:
+ content += f"**{title}**\n\n"
+
+ # 表头
+ content += "| " + " | ".join(headers) + " |\n"
+ content += "| " + " | ".join(["---"] * len(headers)) + " |\n"
+
+ # 数据行
+ for row in rows:
+ content += "| " + " | ".join(str(cell) for cell in row) + " |\n"
+
+ self.sections.append({
+ "title": "",
+ "content": content
+ })
+
+ def add_summary(self, metrics: Dict[str, Any]):
+ """添加数据摘要"""
+ content = "| 指标 | 数值 |\n| --- | --- |\n"
+ for key, value in metrics.items():
+ content += f"| {key} | {value} |\n"
+
+ self.sections.append({
+ "title": "数据概览",
+ "content": content
+ })
+
+ def generate(self, output_path: str) -> str:
+ """生成报告"""
+ report = []
+
+ # 标题
+ report.append(f"# {self.title}")
+ report.append("")
+
+ # 元信息
+ report.append(f"> 生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+ report.append(f"> 作者:{self.author}")
+ report.append("")
+ report.append("---")
+ report.append("")
+
+ # 目录
+ report.append("## 目录")
+ report.append("")
+ for i, section in enumerate(self.sections, 1):
+ if section["title"]:
+ report.append(f"{i}. [{section['title']}](#{section['title'].lower().replace(' ', '-')})")
+ report.append("")
+ report.append("---")
+ report.append("")
+
+ # 内容
+ for section in self.sections:
+ if section["title"]:
+ report.append(f"## {section['title']}")
+ report.append("")
+ report.append(section["content"])
+ report.append("")
+
+ # 图表
+ if self.images:
+ report.append("## 图表")
+ report.append("")
+ for img in self.images:
+ report.append(f"### {img['caption']}")
+ report.append("")
+ report.append(f"![{img['caption']}]({img['path']})")
+ report.append("")
+
+ # 写入文件
+ content = "\n".join(report)
+ with open(output_path, 'w', encoding='utf-8') as f:
+ f.write(content)
+
+ return output_path
+```
+
+### 完整分析流程示例
+
+```python
+import pandas as pd
+from typing import List, Dict
+
+class DataAnalyzer:
+ """数据分析器"""
+
+ def __init__(self, data: List[Dict]):
+ self.df = pd.DataFrame(data)
+ self.report = ReportGenerator("数据分析报告")
+
+ def basic_statistics(self) -> Dict:
+ """基础统计"""
+ stats = {
+ "总记录数": len(self.df),
+ "时间范围": f"{self.df['date'].min()} ~ {self.df['date'].max()}" if 'date' in self.df else "N/A",
+ }
+
+ # 数值列统计
+ numeric_cols = self.df.select_dtypes(include=['number']).columns
+ for col in numeric_cols:
+ stats[f"{col}_总计"] = self.df[col].sum()
+ stats[f"{col}_平均"] = round(self.df[col].mean(), 2)
+ stats[f"{col}_最大"] = self.df[col].max()
+ stats[f"{col}_最小"] = self.df[col].min()
+
+ return stats
+
+ def category_analysis(self, category_col: str, value_col: str) -> pd.DataFrame:
+ """分类分析"""
+ return self.df.groupby(category_col)[value_col].agg([
+ 'count', 'sum', 'mean', 'max', 'min'
+ ]).round(2)
+
+ def time_series_analysis(self, date_col: str, value_col: str, freq: str = 'D') -> pd.DataFrame:
+ """时间序列分析"""
+ df_ts = self.df.copy()
+ df_ts[date_col] = pd.to_datetime(df_ts[date_col])
+ df_ts.set_index(date_col, inplace=True)
+
+ return df_ts.resample(freq)[value_col].agg(['sum', 'mean', 'count']).round(2)
+
+ def generate_report(self, output_dir: str) -> str:
+ """生成完整分析报告"""
+ import os
+ os.makedirs(output_dir, exist_ok=True)
+
+ # 1. 基础统计
+ stats = self.basic_statistics()
+ self.report.add_summary(stats)
+
+ # 2. 添加描述性文字
+ self.report.add_section(
+ "分析说明",
+ f"本报告对 {len(self.df)} 条数据进行了分析,"
+ f"包含 {len(self.df.columns)} 个字段。"
+ )
+
+ # 3. 生成图表(如果有相应字段)
+ numeric_cols = self.df.select_dtypes(include=['number']).columns.tolist()
+ if numeric_cols:
+ # 柱状图
+ col = numeric_cols[0]
+ if len(self.df) <= 20:
+ bar_path = os.path.join(output_dir, "bar_chart.png")
+ plot_bar_chart(
+ self.df.index.tolist() if self.df.index.dtype != 'int64' else list(range(len(self.df))),
+ self.df[col].tolist(),
+ f"{col} 分布",
+ "索引",
+ col,
+ bar_path
+ )
+ self.report.add_image(bar_path, f"{col} 分布柱状图")
+
+ # 4. 生成报告
+ report_path = os.path.join(output_dir, "report.md")
+ return self.report.generate(report_path)
+```
+
+## 10.6 实战案例:社交媒体评论分析
+
+让我们综合运用本章所学,对爬取的社交媒体评论数据进行完整分析:
+
+```python
+"""
+社交媒体评论数据分析
+"""
+import os
+import json
+from datetime import datetime
+from typing import List, Dict
+from collections import Counter
+
+import pandas as pd
+import jieba
+from wordcloud import WordCloud
+import matplotlib.pyplot as plt
+import matplotlib
+matplotlib.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS']
+matplotlib.rcParams['axes.unicode_minus'] = False
+
+
+class CommentAnalyzer:
+ """评论数据分析器"""
+
+ STOPWORDS = {
+ '的', '是', '在', '了', '和', '与', '或', '有', '个', '人',
+ '这', '那', '就', '都', '也', '为', '对', '到', '从', '把',
+ '被', '让', '给', '向', '往', '于', '及', '以', '等', '不',
+ '很', '会', '能', '可', '要', '我', '你', '他', '她', '它',
+ '啊', '吧', '呢', '呀', '哦', '嗯', '哈', '嘿',
+ }
+
+ def __init__(self, comments: List[Dict], output_dir: str = "./analysis_output"):
+ """
+ Args:
+ comments: 评论数据列表,每条评论包含 content, user, time, likes 等字段
+ output_dir: 输出目录
+ """
+ self.comments = comments
+ self.df = pd.DataFrame(comments)
+ self.output_dir = output_dir
+ os.makedirs(output_dir, exist_ok=True)
+
+ def analyze_basic_stats(self) -> Dict:
+ """基础统计分析"""
+ stats = {
+ "total_comments": len(self.df),
+ "unique_users": self.df['user'].nunique() if 'user' in self.df else 0,
+ "total_likes": self.df['likes'].sum() if 'likes' in self.df else 0,
+ "avg_likes": round(self.df['likes'].mean(), 2) if 'likes' in self.df else 0,
+ "avg_length": round(self.df['content'].str.len().mean(), 2),
+ }
+
+ # 时间分析
+ if 'time' in self.df:
+ self.df['time'] = pd.to_datetime(self.df['time'], errors='coerce')
+ valid_times = self.df['time'].dropna()
+ if len(valid_times) > 0:
+ stats["time_range"] = f"{valid_times.min()} ~ {valid_times.max()}"
+
+ return stats
+
+ def analyze_word_frequency(self, top_n: int = 50) -> List[tuple]:
+ """词频分析"""
+ all_words = []
+
+ for content in self.df['content']:
+ words = jieba.lcut(str(content))
+ # 过滤停用词和单字
+ words = [
+ w for w in words
+ if w not in self.STOPWORDS and len(w) > 1
+ ]
+ all_words.extend(words)
+
+ word_freq = Counter(all_words)
+ return word_freq.most_common(top_n)
+
+ def generate_wordcloud(self, font_path: str = None) -> str:
+ """生成词云"""
+ word_freq = self.analyze_word_frequency(200)
+
+ # 转换为空格分隔的文本
+ text = ' '.join([word for word, _ in word_freq for _ in range(word_freq[0][1])])
+
+ # 直接使用词频
+ freq_dict = dict(word_freq)
+
+ wc = WordCloud(
+ width=1200,
+ height=800,
+ background_color='white',
+ font_path=font_path,
+ max_words=200,
+ max_font_size=150,
+ random_state=42
+ )
+
+ wc.generate_from_frequencies(freq_dict)
+
+ output_path = os.path.join(self.output_dir, "wordcloud.png")
+ wc.to_file(output_path)
+
+ return output_path
+
+ def analyze_time_distribution(self) -> str:
+ """时间分布分析"""
+ if 'time' not in self.df:
+ return None
+
+ df = self.df.copy()
+ df['time'] = pd.to_datetime(df['time'], errors='coerce')
+ df = df.dropna(subset=['time'])
+
+ if len(df) == 0:
+ return None
+
+ # 按小时统计
+ df['hour'] = df['time'].dt.hour
+ hour_dist = df.groupby('hour').size()
+
+ # 绘图
+ plt.figure(figsize=(12, 6))
+ plt.bar(hour_dist.index, hour_dist.values, color='steelblue')
+ plt.title('评论时间分布(按小时)', fontsize=14)
+ plt.xlabel('小时', fontsize=12)
+ plt.ylabel('评论数', fontsize=12)
+ plt.xticks(range(0, 24))
+ plt.grid(axis='y', alpha=0.3)
+ plt.tight_layout()
+
+ output_path = os.path.join(self.output_dir, "time_distribution.png")
+ plt.savefig(output_path, dpi=150)
+ plt.close()
+
+ return output_path
+
+ def analyze_user_activity(self, top_n: int = 10) -> str:
+ """用户活跃度分析"""
+ if 'user' not in self.df:
+ return None
+
+ user_counts = self.df['user'].value_counts().head(top_n)
+
+ plt.figure(figsize=(12, 6))
+ plt.barh(range(len(user_counts)), user_counts.values, color='steelblue')
+ plt.yticks(range(len(user_counts)), user_counts.index)
+ plt.title(f'评论最多的 {top_n} 位用户', fontsize=14)
+ plt.xlabel('评论数', fontsize=12)
+ plt.ylabel('用户', fontsize=12)
+ plt.gca().invert_yaxis()
+ plt.tight_layout()
+
+ output_path = os.path.join(self.output_dir, "user_activity.png")
+ plt.savefig(output_path, dpi=150)
+ plt.close()
+
+ return output_path
+
+ def generate_report(self, font_path: str = None) -> str:
+ """生成完整分析报告"""
+ report_lines = []
+
+ # 标题
+ report_lines.append("# 社交媒体评论分析报告")
+ report_lines.append("")
+ report_lines.append(f"> 生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+ report_lines.append("")
+ report_lines.append("---")
+ report_lines.append("")
+
+ # 基础统计
+ stats = self.analyze_basic_stats()
+ report_lines.append("## 1. 基础统计")
+ report_lines.append("")
+ report_lines.append("| 指标 | 数值 |")
+ report_lines.append("| --- | --- |")
+ report_lines.append(f"| 评论总数 | {stats['total_comments']} |")
+ report_lines.append(f"| 独立用户数 | {stats['unique_users']} |")
+ report_lines.append(f"| 点赞总数 | {stats['total_likes']} |")
+ report_lines.append(f"| 平均点赞数 | {stats['avg_likes']} |")
+ report_lines.append(f"| 平均评论长度 | {stats['avg_length']} 字 |")
+ if 'time_range' in stats:
+ report_lines.append(f"| 时间范围 | {stats['time_range']} |")
+ report_lines.append("")
+
+ # 词频分析
+ report_lines.append("## 2. 热门词汇 TOP 20")
+ report_lines.append("")
+ word_freq = self.analyze_word_frequency(20)
+ report_lines.append("| 排名 | 词汇 | 出现次数 |")
+ report_lines.append("| --- | --- | --- |")
+ for i, (word, count) in enumerate(word_freq, 1):
+ report_lines.append(f"| {i} | {word} | {count} |")
+ report_lines.append("")
+
+ # 生成图表
+ report_lines.append("## 3. 可视化图表")
+ report_lines.append("")
+
+ # 词云
+ wordcloud_path = self.generate_wordcloud(font_path)
+ report_lines.append("### 3.1 词云图")
+ report_lines.append("")
+ report_lines.append(f"})")
+ report_lines.append("")
+
+ # 时间分布
+ time_path = self.analyze_time_distribution()
+ if time_path:
+ report_lines.append("### 3.2 评论时间分布")
+ report_lines.append("")
+ report_lines.append(f"})")
+ report_lines.append("")
+
+ # 用户活跃度
+ user_path = self.analyze_user_activity()
+ if user_path:
+ report_lines.append("### 3.3 用户活跃度")
+ report_lines.append("")
+ report_lines.append(f"})")
+ report_lines.append("")
+
+ # 写入报告
+ report_content = "\n".join(report_lines)
+ report_path = os.path.join(self.output_dir, "analysis_report.md")
+ with open(report_path, 'w', encoding='utf-8') as f:
+ f.write(report_content)
+
+ print(f"分析报告已生成: {report_path}")
+ return report_path
+
+
+def demo():
+ """演示分析功能"""
+ # 模拟评论数据
+ comments = [
+ {"content": "Python真的很好学,推荐这个教程!", "user": "user1", "time": "2024-01-15 10:30:00", "likes": 15},
+ {"content": "爬虫入门很有用,学到了很多知识", "user": "user2", "time": "2024-01-15 11:20:00", "likes": 8},
+ {"content": "数据分析这章讲得很详细,点赞", "user": "user3", "time": "2024-01-15 14:45:00", "likes": 12},
+ {"content": "希望能出更多进阶内容,继续学习Python", "user": "user1", "time": "2024-01-15 16:00:00", "likes": 5},
+ {"content": "词云效果很酷,学会了!", "user": "user4", "time": "2024-01-15 18:30:00", "likes": 20},
+ {"content": "教程质量很高,适合入门学习", "user": "user5", "time": "2024-01-15 20:15:00", "likes": 10},
+ {"content": "Python数据分析真的很强大", "user": "user6", "time": "2024-01-16 09:00:00", "likes": 7},
+ {"content": "爬虫技术学起来很有趣", "user": "user7", "time": "2024-01-16 10:30:00", "likes": 9},
+ ]
+
+ # 创建分析器
+ analyzer = CommentAnalyzer(comments, "./demo_output")
+
+ # 生成报告
+ report_path = analyzer.generate_report()
+ print(f"报告已保存到: {report_path}")
+
+
+if __name__ == "__main__":
+ demo()
+```
+
+## 10.7 视频数据分析实战
+
+本节以视频数据为例,演示完整的数据分析流程。
+
+### 数据分析目标
+
+```mermaid
+flowchart TD
+ subgraph 输入数据
+ videos[视频数据]
+ comments[弹幕/评论数据]
+ end
+
+ subgraph 分析维度
+ stat[播放量/互动统计]
+ up[UP主分析]
+ time[发布时间分析]
+ keyword[标题关键词]
+ end
+
+ subgraph 输出
+ rank[热门视频排行]
+ trend[发布趋势图]
+ cloud[标题词云]
+ report[分析报告]
+ end
+
+ videos --> stat --> rank
+ videos --> up --> rank
+ videos --> time --> trend
+ videos --> keyword --> cloud
+ rank --> report
+ trend --> report
+ cloud --> report
+```
+
+### 视频数据分析器
+
+```python
+import os
+import pandas as pd
+import jieba
+from collections import Counter
+from datetime import datetime
+from typing import List, Dict, Any, Optional
+from dataclasses import dataclass
+from wordcloud import WordCloud
+import matplotlib.pyplot as plt
+import matplotlib
+matplotlib.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'PingFang SC']
+matplotlib.rcParams['axes.unicode_minus'] = False
+
+
+@dataclass
+class VideoData:
+ """视频数据模型"""
+ bvid: str
+ title: str
+ owner_name: str
+ owner_mid: int
+ view_count: int
+ like_count: int
+ coin_count: int
+ favorite_count: int
+ share_count: int
+ danmaku_count: int
+ comment_count: int
+ duration_seconds: int
+ publish_time: datetime
+ tags: List[str]
+
+
+class VideoAnalyzer:
+ """视频数据分析器"""
+
+ # 常用停用词
+ STOPWORDS = {
+ '的', '是', '在', '了', '和', '与', '或', '有', '个', '人',
+ '这', '那', '就', '都', '也', '为', '对', '到', '从', '把',
+ '被', '让', '给', '向', '往', '于', '及', '以', '等', '不',
+ '很', '会', '能', '可', '要', '我', '你', '他', '她', '它',
+ '视频', '合集', '第一', '第二', '第三', '更新', '最新',
+ }
+
+ def __init__(self, videos: List[VideoData], output_dir: str = "./video_analysis"):
+ """
+ Args:
+ videos: 视频数据列表
+ output_dir: 输出目录
+ """
+ self.videos = videos
+ self.output_dir = output_dir
+ os.makedirs(output_dir, exist_ok=True)
+
+ # 转换为DataFrame便于分析
+ self.df = pd.DataFrame([
+ {
+ 'bvid': v.bvid,
+ 'title': v.title,
+ 'owner_name': v.owner_name,
+ 'owner_mid': v.owner_mid,
+ 'view_count': v.view_count,
+ 'like_count': v.like_count,
+ 'coin_count': v.coin_count,
+ 'favorite_count': v.favorite_count,
+ 'share_count': v.share_count,
+ 'danmaku_count': v.danmaku_count,
+ 'comment_count': v.comment_count,
+ 'duration_seconds': v.duration_seconds,
+ 'publish_time': v.publish_time,
+ }
+ for v in videos
+ ])
+
+ def basic_statistics(self) -> Dict[str, Any]:
+ """基础统计分析"""
+ stats = {
+ "总视频数": len(self.df),
+ "独立UP主数": self.df['owner_mid'].nunique(),
+ "总播放量": f"{self.df['view_count'].sum():,}",
+ "平均播放量": f"{self.df['view_count'].mean():,.0f}",
+ "最高播放量": f"{self.df['view_count'].max():,}",
+ "总点赞数": f"{self.df['like_count'].sum():,}",
+ "总投币数": f"{self.df['coin_count'].sum():,}",
+ "总收藏数": f"{self.df['favorite_count'].sum():,}",
+ "平均时长": f"{self.df['duration_seconds'].mean() / 60:.1f} 分钟",
+ }
+
+ # 计算互动率
+ if self.df['view_count'].sum() > 0:
+ engagement_rate = (
+ self.df['like_count'].sum() +
+ self.df['coin_count'].sum() +
+ self.df['favorite_count'].sum()
+ ) / self.df['view_count'].sum() * 100
+ stats["平均互动率"] = f"{engagement_rate:.2f}%"
+
+ return stats
+
+ def top_videos_by_views(self, top_n: int = 10) -> pd.DataFrame:
+ """播放量TOP N视频"""
+ return self.df.nlargest(top_n, 'view_count')[
+ ['title', 'owner_name', 'view_count', 'like_count', 'publish_time']
+ ]
+
+ def top_uploaders(self, top_n: int = 10) -> pd.DataFrame:
+ """最活跃UP主(视频数量)"""
+ uploader_stats = self.df.groupby(['owner_mid', 'owner_name']).agg({
+ 'bvid': 'count',
+ 'view_count': 'sum',
+ 'like_count': 'sum'
+ }).reset_index()
+
+ uploader_stats.columns = ['owner_mid', 'owner_name', 'video_count', 'total_views', 'total_likes']
+ return uploader_stats.nlargest(top_n, 'video_count')
+
+ def analyze_publish_time(self) -> Dict[str, pd.Series]:
+ """发布时间分析"""
+ df = self.df.copy()
+ df['publish_time'] = pd.to_datetime(df['publish_time'])
+
+ # 按小时分布
+ df['hour'] = df['publish_time'].dt.hour
+ hourly = df.groupby('hour')['bvid'].count()
+
+ # 按星期分布
+ df['weekday'] = df['publish_time'].dt.dayofweek
+ weekday_names = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
+ daily = df.groupby('weekday')['bvid'].count()
+ daily.index = [weekday_names[i] for i in daily.index]
+
+ # 按日期趋势
+ df['date'] = df['publish_time'].dt.date
+ date_trend = df.groupby('date')['bvid'].count()
+
+ return {
+ 'hourly': hourly,
+ 'daily': daily,
+ 'date_trend': date_trend
+ }
+
+ def analyze_title_keywords(self, top_n: int = 50) -> List[tuple]:
+ """标题关键词分析"""
+ all_words = []
+
+ for title in self.df['title']:
+ words = jieba.lcut(str(title))
+ words = [
+ w.strip() for w in words
+ if w.strip() and w not in self.STOPWORDS and len(w) > 1
+ ]
+ all_words.extend(words)
+
+ return Counter(all_words).most_common(top_n)
+
+ def generate_views_distribution_chart(self) -> str:
+ """生成播放量分布图"""
+ fig, axes = plt.subplots(1, 2, figsize=(14, 5))
+
+ # 播放量直方图
+ axes[0].hist(self.df['view_count'], bins=30, color='steelblue', edgecolor='white')
+ axes[0].set_title('播放量分布', fontsize=14)
+ axes[0].set_xlabel('播放量')
+ axes[0].set_ylabel('视频数')
+
+ # 播放量箱线图(对数尺度)
+ import numpy as np
+ log_views = np.log10(self.df['view_count'] + 1)
+ axes[1].boxplot(log_views)
+ axes[1].set_title('播放量分布(对数)', fontsize=14)
+ axes[1].set_ylabel('log10(播放量)')
+
+ plt.tight_layout()
+ output_path = os.path.join(self.output_dir, 'views_distribution.png')
+ plt.savefig(output_path, dpi=150)
+ plt.close()
+
+ return output_path
+
+ def generate_publish_time_chart(self) -> str:
+ """生成发布时间分析图"""
+ time_data = self.analyze_publish_time()
+
+ fig, axes = plt.subplots(2, 2, figsize=(14, 10))
+
+ # 小时分布
+ axes[0, 0].bar(time_data['hourly'].index, time_data['hourly'].values, color='steelblue')
+ axes[0, 0].set_title('发布时间分布(按小时)', fontsize=12)
+ axes[0, 0].set_xlabel('小时')
+ axes[0, 0].set_ylabel('视频数')
+ axes[0, 0].set_xticks(range(0, 24, 2))
+
+ # 星期分布
+ axes[0, 1].bar(time_data['daily'].index, time_data['daily'].values, color='coral')
+ axes[0, 1].set_title('发布时间分布(按星期)', fontsize=12)
+ axes[0, 1].set_xlabel('星期')
+ axes[0, 1].set_ylabel('视频数')
+
+ # 日期趋势
+ axes[1, 0].plot(time_data['date_trend'].index, time_data['date_trend'].values,
+ marker='o', markersize=3, linewidth=1, color='green')
+ axes[1, 0].set_title('发布趋势', fontsize=12)
+ axes[1, 0].set_xlabel('日期')
+ axes[1, 0].set_ylabel('视频数')
+ axes[1, 0].tick_params(axis='x', rotation=45)
+
+ # 互动数据对比
+ interaction_data = {
+ '点赞': self.df['like_count'].sum(),
+ '投币': self.df['coin_count'].sum(),
+ '收藏': self.df['favorite_count'].sum(),
+ '分享': self.df['share_count'].sum(),
+ }
+ axes[1, 1].bar(interaction_data.keys(), interaction_data.values(), color='purple')
+ axes[1, 1].set_title('互动数据汇总', fontsize=12)
+ axes[1, 1].set_ylabel('总数')
+
+ plt.tight_layout()
+ output_path = os.path.join(self.output_dir, 'publish_time_analysis.png')
+ plt.savefig(output_path, dpi=150)
+ plt.close()
+
+ return output_path
+
+ def generate_title_wordcloud(self, font_path: Optional[str] = None) -> str:
+ """生成标题词云"""
+ keywords = self.analyze_title_keywords(200)
+ freq_dict = dict(keywords)
+
+ wc = WordCloud(
+ width=1200,
+ height=800,
+ background_color='white',
+ font_path=font_path,
+ max_words=200,
+ max_font_size=150,
+ random_state=42,
+ colormap='viridis'
+ )
+
+ wc.generate_from_frequencies(freq_dict)
+
+ output_path = os.path.join(self.output_dir, 'title_wordcloud.png')
+ wc.to_file(output_path)
+
+ return output_path
+
+ def generate_up_ranking_chart(self, top_n: int = 15) -> str:
+ """生成UP主排行图"""
+ top_ups = self.top_uploaders(top_n)
+
+ fig, axes = plt.subplots(1, 2, figsize=(14, 6))
+
+ # 视频数量排行
+ axes[0].barh(range(len(top_ups)), top_ups['video_count'].values, color='steelblue')
+ axes[0].set_yticks(range(len(top_ups)))
+ axes[0].set_yticklabels(top_ups['owner_name'].values)
+ axes[0].set_title(f'视频数量 TOP {top_n} UP主', fontsize=12)
+ axes[0].set_xlabel('视频数')
+ axes[0].invert_yaxis()
+
+ # 总播放量排行
+ top_by_views = self.df.groupby(['owner_mid', 'owner_name'])['view_count'].sum().reset_index()
+ top_by_views = top_by_views.nlargest(top_n, 'view_count')
+
+ axes[1].barh(range(len(top_by_views)), top_by_views['view_count'].values, color='coral')
+ axes[1].set_yticks(range(len(top_by_views)))
+ axes[1].set_yticklabels(top_by_views['owner_name'].values)
+ axes[1].set_title(f'总播放量 TOP {top_n} UP主', fontsize=12)
+ axes[1].set_xlabel('总播放量')
+ axes[1].invert_yaxis()
+
+ plt.tight_layout()
+ output_path = os.path.join(self.output_dir, 'up_ranking.png')
+ plt.savefig(output_path, dpi=150)
+ plt.close()
+
+ return output_path
+
+ def generate_report(self, font_path: Optional[str] = None) -> str:
+ """生成完整分析报告"""
+ report_lines = []
+
+ # 标题
+ report_lines.append("# 视频数据分析报告")
+ report_lines.append("")
+ report_lines.append(f"> 生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+ report_lines.append(f"> 数据量:{len(self.videos)} 条视频")
+ report_lines.append("")
+ report_lines.append("---")
+ report_lines.append("")
+
+ # 基础统计
+ stats = self.basic_statistics()
+ report_lines.append("## 1. 基础统计")
+ report_lines.append("")
+ report_lines.append("| 指标 | 数值 |")
+ report_lines.append("| --- | --- |")
+ for key, value in stats.items():
+ report_lines.append(f"| {key} | {value} |")
+ report_lines.append("")
+
+ # 热门视频排行
+ report_lines.append("## 2. 播放量 TOP 10 视频")
+ report_lines.append("")
+ top_videos = self.top_videos_by_views(10)
+ report_lines.append("| 排名 | 标题 | UP主 | 播放量 | 点赞数 |")
+ report_lines.append("| --- | --- | --- | --- | --- |")
+ for i, (_, row) in enumerate(top_videos.iterrows(), 1):
+ title = row['title'][:30] + '...' if len(row['title']) > 30 else row['title']
+ report_lines.append(
+ f"| {i} | {title} | {row['owner_name']} | "
+ f"{row['view_count']:,} | {row['like_count']:,} |"
+ )
+ report_lines.append("")
+
+ # 关键词排行
+ report_lines.append("## 3. 标题热门关键词 TOP 20")
+ report_lines.append("")
+ keywords = self.analyze_title_keywords(20)
+ report_lines.append("| 排名 | 关键词 | 出现次数 |")
+ report_lines.append("| --- | --- | --- |")
+ for i, (word, count) in enumerate(keywords, 1):
+ report_lines.append(f"| {i} | {word} | {count} |")
+ report_lines.append("")
+
+ # 生成图表
+ report_lines.append("## 4. 数据可视化")
+ report_lines.append("")
+
+ # 播放量分布
+ views_chart = self.generate_views_distribution_chart()
+ report_lines.append("### 4.1 播放量分布")
+ report_lines.append(f"})")
+ report_lines.append("")
+
+ # 发布时间分析
+ time_chart = self.generate_publish_time_chart()
+ report_lines.append("### 4.2 发布时间分析")
+ report_lines.append(f"})")
+ report_lines.append("")
+
+ # UP主排行
+ up_chart = self.generate_up_ranking_chart()
+ report_lines.append("### 4.3 UP主排行")
+ report_lines.append(f"})")
+ report_lines.append("")
+
+ # 标题词云
+ wordcloud_path = self.generate_title_wordcloud(font_path)
+ report_lines.append("### 4.4 标题词云")
+ report_lines.append(f"})")
+ report_lines.append("")
+
+ # 写入报告
+ report_content = "\n".join(report_lines)
+ report_path = os.path.join(self.output_dir, "video_analysis_report.md")
+ with open(report_path, 'w', encoding='utf-8') as f:
+ f.write(report_content)
+
+ print(f"数据分析报告已生成: {report_path}")
+ return report_path
+
+
+async def video_analysis_demo():
+ """视频数据分析演示"""
+ from datetime import timedelta
+ import random
+
+ # 模拟视频数据
+ sample_titles = [
+ "Python爬虫从入门到精通",
+ "数据分析实战教程",
+ "机器学习入门指南",
+ "数据可视化技巧分享",
+ "Web开发最佳实践",
+ "深度学习PyTorch教程",
+ "数据清洗与预处理",
+ "API接口设计规范",
+ ]
+
+ sample_ups = [
+ ("技术UP主A", 12345678),
+ ("数据分析师B", 23456789),
+ ("Python教学C", 34567890),
+ ("编程达人D", 45678901),
+ ]
+
+ videos = []
+ base_time = datetime.now() - timedelta(days=30)
+
+ for i in range(50):
+ up_name, up_mid = random.choice(sample_ups)
+ video = VideoData(
+ bvid=f"BV1{''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=10))}",
+ title=f"{random.choice(sample_titles)} 第{i+1}集",
+ owner_name=up_name,
+ owner_mid=up_mid,
+ view_count=random.randint(1000, 500000),
+ like_count=random.randint(100, 20000),
+ coin_count=random.randint(50, 5000),
+ favorite_count=random.randint(100, 10000),
+ share_count=random.randint(10, 1000),
+ danmaku_count=random.randint(50, 5000),
+ comment_count=random.randint(20, 2000),
+ duration_seconds=random.randint(60, 3600),
+ publish_time=base_time + timedelta(
+ days=random.randint(0, 30),
+ hours=random.randint(0, 23)
+ ),
+ tags=[]
+ )
+ videos.append(video)
+
+ # 创建分析器并生成报告
+ analyzer = VideoAnalyzer(videos, "./video_demo_output")
+ report_path = analyzer.generate_report()
+
+ print(f"演示报告已生成: {report_path}")
+
+
+if __name__ == "__main__":
+ import asyncio
+ asyncio.run(video_analysis_demo())
+```
+
+---
+
+## 本章小结
+
+本章我们学习了数据分析与可视化的核心技术:
+
+1. **pandas 数据分析**
+ - DataFrame 是数据分析的核心数据结构
+ - groupby 实现分组聚合统计
+ - pivot_table 进行多维分析
+ - 时间序列支持滚动统计和重采样
+
+2. **词云生成**
+ - jieba 实现中文分词
+ - 停用词过滤提升分析质量
+ - wordcloud 库生成静态词云
+ - 支持自定义形状和颜色
+
+3. **数据可视化**
+ - matplotlib 生成静态图表
+ - pyecharts 生成交互式图表
+ - 选择合适的图表类型展示数据
+
+4. **自动化报告**
+ - Markdown 格式便于阅读和分享
+ - 模板化生成提高效率
+ - 图表嵌入增强可读性
+
+5. **综合实战**
+ - 视频数据多维统计分析
+ - 发布时间规律分析
+ - UP主活跃度排名
+ - 标题关键词词云
+
+**关键要点:**
+- 数据分析的目标是发现洞察,而非展示技术
+- 选择合适的可视化方式,让数据"说话"
+- 自动化报告生成可以大大提高效率
+- 中文分析需要注意字体和编码问题
+
+---
+
+## 下一章预告
+
+在最后一章「进阶综合实战项目」中,我们将综合运用整个进阶教程所学的所有技术,实现一个完整的社交媒体数据采集与分析工具。该项目将包含:
+
+- 多种登录方式支持
+- 反检测浏览器自动化
+- 代理 IP 轮换
+- 数据清洗和存储
+- 词云和图表分析
+
+敬请期待!
diff --git "a/docs/\347\210\254\350\231\253\350\277\233\344\273\267/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256.md" "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256.md"
new file mode 100644
index 0000000..07ec32e
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256.md"
@@ -0,0 +1,2075 @@
+# 第十一章:进阶综合实战项目
+
+> 本章将综合运用整个进阶教程所学的所有技术,以 **B站(bilibili.com)** 为目标,实现一个完整的视频数据采集与分析工具。该项目将包含登录认证、API 签名、浏览器自动化、数据存储、分析报告等完整功能链路。
+
+## 11.1 项目概述
+
+### 项目目标
+
+构建一个类似 [MediaCrawler](https://github.com/NanmiCoder/MediaCrawler) 简化版的视频数据采集工具,以 **B站** 为目标平台,具备以下能力:
+
+- **登录认证**:支持扫码登录和 Cookie 登录
+- **API 签名**:实现 B站 WBI 签名算法
+- **视频搜索**:按关键词搜索视频
+- **视频详情**:获取完整视频信息(播放量、点赞、收藏等)
+- **数据存储**:支持 JSON、CSV 两种存储方式
+- **数据分析**:自动生成词云和统计报告
+
+> **目标网站说明:**
+> - 网站:https://www.bilibili.com
+> - 类型:国内最大的视频社区平台
+> - 特点:需要登录获取完整数据,API 有 WBI 签名保护
+> - 数据:视频标题、UP主、播放量、点赞、收藏、弹幕数等
+
+### 整体架构图
+
+在开始编码之前,让我们先从宏观角度理解整个项目的架构:
+
+```mermaid
+graph TB
+ subgraph 入口层
+ main["main.py
程序入口"]
+ end
+
+ subgraph 核心模块
+ config["config 配置
settings.py
bilibili_config.py"]
+ crawler["crawler 爬虫
spider.py
核心调度"]
+ store["store 存储
backend.py
JSON/CSV"]
+ end
+
+ subgraph 功能模块
+ login["login 登录
auth.py
扫码/Cookie"]
+ client["client 客户端
bilibili_client.py
API请求"]
+ analysis["analysis 分析
report.py
词云/统计"]
+ end
+
+ subgraph 基础模块
+ core["core 浏览器
browser.py
Playwright封装"]
+ tools["tools 工具
sign.py
WBI签名"]
+ models["models 模型
bilibili.py
数据结构"]
+ end
+
+ main --> config
+ main --> crawler
+ main --> store
+ main --> analysis
+
+ crawler --> login
+ crawler --> client
+
+ login --> core
+ client --> tools
+ tools --> models
+
+ config -.->|配置注入| crawler
+ crawler -->|数据| store
+```
+
+### 爬虫执行流程
+
+整个爬虫从启动到完成的完整流程如下:
+
+```mermaid
+flowchart TD
+ start([程序启动 main.py]) --> step1
+
+ subgraph step1 [步骤1: 初始化浏览器]
+ browser["BrowserManager.start()
启动 Playwright
创建 BrowserContext"]
+ end
+
+ step1 --> step2
+
+ subgraph step2 [步骤2: 登录认证]
+ check{检查登录状态}
+ check -->|已登录| skip[跳过登录]
+ check -->|未登录| login_flow
+ subgraph login_flow [执行登录]
+ qrcode["扫码登录: 显示二维码-等待扫码-获取Cookie"]
+ cookie["Cookie登录: 注入Cookie-验证有效性"]
+ end
+ end
+
+ step2 --> step3
+
+ subgraph step3 [步骤3: 初始化API客户端]
+ init["同步Cookie到httpx
获取WBI签名密钥
初始化签名器"]
+ end
+
+ step3 --> step4
+
+ subgraph step4 [步骤4: 执行爬取任务]
+ mode{爬取模式}
+ mode -->|SEARCH| search["关键词搜索-翻页获取-获取详情"]
+ mode -->|DETAIL| detail[直接获取指定视频详情]
+ search --> request
+ detail --> request
+ request["每次请求:
构造参数-WBI签名-发送请求-解析响应-随机延迟"]
+ end
+
+ step4 --> step5
+
+ subgraph step5 [步骤5: 数据存储]
+ save["BilibiliVideo对象列表
转换为字典
保存为 JSON/CSV"]
+ end
+
+ step5 --> step6
+
+ subgraph step6 [步骤6: 生成分析报告]
+ report["统计分析-词频统计
生成词云-输出Markdown报告"]
+ end
+
+ step6 --> step7
+
+ subgraph step7 [步骤7: 清理资源]
+ cleanup["关闭浏览器
保存登录状态
程序退出"]
+ end
+
+ step7 --> finish([完成])
+```
+
+### 数据流向图
+
+理解数据在各模块之间如何流转:
+
+```mermaid
+flowchart LR
+ subgraph input [用户输入]
+ keyword["关键词
配置文件"]
+ end
+
+ subgraph process [系统处理]
+ search["搜索API
(WBI签名)"]
+ detail["详情API
(获取详情)"]
+ validate["Pydantic数据验证
BilibiliVideo"]
+ end
+
+ subgraph output [最终输出]
+ json["JSON文件
(结构化)"]
+ csv["CSV文件
(表格化)"]
+ report["分析报告
(Markdown)"]
+ wordcloud["词云图片
(PNG)"]
+ end
+
+ keyword --> search
+ search -->|视频列表| detail
+ detail --> validate
+ validate --> json
+ validate --> csv
+ validate --> report
+ report --> wordcloud
+```
+
+### 参考项目
+
+本项目参考 [MediaCrawler](https://github.com/NanmiCoder/MediaCrawler) 的 B站实现:
+
+| 文件 | 说明 |
+|------|------|
+| `media_platform/bilibili/core.py` | 爬虫核心逻辑 |
+| `media_platform/bilibili/client.py` | API 客户端 |
+| `media_platform/bilibili/login.py` | 登录认证 |
+| `media_platform/bilibili/help.py` | WBI 签名算法 |
+
+### 技术栈
+
+| 模块 | 技术选型 | 作用 |
+|------|----------|------|
+| 配置管理 | pydantic-settings | 类型安全的配置,支持环境变量 |
+| 日志系统 | loguru | 优雅的日志记录和轮转 |
+| 浏览器自动化 | Playwright | 处理登录、获取Cookie和签名密钥 |
+| HTTP 客户端 | httpx | 异步HTTP请求,高性能 |
+| 数据验证 | Pydantic | 数据模型定义和验证 |
+| 数据分析 | pandas + jieba + wordcloud | 统计分析和可视化 |
+
+### 项目结构
+
+```
+11_进阶综合实战项目/
+├── config/ # 配置模块
+│ ├── __init__.py
+│ ├── settings.py # 通用配置
+│ └── bilibili_config.py # B站特定配置
+├── core/ # 核心模块
+│ ├── __init__.py
+│ └── browser.py # 浏览器管理
+├── login/ # 登录模块
+│ ├── __init__.py
+│ └── auth.py # B站登录认证
+├── client/ # API 客户端模块
+│ ├── __init__.py
+│ └── bilibili_client.py # B站 API 客户端
+├── crawler/ # 爬虫模块
+│ ├── __init__.py
+│ └── spider.py # B站爬虫实现
+├── store/ # 存储模块
+│ ├── __init__.py
+│ └── backend.py # 存储后端
+├── proxy/ # 代理模块(可选)
+│ ├── __init__.py
+│ └── pool.py # 代理池
+├── models/ # 数据模型模块
+│ ├── __init__.py
+│ └── bilibili.py # B站数据模型
+├── tools/ # 工具模块
+│ ├── __init__.py
+│ └── sign.py # WBI 签名工具
+├── analysis/ # 分析模块
+│ ├── __init__.py
+│ └── report.py # 报告生成
+└── main.py # 入口文件
+```
+
+## 11.2 配置模块设计
+
+### 通用配置
+
+使用 pydantic-settings 实现类型安全的配置管理:
+
+```python
+# config/settings.py
+from pydantic_settings import BaseSettings
+from pydantic import Field
+from typing import Optional, List
+from enum import Enum
+
+
+class StorageType(str, Enum):
+ """存储类型"""
+ JSON = "json"
+ CSV = "csv"
+
+
+class LoginType(str, Enum):
+ """登录类型"""
+ COOKIE = "cookie"
+ QRCODE = "qrcode"
+
+
+class CrawlerType(str, Enum):
+ """爬取类型"""
+ SEARCH = "search" # 关键词搜索
+ DETAIL = "detail" # 指定视频详情
+
+
+class Settings(BaseSettings):
+ """项目配置"""
+
+ # 基础配置
+ app_name: str = "BilibiliCrawler"
+ debug: bool = False
+
+ # 浏览器配置
+ browser_headless: bool = False # B站扫码登录需要显示浏览器
+ browser_timeout: int = 30000
+ browser_user_data_dir: Optional[str] = "./browser_data"
+ save_login_state: bool = True
+
+ # 登录配置
+ login_type: LoginType = LoginType.QRCODE
+ cookie_str: str = ""
+
+ # 爬虫配置
+ crawler_type: CrawlerType = CrawlerType.SEARCH
+ keywords: str = "Python教程" # 搜索关键词,多个用逗号分隔
+ specified_id_list: List[str] = [] # 指定视频列表
+ max_video_count: int = 20
+ max_concurrency: int = 3
+ crawl_delay_min: float = 1.0
+ crawl_delay_max: float = 3.0
+
+ # 存储配置
+ storage_type: StorageType = StorageType.JSON
+ storage_output_dir: str = "./output"
+
+ class Config:
+ env_file = ".env"
+ env_prefix = "CRAWLER_"
+
+
+# 全局配置实例
+settings = Settings()
+```
+
+### B站特定配置
+
+```python
+# config/bilibili_config.py
+"""B站 API 配置"""
+
+# API 地址
+SEARCH_URL = "https://api.bilibili.com/x/web-interface/wbi/search/type"
+VIDEO_INFO_URL = "https://api.bilibili.com/x/web-interface/view"
+NAV_URL = "https://api.bilibili.com/x/web-interface/nav"
+
+# 请求配置
+SEARCH_PAGE_SIZE = 20
+REQUEST_TIMEOUT = 30
+
+# 默认请求头
+DEFAULT_HEADERS = {
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/120.0.0.0 Safari/537.36",
+ "Referer": "https://www.bilibili.com",
+ "Origin": "https://www.bilibili.com",
+}
+
+# WBI 签名密钥混淆表
+WBI_MIXIN_KEY_ENC_TAB = [
+ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
+ 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
+ 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
+ 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
+]
+
+# 登录相关
+LOGIN_BUTTON_SELECTOR = "xpath=//div[@class='right-entry__outside go-login-btn']//div"
+QRCODE_SELECTOR = "//div[@class='login-scan-box']//img"
+LOGIN_COOKIE_KEYS = ["SESSDATA", "DedeUserID", "bili_jct"]
+```
+
+## 11.3 WBI 签名算法
+
+B站使用 WBI 签名保护 API 请求,需要实现签名算法。
+
+### WBI 签名原理
+
+WBI(Web Bilibili Interface)签名是 B站用来保护 API 接口的一种机制,防止接口被恶意调用。
+
+**为什么需要签名?**
+
+- 防止请求被篡改
+- 防止接口被滥用
+- 增加爬虫难度
+
+### WBI 签名流程图
+
+```mermaid
+flowchart TD
+ subgraph step1 ["第一步: 获取签名密钥"]
+ api["调用 /x/web-interface/nav API"]
+ api --> response["响应中包含 wbi_img:
{img_url, sub_url}"]
+ response --> extract["提取文件名作为密钥:
img_key, sub_key"]
+ end
+
+ step1 --> step2
+
+ subgraph step2 ["第二步: 生成盐值 Salt"]
+ concat["raw_key = img_key + sub_key"]
+ concat --> mixin["使用混淆表重排字符:
WBI_MIXIN_KEY_ENC_TAB"]
+ mixin --> salt["salt = 重排后取前32位"]
+ end
+
+ step2 --> step3
+
+ subgraph step3 ["第三步: 计算签名"]
+ params["原始参数:
{keyword, page}"]
+ params --> addwts["添加时间戳 wts"]
+ addwts --> sort["按 key 排序并URL编码"]
+ sort --> append["拼接盐值: query + salt"]
+ append --> md5["计算 MD5 得到 w_rid"]
+ md5 --> final["最终参数:
{...原参数, wts, w_rid}"]
+ end
+```
+
+> **💡 关于 JS 逆向**
+>
+> 你可能会好奇:这个 WBI 签名算法是怎么逆向分析出来的?混淆表 `WBI_MIXIN_KEY_ENC_TAB` 又是从哪里找到的?
+>
+> 别着急!这部分涉及到 **JavaScript 逆向**技术,我会在后面的 **「高级爬虫 - JS 逆向」** 章节中详细讲解。届时会带你一步步分析 B站的前端代码,找出签名算法的实现细节。
+>
+> 本章的重点是让你理解**如何使用**这个签名算法,以及整个项目的工程化架构。签名算法的逆向分析过程,我们后面再深入探讨。
+
+### 签名器实现
+
+```python
+# tools/sign.py
+import hashlib
+import time
+import urllib.parse
+from typing import Dict, Tuple
+from functools import reduce
+
+from ..config import bilibili_config
+
+
+class BilibiliSign:
+ """
+ B站 WBI 签名器
+
+ WBI 签名算法用于保护 B站 API 请求。
+ 签名流程:
+ 1. 从 wbi_img_urls 中提取 img_key 和 sub_key
+ 2. 使用混淆表生成 salt
+ 3. 对请求参数进行签名
+ """
+
+ def __init__(self, img_key: str, sub_key: str):
+ """
+ 初始化签名器
+
+ Args:
+ img_key: 从 img_url 中提取的密钥
+ sub_key: 从 sub_url 中提取的密钥
+ """
+ self.img_key = img_key
+ self.sub_key = sub_key
+
+ def get_salt(self) -> str:
+ """
+ 生成盐值
+
+ 通过混淆表对 img_key + sub_key 进行重排。
+ """
+ raw_wbi_key = self.img_key + self.sub_key
+ return reduce(
+ lambda s, i: s + raw_wbi_key[i],
+ bilibili_config.WBI_MIXIN_KEY_ENC_TAB,
+ ''
+ )[:32]
+
+ def sign(self, req_data: Dict) -> Dict:
+ """
+ 对请求参数进行签名
+
+ Args:
+ req_data: 原始请求参数
+
+ Returns:
+ Dict: 签名后的请求参数(包含 wts 和 w_rid)
+ """
+ salt = self.get_salt()
+
+ # 添加时间戳
+ req_data['wts'] = int(time.time())
+
+ # 按 key 排序并编码
+ params = dict(sorted(req_data.items()))
+ query = urllib.parse.urlencode(params)
+
+ # 计算签名
+ text_to_sign = query + salt
+ w_rid = hashlib.md5(text_to_sign.encode()).hexdigest()
+
+ req_data['w_rid'] = w_rid
+ return req_data
+
+
+def extract_wbi_keys_from_urls(img_url: str, sub_url: str) -> Tuple[str, str]:
+ """
+ 从 URL 中提取 WBI 密钥
+
+ Args:
+ img_url: wbi_img 的 img_url
+ sub_url: wbi_img 的 sub_url
+
+ Returns:
+ Tuple[str, str]: (img_key, sub_key)
+ """
+ def extract_key(url: str) -> str:
+ # 从 URL 中提取文件名(不含扩展名)
+ # 例如:https://xxx/bfs/wbi/xxx.png -> xxx
+ filename = url.rsplit('/', 1)[-1]
+ return filename.split('.')[0]
+
+ return extract_key(img_url), extract_key(sub_url)
+```
+
+## 11.4 数据模型定义
+
+使用 Pydantic 定义视频数据模型:
+
+```python
+# models/bilibili.py
+from typing import Optional, List
+from datetime import datetime
+from pydantic import BaseModel, Field
+
+
+class BilibiliVideo(BaseModel):
+ """B站视频信息模型"""
+
+ # 视频标识
+ video_id: str = Field(default="", description="视频 aid")
+ bvid: str = Field(default="", description="视频 BV 号")
+
+ # 视频信息
+ title: str = Field(default="", description="视频标题")
+ desc: str = Field(default="", description="视频描述")
+ cover_url: str = Field(default="", description="封面 URL")
+ duration: int = Field(default=0, description="时长(秒)")
+ create_time: int = Field(default=0, description="发布时间戳")
+
+ # UP主信息
+ user_id: int = Field(default=0, description="UP主 ID")
+ nickname: str = Field(default="", description="UP主昵称")
+ avatar: str = Field(default="", description="UP主头像")
+
+ # 互动数据
+ play_count: int = Field(default=0, description="播放量")
+ liked_count: int = Field(default=0, description="点赞数")
+ coin_count: int = Field(default=0, description="投币数")
+ favorite_count: int = Field(default=0, description="收藏数")
+ share_count: int = Field(default=0, description="分享数")
+ danmaku_count: int = Field(default=0, description="弹幕数")
+ comment_count: int = Field(default=0, description="评论数")
+
+ # 爬取信息
+ source_keyword: str = Field(default="", description="搜索关键词")
+ crawl_time: str = Field(
+ default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ description="爬取时间"
+ )
+
+ @classmethod
+ def from_api_response(cls, data: dict, source_keyword: str = "") -> "BilibiliVideo":
+ """从视频详情 API 响应构建模型"""
+ stat = data.get("stat", {})
+ owner = data.get("owner", {})
+
+ return cls(
+ video_id=str(data.get("aid", "")),
+ bvid=data.get("bvid", ""),
+ title=data.get("title", ""),
+ desc=data.get("desc", ""),
+ cover_url=data.get("pic", ""),
+ duration=data.get("duration", 0),
+ create_time=data.get("pubdate", 0),
+ user_id=owner.get("mid", 0),
+ nickname=owner.get("name", ""),
+ avatar=owner.get("face", ""),
+ play_count=stat.get("view", 0),
+ liked_count=stat.get("like", 0),
+ coin_count=stat.get("coin", 0),
+ favorite_count=stat.get("favorite", 0),
+ share_count=stat.get("share", 0),
+ danmaku_count=stat.get("danmaku", 0),
+ comment_count=stat.get("reply", 0),
+ source_keyword=source_keyword,
+ )
+
+ @classmethod
+ def from_search_result(cls, data: dict, keyword: str = "") -> "BilibiliVideo":
+ """从搜索结果构建模型"""
+ return cls(
+ video_id=str(data.get("aid", "")),
+ bvid=data.get("bvid", ""),
+ title=data.get("title", "").replace("", "").replace("", ""),
+ desc=data.get("description", ""),
+ cover_url="https:" + data.get("pic", "") if data.get("pic", "").startswith("//") else data.get("pic", ""),
+ duration=data.get("duration", 0) if isinstance(data.get("duration"), int) else 0,
+ user_id=data.get("mid", 0),
+ nickname=data.get("author", ""),
+ avatar=data.get("upic", ""),
+ play_count=data.get("play", 0),
+ liked_count=data.get("like", 0),
+ danmaku_count=data.get("danmaku", 0),
+ source_keyword=keyword,
+ )
+
+ def to_dict(self) -> dict:
+ """转换为字典"""
+ return self.model_dump()
+```
+
+## 11.5 登录认证模块
+
+登录认证是爬虫获取完整数据的关键步骤。B站对未登录用户有很多数据限制,登录后可以获取更多信息。
+
+### 为什么需要登录?
+
+| 数据项 | 未登录 | 已登录 |
+|--------|--------|--------|
+| 搜索结果 | 有限制 | 完整 |
+| 视频详情 | 基础信息 | 完整信息 |
+| 用户数据 | 部分隐藏 | 可见 |
+| API调用频率 | 严格限制 | 相对宽松 |
+
+### 登录方式对比
+
+| 方式 | 优点 | 缺点 | 适用场景 |
+|------|------|------|----------|
+| 扫码登录 | 安全、无需处理复杂逻辑 | 需要手机APP配合 | 首次登录、开发调试 |
+| Cookie登录 | 快速、可自动化 | Cookie会过期 | 批量部署、定时任务 |
+
+### 扫码登录流程图
+
+```mermaid
+sequenceDiagram
+ participant PC as PC浏览器
+ participant Server as B站服务器
+ participant APP as B站APP
+
+ PC->>Server: 1. 访问B站首页
+ PC->>Server: 2. 点击登录按钮
+ PC->>Server: 3. 请求二维码+UUID
+ Server-->>PC: 4. 返回二维码图片
+ Note over PC: 5. 显示二维码
+
+ loop 轮询登录状态
+ PC->>Server: 6. 检查登录状态
+ APP->>Server: 7. 用户扫描二维码
+ Server-->>PC: 8. 状态: 已扫描
+ PC->>Server: 9. 继续轮询
+ APP->>Server: 10. 用户点击确认
+ Server-->>PC: 11. 返回登录凭证(Set-Cookie)
+ end
+
+ Note over PC: 12. 保存Cookie
登录成功!
+```
+
+### Cookie 登录流程图
+
+```mermaid
+flowchart LR
+ subgraph input [用户输入]
+ cookie["用户提供Cookie
(从浏览器复制)"]
+ end
+
+ subgraph process [处理流程]
+ parse["解析Cookie字符串
提取键值对"]
+ inject["注入到BrowserContext
add_cookies()"]
+ visit["访问B站首页
加载页面"]
+ check{"检查关键Cookie
SESSDATA存在?"}
+ end
+
+ subgraph result [结果]
+ success["登录成功!
开始爬取"]
+ fail["Cookie已过期
需要重新登录"]
+ end
+
+ cookie --> parse --> inject --> visit --> check
+ check -->|存在| success
+ check -->|不存在| fail
+```
+
+**关键Cookie说明:**
+
+| Cookie名称 | 说明 |
+|-----------|------|
+| SESSDATA | 会话凭证,最重要的登录标识 |
+| DedeUserID | 用户ID |
+| bili_jct | CSRF Token,某些操作需要 |
+
+### 登录实现代码
+
+```python
+# login/auth.py
+import asyncio
+import base64
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import Optional, List, Dict
+from loguru import logger
+
+from playwright.async_api import BrowserContext, Page
+
+# 登录相关常量
+BILIBILI_URL = "https://www.bilibili.com"
+LOGIN_BUTTON_SELECTOR = "xpath=//div[@class='right-entry__outside go-login-btn']//div"
+QRCODE_SELECTOR = "//div[@class='login-scan-box']//img"
+
+
+class BilibiliLogin:
+ """
+ B站登录类
+
+ 支持扫码登录和 Cookie 登录两种方式。
+ """
+
+ def __init__(
+ self,
+ login_type: str,
+ browser_context: BrowserContext,
+ context_page: Page,
+ cookie_str: str = "",
+ ):
+ self.login_type = login_type
+ self.browser_context = browser_context
+ self.context_page = context_page
+ self.cookie_str = cookie_str
+
+ async def begin(self) -> bool:
+ """开始登录流程"""
+ logger.info(f"[BilibiliLogin] 开始登录,方式: {self.login_type}")
+
+ if self.login_type == "qrcode":
+ return await self.login_by_qrcode()
+ elif self.login_type == "cookie":
+ return await self.login_by_cookies()
+ else:
+ logger.error(f"[BilibiliLogin] 不支持的登录类型: {self.login_type}")
+ return False
+
+ async def login_by_qrcode(self) -> bool:
+ """
+ 扫码登录
+
+ 流程:
+ 1. 访问 B站首页
+ 2. 点击登录按钮
+ 3. 获取二维码图片并显示
+ 4. 等待用户扫码
+ 5. 检查登录状态
+ """
+ logger.info("[BilibiliLogin] 开始扫码登录...")
+
+ try:
+ # 1. 访问 B站首页
+ await self.context_page.goto(BILIBILI_URL)
+ await asyncio.sleep(2)
+
+ # 2. 点击登录按钮
+ try:
+ login_button = await self.context_page.wait_for_selector(
+ LOGIN_BUTTON_SELECTOR,
+ timeout=10000
+ )
+ if login_button:
+ await login_button.click()
+ await asyncio.sleep(1)
+ except Exception as e:
+ logger.warning(f"[BilibiliLogin] 点击登录按钮失败: {e}")
+
+ # 3. 获取并显示二维码
+ qrcode_img = await self._find_login_qrcode()
+ if qrcode_img:
+ await self._show_qrcode(qrcode_img)
+
+ # 4. 等待登录成功
+ logger.info("[BilibiliLogin] 请使用 B站 APP 扫描二维码登录...")
+ logger.info("[BilibiliLogin] 等待登录成功(最长等待 120 秒)...")
+
+ for _ in range(120):
+ if await self.check_login_state():
+ logger.info("[BilibiliLogin] 扫码登录成功!")
+ await asyncio.sleep(2)
+ return True
+ await asyncio.sleep(1)
+
+ logger.error("[BilibiliLogin] 扫码登录超时")
+ return False
+
+ except Exception as e:
+ logger.error(f"[BilibiliLogin] 扫码登录失败: {e}")
+ return False
+
+ async def login_by_cookies(self) -> bool:
+ """Cookie 登录"""
+ logger.info("[BilibiliLogin] 开始 Cookie 登录...")
+
+ if not self.cookie_str:
+ logger.error("[BilibiliLogin] Cookie 字符串为空")
+ return False
+
+ try:
+ cookies = self._parse_cookie_str(self.cookie_str)
+ await self.browser_context.add_cookies(cookies)
+ logger.info(f"[BilibiliLogin] 成功注入 {len(cookies)} 个 Cookie")
+
+ await self.context_page.goto(BILIBILI_URL)
+ await asyncio.sleep(2)
+
+ if await self.check_login_state():
+ logger.info("[BilibiliLogin] Cookie 登录成功!")
+ return True
+ else:
+ logger.error("[BilibiliLogin] Cookie 登录失败,Cookie 可能已过期")
+ return False
+
+ except Exception as e:
+ logger.error(f"[BilibiliLogin] Cookie 登录失败: {e}")
+ return False
+
+ async def check_login_state(self) -> bool:
+ """检查登录状态"""
+ try:
+ cookies = await self.browser_context.cookies()
+ cookie_dict = {c['name']: c['value'] for c in cookies}
+
+ for key in ["SESSDATA", "DedeUserID"]:
+ if key in cookie_dict and cookie_dict[key]:
+ return True
+ return False
+ except Exception:
+ return False
+
+ async def _find_login_qrcode(self) -> Optional[str]:
+ """查找登录二维码"""
+ try:
+ qrcode_element = await self.context_page.wait_for_selector(
+ QRCODE_SELECTOR,
+ timeout=10000
+ )
+ if qrcode_element:
+ qrcode_src = await qrcode_element.get_attribute("src")
+ if qrcode_src and qrcode_src.startswith("data:image"):
+ return qrcode_src.split(",")[1]
+ return None
+ except Exception as e:
+ logger.error(f"[BilibiliLogin] 获取二维码失败: {e}")
+ return None
+
+ async def _show_qrcode(self, qrcode_base64: str):
+ """显示二维码"""
+ try:
+ qrcode_bytes = base64.b64decode(qrcode_base64)
+ qrcode_path = Path("qrcode.png")
+ with open(qrcode_path, 'wb') as f:
+ f.write(qrcode_bytes)
+ logger.info(f"[BilibiliLogin] 二维码已保存到: {qrcode_path.absolute()}")
+
+ print("\n" + "=" * 60)
+ print(" 请使用 B站 APP 扫描二维码登录")
+ print(f" 二维码文件: {qrcode_path.absolute()}")
+ print(" 等待登录中...")
+ print("=" * 60 + "\n")
+ except Exception as e:
+ logger.error(f"[BilibiliLogin] 显示二维码失败: {e}")
+
+ def _parse_cookie_str(self, cookie_str: str) -> List[Dict]:
+ """解析 Cookie 字符串"""
+ cookies = []
+ for item in cookie_str.split(";"):
+ item = item.strip()
+ if not item or "=" not in item:
+ continue
+ parts = item.split("=", 1)
+ name = parts[0].strip()
+ value = parts[1].strip() if len(parts) > 1 else ""
+ if name:
+ cookies.append({
+ "name": name,
+ "value": value,
+ "domain": ".bilibili.com",
+ "path": "/"
+ })
+ return cookies
+```
+
+## 11.6 API 客户端
+
+API 客户端是爬虫与 B站服务器交互的核心模块,负责发送请求、处理签名、解析响应。
+
+### 客户端职责
+
+```mermaid
+graph TB
+ subgraph 核心功能
+ cookie["Cookie管理
• 从浏览器同步
• 注入到请求头
• 验证有效性"]
+ wbi["WBI签名
• 获取密钥
• 参数签名
• 自动刷新"]
+ http["HTTP请求
• GET/POST请求
• 超时处理
• 错误重试"]
+ end
+
+ subgraph API方法
+ api["BilibiliClient API
• search_video_by_keyword
• get_video_info
• pong (登录检查)"]
+ end
+
+ cookie --> api
+ wbi --> api
+ http --> api
+```
+
+### B站 API 列表
+
+| API | 地址 | 功能 | 是否需要签名 |
+|-----|------|------|-------------|
+| 用户信息 | `/x/web-interface/nav` | 获取登录用户信息和WBI密钥 | 否 |
+| 视频搜索 | `/x/web-interface/wbi/search/type` | 按关键词搜索视频 | **是** |
+| 视频详情 | `/x/web-interface/view` | 获取视频完整信息 | 否 |
+
+### 客户端实现代码
+
+```python
+# client/bilibili_client.py
+import json
+from typing import Dict, Optional, List
+from loguru import logger
+import httpx
+
+from playwright.async_api import BrowserContext, Page
+
+from ..tools.sign import BilibiliSign, extract_wbi_keys_from_urls
+from ..models.bilibili import BilibiliVideo
+from ..config import bilibili_config
+
+
+class BilibiliClient:
+ """
+ B站 API 客户端
+
+ 封装 B站的 API 请求,支持 WBI 签名。
+ """
+
+ def __init__(self):
+ self.headers = bilibili_config.DEFAULT_HEADERS.copy()
+ self.cookie_dict: Dict[str, str] = {}
+ self._signer: Optional[BilibiliSign] = None
+ self._timeout = bilibili_config.REQUEST_TIMEOUT
+
+ async def update_cookies(self, browser_context: BrowserContext):
+ """从浏览器上下文更新 Cookie"""
+ cookies = await browser_context.cookies()
+ cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
+ self.headers["Cookie"] = cookie_str
+ self.cookie_dict = {c['name']: c['value'] for c in cookies}
+ logger.info(f"[BilibiliClient] 更新了 {len(cookies)} 个 Cookie")
+
+ async def init_wbi_sign(self, page: Page):
+ """
+ 初始化 WBI 签名器
+
+ 从浏览器的 localStorage 中获取 WBI 密钥。
+ """
+ try:
+ wbi_img_urls = await page.evaluate("""
+ () => {
+ return localStorage.getItem('wbi_img_urls');
+ }
+ """)
+
+ if not wbi_img_urls:
+ logger.warning("[BilibiliClient] 未找到 wbi_img_urls,尝试从 API 获取")
+ await self._fetch_wbi_keys()
+ return
+
+ wbi_data = json.loads(wbi_img_urls)
+ img_url = wbi_data.get("imgUrl", "")
+ sub_url = wbi_data.get("subUrl", "")
+
+ if img_url and sub_url:
+ img_key, sub_key = extract_wbi_keys_from_urls(img_url, sub_url)
+ self._signer = BilibiliSign(img_key, sub_key)
+ logger.info("[BilibiliClient] WBI 签名器初始化成功")
+ else:
+ await self._fetch_wbi_keys()
+
+ except Exception as e:
+ logger.error(f"[BilibiliClient] 初始化 WBI 签名器失败: {e}")
+ await self._fetch_wbi_keys()
+
+ async def _fetch_wbi_keys(self):
+ """从 API 获取 WBI 密钥(备用方案)"""
+ try:
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
+ response = await client.get(
+ "https://api.bilibili.com/x/web-interface/nav",
+ headers=self.headers
+ )
+ data = response.json()
+
+ if data.get("code") == 0:
+ wbi_img = data.get("data", {}).get("wbi_img", {})
+ img_url = wbi_img.get("img_url", "")
+ sub_url = wbi_img.get("sub_url", "")
+
+ if img_url and sub_url:
+ img_key, sub_key = extract_wbi_keys_from_urls(img_url, sub_url)
+ self._signer = BilibiliSign(img_key, sub_key)
+ logger.info("[BilibiliClient] 从 API 获取 WBI 密钥成功")
+ return
+
+ logger.error("[BilibiliClient] 无法获取 WBI 密钥")
+
+ except Exception as e:
+ logger.error(f"[BilibiliClient] 获取 WBI 密钥失败: {e}")
+
+ async def _request(
+ self,
+ method: str,
+ url: str,
+ params: Optional[Dict] = None,
+ enable_sign: bool = False
+ ) -> Optional[Dict]:
+ """发送 HTTP 请求"""
+ try:
+ if enable_sign and self._signer and params:
+ params = self._signer.sign(params)
+
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
+ if method.upper() == "GET":
+ response = await client.get(url, params=params, headers=self.headers)
+ else:
+ response = await client.post(url, params=params, headers=self.headers)
+
+ if response.status_code == 200:
+ return response.json()
+ else:
+ logger.error(f"[BilibiliClient] 请求失败: {response.status_code}")
+ return None
+
+ except Exception as e:
+ logger.error(f"[BilibiliClient] 请求出错: {e}")
+ return None
+
+ async def search_video_by_keyword(
+ self,
+ keyword: str,
+ page: int = 1,
+ page_size: int = 20,
+ ) -> List[BilibiliVideo]:
+ """
+ 按关键词搜索视频
+
+ Args:
+ keyword: 搜索关键词
+ page: 页码
+ page_size: 每页数量
+
+ Returns:
+ List[BilibiliVideo]: 视频列表
+ """
+ logger.info(f"[BilibiliClient] 搜索视频: {keyword}, 第 {page} 页")
+
+ params = {
+ "keyword": keyword,
+ "search_type": "video",
+ "page": page,
+ "page_size": page_size,
+ }
+
+ data = await self._request(
+ "GET",
+ bilibili_config.SEARCH_URL,
+ params=params,
+ enable_sign=True
+ )
+
+ if not data or data.get("code") != 0:
+ logger.error(f"[BilibiliClient] 搜索失败: {data.get('message') if data else 'No response'}")
+ return []
+
+ result = data.get("data", {})
+ video_list = result.get("result", [])
+
+ videos = []
+ for item in video_list:
+ try:
+ video = BilibiliVideo.from_search_result(item, keyword)
+ videos.append(video)
+ except Exception as e:
+ logger.debug(f"[BilibiliClient] 解析视频失败: {e}")
+
+ logger.info(f"[BilibiliClient] 搜索到 {len(videos)} 个视频")
+ return videos
+
+ async def get_video_info(
+ self,
+ aid: Optional[str] = None,
+ bvid: Optional[str] = None
+ ) -> Optional[BilibiliVideo]:
+ """
+ 获取视频详情
+
+ Args:
+ aid: 视频 aid
+ bvid: 视频 BV 号
+
+ Returns:
+ BilibiliVideo: 视频信息
+ """
+ if not aid and not bvid:
+ logger.error("[BilibiliClient] aid 和 bvid 至少提供一个")
+ return None
+
+ params = {}
+ if bvid:
+ params["bvid"] = bvid
+ elif aid:
+ params["aid"] = aid
+
+ logger.info(f"[BilibiliClient] 获取视频详情: {bvid or aid}")
+
+ data = await self._request(
+ "GET",
+ bilibili_config.VIDEO_INFO_URL,
+ params=params,
+ enable_sign=False
+ )
+
+ if not data or data.get("code") != 0:
+ logger.error(f"[BilibiliClient] 获取视频详情失败")
+ return None
+
+ video_data = data.get("data", {})
+ return BilibiliVideo.from_api_response(video_data)
+
+ async def pong(self) -> bool:
+ """检查登录状态"""
+ try:
+ data = await self._request(
+ "GET",
+ "https://api.bilibili.com/x/web-interface/nav",
+ enable_sign=False
+ )
+
+ if data and data.get("code") == 0:
+ user_data = data.get("data", {})
+ if user_data.get("isLogin"):
+ username = user_data.get("uname", "未知用户")
+ logger.info(f"[BilibiliClient] 已登录: {username}")
+ return True
+
+ return False
+ except Exception:
+ return False
+```
+
+## 11.7 爬虫模块
+
+爬虫模块是整个项目的核心调度器,负责协调浏览器、登录、API客户端等组件完成数据采集任务。
+
+### 爬虫类设计
+
+```mermaid
+graph LR
+ subgraph BilibiliCrawler
+ subgraph 属性
+ attr1["browser_manager"] --> desc1["管理Playwright浏览器"]
+ attr2["browser_context"] --> desc2["浏览器上下文(Cookie容器)"]
+ attr3["context_page"] --> desc3["页面实例"]
+ attr4["bili_client"] --> desc4["API客户端"]
+ attr5["_results"] --> desc5["爬取结果列表"]
+ end
+
+ subgraph 方法
+ m1["start()"] --> d1["启动爬虫(主入口)"]
+ m2["_init_browser()"] --> d2["初始化浏览器"]
+ m3["_do_login()"] --> d3["执行登录"]
+ m4["_init_client()"] --> d4["初始化API客户端"]
+ m5["search_by_keywords()"] --> d5["关键词搜索"]
+ m6["get_specified_videos()"] --> d6["获取指定视频"]
+ m7["close()"] --> d7["清理资源"]
+ end
+ end
+```
+
+### 两种爬取模式
+
+#### SEARCH 模式(关键词搜索视频)
+
+```mermaid
+flowchart LR
+ subgraph input [关键词列表]
+ k1["Python"]
+ k2["教程"]
+ k3["数据分析"]
+ end
+
+ subgraph search [搜索分页]
+ p1["第1页"]
+ p2["第2页"]
+ p3["..."]
+ end
+
+ subgraph result [视频列表]
+ v1["BV1xxx"]
+ v2["BV2xxx"]
+ v3["BV3xxx"]
+ end
+
+ detail["获取每个视频详情
(完整播放量等)"]
+
+ input --> search --> result --> detail
+```
+
+#### DETAIL 模式(获取指定视频详情)
+
+```mermaid
+flowchart LR
+ subgraph input [指定BV号列表]
+ bv1["BV1abc"]
+ bv2["BV2def"]
+ bv3["BV3ghi"]
+ end
+
+ api["遍历列表
逐个调用详情API"]
+
+ input --> api
+```
+
+### 反爬策略
+
+为了避免被 B站 封禁,爬虫采用了以下策略:
+
+| 策略 | 实现方式 | 配置项 |
+|------|----------|--------|
+| 随机延迟 | 每次请求后随机等待 1-3 秒 | `crawl_delay_min`, `crawl_delay_max` |
+| 频率控制 | 限制最大爬取数量 | `max_video_count` |
+| 登录态 | 使用真实登录Cookie | `login_type` |
+| 完整请求头 | User-Agent、Referer等 | `DEFAULT_HEADERS` |
+
+### 爬虫实现代码
+
+```python
+# crawler/spider.py
+import asyncio
+import random
+from typing import List, Optional
+from loguru import logger
+
+from playwright.async_api import BrowserContext, Page
+
+from ..config import settings, CrawlerType
+from ..core.browser import BrowserManager
+from ..login.auth import BilibiliLogin
+from ..client.bilibili_client import BilibiliClient
+from ..models.bilibili import BilibiliVideo
+
+
+class BilibiliCrawler:
+ """
+ B站爬虫类
+
+ 整合浏览器管理、登录认证、API客户端,实现完整的爬取流程。
+ """
+
+ def __init__(self):
+ self.browser_manager: Optional[BrowserManager] = None
+ self.browser_context: Optional[BrowserContext] = None
+ self.context_page: Optional[Page] = None
+ self.bili_client: Optional[BilibiliClient] = None
+ self._results: List[BilibiliVideo] = []
+
+ # 配置
+ self.max_video_count = settings.max_video_count
+ self.delay_min = settings.crawl_delay_min
+ self.delay_max = settings.crawl_delay_max
+
+ async def start(self) -> List[BilibiliVideo]:
+ """
+ 启动爬虫
+
+ 完整流程:
+ 1. 启动浏览器
+ 2. 执行登录
+ 3. 初始化 API 客户端
+ 4. 根据配置执行爬取
+ 5. 关闭浏览器
+ """
+ logger.info(f"[BilibiliCrawler] 启动爬虫,类型: {settings.crawler_type}")
+
+ try:
+ # 1. 启动浏览器
+ await self._init_browser()
+
+ # 2. 执行登录
+ login_success = await self._do_login()
+ if not login_success:
+ logger.error("[BilibiliCrawler] 登录失败,退出")
+ return []
+
+ # 3. 初始化 API 客户端
+ await self._init_client()
+
+ # 4. 根据配置执行爬取
+ if settings.crawler_type == CrawlerType.SEARCH:
+ await self.search_by_keywords()
+ elif settings.crawler_type == CrawlerType.DETAIL:
+ await self.get_specified_videos()
+
+ logger.info(f"[BilibiliCrawler] 爬取完成,共 {len(self._results)} 个视频")
+ return self._results
+
+ except Exception as e:
+ logger.exception(f"[BilibiliCrawler] 爬取出错: {e}")
+ return self._results
+
+ finally:
+ await self.close()
+
+ async def _init_browser(self):
+ """初始化浏览器"""
+ logger.info("[BilibiliCrawler] 初始化浏览器...")
+
+ self.browser_manager = BrowserManager(
+ headless=settings.browser_headless,
+ timeout=settings.browser_timeout,
+ user_data_dir=settings.browser_user_data_dir if settings.save_login_state else None
+ )
+
+ self.browser_context = await self.browser_manager.start()
+ self.context_page = await self.browser_manager.new_page()
+
+ async def _do_login(self) -> bool:
+ """执行登录"""
+ self.bili_client = BilibiliClient()
+ await self.bili_client.update_cookies(self.browser_context)
+
+ if await self.bili_client.pong():
+ logger.info("[BilibiliCrawler] 已有登录状态,跳过登录")
+ return True
+
+ login = BilibiliLogin(
+ login_type=settings.login_type.value,
+ browser_context=self.browser_context,
+ context_page=self.context_page,
+ cookie_str=settings.cookie_str
+ )
+
+ success = await login.begin()
+
+ if success:
+ await self.bili_client.update_cookies(self.browser_context)
+
+ return success
+
+ async def _init_client(self):
+ """初始化 API 客户端"""
+ await self.bili_client.init_wbi_sign(self.context_page)
+
+ async def search_by_keywords(self) -> List[BilibiliVideo]:
+ """按关键词搜索视频"""
+ keywords = [kw.strip() for kw in settings.keywords.split(",") if kw.strip()]
+
+ if not keywords:
+ logger.warning("[BilibiliCrawler] 未配置搜索关键词")
+ return []
+
+ logger.info(f"[BilibiliCrawler] 开始搜索,关键词: {keywords}")
+
+ for keyword in keywords:
+ await self._search_single_keyword(keyword)
+ if len(self._results) >= self.max_video_count:
+ break
+
+ return self._results
+
+ async def _search_single_keyword(self, keyword: str):
+ """搜索单个关键词"""
+ page = 1
+
+ while len(self._results) < self.max_video_count:
+ logger.info(f"[BilibiliCrawler] 搜索 '{keyword}',第 {page} 页")
+
+ videos = await self.bili_client.search_video_by_keyword(
+ keyword=keyword,
+ page=page,
+ )
+
+ if not videos:
+ break
+
+ for video in videos:
+ if len(self._results) >= self.max_video_count:
+ break
+
+ # 获取完整视频详情
+ video_detail = await self.bili_client.get_video_info(bvid=video.bvid)
+ if video_detail:
+ video_detail.source_keyword = keyword
+ self._results.append(video_detail)
+ logger.info(f"[BilibiliCrawler] 获取视频: {video_detail.title[:30]}...")
+ else:
+ self._results.append(video)
+
+ await self._random_delay()
+
+ page += 1
+ if page > 50:
+ break
+
+ async def get_specified_videos(self) -> List[BilibiliVideo]:
+ """获取指定视频列表的详情"""
+ video_list = settings.specified_id_list
+
+ if not video_list:
+ logger.warning("[BilibiliCrawler] 未配置指定视频列表")
+ return []
+
+ logger.info(f"[BilibiliCrawler] 获取 {len(video_list)} 个指定视频")
+
+ for video_id in video_list:
+ if len(self._results) >= self.max_video_count:
+ break
+
+ video = await self.bili_client.get_video_info(bvid=video_id)
+ if video:
+ self._results.append(video)
+ logger.info(f"[BilibiliCrawler] 获取视频: {video.title[:30]}...")
+
+ await self._random_delay()
+
+ return self._results
+
+ async def _random_delay(self):
+ """随机延迟"""
+ delay = random.uniform(self.delay_min, self.delay_max)
+ await asyncio.sleep(delay)
+
+ async def close(self):
+ """关闭浏览器"""
+ if self.browser_manager:
+ await self.browser_manager.close()
+```
+
+## 11.8 数据存储模块
+
+数据存储模块负责将爬取到的数据持久化保存,支持多种存储格式。
+
+### 存储架构设计
+
+采用**策略模式**设计,方便扩展新的存储方式:
+
+```mermaid
+graph TB
+ subgraph manager [存储管理器]
+ sm["StorageManager
• save(data)
• load()
• filepath"]
+ end
+
+ subgraph base [抽象基类]
+ bs["BaseStorage (ABC)
• save() 抽象方法
• load() 抽象方法"]
+ end
+
+ subgraph impl [具体实现]
+ json["JSONStorage
• 保存为JSON文件
• 保持数据结构
• 便于程序处理"]
+ csv["CSVStorage
• 保存为CSV文件
• 适合Excel打开
• 便于数据分析"]
+ end
+
+ sm -->|根据配置选择| json
+ sm -->|根据配置选择| csv
+ json -->|继承| bs
+ csv -->|继承| bs
+```
+
+### 存储格式对比
+
+| 格式 | 优点 | 缺点 | 适用场景 |
+|------|------|------|----------|
+| JSON | 保持嵌套结构、程序易读取 | 文件较大、不便人工查看 | 后续程序处理、API接口 |
+| CSV | Excel可打开、便于分析 | 无法保存嵌套结构 | 数据分析、报表制作 |
+
+### 存储实现代码
+
+```python
+# store/backend.py
+import json
+import csv
+from abc import ABC, abstractmethod
+from datetime import datetime
+from pathlib import Path
+from typing import List, Dict, Any
+from loguru import logger
+
+
+class BaseStorage(ABC):
+ """存储基类"""
+
+ @abstractmethod
+ async def save(self, data: List[Dict]) -> bool:
+ pass
+
+ @abstractmethod
+ async def load(self) -> List[Dict]:
+ pass
+
+
+class JSONStorage(BaseStorage):
+ """JSON 存储"""
+
+ def __init__(self, output_dir: str, filename: str = None):
+ self.output_dir = Path(output_dir)
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+
+ if filename:
+ self.filepath = self.output_dir / filename
+ else:
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+ self.filepath = self.output_dir / f"data_{timestamp}.json"
+
+ async def save(self, data: List[Dict]) -> bool:
+ try:
+ with open(self.filepath, 'w', encoding='utf-8') as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ logger.info(f"数据已保存到: {self.filepath} ({len(data)} 条)")
+ return True
+ except Exception as e:
+ logger.error(f"保存失败: {e}")
+ return False
+
+ async def load(self) -> List[Dict]:
+ if not self.filepath.exists():
+ return []
+ try:
+ with open(self.filepath, 'r', encoding='utf-8') as f:
+ return json.load(f)
+ except Exception as e:
+ logger.error(f"加载失败: {e}")
+ return []
+
+
+class CSVStorage(BaseStorage):
+ """CSV 存储"""
+
+ def __init__(self, output_dir: str, filename: str = None, fields: List[str] = None):
+ self.output_dir = Path(output_dir)
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+
+ if filename:
+ self.filepath = self.output_dir / filename
+ else:
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+ self.filepath = self.output_dir / f"data_{timestamp}.csv"
+
+ self.fields = fields
+
+ async def save(self, data: List[Dict]) -> bool:
+ if not data:
+ logger.warning("没有数据需要保存")
+ return True
+
+ try:
+ fields = self.fields or list(data[0].keys())
+
+ with open(self.filepath, 'w', encoding='utf-8-sig', newline='') as f:
+ writer = csv.DictWriter(f, fieldnames=fields, extrasaction='ignore')
+ writer.writeheader()
+ writer.writerows(data)
+
+ logger.info(f"数据已保存到: {self.filepath} ({len(data)} 条)")
+ return True
+ except Exception as e:
+ logger.error(f"保存失败: {e}")
+ return False
+
+ async def load(self) -> List[Dict]:
+ if not self.filepath.exists():
+ return []
+ try:
+ with open(self.filepath, 'r', encoding='utf-8-sig') as f:
+ reader = csv.DictReader(f)
+ return list(reader)
+ except Exception as e:
+ logger.error(f"加载失败: {e}")
+ return []
+
+
+class StorageManager:
+ """存储管理器"""
+
+ def __init__(self, storage_type: str, output_dir: str, **kwargs):
+ self.output_dir = output_dir
+
+ if storage_type == 'json':
+ self._storage = JSONStorage(output_dir, **kwargs)
+ elif storage_type == 'csv':
+ self._storage = CSVStorage(output_dir, **kwargs)
+ else:
+ raise ValueError(f"不支持的存储类型: {storage_type}")
+
+ async def save(self, data: List[Dict]) -> bool:
+ return await self._storage.save(data)
+
+ async def load(self) -> List[Dict]:
+ return await self._storage.load()
+
+ @property
+ def filepath(self) -> Path:
+ return self._storage.filepath
+```
+
+## 11.9 分析报告模块
+
+分析报告模块负责对爬取的数据进行统计分析,生成可视化报告。
+
+### 分析功能概览
+
+```mermaid
+flowchart LR
+ subgraph input [输入数据]
+ videos["BilibiliVideo
对象列表"]
+ end
+
+ subgraph process [分析处理]
+ stats["视频指标统计
• 播放量统计
• 点赞数统计
• 收藏数统计"]
+ top["热门视频排名
TOP 10"]
+ up["UP主分布统计
词频分析
(jieba分词)"]
+ end
+
+ subgraph output [输出结果]
+ md["Markdown 报告
• 数据表格
• TOP排名
• UP主分布"]
+ img["词云图片
(PNG)"]
+ end
+
+ videos --> stats --> md
+ stats --> top --> img
+ top --> up
+```
+
+### 报告内容结构
+
+生成的 Markdown 报告包含以下章节:
+
+| 章节 | 内容 | 分析维度 |
+|------|------|----------|
+| 视频指标统计 | 播放量、点赞、投币等指标的汇总统计 | 总计、平均、最高、最低 |
+| 热门视频 TOP 10 | 按播放量排序的前10个视频 | 标题、UP主、播放量、点赞 |
+| UP主分布 TOP 10 | 出现频率最高的UP主 | UP主名称、视频数量 |
+| 标题热词 TOP 20 | 视频标题中出现最多的词汇 | 词汇、出现频次 |
+| 标题词云 | 可视化展示热门词汇 | 词云图片 |
+
+### 可选依赖说明
+
+分析模块使用了可选依赖,即使没有安装也不会报错:
+
+```mermaid
+graph TB
+ subgraph deps [可选依赖]
+ jieba["jieba
中文分词
(词频统计)"]
+ wc["wordcloud
词云生成
(可视化)"]
+ pd["pandas
数据处理
(可选)"]
+ end
+
+ subgraph fallback [未安装时的降级策略]
+ note["如果未安装:
• jieba 未安装 - 跳过词频统计和词云生成
• wordcloud 未安装 - 跳过词云生成
• pandas 未安装 - 使用纯Python实现统计"]
+ end
+
+ jieba --> note
+ wc --> note
+ pd --> note
+```
+
+### 分析实现代码
+
+```python
+# analysis/report.py
+from typing import List, Dict, Union
+from datetime import datetime
+from collections import Counter
+from pathlib import Path
+from loguru import logger
+
+# 可选依赖
+try:
+ import jieba
+ HAS_JIEBA = True
+except ImportError:
+ HAS_JIEBA = False
+
+try:
+ from wordcloud import WordCloud
+ HAS_WORDCLOUD = True
+except ImportError:
+ HAS_WORDCLOUD = False
+
+
+class BilibiliAnalyzer:
+ """B站视频数据分析器"""
+
+ STOPWORDS = {
+ '的', '是', '在', '了', '和', '与', '或', '有', '个', '人',
+ '这', '那', '就', '都', '也', '为', '对', '到', '从', '把',
+ }
+
+ def __init__(self, videos: List[Union[Dict, any]], output_dir: str = "./output"):
+ # 转换为字典列表
+ self.data = []
+ for video in videos:
+ if hasattr(video, 'to_dict'):
+ self.data.append(video.to_dict())
+ elif hasattr(video, 'model_dump'):
+ self.data.append(video.model_dump())
+ elif isinstance(video, dict):
+ self.data.append(video)
+
+ self.output_dir = Path(output_dir)
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+
+ def video_metrics_stats(self) -> Dict:
+ """视频指标统计"""
+ metrics = {
+ 'play_count': [],
+ 'liked_count': [],
+ 'coin_count': [],
+ 'favorite_count': [],
+ 'share_count': [],
+ 'danmaku_count': [],
+ 'comment_count': [],
+ }
+
+ for item in self.data:
+ for key in metrics.keys():
+ value = item.get(key, 0) or 0
+ metrics[key].append(int(value))
+
+ stats = {}
+ for key, values in metrics.items():
+ if values:
+ stats[key] = {
+ 'total': sum(values),
+ 'avg': sum(values) / len(values),
+ 'max': max(values),
+ 'min': min(values),
+ }
+ return stats
+
+ def top_videos(self, metric: str = 'play_count', top_n: int = 10) -> List[Dict]:
+ """获取排名前 N 的视频"""
+ sorted_data = sorted(
+ self.data,
+ key=lambda x: x.get(metric, 0) or 0,
+ reverse=True
+ )
+ return sorted_data[:top_n]
+
+ def up_distribution(self, top_n: int = 10) -> List[tuple]:
+ """UP主分布统计"""
+ counter = Counter()
+ for item in self.data:
+ nickname = item.get('nickname', '未知UP主')
+ if nickname:
+ counter[nickname] += 1
+ return counter.most_common(top_n)
+
+ def word_frequency(self, text_field: str, top_n: int = 20) -> List[tuple]:
+ """词频统计"""
+ if not HAS_JIEBA:
+ return []
+
+ all_words = []
+ for item in self.data:
+ text = item.get(text_field, '')
+ if text:
+ words = jieba.lcut(str(text))
+ words = [w for w in words if w not in self.STOPWORDS and len(w) > 1]
+ all_words.extend(words)
+
+ return Counter(all_words).most_common(top_n)
+
+ def generate_wordcloud(self, text_field: str, output_file: str = "wordcloud.png") -> str:
+ """生成词云"""
+ if not HAS_WORDCLOUD:
+ return ""
+
+ word_freq = self.word_frequency(text_field, 200)
+ if not word_freq:
+ return ""
+
+ wc = WordCloud(
+ width=1200,
+ height=800,
+ background_color='white',
+ max_words=200,
+ )
+ wc.generate_from_frequencies(dict(word_freq))
+
+ output_path = self.output_dir / output_file
+ wc.to_file(str(output_path))
+ return str(output_path)
+
+
+class ReportGenerator:
+ """报告生成器"""
+
+ def __init__(self, videos: List, output_dir: str = "./output"):
+ self.videos = videos
+ self.output_dir = Path(output_dir)
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+ self.analyzer = BilibiliAnalyzer(videos, output_dir)
+
+ def generate(self, title: str = "B站视频数据分析报告") -> str:
+ """生成完整分析报告"""
+ lines = []
+
+ # 标题
+ lines.append(f"# {title}")
+ lines.append("")
+ lines.append(f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+ lines.append(f"> 数据量: {len(self.analyzer.data)} 条")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+
+ # 1. 视频指标统计
+ lines.append("## 1. 视频指标统计")
+ lines.append("")
+ metrics_stats = self.analyzer.video_metrics_stats()
+ if metrics_stats:
+ lines.append("| 指标 | 总计 | 平均 | 最高 | 最低 |")
+ lines.append("| --- | ---: | ---: | ---: | ---: |")
+
+ metric_names = {
+ 'play_count': '播放量',
+ 'liked_count': '点赞数',
+ 'coin_count': '投币数',
+ 'favorite_count': '收藏数',
+ 'share_count': '分享数',
+ 'danmaku_count': '弹幕数',
+ 'comment_count': '评论数',
+ }
+
+ for key, name in metric_names.items():
+ if key in metrics_stats:
+ stat = metrics_stats[key]
+ lines.append(
+ f"| {name} | {stat['total']:,} | "
+ f"{stat['avg']:,.0f} | {stat['max']:,} | {stat['min']:,} |"
+ )
+ lines.append("")
+
+ # 2. 热门视频 TOP 10
+ lines.append("## 2. 热门视频 TOP 10")
+ lines.append("")
+ top_videos = self.analyzer.top_videos('play_count', 10)
+ if top_videos:
+ lines.append("| 排名 | 标题 | UP主 | 播放量 | 点赞 |")
+ lines.append("| --- | --- | --- | ---: | ---: |")
+ for i, video in enumerate(top_videos, 1):
+ title_short = video.get('title', '')[:30] + '...'
+ lines.append(
+ f"| {i} | {title_short} | {video.get('nickname', '未知')} | "
+ f"{video.get('play_count', 0):,} | {video.get('liked_count', 0):,} |"
+ )
+ lines.append("")
+
+ # 3. UP主分布
+ lines.append("## 3. UP主分布 TOP 10")
+ lines.append("")
+ up_dist = self.analyzer.up_distribution(10)
+ if up_dist:
+ lines.append("| 排名 | UP主 | 视频数 |")
+ lines.append("| --- | --- | ---: |")
+ for i, (name, count) in enumerate(up_dist, 1):
+ lines.append(f"| {i} | {name} | {count} |")
+ lines.append("")
+
+ # 4. 标题热词
+ if HAS_JIEBA:
+ lines.append("## 4. 标题热词 TOP 20")
+ lines.append("")
+ word_freq = self.analyzer.word_frequency('title', 20)
+ if word_freq:
+ lines.append("| 排名 | 词汇 | 频次 |")
+ lines.append("| --- | --- | ---: |")
+ for i, (word, count) in enumerate(word_freq, 1):
+ lines.append(f"| {i} | {word} | {count} |")
+ lines.append("")
+
+ if HAS_WORDCLOUD:
+ wordcloud_path = self.analyzer.generate_wordcloud('title', 'title_wordcloud.png')
+ if wordcloud_path:
+ lines.append("### 标题词云")
+ lines.append("")
+ lines.append("")
+ lines.append("")
+
+ # 保存报告
+ report_content = '\n'.join(lines)
+ report_path = self.output_dir / "report.md"
+ with open(report_path, 'w', encoding='utf-8') as f:
+ f.write(report_content)
+
+ logger.info(f"报告已保存: {report_path}")
+ return str(report_path)
+
+
+def generate_report(videos: List, output_dir: str = "./output") -> str:
+ """生成分析报告(便捷函数)"""
+ generator = ReportGenerator(videos, output_dir)
+ return generator.generate()
+```
+
+## 11.10 主程序入口
+
+整合所有模块:
+
+```python
+# main.py
+import asyncio
+import sys
+from pathlib import Path
+from typing import List
+from loguru import logger
+
+# 导入各模块
+from config import settings, CrawlerType
+from crawler.spider import BilibiliCrawler
+from store.backend import StorageManager
+from analysis.report import generate_report
+from models.bilibili import BilibiliVideo
+
+
+def setup_logger():
+ """配置日志"""
+ logger.remove()
+ logger.add(
+ sys.stderr,
+ format="{time:YYYY-MM-DD HH:mm:ss} | "
+ "{level: <8} | "
+ "{message}",
+ level="INFO"
+ )
+ logger.add(
+ "logs/bilibili_{time:YYYY-MM-DD}.log",
+ rotation="1 day",
+ retention="7 days",
+ level="DEBUG",
+ )
+
+
+async def main():
+ """主函数"""
+
+ print("""
+ ╔══════════════════════════════════════════════════════════╗
+ ║ B站视频数据采集与分析工具 v2.0 ║
+ ║ ║
+ ║ 功能: ║
+ ║ - 视频搜索与详情获取 ║
+ ║ - 扫码登录 / Cookie 登录 ║
+ ║ - JSON / CSV 数据存储 ║
+ ║ - 词云和统计分析报告 ║
+ ║ ║
+ ║ 参考项目:MediaCrawler ║
+ ╚══════════════════════════════════════════════════════════╝
+ """)
+
+ logger.info(f"启动 {settings.app_name}")
+ logger.info(f"爬取类型: {settings.crawler_type.value}")
+ logger.info(f"登录方式: {settings.login_type.value}")
+ logger.info(f"最大数量: {settings.max_video_count}")
+
+ try:
+ # 1. 运行爬虫
+ logger.info("开始爬取数据...")
+ crawler = BilibiliCrawler()
+ videos = await crawler.start()
+ logger.info(f"爬取完成: {len(videos)} 条视频")
+
+ if not videos:
+ logger.warning("没有爬取到数据,退出")
+ return
+
+ # 2. 保存数据
+ data = [video.to_dict() for video in videos]
+ storage = StorageManager(
+ storage_type=settings.storage_type.value,
+ output_dir=settings.storage_output_dir
+ )
+ await storage.save(data)
+ logger.info(f"数据已保存: {storage.filepath}")
+
+ # 3. 生成报告
+ report_path = generate_report(videos, settings.storage_output_dir)
+ logger.info(f"报告已生成: {report_path}")
+
+ logger.info("=" * 50)
+ logger.info("任务完成!")
+ logger.info(f"数据文件: {storage.filepath}")
+ logger.info(f"分析报告: {report_path}")
+ logger.info("=" * 50)
+
+ except KeyboardInterrupt:
+ logger.warning("用户中断执行")
+ except Exception as e:
+ logger.exception(f"执行出错: {e}")
+
+
+if __name__ == "__main__":
+ setup_logger()
+ Path("logs").mkdir(exist_ok=True)
+ Path(settings.storage_output_dir).mkdir(parents=True, exist_ok=True)
+ asyncio.run(main())
+```
+
+## 11.11 运行与测试
+
+### 依赖安装
+
+```bash
+# 安装依赖
+pip install playwright httpx pydantic pydantic-settings loguru
+
+# 安装 Playwright 浏览器
+playwright install chromium
+
+# 可选:数据分析依赖
+pip install pandas jieba wordcloud
+```
+
+### 配置修改
+
+修改 `config/settings.py` 中的配置:
+
+```python
+# 爬取类型
+crawler_type = CrawlerType.SEARCH # 或 CrawlerType.DETAIL
+
+# 搜索关键词
+keywords = "Python教程,数据分析"
+
+# 最大爬取数量
+max_video_count = 20
+
+# 登录方式
+login_type = LoginType.QRCODE # 首次使用扫码登录
+```
+
+### 运行程序
+
+```bash
+cd 源代码/爬虫进阶/11_进阶综合实战项目
+python main.py
+```
+
+首次运行会弹出浏览器窗口,使用 B站 APP 扫码登录后,程序会自动开始爬取。
+
+## 本章小结
+
+本章我们完成了一个完整的 B站视频数据采集与分析项目,综合运用了:
+
+1. **登录认证**
+ - 扫码登录
+ - Cookie 登录
+ - 登录状态持久化
+
+2. **API 签名**
+ - WBI 签名算法
+ - 签名密钥获取
+
+3. **数据爬取**
+ - 关键词搜索
+ - 视频详情获取
+ - 并发控制
+
+4. **数据处理**
+ - Pydantic 数据模型
+ - JSON/CSV 存储
+ - 数据分析报告
+
+5. **工程化设计**
+ - 模块化架构
+ - 配置管理
+ - 日志系统
+
+**关键要点:**
+
+- B站 API 需要 WBI 签名保护
+- 登录状态可以持久化,避免重复扫码
+- 注意控制爬取频率,避免触发限制
+- 做好异常处理和日志记录
+- 遵守 B站使用条款和法律法规
+
+---
+
+## 教程总结
+
+恭喜你完成了 Python 爬虫进阶教程的全部内容!在这 11 章的学习中,你掌握了:
+
+1. **工程化开发**:日志、配置、异常处理
+2. **反爬对抗**:请求伪装、代理 IP、浏览器指纹
+3. **浏览器自动化**:Playwright 基础和进阶
+4. **登录认证**:Cookie 管理、扫码登录
+5. **验证码处理**:OCR 识别、滑块验证
+6. **数据处理**:清洗、去重、分析、可视化
+
+这些技术都源自 [MediaCrawler](https://github.com/NanmiCoder/MediaCrawler) 等实际项目的生产实践,希望能帮助你在爬虫开发领域更进一步。
+
+**最后的建议:**
+
+- 持续关注反爬技术的演进
+- 遵守法律法规和网站规则
+- 参与开源项目,与社区共同成长
+- 将爬虫作为数据获取的手段,关注数据本身的价值
+
+祝你在数据采集的道路上越走越远!
diff --git "a/docs/\347\210\254\350\231\253\350\277\233\344\273\267/README.md" "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/README.md"
new file mode 100644
index 0000000..544941e
--- /dev/null
+++ "b/docs/\347\210\254\350\231\253\350\277\233\344\273\267/README.md"
@@ -0,0 +1,318 @@
+# Python 爬虫进阶教程
+
+> 本教程是「Python 爬虫入门教程」的延续,面向已掌握基础爬虫技术的学习者。教程将从工程化实践入手,逐步深入反爬虫对抗、浏览器自动化、登录认证等进阶主题,最终通过综合实战项目巩固所学知识。
+
+## 适合人群
+
+- 已完成入门教程 11 篇内容的学习者
+- 了解 Python 异步编程基础(async/await)
+- 希望提升爬虫工程化能力和反爬对抗能力的开发者
+
+## 技术栈概览
+
+| 类别 | 技术/工具 |
+|------|----------|
+| HTTP 客户端 | httpx (异步) |
+| 浏览器自动化 | Playwright |
+| 数据验证 | Pydantic |
+| 日志系统 | loguru |
+| 配置管理 | pydantic-settings |
+| 代理管理 | 自研代理池 |
+| 验证码识别 | ddddocr / 第三方打码平台 |
+| 数据分析 | pandas / wordcloud / jieba |
+
+---
+
+## 教程目录
+
+### [01_工程化爬虫开发规范](./01_工程化爬虫开发规范.md)
+
+**学习目标:**
+- 理解爬虫项目的工程化重要性
+- 掌握 loguru 日志库的使用和日志分级策略
+- 学会使用 pydantic-settings 进行配置管理
+- 实现统一的异常处理和错误重试机制
+- 了解项目目录结构的最佳实践
+
+**核心内容:**
+- **日志系统设计**:loguru 基本使用、日志分级(DEBUG/INFO/WARNING/ERROR)、日志轮转和持久化、异步日志写入
+- **配置管理**:环境变量 vs 配置文件、pydantic-settings 实现配置验证、敏感信息的安全管理(.env 文件)、多环境配置切换
+- **异常处理**:自定义异常类设计、全局异常捕获、重试装饰器实现(tenacity 库)、优雅降级策略
+- **项目结构规范**:分层架构设计、模块划分原则、代码复用策略
+
+**实战案例:** 将入门教程第 10 章的数据存储爬虫进行工程化改造,添加完善的日志系统、配置管理和异常处理机制。
+
+---
+
+### [02_反爬虫对抗基础_请求伪装](./02_反爬虫对抗基础_请求伪装.md)
+
+**学习目标:**
+- 理解常见的反爬虫检测手段
+- 掌握 User-Agent 轮换策略
+- 学会构造完整的请求头伪装
+- 理解 Cookie 和 Session 的工作原理
+- 实现基础的速率控制
+
+**核心内容:**
+- **反爬虫机制概述**:基于请求特征的检测、基于行为特征的检测、基于内容的检测、常见的封禁策略
+- **User-Agent 策略**:真实浏览器 UA 收集、UA 随机轮换实现、移动端 vs 桌面端 UA 选择、fake-useragent 库的使用
+- **请求头完整伪装**:必要的请求头字段(Accept、Accept-Language、Accept-Encoding 等)、Referer 的正确设置、请求头一致性维护、使用 curl_cffi 模拟浏览器指纹
+- **速率控制**:固定延迟 vs 随机延迟、自适应速率调整、令牌桶算法实现、asyncio.Semaphore 控制并发数
+
+**实战案例:** 爬取一个具有基础反爬的电商网站商品列表,实现完整的请求伪装和智能速率控制。
+
+---
+
+### [03_代理IP的使用与管理](./03_代理IP的使用与管理.md)
+
+**学习目标:**
+- 理解代理 IP 的工作原理和类型
+- 学会评估代理 IP 的质量指标
+- 掌握代理池的设计与实现
+- 实现代理的自动检测和淘汰机制
+- 理解隧道代理和 API 代理的使用
+
+**核心内容:**
+- **代理 IP 基础**:HTTP 代理 vs HTTPS 代理 vs SOCKS5 代理、透明代理 vs 匿名代理 vs 高匿代理、免费代理 vs 付费代理的取舍、代理提供商选择指南
+- **代理池设计**(参考 MediaCrawler ProxyIpPool 设计):代理池抽象接口设计、代理获取和存储、代理有效性检测、代理评分和淘汰机制、ProxyRefreshMixin 刷新机制
+- **代理使用模式**:每次请求更换代理、按 IP 绑定代理(Session 粘性)、隧道代理的使用、API 提取型代理的封装
+- **代理与异步请求集成**:httpx 设置代理、代理超时和重试策略、代理切换的优雅实现
+
+**实战案例:** 实现一个完整的代理池管理类,支持从多个代理源获取代理、自动检测有效性、按需分配代理,并与爬虫主程序集成。
+
+---
+
+### [04_Playwright浏览器自动化入门](./04_Playwright浏览器自动化入门.md)
+
+**学习目标:**
+- 理解浏览器自动化的应用场景
+- 掌握 Playwright 的安装和基本配置
+- 学会页面导航、元素定位和交互操作
+- 理解同步 API 和异步 API 的选择
+- 掌握截图、PDF 导出等实用功能
+
+**核心内容:**
+- **Playwright 概述**:与 Selenium/Puppeteer 的对比、支持的浏览器(Chromium/Firefox/WebKit)、同步 vs 异步 API 选择、安装和浏览器驱动管理
+- **页面基础操作**:Browser/Context/Page 三层模型、页面导航和等待策略、元素定位方式(CSS/XPath/Text/Role)、点击、输入、选择等交互操作
+- **等待策略**(重要):自动等待机制、waitForSelector / waitForLoadState、自定义等待条件、超时处理
+- **页面内容提取**:获取元素文本和属性、执行 JavaScript 获取数据、拦截网络请求获取 API 数据、截图和 PDF 导出
+- **浏览器配置**:有头模式 vs 无头模式、视口大小设置、用户数据目录持久化、代理设置
+
+**实战案例:** 使用 Playwright 爬取一个需要 JavaScript 渲染的 SPA 单页应用,提取动态加载的数据列表。
+
+---
+
+### [05_Playwright进阶_反检测与性能优化](./05_Playwright进阶_反检测与性能优化.md)
+
+**学习目标:**
+- 理解浏览器指纹检测原理
+- 掌握 stealth.min.js 反检测注入
+- 学会 CDP 模式的使用场景
+- 掌握多浏览器实例管理和资源优化
+- 实现浏览器上下文复用策略
+
+**核心内容:**
+- **浏览器指纹检测**:navigator 属性检测、WebGL/Canvas 指纹、WebDriver 特征检测、行为特征检测
+- **stealth.js 反检测**(MediaCrawler 核心技术):stealth.min.js 的工作原理、注入时机和方式、常见检测点的绕过、自定义反检测脚本
+- **CDP 模式深入**:什么是 Chrome DevTools Protocol、标准模式 vs CDP 模式对比、连接已有浏览器实例、CDP 命令直接调用
+- **性能优化**:禁用图片/CSS/字体加载、浏览器上下文复用、多页面并发管理、内存和资源监控、优雅关闭和资源释放
+- **异常处理**:页面崩溃检测和恢复、超时重试策略、浏览器进程管理
+
+**实战案例:** 使用 Playwright + stealth.js 爬取一个具有严格反爬检测的网站,对比有无反检测的效果差异。
+
+---
+
+### [06_登录认证_Cookie与Session管理](./06_登录认证_Cookie与Session管理.md)
+
+**学习目标:**
+- 深入理解 Cookie 和 Session 的工作机制
+- 掌握 Cookie 的提取、存储和注入
+- 学会检测登录状态和 Cookie 有效性
+- 实现 Cookie 的自动刷新机制
+- 理解不同登录场景的处理策略
+
+**核心内容:**
+- **认证机制深入**:Cookie 的属性详解(Domain/Path/Expires/HttpOnly/Secure)、Session 的服务端实现、Token 认证的工作流程、多设备登录和踢出策略
+- **Cookie 提取与存储**:从浏览器 DevTools 手动提取、使用 Playwright 自动提取、Cookie 的序列化格式(JSON/Netscape)、加密存储敏感 Cookie
+- **Cookie 注入与使用**:httpx 中设置 Cookie、Playwright 中注入 Cookie、Cookie 的作用域管理、多账号 Cookie 轮换
+- **登录状态管理**:登录状态检测方法、Cookie 过期检测、自动重新登录机制、登录状态持久化
+
+**实战案例:** 实现一个完整的 Cookie 管理模块,支持 Cookie 的存储、加载、验证和自动刷新,可用于需要登录的爬虫场景。
+
+---
+
+### [07_登录认证_扫码与短信登录实现](./07_登录认证_扫码与短信登录实现.md)
+
+**学习目标:**
+- 理解扫码登录的技术原理
+- 掌握使用 Playwright 实现扫码登录流程
+- 理解短信验证码登录的实现方式
+- 学会登录状态的监控和回调机制
+- 实现多种登录方式的统一封装
+
+**核心内容:**
+- **扫码登录原理**(参考 MediaCrawler):二维码生成和轮询机制、长轮询 vs WebSocket 状态推送、扫码状态(待扫描/已扫描/已确认/已过期)、登录成功后的 Cookie 获取
+- **Playwright 实现扫码登录**:定位二维码元素、二维码截图和保存、终端显示二维码(可选)、轮询检测登录状态、登录成功后 Cookie 提取
+- **短信验证码登录**:手机号输入和验证码发送、验证码输入(手动/API 接收)、滑块验证码的处理、登录失败的重试策略
+- **登录模块封装**:登录方式抽象(工厂模式)、统一的登录接口设计、登录状态回调机制、异常处理和超时控制
+
+**实战案例:** 参考 MediaCrawler 的登录实现,完成一个支持扫码登录和 Cookie 注入的双模式登录模块。
+
+---
+
+### [08_验证码识别与处理](./08_验证码识别与处理.md)
+
+**学习目标:**
+- 了解常见验证码类型和破解思路
+- 掌握图片验证码的 OCR 识别
+- 学会滑块验证码的轨迹模拟
+- 了解第三方打码平台的使用
+- 理解验证码处理的合规边界
+
+**核心内容:**
+- **验证码类型概览**:图片字符验证码、数学运算验证码、滑块拼图验证码、点选验证码、行为验证码(如 reCAPTCHA)
+- **图片验证码识别**:ddddocr 库的使用、图片预处理(二值化/降噪)、本地模型 vs 云端 API、识别率优化策略
+- **滑块验证码处理**:缺口位置识别(OpenCV/图像差异)、人类拖拽轨迹模拟、速度和加速度曲线、Playwright 实现拖拽操作
+- **第三方打码平台**:平台选择和价格对比、API 调用封装、超时和失败重试、成本控制策略
+- **合规与伦理**:验证码绕过的法律边界、合理使用原则、替代方案考虑
+
+**实战案例:** 实现一个验证码处理模块,支持图片验证码 OCR 识别和简单滑块验证码的自动处理。
+
+---
+
+### [09_数据清洗与预处理](./09_数据清洗与预处理.md)
+
+**学习目标:**
+- 掌握常见的数据清洗技术
+- 学会使用正则表达式提取和清洗数据
+- 理解数据去重和合并策略
+- 掌握数据格式标准化方法
+- 学会处理缺失值和异常值
+
+**核心内容:**
+- **数据清洗概述**:脏数据的常见类型、数据质量评估指标、清洗流程设计
+- **文本清洗**:HTML 标签移除、空白字符处理、特殊字符清理、编码问题处理(Unicode 归一化)
+- **正则表达式高级应用**:复杂模式匹配、命名分组提取、替换和转换、性能优化
+- **数据去重与合并**:精确去重 vs 模糊去重、相似度计算(编辑距离/余弦相似度)、多数据源合并策略、冲突解决规则
+- **数据标准化**:日期时间格式统一、数值单位换算、枚举值映射、字段命名规范
+
+**实战案例:** 对前几章爬取的数据进行完整的清洗流程,包括文本清洗、格式标准化、去重处理,并输出干净的数据集。
+
+---
+
+### [10_数据分析与可视化](./10_数据分析与可视化.md)
+
+**学习目标:**
+- 掌握 pandas 进行数据统计分析
+- 学会生成词云展示文本数据
+- 了解常用的数据可视化库(matplotlib/pyecharts)
+- 实现简单的数据报告生成
+- 理解爬取数据的分析价值
+
+**核心内容:**
+- **pandas 数据分析**:DataFrame 基础操作、数据聚合与分组统计、数据透视表、时间序列分析
+- **词云生成**(参考 MediaCrawler):jieba 中文分词、停用词过滤、wordcloud 库使用、自定义词云形状和配色
+- **数据可视化**:matplotlib 基础图表(折线图/柱状图/饼图)、pyecharts 交互式图表、数据仪表盘设计、图表导出和嵌入
+- **分析报告生成**:Markdown 报告模板、自动化报告生成、图表嵌入、定期报告推送
+
+**实战案例:** 对爬取的社交媒体评论数据进行分析,生成词云、情感倾向统计图表,并输出一份完整的数据分析报告。
+
+---
+
+### [11_进阶综合实战项目](./11_进阶综合实战项目.md)
+
+**学习目标:**
+- 综合运用进阶教程所学的所有技术
+- 实现一个完整的中等复杂度爬虫项目
+- 掌握爬虫项目的完整开发流程
+- 学会项目文档编写和部署
+
+**核心内容:**
+- **项目需求分析**:目标网站分析、数据需求定义、技术方案选型、风险评估
+- **项目架构设计**:模块划分、类图设计、数据流设计、接口定义
+- **核心功能实现**:登录模块(Cookie 注入 + 扫码登录)、爬取模块(Playwright + 反检测)、数据存储(多后端支持)、代理池集成、日志和监控
+- **项目打包与部署**:代码结构整理、依赖管理(requirements.txt / pyproject.toml)、Docker 容器化部署、定时任务配置
+
+**实战案例:** 完成一个类似 MediaCrawler 简化版的社交媒体数据采集工具,支持:
+- 多种登录方式
+- 反检测浏览器自动化
+- 代理 IP 轮换
+- 多格式数据存储
+- 词云生成
+
+---
+
+## 源代码目录结构
+
+```
+源代码/爬虫进阶/
+├── 01_工程化爬虫开发规范/
+│ ├── config/
+│ ├── logger_demo.py
+│ ├── exception_demo.py
+│ └── refactored_crawler/
+├── 02_反爬虫对抗基础_请求伪装/
+│ ├── ua_rotator.py
+│ ├── headers_builder.py
+│ └── rate_limiter.py
+├── 03_代理IP的使用与管理/
+│ ├── proxy_pool/
+│ ├── proxy_checker.py
+│ └── proxy_demo.py
+├── 04_Playwright浏览器自动化入门/
+│ ├── basic_operations.py
+│ ├── wait_strategies.py
+│ └── spa_crawler.py
+├── 05_Playwright进阶_反检测与性能优化/
+│ ├── stealth_demo.py
+│ ├── cdp_mode.py
+│ └── performance_optimization.py
+├── 06_登录认证_Cookie与Session管理/
+│ ├── cookie_manager.py
+│ ├── session_demo.py
+│ └── login_state_checker.py
+├── 07_登录认证_扫码与短信登录实现/
+│ ├── qrcode_login.py
+│ ├── sms_login.py
+│ └── login_factory.py
+├── 08_验证码识别与处理/
+│ ├── ocr_captcha.py
+│ ├── slider_captcha.py
+│ └── captcha_service.py
+├── 09_数据清洗与预处理/
+│ ├── text_cleaner.py
+│ ├── deduplication.py
+│ └── data_normalizer.py
+├── 10_数据分析与可视化/
+│ ├── pandas_analysis.py
+│ ├── wordcloud_generator.py
+│ └── chart_demo.py
+└── 11_进阶综合实战项目/
+ ├── config/
+ ├── core/
+ ├── login/
+ ├── crawler/
+ ├── store/
+ ├── proxy/
+ ├── analysis/
+ └── main.py
+```
+
+---
+
+## 教程特色
+
+1. **与入门教程无缝衔接**:延续异步编程风格,逐步提升难度
+2. **源于实战项目**:核心技术均来自 MediaCrawler 项目的生产实践
+3. **每章都有代码**:提供完整可运行的示例代码和实战项目
+4. **注重工程规范**:从第一章就强调代码质量和可维护性
+5. **循序渐进**:从请求伪装到浏览器自动化,再到综合实战
+
+---
+
+## 学习建议
+
+1. **按顺序学习**:每章内容都有前后依赖,建议按顺序学习
+2. **动手实践**:每章的实战案例务必亲自完成
+3. **阅读源码**:参考 MediaCrawler 项目源码加深理解
+4. **遵守法规**:爬虫技术仅用于学习研究,请遵守相关法律法规
diff --git "a/\347\210\254\350\231\253\350\277\233\344\273\267/README.md" "b/docs/\351\253\230\347\272\247\347\210\254\350\231\253/README.md"
similarity index 100%
rename from "\347\210\254\350\231\253\350\277\233\344\273\267/README.md"
rename to "docs/\351\253\230\347\272\247\347\210\254\350\231\253/README.md"
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..89775f0
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,3320 @@
+{
+ "name": "CrawlerTutorial",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "devDependencies": {
+ "mermaid": "^11.12.2",
+ "vitepress": "^1.3.4",
+ "vitepress-plugin-mermaid": "^2.0.17"
+ }
+ },
+ "node_modules/@algolia/autocomplete-core": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmmirror.com/@algolia/autocomplete-core/-/autocomplete-core-1.9.3.tgz",
+ "integrity": "sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/autocomplete-plugin-algolia-insights": "1.9.3",
+ "@algolia/autocomplete-shared": "1.9.3"
+ }
+ },
+ "node_modules/@algolia/autocomplete-plugin-algolia-insights": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmmirror.com/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.9.3.tgz",
+ "integrity": "sha512-a/yTUkcO/Vyy+JffmAnTWbr4/90cLzw+CC3bRbhnULr/EM0fGNvM13oQQ14f2moLMcVDyAx/leczLlAOovhSZg==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/autocomplete-shared": "1.9.3"
+ },
+ "peerDependencies": {
+ "search-insights": ">= 1 < 3"
+ }
+ },
+ "node_modules/@algolia/autocomplete-preset-algolia": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmmirror.com/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.9.3.tgz",
+ "integrity": "sha512-d4qlt6YmrLMYy95n5TB52wtNDr6EgAIPH81dvvvW8UmuWRgxEtY0NJiPwl/h95JtG2vmRM804M0DSwMCNZlzRA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/autocomplete-shared": "1.9.3"
+ },
+ "peerDependencies": {
+ "@algolia/client-search": ">= 4.9.1 < 6",
+ "algoliasearch": ">= 4.9.1 < 6"
+ }
+ },
+ "node_modules/@algolia/autocomplete-shared": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmmirror.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.9.3.tgz",
+ "integrity": "sha512-Wnm9E4Ye6Rl6sTTqjoymD+l8DjSTHsHboVRYrKgEt8Q7UHm9nYbqhN/i0fhUYA3OAEH7WA8x3jfpnmJm3rKvaQ==",
+ "dev": true,
+ "peerDependencies": {
+ "@algolia/client-search": ">= 4.9.1 < 6",
+ "algoliasearch": ">= 4.9.1 < 6"
+ }
+ },
+ "node_modules/@algolia/cache-browser-local-storage": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.24.0.tgz",
+ "integrity": "sha512-t63W9BnoXVrGy9iYHBgObNXqYXM3tYXCjDSHeNwnsc324r4o5UiVKUiAB4THQ5z9U5hTj6qUvwg/Ez43ZD85ww==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/cache-common": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/cache-common": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/cache-common/-/cache-common-4.24.0.tgz",
+ "integrity": "sha512-emi+v+DmVLpMGhp0V9q9h5CdkURsNmFC+cOS6uK9ndeJm9J4TiqSvPYVu+THUP8P/S08rxf5x2P+p3CfID0Y4g==",
+ "dev": true
+ },
+ "node_modules/@algolia/cache-in-memory": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/cache-in-memory/-/cache-in-memory-4.24.0.tgz",
+ "integrity": "sha512-gDrt2so19jW26jY3/MkFg5mEypFIPbPoXsQGQWAi6TrCPsNOSEYepBMPlucqWigsmEy/prp5ug2jy/N3PVG/8w==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/cache-common": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/client-account": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-account/-/client-account-4.24.0.tgz",
+ "integrity": "sha512-adcvyJ3KjPZFDybxlqnf+5KgxJtBjwTPTeyG2aOyoJvx0Y8dUQAEOEVOJ/GBxX0WWNbmaSrhDURMhc+QeevDsA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/client-common": "4.24.0",
+ "@algolia/client-search": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/client-account/node_modules/@algolia/client-common": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-common/-/client-common-4.24.0.tgz",
+ "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/requester-common": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/client-account/node_modules/@algolia/client-search": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-search/-/client-search-4.24.0.tgz",
+ "integrity": "sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/client-common": "4.24.0",
+ "@algolia/requester-common": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/client-analytics": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-analytics/-/client-analytics-4.24.0.tgz",
+ "integrity": "sha512-y8jOZt1OjwWU4N2qr8G4AxXAzaa8DBvyHTWlHzX/7Me1LX8OayfgHexqrsL4vSBcoMmVw2XnVW9MhL+Y2ZDJXg==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/client-common": "4.24.0",
+ "@algolia/client-search": "4.24.0",
+ "@algolia/requester-common": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/client-analytics/node_modules/@algolia/client-common": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-common/-/client-common-4.24.0.tgz",
+ "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/requester-common": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/client-analytics/node_modules/@algolia/client-search": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-search/-/client-search-4.24.0.tgz",
+ "integrity": "sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/client-common": "4.24.0",
+ "@algolia/requester-common": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/client-common": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-common/-/client-common-5.1.1.tgz",
+ "integrity": "sha512-jkQNQbGY+XQB3Eln7wqqdUZKBzG8lETcsaUk5gcMc6iIwyN/qW0v0fhpKPH+Kli+BImLxo0CWk12CvVvx2exWA==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/client-personalization": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-personalization/-/client-personalization-4.24.0.tgz",
+ "integrity": "sha512-l5FRFm/yngztweU0HdUzz1rC4yoWCFo3IF+dVIVTfEPg906eZg5BOd1k0K6rZx5JzyyoP4LdmOikfkfGsKVE9w==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/client-common": "4.24.0",
+ "@algolia/requester-common": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/client-personalization/node_modules/@algolia/client-common": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-common/-/client-common-4.24.0.tgz",
+ "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/requester-common": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/client-search": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-search/-/client-search-5.1.1.tgz",
+ "integrity": "sha512-SFpb3FI/VouGou/vpuS7qeCA5Y/KpV42P6CEA/1MZQtl/xJkl6PVjikb+Q9YadeHi2jtDV/aQ6PyiVDnX4PQcw==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@algolia/client-common": "5.1.1",
+ "@algolia/requester-browser-xhr": "5.1.1",
+ "@algolia/requester-node-http": "5.1.1"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/logger-common": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/logger-common/-/logger-common-4.24.0.tgz",
+ "integrity": "sha512-LLUNjkahj9KtKYrQhFKCzMx0BY3RnNP4FEtO+sBybCjJ73E8jNdaKJ/Dd8A/VA4imVHP5tADZ8pn5B8Ga/wTMA==",
+ "dev": true
+ },
+ "node_modules/@algolia/logger-console": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/logger-console/-/logger-console-4.24.0.tgz",
+ "integrity": "sha512-X4C8IoHgHfiUROfoRCV+lzSy+LHMgkoEEU1BbKcsfnV0i0S20zyy0NLww9dwVHUWNfPPxdMU+/wKmLGYf96yTg==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/logger-common": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/recommend": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/recommend/-/recommend-4.24.0.tgz",
+ "integrity": "sha512-P9kcgerfVBpfYHDfVZDvvdJv0lEoCvzNlOy2nykyt5bK8TyieYyiD0lguIJdRZZYGre03WIAFf14pgE+V+IBlw==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/cache-browser-local-storage": "4.24.0",
+ "@algolia/cache-common": "4.24.0",
+ "@algolia/cache-in-memory": "4.24.0",
+ "@algolia/client-common": "4.24.0",
+ "@algolia/client-search": "4.24.0",
+ "@algolia/logger-common": "4.24.0",
+ "@algolia/logger-console": "4.24.0",
+ "@algolia/requester-browser-xhr": "4.24.0",
+ "@algolia/requester-common": "4.24.0",
+ "@algolia/requester-node-http": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/recommend/node_modules/@algolia/client-common": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-common/-/client-common-4.24.0.tgz",
+ "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/requester-common": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/recommend/node_modules/@algolia/client-search": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-search/-/client-search-4.24.0.tgz",
+ "integrity": "sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/client-common": "4.24.0",
+ "@algolia/requester-common": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/recommend/node_modules/@algolia/requester-browser-xhr": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.24.0.tgz",
+ "integrity": "sha512-Z2NxZMb6+nVXSjF13YpjYTdvV3032YTBSGm2vnYvYPA6mMxzM3v5rsCiSspndn9rzIW4Qp1lPHBvuoKJV6jnAA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/requester-common": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/recommend/node_modules/@algolia/requester-node-http": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/requester-node-http/-/requester-node-http-4.24.0.tgz",
+ "integrity": "sha512-JF18yTjNOVYvU/L3UosRcvbPMGT9B+/GQWNWnenIImglzNVGpyzChkXLnrSf6uxwVNO6ESGu6oN8MqcGQcjQJw==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/requester-common": "4.24.0"
+ }
+ },
+ "node_modules/@algolia/requester-browser-xhr": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmmirror.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.1.1.tgz",
+ "integrity": "sha512-NXmN1ujJCj5GlJQaMK6DbdiXdcf6nhRef/X40lu9TYi71q9xTo/5RPMI0K2iOp6g07S26BrXFOz6RSV3Ny4LLw==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@algolia/client-common": "5.1.1"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/requester-common": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/requester-common/-/requester-common-4.24.0.tgz",
+ "integrity": "sha512-k3CXJ2OVnvgE3HMwcojpvY6d9kgKMPRxs/kVohrwF5WMr2fnqojnycZkxPoEg+bXm8fi5BBfFmOqgYztRtHsQA==",
+ "dev": true
+ },
+ "node_modules/@algolia/requester-node-http": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmmirror.com/@algolia/requester-node-http/-/requester-node-http-5.1.1.tgz",
+ "integrity": "sha512-xwrgnNTIzgxDEx6zuCKSKTPzQLA8fL/WZiVB6fRpIu5agLMjoAi0cWA5YSDbo+2FFxqVgLqKY/Jz6mKmWtY15Q==",
+ "dev": true,
+ "peer": true,
+ "dependencies": {
+ "@algolia/client-common": "5.1.1"
+ },
+ "engines": {
+ "node": ">= 14.0.0"
+ }
+ },
+ "node_modules/@algolia/transporter": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/transporter/-/transporter-4.24.0.tgz",
+ "integrity": "sha512-86nI7w6NzWxd1Zp9q3413dRshDqAzSbsQjhcDhPIatEFiZrL1/TjnHL8S7jVKFePlIMzDsZWXAXwXzcok9c5oA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/cache-common": "4.24.0",
+ "@algolia/logger-common": "4.24.0",
+ "@algolia/requester-common": "4.24.0"
+ }
+ },
+ "node_modules/@antfu/install-pkg": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmmirror.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
+ "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "package-manager-detector": "^1.3.0",
+ "tinyexec": "^1.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.24.8",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
+ "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.24.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
+ "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.25.4",
+ "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.25.4.tgz",
+ "integrity": "sha512-nq+eWrOgdtu3jG5Os4TQP3x3cLA8hR8TvJNjD8vnPa20WGycimcparWnLK4jJhElTK6SDyuJo1weMKO/5LpmLA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.25.4"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.25.4",
+ "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.25.4.tgz",
+ "integrity": "sha512-zQ1ijeeCXVEh+aNL0RlmkPkG8HUiDcU2pzQQFjtbntgAczRASFzj4H+6+bV+dy1ntKR14I/DypeuRG1uma98iQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.24.8",
+ "@babel/helper-validator-identifier": "^7.24.7",
+ "to-fast-properties": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@braintree/sanitize-url": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmmirror.com/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz",
+ "integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@chevrotain/cst-dts-gen": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmmirror.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
+ "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@chevrotain/gast": "11.0.3",
+ "@chevrotain/types": "11.0.3",
+ "lodash-es": "4.17.21"
+ }
+ },
+ "node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
+ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@chevrotain/gast": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmmirror.com/@chevrotain/gast/-/gast-11.0.3.tgz",
+ "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@chevrotain/types": "11.0.3",
+ "lodash-es": "4.17.21"
+ }
+ },
+ "node_modules/@chevrotain/gast/node_modules/lodash-es": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
+ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@chevrotain/regexp-to-ast": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmmirror.com/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz",
+ "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@chevrotain/types": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmmirror.com/@chevrotain/types/-/types-11.0.3.tgz",
+ "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@chevrotain/utils": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmmirror.com/@chevrotain/utils/-/utils-11.0.3.tgz",
+ "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@docsearch/css": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmmirror.com/@docsearch/css/-/css-3.6.1.tgz",
+ "integrity": "sha512-VtVb5DS+0hRIprU2CO6ZQjK2Zg4QU5HrDM1+ix6rT0umsYvFvatMAnf97NHZlVWDaaLlx7GRfR/7FikANiM2Fg==",
+ "dev": true
+ },
+ "node_modules/@docsearch/js": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmmirror.com/@docsearch/js/-/js-3.6.1.tgz",
+ "integrity": "sha512-erI3RRZurDr1xES5hvYJ3Imp7jtrXj6f1xYIzDzxiS7nNBufYWPbJwrmMqWC5g9y165PmxEmN9pklGCdLi0Iqg==",
+ "dev": true,
+ "dependencies": {
+ "@docsearch/react": "3.6.1",
+ "preact": "^10.0.0"
+ }
+ },
+ "node_modules/@docsearch/react": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmmirror.com/@docsearch/react/-/react-3.6.1.tgz",
+ "integrity": "sha512-qXZkEPvybVhSXj0K7U3bXc233tk5e8PfhoZ6MhPOiik/qUQxYC+Dn9DnoS7CxHQQhHfCvTiN0eY9M12oRghEXw==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/autocomplete-core": "1.9.3",
+ "@algolia/autocomplete-preset-algolia": "1.9.3",
+ "@docsearch/css": "3.6.1",
+ "algoliasearch": "^4.19.1"
+ },
+ "peerDependencies": {
+ "@types/react": ">= 16.8.0 < 19.0.0",
+ "react": ">= 16.8.0 < 19.0.0",
+ "react-dom": ">= 16.8.0 < 19.0.0",
+ "search-insights": ">= 1 < 3"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "search-insights": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@iconify/types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz",
+ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@iconify/utils": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.0.tgz",
+ "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@antfu/install-pkg": "^1.1.0",
+ "@iconify/types": "^2.0.0",
+ "mlly": "^1.8.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
+ "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
+ "dev": true
+ },
+ "node_modules/@mermaid-js/mermaid-mindmap": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmmirror.com/@mermaid-js/mermaid-mindmap/-/mermaid-mindmap-9.3.0.tgz",
+ "integrity": "sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@braintree/sanitize-url": "^6.0.0",
+ "cytoscape": "^3.23.0",
+ "cytoscape-cose-bilkent": "^4.1.0",
+ "cytoscape-fcose": "^2.1.0",
+ "d3": "^7.0.0",
+ "khroma": "^2.0.0",
+ "non-layered-tidy-tree-layout": "^2.0.2"
+ }
+ },
+ "node_modules/@mermaid-js/mermaid-mindmap/node_modules/@braintree/sanitize-url": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmmirror.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz",
+ "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@mermaid-js/parser": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-0.6.3.tgz",
+ "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "langium": "3.3.1"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.1.tgz",
+ "integrity": "sha512-2thheikVEuU7ZxFXubPDOtspKn1x0yqaYQwvALVtEcvFhMifPADBrgRPyHV0TF3b+9BgvgjgagVyvA/UqPZHmg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.1.tgz",
+ "integrity": "sha512-t1lLYn4V9WgnIFHXy1d2Di/7gyzBWS8G5pQSXdZqfrdCGTwi1VasRMSS81DTYb+avDs/Zz4A6dzERki5oRYz1g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.1.tgz",
+ "integrity": "sha512-AH/wNWSEEHvs6t4iJ3RANxW5ZCK3fUnmf0gyMxWCesY1AlUj8jY7GC+rQE4wd3gwmZ9XDOpL0kcFnCjtN7FXlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.1.tgz",
+ "integrity": "sha512-dO0BIz/+5ZdkLZrVgQrDdW7m2RkrLwYTh2YMFG9IpBtlC1x1NPNSXkfczhZieOlOLEqgXOFH3wYHB7PmBtf+Bg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.1.tgz",
+ "integrity": "sha512-sWWgdQ1fq+XKrlda8PsMCfut8caFwZBmhYeoehJ05FdI0YZXk6ZyUjWLrIgbR/VgiGycrFKMMgp7eJ69HOF2pQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.1.tgz",
+ "integrity": "sha512-9OIiSuj5EsYQlmwhmFRA0LRO0dRRjdCVZA3hnmZe1rEwRk11Jy3ECGGq3a7RrVEZ0/pCsYWx8jG3IvcrJ6RCew==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.1.tgz",
+ "integrity": "sha512-0kuAkRK4MeIUbzQYu63NrJmfoUVicajoRAL1bpwdYIYRcs57iyIV9NLcuyDyDXE2GiZCL4uhKSYAnyWpjZkWow==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.1.tgz",
+ "integrity": "sha512-/6dYC9fZtfEY0vozpc5bx1RP4VrtEOhNQGb0HwvYNwXD1BBbwQ5cKIbUVVU7G2d5WRE90NfB922elN8ASXAJEA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.1.tgz",
+ "integrity": "sha512-ltUWy+sHeAh3YZ91NUsV4Xg3uBXAlscQe8ZOXRCVAKLsivGuJsrkawYPUEyCV3DYa9urgJugMLn8Z3Z/6CeyRQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.1.tgz",
+ "integrity": "sha512-BggMndzI7Tlv4/abrgLwa/dxNEMn2gC61DCLrTzw8LkpSKel4o+O+gtjbnkevZ18SKkeN3ihRGPuBxjaetWzWg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.1.tgz",
+ "integrity": "sha512-z/9rtlGd/OMv+gb1mNSjElasMf9yXusAxnRDrBaYB+eS1shFm6/4/xDH1SAISO5729fFKUkJ88TkGPRUh8WSAA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.1.tgz",
+ "integrity": "sha512-kXQVcWqDcDKw0S2E0TmhlTLlUgAmMVqPrJZR+KpH/1ZaZhLSl23GZpQVmawBQGVhyP5WXIsIQ/zqbDBBYmxm5w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.1.tgz",
+ "integrity": "sha512-CbFv/WMQsSdl+bpX6rVbzR4kAjSSBuDgCqb1l4J68UYsQNalz5wOqLGYj4ZI0thGpyX5kc+LLZ9CL+kpqDovZA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.1.tgz",
+ "integrity": "sha512-3Q3brDgA86gHXWHklrwdREKIrIbxC0ZgU8lwpj0eEKGBQH+31uPqr0P2v11pn0tSIxHvcdOWxa4j+YvLNx1i6g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.1.tgz",
+ "integrity": "sha512-tNg+jJcKR3Uwe4L0/wY3Ro0H+u3nrb04+tcq1GSYzBEmKLeOQF2emk1whxlzNqb6MMrQ2JOcQEpuuiPLyRcSIw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.1.tgz",
+ "integrity": "sha512-xGiIH95H1zU7naUyTKEyOA/I0aexNMUdO9qRv0bLKN3qu25bBdrxZHqA3PTJ24YNN/GdMzG4xkDcd/GvjuhfLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@shikijs/core": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmmirror.com/@shikijs/core/-/core-1.14.1.tgz",
+ "integrity": "sha512-KyHIIpKNaT20FtFPFjCQB5WVSTpLR/n+jQXhWHWVUMm9MaOaG9BGOG0MSyt7yA4+Lm+4c9rTc03tt3nYzeYSfw==",
+ "dev": true,
+ "dependencies": {
+ "@types/hast": "^3.0.4"
+ }
+ },
+ "node_modules/@shikijs/transformers": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmmirror.com/@shikijs/transformers/-/transformers-1.14.1.tgz",
+ "integrity": "sha512-JJqL8QBVCJh3L61jqqEXgFq1cTycwjcGj7aSmqOEsbxnETM9hRlaB74QuXvY/fVJNjbNt8nvWo0VwAXKvMSLRg==",
+ "dev": true,
+ "dependencies": {
+ "shiki": "1.14.1"
+ }
+ },
+ "node_modules/@types/d3": {
+ "version": "7.4.3",
+ "resolved": "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz",
+ "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/d3-axis": "*",
+ "@types/d3-brush": "*",
+ "@types/d3-chord": "*",
+ "@types/d3-color": "*",
+ "@types/d3-contour": "*",
+ "@types/d3-delaunay": "*",
+ "@types/d3-dispatch": "*",
+ "@types/d3-drag": "*",
+ "@types/d3-dsv": "*",
+ "@types/d3-ease": "*",
+ "@types/d3-fetch": "*",
+ "@types/d3-force": "*",
+ "@types/d3-format": "*",
+ "@types/d3-geo": "*",
+ "@types/d3-hierarchy": "*",
+ "@types/d3-interpolate": "*",
+ "@types/d3-path": "*",
+ "@types/d3-polygon": "*",
+ "@types/d3-quadtree": "*",
+ "@types/d3-random": "*",
+ "@types/d3-scale": "*",
+ "@types/d3-scale-chromatic": "*",
+ "@types/d3-selection": "*",
+ "@types/d3-shape": "*",
+ "@types/d3-time": "*",
+ "@types/d3-time-format": "*",
+ "@types/d3-timer": "*",
+ "@types/d3-transition": "*",
+ "@types/d3-zoom": "*"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-axis": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmmirror.com/@types/d3-axis/-/d3-axis-3.0.6.tgz",
+ "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-brush": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmmirror.com/@types/d3-brush/-/d3-brush-3.0.6.tgz",
+ "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-chord": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmmirror.com/@types/d3-chord/-/d3-chord-3.0.6.tgz",
+ "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-contour": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmmirror.com/@types/d3-contour/-/d3-contour-3.0.6.tgz",
+ "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-array": "*",
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmmirror.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-dispatch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
+ "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-dsv": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
+ "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-fetch": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
+ "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-dsv": "*"
+ }
+ },
+ "node_modules/@types/d3-force": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz",
+ "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-format": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz",
+ "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-geo": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-hierarchy": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
+ "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-polygon": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
+ "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-quadtree": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
+ "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-random": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.3.tgz",
+ "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-time-format": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmmirror.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
+ "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.5.tgz",
+ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
+ "dev": true
+ },
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz",
+ "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+ "dev": true
+ },
+ "node_modules/@types/markdown-it": {
+ "version": "14.1.2",
+ "resolved": "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+ "dev": true,
+ "dependencies": {
+ "@types/linkify-it": "^5",
+ "@types/mdurl": "^2"
+ }
+ },
+ "node_modules/@types/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+ "dev": true
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "dev": true
+ },
+ "node_modules/@types/web-bluetooth": {
+ "version": "0.0.20",
+ "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
+ "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
+ "dev": true
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.1.2.tgz",
+ "integrity": "sha512-nY9IwH12qeiJqumTCLJLE7IiNx7HZ39cbHaysEUd+Myvbz9KAqd2yq+U01Kab1R/H1BmiyM2ShTYlNH32Fzo3A==",
+ "dev": true,
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.4.38.tgz",
+ "integrity": "sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.24.7",
+ "@vue/shared": "3.4.38",
+ "entities": "^4.5.0",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.4.38.tgz",
+ "integrity": "sha512-Osc/c7ABsHXTsETLgykcOwIxFktHfGSUDkb05V61rocEfsFDcjDLH/IHJSNJP+/Sv9KeN2Lx1V6McZzlSb9EhQ==",
+ "dev": true,
+ "dependencies": {
+ "@vue/compiler-core": "3.4.38",
+ "@vue/shared": "3.4.38"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.4.38.tgz",
+ "integrity": "sha512-s5QfZ+9PzPh3T5H4hsQDJtI8x7zdJaew/dCGgqZ2630XdzaZ3AD8xGZfBqpT8oaD/p2eedd+pL8tD5vvt5ZYJQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.24.7",
+ "@vue/compiler-core": "3.4.38",
+ "@vue/compiler-dom": "3.4.38",
+ "@vue/compiler-ssr": "3.4.38",
+ "@vue/shared": "3.4.38",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.10",
+ "postcss": "^8.4.40",
+ "source-map-js": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.4.38.tgz",
+ "integrity": "sha512-YXznKFQ8dxYpAz9zLuVvfcXhc31FSPFDcqr0kyujbOwNhlmaNvL2QfIy+RZeJgSn5Fk54CWoEUeW+NVBAogGaw==",
+ "dev": true,
+ "dependencies": {
+ "@vue/compiler-dom": "3.4.38",
+ "@vue/shared": "3.4.38"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "7.3.9",
+ "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.3.9.tgz",
+ "integrity": "sha512-D+GTYtFg68bqSu66EugQUydsOqaDlPLNmYw5oYk8k81uBu9/bVTUrqlAJrAA9Am7MXhKz2gWdDkopY6sOBf/Bg==",
+ "dev": true,
+ "dependencies": {
+ "@vue/devtools-kit": "^7.3.9"
+ }
+ },
+ "node_modules/@vue/devtools-kit": {
+ "version": "7.3.9",
+ "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.3.9.tgz",
+ "integrity": "sha512-Gr17nA+DaQzqyhNx1DUJr1CJRzTRfbIuuC80ZgU8MD/qNO302tv9la+ROi+Uaw+ULVwU9T71GnwLy4n8m9Lspg==",
+ "dev": true,
+ "dependencies": {
+ "@vue/devtools-shared": "^7.3.9",
+ "birpc": "^0.2.17",
+ "hookable": "^5.5.3",
+ "mitt": "^3.0.1",
+ "perfect-debounce": "^1.0.0",
+ "speakingurl": "^14.0.1",
+ "superjson": "^2.2.1"
+ }
+ },
+ "node_modules/@vue/devtools-shared": {
+ "version": "7.3.9",
+ "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.3.9.tgz",
+ "integrity": "sha512-CdfMRZKXyI8vw+hqOcQIiLihB6Hbbi7WNZGp7LsuH1Qe4aYAFmTaKjSciRZ301oTnwmU/knC/s5OGuV6UNiNoA==",
+ "dev": true,
+ "dependencies": {
+ "rfdc": "^1.4.1"
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.4.38.tgz",
+ "integrity": "sha512-4vl4wMMVniLsSYYeldAKzbk72+D3hUnkw9z8lDeJacTxAkXeDAP1uE9xr2+aKIN0ipOL8EG2GPouVTH6yF7Gnw==",
+ "dev": true,
+ "dependencies": {
+ "@vue/shared": "3.4.38"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.4.38.tgz",
+ "integrity": "sha512-21z3wA99EABtuf+O3IhdxP0iHgkBs1vuoCAsCKLVJPEjpVqvblwBnTj42vzHRlWDCyxu9ptDm7sI2ZMcWrQqlA==",
+ "dev": true,
+ "dependencies": {
+ "@vue/reactivity": "3.4.38",
+ "@vue/shared": "3.4.38"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.4.38.tgz",
+ "integrity": "sha512-afZzmUreU7vKwKsV17H1NDThEEmdYI+GCAK/KY1U957Ig2NATPVjCROv61R19fjZNzMmiU03n79OMnXyJVN0UA==",
+ "dev": true,
+ "dependencies": {
+ "@vue/reactivity": "3.4.38",
+ "@vue/runtime-core": "3.4.38",
+ "@vue/shared": "3.4.38",
+ "csstype": "^3.1.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.4.38.tgz",
+ "integrity": "sha512-NggOTr82FbPEkkUvBm4fTGcwUY8UuTsnWC/L2YZBmvaQ4C4Jl/Ao4HHTB+l7WnFCt5M/dN3l0XLuyjzswGYVCA==",
+ "dev": true,
+ "dependencies": {
+ "@vue/compiler-ssr": "3.4.38",
+ "@vue/shared": "3.4.38"
+ },
+ "peerDependencies": {
+ "vue": "3.4.38"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.4.38.tgz",
+ "integrity": "sha512-q0xCiLkuWWQLzVrecPb0RMsNWyxICOjPrcrwxTUEHb1fsnvni4dcuyG7RT/Ie7VPTvnjzIaWzRMUBsrqNj/hhw==",
+ "dev": true
+ },
+ "node_modules/@vueuse/core": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-11.0.3.tgz",
+ "integrity": "sha512-RENlh64+SYA9XMExmmH1a3TPqeIuJBNNB/63GT35MZI+zpru3oMRUA6cEFr9HmGqEgUisurwGwnIieF6qu3aXw==",
+ "dev": true,
+ "dependencies": {
+ "@types/web-bluetooth": "^0.0.20",
+ "@vueuse/metadata": "11.0.3",
+ "@vueuse/shared": "11.0.3",
+ "vue-demi": ">=0.14.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@vueuse/core/node_modules/vue-demi": {
+ "version": "0.14.10",
+ "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
+ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vueuse/integrations": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmmirror.com/@vueuse/integrations/-/integrations-11.0.3.tgz",
+ "integrity": "sha512-w6CDisaxs19S5Fd+NPPLFaA3GoX5gxuxrbTTBu0EYap7oH13w75L6C/+7e9mcoF9akhcR6GyYajwVMQEjdapJg==",
+ "dev": true,
+ "dependencies": {
+ "@vueuse/core": "11.0.3",
+ "@vueuse/shared": "11.0.3",
+ "vue-demi": ">=0.14.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "async-validator": "^4",
+ "axios": "^1",
+ "change-case": "^5",
+ "drauu": "^0.4",
+ "focus-trap": "^7",
+ "fuse.js": "^7",
+ "idb-keyval": "^6",
+ "jwt-decode": "^4",
+ "nprogress": "^0.2",
+ "qrcode": "^1.5",
+ "sortablejs": "^1",
+ "universal-cookie": "^7"
+ },
+ "peerDependenciesMeta": {
+ "async-validator": {
+ "optional": true
+ },
+ "axios": {
+ "optional": true
+ },
+ "change-case": {
+ "optional": true
+ },
+ "drauu": {
+ "optional": true
+ },
+ "focus-trap": {
+ "optional": true
+ },
+ "fuse.js": {
+ "optional": true
+ },
+ "idb-keyval": {
+ "optional": true
+ },
+ "jwt-decode": {
+ "optional": true
+ },
+ "nprogress": {
+ "optional": true
+ },
+ "qrcode": {
+ "optional": true
+ },
+ "sortablejs": {
+ "optional": true
+ },
+ "universal-cookie": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vueuse/integrations/node_modules/vue-demi": {
+ "version": "0.14.10",
+ "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
+ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vueuse/metadata": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-11.0.3.tgz",
+ "integrity": "sha512-+FtbO4SD5WpsOcQTcC0hAhNlOid6QNLzqedtquTtQ+CRNBoAt9GuV07c6KNHK1wCmlq8DFPwgiLF2rXwgSHX5Q==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@vueuse/shared": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-11.0.3.tgz",
+ "integrity": "sha512-0rY2m6HS5t27n/Vp5cTDsKTlNnimCqsbh/fmT2LgE+aaU42EMfXo8+bNX91W9I7DDmxfuACXMmrd7d79JxkqWA==",
+ "dev": true,
+ "dependencies": {
+ "vue-demi": ">=0.14.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/@vueuse/shared/node_modules/vue-demi": {
+ "version": "0.14.10",
+ "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
+ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/algoliasearch": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/algoliasearch/-/algoliasearch-4.24.0.tgz",
+ "integrity": "sha512-bf0QV/9jVejssFBmz2HQLxUadxk574t4iwjCKp5E7NBzwKkrDEhKPISIIjAU/p6K5qDx3qoeh4+26zWN1jmw3g==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/cache-browser-local-storage": "4.24.0",
+ "@algolia/cache-common": "4.24.0",
+ "@algolia/cache-in-memory": "4.24.0",
+ "@algolia/client-account": "4.24.0",
+ "@algolia/client-analytics": "4.24.0",
+ "@algolia/client-common": "4.24.0",
+ "@algolia/client-personalization": "4.24.0",
+ "@algolia/client-search": "4.24.0",
+ "@algolia/logger-common": "4.24.0",
+ "@algolia/logger-console": "4.24.0",
+ "@algolia/recommend": "4.24.0",
+ "@algolia/requester-browser-xhr": "4.24.0",
+ "@algolia/requester-common": "4.24.0",
+ "@algolia/requester-node-http": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/algoliasearch/node_modules/@algolia/client-common": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-common/-/client-common-4.24.0.tgz",
+ "integrity": "sha512-bc2ROsNL6w6rqpl5jj/UywlIYC21TwSSoFHKl01lYirGMW+9Eek6r02Tocg4gZ8HAw3iBvu6XQiM3BEbmEMoiA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/requester-common": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/algoliasearch/node_modules/@algolia/client-search": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/client-search/-/client-search-4.24.0.tgz",
+ "integrity": "sha512-uRW6EpNapmLAD0mW47OXqTP8eiIx5F6qN9/x/7HHO6owL3N1IXqydGwW5nhDFBrV+ldouro2W1VX3XlcUXEFCA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/client-common": "4.24.0",
+ "@algolia/requester-common": "4.24.0",
+ "@algolia/transporter": "4.24.0"
+ }
+ },
+ "node_modules/algoliasearch/node_modules/@algolia/requester-browser-xhr": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.24.0.tgz",
+ "integrity": "sha512-Z2NxZMb6+nVXSjF13YpjYTdvV3032YTBSGm2vnYvYPA6mMxzM3v5rsCiSspndn9rzIW4Qp1lPHBvuoKJV6jnAA==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/requester-common": "4.24.0"
+ }
+ },
+ "node_modules/algoliasearch/node_modules/@algolia/requester-node-http": {
+ "version": "4.24.0",
+ "resolved": "https://registry.npmmirror.com/@algolia/requester-node-http/-/requester-node-http-4.24.0.tgz",
+ "integrity": "sha512-JF18yTjNOVYvU/L3UosRcvbPMGT9B+/GQWNWnenIImglzNVGpyzChkXLnrSf6uxwVNO6ESGu6oN8MqcGQcjQJw==",
+ "dev": true,
+ "dependencies": {
+ "@algolia/requester-common": "4.24.0"
+ }
+ },
+ "node_modules/birpc": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmmirror.com/birpc/-/birpc-0.2.17.tgz",
+ "integrity": "sha512-+hkTxhot+dWsLpp3gia5AkVHIsKlZybNT5gIYiDlNzJrmYPcTM9k5/w2uaj3IPpd7LlEYpmCj4Jj1nC41VhDFg==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/chevrotain": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmmirror.com/chevrotain/-/chevrotain-11.0.3.tgz",
+ "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@chevrotain/cst-dts-gen": "11.0.3",
+ "@chevrotain/gast": "11.0.3",
+ "@chevrotain/regexp-to-ast": "11.0.3",
+ "@chevrotain/types": "11.0.3",
+ "@chevrotain/utils": "11.0.3",
+ "lodash-es": "4.17.21"
+ }
+ },
+ "node_modules/chevrotain-allstar": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmmirror.com/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz",
+ "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash-es": "^4.17.21"
+ },
+ "peerDependencies": {
+ "chevrotain": "^11.0.0"
+ }
+ },
+ "node_modules/chevrotain/node_modules/lodash-es": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
+ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz",
+ "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/confbox": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
+ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/copy-anything": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-3.0.5.tgz",
+ "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
+ "dev": true,
+ "dependencies": {
+ "is-what": "^4.1.8"
+ },
+ "engines": {
+ "node": ">=12.13"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mesqueeb"
+ }
+ },
+ "node_modules/cose-base": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-1.0.3.tgz",
+ "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "layout-base": "^1.0.0"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "dev": true
+ },
+ "node_modules/cytoscape": {
+ "version": "3.33.1",
+ "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.1.tgz",
+ "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/cytoscape-cose-bilkent": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmmirror.com/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz",
+ "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cose-base": "^1.0.0"
+ },
+ "peerDependencies": {
+ "cytoscape": "^3.2.0"
+ }
+ },
+ "node_modules/cytoscape-fcose": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmmirror.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz",
+ "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cose-base": "^2.2.0"
+ },
+ "peerDependencies": {
+ "cytoscape": "^3.2.0"
+ }
+ },
+ "node_modules/cytoscape-fcose/node_modules/cose-base": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-2.2.0.tgz",
+ "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "layout-base": "^2.0.0"
+ }
+ },
+ "node_modules/cytoscape-fcose/node_modules/layout-base": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-2.0.1.tgz",
+ "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/d3": {
+ "version": "7.9.0",
+ "resolved": "https://registry.npmmirror.com/d3/-/d3-7.9.0.tgz",
+ "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "3",
+ "d3-axis": "3",
+ "d3-brush": "3",
+ "d3-chord": "3",
+ "d3-color": "3",
+ "d3-contour": "4",
+ "d3-delaunay": "6",
+ "d3-dispatch": "3",
+ "d3-drag": "3",
+ "d3-dsv": "3",
+ "d3-ease": "3",
+ "d3-fetch": "3",
+ "d3-force": "3",
+ "d3-format": "3",
+ "d3-geo": "3",
+ "d3-hierarchy": "3",
+ "d3-interpolate": "3",
+ "d3-path": "3",
+ "d3-polygon": "3",
+ "d3-quadtree": "3",
+ "d3-random": "3",
+ "d3-scale": "4",
+ "d3-scale-chromatic": "3",
+ "d3-selection": "3",
+ "d3-shape": "3",
+ "d3-time": "3",
+ "d3-time-format": "4",
+ "d3-timer": "3",
+ "d3-transition": "3",
+ "d3-zoom": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-axis": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/d3-axis/-/d3-axis-3.0.0.tgz",
+ "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-brush": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/d3-brush/-/d3-brush-3.0.0.tgz",
+ "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "3",
+ "d3-transition": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-chord": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/d3-chord/-/d3-chord-3.0.1.tgz",
+ "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-contour": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmmirror.com/d3-contour/-/d3-contour-4.0.2.tgz",
+ "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-delaunay": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmmirror.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+ "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "delaunator": "5"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dsv": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/d3-dsv/-/d3-dsv-3.0.1.tgz",
+ "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "commander": "7",
+ "iconv-lite": "0.6",
+ "rw": "1"
+ },
+ "bin": {
+ "csv2json": "bin/dsv2json.js",
+ "csv2tsv": "bin/dsv2dsv.js",
+ "dsv2dsv": "bin/dsv2dsv.js",
+ "dsv2json": "bin/dsv2json.js",
+ "json2csv": "bin/json2dsv.js",
+ "json2dsv": "bin/json2dsv.js",
+ "json2tsv": "bin/json2dsv.js",
+ "tsv2csv": "bin/dsv2dsv.js",
+ "tsv2json": "bin/dsv2json.js"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-fetch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/d3-fetch/-/d3-fetch-3.0.1.tgz",
+ "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-dsv": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-force": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz",
+ "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-quadtree": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-geo": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmmirror.com/d3-geo/-/d3-geo-3.1.1.tgz",
+ "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.5.0 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-hierarchy": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmmirror.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+ "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-polygon": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/d3-polygon/-/d3-polygon-3.0.1.tgz",
+ "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-quadtree": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+ "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-random": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/d3-random/-/d3-random-3.0.1.tgz",
+ "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-sankey": {
+ "version": "0.12.3",
+ "resolved": "https://registry.npmmirror.com/d3-sankey/-/d3-sankey-0.12.3.tgz",
+ "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-array": "1 - 2",
+ "d3-shape": "^1.2.0"
+ }
+ },
+ "node_modules/d3-sankey/node_modules/d3-array": {
+ "version": "2.12.1",
+ "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-2.12.1.tgz",
+ "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "internmap": "^1.0.0"
+ }
+ },
+ "node_modules/d3-sankey/node_modules/d3-path": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz",
+ "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-sankey/node_modules/d3-shape": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-1.3.7.tgz",
+ "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-path": "1"
+ }
+ },
+ "node_modules/d3-sankey/node_modules/internmap": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/internmap/-/internmap-1.0.1.tgz",
+ "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale-chromatic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+ "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-interpolate": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/dagre-d3-es": {
+ "version": "7.0.13",
+ "resolved": "https://registry.npmmirror.com/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz",
+ "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "d3": "^7.9.0",
+ "lodash-es": "^4.17.21"
+ }
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.19",
+ "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
+ "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/delaunator": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmmirror.com/delaunator/-/delaunator-5.0.1.tgz",
+ "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "robust-predicates": "^3.0.2"
+ }
+ },
+ "node_modules/dompurify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.1.tgz",
+ "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
+ "dev": true,
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true
+ },
+ "node_modules/focus-trap": {
+ "version": "7.5.4",
+ "resolved": "https://registry.npmmirror.com/focus-trap/-/focus-trap-7.5.4.tgz",
+ "integrity": "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==",
+ "dev": true,
+ "dependencies": {
+ "tabbable": "^6.2.0"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/hachure-fill": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmmirror.com/hachure-fill/-/hachure-fill-0.5.2.tgz",
+ "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hookable": {
+ "version": "5.5.3",
+ "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
+ "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
+ "dev": true
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/is-what": {
+ "version": "4.1.16",
+ "resolved": "https://registry.npmmirror.com/is-what/-/is-what-4.1.16.tgz",
+ "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.13"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mesqueeb"
+ }
+ },
+ "node_modules/katex": {
+ "version": "0.16.27",
+ "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.27.tgz",
+ "integrity": "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==",
+ "dev": true,
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^8.3.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
+ "node_modules/katex/node_modules/commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/khroma": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz",
+ "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==",
+ "dev": true
+ },
+ "node_modules/langium": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmmirror.com/langium/-/langium-3.3.1.tgz",
+ "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chevrotain": "~11.0.3",
+ "chevrotain-allstar": "~0.3.0",
+ "vscode-languageserver": "~9.0.1",
+ "vscode-languageserver-textdocument": "~1.0.11",
+ "vscode-uri": "~3.0.8"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/layout-base": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-1.0.2.tgz",
+ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.17.22",
+ "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.22.tgz",
+ "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.11",
+ "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.11.tgz",
+ "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0"
+ }
+ },
+ "node_modules/mark.js": {
+ "version": "8.11.1",
+ "resolved": "https://registry.npmmirror.com/mark.js/-/mark.js-8.11.1.tgz",
+ "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==",
+ "dev": true
+ },
+ "node_modules/marked": {
+ "version": "16.4.2",
+ "resolved": "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz",
+ "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/mermaid": {
+ "version": "11.12.2",
+ "resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.12.2.tgz",
+ "integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@braintree/sanitize-url": "^7.1.1",
+ "@iconify/utils": "^3.0.1",
+ "@mermaid-js/parser": "^0.6.3",
+ "@types/d3": "^7.4.3",
+ "cytoscape": "^3.29.3",
+ "cytoscape-cose-bilkent": "^4.1.0",
+ "cytoscape-fcose": "^2.2.0",
+ "d3": "^7.9.0",
+ "d3-sankey": "^0.12.3",
+ "dagre-d3-es": "7.0.13",
+ "dayjs": "^1.11.18",
+ "dompurify": "^3.2.5",
+ "katex": "^0.16.22",
+ "khroma": "^2.1.0",
+ "lodash-es": "^4.17.21",
+ "marked": "^16.2.1",
+ "roughjs": "^4.6.6",
+ "stylis": "^4.3.6",
+ "ts-dedent": "^2.2.0",
+ "uuid": "^11.1.0"
+ }
+ },
+ "node_modules/minisearch": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmmirror.com/minisearch/-/minisearch-7.1.0.tgz",
+ "integrity": "sha512-tv7c/uefWdEhcu6hvrfTihflgeEi2tN6VV7HJnCjK6VxM75QQJh4t9FwJCsA2EsRS8LCnu3W87CuGPWMocOLCA==",
+ "dev": true
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "dev": true
+ },
+ "node_modules/mlly": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.0.tgz",
+ "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "pathe": "^2.0.3",
+ "pkg-types": "^1.3.1",
+ "ufo": "^1.6.1"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/non-layered-tidy-tree-layout": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz",
+ "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/package-manager-detector": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
+ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-data-parser": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz",
+ "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/perfect-debounce": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+ "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+ "dev": true
+ },
+ "node_modules/picocolors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.0.1.tgz",
+ "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
+ "dev": true
+ },
+ "node_modules/pkg-types": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz",
+ "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.1.8",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1"
+ }
+ },
+ "node_modules/points-on-curve": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz",
+ "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/points-on-path": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmmirror.com/points-on-path/-/points-on-path-0.2.1.tgz",
+ "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-data-parser": "0.1.0",
+ "points-on-curve": "0.2.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.4.41",
+ "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.41.tgz",
+ "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.7",
+ "picocolors": "^1.0.1",
+ "source-map-js": "^1.2.0"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/preact": {
+ "version": "10.23.2",
+ "resolved": "https://registry.npmmirror.com/preact/-/preact-10.23.2.tgz",
+ "integrity": "sha512-kKYfePf9rzKnxOAKDpsWhg/ysrHPqT+yQ7UW4JjdnqjFIeNUnNcEJvhuA8fDenxAGWzUqtd51DfVg7xp/8T9NA==",
+ "dev": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
+ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+ "dev": true
+ },
+ "node_modules/robust-predicates": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.2.tgz",
+ "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
+ "dev": true,
+ "license": "Unlicense"
+ },
+ "node_modules/rollup": {
+ "version": "4.21.1",
+ "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.21.1.tgz",
+ "integrity": "sha512-ZnYyKvscThhgd3M5+Qt3pmhO4jIRR5RGzaSovB6Q7rGNrK5cUncrtLmcTTJVSdcKXyZjW8X8MB0JMSuH9bcAJg==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.5"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.21.1",
+ "@rollup/rollup-android-arm64": "4.21.1",
+ "@rollup/rollup-darwin-arm64": "4.21.1",
+ "@rollup/rollup-darwin-x64": "4.21.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.21.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.21.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.21.1",
+ "@rollup/rollup-linux-arm64-musl": "4.21.1",
+ "@rollup/rollup-linux-powerpc64le-gnu": "4.21.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.21.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.21.1",
+ "@rollup/rollup-linux-x64-gnu": "4.21.1",
+ "@rollup/rollup-linux-x64-musl": "4.21.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.21.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.21.1",
+ "@rollup/rollup-win32-x64-msvc": "4.21.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/roughjs": {
+ "version": "4.6.6",
+ "resolved": "https://registry.npmmirror.com/roughjs/-/roughjs-4.6.6.tgz",
+ "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hachure-fill": "^0.5.2",
+ "path-data-parser": "^0.1.0",
+ "points-on-curve": "^0.2.0",
+ "points-on-path": "^0.2.1"
+ }
+ },
+ "node_modules/rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/search-insights": {
+ "version": "2.17.0",
+ "resolved": "https://registry.npmmirror.com/search-insights/-/search-insights-2.17.0.tgz",
+ "integrity": "sha512-AskayU3QNsXQzSL6v4LTYST7NNfs2HWyHHB+sdORP9chsytAhro5XRfToAMI/LAVYgNbzowVZTMfBRodgbUHKg==",
+ "dev": true,
+ "peer": true
+ },
+ "node_modules/shiki": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmmirror.com/shiki/-/shiki-1.14.1.tgz",
+ "integrity": "sha512-FujAN40NEejeXdzPt+3sZ3F2dx1U24BY2XTY01+MG8mbxCiA2XukXdcbyMyLAHJ/1AUUnQd1tZlvIjefWWEJeA==",
+ "dev": true,
+ "dependencies": {
+ "@shikijs/core": "1.14.1",
+ "@types/hast": "^3.0.4"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.0.tgz",
+ "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/speakingurl": {
+ "version": "14.0.1",
+ "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz",
+ "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stylis": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz",
+ "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/superjson": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.1.tgz",
+ "integrity": "sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA==",
+ "dev": true,
+ "dependencies": {
+ "copy-anything": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tabbable": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmmirror.com/tabbable/-/tabbable-6.2.0.tgz",
+ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
+ "dev": true
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.0.2.tgz",
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ts-dedent": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmmirror.com/ts-dedent/-/ts-dedent-2.2.0.tgz",
+ "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.10"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.3",
+ "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.3.tgz",
+ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/uuid": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmmirror.com/uuid/-/uuid-11.1.0.tgz",
+ "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/esm/bin/uuid"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.2",
+ "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.2.tgz",
+ "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.41",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitepress": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmmirror.com/vitepress/-/vitepress-1.3.4.tgz",
+ "integrity": "sha512-I1/F6OW1xl3kW4PaIMC6snxjWgf3qfziq2aqsDoFc/Gt41WbcRv++z8zjw8qGRIJ+I4bUW7ZcKFDHHN/jkH9DQ==",
+ "dev": true,
+ "dependencies": {
+ "@docsearch/css": "^3.6.1",
+ "@docsearch/js": "^3.6.1",
+ "@shikijs/core": "^1.13.0",
+ "@shikijs/transformers": "^1.13.0",
+ "@types/markdown-it": "^14.1.2",
+ "@vitejs/plugin-vue": "^5.1.2",
+ "@vue/devtools-api": "^7.3.8",
+ "@vue/shared": "^3.4.38",
+ "@vueuse/core": "^11.0.0",
+ "@vueuse/integrations": "^11.0.0",
+ "focus-trap": "^7.5.4",
+ "mark.js": "8.11.1",
+ "minisearch": "^7.1.0",
+ "shiki": "^1.13.0",
+ "vite": "^5.4.1",
+ "vue": "^3.4.38"
+ },
+ "bin": {
+ "vitepress": "bin/vitepress.js"
+ },
+ "peerDependencies": {
+ "markdown-it-mathjax3": "^4",
+ "postcss": "^8"
+ },
+ "peerDependenciesMeta": {
+ "markdown-it-mathjax3": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitepress-plugin-mermaid": {
+ "version": "2.0.17",
+ "resolved": "https://registry.npmmirror.com/vitepress-plugin-mermaid/-/vitepress-plugin-mermaid-2.0.17.tgz",
+ "integrity": "sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==",
+ "dev": true,
+ "license": "MIT",
+ "optionalDependencies": {
+ "@mermaid-js/mermaid-mindmap": "^9.3.0"
+ },
+ "peerDependencies": {
+ "mermaid": "10 || 11",
+ "vitepress": "^1.0.0 || ^1.0.0-alpha"
+ }
+ },
+ "node_modules/vscode-jsonrpc": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
+ "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/vscode-languageserver": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmmirror.com/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
+ "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-languageserver-protocol": "3.17.5"
+ },
+ "bin": {
+ "installServerIntoExtension": "bin/installServerIntoExtension"
+ }
+ },
+ "node_modules/vscode-languageserver-protocol": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmmirror.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
+ "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "vscode-jsonrpc": "8.2.0",
+ "vscode-languageserver-types": "3.17.5"
+ }
+ },
+ "node_modules/vscode-languageserver-textdocument": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmmirror.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
+ "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-languageserver-types": {
+ "version": "3.17.5",
+ "resolved": "https://registry.npmmirror.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
+ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.0.8.tgz",
+ "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vue": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.4.38.tgz",
+ "integrity": "sha512-f0ZgN+mZ5KFgVv9wz0f4OgVKukoXtS3nwET4c2vLBGQR50aI8G0cqbFtLlX9Yiyg3LFGBitruPHt2PxwTduJEw==",
+ "dev": true,
+ "dependencies": {
+ "@vue/compiler-dom": "3.4.38",
+ "@vue/compiler-sfc": "3.4.38",
+ "@vue/runtime-dom": "3.4.38",
+ "@vue/server-renderer": "3.4.38",
+ "@vue/shared": "3.4.38"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3ef7cb7
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+ "scripts": {
+ "docs:dev": "vitepress dev docs",
+ "docs:build": "vitepress build docs",
+ "docs:preview": "vitepress preview docs"
+ },
+ "devDependencies": {
+ "mermaid": "^11.12.2",
+ "vitepress": "^1.3.4",
+ "vitepress-plugin-mermaid": "^2.0.17"
+ }
+}
diff --git a/static/images/qrcode/1.png b/static/images/qrcode/1.png
deleted file mode 100644
index c8f03ff..0000000
Binary files a/static/images/qrcode/1.png and /dev/null differ
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226/001_\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226\344\273\243\347\240\201.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226/001_\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226\344\273\243\347\240\201.py"
index d32d907..5241e88 100644
--- "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226/001_\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226\344\273\243\347\240\201.py"
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226/001_\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226\344\273\243\347\240\201.py"
@@ -4,9 +4,8 @@
# @Time : 2024/3/27 22:47
# @Desc : 分别使用两个库演示如何提取html文档结构数据
from bs4 import BeautifulSoup
-from parsel import Selector
-
from common import NoteContent
+from parsel import Selector
def parse_html_use_bs(html_content: str):
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226/002_\346\272\220\347\240\201\345\256\236\347\216\260_\345\220\214\346\255\245\347\211\210\346\234\254.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226/002_\346\272\220\347\240\201\345\256\236\347\216\260_\345\220\214\346\255\245\347\211\210\346\234\254.py"
index 899e2a7..4d9c555 100644
--- "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226/002_\346\272\220\347\240\201\345\256\236\347\216\260_\345\220\214\346\255\245\347\211\210\346\234\254.py"
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226/002_\346\272\220\347\240\201\345\256\236\347\216\260_\345\220\214\346\255\245\347\211\210\346\234\254.py"
@@ -8,7 +8,6 @@
import requests
from bs4 import BeautifulSoup
-
from common import NoteContent, NoteContentDetail, NotePushComment
FIRST_N_PAGE = 10 # 前N页的论坛帖子数据
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226/003_\346\272\220\347\240\201\345\256\236\347\216\260_\345\274\202\346\255\245\347\211\210\346\234\254.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226/003_\346\272\220\347\240\201\345\256\236\347\216\260_\345\274\202\346\255\245\347\211\210\346\234\254.py"
index 5261490..71e8de7 100644
--- "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226/003_\346\272\220\347\240\201\345\256\236\347\216\260_\345\274\202\346\255\245\347\211\210\346\234\254.py"
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/08_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2301_\351\235\231\346\200\201\347\275\221\351\241\265\346\225\260\346\215\256\346\217\220\345\217\226/003_\346\272\220\347\240\201\345\256\236\347\216\260_\345\274\202\346\255\245\347\211\210\346\234\254.py"
@@ -7,9 +7,8 @@
from typing import List
import httpx
-from parsel import Selector
-
from common import NoteContent, NoteContentDetail, NotePushComment
+from parsel import Selector
FIRST_N_PAGE = 10 # 前N页的论坛帖子数据
BASE_HOST = "https://www.ptt.cc"
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226/001_curl_to_request.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226/001_curl_to_request.py"
index 5f22d28..121e2b4 100644
--- "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226/001_curl_to_request.py"
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226/001_curl_to_request.py"
@@ -5,9 +5,10 @@
# @Desc : 下面代码是通过从chrom浏览器复制请求的curl命令转成python的代码,转换地址:https://hasdata.com/curl-to-python-converter
# @Desc : 今日的目标站点是雅虎财经的国外站点,他们已经关闭了中国大陆访问,所以需要开启全局VPN(科学上网工具)才能获得目标数据。
-import requests
import pprint
+import requests
+
cookies = {
'GUC': 'AQEBCAFmDYVmOUIdcARM&s=AQAAANxlE2ny&g=Zgw0yA',
'A1': 'd=AQABBBB0fGQCEKnzzPnIHq8Lm4HEj-GCp50FEgEBCAGFDWY5Zliia3sB_eMBAAcIEHR8ZOGCp50&S=AQAAAgF-nCWw8AxSZ-gyIaeg4aI',
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226/002_\346\272\220\347\240\201\345\256\236\347\216\260_\345\220\214\346\255\245\347\211\210\346\234\254.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226/002_\346\272\220\347\240\201\345\256\236\347\216\260_\345\220\214\346\255\245\347\211\210\346\234\254.py"
index 91aad28..0d1b872 100644
--- "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226/002_\346\272\220\347\240\201\345\256\236\347\216\260_\345\220\214\346\255\245\347\211\210\346\234\254.py"
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226/002_\346\272\220\347\240\201\345\256\236\347\216\260_\345\220\214\346\255\245\347\211\210\346\234\254.py"
@@ -7,10 +7,9 @@
import csv
import random
import time
-from typing import List, Dict, Any
+from typing import Any, Dict, List
import requests
-
from common import SymbolContent, make_req_params_and_headers
HOST = "https://query1.finance.yahoo.com"
@@ -69,7 +68,7 @@ def send_request(page_start: int, page_size: int) -> Dict[str, Any]:
response = requests.post(url=req_url, params=common_params, json=common_payload_data, headers=headers)
if response.status_code != 200:
- raise Exception("发起请求是发生异常,请求发生错误,原因:", response.text)
+ raise Exception("发起请求时发生异常,请求发生错误,原因:", response.text)
try:
response_dict: Dict = response.json()
return response_dict
@@ -118,7 +117,7 @@ def run_crawler(save_file_name: str) -> None:
"""
# step1 获取最大数据总量
max_total: int = get_max_total_count()
- # step2 遍历每一夜数据并解析存储到数据容器中
+ # step2 遍历每一页数据并解析存储到数据容器中
data_list: List[SymbolContent] = fetch_currency_data_list(max_total)
# step3 将数据容器中的数据保存csv
save_data_to_csv(save_file_name, data_list)
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226/003_\346\272\220\347\240\201\345\256\236\347\216\260_\345\274\202\346\255\245\347\211\210\346\234\254.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226/003_\346\272\220\347\240\201\345\256\236\347\216\260_\345\274\202\346\255\245\347\211\210\346\234\254.py"
index 27e0db7..47290e0 100644
--- "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226/003_\346\272\220\347\240\201\345\256\236\347\216\260_\345\274\202\346\255\245\347\211\210\346\234\254.py"
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/09_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2302_\345\212\250\346\200\201\346\225\260\346\215\256\346\217\220\345\217\226/003_\346\272\220\347\240\201\345\256\236\347\216\260_\345\274\202\346\255\245\347\211\210\346\234\254.py"
@@ -4,15 +4,14 @@
# @Time : 2024/4/7 17:08
# @Desc : https://finance.yahoo.com/crypto页面的加密货币表格数据
# @Desc : 下面的代码请挂全局的科学上网工具再跑
+import asyncio
import csv
import random
-import asyncio
import time
-from typing import List, Dict, Any
+from typing import Any, Dict, List
import aiofiles
import httpx
-
from common import SymbolContent, make_req_params_and_headers
HOST = "https://query1.finance.yahoo.com"
@@ -73,7 +72,7 @@ async def send_request(page_start: int, page_size: int) -> Dict[str, Any]:
response = await client.post(url=req_url, params=common_params, json=common_payload_data, headers=headers,
timeout=30)
if response.status_code != 200:
- raise Exception("发起请求是发生异常,请求发生错误,原因:", response.text)
+ raise Exception("发起请求时发生异常,请求发生错误,原因:", response.text)
try:
response_dict: Dict = response.json()
return response_dict
@@ -123,7 +122,7 @@ async def run_crawler(save_file_name: str) -> None:
"""
# step1 获取最大数据总量
max_total: int = await get_max_total_count()
- # step2 遍历每一夜数据并解析存储到数据容器中
+ # step2 遍历每一页数据并解析存储到数据容器中
data_list: List[SymbolContent] = await fetch_currency_data_list(max_total)
# step3 将数据容器中的数据保存csv
await save_data_to_csv(save_file_name, data_list)
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/abstract_store.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/abstract_store.py"
new file mode 100644
index 0000000..6129b7c
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/abstract_store.py"
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# @Author : relakkes@gmail.com
+# @Name : 程序员阿江-Relakkes
+# @Time : 2024/6/7 14:56
+# @Desc :
+from abc import ABC, abstractmethod
+
+from common import SymbolContent
+
+
+class AbstractStore(ABC):
+ @abstractmethod
+ async def save(self, save_item: SymbolContent):
+ """
+ 存储数据
+ :param save_item:
+ :return:
+ """
+ raise NotImplementedError
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/abstract_store_impl.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/abstract_store_impl.py"
new file mode 100644
index 0000000..59984ba
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/abstract_store_impl.py"
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+# @Author : relakkes@gmail.com
+# @Name : 程序员阿江-Relakkes
+# @Time : 2024/6/7 16:04
+# @Desc : 存储实现层
+import csv
+import json
+import os
+import pathlib
+import time
+from typing import Dict, Optional
+
+import aiofiles
+from abstract_store import AbstractStore
+from async_db import MysqlConnect, AsyncMysqlDB
+from common import SymbolContent
+
+
+class StoreFactory:
+ @staticmethod
+ def get_store(store_type: str) -> AbstractStore:
+ if store_type == "csv":
+ return CsvStoreImpl()
+ elif store_type == "json":
+ return JsonStoreImpl()
+ elif store_type == "db":
+ return DbStoreImpl()
+ else:
+ raise ValueError(f"Unknown store type: {store_type}")
+
+
+class CsvStoreImpl(AbstractStore):
+
+ def __init__(self):
+ self.csv_store_path = "data/csv"
+
+ def make_save_file_name(self) -> str:
+ """
+ make save file name
+ :return:
+ """
+ return f"{self.csv_store_path}/symbol_content_{int(time.time())}.csv"
+
+ async def save(self, save_item: SymbolContent):
+ """
+ save data to csv
+ :param save_item:
+ :return:
+ """
+ pathlib.Path(self.csv_store_path).mkdir(parents=True, exist_ok=True)
+ save_file_name = self.make_save_file_name()
+ async with aiofiles.open(save_file_name, mode='a+', encoding="utf-8-sig", newline="") as f:
+ f.fileno()
+ writer = csv.writer(f)
+ save_item_dict: Dict = save_item.model_dump()
+ if await f.tell() == 0:
+ await writer.writerow(save_item_dict.keys())
+ await writer.writerow(save_item_dict.values())
+
+
+class JsonStoreImpl(AbstractStore):
+
+ def __init__(self):
+ self.json_store_path = "data/json"
+
+ def make_save_file_name(self) -> str:
+ """
+ make save file name
+ :return:
+ """
+ return f"{self.json_store_path}/symbol_content_{int(time.time())}.json"
+
+ async def save(self, save_item: SymbolContent):
+ """
+ save data to json
+ :param save_item:
+ :return:
+ """
+ pathlib.Path(self.json_store_path).mkdir(parents=True, exist_ok=True)
+ save_file_name = self.make_save_file_name()
+ save_data_list = []
+ # todo 如果这里涉及并发写入,需要加锁, 可以查看MediaCrawler项目中的实现方式
+ # 先判断文件是否存在,如果存在则读取文件内容放到save_data_list中,然后再将新的数据添加到save_data_list中
+ if os.path.exists(save_file_name):
+ async with aiofiles.open(save_file_name, 'r', encoding='utf-8') as file:
+ save_data_list = json.loads(await file.read())
+ save_data_list.append(save_item.model_dump())
+
+ # 将数据写入到文件中
+ async with aiofiles.open(save_file_name, 'w', encoding='utf-8') as file:
+ await file.write(json.dumps(save_data_list, ensure_ascii=False))
+
+
+class DbStoreImpl(AbstractStore):
+ def __init__(self):
+ self.db: Optional[AsyncMysqlDB] = None
+
+ async def save(self, save_item: SymbolContent):
+ """
+ save data to db
+ :param save_item:
+ :return:
+ """
+ self.db = (await MysqlConnect().async_init()).get_db()
+ from sqls import (insert_symbol_content,
+ query_symbol_content_by_symbol,
+ update_symbol_content)
+
+ # 查询是否存在
+ exist_item = await query_symbol_content_by_symbol(self.db, save_item.symbol)
+ if exist_item.symbol:
+ # 更新
+ await update_symbol_content(self.db, save_item)
+ else:
+ # 插入
+ await insert_symbol_content(self.db, save_item)
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/async_db.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/async_db.py"
new file mode 100644
index 0000000..6225bfb
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/async_db.py"
@@ -0,0 +1,132 @@
+# -*- coding: utf-8 -*-
+# @Author : relakkes@gmail.com
+# @Name : 程序员阿江-Relakkes
+# @Time : 2024/6/7 17:08
+# @Desc :
+import os
+from typing import Any, Dict, List, Optional, Union
+
+import aiomysql
+
+
+class AsyncMysqlDB:
+ def __init__(self, pool: aiomysql.Pool) -> None:
+ self.__pool = pool
+
+ async def query(self, sql: str, *args: Union[str, int]) -> List[Dict[str, Any]]:
+ """
+ 从给定的 SQL 中查询记录,返回的是一个列表
+ :param sql: 查询的sql
+ :param args: sql中传递动态参数列表
+ :return:
+ """
+ async with self.__pool.acquire() as conn:
+ async with conn.cursor(aiomysql.DictCursor) as cur:
+ await cur.execute(sql, args)
+ data = await cur.fetchall()
+ return data or []
+
+ async def get_first(self, sql: str, *args: Union[str, int]) -> Union[Dict[str, Any], None]:
+ """
+ 从给定的 SQL 中查询记录,返回的是符合条件的第一个结果
+ :param sql: 查询的sql
+ :param args:sql中传递动态参数列表
+ :return:
+ """
+ async with self.__pool.acquire() as conn:
+ async with conn.cursor(aiomysql.DictCursor) as cur:
+ await cur.execute(sql, args)
+ data = await cur.fetchone()
+ return data
+
+ async def item_to_table(self, table_name: str, item: Dict[str, Any]) -> int:
+ """
+ 表中插入数据
+ :param table_name: 表名
+ :param item: 一条记录的字典信息
+ :return:
+ """
+ fields = list(item.keys())
+ values = list(item.values())
+ fields = [f'`{field}`' for field in fields]
+ fieldstr = ','.join(fields)
+ valstr = ','.join(['%s'] * len(item))
+ sql = "INSERT INTO %s (%s) VALUES(%s)" % (table_name, fieldstr, valstr)
+ async with self.__pool.acquire() as conn:
+ async with conn.cursor(aiomysql.DictCursor) as cur:
+ await cur.execute(sql, values)
+ lastrowid = cur.lastrowid
+ return lastrowid
+
+ async def update_table(self, table_name: str, updates: Dict[str, Any], field_where: str,
+ value_where: Union[str, int, float]) -> int:
+ """
+ 更新指定表的记录
+ :param table_name: 表名
+ :param updates: 需要更新的字段和值的 key - value 映射
+ :param field_where: update 语句 where 条件中的字段名
+ :param value_where: update 语句 where 条件中的字段值
+ :return:
+ """
+ upsets = []
+ values = []
+ for k, v in updates.items():
+ s = '`%s`=%%s' % k
+ upsets.append(s)
+ values.append(v)
+ upsets = ','.join(upsets)
+ sql = 'UPDATE %s SET %s WHERE %s="%s"' % (
+ table_name,
+ upsets,
+ field_where, value_where,
+ )
+ async with self.__pool.acquire() as conn:
+ async with conn.cursor() as cur:
+ rows = await cur.execute(sql, values)
+ return rows
+
+ async def execute(self, sql: str, *args: Union[str, int]) -> int:
+ """
+ 需要更新、写入等操作的 excute 执行语句
+ :param sql:
+ :param args:
+ :return:
+ """
+ async with self.__pool.acquire() as conn:
+ async with conn.cursor() as cur:
+ rows = await cur.execute(sql, args)
+ return rows
+
+
+class MysqlConnect:
+ _instance = None
+
+ def __new__(cls, *args, **kwargs):
+ if cls._instance is None:
+ cls._instance = super(MysqlConnect, cls).__new__(cls, *args, **kwargs)
+ return cls._instance
+
+ def __init__(self):
+ self.db: Optional[AsyncMysqlDB] = None
+
+ async def async_init(self):
+ if not hasattr(self, 'db') or self.db is None:
+ pool = await aiomysql.create_pool(
+ **self.mysql_conn_config,
+ autocommit=True,
+ )
+ self.db: AsyncMysqlDB = AsyncMysqlDB(pool)
+ return self
+
+ @property
+ def mysql_conn_config(self) -> Dict[str, str]:
+ return {
+ "host": os.getenv("MYSQL_HOST", "localhost"),
+ "port": int(os.getenv("MYSQL_PORT", 3306)),
+ "user": os.getenv("MYSQL_USER", "root"),
+ "password": os.getenv("MYSQL_PASSWORD", "123456"),
+ "db": os.getenv("MYSQL_DB", "crawler_turorial"),
+ }
+
+ def get_db(self) -> AsyncMysqlDB:
+ return self.db
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/common.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/common.py"
new file mode 100644
index 0000000..31a9adb
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/common.py"
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# @Author : relakkes@gmail.com
+# @Name : 程序员阿江-Relakkes
+# @Time : 2024/6/7 15:46
+# @Desc : 公共代码,包含模型类定义、请求头参数构造
+
+from pydantic import BaseModel, Field
+
+
+class SymbolContent(BaseModel):
+ symbol: str = Field(default="", title="Symbol")
+ name: str = Field(default="", title="Name")
+ price: str = Field(default="", title="价格盘中")
+ change_price: str = Field(default="", title="跌涨价格")
+ change_percent: str = Field(default="", title="跌涨幅")
+ market_price: str = Field(default="", title="市值")
+
+
+def make_req_params_and_headers():
+ headers = {
+ # cookies是必须的,并且和common_params的crumb参数绑定的。
+ 'cookie': 'axids=gam=y-lf5u4KlE2uJWDQYbXyUTkKMC2GVH7OUj~A&dv360=eS1XSElPM3l4RTJ1SHVVV3hNZVBDeG9aTDlDYXdaQ1dPNX5B&ydsp=y-_wiZU4RE2uIAxUbGalyjvJCoR6Le9iVT~A&tbla=y-gt2Wyc1E2uI4nvAYanhnPTMrhn4c3edZ~A; tbla_id=fde33964-c427-4b9c-b849-90a304938e21-tuctb84a272; GUCS=AT4NxRV-; GUC=AQEBCAFmZIhmlkIdcARM&s=AQAAALSYfqJT&g=ZmM4GA; A1=d=AQABBBB0fGQCEKnzzPnIHq8Lm4HEj-GCp50FEgEBCAGIZGaWZliia3sB_eMBAAcIEHR8ZOGCp50&S=AQAAAqkyAfNzjKXHZrWdWvU1Rvo; A3=d=AQABBBB0fGQCEKnzzPnIHq8Lm4HEj-GCp50FEgEBCAGIZGaWZliia3sB_eMBAAcIEHR8ZOGCp50&S=AQAAAqkyAfNzjKXHZrWdWvU1Rvo; A1S=d=AQABBBB0fGQCEKnzzPnIHq8Lm4HEj-GCp50FEgEBCAGIZGaWZliia3sB_eMBAAcIEHR8ZOGCp50&S=AQAAAqkyAfNzjKXHZrWdWvU1Rvo; cmp=t=1717778449&j=0&u=1---; gpp=DBAA; gpp_sid=-1',
+ 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
+ }
+ common_params = {
+ 'crumb': 'UllRf10isbP',
+ 'lang': 'en-US',
+ 'region': 'US',
+ 'formatted': 'true',
+ 'corsDomain': 'finance.yahoo.com',
+ }
+ common_payload_data = {
+ 'offset': 0, # 这个是分页其实位置
+ 'size': 25, # 这个是分页数量
+ 'sortType': 'DESC',
+ 'sortField': 'intradaymarketcap',
+ 'quoteType': 'CRYPTOCURRENCY',
+ 'query': {
+ 'operator': 'and',
+ 'operands': [
+ {
+ 'operator': 'eq',
+ 'operands': [
+ 'currency',
+ 'USD',
+ ],
+ },
+ {
+ 'operator': 'eq',
+ 'operands': [
+ 'exchange',
+ 'CCC',
+ ],
+ },
+ ],
+ },
+ 'userId': '',
+ 'userIdType': 'guid',
+ }
+ return common_params, headers, common_payload_data
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/main.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/main.py"
new file mode 100644
index 0000000..b6b7144
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/main.py"
@@ -0,0 +1,118 @@
+# -*- coding: utf-8 -*-
+# @Author : relakkes@gmail.com
+# @Name : 程序员阿江-Relakkes
+# @Time : 2024/5/18 18:09
+# @Desc : https://finance.yahoo.com/crypto页面的加密货币表格数据
+# @Desc : 下面的代码请挂全局的科学上网工具再跑
+# @Desc : 支持各种存储方式,如csv、json、db
+
+import asyncio
+import random
+from typing import Any, Dict, List
+
+import httpx
+from abstract_store_impl import StoreFactory
+from common import SymbolContent, make_req_params_and_headers
+
+HOST = "https://query1.finance.yahoo.com"
+SYMBOL_QUERY_API_URI = "/v1/finance/screener"
+PAGE_SIZE = 100 # 可选配置(25, 50, 100)
+
+
+def parse_symbol_content(quote_item: Dict) -> SymbolContent:
+ """
+ 数据提取
+ :param quote_item:
+ :return:
+ """
+ symbol_content = SymbolContent()
+ symbol_content.symbol = quote_item["symbol"]
+ symbol_content.name = quote_item["shortName"]
+ symbol_content.price = quote_item["regularMarketPrice"]["fmt"]
+ symbol_content.change_price = quote_item["regularMarketChange"]["fmt"]
+ symbol_content.change_percent = quote_item["regularMarketChangePercent"]["fmt"]
+ symbol_content.market_price = quote_item["marketCap"]["fmt"]
+ return symbol_content
+
+
+async def fetch_currency_data_list(max_total_count: int) -> List[SymbolContent]:
+ """
+ 通过最大币种数量计算爬取次数,解析数据存入数据容器
+ :param max_total_count:
+ :return:
+ """
+ symbol_data_list: List[SymbolContent] = []
+ page_start = 0
+ while page_start <= max_total_count:
+ response_dict: Dict = await send_request(page_start=page_start, page_size=PAGE_SIZE)
+ for quote in response_dict["finance"]["result"][0]["quotes"]:
+ parsed_content: SymbolContent = parse_symbol_content(quote)
+ print(parsed_content)
+ symbol_data_list.append(parsed_content)
+ page_start += PAGE_SIZE
+ await asyncio.sleep(random.random())
+ return symbol_data_list
+
+
+async def send_request(page_start: int, page_size: int) -> Dict[str, Any]:
+ """
+ 公共的发送请求的函数
+ :param page_start: 分页起始位置
+ :param page_size: 每一页的长度
+ :return:
+ """
+ print(f"[send_request] page_start:{page_start}")
+ req_url = HOST + SYMBOL_QUERY_API_URI
+ common_params, headers, common_payload_data = make_req_params_and_headers()
+ # 修改分页变动参数
+ common_payload_data["offset"] = page_start
+ common_payload_data["size"] = page_size
+
+ async with httpx.AsyncClient() as client:
+ response = await client.post(url=req_url, params=common_params, json=common_payload_data, headers=headers,
+ timeout=30)
+ if response.status_code != 200:
+ raise Exception("发起请求时发生异常,请求发生错误,原因:", response.text)
+ try:
+ response_dict: Dict = response.json()
+ return response_dict
+ except Exception as e:
+ raise e
+
+
+async def get_max_total_count() -> int:
+ """
+ 获取所有币种总数量
+ :return:
+ """
+ print("开始获取最大的币种数量")
+ try:
+ response_dict: Dict = await send_request(page_start=0, page_size=PAGE_SIZE)
+ total_num: int = response_dict["finance"]["result"][0]["total"]
+ print(f"获取到 {total_num} 种币种")
+ return total_num
+ except Exception as e:
+ print("错误信息:", e)
+ return 0
+
+
+async def run_crawler(data_save_type: str) -> None:
+ """
+ 爬虫主流程
+ :param data_save_type: 数据存储的类型,支持csv、json、db
+ :return:
+ """
+ # step1 获取最大数据总量
+ # max_total: int = await get_max_total_count()
+ max_total = 100 # 测试用
+ # step2 遍历每一页数据并解析存储到数据容器中
+ data_list: List[SymbolContent] = await fetch_currency_data_list(max_total)
+ # step3 将数据保存到指定存储介质中
+ for data_item in data_list:
+ await StoreFactory.get_store(data_save_type).save(data_item)
+
+
+if __name__ == '__main__':
+ _data_save_type = "csv" # 可选配置(csv、json、db)
+ asyncio.get_event_loop().run_until_complete(run_crawler(_data_save_type))
+ # asyncio.run(run_crawler(_data_save_type))
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/requirements.txt" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/requirements.txt"
new file mode 100644
index 0000000..a76c36f
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/requirements.txt"
@@ -0,0 +1,4 @@
+aiomysql==0.2.0
+aiofiles~=23.2.1
+httpx==0.24.0
+pydantic==2.7.3
\ No newline at end of file
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/sqls.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/sqls.py"
new file mode 100644
index 0000000..e30a191
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/sqls.py"
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+# @Author : relakkes@gmail.com
+# @Name : 程序员阿江-Relakkes
+# @Time : 2024/6/7 17:09
+# @Desc :
+
+from async_db import AsyncMysqlDB
+from common import SymbolContent
+
+
+async def insert_symbol_content(db: AsyncMysqlDB, symbol_content: SymbolContent) -> int:
+ """
+ 插入数据
+ :param db:
+ :param symbol_content:
+ :return:
+ """
+ item = symbol_content.model_dump()
+ return await db.item_to_table("symbol_content", item)
+
+
+async def update_symbol_content(db: AsyncMysqlDB, symbol_content: SymbolContent) -> int:
+ """
+ 更新数据
+ :param db:
+ :param symbol_content:
+ :return:
+ """
+ item = symbol_content.model_dump()
+ return await db.update_table("symbol_content", item, "symbol", symbol_content.symbol)
+
+
+async def query_symbol_content_by_symbol(db: AsyncMysqlDB, symbol: str) -> SymbolContent:
+ """
+ 查询数据
+ :param db:
+ :param symbol:
+ :return:
+ """
+ sql = f"select * from symbol_content where symbol = '{symbol}'"
+ rows = await db.query(sql)
+ if len(rows) > 0:
+ return SymbolContent(**rows[0])
+ return SymbolContent()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/symbol_content.sql" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/symbol_content.sql"
new file mode 100644
index 0000000..4f3f613
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/symbol_content.sql"
@@ -0,0 +1,11 @@
+create table symbol_content
+(
+ `id` int NOT NULL AUTO_INCREMENT COMMENT '自增ID',
+ `symbol` varchar(255) DEFAULT NULL COMMENT '货币',
+ `name` varchar(255) DEFAULT NULL COMMENT '名称',
+ `price` varchar(255) DEFAULT NULL COMMENT '价格盘中',
+ `change_price` varchar(255) DEFAULT NULL COMMENT '跌涨价格',
+ `change_percent` varchar(255) DEFAULT NULL COMMENT '跌涨百分比',
+ `market_price` varchar(255) DEFAULT NULL COMMENT '市值',
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='';;
\ No newline at end of file
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/test_pydantic_model.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/test_pydantic_model.py"
new file mode 100644
index 0000000..9c95b6b
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260/test_pydantic_model.py"
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+# @Author : relakkes@gmail.com
+# @Name : 程序员阿江-Relakkes
+# @Time : 2024/6/8 01:21
+# @Desc : pydantic模型基本使用
+
+# 导入Pydantic库的BaseModel基类
+from pydantic import BaseModel
+
+# 定义你的模型类
+class User(BaseModel):
+ id: int
+ name: str
+ age: int
+
+# 实例化你的模型类
+user = User(id=1, name="小明", age=18)
+# 将模型类转换成dict
+user_dict = user.model_dump()
+print(type(user_dict), user_dict) # {'id': 1, 'name': '小明', 'age': 18}
+
+# 将模型类转换成json
+user_json = user.model_dump_json()
+print(type(user_json), user_json) # {"id": 1, "name": "小明", "age": 18}
\ No newline at end of file
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/1_multi_process_demo.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/1_multi_process_demo.py"
new file mode 100644
index 0000000..7dc54c3
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/1_multi_process_demo.py"
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+import multiprocessing
+import time
+
+
+def worker(num):
+ print(f"Worker {num} started")
+ time.sleep(2)
+ print(f"Worker {num} finished")
+
+
+if __name__ == "__main__":
+ processes = []
+ for i in range(5):
+ p = multiprocessing.Process(target=worker, args=(i,))
+ processes.append(p)
+ p.start()
+
+ for p in processes:
+ p.join()
+
+ print("All processes completed")
\ No newline at end of file
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/2_multi_thread_demo.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/2_multi_thread_demo.py"
new file mode 100644
index 0000000..10104b7
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/2_multi_thread_demo.py"
@@ -0,0 +1,18 @@
+import threading
+import time
+
+def worker(num):
+ print(f"Thread {num} started")
+ time.sleep(2)
+ print(f"Thread {num} finished")
+
+threads = []
+for i in range(5):
+ t = threading.Thread(target=worker, args=(i,))
+ threads.append(t)
+ t.start()
+
+for t in threads:
+ t.join()
+
+print("All threads completed")
\ No newline at end of file
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/3_multi_coroutine_demo.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/3_multi_coroutine_demo.py"
new file mode 100644
index 0000000..ad6ec01
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/3_multi_coroutine_demo.py"
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+import asyncio
+
+async def worker(num):
+ print(f"Coroutine {num} started")
+ await asyncio.sleep(2)
+ print(f"Coroutine {num} finished")
+
+async def main():
+ tasks = [asyncio.create_task(worker(i)) for i in range(5)]
+ await asyncio.gather(*tasks)
+
+asyncio.run(main())
+print("All coroutines completed")
\ No newline at end of file
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/common.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/common.py"
new file mode 100644
index 0000000..dda1f12
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/common.py"
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+# @Author : relakkes@gmail.com
+# @Name : 程序员阿江-Relakkes
+# @Time : 2024/4/7 20:54
+# @Desc : 存放一些公共的函数
+from typing import List
+
+
+class SymbolContent:
+ symbol: str = ""
+ name: str = ""
+ price: str = "" # 价格(盘中)
+ change_price: str = "" # 跌涨价格
+ change_percent: str = "" # 跌涨幅
+ market_price: str = "" # 市值
+
+ @classmethod
+ def get_fields(cls) -> List[str]:
+ return [key for key in cls.__dict__.keys() if not key.startswith('__') and key != "get_fields"]
+
+ def __str__(self):
+ return f"""
+Symbol: {self.symbol}
+Name: {self.name}
+Price: {self.price}
+Change Price: {self.change_price}
+Change Percent: {self.change_percent}
+Market Price: {self.market_price}
+"""
+
+
+def make_req_params_and_headers():
+ headers = {
+ # cookies是必须的,并且和common_params的crumb参数绑定的。
+ 'cookie': 'GUC=AQEBCAFmDYVmOUIdcARM&s=AQAAANxlE2ny&g=Zgw0yA; A1=d=AQABBBB0fGQCEKnzzPnIHq8Lm4HEj-GCp50FEgEBCAGFDWY5Zliia3sB_eMBAAcIEHR8ZOGCp50&S=AQAAAgF-nCWw8AxSZ-gyIaeg4aI; A3=d=AQABBBB0fGQCEKnzzPnIHq8Lm4HEj-GCp50FEgEBCAGFDWY5Zliia3sB_eMBAAcIEHR8ZOGCp50&S=AQAAAgF-nCWw8AxSZ-gyIaeg4aI; axids=gam=y-lf5u4KlE2uJWDQYbXyUTkKMC2GVH7OUj~A&dv360=eS1XSElPM3l4RTJ1SHVVV3hNZVBDeG9aTDlDYXdaQ1dPNX5B&ydsp=y-_wiZU4RE2uIAxUbGalyjvJCoR6Le9iVT~A&tbla=y-gt2Wyc1E2uI4nvAYanhnPTMrhn4c3edZ~A; tbla_id=fde33964-c427-4b9c-b849-90a304938e21-tuctb84a272; cmp=t=1712472060&j=0&u=1YNN; gpp=DBABBg~BVoIgACA.QA; gpp_sid=8; A1S=d=AQABBBB0fGQCEKnzzPnIHq8Lm4HEj-GCp50FEgEBCAGFDWY5Zliia3sB_eMBAAcIEHR8ZOGCp50&S=AQAAAgF-nCWw8AxSZ-gyIaeg4aI&j=WORLD',
+ 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
+ }
+ common_params = {
+ 'crumb': 'UllRf10isbP',
+ 'lang': 'en-US',
+ 'region': 'US',
+ 'formatted': 'true',
+ 'corsDomain': 'finance.yahoo.com',
+ }
+ common_payload_data = {
+ 'offset': 0, # 这个是分页其实位置
+ 'size': 25, # 这个是分页数量
+ 'sortType': 'DESC',
+ 'sortField': 'intradaymarketcap',
+ 'quoteType': 'CRYPTOCURRENCY',
+ 'query': {
+ 'operator': 'and',
+ 'operands': [
+ {
+ 'operator': 'eq',
+ 'operands': [
+ 'currency',
+ 'USD',
+ ],
+ },
+ {
+ 'operator': 'eq',
+ 'operands': [
+ 'exchange',
+ 'CCC',
+ ],
+ },
+ ],
+ },
+ 'userId': '',
+ 'userIdType': 'guid',
+ }
+ return common_params, headers, common_payload_data
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/run_crawler_multi_coroutine.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/run_crawler_multi_coroutine.py"
new file mode 100644
index 0000000..356e960
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/run_crawler_multi_coroutine.py"
@@ -0,0 +1,149 @@
+# -*- coding: utf-8 -*-
+import asyncio
+import csv
+import time
+from typing import Any, Dict, List
+
+import aiofiles
+import httpx
+from common import SymbolContent, make_req_params_and_headers
+
+HOST = "https://query1.finance.yahoo.com"
+SYMBOL_QUERY_API_URI = "/v1/finance/screener"
+PAGE_SIZE = 100 # 可选配置(25, 50, 100)
+
+
+def parse_symbol_content(quote_item: Dict) -> SymbolContent:
+ """
+ 数据提取
+ :param quote_item:
+ :return:
+ """
+ symbol_content = SymbolContent()
+ symbol_content.symbol = quote_item["symbol"]
+ symbol_content.name = quote_item["shortName"]
+ symbol_content.price = quote_item["regularMarketPrice"]["fmt"]
+ symbol_content.change_price = quote_item["regularMarketChange"]["fmt"]
+ symbol_content.change_percent = quote_item["regularMarketChangePercent"]["fmt"]
+ symbol_content.market_price = quote_item["marketCap"]["fmt"]
+ return symbol_content
+
+
+async def send_request(page_start: int, page_size: int) -> Dict[str, Any]:
+ """
+ 公共的发送请求的函数
+ :param page_start: 分页起始位置
+ :param page_size: 每一页的长度
+ :return:
+ """
+ # print(f"[send_request] page_start:{page_start}")
+ req_url = HOST + SYMBOL_QUERY_API_URI
+ common_params, headers, common_payload_data = make_req_params_and_headers()
+ # 修改分页变动参数
+ common_payload_data["offset"] = page_start
+ common_payload_data["size"] = page_size
+
+ async with httpx.AsyncClient() as client:
+ response = await client.post(url=req_url, params=common_params, json=common_payload_data, headers=headers,
+ timeout=30)
+ if response.status_code != 200:
+ raise Exception("发起请求时发生异常,请求发生错误,原因:", response.text)
+ try:
+ response_dict: Dict = response.json()
+ return response_dict
+ except Exception as e:
+ raise e
+
+
+async def fetch_currency_data_single(page_start: int) -> List[SymbolContent]:
+ """
+ Fetch currency data for a single page.
+ :param page_start: Page start index.
+ :return: List of SymbolContent for the page.
+ """
+ try:
+ response_dict: Dict = await send_request(page_start=page_start, page_size=PAGE_SIZE)
+ return [
+ parse_symbol_content(quote) for quote in response_dict["finance"]["result"][0]["quotes"]
+ ]
+ except Exception as e:
+ print(f"Error fetching data for page_start={page_start}: {e}")
+ return []
+
+
+async def fetch_currency_data_list(max_total_count: int) -> List[SymbolContent]:
+ """
+ Fetch currency data using asyncio.
+ :param max_total_count: Maximum total count of currencies.
+ :return: List of all SymbolContent.
+ """
+ page_starts = list(range(0, max_total_count, PAGE_SIZE))
+ print(f"总共发起: {len(page_starts)} 次网络请求")
+
+ tasks = [fetch_currency_data_single(page_start) for page_start in page_starts]
+ results = await asyncio.gather(*tasks)
+
+ # 扁平化结果列表
+ return [item for sublist in results for item in sublist]
+
+
+async def get_max_total_count() -> int:
+ """
+ 获取所有币种总数量
+ :return:
+ """
+ print("开始获取最大的币种数量")
+ try:
+ response_dict: Dict = await send_request(page_start=0, page_size=PAGE_SIZE)
+ total_num: int = response_dict["finance"]["result"][0]["total"]
+ print(f"获取到 {total_num} 种币种")
+ return total_num
+ except Exception as e:
+ print("错误信息:", e)
+ return 0
+
+
+async def save_data_to_csv(save_file_name: str, currency_data_list: List[SymbolContent]) -> None:
+ """
+ 保存数据存储到CSV文件中
+ :param save_file_name: 保存的文件名
+ :param currency_data_list:
+ :return:
+ """
+ async with aiofiles.open(save_file_name, mode='w', newline='', encoding='utf-8') as file:
+ writer = csv.writer(file)
+ # 写入标题行
+ await file.write(','.join(SymbolContent.get_fields()) + '\n')
+ # 遍历数据列表,并将每个币种的名称写入CSV
+ for symbol in currency_data_list:
+ await file.write(f"{symbol.symbol},{symbol.name},{symbol.price},{symbol.change_price},{symbol.change_percent},{symbol.market_price}\n")
+
+
+async def run_crawler_async(save_file_name: str) -> None:
+ """
+ 爬虫主流程(异步并发版本)
+ :param save_file_name:
+ :return:
+ """
+ # step1 获取最大数据总量
+ max_total: int = await get_max_total_count()
+ # step2 遍历每一页数据并解析存储到数据容器中
+ data_list: List[SymbolContent] = await fetch_currency_data_list(max_total)
+ # step3 将数据容器中的数据保存csv
+ await save_data_to_csv(save_file_name, data_list)
+
+async def main():
+ """
+ 主函数
+ :return:
+ """
+ start_time = time.time()
+ save_csv_file_name = f"symbol_data_{int(start_time)}.csv"
+ await run_crawler_async(save_csv_file_name)
+ end_time = time.time()
+ print(f"asyncio调度协程执行程序耗时: {end_time - start_time} 秒")
+
+
+if __name__ == '__main__':
+ asyncio.run(main())
+
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/run_crawler_multi_process.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/run_crawler_multi_process.py"
new file mode 100644
index 0000000..f3b736d
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/run_crawler_multi_process.py"
@@ -0,0 +1,139 @@
+# -*- coding: utf-8 -*-
+import csv
+import time
+from typing import Any, Dict, List
+from multiprocessing import Pool, cpu_count
+
+import requests
+from common import SymbolContent, make_req_params_and_headers
+
+HOST = "https://query1.finance.yahoo.com"
+SYMBOL_QUERY_API_URI = "/v1/finance/screener"
+PAGE_SIZE = 100 # 可选配置(25, 50, 100)
+
+
+def parse_symbol_content(quote_item: Dict) -> SymbolContent:
+ """
+ 数据提取
+ :param quote_item:
+ :return:
+ """
+ symbol_content = SymbolContent()
+ symbol_content.symbol = quote_item["symbol"]
+ symbol_content.name = quote_item["shortName"]
+ symbol_content.price = quote_item["regularMarketPrice"]["fmt"]
+ symbol_content.change_price = quote_item["regularMarketChange"]["fmt"]
+ symbol_content.change_percent = quote_item["regularMarketChangePercent"]["fmt"]
+ symbol_content.market_price = quote_item["marketCap"]["fmt"]
+ return symbol_content
+
+
+def send_request(page_start: int, page_size: int) -> Dict[str, Any]:
+ """
+ 公共的发送请求的函数
+ :param page_start: 分页起始位置
+ :param page_size: 每一页的长度
+ :return:
+ """
+ # print(f"[send_request] page_start:{page_start}")
+ req_url = HOST + SYMBOL_QUERY_API_URI
+ common_params, headers, common_payload_data = make_req_params_and_headers()
+ # 修改分页变动参数
+ common_payload_data["offset"] = page_start
+ common_payload_data["size"] = page_size
+
+ response = requests.post(url=req_url, params=common_params, json=common_payload_data, headers=headers)
+ if response.status_code != 200:
+ raise Exception("发起请求时发生异常,请求发生错误,原因:", response.text)
+ try:
+ response_dict: Dict = response.json()
+ return response_dict
+ except Exception as e:
+ raise e
+
+
+def fetch_currency_data_single(page_start: int) -> List[SymbolContent]:
+ """
+ Fetch currency data for a single page.
+ :param page_start: Page start index.
+ :return: List of SymbolContent for the page.
+ """
+ try:
+ response_dict: Dict = send_request(page_start=page_start, page_size=PAGE_SIZE)
+ symbol_data_list: List[SymbolContent] = [
+ parse_symbol_content(quote) for quote in response_dict["finance"]["result"][0]["quotes"]
+ ]
+ return symbol_data_list
+ except Exception as e:
+ print(f"Error fetching data for page_start={page_start}: {e}")
+ return []
+
+
+def fetch_currency_data_list(max_total_count: int) -> List[SymbolContent]:
+ """
+ Fetch currency data using multiprocessing.
+ :param max_total_count: Maximum total count of currencies.
+ :return: List of all SymbolContent.
+ """
+ with Pool(processes=cpu_count()) as pool:
+ page_starts = list(range(0, max_total_count, PAGE_SIZE))
+ print(f"总共发起: {len(page_starts)} 次网络请求")
+ results = pool.map(fetch_currency_data_single, page_starts)
+
+ # Flatten the list of lists into a single list
+ return [item for sublist in results for item in sublist]
+
+
+def get_max_total_count() -> int:
+ """
+ 获取所有币种总数量
+ :return:
+ """
+ print("开始获取最大的币种数量")
+ try:
+ response_dict: Dict = send_request(page_start=0, page_size=PAGE_SIZE)
+ total_num: int = response_dict["finance"]["result"][0]["total"]
+ print(f"获取到 {total_num} 种币种")
+ return total_num
+ except Exception as e:
+ print("错误信息:", e)
+ return 0
+
+
+def save_data_to_csv(save_file_name: str, currency_data_list: List[SymbolContent]) -> None:
+ """
+ 保存数据存储到CSV文件中
+ :param save_file_name: 保存的文件名
+ :param currency_data_list:
+ :return:
+ """
+ with open(save_file_name, mode='w', newline='', encoding='utf-8') as file:
+ writer = csv.writer(file)
+ # 写入标题行
+ writer.writerow(SymbolContent.get_fields())
+ # 遍历数据列表,并将每个币种的名称写入CSV
+ for symbol in currency_data_list:
+ writer.writerow([symbol.symbol, symbol.name, symbol.price, symbol.change_price, symbol.change_percent,
+ symbol.market_price])
+
+
+def run_crawler_mp(save_file_name: str) -> None:
+ """
+ 爬虫主流程(多进程版本)
+ :param save_file_name:
+ :return:
+ """
+ # step1 获取最大数据总量
+ max_total: int = get_max_total_count()
+ # step2 遍历每一页数据并解析存储到数据容器中
+ data_list: List[SymbolContent] = fetch_currency_data_list(max_total)
+ # step3 将数据容器中的数据保存csv
+ save_data_to_csv(save_file_name, data_list)
+
+
+if __name__ == '__main__':
+ start_time = time.time()
+ save_csv_file_name = f"symbol_data_{int(start_time)}.csv"
+ run_crawler_mp(save_csv_file_name)
+ end_time = time.time()
+ print(f"多进程执行程序耗时: {end_time - start_time} 秒")
\ No newline at end of file
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/run_crawler_multi_thread.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/run_crawler_multi_thread.py"
new file mode 100644
index 0000000..529e0af
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260/run_crawler_multi_thread.py"
@@ -0,0 +1,141 @@
+# -*- coding: utf-8 -*-
+import csv
+import time
+from os import cpu_count
+from typing import Any, Dict, List
+from concurrent.futures import ThreadPoolExecutor
+
+import requests
+from common import SymbolContent, make_req_params_and_headers
+
+HOST = "https://query1.finance.yahoo.com"
+SYMBOL_QUERY_API_URI = "/v1/finance/screener"
+PAGE_SIZE = 100 # 可选配置(25, 50, 100)
+
+
+def parse_symbol_content(quote_item: Dict) -> SymbolContent:
+ """
+ 数据提取
+ :param quote_item:
+ :return:
+ """
+ symbol_content = SymbolContent()
+ symbol_content.symbol = quote_item["symbol"]
+ symbol_content.name = quote_item["shortName"]
+ symbol_content.price = quote_item["regularMarketPrice"]["fmt"]
+ symbol_content.change_price = quote_item["regularMarketChange"]["fmt"]
+ symbol_content.change_percent = quote_item["regularMarketChangePercent"]["fmt"]
+ symbol_content.market_price = quote_item["marketCap"]["fmt"]
+ return symbol_content
+
+
+def send_request(page_start: int, page_size: int) -> Dict[str, Any]:
+ """
+ 公共的发送请求的函数
+ :param page_start: 分页起始位置
+ :param page_size: 每一页的长度
+ :return:
+ """
+ # print(f"[send_request] page_start:{page_start}")
+ req_url = HOST + SYMBOL_QUERY_API_URI
+ common_params, headers, common_payload_data = make_req_params_and_headers()
+ # 修改分页变动参数
+ common_payload_data["offset"] = page_start
+ common_payload_data["size"] = page_size
+
+ response = requests.post(url=req_url, params=common_params, json=common_payload_data, headers=headers)
+ if response.status_code != 200:
+ raise Exception("发起请求时发生异常,请求发生错误,原因:", response.text)
+ try:
+ response_dict: Dict = response.json()
+ return response_dict
+ except Exception as e:
+ raise e
+
+
+def fetch_currency_data_single(page_start: int) -> List[SymbolContent]:
+ """
+ Fetch currency data for a single page.
+ :param page_start: Page start index.
+ :return: List of SymbolContent for the page.
+ """
+ try:
+ response_dict: Dict = send_request(page_start=page_start, page_size=PAGE_SIZE)
+ return [
+ parse_symbol_content(quote) for quote in response_dict["finance"]["result"][0]["quotes"]
+ ]
+ except Exception as e:
+ print(f"Error fetching data for page_start={page_start}: {e}")
+ return []
+
+
+def fetch_currency_data_list(max_total_count: int) -> List[SymbolContent]:
+ """
+ Fetch currency data using multithreading.
+ :param max_total_count: Maximum total count of currencies.
+ :return: List of all SymbolContent.
+ """
+ with ThreadPoolExecutor(max_workers=cpu_count() * 2) as executor:
+ page_starts = list(range(0, max_total_count, PAGE_SIZE))
+ print(f"总共发起: {len(page_starts)} 次网络请求")
+
+ # 使用 map 方法
+ results = list(executor.map(fetch_currency_data_single, page_starts))
+
+ # 扁平化结果列表
+ return [item for sublist in results for item in sublist]
+
+
+def get_max_total_count() -> int:
+ """
+ 获取所有币种总数量
+ :return:
+ """
+ print("开始获取最大的币种数量")
+ try:
+ response_dict: Dict = send_request(page_start=0, page_size=PAGE_SIZE)
+ total_num: int = response_dict["finance"]["result"][0]["total"]
+ print(f"获取到 {total_num} 种币种")
+ return total_num
+ except Exception as e:
+ print("错误信息:", e)
+ return 0
+
+
+def save_data_to_csv(save_file_name: str, currency_data_list: List[SymbolContent]) -> None:
+ """
+ 保存数据存储到CSV文件中
+ :param save_file_name: 保存的文件名
+ :param currency_data_list:
+ :return:
+ """
+ with open(save_file_name, mode='w', newline='', encoding='utf-8') as file:
+ writer = csv.writer(file)
+ # 写入标题行
+ writer.writerow(SymbolContent.get_fields())
+ # 遍历数据列表,并将每个币种的名称写入CSV
+ for symbol in currency_data_list:
+ writer.writerow([symbol.symbol, symbol.name, symbol.price, symbol.change_price, symbol.change_percent,
+ symbol.market_price])
+
+
+def run_crawler_mt(save_file_name: str) -> None:
+ """
+ 爬虫主流程(多线程版本)
+ :param save_file_name:
+ :return:
+ """
+ # step1 获取最大数据总量
+ max_total: int = get_max_total_count()
+ # step2 遍历每一页数据并解析存储到数据容器中
+ data_list: List[SymbolContent] = fetch_currency_data_list(max_total)
+ # step3 将数据容器中的数据保存csv
+ save_data_to_csv(save_file_name, data_list)
+
+
+if __name__ == '__main__':
+ start_time = time.time()
+ save_csv_file_name = f"symbol_data_{int(start_time)}.csv"
+ run_crawler_mt(save_csv_file_name)
+ end_time = time.time()
+ print(f"多线程执行程序耗时: {end_time - start_time} 秒")
\ No newline at end of file
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/main.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/main.py"
index e380e06..461032f 100644
--- "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/main.py"
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\345\205\245\351\227\250/main.py"
@@ -3,9 +3,11 @@
# @Time : 2024/3/24 16:35
# @Desc :
-import httpx
import asyncio
+import httpx
+
+
async def post_data():
data = {'name': '程序员阿江','email':'relakkes@gmail.com'}
async with httpx.AsyncClient() as client:
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/README.md" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/README.md"
new file mode 100644
index 0000000..937f08a
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/README.md"
@@ -0,0 +1,33 @@
+# 第01章:工程化爬虫开发规范
+
+展示日志系统、配置管理、异常处理等工程化实践。
+
+## 快速开始
+
+### 使用 uv 安装依赖
+
+```bash
+# 进入本章目录
+cd 01_工程化爬虫开发规范
+
+# 安装依赖
+uv sync
+
+# 运行示例
+uv run python logger_demo.py
+uv run python exception_demo.py
+uv run python refactored_crawler/main.py
+```
+
+### 核心依赖
+
+- `httpx` - 异步HTTP客户端
+- `pydantic` - 数据验证
+- `parsel` - HTML解析
+- `loguru` - 日志系统
+
+## 主要文件
+
+- `logger_demo.py` - 日志系统演示
+- `exception_demo.py` - 异常处理演示
+- `refactored_crawler/` - 工程化改造后的完整爬虫项目
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/config/__init__.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/config/__init__.py"
new file mode 100644
index 0000000..6be28b0
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/config/__init__.py"
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# @Desc: 配置模块
+from .settings import settings, CrawlerSettings
+
+__all__ = ["settings", "CrawlerSettings"]
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/config/settings.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/config/settings.py"
new file mode 100644
index 0000000..2c837e9
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/config/settings.py"
@@ -0,0 +1,115 @@
+# -*- coding: utf-8 -*-
+# @Desc: 爬虫配置管理模块
+# 使用 pydantic-settings 进行配置管理,支持环境变量和 .env 文件
+
+from pydantic_settings import BaseSettings
+from pydantic import Field
+from typing import Optional
+import os
+
+
+class CrawlerSettings(BaseSettings):
+ """
+ 爬虫项目配置类
+
+ 配置优先级(从高到低):
+ 1. 环境变量 (CRAWLER_XXX)
+ 2. .env 文件
+ 3. 默认值
+ """
+
+ # 运行环境配置
+ env: str = Field(default="development", description="运行环境: development/production")
+ debug: bool = Field(default=False, description="调试模式")
+ log_level: str = Field(default="INFO", description="日志级别: DEBUG/INFO/WARNING/ERROR")
+
+ # HTTP 请求配置
+ request_timeout: int = Field(default=30, description="请求超时时间(秒)")
+ max_retries: int = Field(default=3, description="最大重试次数")
+ retry_delay: float = Field(default=1.0, description="重试基础延迟(秒)")
+ retry_max_delay: float = Field(default=60.0, description="重试最大延迟(秒)")
+
+ # 并发控制配置
+ max_concurrent: int = Field(default=10, description="最大并发请求数")
+ request_delay: float = Field(default=0.5, description="请求间隔(秒)")
+
+ # 数据库配置
+ db_host: str = Field(default="localhost", description="数据库主机")
+ db_port: int = Field(default=3306, description="数据库端口")
+ db_user: str = Field(default="root", description="数据库用户名")
+ db_password: str = Field(default="", description="数据库密码")
+ db_name: str = Field(default="crawler_db", description="数据库名称")
+
+ # 代理配置
+ proxy_url: Optional[str] = Field(default=None, description="HTTP代理地址")
+
+ # 存储配置
+ output_dir: str = Field(default="./data", description="数据输出目录")
+ log_dir: str = Field(default="./logs", description="日志输出目录")
+
+ class Config:
+ # .env 文件路径
+ env_file = ".env"
+ env_file_encoding = "utf-8"
+ # 环境变量前缀
+ env_prefix = "CRAWLER_"
+ # 额外字段处理
+ extra = "ignore"
+
+ @property
+ def db_url(self) -> str:
+ """构建数据库连接URL"""
+ return f"mysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
+
+ def ensure_dirs(self):
+ """确保必要的目录存在"""
+ os.makedirs(self.output_dir, exist_ok=True)
+ os.makedirs(self.log_dir, exist_ok=True)
+
+
+# 全局配置实例(单例模式)
+settings = CrawlerSettings()
+
+
+# 开发环境配置
+class DevelopmentSettings(CrawlerSettings):
+ """开发环境配置"""
+ env: str = "development"
+ debug: bool = True
+ log_level: str = "DEBUG"
+ max_concurrent: int = 5
+
+
+# 生产环境配置
+class ProductionSettings(CrawlerSettings):
+ """生产环境配置"""
+ env: str = "production"
+ debug: bool = False
+ log_level: str = "INFO"
+ max_concurrent: int = 20
+
+
+def get_settings() -> CrawlerSettings:
+ """
+ 根据环境变量返回对应的配置实例
+
+ 设置环境变量 CRAWLER_ENV=production 可切换到生产配置
+ """
+ env = os.getenv("CRAWLER_ENV", "development")
+ settings_map = {
+ "development": DevelopmentSettings,
+ "production": ProductionSettings,
+ }
+ settings_class = settings_map.get(env, DevelopmentSettings)
+ return settings_class()
+
+
+if __name__ == "__main__":
+ # 测试配置
+ config = get_settings()
+ print(f"当前环境: {config.env}")
+ print(f"调试模式: {config.debug}")
+ print(f"日志级别: {config.log_level}")
+ print(f"请求超时: {config.request_timeout}秒")
+ print(f"最大并发: {config.max_concurrent}")
+ print(f"数据库URL: {config.db_url}")
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/exception_demo.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/exception_demo.py"
new file mode 100644
index 0000000..4abf6b9
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/exception_demo.py"
@@ -0,0 +1,300 @@
+# -*- coding: utf-8 -*-
+# @Desc: 异常处理与重试机制演示
+# 演示自定义异常类、tenacity 重试库的使用
+
+import asyncio
+import random
+from typing import Optional
+
+from loguru import logger
+from tenacity import (
+ retry,
+ stop_after_attempt,
+ wait_exponential,
+ wait_random_exponential,
+ retry_if_exception_type,
+ before_sleep_log,
+ RetryError
+)
+
+
+# ==================== 自定义异常类 ====================
+
+class CrawlerException(Exception):
+ """爬虫基础异常类"""
+
+ def __init__(self, message: str, url: Optional[str] = None):
+ self.message = message
+ self.url = url
+ super().__init__(self.message)
+
+ def __str__(self):
+ if self.url:
+ return f"{self.message} (URL: {self.url})"
+ return self.message
+
+
+class RequestException(CrawlerException):
+ """HTTP 请求异常"""
+ pass
+
+
+class TimeoutException(RequestException):
+ """请求超时异常"""
+ pass
+
+
+class HTTPStatusException(RequestException):
+ """HTTP 状态码异常"""
+
+ def __init__(self, message: str, status_code: int, url: Optional[str] = None):
+ self.status_code = status_code
+ super().__init__(message, url)
+
+ def __str__(self):
+ return f"HTTP {self.status_code}: {self.message}"
+
+
+class RateLimitException(RequestException):
+ """触发速率限制异常"""
+ pass
+
+
+class IPBlockedException(RequestException):
+ """IP 被封禁异常"""
+ pass
+
+
+class ParseException(CrawlerException):
+ """数据解析异常"""
+ pass
+
+
+class StorageException(CrawlerException):
+ """数据存储异常"""
+ pass
+
+
+class LoginRequiredException(CrawlerException):
+ """需要登录异常"""
+ pass
+
+
+# ==================== 重试装饰器示例 ====================
+
+@retry(
+ stop=stop_after_attempt(3), # 最多重试 3 次
+ wait=wait_exponential(multiplier=1, max=10), # 指数退避: 1s, 2s, 4s...最长10s
+ retry=retry_if_exception_type(TimeoutException), # 只对超时异常重试
+ before_sleep=before_sleep_log(logger, "WARNING") # 重试前记录日志
+)
+async def fetch_with_basic_retry(url: str) -> str:
+ """基础重试示例"""
+ # 模拟随机超时
+ if random.random() < 0.7: # 70% 概率超时
+ logger.debug(f"模拟请求超时: {url}")
+ raise TimeoutException("请求超时", url)
+
+ logger.info(f"请求成功: {url}")
+ return f"Response from {url}"
+
+
+@retry(
+ stop=stop_after_attempt(5),
+ wait=wait_random_exponential(multiplier=1, min=1, max=30), # 随机指数退避
+ retry=retry_if_exception_type((TimeoutException, RateLimitException)),
+ reraise=True # 重试用尽后重新抛出原始异常
+)
+async def fetch_with_advanced_retry(url: str) -> str:
+ """高级重试示例 - 更多配置项"""
+ dice = random.random()
+
+ if dice < 0.4:
+ raise TimeoutException("连接超时", url)
+ elif dice < 0.6:
+ raise RateLimitException("触发速率限制", url)
+
+ return f"Success: {url}"
+
+
+def create_retry_decorator(
+ max_attempts: int = 3,
+ base_delay: float = 1.0,
+ max_delay: float = 60.0
+):
+ """
+ 工厂函数:创建可配置的重试装饰器
+
+ Args:
+ max_attempts: 最大重试次数
+ base_delay: 基础延迟时间
+ max_delay: 最大延迟时间
+ """
+ return retry(
+ stop=stop_after_attempt(max_attempts),
+ wait=wait_exponential(multiplier=base_delay, max=max_delay),
+ retry=retry_if_exception_type((
+ TimeoutException,
+ RateLimitException
+ )),
+ reraise=True
+ )
+
+
+# 使用工厂函数创建装饰器
+@create_retry_decorator(max_attempts=4, base_delay=2.0)
+async def fetch_important_data(url: str) -> dict:
+ """重要数据获取 - 使用更多重试次数"""
+ if random.random() < 0.6:
+ raise TimeoutException("超时", url)
+ return {"data": "important", "url": url}
+
+
+# ==================== 异常处理最佳实践 ====================
+
+async def crawl_page(url: str) -> Optional[dict]:
+ """
+ 爬取单个页面的示例 - 展示异常处理最佳实践
+ """
+ try:
+ # 模拟请求
+ result = await fetch_with_basic_retry(url)
+ return {"url": url, "content": result}
+
+ except TimeoutException as e:
+ # 可恢复的异常 - 记录警告并返回 None
+ logger.warning(f"页面爬取超时,跳过: {e}")
+ return None
+
+ except RateLimitException as e:
+ # 需要特殊处理的异常
+ logger.warning(f"触发速率限制: {e}")
+ # 这里可以切换代理或增加延迟
+ return None
+
+ except IPBlockedException as e:
+ # 严重异常 - 向上抛出
+ logger.error(f"IP被封禁: {e}")
+ raise
+
+ except CrawlerException as e:
+ # 其他爬虫异常
+ logger.error(f"爬虫异常: {e}")
+ return None
+
+ except Exception as e:
+ # 未预期的异常 - 记录完整堆栈
+ logger.exception(f"未知异常: {e}")
+ raise
+
+
+async def run_crawler(urls: list):
+ """
+ 爬虫主程序 - 展示全局异常处理
+ """
+ results = []
+
+ try:
+ for url in urls:
+ logger.info(f"开始爬取: {url}")
+ result = await crawl_page(url)
+ if result:
+ results.append(result)
+ # 请求间隔
+ await asyncio.sleep(0.5)
+
+ logger.info(f"爬取完成,成功: {len(results)}/{len(urls)}")
+ return results
+
+ except IPBlockedException:
+ logger.critical("IP被封禁,终止爬虫")
+ raise
+
+ except asyncio.CancelledError:
+ logger.warning("任务被取消")
+ raise
+
+ except Exception as e:
+ logger.exception(f"爬虫运行异常: {e}")
+ raise
+
+ finally:
+ # 清理工作
+ logger.info("执行清理工作...")
+
+
+# ==================== 演示函数 ====================
+
+async def demo_basic_retry():
+ """演示基础重试"""
+ print("\n" + "=" * 50)
+ print("演示 1: 基础重试机制")
+ print("=" * 50)
+
+ url = "https://example.com/data"
+ try:
+ result = await fetch_with_basic_retry(url)
+ print(f"结果: {result}")
+ except TimeoutException as e:
+ print(f"重试用尽后仍然失败: {e}")
+
+
+async def demo_advanced_retry():
+ """演示高级重试"""
+ print("\n" + "=" * 50)
+ print("演示 2: 高级重试机制")
+ print("=" * 50)
+
+ url = "https://example.com/important"
+ try:
+ result = await fetch_with_advanced_retry(url)
+ print(f"结果: {result}")
+ except (TimeoutException, RateLimitException) as e:
+ print(f"重试用尽: {e}")
+
+
+async def demo_exception_handling():
+ """演示异常处理"""
+ print("\n" + "=" * 50)
+ print("演示 3: 异常处理最佳实践")
+ print("=" * 50)
+
+ urls = [
+ "https://example.com/page1",
+ "https://example.com/page2",
+ "https://example.com/page3",
+ ]
+
+ try:
+ results = await run_crawler(urls)
+ print(f"成功获取 {len(results)} 个页面")
+ except Exception as e:
+ print(f"爬虫异常终止: {e}")
+
+
+async def main():
+ """主函数"""
+ # 配置日志
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="DEBUG"
+ )
+
+ print("=" * 50)
+ print("异常处理与重试机制演示")
+ print("=" * 50)
+
+ # 运行演示
+ await demo_basic_retry()
+ await demo_advanced_retry()
+ await demo_exception_handling()
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/logger_demo.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/logger_demo.py"
new file mode 100644
index 0000000..4c0e768
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/logger_demo.py"
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+# @Desc: loguru 日志库使用演示
+# 演示 loguru 的各种功能:日志级别、文件输出、日志轮转等
+
+import sys
+from loguru import logger
+
+
+def basic_usage():
+ """基础用法演示"""
+ print("=" * 50)
+ print("1. 基础日志输出")
+ print("=" * 50)
+
+ # loguru 默认配置下的日志输出
+ logger.debug("这是 DEBUG 级别 - 用于详细的调试信息")
+ logger.info("这是 INFO 级别 - 用于正常的运行信息")
+ logger.warning("这是 WARNING 级别 - 用于警告信息")
+ logger.error("这是 ERROR 级别 - 用于错误信息")
+ logger.critical("这是 CRITICAL 级别 - 用于严重错误")
+
+
+def custom_format():
+ """自定义日志格式"""
+ print("\n" + "=" * 50)
+ print("2. 自定义日志格式")
+ print("=" * 50)
+
+ # 移除默认处理器
+ logger.remove()
+
+ # 添加自定义格式的处理器
+ logger.add(
+ sys.stderr,
+ format="{time:YYYY-MM-DD HH:mm:ss.SSS} | "
+ "{level: <8} | "
+ "{name}:{function}:{line} | "
+ "{message}",
+ level="DEBUG",
+ colorize=True
+ )
+
+ logger.info("使用自定义格式的日志输出")
+ logger.debug("包含时间、级别、文件名、函数名、行号等信息")
+
+
+def file_logging():
+ """文件日志演示"""
+ print("\n" + "=" * 50)
+ print("3. 文件日志输出")
+ print("=" * 50)
+
+ # 添加文件日志
+ # rotation: 日志轮转策略
+ # retention: 日志保留策略
+ # compression: 旧日志压缩
+ logger.add(
+ "logs/demo_{time:YYYY-MM-DD}.log",
+ rotation="100 MB", # 文件达到 100MB 时轮转
+ retention="7 days", # 保留 7 天的日志
+ compression="zip", # 压缩旧日志
+ encoding="utf-8",
+ level="DEBUG"
+ )
+
+ logger.info("这条日志会同时输出到控制台和文件")
+ logger.debug("文件日志支持自动轮转和压缩")
+
+
+def error_logging():
+ """错误日志处理"""
+ print("\n" + "=" * 50)
+ print("4. 异常日志记录")
+ print("=" * 50)
+
+ # 单独的错误日志文件
+ logger.add(
+ "logs/error_{time:YYYY-MM-DD}.log",
+ rotation="00:00", # 每天午夜轮转
+ retention="30 days", # 保留 30 天
+ level="ERROR", # 只记录 ERROR 及以上级别
+ encoding="utf-8"
+ )
+
+ try:
+ result = 1 / 0
+ except ZeroDivisionError:
+ # 使用 exception() 方法会自动记录完整的堆栈信息
+ logger.exception("捕获到除零错误")
+
+ logger.error("这是一条普通的错误日志")
+
+
+def context_logging():
+ """上下文日志"""
+ print("\n" + "=" * 50)
+ print("5. 带上下文的日志")
+ print("=" * 50)
+
+ # 使用 bind() 添加上下文信息
+ context_logger = logger.bind(user_id="12345", request_id="abc-xyz")
+ context_logger.info("处理用户请求")
+ context_logger.info("请求处理完成")
+
+ # 使用 opt() 添加额外信息
+ logger.opt(colors=True).info("支持 彩色 文本")
+
+
+def structured_logging():
+ """结构化日志"""
+ print("\n" + "=" * 50)
+ print("6. 结构化日志(JSON格式)")
+ print("=" * 50)
+
+ # 移除之前的处理器
+ logger.remove()
+
+ # 添加 JSON 格式的日志输出(适合日志分析系统)
+ logger.add(
+ "logs/structured.json",
+ serialize=True, # 输出 JSON 格式
+ rotation="10 MB",
+ level="DEBUG"
+ )
+
+ # 同时保留控制台输出
+ logger.add(sys.stderr, level="INFO")
+
+ # 记录结构化数据
+ logger.info("用户登录", user_id=12345, ip="192.168.1.1")
+ logger.info("请求完成", url="/api/data", status=200, duration_ms=150)
+
+
+def crawler_logging_example():
+ """爬虫场景的日志使用示例"""
+ print("\n" + "=" * 50)
+ print("7. 爬虫场景日志示例")
+ print("=" * 50)
+
+ # 重置日志配置
+ logger.remove()
+ logger.add(sys.stderr, level="DEBUG")
+
+ # 模拟爬虫运行时的日志
+ logger.info("爬虫任务启动")
+ logger.debug("配置加载完成: max_concurrent=10, timeout=30s")
+
+ # 模拟爬取过程
+ for page in range(1, 4):
+ logger.info(f"开始爬取第 {page} 页")
+ logger.debug(f"请求URL: https://example.com/page/{page}")
+
+ # 模拟一些情况
+ if page == 2:
+ logger.warning(f"第 {page} 页请求超时,准备重试")
+ logger.info(f"第 {page} 页重试成功")
+
+ logger.info(f"第 {page} 页爬取完成,获取 20 条数据")
+
+ logger.info("爬虫任务完成,共获取 60 条数据")
+
+
+def main():
+ """主函数"""
+ # 确保日志目录存在
+ import os
+ os.makedirs("logs", exist_ok=True)
+
+ # 运行所有演示
+ basic_usage()
+ custom_format()
+ file_logging()
+ error_logging()
+ context_logging()
+ # structured_logging() # 取消注释以测试 JSON 日志
+ crawler_logging_example()
+
+
+if __name__ == "__main__":
+ main()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/pyproject.toml" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/pyproject.toml"
new file mode 100644
index 0000000..7cc8074
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/pyproject.toml"
@@ -0,0 +1,19 @@
+[project]
+name = "chapter01-engineering-standards"
+version = "0.1.0"
+description = "第01章:工程化爬虫开发规范 - 展示日志、配置管理、异常处理等工程化实践"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ "httpx>=0.27.0",
+ "pydantic>=2.0.0",
+ "parsel>=1.9.0",
+ "loguru>=0.7.0",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.uv]
+dev-dependencies = []
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/__init__.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/__init__.py"
new file mode 100644
index 0000000..92d0381
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/__init__.py"
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+# @Desc: 工程化爬虫重构示例
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/client.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/client.py"
new file mode 100644
index 0000000..3f9b9b0
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/client.py"
@@ -0,0 +1,108 @@
+# -*- coding: utf-8 -*-
+# @Desc: HTTP 客户端封装
+
+from typing import Optional
+
+import httpx
+from loguru import logger
+from tenacity import (
+ retry,
+ stop_after_attempt,
+ wait_exponential,
+ retry_if_exception_type
+)
+
+from .config import settings
+from .exceptions import RequestException, TimeoutException
+
+
+class CrawlerClient:
+ """
+ 封装的 HTTP 客户端
+
+ 特性:
+ - 统一的请求头配置
+ - 自动重试机制
+ - 优雅的错误处理
+ - 资源自动管理
+ """
+
+ DEFAULT_HEADERS = {
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/131.0.0.0 Safari/537.36",
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ }
+
+ def __init__(self):
+ self._client: Optional[httpx.AsyncClient] = None
+
+ async def __aenter__(self):
+ await self.start()
+ return self
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
+ await self.close()
+
+ async def start(self):
+ """启动客户端"""
+ if self._client is None:
+ self._client = httpx.AsyncClient(
+ timeout=settings.request_timeout,
+ headers=self.DEFAULT_HEADERS,
+ follow_redirects=True
+ )
+ logger.debug("HTTP 客户端已启动")
+
+ async def close(self):
+ """关闭客户端"""
+ if self._client:
+ await self._client.aclose()
+ self._client = None
+ logger.debug("HTTP 客户端已关闭")
+
+ @retry(
+ stop=stop_after_attempt(3),
+ wait=wait_exponential(multiplier=1, max=10),
+ retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError))
+ )
+ async def get(self, url: str, **kwargs) -> httpx.Response:
+ """
+ 发送 GET 请求(带重试)
+
+ Args:
+ url: 请求 URL
+ **kwargs: 传递给 httpx 的其他参数
+
+ Returns:
+ httpx.Response 响应对象
+
+ Raises:
+ RequestException: 请求失败
+ """
+ if not self._client:
+ await self.start()
+
+ logger.debug(f"GET 请求: {url}")
+
+ try:
+ response = await self._client.get(url, **kwargs)
+ response.raise_for_status()
+ logger.info(f"请求成功: {url} [{response.status_code}]")
+ return response
+
+ except httpx.TimeoutException as e:
+ logger.warning(f"请求超时: {url}")
+ raise TimeoutException(f"请求超时: {url}") from e
+
+ except httpx.HTTPStatusError as e:
+ logger.error(f"HTTP 错误: {url} [{e.response.status_code}]")
+ raise RequestException(
+ f"HTTP 状态码异常: {e.response.status_code}",
+ url=url
+ ) from e
+
+ except Exception as e:
+ logger.exception(f"请求异常: {url}")
+ raise RequestException(f"请求异常: {e}", url=url) from e
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/config.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/config.py"
new file mode 100644
index 0000000..a0fb362
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/config.py"
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+# @Desc: 配置管理
+
+from pydantic_settings import BaseSettings
+from pydantic import Field
+from typing import Optional
+
+
+class Settings(BaseSettings):
+ """爬虫配置"""
+
+ # 环境配置
+ env: str = Field(default="development", description="运行环境")
+ debug: bool = Field(default=True, description="调试模式")
+ log_level: str = Field(default="DEBUG", description="日志级别")
+
+ # 爬虫配置
+ first_n_page: int = Field(default=3, description="爬取前N页")
+ base_host: str = Field(default="https://www.ptt.cc", description="目标站点")
+ request_timeout: int = Field(default=30, description="请求超时")
+ max_retries: int = Field(default=3, description="最大重试次数")
+ request_delay: float = Field(default=0.5, description="请求间隔")
+
+ # 输出配置
+ output_dir: str = Field(default="./output", description="输出目录")
+ log_dir: str = Field(default="./logs", description="日志目录")
+
+ class Config:
+ env_file = ".env"
+ env_prefix = "CRAWLER_"
+
+
+settings = Settings()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/crawler.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/crawler.py"
new file mode 100644
index 0000000..e2f4fa3
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/crawler.py"
@@ -0,0 +1,135 @@
+# -*- coding: utf-8 -*-
+# @Desc: 爬虫核心逻辑
+
+import asyncio
+from typing import List
+from loguru import logger
+
+from .config import settings
+from .client import CrawlerClient
+from .parser import BBSParser
+from .models import NoteItem, NoteDetail, CrawlResult
+from .exceptions import CrawlerException
+
+
+class BBSCrawler:
+ """
+ BBS 论坛爬虫
+
+ 工程化特性:
+ - 配置驱动
+ - 完善的日志记录
+ - 优雅的异常处理
+ - 资源自动管理
+ """
+
+ def __init__(self):
+ self.client = CrawlerClient()
+ self.parser = BBSParser()
+ self.result = CrawlResult()
+
+ async def run(self) -> CrawlResult:
+ """
+ 运行爬虫
+
+ Returns:
+ 爬取结果
+ """
+ logger.info(f"开始爬取任务 - 目标: 前 {settings.first_n_page} 页")
+
+ async with self.client:
+ # Step 1: 获取分页信息
+ previous_number = await self._get_previous_page_number()
+
+ # Step 2: 爬取帖子列表
+ note_items = await self._crawl_note_list(previous_number)
+
+ # Step 3: 爬取帖子详情
+ await self._crawl_note_details(note_items)
+
+ logger.info(
+ f"爬取完成 - 成功: {self.result.success_count}, "
+ f"失败: {self.result.fail_count}"
+ )
+
+ return self.result
+
+ async def _get_previous_page_number(self) -> int:
+ """获取上一页分页号"""
+ logger.info("获取分页信息...")
+
+ url = f"{settings.base_host}/bbs/Stock/index.html"
+ response = await self.client.get(url)
+ page_number = self.parser.parse_previous_page_number(response.text)
+
+ logger.info(f"当前最新分页号: {page_number + 1}")
+ return page_number
+
+ async def _crawl_note_list(self, previous_number: int) -> List[NoteItem]:
+ """爬取帖子列表"""
+ logger.info(f"开始爬取帖子列表...")
+
+ all_notes: List[NoteItem] = []
+ start_page = previous_number + 1
+ end_page = start_page - settings.first_n_page
+
+ for page_num in range(start_page, end_page, -1):
+ try:
+ url = f"{settings.base_host}/bbs/Stock/index{page_num}.html"
+ logger.info(f"爬取第 {page_num} 页...")
+
+ response = await self.client.get(url)
+ notes = self.parser.parse_note_list(response.text)
+ all_notes.extend(notes)
+
+ logger.info(f"第 {page_num} 页获取 {len(notes)} 个帖子")
+
+ # 请求间隔
+ await asyncio.sleep(settings.request_delay)
+
+ except CrawlerException as e:
+ logger.warning(f"第 {page_num} 页爬取失败: {e}")
+ continue
+
+ self.result.total_pages = settings.first_n_page
+ self.result.total_notes = len(all_notes)
+ logger.info(f"帖子列表爬取完成, 共 {len(all_notes)} 个帖子")
+
+ return all_notes
+
+ async def _crawl_note_details(self, note_items: List[NoteItem]):
+ """爬取帖子详情"""
+ logger.info(f"开始爬取帖子详情, 共 {len(note_items)} 个...")
+
+ for idx, note_item in enumerate(note_items, 1):
+ try:
+ url = f"{settings.base_host}{note_item.detail_link}"
+ logger.debug(f"[{idx}/{len(note_items)}] 爬取: {note_item.title[:30]}...")
+
+ response = await self.client.get(url)
+ detail = self.parser.parse_note_detail(response.text, note_item)
+
+ self.result.notes.append(detail)
+ self.result.success_count += 1
+
+ # 请求间隔
+ await asyncio.sleep(settings.request_delay)
+
+ except CrawlerException as e:
+ logger.warning(f"帖子详情爬取失败: {note_item.title[:20]}... - {e}")
+ self.result.fail_count += 1
+ continue
+
+ except Exception as e:
+ logger.exception(f"未知错误: {e}")
+ self.result.fail_count += 1
+ continue
+
+ # 每 10 个帖子输出一次进度
+ if idx % 10 == 0:
+ logger.info(f"进度: {idx}/{len(note_items)}")
+
+ async def cleanup(self):
+ """清理资源"""
+ logger.debug("执行清理...")
+ await self.client.close()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/exceptions.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/exceptions.py"
new file mode 100644
index 0000000..a682a24
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/exceptions.py"
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# @Desc: 自定义异常类
+
+from typing import Optional
+
+
+class CrawlerException(Exception):
+ """爬虫基础异常类"""
+
+ def __init__(self, message: str, url: Optional[str] = None):
+ self.message = message
+ self.url = url
+ super().__init__(self.message)
+
+ def __str__(self):
+ if self.url:
+ return f"{self.message} (URL: {self.url})"
+ return self.message
+
+
+class RequestException(CrawlerException):
+ """HTTP 请求异常"""
+ pass
+
+
+class TimeoutException(RequestException):
+ """请求超时异常"""
+ pass
+
+
+class HTTPStatusException(RequestException):
+ """HTTP 状态码异常"""
+
+ def __init__(self, message: str, status_code: int, url: Optional[str] = None):
+ self.status_code = status_code
+ super().__init__(message, url)
+
+
+class ParseException(CrawlerException):
+ """数据解析异常"""
+ pass
+
+
+class StorageException(CrawlerException):
+ """数据存储异常"""
+ pass
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/logger.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/logger.py"
new file mode 100644
index 0000000..f9e2a8a
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/logger.py"
@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+# @Desc: 日志配置
+
+import sys
+import os
+from loguru import logger
+from config import settings
+
+
+def setup_logger():
+ """配置日志系统"""
+ # 确保日志目录存在
+ os.makedirs(settings.log_dir, exist_ok=True)
+
+ # 移除默认处理器
+ logger.remove()
+
+ # 控制台输出 - 彩色格式
+ logger.add(
+ sys.stderr,
+ level=settings.log_level,
+ format="{time:YYYY-MM-DD HH:mm:ss} | "
+ "{level: <8} | "
+ "{name}:{function}:{line} | "
+ "{message}",
+ colorize=True
+ )
+
+ # 文件输出 - 所有日志
+ logger.add(
+ f"{settings.log_dir}/crawler_{settings.env}.log",
+ rotation="00:00", # 每天午夜轮转
+ retention="7 days", # 保留7天
+ compression="zip", # 压缩旧日志
+ level="DEBUG",
+ encoding="utf-8",
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} | {message}"
+ )
+
+ # 错误日志单独文件
+ logger.add(
+ f"{settings.log_dir}/error_{settings.env}.log",
+ rotation="00:00",
+ retention="30 days",
+ level="ERROR",
+ encoding="utf-8",
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} | {message}"
+ )
+
+ logger.info(f"日志系统初始化完成 - 级别: {settings.log_level}")
+ return logger
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/main.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/main.py"
new file mode 100644
index 0000000..1ab2369
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/main.py"
@@ -0,0 +1,78 @@
+# -*- coding: utf-8 -*-
+# @Desc: 爬虫主程序入口
+
+import asyncio
+import json
+import os
+from loguru import logger
+
+from .logger import setup_logger
+from .config import settings
+from .crawler import BBSCrawler
+from .exceptions import CrawlerException
+
+
+async def main():
+ """主程序"""
+ # 初始化日志
+ setup_logger()
+
+ logger.info("=" * 50)
+ logger.info("工程化爬虫示例")
+ logger.info("=" * 50)
+ logger.info(f"运行环境: {settings.env}")
+ logger.info(f"调试模式: {settings.debug}")
+ logger.info(f"目标页数: {settings.first_n_page}")
+
+ # 创建输出目录
+ os.makedirs(settings.output_dir, exist_ok=True)
+
+ # 创建爬虫实例
+ crawler = BBSCrawler()
+
+ try:
+ # 运行爬虫
+ result = await crawler.run()
+
+ # 保存结果
+ output_file = f"{settings.output_dir}/crawl_result.json"
+ with open(output_file, "w", encoding="utf-8") as f:
+ json.dump(result.model_dump(), f, ensure_ascii=False, indent=2)
+
+ logger.info(f"结果已保存到: {output_file}")
+
+ # 输出统计
+ logger.info("=" * 50)
+ logger.info("爬取统计")
+ logger.info("=" * 50)
+ logger.info(f"总页数: {result.total_pages}")
+ logger.info(f"总帖子: {result.total_notes}")
+ logger.info(f"成功数: {result.success_count}")
+ logger.info(f"失败数: {result.fail_count}")
+
+ except CrawlerException as e:
+ logger.error(f"爬虫异常: {e}")
+ raise
+
+ except Exception as e:
+ logger.exception(f"未预期的异常: {e}")
+ raise
+
+ finally:
+ await crawler.cleanup()
+ logger.info("程序结束")
+
+
+def run():
+ """运行入口"""
+ try:
+ asyncio.run(main())
+ except KeyboardInterrupt:
+ logger.info("用户中断程序")
+ except Exception as e:
+ logger.critical(f"程序异常退出: {e}")
+ exit(1)
+
+
+if __name__ == "__main__":
+ run()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/models.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/models.py"
new file mode 100644
index 0000000..b9abdf0
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/models.py"
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+# @Desc: 数据模型定义
+
+from typing import List, Optional
+from pydantic import BaseModel, Field
+
+
+class NoteItem(BaseModel):
+ """帖子列表项"""
+ title: str = Field(default="", description="帖子标题")
+ author: str = Field(default="", description="作者")
+ publish_date: str = Field(default="", description="发布日期")
+ detail_link: str = Field(default="", description="详情链接")
+
+
+class PushComment(BaseModel):
+ """推文/评论"""
+ user_name: str = Field(default="", description="用户名")
+ content: str = Field(default="", description="评论内容")
+ push_time: str = Field(default="", description="评论时间")
+
+
+class NoteDetail(BaseModel):
+ """帖子详情"""
+ title: str = Field(default="", description="帖子标题")
+ author: str = Field(default="", description="作者")
+ detail_link: str = Field(default="", description="详情链接")
+ publish_datetime: str = Field(default="", description="发布时间")
+ content: str = Field(default="", description="正文内容")
+ comments: List[PushComment] = Field(default_factory=list, description="评论列表")
+
+
+class CrawlResult(BaseModel):
+ """爬取结果"""
+ total_pages: int = Field(default=0, description="爬取的页数")
+ total_notes: int = Field(default=0, description="帖子总数")
+ success_count: int = Field(default=0, description="成功数")
+ fail_count: int = Field(default=0, description="失败数")
+ notes: List[NoteDetail] = Field(default_factory=list, description="帖子详情列表")
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/parser.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/parser.py"
new file mode 100644
index 0000000..b4d9ba2
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/01_\345\267\245\347\250\213\345\214\226\347\210\254\350\231\253\345\274\200\345\217\221\350\247\204\350\214\203/refactored_crawler/parser.py"
@@ -0,0 +1,150 @@
+# -*- coding: utf-8 -*-
+# @Desc: 页面解析器
+
+from typing import List, Optional
+from parsel import Selector
+from loguru import logger
+
+from .models import NoteItem, NoteDetail, PushComment
+from .exceptions import ParseException
+
+
+class BBSParser:
+ """BBS 页面解析器"""
+
+ @staticmethod
+ def parse_previous_page_number(html: str) -> int:
+ """
+ 解析首页获取上一页的分页号
+
+ Args:
+ html: 页面 HTML 内容
+
+ Returns:
+ 分页号
+ """
+ try:
+ selector = Selector(text=html)
+ css_selector = "#action-bar-container > div > div.btn-group.btn-group-paging > a:nth-child(2)"
+ pagination_link = selector.css(css_selector).attrib.get("href", "")
+
+ if not pagination_link:
+ raise ParseException("无法找到分页链接")
+
+ # 从 /bbs/Stock/index7084.html 提取数字
+ page_number = int(
+ pagination_link
+ .replace("/bbs/Stock/index", "")
+ .replace(".html", "")
+ )
+
+ logger.debug(f"解析到上一页分页号: {page_number}")
+ return page_number
+
+ except Exception as e:
+ logger.error(f"解析分页号失败: {e}")
+ raise ParseException(f"解析分页号失败: {e}")
+
+ @staticmethod
+ def parse_note_list(html: str) -> List[NoteItem]:
+ """
+ 解析帖子列表页
+
+ Args:
+ html: 页面 HTML 内容
+
+ Returns:
+ 帖子列表
+ """
+ notes = []
+ selector = Selector(text=html)
+
+ try:
+ note_elements = selector.css("div.r-ent")
+
+ for element in note_elements:
+ title_el = element.css("div.title a")
+ author_el = element.css("div.meta div.author")
+ date_el = element.css("div.meta div.date")
+
+ note = NoteItem(
+ title=title_el.css("::text").get("").strip() if title_el else "",
+ author=author_el.css("::text").get("").strip() if author_el else "",
+ publish_date=date_el.css("::text").get("").strip() if date_el else "",
+ detail_link=title_el.attrib.get("href", "") if title_el else ""
+ )
+
+ # 跳过无链接的帖子(可能已删除)
+ if note.detail_link:
+ notes.append(note)
+
+ logger.debug(f"解析到 {len(notes)} 个帖子")
+ return notes
+
+ except Exception as e:
+ logger.error(f"解析帖子列表失败: {e}")
+ raise ParseException(f"解析帖子列表失败: {e}")
+
+ @staticmethod
+ def parse_note_detail(html: str, note_item: NoteItem) -> NoteDetail:
+ """
+ 解析帖子详情页
+
+ Args:
+ html: 页面 HTML 内容
+ note_item: 帖子基本信息
+
+ Returns:
+ 帖子详情
+ """
+ selector = Selector(text=html)
+
+ try:
+ # 基本信息从列表项复制
+ detail = NoteDetail(
+ title=note_item.title,
+ author=note_item.author,
+ detail_link=note_item.detail_link
+ )
+
+ # 解析发布时间
+ time_el = selector.css(
+ "#main-content > div:nth-child(4) > span.article-meta-value"
+ )
+ if time_el:
+ detail.publish_datetime = time_el.css("::text").get("").strip()
+
+ # 解析正文内容
+ # 获取 main-content 的全部文本,排除元信息和评论部分
+ main_content = selector.css("#main-content")
+ if main_content:
+ # 提取正文:获取所有文本节点,过滤掉元信息和评论
+ content_parts = []
+ for text_node in main_content.xpath("text()"):
+ text = text_node.get().strip()
+ if text and not text.startswith("--"): # 排除签名分隔线
+ content_parts.append(text)
+ detail.content = "\n".join(content_parts)
+
+ # 解析评论
+ comments = []
+ push_elements = selector.css("#main-content > div.push")
+
+ for push_el in push_elements:
+ spans = push_el.css("span")
+ if len(spans) >= 4:
+ comment = PushComment(
+ user_name=spans[1].css("::text").get("").strip(),
+ content=spans[2].css("::text").get("").strip().lstrip(": "),
+ push_time=spans[3].css("::text").get("").strip()
+ )
+ comments.append(comment)
+
+ detail.comments = comments
+ logger.debug(f"解析帖子详情完成: {detail.title}, 评论数: {len(comments)}")
+
+ return detail
+
+ except Exception as e:
+ logger.error(f"解析帖子详情失败: {e}")
+ raise ParseException(f"解析帖子详情失败: {e}")
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/README.md" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/README.md"
new file mode 100644
index 0000000..98b75ac
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/README.md"
@@ -0,0 +1,37 @@
+# 第02章:反爬虫对抗基础 - 请求伪装
+
+展示User-Agent轮换、请求头伪装、速率控制等反爬技术。
+
+## 快速开始
+
+### 使用 uv 安装依赖
+
+```bash
+cd 02_反爬虫对抗基础_请求伪装
+
+# 安装基础依赖
+uv sync
+
+# 安装高级功能(TLS指纹伪装,可选)
+uv sync --extra advanced
+
+# 运行示例
+uv run python ua_rotator.py
+uv run python headers_builder.py
+uv run python rate_limiter.py
+uv run python anti_detection_crawler.py
+```
+
+### 核心依赖
+
+- `httpx` - 异步HTTP客户端
+- `loguru` - 日志系统
+- `fake-useragent` - User-Agent生成
+- `curl-cffi`(可选)- TLS指纹伪装
+
+## 主要文件
+
+- `ua_rotator.py` - User-Agent轮换器
+- `headers_builder.py` - 请求头构建器
+- `rate_limiter.py` - 速率限制器
+- `anti_detection_crawler.py` - 完整反检测爬虫示例
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/anti_detection_crawler.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/anti_detection_crawler.py"
new file mode 100644
index 0000000..d3f62c0
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/anti_detection_crawler.py"
@@ -0,0 +1,254 @@
+# -*- coding: utf-8 -*-
+# @Desc: 完整的反检测爬虫示例
+# 结合 UA 轮换、请求头伪装、速率控制的实战案例
+
+import asyncio
+import random
+from typing import List, Dict, Optional, Any
+from dataclasses import dataclass
+from loguru import logger
+
+# 导入本章的模块
+from ua_rotator import UARotator
+from headers_builder import HeadersBuilder
+from rate_limiter import CompositeRateLimiter, RateLimitConfig
+
+# 尝试导入 curl_cffi,如果不可用则使用 httpx
+try:
+ from curl_cffi.requests import AsyncSession
+ USE_CURL_CFFI = True
+ logger.info("使用 curl_cffi 作为 HTTP 客户端(支持 TLS 指纹模拟)")
+except ImportError:
+ import httpx
+ USE_CURL_CFFI = False
+ logger.info("使用 httpx 作为 HTTP 客户端")
+
+
+@dataclass
+class CrawlerConfig:
+ """爬虫配置"""
+ # 目标站点
+ base_url: str = "https://httpbin.org"
+
+ # 速率限制
+ requests_per_second: float = 2.0
+ max_concurrent: int = 3
+ min_delay: float = 0.5
+ max_delay: float = 1.5
+
+ # 请求配置
+ timeout: int = 30
+ max_retries: int = 3
+
+ # curl_cffi 配置
+ browser_type: str = "chrome120"
+
+
+class AntiDetectionCrawler:
+ """
+ 反检测爬虫
+
+ 特性:
+ - User-Agent 随机轮换
+ - 完整的请求头伪装
+ - TLS 指纹模拟(使用 curl_cffi)
+ - 智能速率控制
+ - 优雅的重试机制
+ """
+
+ def __init__(self, config: Optional[CrawlerConfig] = None):
+ """
+ 初始化爬虫
+
+ Args:
+ config: 爬虫配置
+ """
+ self.config = config or CrawlerConfig()
+
+ # 初始化组件
+ self.ua_rotator = UARotator()
+ self.headers_builder = HeadersBuilder(self.ua_rotator)
+ self.rate_limiter = CompositeRateLimiter(
+ RateLimitConfig(
+ requests_per_second=self.config.requests_per_second,
+ max_concurrent=self.config.max_concurrent,
+ min_delay=self.config.min_delay,
+ max_delay=self.config.max_delay
+ )
+ )
+
+ self._session = None
+ self._stats = {
+ "total_requests": 0,
+ "success_requests": 0,
+ "failed_requests": 0
+ }
+
+ async def __aenter__(self):
+ await self.start()
+ return self
+
+ async def __aexit__(self, *args):
+ await self.close()
+
+ async def start(self):
+ """启动爬虫"""
+ if USE_CURL_CFFI:
+ self._session = AsyncSession(
+ impersonate=self.config.browser_type,
+ timeout=self.config.timeout
+ )
+ else:
+ self._session = httpx.AsyncClient(timeout=self.config.timeout)
+
+ logger.info(f"爬虫启动 - 目标: {self.config.base_url}")
+
+ async def close(self):
+ """关闭爬虫"""
+ if self._session:
+ if USE_CURL_CFFI:
+ await self._session.close()
+ else:
+ await self._session.aclose()
+ self._session = None
+
+ logger.info(f"爬虫关闭 - 统计: {self._stats}")
+
+ async def fetch(
+ self,
+ url: str,
+ referer: Optional[str] = None,
+ is_api: bool = False
+ ) -> Optional[Dict[str, Any]]:
+ """
+ 获取页面/API 数据
+
+ Args:
+ url: 目标 URL
+ referer: Referer 地址
+ is_api: 是否是 API 请求
+
+ Returns:
+ 响应数据或 None
+ """
+ async with self.rate_limiter:
+ self._stats["total_requests"] += 1
+
+ # 构建请求头
+ if is_api:
+ headers = self.headers_builder.build_api_headers(
+ referer=referer or self.config.base_url
+ )
+ else:
+ headers = self.headers_builder.build_page_headers(referer=referer)
+
+ # 重试逻辑
+ for attempt in range(self.config.max_retries):
+ try:
+ logger.debug(f"请求: {url} (尝试 {attempt + 1}/{self.config.max_retries})")
+
+ response = await self._session.get(url, headers=headers)
+
+ # 检查状态码
+ if USE_CURL_CFFI:
+ status_code = response.status_code
+ if status_code >= 400:
+ raise Exception(f"HTTP {status_code}")
+ data = response.json() if is_api else {"text": response.text}
+ else:
+ response.raise_for_status()
+ data = response.json() if is_api else {"text": response.text}
+
+ self._stats["success_requests"] += 1
+ logger.info(f"成功: {url}")
+ return data
+
+ except Exception as e:
+ logger.warning(f"请求失败: {url} - {e}")
+ if attempt < self.config.max_retries - 1:
+ # 等待后重试
+ await asyncio.sleep(random.uniform(1, 3))
+ else:
+ self._stats["failed_requests"] += 1
+ logger.error(f"最终失败: {url}")
+ return None
+
+ async def fetch_batch(
+ self,
+ urls: List[str],
+ referer: Optional[str] = None
+ ) -> List[Optional[Dict[str, Any]]]:
+ """
+ 批量获取数据
+
+ Args:
+ urls: URL 列表
+ referer: Referer 地址
+
+ Returns:
+ 结果列表
+ """
+ tasks = [self.fetch(url, referer) for url in urls]
+ return await asyncio.gather(*tasks)
+
+
+async def demo():
+ """演示反检测爬虫"""
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="DEBUG"
+ )
+
+ print("=" * 60)
+ print("反检测爬虫演示")
+ print("=" * 60)
+
+ config = CrawlerConfig(
+ base_url="https://httpbin.org",
+ requests_per_second=1.0,
+ max_concurrent=2,
+ min_delay=0.5,
+ max_delay=1.0
+ )
+
+ async with AntiDetectionCrawler(config) as crawler:
+ # 测试 1: 检查请求头
+ print("\n--- 测试 1: 检查请求头 ---")
+ result = await crawler.fetch(
+ "https://httpbin.org/headers",
+ is_api=True
+ )
+ if result:
+ headers = result.get("headers", {})
+ print(f"User-Agent: {headers.get('User-Agent', 'N/A')[:60]}...")
+ print(f"Accept: {headers.get('Accept', 'N/A')[:40]}...")
+
+ # 测试 2: 检查 IP
+ print("\n--- 测试 2: 检查 IP ---")
+ result = await crawler.fetch(
+ "https://httpbin.org/ip",
+ is_api=True
+ )
+ if result:
+ print(f"Origin IP: {result.get('origin', 'N/A')}")
+
+ # 测试 3: 批量请求(测试速率控制)
+ print("\n--- 测试 3: 批量请求(速率控制) ---")
+ urls = [
+ "https://httpbin.org/get?id=1",
+ "https://httpbin.org/get?id=2",
+ "https://httpbin.org/get?id=3",
+ "https://httpbin.org/get?id=4",
+ ]
+ results = await crawler.fetch_batch(urls)
+ print(f"批量请求完成: 成功 {sum(1 for r in results if r)}/{len(urls)}")
+
+ print("\n" + "=" * 60)
+ print("演示完成")
+ print("=" * 60)
+
+
+if __name__ == "__main__":
+ asyncio.run(demo())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/headers_builder.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/headers_builder.py"
new file mode 100644
index 0000000..805d18f
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/headers_builder.py"
@@ -0,0 +1,216 @@
+# -*- coding: utf-8 -*-
+# @Desc: 请求头构建器
+
+from typing import Dict, Optional
+from ua_rotator import UARotator
+
+
+class HeadersBuilder:
+ """
+ HTTP 请求头构建器
+
+ 功能:
+ - 构建完整的浏览器请求头
+ - 支持页面请求和 API 请求
+ - 自动设置 Referer 和 Origin
+ - 集成 UA 轮换
+ """
+
+ # 页面请求头模板(访问 HTML 页面)
+ PAGE_HEADERS_TEMPLATE = {
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Connection": "keep-alive",
+ "Upgrade-Insecure-Requests": "1",
+ "Sec-Ch-Ua": '"Not_A Brand";v="8", "Chromium";v="131", "Google Chrome";v="131"',
+ "Sec-Ch-Ua-Mobile": "?0",
+ "Sec-Ch-Ua-Platform": '"Windows"',
+ "Sec-Fetch-Dest": "document",
+ "Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Site": "none",
+ "Sec-Fetch-User": "?1",
+ "Cache-Control": "max-age=0",
+ }
+
+ # API 请求头模板(AJAX/Fetch 请求)
+ API_HEADERS_TEMPLATE = {
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Connection": "keep-alive",
+ "Sec-Ch-Ua": '"Not_A Brand";v="8", "Chromium";v="131", "Google Chrome";v="131"',
+ "Sec-Ch-Ua-Mobile": "?0",
+ "Sec-Ch-Ua-Platform": '"Windows"',
+ "Sec-Fetch-Dest": "empty",
+ "Sec-Fetch-Mode": "cors",
+ "Sec-Fetch-Site": "same-origin",
+ }
+
+ def __init__(self, ua_rotator: Optional[UARotator] = None):
+ """
+ 初始化请求头构建器
+
+ Args:
+ ua_rotator: UA 轮换器实例,如果不提供则创建默认实例
+ """
+ self.ua_rotator = ua_rotator or UARotator()
+
+ def build_page_headers(
+ self,
+ referer: Optional[str] = None,
+ host: Optional[str] = None,
+ extra_headers: Optional[Dict[str, str]] = None
+ ) -> Dict[str, str]:
+ """
+ 构建页面请求头
+
+ Args:
+ referer: Referer 地址
+ host: Host 地址
+ extra_headers: 额外的请求头
+
+ Returns:
+ 完整的请求头字典
+ """
+ headers = self.PAGE_HEADERS_TEMPLATE.copy()
+ headers["User-Agent"] = self.ua_rotator.get_random()
+
+ if host:
+ headers["Host"] = host
+
+ if referer:
+ headers["Referer"] = referer
+ # 有 Referer 时,Sec-Fetch-Site 应该改为 same-origin 或 cross-site
+ headers["Sec-Fetch-Site"] = "same-origin"
+
+ if extra_headers:
+ headers.update(extra_headers)
+
+ return headers
+
+ def build_api_headers(
+ self,
+ referer: str,
+ origin: Optional[str] = None,
+ content_type: Optional[str] = None,
+ extra_headers: Optional[Dict[str, str]] = None
+ ) -> Dict[str, str]:
+ """
+ 构建 API 请求头
+
+ Args:
+ referer: Referer 地址(API 请求通常需要)
+ origin: Origin 地址(POST 请求通常需要)
+ content_type: Content-Type
+ extra_headers: 额外的请求头
+
+ Returns:
+ 完整的请求头字典
+ """
+ headers = self.API_HEADERS_TEMPLATE.copy()
+ headers["User-Agent"] = self.ua_rotator.get_random()
+ headers["Referer"] = referer
+
+ if origin:
+ headers["Origin"] = origin
+
+ if content_type:
+ headers["Content-Type"] = content_type
+
+ if extra_headers:
+ headers.update(extra_headers)
+
+ return headers
+
+ def build_ajax_headers(
+ self,
+ referer: str,
+ x_requested_with: bool = True
+ ) -> Dict[str, str]:
+ """
+ 构建传统 AJAX 请求头
+
+ Args:
+ referer: Referer 地址
+ x_requested_with: 是否添加 X-Requested-With 头
+
+ Returns:
+ AJAX 请求头字典
+ """
+ headers = self.build_api_headers(referer=referer)
+
+ if x_requested_with:
+ headers["X-Requested-With"] = "XMLHttpRequest"
+
+ return headers
+
+ def build_mobile_headers(
+ self,
+ referer: Optional[str] = None,
+ extra_headers: Optional[Dict[str, str]] = None
+ ) -> Dict[str, str]:
+ """
+ 构建移动端请求头
+
+ Args:
+ referer: Referer 地址
+ extra_headers: 额外的请求头
+
+ Returns:
+ 移动端请求头字典
+ """
+ headers = self.PAGE_HEADERS_TEMPLATE.copy()
+ headers["User-Agent"] = self.ua_rotator.get_mobile()
+ headers["Sec-Ch-Ua-Mobile"] = "?1"
+ headers["Sec-Ch-Ua-Platform"] = '"Android"'
+
+ if referer:
+ headers["Referer"] = referer
+ headers["Sec-Fetch-Site"] = "same-origin"
+
+ if extra_headers:
+ headers.update(extra_headers)
+
+ return headers
+
+
+def demo():
+ """演示请求头构建器的使用"""
+ print("=" * 60)
+ print("请求头构建器演示")
+ print("=" * 60)
+
+ builder = HeadersBuilder()
+
+ print("\n1. 页面请求头:")
+ page_headers = builder.build_page_headers(
+ referer="https://example.com/list",
+ host="example.com"
+ )
+ for key, value in list(page_headers.items())[:8]:
+ print(f" {key}: {value[:50]}...")
+
+ print("\n2. API 请求头:")
+ api_headers = builder.build_api_headers(
+ referer="https://example.com/page",
+ origin="https://example.com",
+ content_type="application/json"
+ )
+ for key, value in list(api_headers.items())[:6]:
+ print(f" {key}: {value[:50]}...")
+
+ print("\n3. AJAX 请求头:")
+ ajax_headers = builder.build_ajax_headers(
+ referer="https://example.com/dashboard"
+ )
+ print(f" X-Requested-With: {ajax_headers.get('X-Requested-With')}")
+
+ print("\n4. 移动端请求头:")
+ mobile_headers = builder.build_mobile_headers()
+ print(f" User-Agent: {mobile_headers['User-Agent'][:60]}...")
+ print(f" Sec-Ch-Ua-Mobile: {mobile_headers['Sec-Ch-Ua-Mobile']}")
+
+
+if __name__ == "__main__":
+ demo()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/pyproject.toml" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/pyproject.toml"
new file mode 100644
index 0000000..72fb8b3
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/pyproject.toml"
@@ -0,0 +1,23 @@
+[project]
+name = "chapter02-anti-detection-basics"
+version = "0.1.0"
+description = "第02章:反爬虫对抗基础 - 请求伪装、User-Agent轮换、速率控制"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ "httpx>=0.27.0",
+ "loguru>=0.7.0",
+ "fake-useragent>=1.5.0",
+]
+
+[project.optional-dependencies]
+advanced = [
+ "curl-cffi>=0.7.0", # TLS指纹伪装(可选)
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.uv]
+dev-dependencies = []
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/rate_limiter.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/rate_limiter.py"
new file mode 100644
index 0000000..7b19e1f
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/rate_limiter.py"
@@ -0,0 +1,380 @@
+# -*- coding: utf-8 -*-
+# @Desc: 速率限制器实现
+
+import asyncio
+import time
+import random
+from typing import Optional, List, Any, Callable, Coroutine
+from dataclasses import dataclass
+
+
+class TokenBucket:
+ """
+ 令牌桶限速器
+
+ 工作原理:
+ - 桶有固定容量(最大令牌数)
+ - 以固定速率向桶中添加令牌
+ - 每次请求消耗一个令牌
+ - 桶空时请求需要等待
+
+ 适用场景:
+ - 需要精确控制平均请求速率
+ - 允许一定程度的突发请求
+ """
+
+ def __init__(
+ self,
+ rate: float,
+ capacity: Optional[int] = None
+ ):
+ """
+ 初始化令牌桶
+
+ Args:
+ rate: 每秒添加的令牌数(即每秒最多请求数)
+ capacity: 桶容量,默认等于 rate(允许的最大突发数)
+ """
+ self.rate = rate
+ self.capacity = capacity if capacity is not None else int(rate)
+ self.tokens = float(self.capacity) # 初始令牌数
+ self.last_time = time.monotonic()
+ self._lock = asyncio.Lock()
+
+ async def acquire(self, tokens: int = 1) -> float:
+ """
+ 获取令牌
+
+ Args:
+ tokens: 需要的令牌数
+
+ Returns:
+ 实际等待的时间(秒)
+ """
+ async with self._lock:
+ now = time.monotonic()
+
+ # 计算从上次到现在应该添加的令牌数
+ elapsed = now - self.last_time
+ self.tokens = min(
+ float(self.capacity),
+ self.tokens + elapsed * self.rate
+ )
+ self.last_time = now
+
+ # 如果令牌不足,计算需要等待的时间
+ wait_time = 0.0
+ if self.tokens < tokens:
+ wait_time = (tokens - self.tokens) / self.rate
+ await asyncio.sleep(wait_time)
+ self.tokens = 0
+ else:
+ self.tokens -= tokens
+
+ return wait_time
+
+ async def __aenter__(self):
+ await self.acquire()
+ return self
+
+ async def __aexit__(self, *args):
+ pass
+
+
+class RandomDelayLimiter:
+ """
+ 随机延迟限速器
+
+ 在每次请求后添加随机延迟,模拟人类行为
+ """
+
+ def __init__(
+ self,
+ min_delay: float = 1.0,
+ max_delay: float = 3.0
+ ):
+ """
+ 初始化随机延迟限速器
+
+ Args:
+ min_delay: 最小延迟(秒)
+ max_delay: 最大延迟(秒)
+ """
+ self.min_delay = min_delay
+ self.max_delay = max_delay
+
+ async def wait(self) -> float:
+ """
+ 等待随机时间
+
+ Returns:
+ 实际等待的时间(秒)
+ """
+ delay = random.uniform(self.min_delay, self.max_delay)
+ await asyncio.sleep(delay)
+ return delay
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ await self.wait()
+
+
+class AdaptiveRateLimiter:
+ """
+ 自适应速率限制器
+
+ 根据响应情况自动调整请求速率:
+ - 正常响应:逐渐提升速率
+ - 被限制/错误:降低速率
+ """
+
+ def __init__(
+ self,
+ initial_rate: float = 1.0,
+ min_rate: float = 0.1,
+ max_rate: float = 5.0,
+ increase_factor: float = 1.1,
+ decrease_factor: float = 0.5
+ ):
+ """
+ 初始化自适应限速器
+
+ Args:
+ initial_rate: 初始速率(请求/秒)
+ min_rate: 最小速率
+ max_rate: 最大速率
+ increase_factor: 成功时的速率增长因子
+ decrease_factor: 失败时的速率降低因子
+ """
+ self.current_rate = initial_rate
+ self.min_rate = min_rate
+ self.max_rate = max_rate
+ self.increase_factor = increase_factor
+ self.decrease_factor = decrease_factor
+ self._bucket = TokenBucket(rate=initial_rate)
+ self._lock = asyncio.Lock()
+
+ async def acquire(self) -> float:
+ """获取令牌"""
+ return await self._bucket.acquire()
+
+ async def report_success(self):
+ """报告请求成功,提升速率"""
+ async with self._lock:
+ new_rate = min(
+ self.current_rate * self.increase_factor,
+ self.max_rate
+ )
+ if new_rate != self.current_rate:
+ self.current_rate = new_rate
+ self._bucket = TokenBucket(rate=self.current_rate)
+
+ async def report_failure(self):
+ """报告请求失败/被限制,降低速率"""
+ async with self._lock:
+ new_rate = max(
+ self.current_rate * self.decrease_factor,
+ self.min_rate
+ )
+ if new_rate != self.current_rate:
+ self.current_rate = new_rate
+ self._bucket = TokenBucket(rate=self.current_rate)
+ # 失败后额外等待
+ await asyncio.sleep(2.0)
+
+ @property
+ def rate(self) -> float:
+ """当前速率"""
+ return self.current_rate
+
+
+class ConcurrencyLimiter:
+ """
+ 并发限制器
+
+ 限制同时进行的请求数量
+ """
+
+ def __init__(self, max_concurrent: int = 10):
+ """
+ 初始化并发限制器
+
+ Args:
+ max_concurrent: 最大并发数
+ """
+ self.max_concurrent = max_concurrent
+ self.semaphore = asyncio.Semaphore(max_concurrent)
+ self._active_count = 0
+ self._lock = asyncio.Lock()
+
+ async def acquire(self):
+ """获取并发许可"""
+ await self.semaphore.acquire()
+ async with self._lock:
+ self._active_count += 1
+
+ async def release(self):
+ """释放并发许可"""
+ self.semaphore.release()
+ async with self._lock:
+ self._active_count -= 1
+
+ @property
+ def active_count(self) -> int:
+ """当前活跃请求数"""
+ return self._active_count
+
+ async def __aenter__(self):
+ await self.acquire()
+ return self
+
+ async def __aexit__(self, *args):
+ await self.release()
+
+
+@dataclass
+class RateLimitConfig:
+ """速率限制配置"""
+ requests_per_second: float = 2.0
+ max_concurrent: int = 5
+ min_delay: float = 0.5
+ max_delay: float = 2.0
+
+
+class CompositeRateLimiter:
+ """
+ 组合速率限制器
+
+ 结合多种限速策略:
+ - 令牌桶控制平均速率
+ - 并发限制控制同时请求数
+ - 随机延迟模拟人类行为
+ """
+
+ def __init__(self, config: Optional[RateLimitConfig] = None):
+ """
+ 初始化组合限速器
+
+ Args:
+ config: 速率限制配置
+ """
+ self.config = config or RateLimitConfig()
+ self.token_bucket = TokenBucket(rate=self.config.requests_per_second)
+ self.concurrency_limiter = ConcurrencyLimiter(self.config.max_concurrent)
+ self.delay_limiter = RandomDelayLimiter(
+ self.config.min_delay,
+ self.config.max_delay
+ )
+
+ async def __aenter__(self):
+ """进入限速上下文"""
+ # 先获取并发许可
+ await self.concurrency_limiter.acquire()
+ # 再获取令牌
+ await self.token_bucket.acquire()
+ return self
+
+ async def __aexit__(self, *args):
+ """退出限速上下文"""
+ # 随机延迟
+ await self.delay_limiter.wait()
+ # 释放并发许可
+ await self.concurrency_limiter.release()
+
+
+# ==================== 演示代码 ====================
+
+async def demo_token_bucket():
+ """演示令牌桶限速器"""
+ print("\n" + "=" * 50)
+ print("1. 令牌桶限速器演示")
+ print("=" * 50)
+
+ # 每秒 2 个请求
+ limiter = TokenBucket(rate=2.0)
+
+ start_time = time.time()
+ for i in range(6):
+ async with limiter:
+ elapsed = time.time() - start_time
+ print(f" 请求 {i+1}: 时间 {elapsed:.2f}s")
+
+
+async def demo_random_delay():
+ """演示随机延迟限速器"""
+ print("\n" + "=" * 50)
+ print("2. 随机延迟限速器演示")
+ print("=" * 50)
+
+ limiter = RandomDelayLimiter(min_delay=0.5, max_delay=1.5)
+
+ for i in range(3):
+ print(f" 请求 {i+1}...")
+ async with limiter:
+ pass # 请求完成后自动延迟
+
+
+async def demo_concurrency():
+ """演示并发限制器"""
+ print("\n" + "=" * 50)
+ print("3. 并发限制器演示")
+ print("=" * 50)
+
+ limiter = ConcurrencyLimiter(max_concurrent=3)
+
+ async def task(task_id: int):
+ async with limiter:
+ print(f" 任务 {task_id} 开始, 当前并发: {limiter.active_count}")
+ await asyncio.sleep(0.5)
+ print(f" 任务 {task_id} 完成")
+
+ # 同时启动 6 个任务,但最多 3 个并发
+ tasks = [task(i) for i in range(6)]
+ await asyncio.gather(*tasks)
+
+
+async def demo_composite():
+ """演示组合限速器"""
+ print("\n" + "=" * 50)
+ print("4. 组合限速器演示")
+ print("=" * 50)
+
+ config = RateLimitConfig(
+ requests_per_second=2.0,
+ max_concurrent=2,
+ min_delay=0.3,
+ max_delay=0.8
+ )
+ limiter = CompositeRateLimiter(config)
+
+ start_time = time.time()
+
+ async def request(req_id: int):
+ async with limiter:
+ elapsed = time.time() - start_time
+ print(f" 请求 {req_id}: 时间 {elapsed:.2f}s, 并发: {limiter.concurrency_limiter.active_count}")
+
+ tasks = [request(i) for i in range(5)]
+ await asyncio.gather(*tasks)
+
+
+async def main():
+ """主函数"""
+ print("=" * 50)
+ print("速率限制器演示")
+ print("=" * 50)
+
+ await demo_token_bucket()
+ await demo_random_delay()
+ await demo_concurrency()
+ await demo_composite()
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/ua_rotator.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/ua_rotator.py"
new file mode 100644
index 0000000..b4e168d
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/02_\345\217\215\347\210\254\350\231\253\345\257\271\346\212\227\345\237\272\347\241\200_\350\257\267\346\261\202\344\274\252\350\243\205/ua_rotator.py"
@@ -0,0 +1,183 @@
+# -*- coding: utf-8 -*-
+# @Desc: User-Agent 轮换器实现
+
+import random
+from typing import List, Optional
+
+# 尝试导入 fake_useragent
+try:
+ from fake_useragent import UserAgent
+ FAKE_UA_AVAILABLE = True
+except ImportError:
+ FAKE_UA_AVAILABLE = False
+
+
+# 预定义的 User-Agent 列表(2025年主流浏览器版本)
+DESKTOP_USER_AGENTS = [
+ # Chrome - Windows
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
+ # Chrome - Mac
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
+ # Firefox - Windows
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0",
+ # Firefox - Mac
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0",
+ # Safari - Mac
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15",
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15",
+ # Edge - Windows
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0",
+]
+
+MOBILE_USER_AGENTS = [
+ # iPhone - Safari
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Mobile/15E148 Safari/604.1",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1",
+ # iPhone - Chrome
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/131.0.6778.103 Mobile/15E148 Safari/604.1",
+ # Android - Chrome
+ "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.104 Mobile Safari/537.36",
+ "Mozilla/5.0 (Linux; Android 14; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.104 Mobile Safari/537.36",
+ "Mozilla/5.0 (Linux; Android 14; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.104 Mobile Safari/537.36",
+]
+
+
+class UARotator:
+ """
+ User-Agent 轮换器
+
+ 支持:
+ - 随机获取桌面端/移动端 UA
+ - 自定义 UA 列表
+ - 集成 fake-useragent 库(可选)
+ """
+
+ def __init__(
+ self,
+ use_fake_ua: bool = False,
+ custom_uas: Optional[List[str]] = None
+ ):
+ """
+ 初始化 UA 轮换器
+
+ Args:
+ use_fake_ua: 是否使用 fake-useragent 库
+ custom_uas: 自定义 UA 列表(优先级最高)
+ """
+ self.custom_uas = custom_uas or []
+ self.use_fake_ua = use_fake_ua and FAKE_UA_AVAILABLE
+ self._fake_ua = None
+
+ if self.use_fake_ua:
+ try:
+ self._fake_ua = UserAgent()
+ except Exception as e:
+ print(f"Warning: fake-useragent 初始化失败: {e}")
+ self.use_fake_ua = False
+
+ def get_random(self) -> str:
+ """
+ 获取随机 User-Agent(桌面端)
+
+ Returns:
+ 随机的 User-Agent 字符串
+ """
+ # 优先使用自定义列表
+ if self.custom_uas:
+ return random.choice(self.custom_uas)
+
+ # 使用 fake-useragent
+ if self.use_fake_ua and self._fake_ua:
+ try:
+ return self._fake_ua.random
+ except Exception:
+ pass
+
+ # 使用预定义列表
+ return random.choice(DESKTOP_USER_AGENTS)
+
+ def get_chrome(self) -> str:
+ """获取 Chrome User-Agent"""
+ if self.use_fake_ua and self._fake_ua:
+ try:
+ return self._fake_ua.chrome
+ except Exception:
+ pass
+
+ # 从预定义列表中筛选 Chrome UA
+ chrome_uas = [ua for ua in DESKTOP_USER_AGENTS if "Chrome" in ua and "Edg" not in ua]
+ return random.choice(chrome_uas) if chrome_uas else self.get_random()
+
+ def get_firefox(self) -> str:
+ """获取 Firefox User-Agent"""
+ if self.use_fake_ua and self._fake_ua:
+ try:
+ return self._fake_ua.firefox
+ except Exception:
+ pass
+
+ firefox_uas = [ua for ua in DESKTOP_USER_AGENTS if "Firefox" in ua]
+ return random.choice(firefox_uas) if firefox_uas else self.get_random()
+
+ def get_safari(self) -> str:
+ """获取 Safari User-Agent"""
+ if self.use_fake_ua and self._fake_ua:
+ try:
+ return self._fake_ua.safari
+ except Exception:
+ pass
+
+ safari_uas = [ua for ua in DESKTOP_USER_AGENTS if "Safari" in ua and "Chrome" not in ua]
+ return random.choice(safari_uas) if safari_uas else self.get_random()
+
+ def get_mobile(self) -> str:
+ """获取移动端 User-Agent"""
+ return random.choice(MOBILE_USER_AGENTS)
+
+ def get_ios(self) -> str:
+ """获取 iOS User-Agent"""
+ ios_uas = [ua for ua in MOBILE_USER_AGENTS if "iPhone" in ua]
+ return random.choice(ios_uas) if ios_uas else self.get_mobile()
+
+ def get_android(self) -> str:
+ """获取 Android User-Agent"""
+ android_uas = [ua for ua in MOBILE_USER_AGENTS if "Android" in ua]
+ return random.choice(android_uas) if android_uas else self.get_mobile()
+
+
+def demo():
+ """演示 UA 轮换器的使用"""
+ print("=" * 60)
+ print("User-Agent 轮换器演示")
+ print("=" * 60)
+
+ rotator = UARotator()
+
+ print("\n1. 随机桌面端 UA:")
+ for i in range(3):
+ print(f" {i+1}. {rotator.get_random()[:80]}...")
+
+ print("\n2. Chrome UA:")
+ print(f" {rotator.get_chrome()[:80]}...")
+
+ print("\n3. Firefox UA:")
+ print(f" {rotator.get_firefox()[:80]}...")
+
+ print("\n4. 移动端 UA:")
+ print(f" iOS: {rotator.get_ios()[:60]}...")
+ print(f" Android: {rotator.get_android()[:60]}...")
+
+ # 测试 fake-useragent(如果可用)
+ if FAKE_UA_AVAILABLE:
+ print("\n5. 使用 fake-useragent:")
+ rotator_fake = UARotator(use_fake_ua=True)
+ for i in range(3):
+ print(f" {i+1}. {rotator_fake.get_random()[:80]}...")
+
+
+if __name__ == "__main__":
+ demo()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/README.md" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/README.md"
new file mode 100644
index 0000000..db69474
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/README.md"
@@ -0,0 +1,20 @@
+# 第03章:代理IP的使用与管理
+
+展示代理池设计、代理检测、多URL测试等代理管理功能。
+
+## 快速开始
+
+```bash
+cd 03_代理IP的使用与管理
+uv sync
+uv run python proxy_demo.py
+```
+
+### 核心依赖
+- `httpx` - HTTP客户端
+- `loguru` - 日志系统
+
+## 测试URL
+- `httpbin.org/ip` - IP检测
+- `api.ipify.org` - IP服务
+- `ip-api.com/json/` - IP地理位置
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_checker.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_checker.py"
new file mode 100644
index 0000000..0790c98
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_checker.py"
@@ -0,0 +1,169 @@
+# -*- coding: utf-8 -*-
+# @Desc: 独立的代理检测脚本
+# 用于快速检测代理可用性
+
+import asyncio
+import sys
+from typing import List
+from loguru import logger
+
+# 添加模块路径
+sys.path.insert(0, ".")
+
+from proxy_pool.base import ProxyInfo, ProxyProtocol
+from proxy_pool.checker import ProxyChecker
+
+
+async def check_single_proxy(proxy_str: str) -> bool:
+ """
+ 检测单个代理
+
+ Args:
+ proxy_str: 代理字符串,格式为 host:port 或 protocol://host:port
+
+ Returns:
+ 代理是否可用
+ """
+ # 解析代理字符串
+ if "://" in proxy_str:
+ protocol, rest = proxy_str.split("://")
+ host, port = rest.split(":")
+ protocol = ProxyProtocol(protocol)
+ else:
+ host, port = proxy_str.split(":")
+ protocol = ProxyProtocol.HTTP
+
+ proxy = ProxyInfo(
+ host=host,
+ port=int(port),
+ protocol=protocol
+ )
+
+ checker = ProxyChecker(timeout=10)
+ is_valid = await checker.check(proxy)
+
+ if is_valid:
+ print(f"✓ 代理可用: {proxy.url}")
+ print(f" 响应时间: {proxy.avg_response_time:.2f}s")
+ else:
+ print(f"✗ 代理不可用: {proxy.url}")
+
+ return is_valid
+
+
+async def check_proxy_list(proxy_list: List[str]) -> List[ProxyInfo]:
+ """
+ 批量检测代理列表
+
+ Args:
+ proxy_list: 代理字符串列表
+
+ Returns:
+ 可用的代理列表
+ """
+ proxies = []
+
+ for proxy_str in proxy_list:
+ try:
+ if "://" in proxy_str:
+ protocol, rest = proxy_str.split("://")
+ host, port = rest.split(":")
+ protocol = ProxyProtocol(protocol)
+ else:
+ host, port = proxy_str.split(":")
+ protocol = ProxyProtocol.HTTP
+
+ proxies.append(ProxyInfo(
+ host=host,
+ port=int(port),
+ protocol=protocol
+ ))
+ except Exception as e:
+ logger.warning(f"解析代理失败: {proxy_str} - {e}")
+
+ if not proxies:
+ print("没有有效的代理可检测")
+ return []
+
+ print(f"开始检测 {len(proxies)} 个代理...")
+
+ checker = ProxyChecker(timeout=10)
+ valid_proxies = await checker.check_batch(proxies, concurrency=20)
+
+ print(f"\n检测结果: {len(valid_proxies)}/{len(proxies)} 可用")
+
+ for proxy in valid_proxies:
+ print(f" ✓ {proxy.host}:{proxy.port} (响应: {proxy.avg_response_time:.2f}s)")
+
+ return valid_proxies
+
+
+async def check_from_file(filepath: str) -> List[ProxyInfo]:
+ """
+ 从文件读取代理并检测
+
+ Args:
+ filepath: 代理列表文件路径(每行一个代理)
+
+ Returns:
+ 可用的代理列表
+ """
+ try:
+ with open(filepath, "r") as f:
+ proxy_list = [line.strip() for line in f if line.strip()]
+ except FileNotFoundError:
+ print(f"文件不存在: {filepath}")
+ return []
+
+ return await check_proxy_list(proxy_list)
+
+
+def main():
+ """主函数"""
+ import argparse
+
+ parser = argparse.ArgumentParser(description="代理检测工具")
+ parser.add_argument(
+ "proxy",
+ nargs="?",
+ help="要检测的代理 (host:port)"
+ )
+ parser.add_argument(
+ "-f", "--file",
+ help="从文件读取代理列表"
+ )
+ parser.add_argument(
+ "-l", "--list",
+ nargs="+",
+ help="检测多个代理"
+ )
+
+ args = parser.parse_args()
+
+ # 配置日志
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {message}",
+ level="INFO"
+ )
+
+ if args.file:
+ # 从文件读取
+ asyncio.run(check_from_file(args.file))
+ elif args.list:
+ # 检测多个代理
+ asyncio.run(check_proxy_list(args.list))
+ elif args.proxy:
+ # 检测单个代理
+ asyncio.run(check_single_proxy(args.proxy))
+ else:
+ # 演示模式
+ print("代理检测工具使用示例:")
+ print(" 检测单个代理: python proxy_checker.py 127.0.0.1:8080")
+ print(" 检测多个代理: python proxy_checker.py -l 1.1.1.1:8080 2.2.2.2:8080")
+ print(" 从文件读取: python proxy_checker.py -f proxies.txt")
+
+
+if __name__ == "__main__":
+ main()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_demo.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_demo.py"
new file mode 100644
index 0000000..ac43725
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_demo.py"
@@ -0,0 +1,274 @@
+# -*- coding: utf-8 -*-
+# @Desc: 代理池使用演示
+# 展示完整的代理池使用流程
+
+import asyncio
+from typing import List, Optional
+from loguru import logger
+import httpx
+
+from proxy_pool.base import ProxyInfo, ProxyProtocol, IProxyFetcher
+from proxy_pool.checker import ProxyChecker
+from proxy_pool.pool import ProxyPool
+
+
+class MockProxyFetcher(IProxyFetcher):
+ """
+ 模拟代理获取器
+
+ 用于演示,实际使用时替换为真实的代理获取器
+ """
+
+ # 模拟的代理列表(这些是公开的测试代理,可能不可用)
+ MOCK_PROXIES = [
+ # 这些只是示例,实际运行时可能不可用
+ ("103.152.112.157", 80),
+ ("203.142.78.109", 8080),
+ ("103.83.232.225", 80),
+ ("190.61.88.147", 8080),
+ ("45.167.126.108", 3128),
+ ]
+
+ async def fetch(self) -> List[ProxyInfo]:
+ """获取模拟代理"""
+ proxies = []
+ for host, port in self.MOCK_PROXIES:
+ proxies.append(ProxyInfo(
+ host=host,
+ port=port,
+ protocol=ProxyProtocol.HTTP
+ ))
+ logger.info(f"获取到 {len(proxies)} 个模拟代理")
+ return proxies
+
+
+class ProxiedCrawler:
+ """
+ 使用代理池的爬虫示例
+ """
+
+ def __init__(self, proxy_pool: ProxyPool):
+ self.proxy_pool = proxy_pool
+ self.success_count = 0
+ self.fail_count = 0
+
+ async def fetch(self, url: str) -> Optional[str]:
+ """
+ 使用代理获取页面
+
+ Args:
+ url: 目标 URL
+
+ Returns:
+ 页面内容或 None
+ """
+ # 获取代理
+ proxy = await self.proxy_pool.get_proxy()
+
+ if not proxy:
+ logger.warning("无可用代理,直接请求")
+ proxy_url = None
+ else:
+ proxy_url = proxy.url
+ logger.info(f"使用代理: {proxy.host}:{proxy.port}")
+
+ try:
+ async with httpx.AsyncClient(
+ proxies=proxy_url,
+ timeout=15,
+ verify=False
+ ) as client:
+ response = await client.get(url)
+ response.raise_for_status()
+
+ # 报告成功
+ if proxy:
+ await self.proxy_pool.return_proxy(proxy, success=True)
+ self.success_count += 1
+
+ return response.text
+
+ except Exception as e:
+ logger.warning(f"请求失败: {e}")
+ # 报告失败
+ if proxy:
+ await self.proxy_pool.return_proxy(proxy, success=False)
+ self.fail_count += 1
+ return None
+
+
+async def demo_proxy_pool():
+ """演示代理池基本功能"""
+ print("=" * 60)
+ print("代理池基本功能演示")
+ print("=" * 60)
+
+ # 创建组件
+ fetcher = MockProxyFetcher()
+ checker = ProxyChecker(timeout=10)
+
+ # 创建代理池
+ pool = ProxyPool(
+ fetcher=fetcher,
+ checker=checker,
+ min_proxies=2,
+ max_proxies=10,
+ check_interval=60
+ )
+
+ async with pool:
+ print(f"\n代理池大小: {pool.size}")
+ print(f"统计信息: {pool.get_stats()}")
+
+ # 获取代理
+ print("\n获取代理测试:")
+ for i in range(3):
+ proxy = await pool.get_proxy()
+ if proxy:
+ print(f" {i+1}. {proxy.host}:{proxy.port} (评分: {proxy.score:.2f})")
+ # 模拟使用结果
+ await pool.return_proxy(proxy, success=(i % 2 == 0))
+ else:
+ print(f" {i+1}. 无可用代理")
+
+ print(f"\n最终统计: {pool.get_stats()}")
+
+
+async def demo_proxied_crawler():
+ """演示集成代理池的爬虫"""
+ print("\n" + "=" * 60)
+ print("代理爬虫演示")
+ print("=" * 60)
+
+ # 创建代理池
+ pool = ProxyPool(
+ fetcher=MockProxyFetcher(),
+ checker=ProxyChecker(timeout=10),
+ min_proxies=2,
+ max_proxies=10
+ )
+
+ async with pool:
+ # 创建爬虫
+ crawler = ProxiedCrawler(pool)
+
+ # 测试请求 - 使用多个测试URL
+ urls = [
+ "https://httpbin.org/ip",
+ "https://httpbin.org/headers",
+ ]
+
+ print("\n开始爬取测试:")
+ for url in urls:
+ content = await crawler.fetch(url)
+ if content:
+ print(f" ✓ {url[:40]}... ({len(content)} bytes)")
+ else:
+ print(f" ✗ {url[:40]}... 失败")
+
+ print(f"\n爬取统计: 成功 {crawler.success_count}, 失败 {crawler.fail_count}")
+
+
+async def demo_multi_url_test():
+ """演示多URL代理测试 - 综合验证代理功能"""
+ print("\n" + "=" * 60)
+ print("多URL代理测试演示")
+ print("=" * 60)
+
+ # 定义测试URL列表
+ test_urls = [
+ {
+ "url": "https://httpbin.org/ip",
+ "name": "httpbin IP检测",
+ "extract": lambda data: data.get("origin", "N/A")
+ },
+ {
+ "url": "https://api.ipify.org?format=json",
+ "name": "ipify IP服务",
+ "extract": lambda data: data.get("ip", "N/A")
+ },
+ {
+ "url": "http://ip-api.com/json/",
+ "name": "ip-api 地理位置",
+ "extract": lambda data: f"{data.get('query', 'N/A')} ({data.get('country', 'N/A')}, {data.get('city', 'N/A')})"
+ },
+ ]
+
+ print("\n测试不使用代理的情况:")
+ print("-" * 60)
+
+ async with httpx.AsyncClient(timeout=10) as client:
+ for test in test_urls:
+ try:
+ response = await client.get(test["url"])
+ if response.status_code == 200:
+ try:
+ data = response.json()
+ result = test["extract"](data)
+ print(f" ✓ {test['name']}: {result}")
+ except Exception:
+ print(f" ✓ {test['name']}: {response.text[:50]}...")
+ else:
+ print(f" ✗ {test['name']}: HTTP {response.status_code}")
+ except Exception as e:
+ print(f" ✗ {test['name']}: {str(e)[:50]}")
+
+ print("\n说明:")
+ print(" - httpbin.org: 通用HTTP测试服务,返回请求IP")
+ print(" - ipify.org: 专门的IP获取服务,简单直接")
+ print(" - ip-api.com: 提供IP地理位置信息")
+ print("\n如果使用代理,这些服务返回的IP应该是代理服务器的IP")
+
+
+async def demo_manual_proxy():
+ """演示手动使用代理"""
+ print("\n" + "=" * 60)
+ print("手动代理使用演示")
+ print("=" * 60)
+
+ # 直接使用 httpx 设置代理
+ proxy_url = "http://127.0.0.1:7890" # 替换为你的代理地址
+
+ print(f"\n使用代理: {proxy_url}")
+ print("(如果代理不可用,请求会失败)")
+
+ try:
+ async with httpx.AsyncClient(
+ proxies=proxy_url,
+ timeout=10
+ ) as client:
+ response = await client.get("https://httpbin.org/ip")
+ print(f"响应: {response.text}")
+ except Exception as e:
+ print(f"请求失败 (代理可能不可用): {e}")
+
+
+async def main():
+ """主函数"""
+ # 配置日志
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="DEBUG"
+ )
+
+ print("=" * 60)
+ print("代理 IP 使用与管理演示")
+ print("=" * 60)
+ print("\n注意: 演示使用的是模拟代理,可能不可用")
+ print("实际使用时请替换为真实的代理服务")
+
+ # 运行演示
+ await demo_proxy_pool()
+ await demo_proxied_crawler()
+ await demo_multi_url_test() # 新增:多URL测试
+ # await demo_manual_proxy() # 需要有可用代理才能测试
+
+ print("\n" + "=" * 60)
+ print("演示完成")
+ print("=" * 60)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_pool/__init__.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_pool/__init__.py"
new file mode 100644
index 0000000..dfb4c81
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_pool/__init__.py"
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# @Desc: 代理池模块
+
+from .base import ProxyInfo, ProxyProtocol, IProxyFetcher, IProxyChecker, IProxyPool
+from .checker import ProxyChecker
+from .pool import ProxyPool
+
+__all__ = [
+ "ProxyInfo",
+ "ProxyProtocol",
+ "IProxyFetcher",
+ "IProxyChecker",
+ "IProxyPool",
+ "ProxyChecker",
+ "ProxyPool",
+]
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_pool/base.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_pool/base.py"
new file mode 100644
index 0000000..6923c12
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_pool/base.py"
@@ -0,0 +1,190 @@
+# -*- coding: utf-8 -*-
+# @Desc: 代理池基础类和接口定义
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Optional, List
+import time
+
+
+class ProxyProtocol(Enum):
+ """代理协议枚举"""
+ HTTP = "http"
+ HTTPS = "https"
+ SOCKS5 = "socks5"
+
+
+@dataclass
+class ProxyInfo:
+ """
+ 代理信息数据类
+
+ 包含代理的基本信息和质量指标
+ """
+ host: str
+ port: int
+ protocol: ProxyProtocol = ProxyProtocol.HTTP
+ username: Optional[str] = None
+ password: Optional[str] = None
+
+ # 质量指标
+ success_count: int = 0
+ fail_count: int = 0
+ avg_response_time: float = 0.0
+ last_check_time: float = field(default_factory=time.time)
+ last_use_time: float = 0.0
+
+ @property
+ def url(self) -> str:
+ """
+ 构建代理 URL
+
+ Returns:
+ 代理 URL 字符串,如 http://user:pass@host:port
+ """
+ auth = ""
+ if self.username and self.password:
+ auth = f"{self.username}:{self.password}@"
+ return f"{self.protocol.value}://{auth}{self.host}:{self.port}"
+
+ @property
+ def score(self) -> float:
+ """
+ 计算代理评分
+
+ 综合考虑成功率和响应时间
+
+ Returns:
+ 0-1 之间的评分
+ """
+ total = self.success_count + self.fail_count
+
+ if total == 0:
+ return 0.5 # 未测试的代理给中等分数
+
+ # 成功率权重 70%
+ success_rate = self.success_count / total
+
+ # 响应时间权重 30%,响应时间越短分数越高
+ # 假设 10 秒以上响应时间为 0 分
+ time_score = max(0, 1 - self.avg_response_time / 10)
+
+ return success_rate * 0.7 + time_score * 0.3
+
+ @property
+ def is_stale(self) -> bool:
+ """
+ 检查代理是否过期(超过 10 分钟未检测)
+ """
+ return time.time() - self.last_check_time > 600
+
+ def __hash__(self):
+ return hash((self.host, self.port))
+
+ def __eq__(self, other):
+ if not isinstance(other, ProxyInfo):
+ return False
+ return self.host == other.host and self.port == other.port
+
+ def __str__(self):
+ return f"Proxy({self.host}:{self.port}, score={self.score:.2f})"
+
+
+class IProxyFetcher(ABC):
+ """
+ 代理获取器接口
+
+ 负责从各种来源获取代理列表
+ """
+
+ @abstractmethod
+ async def fetch(self) -> List[ProxyInfo]:
+ """
+ 获取代理列表
+
+ Returns:
+ 代理信息列表
+ """
+ pass
+
+
+class IProxyChecker(ABC):
+ """
+ 代理检测器接口
+
+ 负责检测代理的可用性
+ """
+
+ @abstractmethod
+ async def check(self, proxy: ProxyInfo) -> bool:
+ """
+ 检测单个代理是否可用
+
+ Args:
+ proxy: 代理信息
+
+ Returns:
+ 代理是否可用
+ """
+ pass
+
+
+class IProxyPool(ABC):
+ """
+ 代理池接口
+
+ 负责代理的存储和分配
+ """
+
+ @abstractmethod
+ async def get_proxy(self) -> Optional[ProxyInfo]:
+ """
+ 获取一个可用代理
+
+ Returns:
+ 代理信息,如果没有可用代理则返回 None
+ """
+ pass
+
+ @abstractmethod
+ async def return_proxy(self, proxy: ProxyInfo, success: bool):
+ """
+ 归还代理并报告使用结果
+
+ Args:
+ proxy: 代理信息
+ success: 使用是否成功
+ """
+ pass
+
+ @abstractmethod
+ async def add_proxy(self, proxy: ProxyInfo):
+ """
+ 添加代理到池中
+
+ Args:
+ proxy: 代理信息
+ """
+ pass
+
+ @abstractmethod
+ async def remove_proxy(self, proxy: ProxyInfo):
+ """
+ 从池中移除代理
+
+ Args:
+ proxy: 代理信息
+ """
+ pass
+
+ @property
+ @abstractmethod
+ def size(self) -> int:
+ """
+ 获取代理池大小
+
+ Returns:
+ 代理数量
+ """
+ pass
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_pool/checker.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_pool/checker.py"
new file mode 100644
index 0000000..a5511a5
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_pool/checker.py"
@@ -0,0 +1,174 @@
+# -*- coding: utf-8 -*-
+# @Desc: 代理检测器实现
+
+import asyncio
+import time
+from typing import List
+from loguru import logger
+import httpx
+
+from .base import ProxyInfo, IProxyChecker
+
+
+class ProxyChecker(IProxyChecker):
+ """
+ 代理检测器
+
+ 功能:
+ - 检测代理的可用性
+ - 测量响应时间
+ - 支持批量检测
+ """
+
+ # 用于检测的 URL 列表(按优先级排序)
+ CHECK_URLS = [
+ "https://httpbin.org/ip",
+ "https://api.ipify.org?format=json",
+ "https://ifconfig.me/ip",
+ ]
+
+ def __init__(
+ self,
+ timeout: int = 10,
+ check_urls: List[str] = None
+ ):
+ """
+ 初始化检测器
+
+ Args:
+ timeout: 检测超时时间(秒)
+ check_urls: 自定义检测 URL 列表
+ """
+ self.timeout = timeout
+ self.check_urls = check_urls or self.CHECK_URLS
+
+ async def check(self, proxy: ProxyInfo) -> bool:
+ """
+ 检测单个代理是否可用
+
+ Args:
+ proxy: 代理信息
+
+ Returns:
+ 代理是否可用
+ """
+ start_time = time.time()
+
+ try:
+ async with httpx.AsyncClient(
+ proxies=proxy.url,
+ timeout=self.timeout,
+ verify=False # 跳过 SSL 验证(某些代理可能有问题)
+ ) as client:
+ for url in self.check_urls:
+ try:
+ response = await client.get(url)
+
+ if response.status_code == 200:
+ # 计算响应时间
+ response_time = time.time() - start_time
+
+ # 更新代理信息
+ # 使用指数移动平均更新响应时间
+ if proxy.avg_response_time > 0:
+ proxy.avg_response_time = (
+ proxy.avg_response_time * 0.7 +
+ response_time * 0.3
+ )
+ else:
+ proxy.avg_response_time = response_time
+
+ proxy.last_check_time = time.time()
+
+ logger.debug(
+ f"代理可用: {proxy.host}:{proxy.port}, "
+ f"响应时间: {response_time:.2f}s"
+ )
+ return True
+
+ except httpx.TimeoutException:
+ continue
+ except httpx.ConnectError:
+ continue
+ except Exception as e:
+ logger.debug(f"检测请求异常: {url} - {e}")
+ continue
+
+ except Exception as e:
+ logger.debug(f"代理检测失败: {proxy.host}:{proxy.port} - {e}")
+
+ return False
+
+ async def check_batch(
+ self,
+ proxies: List[ProxyInfo],
+ concurrency: int = 20
+ ) -> List[ProxyInfo]:
+ """
+ 批量检测代理
+
+ Args:
+ proxies: 代理列表
+ concurrency: 并发数
+
+ Returns:
+ 可用的代理列表
+ """
+ semaphore = asyncio.Semaphore(concurrency)
+ valid_proxies = []
+ checked_count = 0
+ total_count = len(proxies)
+
+ async def check_one(proxy: ProxyInfo):
+ nonlocal checked_count
+ async with semaphore:
+ is_valid = await self.check(proxy)
+ checked_count += 1
+
+ if is_valid:
+ valid_proxies.append(proxy)
+
+ # 进度日志
+ if checked_count % 10 == 0 or checked_count == total_count:
+ logger.info(
+ f"检测进度: {checked_count}/{total_count}, "
+ f"有效: {len(valid_proxies)}"
+ )
+
+ # 创建任务
+ tasks = [check_one(p) for p in proxies]
+
+ # 并发执行
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+ logger.info(
+ f"检测完成: {len(valid_proxies)}/{len(proxies)} 可用 "
+ f"({len(valid_proxies)/len(proxies)*100:.1f}%)"
+ )
+
+ return valid_proxies
+
+
+class TargetSiteChecker(ProxyChecker):
+ """
+ 目标站点检测器
+
+ 使用实际目标站点进行检测,确保代理对目标网站可用
+ """
+
+ def __init__(
+ self,
+ target_url: str,
+ expected_status: int = 200,
+ timeout: int = 15
+ ):
+ """
+ 初始化目标站点检测器
+
+ Args:
+ target_url: 目标站点 URL
+ expected_status: 期望的状态码
+ timeout: 超时时间
+ """
+ super().__init__(timeout=timeout, check_urls=[target_url])
+ self.expected_status = expected_status
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_pool/pool.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_pool/pool.py"
new file mode 100644
index 0000000..f85f3be
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/proxy_pool/pool.py"
@@ -0,0 +1,283 @@
+# -*- coding: utf-8 -*-
+# @Desc: 代理池实现
+
+import asyncio
+import random
+import time
+from typing import Optional, Dict, List
+from loguru import logger
+
+from .base import ProxyInfo, IProxyFetcher, IProxyChecker, IProxyPool
+
+
+class ProxyPool(IProxyPool):
+ """
+ 代理池实现
+
+ 特性:
+ - 自动获取和检测代理
+ - 基于评分的智能分配
+ - 自动淘汰失效代理
+ - 后台自动刷新
+ """
+
+ def __init__(
+ self,
+ fetcher: IProxyFetcher,
+ checker: IProxyChecker,
+ min_proxies: int = 10,
+ max_proxies: int = 100,
+ check_interval: int = 300,
+ max_fail_count: int = 3,
+ score_threshold: float = 0.3
+ ):
+ """
+ 初始化代理池
+
+ Args:
+ fetcher: 代理获取器
+ checker: 代理检测器
+ min_proxies: 最小代理数量(低于此数量时自动补充)
+ max_proxies: 最大代理数量
+ check_interval: 检测间隔(秒)
+ max_fail_count: 最大连续失败次数(超过后触发淘汰检查)
+ score_threshold: 淘汰评分阈值
+ """
+ self.fetcher = fetcher
+ self.checker = checker
+ self.min_proxies = min_proxies
+ self.max_proxies = max_proxies
+ self.check_interval = check_interval
+ self.max_fail_count = max_fail_count
+ self.score_threshold = score_threshold
+
+ # 代理存储 {key: ProxyInfo}
+ self._proxies: Dict[str, ProxyInfo] = {}
+ self._lock = asyncio.Lock()
+
+ # 后台任务
+ self._refresh_task: Optional[asyncio.Task] = None
+ self._running = False
+
+ def _proxy_key(self, proxy: ProxyInfo) -> str:
+ """生成代理唯一标识"""
+ return f"{proxy.host}:{proxy.port}"
+
+ async def start(self):
+ """启动代理池"""
+ if self._running:
+ return
+
+ self._running = True
+ logger.info("代理池启动中...")
+
+ # 初始获取代理
+ await self._refresh_proxies()
+
+ # 启动后台刷新任务
+ self._refresh_task = asyncio.create_task(self._refresh_loop())
+
+ logger.info(f"代理池已启动,当前代理数: {self.size}")
+
+ async def stop(self):
+ """停止代理池"""
+ if not self._running:
+ return
+
+ self._running = False
+
+ if self._refresh_task:
+ self._refresh_task.cancel()
+ try:
+ await self._refresh_task
+ except asyncio.CancelledError:
+ pass
+
+ logger.info("代理池已停止")
+
+ async def __aenter__(self):
+ await self.start()
+ return self
+
+ async def __aexit__(self, *args):
+ await self.stop()
+
+ async def _refresh_loop(self):
+ """后台刷新循环"""
+ while self._running:
+ try:
+ await asyncio.sleep(self.check_interval)
+
+ # 检查是否需要刷新
+ if self.size < self.min_proxies:
+ logger.info("代理数量不足,触发刷新")
+ await self._refresh_proxies()
+ else:
+ # 清理过期代理
+ await self._cleanup_stale_proxies()
+
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.error(f"代理刷新异常: {e}")
+ await asyncio.sleep(60) # 出错后等待一分钟
+
+ async def _refresh_proxies(self):
+ """刷新代理"""
+ logger.info(f"开始刷新代理,当前数量: {self.size}")
+
+ try:
+ # 获取新代理
+ new_proxies = await self.fetcher.fetch()
+
+ if not new_proxies:
+ logger.warning("未获取到新代理")
+ return
+
+ logger.info(f"获取到 {len(new_proxies)} 个代理,开始检测...")
+
+ # 检测代理
+ valid_proxies = await self.checker.check_batch(new_proxies)
+
+ # 添加到池中
+ async with self._lock:
+ added_count = 0
+ for proxy in valid_proxies:
+ key = self._proxy_key(proxy)
+ if key not in self._proxies and len(self._proxies) < self.max_proxies:
+ self._proxies[key] = proxy
+ added_count += 1
+
+ logger.info(f"添加了 {added_count} 个新代理,当前总数: {self.size}")
+
+ except Exception as e:
+ logger.error(f"刷新代理失败: {e}")
+
+ async def _cleanup_stale_proxies(self):
+ """清理过期代理"""
+ async with self._lock:
+ stale_keys = [
+ key for key, proxy in self._proxies.items()
+ if proxy.is_stale
+ ]
+
+ for key in stale_keys:
+ del self._proxies[key]
+
+ if stale_keys:
+ logger.info(f"清理了 {len(stale_keys)} 个过期代理")
+
+ async def get_proxy(self) -> Optional[ProxyInfo]:
+ """
+ 获取一个可用代理
+
+ 使用加权随机选择,评分高的代理被选中概率更大
+ """
+ async with self._lock:
+ if not self._proxies:
+ logger.warning("代理池为空")
+ return None
+
+ # 获取所有代理
+ proxies = list(self._proxies.values())
+
+ # 计算权重(评分越高权重越大,最小权重 0.1)
+ weights = [max(p.score, 0.1) for p in proxies]
+
+ # 加权随机选择
+ selected = random.choices(proxies, weights=weights, k=1)[0]
+ selected.last_use_time = time.time()
+
+ logger.debug(
+ f"分配代理: {selected.host}:{selected.port} "
+ f"(评分: {selected.score:.2f})"
+ )
+
+ return selected
+
+ async def return_proxy(self, proxy: ProxyInfo, success: bool):
+ """
+ 归还代理并报告使用结果
+
+ Args:
+ proxy: 代理信息
+ success: 使用是否成功
+ """
+ async with self._lock:
+ key = self._proxy_key(proxy)
+
+ if key not in self._proxies:
+ return
+
+ stored_proxy = self._proxies[key]
+
+ if success:
+ stored_proxy.success_count += 1
+ logger.debug(f"代理使用成功: {proxy.host}:{proxy.port}")
+ else:
+ stored_proxy.fail_count += 1
+ logger.debug(f"代理使用失败: {proxy.host}:{proxy.port}")
+
+ # 检查是否需要淘汰
+ if stored_proxy.fail_count >= self.max_fail_count:
+ total = stored_proxy.success_count + stored_proxy.fail_count
+
+ # 有足够样本且评分过低时淘汰
+ if total >= 5 and stored_proxy.score < self.score_threshold:
+ del self._proxies[key]
+ logger.info(
+ f"淘汰低质量代理: {proxy.host}:{proxy.port} "
+ f"(评分: {stored_proxy.score:.2f})"
+ )
+
+ async def add_proxy(self, proxy: ProxyInfo):
+ """添加代理"""
+ async with self._lock:
+ key = self._proxy_key(proxy)
+ if key not in self._proxies and len(self._proxies) < self.max_proxies:
+ self._proxies[key] = proxy
+ logger.debug(f"添加代理: {proxy.host}:{proxy.port}")
+
+ async def remove_proxy(self, proxy: ProxyInfo):
+ """移除代理"""
+ async with self._lock:
+ key = self._proxy_key(proxy)
+ if key in self._proxies:
+ del self._proxies[key]
+ logger.debug(f"移除代理: {proxy.host}:{proxy.port}")
+
+ @property
+ def size(self) -> int:
+ """代理池大小"""
+ return len(self._proxies)
+
+ def get_stats(self) -> Dict:
+ """
+ 获取统计信息
+
+ Returns:
+ 包含各种统计指标的字典
+ """
+ if not self._proxies:
+ return {
+ "total": 0,
+ "avg_score": 0,
+ "max_score": 0,
+ "min_score": 0,
+ }
+
+ proxies = list(self._proxies.values())
+ scores = [p.score for p in proxies]
+
+ return {
+ "total": len(proxies),
+ "avg_score": sum(scores) / len(scores),
+ "max_score": max(scores),
+ "min_score": min(scores),
+ "total_success": sum(p.success_count for p in proxies),
+ "total_fail": sum(p.fail_count for p in proxies),
+ }
+
+ def get_all_proxies(self) -> List[ProxyInfo]:
+ """获取所有代理列表(用于调试)"""
+ return list(self._proxies.values())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/pyproject.toml" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/pyproject.toml"
new file mode 100644
index 0000000..e0df9a4
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/03_\344\273\243\347\220\206IP\347\232\204\344\275\277\347\224\250\344\270\216\347\256\241\347\220\206/pyproject.toml"
@@ -0,0 +1,17 @@
+[project]
+name = "chapter03-proxy-management"
+version = "0.1.0"
+description = "第03章:代理IP的使用与管理 - 代理池设计、代理检测、多URL测试"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ "httpx>=0.27.0",
+ "loguru>=0.7.0",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.uv]
+dev-dependencies = []
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/README.md" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/README.md"
new file mode 100644
index 0000000..f17dbfc
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/README.md"
@@ -0,0 +1,18 @@
+# 第04章:Playwright浏览器自动化入门
+
+展示Playwright基本操作、元素定位、等待策略等。
+
+## 快速开始
+
+```bash
+cd 04_Playwright浏览器自动化入门
+uv sync
+uv run playwright install chromium # 安装浏览器
+uv run python basic_operations.py
+uv run python wait_strategies.py
+uv run python spa_crawler.py
+```
+
+### 目标网站
+- **quotes.toscrape.com** - 静态版本
+- **quotes.toscrape.com/js/** - SPA版本(需要JavaScript渲染)
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/basic_operations.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/basic_operations.py"
new file mode 100644
index 0000000..37ca39b
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/basic_operations.py"
@@ -0,0 +1,252 @@
+# -*- coding: utf-8 -*-
+# @Desc: Playwright 基础操作演示
+
+import asyncio
+from playwright.async_api import async_playwright
+from loguru import logger
+
+
+async def demo_navigation():
+ """演示页面导航"""
+ print("\n" + "=" * 50)
+ print("1. 页面导航演示")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ try:
+ # 基本导航
+ logger.info("访问 example.com...")
+ await page.goto("https://example.com")
+ print(f"页面标题: {await page.title()}")
+ print(f"当前 URL: {page.url}")
+
+ # 使用不同的等待策略
+ logger.info("使用 networkidle 等待策略...")
+ await page.goto(
+ "https://quotes.toscrape.com/",
+ wait_until="networkidle"
+ )
+ print(f"页面标题: {await page.title()}")
+
+ finally:
+ await browser.close()
+
+
+async def demo_locators():
+ """演示元素定位"""
+ print("\n" + "=" * 50)
+ print("2. 元素定位演示")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ try:
+ await page.goto("https://quotes.toscrape.com/")
+
+ # 使用 CSS 选择器
+ title = await page.locator("h1 a").text_content()
+ print(f"CSS 选择器 - 标题: {title}")
+
+ # 使用 text 定位
+ about_link = page.get_by_text("About")
+ print(f"Text 定位 - About 链接存在: {await about_link.count() > 0}")
+
+ # 使用 role 定位
+ links = page.get_by_role("link")
+ print(f"Role 定位 - 链接数量: {await links.count()}")
+
+ # 组合定位
+ first_quote = page.locator("div.quote").first
+ author = await first_quote.locator("small.author").text_content()
+ print(f"组合定位 - 第一条名言作者: {author}")
+
+ # 获取所有元素
+ quotes = await page.locator("div.quote").all()
+ print(f"共找到 {len(quotes)} 条名言")
+
+ finally:
+ await browser.close()
+
+
+async def demo_interactions():
+ """演示交互操作"""
+ print("\n" + "=" * 50)
+ print("3. 交互操作演示")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ try:
+ # 访问登录页面
+ await page.goto("https://quotes.toscrape.com/login")
+ logger.info("访问登录页面")
+
+ # 输入用户名
+ await page.fill("input#username", "testuser")
+ print("输入用户名: testuser")
+
+ # 输入密码
+ await page.fill("input#password", "testpass")
+ print("输入密码: ******")
+
+ # 点击登录按钮
+ await page.click("input[type='submit']")
+ print("点击登录按钮")
+
+ # 等待页面跳转
+ await page.wait_for_load_state("networkidle")
+ print(f"登录后 URL: {page.url}")
+
+ finally:
+ await browser.close()
+
+
+async def demo_content_extraction():
+ """演示内容提取"""
+ print("\n" + "=" * 50)
+ print("4. 内容提取演示")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ try:
+ await page.goto("https://quotes.toscrape.com/")
+
+ # 提取文本内容
+ quotes_data = []
+ quotes = await page.locator("div.quote").all()
+
+ for quote in quotes[:3]: # 只取前 3 条
+ text = await quote.locator("span.text").text_content()
+ author = await quote.locator("small.author").text_content()
+ tags = await quote.locator("a.tag").all_text_contents()
+
+ quotes_data.append({
+ "text": text[:60] + "..." if len(text) > 60 else text,
+ "author": author,
+ "tags": tags
+ })
+
+ print("\n提取的名言数据:")
+ for i, item in enumerate(quotes_data, 1):
+ print(f"\n{i}. {item['author']}")
+ print(f" {item['text']}")
+ print(f" 标签: {', '.join(item['tags'])}")
+
+ # 获取属性
+ first_link = page.locator("div.quote a.tag").first
+ href = await first_link.get_attribute("href")
+ print(f"\n第一个标签链接: {href}")
+
+ # 执行 JavaScript
+ title = await page.evaluate("document.title")
+ print(f"通过 JS 获取标题: {title}")
+
+ finally:
+ await browser.close()
+
+
+async def demo_waiting():
+ """演示等待策略"""
+ print("\n" + "=" * 50)
+ print("5. 等待策略演示")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ try:
+ # 设置默认超时
+ page.set_default_timeout(30000)
+ logger.info("设置默认超时: 30秒")
+
+ # 访问页面
+ await page.goto("https://quotes.toscrape.com/")
+
+ # 等待选择器
+ await page.wait_for_selector("div.quote")
+ print("等待 div.quote 出现 - 成功")
+
+ # 等待页面状态
+ await page.wait_for_load_state("networkidle")
+ print("等待 networkidle - 成功")
+
+ # 单次操作超时
+ try:
+ await page.wait_for_selector("div.not-exist", timeout=2000)
+ except Exception as e:
+ print(f"等待不存在的元素 - 超时 (预期行为)")
+
+ finally:
+ await browser.close()
+
+
+async def demo_screenshot():
+ """演示截图功能"""
+ print("\n" + "=" * 50)
+ print("6. 截图功能演示")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ try:
+ await page.goto("https://quotes.toscrape.com/")
+
+ # 页面截图
+ await page.screenshot(path="screenshot_viewport.png")
+ print("视口截图已保存: screenshot_viewport.png")
+
+ # 全页面截图
+ await page.screenshot(path="screenshot_fullpage.png", full_page=True)
+ print("全页面截图已保存: screenshot_fullpage.png")
+
+ # 元素截图
+ first_quote = page.locator("div.quote").first
+ await first_quote.screenshot(path="screenshot_element.png")
+ print("元素截图已保存: screenshot_element.png")
+
+ finally:
+ await browser.close()
+
+
+async def main():
+ """主函数"""
+ # 配置日志
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {message}",
+ level="INFO"
+ )
+
+ print("=" * 50)
+ print("Playwright 基础操作演示")
+ print("=" * 50)
+
+ # 运行所有演示
+ await demo_navigation()
+ await demo_locators()
+ await demo_interactions()
+ await demo_content_extraction()
+ await demo_waiting()
+ await demo_screenshot()
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/pyproject.toml" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/pyproject.toml"
new file mode 100644
index 0000000..fb491a0
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/pyproject.toml"
@@ -0,0 +1,19 @@
+[project]
+name = "chapter04-playwright-basics"
+version = "0.1.0"
+description = "第04章:Playwright浏览器自动化入门 - 页面操作、元素定位、等待策略"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ "playwright>=1.45.0",
+ "loguru>=0.7.0",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.uv]
+dev-dependencies = []
+
+# 提示:安装后需要运行 playwright install 安装浏览器驱动
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/spa_crawler.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/spa_crawler.py"
new file mode 100644
index 0000000..27796c6
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/spa_crawler.py"
@@ -0,0 +1,263 @@
+# -*- coding: utf-8 -*-
+# @Desc: 使用 Playwright 爬取 SPA(单页应用)完整示例
+
+import asyncio
+import json
+from typing import List, Dict, Optional
+from dataclasses import dataclass, asdict
+from playwright.async_api import async_playwright, Page, Browser
+from loguru import logger
+
+
+@dataclass
+class Quote:
+ """名言数据模型"""
+ text: str
+ author: str
+ tags: List[str]
+
+
+class SPACrawler:
+ """
+ SPA 爬虫示例
+
+ 爬取 https://quotes.toscrape.com/js/ - 一个需要 JavaScript 渲染的页面
+ """
+
+ BASE_URL = "https://quotes.toscrape.com/js/"
+
+ def __init__(
+ self,
+ headless: bool = True,
+ timeout: int = 30000
+ ):
+ """
+ 初始化爬虫
+
+ Args:
+ headless: 是否无头模式
+ timeout: 默认超时时间(毫秒)
+ """
+ self.headless = headless
+ self.timeout = timeout
+ self._browser: Optional[Browser] = None
+ self._page: Optional[Page] = None
+
+ async def __aenter__(self):
+ await self.start()
+ return self
+
+ async def __aexit__(self, *args):
+ await self.close()
+
+ async def start(self):
+ """启动浏览器"""
+ playwright = await async_playwright().start()
+ self._browser = await playwright.chromium.launch(
+ headless=self.headless
+ )
+ context = await self._browser.new_context(
+ viewport={"width": 1920, "height": 1080}
+ )
+ self._page = await context.new_page()
+ self._page.set_default_timeout(self.timeout)
+ logger.info(f"浏览器已启动 (headless={self.headless})")
+
+ async def close(self):
+ """关闭浏览器"""
+ if self._browser:
+ await self._browser.close()
+ logger.info("浏览器已关闭")
+
+ async def crawl_page(self, page_num: int = 1) -> List[Quote]:
+ """
+ 爬取单页数据
+
+ Args:
+ page_num: 页码
+
+ Returns:
+ 名言列表
+ """
+ url = f"{self.BASE_URL}page/{page_num}/" if page_num > 1 else self.BASE_URL
+
+ logger.info(f"正在爬取第 {page_num} 页: {url}")
+
+ # 访问页面
+ await self._page.goto(url, wait_until="networkidle")
+
+ # 等待内容加载(SPA 需要等待 JS 渲染)
+ await self._page.wait_for_selector("div.quote")
+
+ # 提取数据
+ quotes = []
+ quote_elements = await self._page.locator("div.quote").all()
+
+ for element in quote_elements:
+ text = await element.locator("span.text").text_content()
+ author = await element.locator("small.author").text_content()
+ tags = await element.locator("a.tag").all_text_contents()
+
+ # 清理文本(去除引号符号)
+ text = text.strip().strip(""").strip(""")
+
+ quotes.append(Quote(
+ text=text,
+ author=author.strip(),
+ tags=tags
+ ))
+
+ logger.info(f"第 {page_num} 页爬取完成,获取 {len(quotes)} 条名言")
+ return quotes
+
+ async def crawl_all_pages(self, max_pages: int = 10) -> List[Quote]:
+ """
+ 爬取所有页面
+
+ Args:
+ max_pages: 最大页数
+
+ Returns:
+ 所有名言列表
+ """
+ all_quotes = []
+
+ for page_num in range(1, max_pages + 1):
+ try:
+ quotes = await self.crawl_page(page_num)
+ all_quotes.extend(quotes)
+
+ # 检查是否还有下一页
+ next_button = self._page.locator("li.next a")
+ if await next_button.count() == 0:
+ logger.info("已到达最后一页")
+ break
+
+ # 页面间延迟
+ await asyncio.sleep(0.5)
+
+ except Exception as e:
+ logger.error(f"爬取第 {page_num} 页失败: {e}")
+ break
+
+ logger.info(f"爬取完成,共获取 {len(all_quotes)} 条名言")
+ return all_quotes
+
+ async def crawl_with_pagination(self) -> List[Quote]:
+ """
+ 使用点击分页的方式爬取
+
+ Returns:
+ 所有名言列表
+ """
+ all_quotes = []
+ page_num = 1
+
+ # 访问首页
+ await self._page.goto(self.BASE_URL, wait_until="networkidle")
+
+ while True:
+ logger.info(f"正在爬取第 {page_num} 页...")
+
+ # 等待内容加载
+ await self._page.wait_for_selector("div.quote")
+
+ # 提取当前页数据
+ quote_elements = await self._page.locator("div.quote").all()
+
+ for element in quote_elements:
+ text = await element.locator("span.text").text_content()
+ author = await element.locator("small.author").text_content()
+ tags = await element.locator("a.tag").all_text_contents()
+
+ text = text.strip().strip(""").strip(""")
+
+ all_quotes.append(Quote(
+ text=text,
+ author=author.strip(),
+ tags=tags
+ ))
+
+ logger.info(f"第 {page_num} 页完成,累计 {len(all_quotes)} 条")
+
+ # 检查并点击下一页
+ next_button = self._page.locator("li.next a")
+ if await next_button.count() == 0:
+ logger.info("已到达最后一页")
+ break
+
+ # 点击下一页
+ await next_button.click()
+ await self._page.wait_for_load_state("networkidle")
+
+ page_num += 1
+
+ # 防止无限循环
+ if page_num > 20:
+ break
+
+ return all_quotes
+
+
+async def main():
+ """主函数"""
+ # 配置日志
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="INFO"
+ )
+
+ print("=" * 60)
+ print("Playwright SPA 爬虫示例")
+ print("=" * 60)
+ print("\n目标网站: https://quotes.toscrape.com/js/")
+ print("这是一个需要 JavaScript 渲染的页面\n")
+
+ async with SPACrawler(headless=True) as crawler:
+ # 方法 1: 直接访问各页面 URL
+ print("\n--- 方法 1: 直接访问各页面 URL ---")
+ quotes = await crawler.crawl_all_pages(max_pages=3)
+
+ # 输出部分结果
+ print("\n爬取结果示例:")
+ for i, quote in enumerate(quotes[:5], 1):
+ print(f"\n{i}. {quote.author}")
+ print(f" \"{quote.text[:60]}...\"")
+ print(f" 标签: {', '.join(quote.tags)}")
+
+ # 保存结果
+ output_file = "quotes_spa.json"
+ with open(output_file, "w", encoding="utf-8") as f:
+ json.dump(
+ [asdict(q) for q in quotes],
+ f,
+ ensure_ascii=False,
+ indent=2
+ )
+ print(f"\n结果已保存到: {output_file}")
+
+ # 统计信息
+ print("\n" + "=" * 60)
+ print("爬取统计")
+ print("=" * 60)
+ print(f"总名言数: {len(quotes)}")
+
+ # 作者统计
+ authors = {}
+ for quote in quotes:
+ authors[quote.author] = authors.get(quote.author, 0) + 1
+
+ print(f"作者数量: {len(authors)}")
+ print("\n出现最多的作者:")
+ for author, count in sorted(authors.items(), key=lambda x: -x[1])[:5]:
+ print(f" {author}: {count} 条")
+
+ print("\n" + "=" * 60)
+ print("演示完成")
+ print("=" * 60)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/wait_strategies.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/wait_strategies.py"
new file mode 100644
index 0000000..b7af8c6
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/04_Playwright\346\265\217\350\247\210\345\231\250\350\207\252\345\212\250\345\214\226\345\205\245\351\227\250/wait_strategies.py"
@@ -0,0 +1,267 @@
+# -*- coding: utf-8 -*-
+# @Desc: Playwright 等待策略详解
+
+import asyncio
+from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeout
+from loguru import logger
+
+
+async def demo_auto_waiting():
+ """演示自动等待"""
+ print("\n" + "=" * 50)
+ print("1. 自动等待机制")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ try:
+ await page.goto("https://quotes.toscrape.com/login")
+
+ # Playwright 的操作会自动等待元素可操作
+ # 以下操作会自动等待:
+ # - 元素存在于 DOM
+ # - 元素可见
+ # - 元素稳定(不在动画中)
+ # - 元素可接收事件
+ # - 元素没有被其他元素遮挡
+
+ print("自动等待 - 填充用户名...")
+ await page.fill("input#username", "test")
+ print("自动等待 - 成功")
+
+ print("自动等待 - 点击登录...")
+ await page.click("input[type='submit']")
+ print("自动等待 - 成功")
+
+ finally:
+ await browser.close()
+
+
+async def demo_wait_for_selector():
+ """演示 wait_for_selector"""
+ print("\n" + "=" * 50)
+ print("2. wait_for_selector 用法")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ try:
+ await page.goto("https://quotes.toscrape.com/")
+
+ # 等待元素出现(默认 state="visible")
+ element = await page.wait_for_selector("div.quote")
+ print(f"等待 visible - 找到元素")
+
+ # 等待元素附加到 DOM(不管是否可见)
+ element = await page.wait_for_selector("div.quote", state="attached")
+ print(f"等待 attached - 找到元素")
+
+ # 等待元素消失
+ # await page.wait_for_selector("div.loading", state="hidden")
+ print(f"等待 hidden - 跳过(页面无 loading 元素)")
+
+ # 等待元素从 DOM 移除
+ # await page.wait_for_selector("div.temp", state="detached")
+ print(f"等待 detached - 跳过")
+
+ finally:
+ await browser.close()
+
+
+async def demo_wait_for_load_state():
+ """演示 wait_for_load_state"""
+ print("\n" + "=" * 50)
+ print("3. wait_for_load_state 用法")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ try:
+ # 导航并等待 DOM 加载
+ print("导航到页面...")
+ await page.goto("https://quotes.toscrape.com/")
+
+ # 等待 domcontentloaded - DOM 解析完成
+ await page.wait_for_load_state("domcontentloaded")
+ print("domcontentloaded - 完成")
+
+ # 等待 load - 所有资源加载完成
+ await page.wait_for_load_state("load")
+ print("load - 完成")
+
+ # 等待 networkidle - 网络空闲(500ms 无新请求)
+ await page.wait_for_load_state("networkidle")
+ print("networkidle - 完成")
+
+ finally:
+ await browser.close()
+
+
+async def demo_wait_for_url():
+ """演示 wait_for_url"""
+ print("\n" + "=" * 50)
+ print("4. wait_for_url 用法")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ try:
+ await page.goto("https://quotes.toscrape.com/login")
+
+ # 填写登录表单
+ await page.fill("input#username", "test")
+ await page.fill("input#password", "test")
+ await page.click("input[type='submit']")
+
+ # 等待 URL 变化(使用 glob 模式)
+ await page.wait_for_url("**/")
+ print(f"URL 已变化到: {page.url}")
+
+ # 也可以使用函数
+ # await page.wait_for_url(lambda url: "quotes" in url)
+
+ finally:
+ await browser.close()
+
+
+async def demo_wait_for_function():
+ """演示 wait_for_function"""
+ print("\n" + "=" * 50)
+ print("5. wait_for_function 用法")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ try:
+ await page.goto("https://quotes.toscrape.com/")
+
+ # 等待 JavaScript 条件为真
+ await page.wait_for_function(
+ "document.querySelectorAll('.quote').length > 0"
+ )
+ print("等待 JS 条件 - 名言已加载")
+
+ # 等待特定元素数量
+ await page.wait_for_function(
+ "document.querySelectorAll('.quote').length >= 10"
+ )
+ print("等待 JS 条件 - 至少 10 条名言")
+
+ # 等待全局变量
+ # await page.wait_for_function("window.dataLoaded === true")
+
+ finally:
+ await browser.close()
+
+
+async def demo_expect_patterns():
+ """演示 expect 模式(等待请求/响应)"""
+ print("\n" + "=" * 50)
+ print("6. expect 模式(等待请求/响应)")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ try:
+ # 等待响应
+ async with page.expect_response("**/api/**") as response_info:
+ # 这个例子使用的网站没有 API,所以会超时
+ # 实际使用时,这里会等待匹配的响应
+ pass
+ # response = await response_info.value
+
+ print("expect_response - 跳过(示例网站无 API)")
+
+ # 正常访问
+ await page.goto("https://quotes.toscrape.com/")
+
+ # 等待导航
+ async with page.expect_navigation():
+ await page.click("a[href='/page/2/']")
+ print(f"expect_navigation - 导航到: {page.url}")
+
+ except PlaywrightTimeout:
+ print("超时 - 这是预期行为(示例网站无 API)")
+
+ finally:
+ await browser.close()
+
+
+async def demo_timeout_handling():
+ """演示超时处理"""
+ print("\n" + "=" * 50)
+ print("7. 超时处理")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ try:
+ await page.goto("https://quotes.toscrape.com/")
+
+ # 设置默认超时
+ page.set_default_timeout(5000) # 5 秒
+ print("设置默认超时: 5秒")
+
+ # 单次操作超时
+ try:
+ await page.wait_for_selector(
+ "div.not-exist",
+ timeout=2000 # 2 秒
+ )
+ except PlaywrightTimeout:
+ print("捕获超时异常 - 元素不存在")
+
+ # 使用 try-except 处理可能的超时
+ try:
+ await page.click("button.maybe-exist", timeout=1000)
+ except PlaywrightTimeout:
+ print("捕获超时异常 - 按钮不存在")
+ except Exception as e:
+ print(f"捕获其他异常: {type(e).__name__}")
+
+ finally:
+ await browser.close()
+
+
+async def main():
+ """主函数"""
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {message}",
+ level="INFO"
+ )
+
+ print("=" * 50)
+ print("Playwright 等待策略详解")
+ print("=" * 50)
+
+ await demo_auto_waiting()
+ await demo_wait_for_selector()
+ await demo_wait_for_load_state()
+ await demo_wait_for_url()
+ await demo_wait_for_function()
+ await demo_expect_patterns()
+ await demo_timeout_handling()
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226/README.md" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226/README.md"
new file mode 100644
index 0000000..455c346
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226/README.md"
@@ -0,0 +1,23 @@
+# 第05章:Playwright进阶 - 反检测与性能优化
+
+展示stealth.js注入、CDP模式、浏览器指纹伪装、性能优化等高级技术。
+
+## 快速开始
+
+```bash
+cd 05_Playwright进阶_反检测与性能优化
+uv sync
+uv run playwright install chromium
+uv run python stealth_demo.py
+uv run python cdp_mode.py
+uv run python performance_optimization.py
+```
+
+### 测试网站
+- **bot.sannysoft.com** - 最佳的反检测测试网站
+
+### 核心技术
+- stealth.js注入 - 隐藏自动化特征
+- CDP模式 - 直接调用Chrome DevTools Protocol
+- 资源拦截 - 阻止不必要的资源加载
+- 浏览器上下文池 - 复用提升性能
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226/cdp_mode.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226/cdp_mode.py"
new file mode 100644
index 0000000..d9e1cf6
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226/cdp_mode.py"
@@ -0,0 +1,246 @@
+# -*- coding: utf-8 -*-
+# @Desc: Playwright CDP 模式演示
+
+import asyncio
+import base64
+from playwright.async_api import async_playwright
+from loguru import logger
+
+
+async def demo_cdp_session():
+ """演示 CDP Session 基本用法"""
+ print("\n" + "=" * 50)
+ print("1. CDP Session 基本用法")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ context = await browser.new_context()
+ page = await context.new_page()
+
+ # 创建 CDP Session
+ client = await context.new_cdp_session(page)
+
+ # 启用网络事件
+ await client.send("Network.enable")
+
+ # 获取性能指标
+ await page.goto("https://example.com")
+ metrics = await client.send("Performance.getMetrics")
+
+ print("性能指标:")
+ for metric in metrics.get("metrics", [])[:5]:
+ print(f" {metric['name']}: {metric['value']:.2f}")
+
+ await browser.close()
+
+
+async def demo_network_emulation():
+ """演示网络条件模拟"""
+ print("\n" + "=" * 50)
+ print("2. 网络条件模拟")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ context = await browser.new_context()
+ page = await context.new_page()
+
+ client = await context.new_cdp_session(page)
+
+ # 启用网络
+ await client.send("Network.enable")
+
+ # 模拟慢速网络(3G)
+ await client.send("Network.emulateNetworkConditions", {
+ "offline": False,
+ "downloadThroughput": 750 * 1024 / 8, # 750 Kbps
+ "uploadThroughput": 250 * 1024 / 8, # 250 Kbps
+ "latency": 100 # 100ms
+ })
+
+ print("已设置 3G 网络条件")
+
+ # 测试加载时间
+ import time
+ start = time.time()
+ await page.goto("https://example.com", wait_until="load")
+ elapsed = time.time() - start
+ print(f"加载时间 (3G): {elapsed:.2f}s")
+
+ # 恢复正常网络
+ await client.send("Network.emulateNetworkConditions", {
+ "offline": False,
+ "downloadThroughput": -1, # 不限制
+ "uploadThroughput": -1,
+ "latency": 0
+ })
+
+ start = time.time()
+ await page.reload(wait_until="load")
+ elapsed = time.time() - start
+ print(f"加载时间 (正常): {elapsed:.2f}s")
+
+ await browser.close()
+
+
+async def demo_cdp_screenshot():
+ """演示 CDP 截图"""
+ print("\n" + "=" * 50)
+ print("3. CDP 截图(高级选项)")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ context = await browser.new_context()
+ page = await context.new_page()
+
+ client = await context.new_cdp_session(page)
+
+ await page.goto("https://quotes.toscrape.com/")
+
+ # 使用 CDP 截取完整页面
+ result = await client.send("Page.captureScreenshot", {
+ "format": "png",
+ "captureBeyondViewport": True, # 捕获视口外内容
+ "fromSurface": True
+ })
+
+ # 保存截图
+ image_data = base64.b64decode(result["data"])
+ with open("cdp_screenshot.png", "wb") as f:
+ f.write(image_data)
+
+ print("CDP 截图已保存: cdp_screenshot.png")
+
+ await browser.close()
+
+
+async def demo_cdp_dom():
+ """演示 CDP DOM 操作"""
+ print("\n" + "=" * 50)
+ print("4. CDP DOM 操作")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ context = await browser.new_context()
+ page = await context.new_page()
+
+ client = await context.new_cdp_session(page)
+
+ await page.goto("https://quotes.toscrape.com/")
+
+ # 启用 DOM
+ await client.send("DOM.enable")
+
+ # 获取文档
+ doc = await client.send("DOM.getDocument")
+ root_node_id = doc["root"]["nodeId"]
+
+ # 查询选择器
+ result = await client.send("DOM.querySelectorAll", {
+ "nodeId": root_node_id,
+ "selector": "div.quote"
+ })
+
+ print(f"找到 {len(result['nodeIds'])} 个 quote 元素")
+
+ # 获取第一个元素的 HTML
+ if result["nodeIds"]:
+ outer_html = await client.send("DOM.getOuterHTML", {
+ "nodeId": result["nodeIds"][0]
+ })
+ print(f"第一个元素 HTML 长度: {len(outer_html['outerHTML'])} 字符")
+
+ await browser.close()
+
+
+async def demo_cdp_cookies():
+ """演示 CDP Cookie 操作"""
+ print("\n" + "=" * 50)
+ print("5. CDP Cookie 操作")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ context = await browser.new_context()
+ page = await context.new_page()
+
+ client = await context.new_cdp_session(page)
+
+ # 启用网络
+ await client.send("Network.enable")
+
+ await page.goto("https://quotes.toscrape.com/login")
+
+ # 获取所有 cookies
+ cookies = await client.send("Network.getAllCookies")
+ print(f"当前 Cookies 数量: {len(cookies['cookies'])}")
+
+ # 设置自定义 cookie
+ await client.send("Network.setCookie", {
+ "name": "custom_cookie",
+ "value": "test_value",
+ "domain": "quotes.toscrape.com",
+ "path": "/"
+ })
+
+ # 再次获取
+ cookies = await client.send("Network.getAllCookies")
+ print(f"添加后 Cookies 数量: {len(cookies['cookies'])}")
+
+ for cookie in cookies['cookies']:
+ print(f" {cookie['name']}: {cookie['value'][:20]}...")
+
+ await browser.close()
+
+
+async def demo_connect_existing():
+ """演示连接已有浏览器(需要手动启动 Chrome)"""
+ print("\n" + "=" * 50)
+ print("6. 连接已有浏览器")
+ print("=" * 50)
+
+ print("此功能需要先手动启动带调试端口的 Chrome:")
+ print(" chrome --remote-debugging-port=9222")
+ print("")
+ print("跳过此演示...")
+
+ # 实际代码:
+ # async with async_playwright() as p:
+ # browser = await p.chromium.connect_over_cdp("http://localhost:9222")
+ # contexts = browser.contexts
+ # if contexts:
+ # page = contexts[0].pages[0]
+ # print(f"已连接到: {page.url}")
+ # await browser.close()
+
+
+async def main():
+ """主函数"""
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="INFO"
+ )
+
+ print("=" * 50)
+ print("Playwright CDP 模式演示")
+ print("=" * 50)
+
+ await demo_cdp_session()
+ await demo_network_emulation()
+ await demo_cdp_screenshot()
+ await demo_cdp_dom()
+ await demo_cdp_cookies()
+ await demo_connect_existing()
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226/performance_optimization.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226/performance_optimization.py"
new file mode 100644
index 0000000..4001b1f
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226/performance_optimization.py"
@@ -0,0 +1,332 @@
+# -*- coding: utf-8 -*-
+# @Desc: Playwright 性能优化演示
+
+import asyncio
+import time
+from typing import List, Dict, Optional
+from playwright.async_api import async_playwright, Browser, BrowserContext, Page
+from loguru import logger
+
+
+class ResourceBlocker:
+ """资源拦截器 - 阻止不必要的资源加载"""
+
+ # 要阻止的资源类型
+ BLOCKED_RESOURCE_TYPES = {
+ "image",
+ "media",
+ "font",
+ "stylesheet",
+ }
+
+ # 要阻止的 URL 模式
+ BLOCKED_URL_PATTERNS = [
+ "**/analytics**",
+ "**/gtag/**",
+ "**/google-analytics**",
+ "**/facebook.com/**",
+ "**/doubleclick.net/**",
+ "**/*.woff",
+ "**/*.woff2",
+ "**/*.ttf",
+ ]
+
+ @classmethod
+ async def setup(cls, context: BrowserContext):
+ """设置资源拦截"""
+ # 按资源类型拦截
+ async def block_by_type(route):
+ if route.request.resource_type in cls.BLOCKED_RESOURCE_TYPES:
+ await route.abort()
+ else:
+ await route.continue_()
+
+ await context.route("**/*", block_by_type)
+
+ logger.debug("资源拦截器已设置")
+
+ @classmethod
+ async def setup_selective(cls, context: BrowserContext, allow_css: bool = False):
+ """选择性资源拦截"""
+ blocked_types = cls.BLOCKED_RESOURCE_TYPES.copy()
+ if allow_css:
+ blocked_types.discard("stylesheet")
+
+ async def block_selective(route):
+ if route.request.resource_type in blocked_types:
+ await route.abort()
+ else:
+ await route.continue_()
+
+ await context.route("**/*", block_selective)
+
+
+class ContextPool:
+ """浏览器上下文池"""
+
+ def __init__(self, browser: Browser, pool_size: int = 3):
+ self.browser = browser
+ self.pool_size = pool_size
+ self._contexts: List[BrowserContext] = []
+ self._available: asyncio.Queue = asyncio.Queue()
+ self._lock = asyncio.Lock()
+
+ async def initialize(self, with_stealth: bool = False, block_resources: bool = True):
+ """初始化上下文池"""
+ for i in range(self.pool_size):
+ context = await self.browser.new_context(
+ viewport={"width": 1920, "height": 1080}
+ )
+
+ if block_resources:
+ await ResourceBlocker.setup(context)
+
+ if with_stealth:
+ await context.add_init_script("""
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
+ """)
+
+ self._contexts.append(context)
+ await self._available.put(context)
+
+ logger.info(f"上下文池初始化完成,大小: {self.pool_size}")
+
+ async def acquire(self) -> BrowserContext:
+ """获取上下文"""
+ return await self._available.get()
+
+ async def release(self, context: BrowserContext):
+ """释放上下文"""
+ # 清理 cookies
+ await context.clear_cookies()
+ await self._available.put(context)
+
+ async def close_all(self):
+ """关闭所有上下文"""
+ for context in self._contexts:
+ await context.close()
+ self._contexts.clear()
+ logger.info("所有上下文已关闭")
+
+
+class OptimizedCrawler:
+ """优化的爬虫"""
+
+ def __init__(
+ self,
+ max_concurrent: int = 5,
+ block_resources: bool = True,
+ use_stealth: bool = True
+ ):
+ self.max_concurrent = max_concurrent
+ self.block_resources = block_resources
+ self.use_stealth = use_stealth
+
+ self._browser: Optional[Browser] = None
+ self._context_pool: Optional[ContextPool] = None
+ self._semaphore = asyncio.Semaphore(max_concurrent)
+
+ # 统计
+ self.stats = {
+ "total": 0,
+ "success": 0,
+ "failed": 0,
+ "total_time": 0.0,
+ }
+
+ async def start(self, playwright):
+ """启动爬虫"""
+ self._browser = await playwright.chromium.launch(headless=True)
+ self._context_pool = ContextPool(self._browser, pool_size=self.max_concurrent)
+ await self._context_pool.initialize(
+ with_stealth=self.use_stealth,
+ block_resources=self.block_resources
+ )
+ logger.info("优化爬虫已启动")
+
+ async def stop(self):
+ """停止爬虫"""
+ if self._context_pool:
+ await self._context_pool.close_all()
+ if self._browser:
+ await self._browser.close()
+ logger.info("优化爬虫已停止")
+
+ async def fetch(self, url: str) -> Dict:
+ """获取单个页面"""
+ start_time = time.time()
+ self.stats["total"] += 1
+
+ async with self._semaphore:
+ context = await self._context_pool.acquire()
+ page = await context.new_page()
+
+ try:
+ await page.goto(url, wait_until="domcontentloaded", timeout=15000)
+ title = await page.title()
+ content_length = len(await page.content())
+
+ elapsed = time.time() - start_time
+ self.stats["success"] += 1
+ self.stats["total_time"] += elapsed
+
+ return {
+ "url": url,
+ "title": title,
+ "content_length": content_length,
+ "time": elapsed,
+ "success": True
+ }
+
+ except Exception as e:
+ self.stats["failed"] += 1
+ return {
+ "url": url,
+ "error": str(e),
+ "success": False
+ }
+
+ finally:
+ await page.close()
+ await self._context_pool.release(context)
+
+ async def fetch_batch(self, urls: List[str]) -> List[Dict]:
+ """批量获取页面"""
+ tasks = [self.fetch(url) for url in urls]
+ return await asyncio.gather(*tasks)
+
+
+async def demo_resource_blocking():
+ """演示资源拦截效果"""
+ print("\n" + "=" * 50)
+ print("1. 资源拦截效果对比")
+ print("=" * 50)
+
+ test_url = "https://quotes.toscrape.com/"
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+
+ # 不拦截资源
+ context1 = await browser.new_context()
+ page1 = await context1.new_page()
+
+ start = time.time()
+ await page1.goto(test_url, wait_until="load")
+ time_without_blocking = time.time() - start
+ await context1.close()
+
+ # 拦截资源
+ context2 = await browser.new_context()
+ await ResourceBlocker.setup(context2)
+ page2 = await context2.new_page()
+
+ start = time.time()
+ await page2.goto(test_url, wait_until="load")
+ time_with_blocking = time.time() - start
+ await context2.close()
+
+ print(f"不拦截资源: {time_without_blocking:.2f}s")
+ print(f"拦截资源: {time_with_blocking:.2f}s")
+ print(f"提升: {(1 - time_with_blocking/time_without_blocking)*100:.1f}%")
+
+ await browser.close()
+
+
+async def demo_context_pool():
+ """演示上下文池"""
+ print("\n" + "=" * 50)
+ print("2. 上下文池演示")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ pool = ContextPool(browser, pool_size=3)
+ await pool.initialize()
+
+ # 模拟并发使用
+ async def use_context(task_id: int):
+ context = await pool.acquire()
+ page = await context.new_page()
+ await page.goto("https://example.com")
+ print(f"任务 {task_id}: 使用上下文完成")
+ await page.close()
+ await pool.release(context)
+
+ # 启动 5 个任务(但池大小只有 3)
+ tasks = [use_context(i) for i in range(5)]
+ await asyncio.gather(*tasks)
+
+ await pool.close_all()
+ await browser.close()
+
+
+async def demo_optimized_crawler():
+ """演示优化爬虫"""
+ print("\n" + "=" * 50)
+ print("3. 优化爬虫演示")
+ print("=" * 50)
+
+ urls = [
+ "https://quotes.toscrape.com/",
+ "https://quotes.toscrape.com/page/2/",
+ "https://quotes.toscrape.com/page/3/",
+ "https://example.com/",
+ "https://httpbin.org/html",
+ ]
+
+ async with async_playwright() as p:
+ crawler = OptimizedCrawler(
+ max_concurrent=3,
+ block_resources=True,
+ use_stealth=True
+ )
+
+ await crawler.start(p)
+
+ try:
+ results = await crawler.fetch_batch(urls)
+
+ print("\n爬取结果:")
+ for result in results:
+ if result["success"]:
+ print(f" ✓ {result['url'][:40]}... ({result['time']:.2f}s)")
+ else:
+ print(f" ✗ {result['url'][:40]}... ({result['error'][:30]})")
+
+ print(f"\n统计:")
+ print(f" 总请求: {crawler.stats['total']}")
+ print(f" 成功: {crawler.stats['success']}")
+ print(f" 失败: {crawler.stats['failed']}")
+ if crawler.stats['success'] > 0:
+ avg_time = crawler.stats['total_time'] / crawler.stats['success']
+ print(f" 平均耗时: {avg_time:.2f}s")
+
+ finally:
+ await crawler.stop()
+
+
+async def main():
+ """主函数"""
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="INFO"
+ )
+
+ print("=" * 50)
+ print("Playwright 性能优化演示")
+ print("=" * 50)
+
+ await demo_resource_blocking()
+ await demo_context_pool()
+ await demo_optimized_crawler()
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226/stealth_demo.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226/stealth_demo.py"
new file mode 100644
index 0000000..c7b1f10
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/05_Playwright\350\277\233\351\230\266_\345\217\215\346\243\200\346\265\213\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226/stealth_demo.py"
@@ -0,0 +1,274 @@
+# -*- coding: utf-8 -*-
+# @Desc: Playwright stealth 反检测演示
+
+import asyncio
+from playwright.async_api import async_playwright
+from loguru import logger
+
+
+# stealth.min.js 核心脚本(简化版)
+# 实际使用时建议使用完整的 stealth.min.js
+STEALTH_JS_MINIMAL = """
+// 1. 隐藏 webdriver 标志
+Object.defineProperty(navigator, 'webdriver', {
+ get: () => undefined
+});
+
+// 2. 模拟 Chrome 对象
+window.chrome = {
+ runtime: {},
+ loadTimes: function() {},
+ csi: function() {},
+ app: {}
+};
+
+// 3. 模拟 plugins 列表
+Object.defineProperty(navigator, 'plugins', {
+ get: () => {
+ const plugins = [
+ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
+ { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
+ { name: 'Native Client', filename: 'internal-nacl-plugin' }
+ ];
+ plugins.length = 3;
+ return plugins;
+ }
+});
+
+// 4. 模拟 languages
+Object.defineProperty(navigator, 'languages', {
+ get: () => ['zh-CN', 'zh', 'en-US', 'en']
+});
+
+// 5. 修复 permissions API
+const originalQuery = window.navigator.permissions.query;
+window.navigator.permissions.query = (parameters) => (
+ parameters.name === 'notifications' ?
+ Promise.resolve({ state: Notification.permission }) :
+ originalQuery(parameters)
+);
+
+// 6. 隐藏 automation 标志
+if (navigator.userAgentData) {
+ Object.defineProperty(navigator.userAgentData, 'brands', {
+ get: () => [
+ { brand: 'Google Chrome', version: '131' },
+ { brand: 'Chromium', version: '131' },
+ { brand: 'Not_A Brand', version: '8' }
+ ]
+ });
+}
+
+// 7. 修复 iframe contentWindow
+const originalAttachShadow = Element.prototype.attachShadow;
+Element.prototype.attachShadow = function(init) {
+ if (init.mode === 'closed') {
+ init.mode = 'open';
+ }
+ return originalAttachShadow.call(this, init);
+};
+
+console.log('Stealth script injected!');
+"""
+
+
+async def create_stealth_browser(playwright, headless: bool = True):
+ """
+ 创建带反检测的浏览器实例
+
+ Args:
+ playwright: playwright 实例
+ headless: 是否无头模式
+
+ Returns:
+ 配置好的浏览器实例
+ """
+ browser = await playwright.chromium.launch(
+ headless=headless,
+ args=[
+ '--disable-blink-features=AutomationControlled', # 禁用自动化控制特征
+ '--disable-dev-shm-usage',
+ '--no-sandbox',
+ ]
+ )
+ return browser
+
+
+async def create_stealth_context(browser, stealth_js: str = None):
+ """
+ 创建带反检测的浏览器上下文
+
+ Args:
+ browser: 浏览器实例
+ stealth_js: stealth 脚本内容
+
+ Returns:
+ 配置好的上下文
+ """
+ context = await browser.new_context(
+ viewport={'width': 1920, 'height': 1080},
+ user_agent=(
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
+ 'AppleWebKit/537.36 (KHTML, like Gecko) '
+ 'Chrome/131.0.0.0 Safari/537.36'
+ ),
+ locale='zh-CN',
+ timezone_id='Asia/Shanghai',
+ )
+
+ # 注入 stealth 脚本
+ js = stealth_js or STEALTH_JS_MINIMAL
+ await context.add_init_script(js)
+
+ return context
+
+
+async def test_detection(page, test_url: str = "https://bot.sannysoft.com/"):
+ """
+ 测试反检测效果
+
+ Args:
+ page: 页面实例
+ test_url: 检测网站 URL
+ """
+ logger.info(f"访问检测网站: {test_url}")
+ await page.goto(test_url, wait_until="networkidle")
+
+ # 检查关键指标
+ checks = {
+ "webdriver": await page.evaluate("navigator.webdriver"),
+ "chrome": await page.evaluate("!!window.chrome"),
+ "plugins_length": await page.evaluate("navigator.plugins.length"),
+ "languages": await page.evaluate("navigator.languages"),
+ }
+
+ logger.info("检测结果:")
+ for key, value in checks.items():
+ status = "✓" if _check_passed(key, value) else "✗"
+ logger.info(f" {status} {key}: {value}")
+
+ # 保存截图
+ await page.screenshot(path="stealth_test_result.png", full_page=True)
+ logger.info("截图已保存: stealth_test_result.png")
+
+
+def _check_passed(key: str, value) -> bool:
+ """检查是否通过"""
+ if key == "webdriver":
+ return value is None or value == "undefined"
+ elif key == "chrome":
+ return value is True
+ elif key == "plugins_length":
+ return value > 0
+ elif key == "languages":
+ return len(value) > 0
+ return True
+
+
+async def demo_without_stealth():
+ """演示没有 stealth 的情况"""
+ print("\n" + "=" * 50)
+ print("1. 没有 stealth 的浏览器")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await p.chromium.launch(headless=True)
+ page = await browser.new_page()
+
+ await page.goto("https://bot.sannysoft.com/", wait_until="networkidle")
+
+ # 检查 webdriver
+ webdriver = await page.evaluate("navigator.webdriver")
+ chrome = await page.evaluate("!!window.chrome")
+
+ print(f"navigator.webdriver: {webdriver}")
+ print(f"window.chrome exists: {chrome}")
+
+ await page.screenshot(path="no_stealth.png", full_page=True)
+ print("截图已保存: no_stealth.png")
+
+ await browser.close()
+
+
+async def demo_with_stealth():
+ """演示使用 stealth 的情况"""
+ print("\n" + "=" * 50)
+ print("2. 使用 stealth 的浏览器")
+ print("=" * 50)
+
+ async with async_playwright() as p:
+ browser = await create_stealth_browser(p, headless=True)
+ context = await create_stealth_context(browser)
+ page = await context.new_page()
+
+ await test_detection(page)
+
+ await browser.close()
+
+
+async def demo_custom_stealth():
+ """演示自定义 stealth 配置"""
+ print("\n" + "=" * 50)
+ print("3. 自定义 stealth 配置")
+ print("=" * 50)
+
+ # 可以根据需要添加更多的规避代码
+ custom_stealth = STEALTH_JS_MINIMAL + """
+ // 额外的自定义规避
+ Object.defineProperty(navigator, 'hardwareConcurrency', {
+ get: () => 8
+ });
+
+ Object.defineProperty(navigator, 'deviceMemory', {
+ get: () => 8
+ });
+
+ Object.defineProperty(screen, 'colorDepth', {
+ get: () => 24
+ });
+ """
+
+ async with async_playwright() as p:
+ browser = await create_stealth_browser(p, headless=True)
+ context = await create_stealth_context(browser, custom_stealth)
+ page = await context.new_page()
+
+ # 检查自定义属性
+ await page.goto("about:blank")
+
+ hardware = await page.evaluate("navigator.hardwareConcurrency")
+ memory = await page.evaluate("navigator.deviceMemory")
+ color_depth = await page.evaluate("screen.colorDepth")
+
+ print(f"hardwareConcurrency: {hardware}")
+ print(f"deviceMemory: {memory}")
+ print(f"colorDepth: {color_depth}")
+
+ await browser.close()
+
+
+async def main():
+ """主函数"""
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="INFO"
+ )
+
+ print("=" * 50)
+ print("Playwright Stealth 反检测演示")
+ print("=" * 50)
+
+ await demo_without_stealth()
+ await demo_with_stealth()
+ await demo_custom_stealth()
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+ print("\n提示: 查看生成的截图文件对比效果")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/README.md" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/README.md"
new file mode 100644
index 0000000..90180cc
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/README.md"
@@ -0,0 +1,21 @@
+# 第06章:登录认证 - Cookie与Session管理
+
+展示Cookie管理、登录状态检测、真实网站登录演示。
+
+## 快速开始
+
+```bash
+cd 06_登录认证_Cookie与Session管理
+uv sync
+uv run python cookie_manager.py
+uv run python login_state_checker.py
+uv run python session_demo.py # 包含 quotes.toscrape.com 真实登录演示
+```
+
+### 新增功能
+✨ **真实登录演示**:`session_demo.py` 中的 `demo_real_login()` 函数演示了完整的 quotes.toscrape.com 登录流程。
+
+### 核心依赖
+- `httpx` - HTTP客户端
+- `loguru` - 日志系统
+- `cryptography`(可选)- Cookie加密
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/bilibili_cookie.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/bilibili_cookie.py"
new file mode 100644
index 0000000..21d5118
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/bilibili_cookie.py"
@@ -0,0 +1,483 @@
+# -*- coding: utf-8 -*-
+"""
+B站 Cookie 管理实战
+
+本模块展示如何管理 B站 Cookie,包括:
+- B站核心 Cookie 结构(SESSDATA、DedeUserID、bili_jct)
+- Cookie 提取与存储
+- 登录状态验证
+- httpx 和 Playwright 集成
+
+这是第06章"登录认证-Cookie与Session管理"的B站实战示例。
+
+与第11章综合实战项目的关联:
+- login/auth.py: BilibiliCookieManager 登录管理
+- models/cookies.py: Cookie 模型定义
+"""
+
+import json
+import asyncio
+from pathlib import Path
+from typing import Optional, Dict, List
+from dataclasses import dataclass, field
+from datetime import datetime
+
+import httpx
+from loguru import logger
+
+
+# ============== B站 Cookie 数据类 ==============
+
+@dataclass
+class BilibiliCookies:
+ """
+ B站 Cookie 数据类
+
+ 核心 Cookie:
+ - SESSDATA: 会话凭证(最重要)
+ - DedeUserID: 用户ID
+ - bili_jct: CSRF Token(POST请求必需)
+
+ 辅助 Cookie:
+ - buvid3/buvid4: 设备标识
+ - sid: 短会话ID
+ """
+ sessdata: str
+ dede_user_id: str
+ bili_jct: str
+ buvid3: str = ""
+ buvid4: str = ""
+ sid: str = ""
+ raw_cookies: List[dict] = field(default_factory=list)
+
+ @classmethod
+ def from_playwright_cookies(cls, cookies: List[dict]) -> "BilibiliCookies":
+ """
+ 从 Playwright 格式的 Cookie 创建
+
+ Args:
+ cookies: Playwright context.cookies() 返回的列表
+
+ Returns:
+ BilibiliCookies 实例
+ """
+ cookie_dict = {c["name"]: c["value"] for c in cookies}
+
+ return cls(
+ sessdata=cookie_dict.get("SESSDATA", ""),
+ dede_user_id=cookie_dict.get("DedeUserID", ""),
+ bili_jct=cookie_dict.get("bili_jct", ""),
+ buvid3=cookie_dict.get("buvid3", ""),
+ buvid4=cookie_dict.get("buvid4", ""),
+ sid=cookie_dict.get("sid", ""),
+ raw_cookies=cookies
+ )
+
+ @classmethod
+ def from_browser_string(cls, cookie_string: str) -> "BilibiliCookies":
+ """
+ 从浏览器复制的 Cookie 字符串创建
+
+ Args:
+ cookie_string: 格式 "SESSDATA=xxx; DedeUserID=xxx; bili_jct=xxx"
+
+ Returns:
+ BilibiliCookies 实例
+ """
+ cookie_dict = {}
+ for item in cookie_string.split(";"):
+ item = item.strip()
+ if "=" in item:
+ key, value = item.split("=", 1)
+ cookie_dict[key.strip()] = value.strip()
+
+ return cls(
+ sessdata=cookie_dict.get("SESSDATA", ""),
+ dede_user_id=cookie_dict.get("DedeUserID", ""),
+ bili_jct=cookie_dict.get("bili_jct", ""),
+ buvid3=cookie_dict.get("buvid3", ""),
+ buvid4=cookie_dict.get("buvid4", ""),
+ sid=cookie_dict.get("sid", "")
+ )
+
+ def to_httpx_cookies(self) -> Dict[str, str]:
+ """转换为 httpx 可用的字典格式"""
+ cookies = {
+ "SESSDATA": self.sessdata,
+ "DedeUserID": self.dede_user_id,
+ "bili_jct": self.bili_jct,
+ }
+ if self.buvid3:
+ cookies["buvid3"] = self.buvid3
+ if self.buvid4:
+ cookies["buvid4"] = self.buvid4
+ if self.sid:
+ cookies["sid"] = self.sid
+ return cookies
+
+ def to_playwright_cookies(self, domain: str = ".bilibili.com") -> List[dict]:
+ """
+ 转换为 Playwright 可用的格式
+
+ Args:
+ domain: Cookie 的域名
+
+ Returns:
+ Playwright 格式的 Cookie 列表
+ """
+ if self.raw_cookies:
+ return self.raw_cookies
+
+ cookies = []
+ for name, value in self.to_httpx_cookies().items():
+ cookies.append({
+ "name": name,
+ "value": value,
+ "domain": domain,
+ "path": "/"
+ })
+ return cookies
+
+ def is_valid(self) -> bool:
+ """检查核心 Cookie 是否存在"""
+ return bool(self.sessdata and self.dede_user_id and self.bili_jct)
+
+ def to_header_string(self) -> str:
+ """转换为请求头 Cookie 格式"""
+ return "; ".join(f"{k}={v}" for k, v in self.to_httpx_cookies().items())
+
+
+# ============== B站 Cookie 管理器 ==============
+
+class BilibiliCookieManager:
+ """
+ B站 Cookie 管理器
+
+ 功能:
+ - Cookie 加载/保存
+ - 登录状态检测
+ - Cookie 有效性验证
+ - 支持多种格式(JSON、字符串)
+ """
+
+ # 登录状态检测 API
+ CHECK_URL = "https://api.bilibili.com/x/web-interface/nav"
+
+ # 请求头
+ HEADERS = {
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/131.0.0.0 Safari/537.36",
+ "Referer": "https://www.bilibili.com/"
+ }
+
+ def __init__(self, storage_path: str = "bilibili_cookies.json"):
+ """
+ 初始化管理器
+
+ Args:
+ storage_path: Cookie 存储文件路径
+ """
+ self.storage_path = Path(storage_path)
+ self._cookies: Optional[BilibiliCookies] = None
+ self._last_check: Optional[datetime] = None
+ self._user_info: Optional[dict] = None
+
+ async def load(self) -> bool:
+ """
+ 从文件加载 Cookie
+
+ Returns:
+ 是否加载成功
+ """
+ if not self.storage_path.exists():
+ logger.warning(f"Cookie 文件不存在: {self.storage_path}")
+ return False
+
+ try:
+ with open(self.storage_path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+
+ if isinstance(data, list):
+ # Playwright 格式
+ self._cookies = BilibiliCookies.from_playwright_cookies(data)
+ elif isinstance(data, dict):
+ # 自定义格式
+ self._cookies = BilibiliCookies(
+ sessdata=data.get("SESSDATA", ""),
+ dede_user_id=data.get("DedeUserID", ""),
+ bili_jct=data.get("bili_jct", ""),
+ buvid3=data.get("buvid3", ""),
+ buvid4=data.get("buvid4", ""),
+ sid=data.get("sid", "")
+ )
+
+ logger.info(f"Cookie 加载成功,用户ID: {self._cookies.dede_user_id}")
+ return True
+
+ except Exception as e:
+ logger.error(f"加载 Cookie 失败: {e}")
+ return False
+
+ async def save(self, cookies: Optional[BilibiliCookies] = None):
+ """
+ 保存 Cookie 到文件
+
+ Args:
+ cookies: 要保存的 Cookie,如果为 None 则保存当前 Cookie
+ """
+ if cookies:
+ self._cookies = cookies
+
+ if not self._cookies:
+ logger.warning("没有 Cookie 可保存")
+ return
+
+ self.storage_path.parent.mkdir(parents=True, exist_ok=True)
+
+ data = {
+ "SESSDATA": self._cookies.sessdata,
+ "DedeUserID": self._cookies.dede_user_id,
+ "bili_jct": self._cookies.bili_jct,
+ "buvid3": self._cookies.buvid3,
+ "buvid4": self._cookies.buvid4,
+ "sid": self._cookies.sid,
+ "save_time": datetime.now().isoformat()
+ }
+
+ with open(self.storage_path, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+
+ logger.info(f"Cookie 已保存到: {self.storage_path}")
+
+ async def verify(self) -> bool:
+ """
+ 验证 Cookie 是否有效
+
+ Returns:
+ Cookie 是否有效
+ """
+ if not self._cookies or not self._cookies.is_valid():
+ return False
+
+ try:
+ async with httpx.AsyncClient(
+ cookies=self._cookies.to_httpx_cookies(),
+ headers=self.HEADERS,
+ timeout=10
+ ) as client:
+ resp = await client.get(self.CHECK_URL)
+ data = resp.json()
+
+ if data.get("code") == 0:
+ user_info = data.get("data", {})
+ if user_info.get("isLogin"):
+ self._user_info = user_info
+ self._last_check = datetime.now()
+ logger.info(f"Cookie 有效,用户: {user_info.get('uname')}")
+ return True
+
+ logger.warning("Cookie 已失效")
+ return False
+
+ except Exception as e:
+ logger.error(f"验证 Cookie 失败: {e}")
+ return False
+
+ async def get_valid_cookies(self) -> Optional[BilibiliCookies]:
+ """
+ 获取有效的 Cookie
+
+ Returns:
+ 有效的 BilibiliCookies,如果无效则返回 None
+ """
+ if self._cookies is None:
+ await self.load()
+
+ if self._cookies and await self.verify():
+ return self._cookies
+
+ return None
+
+ def set_cookies_from_string(self, cookie_string: str):
+ """
+ 从浏览器复制的字符串设置 Cookie
+
+ Args:
+ cookie_string: 格式 "SESSDATA=xxx; DedeUserID=xxx; bili_jct=xxx"
+ """
+ self._cookies = BilibiliCookies.from_browser_string(cookie_string)
+ logger.info(f"Cookie 已设置,用户ID: {self._cookies.dede_user_id}")
+
+ @property
+ def cookies(self) -> Optional[BilibiliCookies]:
+ """获取当前 Cookie(不验证)"""
+ return self._cookies
+
+ @property
+ def user_info(self) -> Optional[dict]:
+ """获取用户信息(需先调用 verify)"""
+ return self._user_info
+
+
+# ============== Cookie 格式转换工具 ==============
+
+def playwright_cookies_to_httpx(playwright_cookies: List[dict]) -> dict:
+ """
+ 将 Playwright 格式的 Cookie 转换为 httpx 格式
+
+ Args:
+ playwright_cookies: Playwright 格式 [{"name": "x", "value": "y", ...}, ...]
+
+ Returns:
+ httpx 格式 {"name": "value", ...}
+ """
+ return {c["name"]: c["value"] for c in playwright_cookies}
+
+
+def httpx_cookies_to_playwright(cookies_dict: dict, domain: str = ".bilibili.com") -> List[dict]:
+ """
+ 将 httpx 字典格式转换为 Playwright 格式
+
+ Args:
+ cookies_dict: 简单字典 {"name": "value", ...}
+ domain: Cookie 的域名
+
+ Returns:
+ Playwright 格式的 Cookie 列表
+ """
+ return [
+ {
+ "name": name,
+ "value": value,
+ "domain": domain,
+ "path": "/"
+ }
+ for name, value in cookies_dict.items()
+ ]
+
+
+# ============== 演示入口 ==============
+
+async def demo_bilibili_cookie():
+ """演示 B站 Cookie 管理"""
+ logger.info("=" * 50)
+ logger.info("B站 Cookie 管理示例")
+ logger.info("=" * 50)
+
+ # 1. 展示 Cookie 结构
+ logger.info("\n--- 1. B站核心 Cookie 结构 ---")
+ logger.info("SESSDATA: 会话凭证(最重要,有效期约1个月)")
+ logger.info("DedeUserID: 用户ID")
+ logger.info("bili_jct: CSRF Token(POST请求必需)")
+ logger.info("buvid3/buvid4: 设备标识")
+
+ # 2. 演示从字符串创建 Cookie
+ logger.info("\n--- 2. 从浏览器字符串创建 Cookie ---")
+ # 示例字符串(实际使用时替换为真实值)
+ sample_string = "SESSDATA=sample_sessdata; DedeUserID=123456; bili_jct=sample_csrf"
+ cookies = BilibiliCookies.from_browser_string(sample_string)
+
+ logger.info(f"SESSDATA: {cookies.sessdata}")
+ logger.info(f"DedeUserID: {cookies.dede_user_id}")
+ logger.info(f"bili_jct: {cookies.bili_jct}")
+ logger.info(f"Cookie 是否有效: {cookies.is_valid()}")
+
+ # 3. 演示 Cookie 管理器
+ logger.info("\n--- 3. Cookie 管理器使用 ---")
+ manager = BilibiliCookieManager("data/bilibili_cookies.json")
+
+ # 检查是否有现有 Cookie
+ if await manager.load():
+ logger.info("已加载现有 Cookie")
+
+ # 验证 Cookie
+ if await manager.verify():
+ user_info = manager.user_info
+ if user_info:
+ logger.info(f"用户名: {user_info.get('uname')}")
+ logger.info(f"等级: {user_info.get('level_info', {}).get('current_level')}")
+ logger.info(f"硬币: {user_info.get('money')}")
+ else:
+ logger.warning("Cookie 已失效,需要重新登录")
+ else:
+ logger.info("没有找到 Cookie 文件")
+
+ # 4. 展示使用方法
+ logger.info("\n--- 4. 使用 Cookie 请求 API ---")
+ logger.info("""
+使用示例代码:
+
+ # 方式1: 直接使用 httpx
+ async with httpx.AsyncClient(
+ cookies=cookies.to_httpx_cookies(),
+ headers={"Referer": "https://www.bilibili.com/"}
+ ) as client:
+ resp = await client.get("https://api.bilibili.com/x/web-interface/nav")
+ data = resp.json()
+
+ # 方式2: 使用 Playwright
+ context = await browser.new_context()
+ await context.add_cookies(cookies.to_playwright_cookies())
+ page = await context.new_page()
+ """)
+
+ # 5. 格式转换演示
+ logger.info("\n--- 5. Cookie 格式转换 ---")
+ playwright_format = cookies.to_playwright_cookies()
+ httpx_format = cookies.to_httpx_cookies()
+ header_format = cookies.to_header_string()
+
+ logger.info(f"Playwright 格式: {playwright_format[:1]}...")
+ logger.info(f"httpx 格式: {list(httpx_format.keys())}")
+ logger.info(f"Header 格式: {header_format[:50]}...")
+
+
+async def demo_with_real_api():
+ """使用真实 API 的演示(需要有效 Cookie)"""
+ manager = BilibiliCookieManager("data/bilibili_cookies.json")
+
+ if not await manager.load():
+ logger.info("请先准备 Cookie 文件")
+ return
+
+ cookies = await manager.get_valid_cookies()
+ if not cookies:
+ logger.error("Cookie 无效")
+ return
+
+ async with httpx.AsyncClient(
+ cookies=cookies.to_httpx_cookies(),
+ headers={
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/131.0.0.0 Safari/537.36",
+ "Referer": "https://www.bilibili.com/"
+ }
+ ) as client:
+ # 获取用户信息
+ resp = await client.get("https://api.bilibili.com/x/web-interface/nav")
+ data = resp.json()
+
+ if data.get("code") == 0:
+ user = data.get("data", {})
+ print(f"\n用户名: {user.get('uname')}")
+ print(f"等级: {user.get('level_info', {}).get('current_level')}")
+ print(f"硬币: {user.get('money')}")
+
+ # 获取收藏夹
+ resp = await client.get(
+ "https://api.bilibili.com/x/v3/fav/folder/created/list-all",
+ params={"up_mid": cookies.dede_user_id}
+ )
+ fav_data = resp.json()
+
+ if fav_data.get("code") == 0:
+ folders = fav_data.get("data", {}).get("list", [])
+ print(f"\n收藏夹列表 ({len(folders)}个):")
+ for folder in folders[:5]:
+ print(f" - {folder.get('title')} ({folder.get('media_count')}个)")
+
+
+if __name__ == "__main__":
+ asyncio.run(demo_bilibili_cookie())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/cookie_manager.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/cookie_manager.py"
new file mode 100644
index 0000000..a42e1fe
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/cookie_manager.py"
@@ -0,0 +1,433 @@
+# -*- coding: utf-8 -*-
+# @Desc: Cookie 管理器 - 完整的 Cookie 存储、加载、验证和刷新功能
+
+import json
+import asyncio
+from pathlib import Path
+from typing import Optional, Callable, Awaitable, List, Dict
+from datetime import datetime
+from dataclasses import dataclass, field
+from loguru import logger
+
+# 可选的加密支持
+try:
+ from cryptography.fernet import Fernet
+ HAS_CRYPTO = True
+except ImportError:
+ HAS_CRYPTO = False
+
+
+class CookieSerializer:
+ """Cookie 序列化工具"""
+
+ @staticmethod
+ def to_json(cookies: list, filepath: str):
+ """保存为 JSON 格式"""
+ with open(filepath, "w", encoding="utf-8") as f:
+ json.dump(cookies, f, indent=2, ensure_ascii=False)
+
+ @staticmethod
+ def from_json(filepath: str) -> list:
+ """从 JSON 加载"""
+ with open(filepath, "r", encoding="utf-8") as f:
+ return json.load(f)
+
+ @staticmethod
+ def to_netscape(cookies: list, filepath: str):
+ """
+ 保存为 Netscape 格式(兼容 curl/wget)
+ 格式: domain, flag, path, secure, expiry, name, value
+ """
+ lines = ["# Netscape HTTP Cookie File", "# https://curl.se/docs/http-cookies.html"]
+
+ for c in cookies:
+ domain = c.get("domain", "")
+ flag = "TRUE" if domain.startswith(".") else "FALSE"
+ path = c.get("path", "/")
+ secure = "TRUE" if c.get("secure", False) else "FALSE"
+ expiry = str(int(c.get("expires", 0)))
+ name = c.get("name", "")
+ value = c.get("value", "")
+
+ lines.append(f"{domain}\t{flag}\t{path}\t{secure}\t{expiry}\t{name}\t{value}")
+
+ with open(filepath, "w", encoding="utf-8") as f:
+ f.write("\n".join(lines))
+
+ @staticmethod
+ def to_dict(cookies: list) -> dict:
+ """转换为简单字典格式(name: value)"""
+ return {c["name"]: c["value"] for c in cookies}
+
+ @staticmethod
+ def playwright_to_httpx(playwright_cookies: list) -> dict:
+ """将 Playwright 格式转换为 httpx 格式"""
+ return {c["name"]: c["value"] for c in playwright_cookies}
+
+ @staticmethod
+ def dict_to_playwright(cookies_dict: dict, domain: str) -> list:
+ """将简单字典转换为 Playwright 格式"""
+ return [
+ {
+ "name": name,
+ "value": value,
+ "domain": domain,
+ "path": "/"
+ }
+ for name, value in cookies_dict.items()
+ ]
+
+
+class SecureCookieStorage:
+ """加密 Cookie 存储"""
+
+ def __init__(self, key: bytes = None):
+ if not HAS_CRYPTO:
+ raise ImportError("需要安装 cryptography 库: pip install cryptography")
+
+ # 如果没有提供密钥,生成新密钥
+ self.key = key or Fernet.generate_key()
+ self.cipher = Fernet(self.key)
+
+ def save_key(self, filepath: str):
+ """保存密钥(请妥善保管)"""
+ with open(filepath, "wb") as f:
+ f.write(self.key)
+ logger.info(f"密钥已保存到: {filepath}")
+
+ @classmethod
+ def load_key(cls, filepath: str) -> "SecureCookieStorage":
+ """从文件加载密钥"""
+ with open(filepath, "rb") as f:
+ return cls(f.read())
+
+ def encrypt_cookies(self, cookies: list, filepath: str):
+ """加密并保存 Cookie"""
+ data = json.dumps(cookies).encode("utf-8")
+ encrypted = self.cipher.encrypt(data)
+ with open(filepath, "wb") as f:
+ f.write(encrypted)
+ logger.info(f"Cookie 已加密保存到: {filepath}")
+
+ def decrypt_cookies(self, filepath: str) -> list:
+ """解密并加载 Cookie"""
+ with open(filepath, "rb") as f:
+ encrypted = f.read()
+ decrypted = self.cipher.decrypt(encrypted)
+ cookies = json.loads(decrypted.decode("utf-8"))
+ logger.info(f"已解密加载 {len(cookies)} 个 Cookie")
+ return cookies
+
+
+@dataclass
+class AccountCookie:
+ """账号 Cookie 信息"""
+ account_id: str
+ cookies: dict
+ last_used: Optional[datetime] = None
+ use_count: int = 0
+ is_valid: bool = True
+ created_at: datetime = field(default_factory=datetime.now)
+
+
+class CookieRotator:
+ """Cookie 轮换器 - 支持多账号 Cookie 管理"""
+
+ def __init__(self, min_interval: float = 5.0):
+ """
+ Args:
+ min_interval: 同一账号最小使用间隔(秒)
+ """
+ self._accounts: Dict[str, AccountCookie] = {}
+ self._min_interval = min_interval
+ self._lock = asyncio.Lock()
+
+ def add_account(self, account_id: str, cookies: dict):
+ """添加账号"""
+ self._accounts[account_id] = AccountCookie(
+ account_id=account_id,
+ cookies=cookies
+ )
+ logger.info(f"添加账号: {account_id}")
+
+ def remove_account(self, account_id: str):
+ """移除账号"""
+ if account_id in self._accounts:
+ del self._accounts[account_id]
+ logger.info(f"移除账号: {account_id}")
+
+ async def get_cookies(self) -> Optional[dict]:
+ """获取一个可用的 Cookie(负载均衡)"""
+ async with self._lock:
+ now = datetime.now()
+ available = []
+
+ for acc in self._accounts.values():
+ if not acc.is_valid:
+ continue
+
+ # 检查使用间隔
+ if acc.last_used:
+ elapsed = (now - acc.last_used).total_seconds()
+ if elapsed < self._min_interval:
+ continue
+
+ available.append(acc)
+
+ if not available:
+ logger.warning("没有可用的账号")
+ return None
+
+ # 选择使用次数最少的账号(负载均衡)
+ selected = min(available, key=lambda x: x.use_count)
+ selected.last_used = now
+ selected.use_count += 1
+
+ logger.debug(f"使用账号: {selected.account_id} (使用次数: {selected.use_count})")
+ return selected.cookies
+
+ def mark_invalid(self, account_id: str):
+ """标记账号失效"""
+ if account_id in self._accounts:
+ self._accounts[account_id].is_valid = False
+ logger.warning(f"账号已标记失效: {account_id}")
+
+ def mark_valid(self, account_id: str):
+ """标记账号有效"""
+ if account_id in self._accounts:
+ self._accounts[account_id].is_valid = True
+ logger.info(f"账号已标记有效: {account_id}")
+
+ @property
+ def valid_count(self) -> int:
+ """有效账号数量"""
+ return sum(1 for acc in self._accounts.values() if acc.is_valid)
+
+ @property
+ def total_count(self) -> int:
+ """总账号数量"""
+ return len(self._accounts)
+
+ def get_stats(self) -> dict:
+ """获取统计信息"""
+ return {
+ "total": self.total_count,
+ "valid": self.valid_count,
+ "invalid": self.total_count - self.valid_count,
+ "accounts": [
+ {
+ "id": acc.account_id,
+ "valid": acc.is_valid,
+ "use_count": acc.use_count,
+ "last_used": acc.last_used.isoformat() if acc.last_used else None
+ }
+ for acc in self._accounts.values()
+ ]
+ }
+
+
+class CookieManager:
+ """完整的 Cookie 管理器"""
+
+ def __init__(
+ self,
+ storage_path: str,
+ login_checker: Callable[[dict], Awaitable[bool]],
+ auto_refresh_callback: Optional[Callable[[], Awaitable[list]]] = None,
+ check_interval: int = 300
+ ):
+ """
+ Args:
+ storage_path: Cookie 存储路径
+ login_checker: 登录状态检测函数,接收 cookies dict,返回是否有效
+ auto_refresh_callback: 自动刷新回调(如重新登录),返回新的 cookies list
+ check_interval: 检测间隔(秒),默认 5 分钟
+ """
+ self.storage_path = Path(storage_path)
+ self.login_checker = login_checker
+ self.auto_refresh_callback = auto_refresh_callback
+ self._check_interval = check_interval
+
+ self._cookies: Optional[list] = None
+ self._last_check: Optional[datetime] = None
+
+ async def load(self) -> bool:
+ """加载 Cookie"""
+ if not self.storage_path.exists():
+ logger.warning(f"Cookie 文件不存在: {self.storage_path}")
+ return False
+
+ try:
+ with open(self.storage_path, "r", encoding="utf-8") as f:
+ self._cookies = json.load(f)
+ logger.info(f"加载了 {len(self._cookies)} 个 Cookie")
+ return True
+ except Exception as e:
+ logger.error(f"加载 Cookie 失败: {e}")
+ return False
+
+ async def save(self):
+ """保存 Cookie"""
+ if self._cookies:
+ self.storage_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(self.storage_path, "w", encoding="utf-8") as f:
+ json.dump(self._cookies, f, indent=2, ensure_ascii=False)
+ logger.info(f"保存了 {len(self._cookies)} 个 Cookie")
+
+ def update(self, cookies: list):
+ """更新 Cookie"""
+ self._cookies = cookies
+ self._last_check = datetime.now()
+ logger.info(f"更新了 {len(cookies)} 个 Cookie")
+
+ async def get_valid_cookies(self) -> Optional[dict]:
+ """
+ 获取有效的 Cookie(自动检测和刷新)
+
+ Returns:
+ 简单字典格式的 Cookie,如 {"name": "value", ...}
+ """
+ # 首次加载
+ if self._cookies is None:
+ await self.load()
+
+ if not self._cookies:
+ if self.auto_refresh_callback:
+ await self._refresh_cookies()
+ if not self._cookies:
+ return None
+
+ # 检查是否需要验证
+ if self._need_check():
+ is_valid = await self._validate()
+ if not is_valid:
+ if self.auto_refresh_callback:
+ await self._refresh_cookies()
+ else:
+ return None
+
+ # 返回简单字典格式
+ return CookieSerializer.to_dict(self._cookies)
+
+ async def get_playwright_cookies(self) -> Optional[list]:
+ """
+ 获取 Playwright 格式的 Cookie
+
+ Returns:
+ Playwright 格式的 Cookie 列表
+ """
+ if self._cookies is None:
+ await self.load()
+ return self._cookies
+
+ def _need_check(self) -> bool:
+ """是否需要检测"""
+ if self._last_check is None:
+ return True
+ elapsed = (datetime.now() - self._last_check).total_seconds()
+ return elapsed > self._check_interval
+
+ async def _validate(self) -> bool:
+ """验证 Cookie 是否有效"""
+ logger.debug("验证 Cookie 有效性...")
+ cookies_dict = CookieSerializer.to_dict(self._cookies)
+ is_valid = await self.login_checker(cookies_dict)
+ self._last_check = datetime.now()
+
+ if is_valid:
+ logger.info("Cookie 验证通过")
+ else:
+ logger.warning("Cookie 已失效")
+
+ return is_valid
+
+ async def _refresh_cookies(self):
+ """刷新 Cookie"""
+ if not self.auto_refresh_callback:
+ return
+
+ logger.info("开始刷新 Cookie...")
+ try:
+ new_cookies = await self.auto_refresh_callback()
+ if new_cookies:
+ self._cookies = new_cookies
+ self._last_check = datetime.now()
+ await self.save()
+ logger.info("Cookie 刷新成功")
+ except Exception as e:
+ logger.error(f"Cookie 刷新失败: {e}")
+
+ async def force_refresh(self) -> bool:
+ """强制刷新 Cookie"""
+ if not self.auto_refresh_callback:
+ logger.error("未设置刷新回调")
+ return False
+
+ await self._refresh_cookies()
+ return self._cookies is not None
+
+
+async def demo():
+ """演示 Cookie 管理器的使用"""
+ import httpx
+
+ # 模拟登录检测函数
+ async def check_login(cookies: dict) -> bool:
+ """检测登录状态"""
+ try:
+ async with httpx.AsyncClient(cookies=cookies) as client:
+ resp = await client.get("https://httpbin.org/cookies", timeout=10)
+ data = resp.json()
+ # 检查是否有我们设置的 cookie
+ return "session" in data.get("cookies", {})
+ except Exception as e:
+ logger.error(f"检测失败: {e}")
+ return False
+
+ # 创建管理器
+ manager = CookieManager(
+ storage_path="data/demo_cookies.json",
+ login_checker=check_login
+ )
+
+ # 手动设置一些测试 Cookie
+ test_cookies = [
+ {"name": "session", "value": "test123", "domain": "httpbin.org", "path": "/"},
+ {"name": "user", "value": "demo", "domain": "httpbin.org", "path": "/"}
+ ]
+ manager.update(test_cookies)
+ await manager.save()
+
+ # 获取有效的 Cookie
+ cookies = await manager.get_valid_cookies()
+ if cookies:
+ print(f"获取到 Cookie: {cookies}")
+
+ # 演示 Cookie 轮换器
+ print("\n--- Cookie 轮换器演示 ---")
+ rotator = CookieRotator(min_interval=2.0)
+
+ # 添加多个账号
+ rotator.add_account("account_1", {"session": "sess_1", "token": "tok_1"})
+ rotator.add_account("account_2", {"session": "sess_2", "token": "tok_2"})
+ rotator.add_account("account_3", {"session": "sess_3", "token": "tok_3"})
+
+ # 模拟获取 Cookie(负载均衡)
+ for i in range(5):
+ cookies = await rotator.get_cookies()
+ print(f"请求 {i+1}: 使用 Cookie = {cookies}")
+ await asyncio.sleep(0.5)
+
+ # 查看统计
+ print(f"\n统计信息: {rotator.get_stats()}")
+
+
+if __name__ == "__main__":
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="DEBUG"
+ )
+
+ asyncio.run(demo())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/login_state_checker.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/login_state_checker.py"
new file mode 100644
index 0000000..5198ae1
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/login_state_checker.py"
@@ -0,0 +1,396 @@
+# -*- coding: utf-8 -*-
+# @Desc: 登录状态检测器 - 支持多种检测方式
+
+import asyncio
+import time
+from typing import Callable, Optional
+from datetime import datetime, timedelta
+import httpx
+from loguru import logger
+
+
+class LoginStateChecker:
+ """登录状态检测器"""
+
+ def __init__(
+ self,
+ check_url: str,
+ success_indicator: Callable[[httpx.Response], bool],
+ timeout: float = 10.0
+ ):
+ """
+ Args:
+ check_url: 用于检测登录状态的 URL
+ success_indicator: 判断是否登录成功的函数,接收响应对象
+ timeout: 请求超时时间
+ """
+ self.check_url = check_url
+ self.success_indicator = success_indicator
+ self.timeout = timeout
+
+ async def is_logged_in(self, cookies: dict) -> bool:
+ """
+ 检查是否已登录
+
+ Args:
+ cookies: Cookie 字典
+
+ Returns:
+ 是否已登录
+ """
+ try:
+ async with httpx.AsyncClient(cookies=cookies, follow_redirects=True) as client:
+ resp = await client.get(self.check_url, timeout=self.timeout)
+ return self.success_indicator(resp)
+ except httpx.TimeoutException:
+ logger.warning(f"登录状态检测超时: {self.check_url}")
+ return False
+ except Exception as e:
+ logger.warning(f"登录状态检测失败: {e}")
+ return False
+
+ @classmethod
+ def create_json_checker(
+ cls,
+ check_url: str,
+ success_field: str,
+ timeout: float = 10.0
+ ) -> "LoginStateChecker":
+ """
+ 创建 JSON 响应检测器
+
+ 检测响应 JSON 中是否包含指定字段
+
+ Args:
+ check_url: API 地址
+ success_field: 成功时 JSON 中存在的字段
+ timeout: 超时时间
+
+ Example:
+ # 检测 /api/user/info 返回的 JSON 是否包含 user_id 字段
+ checker = LoginStateChecker.create_json_checker(
+ "https://example.com/api/user/info",
+ "user_id"
+ )
+ """
+ def indicator(resp: httpx.Response) -> bool:
+ try:
+ if resp.status_code != 200:
+ return False
+ data = resp.json()
+ return success_field in data
+ except Exception:
+ return False
+
+ return cls(check_url, indicator, timeout)
+
+ @classmethod
+ def create_json_value_checker(
+ cls,
+ check_url: str,
+ field: str,
+ expected_value,
+ timeout: float = 10.0
+ ) -> "LoginStateChecker":
+ """
+ 创建 JSON 值检测器
+
+ 检测响应 JSON 中指定字段是否等于预期值
+
+ Args:
+ check_url: API 地址
+ field: 要检测的字段
+ expected_value: 预期值
+ timeout: 超时时间
+
+ Example:
+ # 检测 /api/status 返回的 logged_in 字段是否为 True
+ checker = LoginStateChecker.create_json_value_checker(
+ "https://example.com/api/status",
+ "logged_in",
+ True
+ )
+ """
+ def indicator(resp: httpx.Response) -> bool:
+ try:
+ if resp.status_code != 200:
+ return False
+ data = resp.json()
+ return data.get(field) == expected_value
+ except Exception:
+ return False
+
+ return cls(check_url, indicator, timeout)
+
+ @classmethod
+ def create_redirect_checker(
+ cls,
+ check_url: str,
+ login_url_pattern: str,
+ timeout: float = 10.0
+ ) -> "LoginStateChecker":
+ """
+ 创建重定向检测器
+
+ 如果被重定向到登录页,说明未登录
+
+ Args:
+ check_url: 需要登录的页面
+ login_url_pattern: 登录页 URL 特征(如 "/login")
+ timeout: 超时时间
+
+ Example:
+ # 访问 /dashboard,如果被重定向到 /login 说明未登录
+ checker = LoginStateChecker.create_redirect_checker(
+ "https://example.com/dashboard",
+ "/login"
+ )
+ """
+ def indicator(resp: httpx.Response) -> bool:
+ # 如果没有被重定向到登录页,说明已登录
+ return login_url_pattern not in str(resp.url)
+
+ return cls(check_url, indicator, timeout)
+
+ @classmethod
+ def create_status_code_checker(
+ cls,
+ check_url: str,
+ success_codes: list = None,
+ timeout: float = 10.0
+ ) -> "LoginStateChecker":
+ """
+ 创建状态码检测器
+
+ Args:
+ check_url: 检测 URL
+ success_codes: 成功的状态码列表,默认 [200]
+ timeout: 超时时间
+
+ Example:
+ # 检测 API 是否返回 200
+ checker = LoginStateChecker.create_status_code_checker(
+ "https://example.com/api/user",
+ [200]
+ )
+ """
+ success_codes = success_codes or [200]
+
+ def indicator(resp: httpx.Response) -> bool:
+ return resp.status_code in success_codes
+
+ return cls(check_url, indicator, timeout)
+
+ @classmethod
+ def create_content_checker(
+ cls,
+ check_url: str,
+ success_text: str,
+ timeout: float = 10.0
+ ) -> "LoginStateChecker":
+ """
+ 创建内容检测器
+
+ 检测响应内容是否包含指定文本
+
+ Args:
+ check_url: 检测 URL
+ success_text: 登录成功时页面包含的文本
+ timeout: 超时时间
+
+ Example:
+ # 检测页面是否包含"欢迎回来"
+ checker = LoginStateChecker.create_content_checker(
+ "https://example.com/dashboard",
+ "欢迎回来"
+ )
+ """
+ def indicator(resp: httpx.Response) -> bool:
+ return success_text in resp.text
+
+ return cls(check_url, indicator, timeout)
+
+
+class CookieExpiryMonitor:
+ """Cookie 过期监控器"""
+
+ @staticmethod
+ def check_expiry(cookies: list) -> tuple:
+ """
+ 检查 Cookie 是否过期
+
+ Args:
+ cookies: Playwright 格式的 Cookie 列表
+
+ Returns:
+ (是否全部有效, 已过期的 Cookie 列表)
+ """
+ now = time.time()
+ expired = []
+
+ for cookie in cookies:
+ expires = cookie.get("expires", 0)
+ # expires > 0 表示设置了过期时间
+ # expires == -1 或 0 通常表示会话 Cookie
+ if expires > 0 and expires < now:
+ expired.append(cookie)
+
+ return len(expired) == 0, expired
+
+ @staticmethod
+ def get_earliest_expiry(cookies: list) -> Optional[datetime]:
+ """
+ 获取最早过期的时间
+
+ Args:
+ cookies: Cookie 列表
+
+ Returns:
+ 最早过期的 datetime,如果没有过期时间则返回 None
+ """
+ expiry_times = []
+ for c in cookies:
+ expires = c.get("expires", 0)
+ if expires > 0:
+ expiry_times.append(expires)
+
+ if not expiry_times:
+ return None
+
+ return datetime.fromtimestamp(min(expiry_times))
+
+ @staticmethod
+ def will_expire_soon(cookies: list, threshold_hours: int = 1) -> bool:
+ """
+ 检查 Cookie 是否即将过期
+
+ Args:
+ cookies: Cookie 列表
+ threshold_hours: 阈值小时数
+
+ Returns:
+ 是否在阈值时间内过期
+ """
+ earliest = CookieExpiryMonitor.get_earliest_expiry(cookies)
+ if earliest is None:
+ return False # 没有过期时间的认为不会过期
+ return earliest < datetime.now() + timedelta(hours=threshold_hours)
+
+ @staticmethod
+ def get_expiry_summary(cookies: list) -> dict:
+ """
+ 获取 Cookie 过期时间摘要
+
+ Args:
+ cookies: Cookie 列表
+
+ Returns:
+ 摘要信息字典
+ """
+ now = time.time()
+ summary = {
+ "total": len(cookies),
+ "session_cookies": 0, # 会话 Cookie(无过期时间)
+ "valid": 0,
+ "expired": 0,
+ "expiring_soon": 0, # 1小时内过期
+ "earliest_expiry": None,
+ "latest_expiry": None
+ }
+
+ expiry_times = []
+
+ for cookie in cookies:
+ expires = cookie.get("expires", 0)
+ if expires <= 0:
+ summary["session_cookies"] += 1
+ elif expires < now:
+ summary["expired"] += 1
+ elif expires < now + 3600: # 1小时内
+ summary["expiring_soon"] += 1
+ summary["valid"] += 1
+ expiry_times.append(expires)
+ else:
+ summary["valid"] += 1
+ expiry_times.append(expires)
+
+ if expiry_times:
+ summary["earliest_expiry"] = datetime.fromtimestamp(min(expiry_times)).isoformat()
+ summary["latest_expiry"] = datetime.fromtimestamp(max(expiry_times)).isoformat()
+
+ return summary
+
+
+async def demo():
+ """演示登录状态检测"""
+
+ print("=" * 50)
+ print("登录状态检测器演示")
+ print("=" * 50)
+
+ # 1. JSON 检测器
+ print("\n1. JSON 字段检测器")
+ checker1 = LoginStateChecker.create_json_checker(
+ "https://httpbin.org/json",
+ "slideshow"
+ )
+ # httpbin.org/json 总是返回包含 slideshow 的 JSON
+ result = await checker1.is_logged_in({})
+ print(f" 检测结果: {'已登录' if result else '未登录'}")
+
+ # 2. 状态码检测器
+ print("\n2. 状态码检测器")
+ checker2 = LoginStateChecker.create_status_code_checker(
+ "https://httpbin.org/status/200",
+ [200]
+ )
+ result = await checker2.is_logged_in({})
+ print(f" 检测结果 (200): {'已登录' if result else '未登录'}")
+
+ checker2_fail = LoginStateChecker.create_status_code_checker(
+ "https://httpbin.org/status/401",
+ [200]
+ )
+ result = await checker2_fail.is_logged_in({})
+ print(f" 检测结果 (401): {'已登录' if result else '未登录'}")
+
+ # 3. 内容检测器
+ print("\n3. 内容检测器")
+ checker3 = LoginStateChecker.create_content_checker(
+ "https://httpbin.org/html",
+ "Herman Melville" # httpbin.org/html 页面包含这个名字
+ )
+ result = await checker3.is_logged_in({})
+ print(f" 检测结果: {'已登录' if result else '未登录'}")
+
+ # 4. Cookie 过期监控
+ print("\n4. Cookie 过期监控")
+ test_cookies = [
+ {"name": "session", "value": "abc", "expires": time.time() + 3600}, # 1小时后过期
+ {"name": "token", "value": "xyz", "expires": time.time() + 86400}, # 1天后过期
+ {"name": "temp", "value": "123", "expires": 0}, # 会话 Cookie
+ ]
+
+ is_valid, expired = CookieExpiryMonitor.check_expiry(test_cookies)
+ print(f" 全部有效: {is_valid}, 过期数量: {len(expired)}")
+
+ will_expire = CookieExpiryMonitor.will_expire_soon(test_cookies, threshold_hours=2)
+ print(f" 2小时内过期: {will_expire}")
+
+ summary = CookieExpiryMonitor.get_expiry_summary(test_cookies)
+ print(f" 摘要: {summary}")
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="DEBUG"
+ )
+
+ asyncio.run(demo())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/pyproject.toml" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/pyproject.toml"
new file mode 100644
index 0000000..7f39f75
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/pyproject.toml"
@@ -0,0 +1,22 @@
+[project]
+name = "chapter06-cookie-session"
+version = "0.1.0"
+description = "第06章:登录认证 - Cookie与Session管理、登录状态检测、真实登录演示"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ "httpx>=0.27.0",
+ "loguru>=0.7.0",
+]
+
+[project.optional-dependencies]
+encryption = [
+ "cryptography>=42.0.0", # Cookie加密存储(可选)
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.uv]
+dev-dependencies = []
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/session_demo.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/session_demo.py"
new file mode 100644
index 0000000..86cd8b9
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/06_\347\231\273\345\275\225\350\256\244\350\257\201_Cookie\344\270\216Session\347\256\241\347\220\206/session_demo.py"
@@ -0,0 +1,468 @@
+# -*- coding: utf-8 -*-
+# @Desc: 完整的会话管理演示 - 展示如何在爬虫中管理登录状态
+
+import asyncio
+import json
+from typing import Optional
+from pathlib import Path
+import httpx
+from loguru import logger
+
+from cookie_manager import CookieManager, CookieSerializer, CookieRotator
+from login_state_checker import LoginStateChecker, CookieExpiryMonitor
+
+
+class SessionCrawler:
+ """带登录状态管理的爬虫"""
+
+ def __init__(
+ self,
+ base_url: str,
+ cookie_path: str,
+ check_endpoint: str = "/api/user/info"
+ ):
+ """
+ Args:
+ base_url: 基础 URL
+ cookie_path: Cookie 存储路径
+ check_endpoint: 登录状态检测端点
+ """
+ self.base_url = base_url.rstrip("/")
+ self.cookie_path = cookie_path
+ self.check_endpoint = check_endpoint
+
+ # 初始化登录检测器
+ self.checker = LoginStateChecker.create_json_checker(
+ check_url=f"{self.base_url}{check_endpoint}",
+ success_field="user"
+ )
+
+ # 初始化 Cookie 管理器
+ self.cookie_manager = CookieManager(
+ storage_path=cookie_path,
+ login_checker=self.checker.is_logged_in
+ )
+
+ self._client: Optional[httpx.AsyncClient] = None
+
+ async def __aenter__(self):
+ await self.start()
+ return self
+
+ async def __aexit__(self, *args):
+ await self.close()
+
+ async def start(self):
+ """启动爬虫"""
+ cookies = await self.cookie_manager.get_valid_cookies()
+ if not cookies:
+ logger.warning("无法获取有效的 Cookie,将以未登录状态运行")
+ cookies = {}
+
+ self._client = httpx.AsyncClient(
+ base_url=self.base_url,
+ cookies=cookies,
+ timeout=30.0,
+ follow_redirects=True
+ )
+ logger.info(f"爬虫启动成功,基础URL: {self.base_url}")
+
+ async def close(self):
+ """关闭爬虫"""
+ if self._client:
+ await self._client.aclose()
+ await self.cookie_manager.save()
+ logger.info("爬虫已关闭")
+
+ async def get(self, endpoint: str, **kwargs) -> httpx.Response:
+ """GET 请求"""
+ return await self._client.get(endpoint, **kwargs)
+
+ async def post(self, endpoint: str, **kwargs) -> httpx.Response:
+ """POST 请求"""
+ return await self._client.post(endpoint, **kwargs)
+
+ async def fetch_json(self, endpoint: str) -> dict:
+ """获取 JSON 数据"""
+ try:
+ resp = await self._client.get(endpoint)
+ resp.raise_for_status()
+ return resp.json()
+ except httpx.HTTPStatusError as e:
+ if e.response.status_code == 401:
+ logger.warning("登录状态失效,需要重新登录")
+ raise
+
+
+async def demo_basic_session():
+ """演示基本的会话管理"""
+ print("\n" + "=" * 50)
+ print("1. 基本会话管理演示")
+ print("=" * 50)
+
+ # 使用 httpbin.org 演示
+ async with httpx.AsyncClient() as client:
+ # 设置 Cookie
+ resp = await client.get(
+ "https://httpbin.org/cookies/set",
+ params={"session": "demo123", "user": "test"}
+ )
+ print(f"设置 Cookie 后的响应: {resp.status_code}")
+
+ # 查看当前 Cookie
+ resp = await client.get("https://httpbin.org/cookies")
+ cookies = resp.json().get("cookies", {})
+ print(f"当前 Cookie: {cookies}")
+
+
+async def demo_cookie_persistence():
+ """演示 Cookie 持久化"""
+ print("\n" + "=" * 50)
+ print("2. Cookie 持久化演示")
+ print("=" * 50)
+
+ # 创建测试 Cookie
+ test_cookies = [
+ {
+ "name": "session_id",
+ "value": "abc123xyz",
+ "domain": "example.com",
+ "path": "/",
+ "secure": True,
+ "httpOnly": True
+ },
+ {
+ "name": "user_token",
+ "value": "token_value_here",
+ "domain": "example.com",
+ "path": "/"
+ }
+ ]
+
+ # 保存为 JSON
+ json_path = "data/demo_cookies.json"
+ Path("data").mkdir(exist_ok=True)
+ CookieSerializer.to_json(test_cookies, json_path)
+ print(f"已保存为 JSON: {json_path}")
+
+ # 保存为 Netscape 格式
+ netscape_path = "data/demo_cookies.txt"
+ CookieSerializer.to_netscape(test_cookies, netscape_path)
+ print(f"已保存为 Netscape: {netscape_path}")
+
+ # 从 JSON 加载
+ loaded = CookieSerializer.from_json(json_path)
+ print(f"从 JSON 加载了 {len(loaded)} 个 Cookie")
+
+ # 转换为 httpx 格式
+ httpx_cookies = CookieSerializer.to_dict(loaded)
+ print(f"httpx 格式: {httpx_cookies}")
+
+
+async def demo_cookie_rotation():
+ """演示多账号 Cookie 轮换"""
+ print("\n" + "=" * 50)
+ print("3. 多账号 Cookie 轮换演示")
+ print("=" * 50)
+
+ rotator = CookieRotator(min_interval=1.0)
+
+ # 添加多个账号
+ rotator.add_account("user_001", {"session": "sess_001", "token": "tok_001"})
+ rotator.add_account("user_002", {"session": "sess_002", "token": "tok_002"})
+ rotator.add_account("user_003", {"session": "sess_003", "token": "tok_003"})
+
+ print(f"添加了 {rotator.total_count} 个账号")
+
+ # 模拟多次请求
+ print("\n模拟请求(负载均衡):")
+ for i in range(6):
+ cookies = await rotator.get_cookies()
+ if cookies:
+ print(f" 请求 {i+1}: session={cookies.get('session')}")
+ await asyncio.sleep(0.3)
+
+ # 标记一个账号失效
+ rotator.mark_invalid("user_002")
+ print(f"\n标记 user_002 失效后,有效账号: {rotator.valid_count}")
+
+ # 继续请求
+ print("\n继续请求(排除失效账号):")
+ for i in range(3):
+ cookies = await rotator.get_cookies()
+ if cookies:
+ print(f" 请求 {i+1}: session={cookies.get('session')}")
+ await asyncio.sleep(0.3)
+
+ # 获取统计
+ stats = rotator.get_stats()
+ print(f"\n统计信息:")
+ for acc in stats["accounts"]:
+ print(f" {acc['id']}: 有效={acc['valid']}, 使用次数={acc['use_count']}")
+
+
+async def demo_login_detection():
+ """演示登录状态检测"""
+ print("\n" + "=" * 50)
+ print("4. 登录状态检测演示")
+ print("=" * 50)
+
+ # 使用不同的检测方式
+
+ # 1. JSON 字段检测
+ print("\n1) JSON 字段检测:")
+ checker1 = LoginStateChecker.create_json_checker(
+ "https://httpbin.org/json",
+ "slideshow"
+ )
+ result = await checker1.is_logged_in({})
+ print(f" 结果: {'通过' if result else '失败'}")
+
+ # 2. 状态码检测
+ print("\n2) 状态码检测:")
+ checker2 = LoginStateChecker.create_status_code_checker(
+ "https://httpbin.org/status/200"
+ )
+ result = await checker2.is_logged_in({})
+ print(f" 200 状态: {'通过' if result else '失败'}")
+
+ checker2_fail = LoginStateChecker.create_status_code_checker(
+ "https://httpbin.org/status/401"
+ )
+ result = await checker2_fail.is_logged_in({})
+ print(f" 401 状态: {'通过' if result else '失败'}")
+
+ # 3. 内容检测
+ print("\n3) 内容检测:")
+ checker3 = LoginStateChecker.create_content_checker(
+ "https://httpbin.org/html",
+ "Herman Melville"
+ )
+ result = await checker3.is_logged_in({})
+ print(f" 包含指定文本: {'通过' if result else '失败'}")
+
+
+async def demo_expiry_monitoring():
+ """演示 Cookie 过期监控"""
+ print("\n" + "=" * 50)
+ print("5. Cookie 过期监控演示")
+ print("=" * 50)
+
+ import time
+
+ # 创建不同过期时间的 Cookie
+ test_cookies = [
+ {"name": "expired", "value": "1", "expires": time.time() - 3600}, # 已过期
+ {"name": "expiring", "value": "2", "expires": time.time() + 1800}, # 30分钟后过期
+ {"name": "valid", "value": "3", "expires": time.time() + 86400}, # 1天后过期
+ {"name": "session", "value": "4", "expires": 0}, # 会话 Cookie
+ ]
+
+ # 检查过期
+ is_valid, expired = CookieExpiryMonitor.check_expiry(test_cookies)
+ print(f"全部有效: {is_valid}")
+ print(f"已过期 Cookie: {[c['name'] for c in expired]}")
+
+ # 是否即将过期
+ will_expire = CookieExpiryMonitor.will_expire_soon(test_cookies, threshold_hours=1)
+ print(f"1小时内过期: {will_expire}")
+
+ # 获取摘要
+ summary = CookieExpiryMonitor.get_expiry_summary(test_cookies)
+ print(f"\nCookie 摘要:")
+ print(f" 总数: {summary['total']}")
+ print(f" 会话 Cookie: {summary['session_cookies']}")
+ print(f" 有效: {summary['valid']}")
+ print(f" 已过期: {summary['expired']}")
+ print(f" 即将过期: {summary['expiring_soon']}")
+
+
+async def demo_real_login():
+ """演示真实网站登录流程 - quotes.toscrape.com"""
+ print("\n" + "=" * 50)
+ print("6. 真实网站登录演示 (quotes.toscrape.com)")
+ print("=" * 50)
+
+ login_url = "https://quotes.toscrape.com/login"
+ home_url = "https://quotes.toscrape.com/"
+
+ async with httpx.AsyncClient(follow_redirects=True) as client:
+ # 1. 访问登录页面
+ print(f"\n1) 访问登录页面: {login_url}")
+ resp = await client.get(login_url)
+ print(f" 状态码: {resp.status_code}")
+
+ # 2. 提交登录表单(quotes.toscrape.com 接受任意用户名密码)
+ print("\n2) 提交登录表单...")
+ login_data = {
+ "username": "admin",
+ "password": "admin"
+ }
+ resp = await client.post(login_url, data=login_data)
+ print(f" 状态码: {resp.status_code}")
+
+ # 3. 检查登录状态
+ print("\n3) 检查登录状态...")
+ if "Logout" in resp.text:
+ print(" ✅ 登录成功!")
+
+ # 4. 获取并显示 Cookie
+ print("\n4) 获取到的 Cookie:")
+ cookies_dict = dict(client.cookies)
+ for name, value in cookies_dict.items():
+ print(f" {name}: {value}")
+
+ # 5. 保存 Cookie 到文件
+ print("\n5) 保存 Cookie 到文件...")
+ Path("data").mkdir(exist_ok=True)
+ cookie_path = "data/quotes_cookies.json"
+
+ # 转换为标准格式
+ cookies_list = [
+ {
+ "name": name,
+ "value": value,
+ "domain": "quotes.toscrape.com",
+ "path": "/"
+ }
+ for name, value in cookies_dict.items()
+ ]
+
+ with open(cookie_path, "w") as f:
+ json.dump(cookies_list, f, indent=2)
+ print(f" Cookie 已保存到: {cookie_path}")
+
+ # 6. 测试 Cookie 复用
+ print("\n6) 测试 Cookie 复用...")
+ async with httpx.AsyncClient() as new_client:
+ # 加载保存的 Cookie
+ with open(cookie_path, "r") as f:
+ loaded_cookies = json.load(f)
+
+ # 转换为 httpx 格式并注入
+ cookies_dict = {c["name"]: c["value"] for c in loaded_cookies}
+ new_client.cookies.update(cookies_dict)
+
+ # 访问主页
+ resp = await new_client.get(home_url)
+ if "Logout" in resp.text:
+ print(" ✅ Cookie 复用成功!保持登录状态")
+ else:
+ print(" ❌ Cookie 复用失败")
+
+ # 7. 使用 CookieManager 管理
+ print("\n7) 使用 CookieManager 管理...")
+
+ async def check_quotes_login(cookies: dict) -> bool:
+ """检查 quotes.toscrape.com 登录状态"""
+ try:
+ async with httpx.AsyncClient(cookies=cookies, timeout=10) as c:
+ resp = await c.get(home_url)
+ return "Logout" in resp.text
+ except Exception:
+ return False
+
+ manager = CookieManager(
+ storage_path="data/quotes_managed_cookies.json",
+ login_checker=check_quotes_login
+ )
+
+ # 保存 Cookie
+ manager.update(cookies_list)
+ await manager.save()
+ print(" Cookie 已通过 CookieManager 保存")
+
+ # 验证并获取有效 Cookie
+ valid_cookies = await manager.get_valid_cookies()
+ if valid_cookies:
+ print(f" ✅ CookieManager 验证通过,获取到 {len(valid_cookies)} 个有效 Cookie")
+ else:
+ print(" ❌ CookieManager 验证失败")
+
+ else:
+ print(" ❌ 登录失败")
+
+ print("\n真实登录演示完成")
+
+
+async def demo_complete_workflow():
+ """演示完整的工作流程"""
+ print("\n" + "=" * 50)
+ print("7. 完整工作流程演示")
+ print("=" * 50)
+
+ # 模拟一个完整的登录和爬取流程
+
+ # 1. 定义检测函数
+ async def check_login(cookies: dict) -> bool:
+ try:
+ async with httpx.AsyncClient(cookies=cookies) as client:
+ resp = await client.get("https://httpbin.org/cookies", timeout=10)
+ data = resp.json()
+ return "session" in data.get("cookies", {})
+ except Exception:
+ return False
+
+ # 2. 创建 Cookie 管理器
+ manager = CookieManager(
+ storage_path="data/workflow_cookies.json",
+ login_checker=check_login,
+ check_interval=60
+ )
+
+ # 3. 模拟已有 Cookie(实际场景中可能是从浏览器提取的)
+ existing_cookies = [
+ {
+ "name": "session",
+ "value": "workflow_session_123",
+ "domain": "httpbin.org",
+ "path": "/"
+ }
+ ]
+ manager.update(existing_cookies)
+ await manager.save()
+ print("已保存初始 Cookie")
+
+ # 4. 获取有效 Cookie 并使用
+ cookies = await manager.get_valid_cookies()
+ if cookies:
+ print(f"获取到有效 Cookie: {cookies}")
+
+ # 使用 Cookie 发起请求
+ async with httpx.AsyncClient(cookies=cookies) as client:
+ resp = await client.get("https://httpbin.org/cookies")
+ print(f"请求结果: {resp.json()}")
+ else:
+ print("Cookie 无效或不存在")
+
+ print("\n工作流程演示完成")
+
+
+async def main():
+ """主函数"""
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="INFO"
+ )
+
+ print("=" * 50)
+ print("Cookie 与 Session 管理演示")
+ print("=" * 50)
+
+ await demo_basic_session()
+ await demo_cookie_persistence()
+ await demo_cookie_rotation()
+ await demo_login_detection()
+ await demo_expiry_monitoring()
+ await demo_real_login() # 新增:真实登录演示
+ await demo_complete_workflow()
+
+ print("\n" + "=" * 50)
+ print("所有演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/README.md" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/README.md"
new file mode 100644
index 0000000..053c2d0
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/README.md"
@@ -0,0 +1,37 @@
+# 第07章:登录认证 - 扫码与短信登录实现
+
+展示扫码登录和短信登录的技术框架和实现思路。
+
+## 快速开始
+
+```bash
+cd 07_登录认证_扫码与短信登录实现
+
+# 安装基础依赖
+uv sync
+
+# 安装二维码功能(可选)
+uv sync --extra qrcode
+
+# 安装浏览器
+uv run playwright install chromium
+
+# 运行示例
+uv run python qrcode_login.py
+uv run python sms_login.py
+uv run python login_factory.py
+```
+
+### 重要说明
+
+⚠️ 本章代码提供的是**技术框架**而非特定网站实战,因为没有公开的练习网站提供真实扫码/短信登录功能。
+
+代码展示了:
+- 扫码登录的技术原理和流程
+- 短信登录的实现模式
+- 登录工厂模式设计
+
+实际使用时需要根据目标网站适配具体的:
+- 二维码选择器
+- 状态轮询机制
+- Cookie获取方式
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/bilibili_qrcode_login.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/bilibili_qrcode_login.py"
new file mode 100644
index 0000000..b1d45fa
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/bilibili_qrcode_login.py"
@@ -0,0 +1,470 @@
+# -*- coding: utf-8 -*-
+"""
+B站扫码登录实战
+
+本模块展示B站扫码登录的完整实现,包括:
+- B站扫码API调用(二维码生成、状态轮询)
+- 二维码图片生成与终端显示
+- 登录状态监控与回调
+- Cookie提取与保存
+
+这是第07章"登录认证-扫码与短信登录实现"的B站实战示例。
+
+与第11章综合实战项目的关联:
+- login/auth.py: 登录模块实现
+- config/bilibili_config.py: 登录相关常量定义
+"""
+
+import asyncio
+import json
+from io import BytesIO
+from pathlib import Path
+from dataclasses import dataclass, field
+from typing import Optional, Callable, Awaitable, Dict
+from enum import IntEnum
+
+import httpx
+from loguru import logger
+
+# 可选依赖:二维码生成
+try:
+ import qrcode
+ HAS_QRCODE = True
+except ImportError:
+ HAS_QRCODE = False
+ logger.warning("未安装 qrcode 库,终端二维码显示功能不可用。安装: pip install qrcode")
+
+
+# ============== B站扫码状态枚举 ==============
+
+class BilibiliQRStatus(IntEnum):
+ """
+ B站扫码状态码
+
+ | 状态码 | 含义 |
+ |-------|------|
+ | 0 | 登录成功 |
+ | 86101 | 未扫描 |
+ | 86090 | 已扫描,待确认 |
+ | 86038 | 已过期 |
+ """
+ SUCCESS = 0 # 登录成功
+ NOT_SCANNED = 86101 # 未扫描
+ SCANNED = 86090 # 已扫描,待确认
+ EXPIRED = 86038 # 已过期
+
+
+# ============== B站 Cookie 数据类 ==============
+
+@dataclass
+class BilibiliLoginCookies:
+ """
+ B站登录 Cookie 数据类
+
+ 扫码登录成功后提取的Cookie
+ """
+ sessdata: str
+ dede_user_id: str
+ bili_jct: str
+ buvid3: str = ""
+ buvid4: str = ""
+ sid: str = ""
+
+ def to_dict(self) -> Dict[str, str]:
+ """转换为 httpx 可用的字典格式"""
+ cookies = {
+ "SESSDATA": self.sessdata,
+ "DedeUserID": self.dede_user_id,
+ "bili_jct": self.bili_jct,
+ }
+ if self.buvid3:
+ cookies["buvid3"] = self.buvid3
+ if self.buvid4:
+ cookies["buvid4"] = self.buvid4
+ if self.sid:
+ cookies["sid"] = self.sid
+ return cookies
+
+ def to_header_string(self) -> str:
+ """转换为请求头 Cookie 格式"""
+ return "; ".join(f"{k}={v}" for k, v in self.to_dict().items())
+
+ def is_valid(self) -> bool:
+ """检查核心 Cookie 是否存在"""
+ return bool(self.sessdata and self.dede_user_id and self.bili_jct)
+
+
+# ============== B站扫码登录实现 ==============
+
+class BilibiliQRCodeLogin:
+ """
+ B站扫码登录实现
+
+ 流程:
+ 1. 调用 /qrcode/generate 获取二维码URL和qrcode_key
+ 2. 生成二维码图片(保存到文件或终端显示)
+ 3. 轮询 /qrcode/poll 检查扫码状态
+ 4. 登录成功后提取Cookie
+
+ 使用示例:
+ async with BilibiliQRCodeLogin() as login:
+ cookies = await login.login()
+ if cookies:
+ print(f"登录成功: {cookies.dede_user_id}")
+ """
+
+ # B站登录相关API
+ QRCODE_GENERATE_URL = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate"
+ QRCODE_POLL_URL = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll"
+
+ # 请求头
+ HEADERS = {
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/131.0.0.0 Safari/537.36",
+ "Referer": "https://www.bilibili.com/",
+ "Origin": "https://www.bilibili.com"
+ }
+
+ def __init__(
+ self,
+ timeout: int = 180,
+ poll_interval: float = 2.0,
+ on_status_change: Optional[Callable[[int, str], Awaitable[None]]] = None
+ ):
+ """
+ 初始化扫码登录
+
+ Args:
+ timeout: 登录超时时间(秒)
+ poll_interval: 状态轮询间隔(秒)
+ on_status_change: 状态变化回调 (status_code, message)
+ """
+ self.timeout = timeout
+ self.poll_interval = poll_interval
+ self.on_status_change = on_status_change
+
+ self._client: Optional[httpx.AsyncClient] = None
+ self._qrcode_key: str = ""
+ self._qrcode_url: str = ""
+ self._current_status: int = -1
+
+ async def __aenter__(self):
+ """异步上下文管理器入口"""
+ self._client = httpx.AsyncClient(
+ headers=self.HEADERS,
+ timeout=30
+ )
+ return self
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
+ """异步上下文管理器出口"""
+ if self._client:
+ await self._client.aclose()
+
+ async def _notify_status(self, code: int, message: str):
+ """通知状态变化"""
+ if code != self._current_status:
+ self._current_status = code
+ logger.info(f"B站登录状态: {message} ({code})")
+ if self.on_status_change:
+ await self.on_status_change(code, message)
+
+ async def generate_qrcode(self) -> tuple:
+ """
+ 生成登录二维码
+
+ Returns:
+ (qrcode_url, qrcode_image_bytes) 或 (qrcode_url, None) 如果无法生成图片
+ """
+ resp = await self._client.get(self.QRCODE_GENERATE_URL)
+ data = resp.json()
+
+ if data["code"] != 0:
+ raise Exception(f"获取二维码失败: {data['message']}")
+
+ self._qrcode_key = data["data"]["qrcode_key"]
+ self._qrcode_url = data["data"]["url"]
+
+ logger.info(f"二维码生成成功,qrcode_key: {self._qrcode_key[:20]}...")
+
+ # 生成二维码图片
+ image_bytes = None
+ if HAS_QRCODE:
+ qr = qrcode.QRCode(
+ version=1,
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
+ box_size=10,
+ border=2
+ )
+ qr.add_data(self._qrcode_url)
+ qr.make(fit=True)
+
+ img = qr.make_image(fill_color="black", back_color="white")
+ buffer = BytesIO()
+ img.save(buffer, format="PNG")
+ image_bytes = buffer.getvalue()
+
+ return self._qrcode_url, image_bytes
+
+ def print_qrcode_to_terminal(self, url: Optional[str] = None):
+ """在终端打印二维码"""
+ if not HAS_QRCODE:
+ logger.warning("需要安装 qrcode 库才能在终端显示二维码: pip install qrcode")
+ return
+
+ url = url or self._qrcode_url
+ if not url:
+ logger.warning("没有可用的二维码URL")
+ return
+
+ qr = qrcode.QRCode(
+ version=1,
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
+ box_size=1,
+ border=1
+ )
+ qr.add_data(url)
+ qr.make(fit=True)
+ qr.print_ascii(invert=True)
+
+ async def poll_status(self) -> tuple:
+ """
+ 轮询登录状态
+
+ Returns:
+ (status_code, cookies_if_success)
+ """
+ if not self._qrcode_key:
+ raise RuntimeError("请先调用 generate_qrcode() 生成二维码")
+
+ resp = await self._client.get(
+ self.QRCODE_POLL_URL,
+ params={"qrcode_key": self._qrcode_key}
+ )
+ data = resp.json()
+
+ code = data["data"]["code"]
+ message = data["data"]["message"]
+
+ await self._notify_status(code, message)
+
+ if code == BilibiliQRStatus.SUCCESS:
+ # 登录成功,从响应中提取Cookie
+ cookies = self._extract_cookies(resp)
+ return code, cookies
+
+ return code, None
+
+ def _extract_cookies(self, resp: httpx.Response) -> BilibiliLoginCookies:
+ """从响应中提取B站Cookie"""
+ cookies = resp.cookies
+
+ return BilibiliLoginCookies(
+ sessdata=cookies.get("SESSDATA", ""),
+ dede_user_id=cookies.get("DedeUserID", ""),
+ bili_jct=cookies.get("bili_jct", ""),
+ buvid3=cookies.get("buvid3", ""),
+ buvid4=cookies.get("buvid4", ""),
+ sid=cookies.get("sid", ""),
+ )
+
+ async def login(
+ self,
+ save_qrcode_path: str = "bilibili_qrcode.png",
+ show_in_terminal: bool = True
+ ) -> Optional[BilibiliLoginCookies]:
+ """
+ 执行完整的扫码登录流程
+
+ Args:
+ save_qrcode_path: 二维码图片保存路径
+ show_in_terminal: 是否在终端显示二维码
+
+ Returns:
+ 登录成功返回Cookie,失败返回None
+ """
+ # 1. 生成二维码
+ url, image_bytes = await self.generate_qrcode()
+
+ # 保存二维码图片
+ if image_bytes:
+ with open(save_qrcode_path, "wb") as f:
+ f.write(image_bytes)
+ logger.info(f"二维码已保存至: {save_qrcode_path}")
+
+ # 在终端显示
+ if show_in_terminal:
+ print("\n" + "=" * 50)
+ print("请使用B站APP扫描以下二维码登录")
+ print("=" * 50 + "\n")
+
+ if HAS_QRCODE:
+ self.print_qrcode_to_terminal(url)
+ else:
+ print(f"二维码URL: {url}")
+
+ if image_bytes:
+ print(f"\n二维码图片也已保存至: {save_qrcode_path}\n")
+
+ # 2. 轮询登录状态
+ start_time = asyncio.get_event_loop().time()
+
+ while True:
+ elapsed = asyncio.get_event_loop().time() - start_time
+ if elapsed > self.timeout:
+ logger.warning("登录超时")
+ return None
+
+ code, cookies = await self.poll_status()
+
+ if code == BilibiliQRStatus.SUCCESS:
+ logger.info("B站登录成功!")
+ return cookies
+
+ if code == BilibiliQRStatus.EXPIRED:
+ logger.warning("二维码已过期")
+ return None
+
+ await asyncio.sleep(self.poll_interval)
+
+
+# ============== Cookie 验证工具 ==============
+
+async def verify_bilibili_cookies(cookies: Dict[str, str]) -> Optional[dict]:
+ """
+ 验证B站Cookie是否有效
+
+ Args:
+ cookies: Cookie字典
+
+ Returns:
+ 有效返回用户信息,无效返回None
+ """
+ async with httpx.AsyncClient(
+ cookies=cookies,
+ headers={
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/131.0.0.0 Safari/537.36",
+ "Referer": "https://www.bilibili.com/",
+ },
+ timeout=10
+ ) as client:
+ resp = await client.get("https://api.bilibili.com/x/web-interface/nav")
+ data = resp.json()
+
+ if data["code"] == 0 and data["data"].get("isLogin"):
+ return {
+ "mid": data["data"]["mid"],
+ "uname": data["data"]["uname"],
+ "level": data["data"]["level_info"]["current_level"],
+ "vip_type": data["data"]["vipType"],
+ "money": data["data"]["money"],
+ }
+
+ return None
+
+
+# ============== 演示入口 ==============
+
+async def demo_bilibili_qrcode_login():
+ """演示B站扫码登录"""
+ logger.info("=" * 50)
+ logger.info("B站扫码登录示例")
+ logger.info("=" * 50)
+
+ # 定义状态变化回调
+ async def on_status(code: int, message: str):
+ status_emoji = {
+ BilibiliQRStatus.NOT_SCANNED: "...",
+ BilibiliQRStatus.SCANNED: "[已扫描]",
+ BilibiliQRStatus.SUCCESS: "[成功]",
+ BilibiliQRStatus.EXPIRED: "[过期]",
+ }
+ emoji = status_emoji.get(code, "?")
+ print(f" {emoji} {message}")
+
+ # 执行扫码登录
+ async with BilibiliQRCodeLogin(
+ timeout=180,
+ poll_interval=2.0,
+ on_status_change=on_status
+ ) as login:
+ cookies = await login.login(
+ save_qrcode_path="bilibili_qrcode.png",
+ show_in_terminal=True
+ )
+
+ if cookies:
+ # 保存Cookie
+ cookie_path = Path("data/bilibili_login_cookies.json")
+ cookie_path.parent.mkdir(parents=True, exist_ok=True)
+
+ with open(cookie_path, "w", encoding="utf-8") as f:
+ json.dump(cookies.to_dict(), f, indent=2, ensure_ascii=False)
+
+ print("\n" + "=" * 50)
+ print("登录成功!")
+ print("=" * 50)
+ print(f"Cookie已保存至: {cookie_path}")
+ print(f"SESSDATA: {cookies.sessdata[:20]}...")
+ print(f"DedeUserID: {cookies.dede_user_id}")
+ print(f"bili_jct: {cookies.bili_jct[:20]}...")
+
+ # 验证Cookie
+ user_info = await verify_bilibili_cookies(cookies.to_dict())
+ if user_info:
+ print(f"\n用户信息:")
+ print(f" 用户名: {user_info['uname']}")
+ print(f" 等级: LV{user_info['level']}")
+ print(f" 硬币: {user_info['money']}")
+ else:
+ print("\n登录失败或超时")
+
+
+async def demo_verify_existing_cookies():
+ """演示验证已保存的Cookie"""
+ logger.info("=" * 50)
+ logger.info("验证已保存的B站Cookie")
+ logger.info("=" * 50)
+
+ cookie_path = Path("data/bilibili_login_cookies.json")
+
+ if not cookie_path.exists():
+ logger.info("没有找到已保存的Cookie,请先执行扫码登录")
+ return
+
+ with open(cookie_path, "r", encoding="utf-8") as f:
+ cookies = json.load(f)
+
+ user_info = await verify_bilibili_cookies(cookies)
+
+ if user_info:
+ print(f"Cookie有效!")
+ print(f" 用户名: {user_info['uname']}")
+ print(f" 用户ID: {user_info['mid']}")
+ print(f" 等级: LV{user_info['level']}")
+ else:
+ print("Cookie已失效,请重新登录")
+
+
+async def main():
+ """主演示函数"""
+ logger.info("\n" + "=" * 60)
+ logger.info("B站扫码登录实战示例")
+ logger.info("=" * 60)
+
+ # 检查是否有已保存的Cookie
+ cookie_path = Path("data/bilibili_login_cookies.json")
+ if cookie_path.exists():
+ logger.info("\n检测到已保存的Cookie,正在验证...")
+ await demo_verify_existing_cookies()
+ print("\n如需重新登录,请删除 data/bilibili_login_cookies.json 后重试")
+ else:
+ logger.info("\n开始扫码登录流程...")
+ await demo_bilibili_qrcode_login()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/login_factory.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/login_factory.py"
new file mode 100644
index 0000000..5818aa2
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/login_factory.py"
@@ -0,0 +1,476 @@
+# -*- coding: utf-8 -*-
+# @Desc: 登录模块工厂 - 统一封装多种登录方式
+
+import asyncio
+import json
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import Optional, Callable, Awaitable, Dict, Any
+from enum import Enum
+from dataclasses import dataclass
+from loguru import logger
+
+
+class LoginMethod(Enum):
+ """登录方式枚举"""
+ QRCODE = "qrcode" # 扫码登录
+ SMS = "sms" # 短信验证码
+ PASSWORD = "password" # 账号密码
+ COOKIE = "cookie" # Cookie 注入
+
+
+@dataclass
+class LoginResult:
+ """登录结果"""
+ success: bool
+ cookies: list = None
+ error: str = None
+ method: LoginMethod = None
+
+ def __post_init__(self):
+ if self.cookies is None:
+ self.cookies = []
+
+
+class BaseLogin(ABC):
+ """登录基类"""
+
+ @property
+ @abstractmethod
+ def method(self) -> LoginMethod:
+ """登录方式"""
+ pass
+
+ @abstractmethod
+ async def login(self) -> LoginResult:
+ """
+ 执行登录
+
+ Returns:
+ 登录结果
+ """
+ pass
+
+ @abstractmethod
+ async def close(self):
+ """清理资源"""
+ pass
+
+
+class CookieLogin(BaseLogin):
+ """Cookie 注入登录"""
+
+ def __init__(self, cookies: list, verify_url: str = None):
+ """
+ Args:
+ cookies: Cookie 列表
+ verify_url: 验证 URL(可选)
+ """
+ self._cookies = cookies
+ self._verify_url = verify_url
+
+ @property
+ def method(self) -> LoginMethod:
+ return LoginMethod.COOKIE
+
+ async def login(self) -> LoginResult:
+ """Cookie 注入直接返回成功"""
+ logger.info(f"使用 Cookie 注入登录,Cookie 数量: {len(self._cookies)}")
+ return LoginResult(
+ success=True,
+ cookies=self._cookies,
+ method=self.method
+ )
+
+ async def close(self):
+ pass
+
+
+class QRCodeLoginWrapper(BaseLogin):
+ """扫码登录包装器"""
+
+ def __init__(
+ self,
+ login_url: str,
+ qrcode_selector: str,
+ success_url_pattern: str,
+ timeout: int = 120,
+ on_qrcode_ready: Callable[[str], Awaitable[None]] = None,
+ headless: bool = False
+ ):
+ self.login_url = login_url
+ self.qrcode_selector = qrcode_selector
+ self.success_url_pattern = success_url_pattern
+ self.timeout = timeout
+ self.on_qrcode_ready = on_qrcode_ready
+ self.headless = headless
+
+ self._playwright = None
+ self._qrcode_login = None
+
+ @property
+ def method(self) -> LoginMethod:
+ return LoginMethod.QRCODE
+
+ async def login(self) -> LoginResult:
+ from playwright.async_api import async_playwright
+ from qrcode_login import QRCodeLogin
+
+ try:
+ self._playwright = await async_playwright().start()
+ self._qrcode_login = QRCodeLogin(
+ login_url=self.login_url,
+ qrcode_selector=self.qrcode_selector,
+ success_url_pattern=self.success_url_pattern,
+ timeout=self.timeout
+ )
+
+ await self._qrcode_login.start(self._playwright, headless=self.headless)
+
+ cookies = await self._qrcode_login.login(
+ on_qrcode_ready=self.on_qrcode_ready
+ )
+
+ if cookies:
+ return LoginResult(
+ success=True,
+ cookies=cookies,
+ method=self.method
+ )
+
+ return LoginResult(
+ success=False,
+ error="登录超时或用户取消",
+ method=self.method
+ )
+
+ except Exception as e:
+ logger.error(f"扫码登录异常: {e}")
+ return LoginResult(
+ success=False,
+ error=str(e),
+ method=self.method
+ )
+
+ async def close(self):
+ if self._qrcode_login:
+ await self._qrcode_login.close()
+ if self._playwright:
+ await self._playwright.stop()
+
+
+class SMSLoginWrapper(BaseLogin):
+ """短信登录包装器"""
+
+ def __init__(
+ self,
+ login_url: str,
+ phone_input_selector: str,
+ send_code_btn_selector: str,
+ code_input_selector: str,
+ submit_btn_selector: str,
+ success_url_pattern: str,
+ phone: str,
+ get_code_callback: Callable[[], Awaitable[str]],
+ headless: bool = False
+ ):
+ self.login_url = login_url
+ self.phone_input_selector = phone_input_selector
+ self.send_code_btn_selector = send_code_btn_selector
+ self.code_input_selector = code_input_selector
+ self.submit_btn_selector = submit_btn_selector
+ self.success_url_pattern = success_url_pattern
+ self.phone = phone
+ self.get_code_callback = get_code_callback
+ self.headless = headless
+
+ self._playwright = None
+ self._sms_login = None
+
+ @property
+ def method(self) -> LoginMethod:
+ return LoginMethod.SMS
+
+ async def login(self) -> LoginResult:
+ from playwright.async_api import async_playwright
+ from sms_login import SMSLogin
+
+ try:
+ self._playwright = await async_playwright().start()
+ self._sms_login = SMSLogin(
+ login_url=self.login_url,
+ phone_input_selector=self.phone_input_selector,
+ send_code_btn_selector=self.send_code_btn_selector,
+ code_input_selector=self.code_input_selector,
+ submit_btn_selector=self.submit_btn_selector,
+ success_url_pattern=self.success_url_pattern
+ )
+
+ await self._sms_login.start(self._playwright, headless=self.headless)
+
+ cookies = await self._sms_login.login_with_manual_code(
+ phone=self.phone,
+ get_code_callback=self.get_code_callback
+ )
+
+ if cookies:
+ return LoginResult(
+ success=True,
+ cookies=cookies,
+ method=self.method
+ )
+
+ return LoginResult(
+ success=False,
+ error="登录失败",
+ method=self.method
+ )
+
+ except Exception as e:
+ logger.error(f"短信登录异常: {e}")
+ return LoginResult(
+ success=False,
+ error=str(e),
+ method=self.method
+ )
+
+ async def close(self):
+ if self._sms_login:
+ await self._sms_login.close()
+ if self._playwright:
+ await self._playwright.stop()
+
+
+class LoginFactory:
+ """登录工厂"""
+
+ @staticmethod
+ def create_cookie_login(cookies: list, **kwargs) -> BaseLogin:
+ """创建 Cookie 登录"""
+ return CookieLogin(cookies, **kwargs)
+
+ @staticmethod
+ def create_qrcode_login(
+ login_url: str,
+ qrcode_selector: str,
+ success_url_pattern: str,
+ **kwargs
+ ) -> BaseLogin:
+ """创建扫码登录"""
+ return QRCodeLoginWrapper(
+ login_url=login_url,
+ qrcode_selector=qrcode_selector,
+ success_url_pattern=success_url_pattern,
+ **kwargs
+ )
+
+ @staticmethod
+ def create_sms_login(
+ login_url: str,
+ phone: str,
+ get_code_callback: Callable[[], Awaitable[str]],
+ phone_input_selector: str,
+ send_code_btn_selector: str,
+ code_input_selector: str,
+ submit_btn_selector: str,
+ success_url_pattern: str,
+ **kwargs
+ ) -> BaseLogin:
+ """创建短信登录"""
+ return SMSLoginWrapper(
+ login_url=login_url,
+ phone=phone,
+ get_code_callback=get_code_callback,
+ phone_input_selector=phone_input_selector,
+ send_code_btn_selector=send_code_btn_selector,
+ code_input_selector=code_input_selector,
+ submit_btn_selector=submit_btn_selector,
+ success_url_pattern=success_url_pattern,
+ **kwargs
+ )
+
+
+class LoginManager:
+ """统一登录管理器"""
+
+ def __init__(
+ self,
+ platform: str,
+ cookie_path: str,
+ default_method: LoginMethod = LoginMethod.COOKIE
+ ):
+ """
+ Args:
+ platform: 平台名称
+ cookie_path: Cookie 存储路径
+ default_method: 默认登录方式
+ """
+ self.platform = platform
+ self.cookie_path = Path(cookie_path)
+ self.default_method = default_method
+ self._cookies: list = []
+
+ async def ensure_login(
+ self,
+ login_config: Dict[str, Any] = None,
+ force_login: bool = False
+ ) -> bool:
+ """
+ 确保已登录
+
+ 优先使用已保存的 Cookie,如果无效则使用指定方式登录
+
+ Args:
+ login_config: 登录配置
+ force_login: 是否强制重新登录
+
+ Returns:
+ 是否登录成功
+ """
+ # 如果不强制登录,先尝试加载已保存的 Cookie
+ if not force_login and await self._try_load_cookies():
+ logger.info(f"[{self.platform}] 使用已保存的 Cookie")
+ return True
+
+ # 执行登录
+ if not login_config:
+ logger.error(f"[{self.platform}] 需要登录配置")
+ return False
+
+ method = login_config.pop('method', self.default_method)
+ logger.info(f"[{self.platform}] 开始 {method.value} 登录")
+
+ # 创建登录实例
+ login = self._create_login(method, login_config)
+ if not login:
+ logger.error(f"[{self.platform}] 不支持的登录方式: {method}")
+ return False
+
+ try:
+ result = await login.login()
+
+ if result.success:
+ self._cookies = result.cookies
+ await self._save_cookies()
+ logger.info(f"[{self.platform}] 登录成功")
+ return True
+ else:
+ logger.error(f"[{self.platform}] 登录失败: {result.error}")
+ return False
+
+ finally:
+ await login.close()
+
+ def _create_login(
+ self,
+ method: LoginMethod,
+ config: Dict[str, Any]
+ ) -> Optional[BaseLogin]:
+ """根据方式创建登录实例"""
+ if method == LoginMethod.COOKIE:
+ return LoginFactory.create_cookie_login(**config)
+ elif method == LoginMethod.QRCODE:
+ return LoginFactory.create_qrcode_login(**config)
+ elif method == LoginMethod.SMS:
+ return LoginFactory.create_sms_login(**config)
+ return None
+
+ async def _try_load_cookies(self) -> bool:
+ """尝试加载 Cookie"""
+ if not self.cookie_path.exists():
+ return False
+
+ try:
+ with open(self.cookie_path, "r", encoding="utf-8") as f:
+ self._cookies = json.load(f)
+
+ # 简单验证:检查是否有 Cookie
+ return len(self._cookies) > 0
+
+ except Exception as e:
+ logger.warning(f"加载 Cookie 失败: {e}")
+ return False
+
+ async def _save_cookies(self):
+ """保存 Cookie"""
+ self.cookie_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(self.cookie_path, "w", encoding="utf-8") as f:
+ json.dump(self._cookies, f, indent=2, ensure_ascii=False)
+ logger.info(f"Cookie 已保存: {self.cookie_path}")
+
+ def get_cookies(self) -> list:
+ """获取 Cookie 列表"""
+ return self._cookies
+
+ def get_cookies_dict(self) -> dict:
+ """获取字典格式的 Cookie"""
+ return {c["name"]: c["value"] for c in self._cookies}
+
+ async def clear_cookies(self):
+ """清除 Cookie"""
+ self._cookies = []
+ if self.cookie_path.exists():
+ self.cookie_path.unlink()
+ logger.info(f"[{self.platform}] Cookie 已清除")
+
+
+async def demo_login_factory():
+ """登录工厂演示"""
+ print("=" * 50)
+ print("登录工厂演示")
+ print("=" * 50)
+
+ # 1. Cookie 登录演示
+ print("\n1. Cookie 登录演示:")
+ test_cookies = [
+ {"name": "session", "value": "test123", "domain": "example.com", "path": "/"}
+ ]
+ cookie_login = LoginFactory.create_cookie_login(cookies=test_cookies)
+ result = await cookie_login.login()
+ print(f" 登录方式: {result.method.value}")
+ print(f" 登录结果: {'成功' if result.success else '失败'}")
+ print(f" Cookie 数量: {len(result.cookies)}")
+ await cookie_login.close()
+
+ # 2. 登录管理器演示
+ print("\n2. 登录管理器演示:")
+ manager = LoginManager(
+ platform="demo_platform",
+ cookie_path="data/demo_login_cookies.json",
+ default_method=LoginMethod.COOKIE
+ )
+
+ # 使用 Cookie 配置
+ success = await manager.ensure_login(
+ login_config={
+ 'method': LoginMethod.COOKIE,
+ 'cookies': test_cookies
+ }
+ )
+ print(f" 登录结果: {'成功' if success else '失败'}")
+ print(f" Cookie: {manager.get_cookies_dict()}")
+
+ # 3. 再次调用会使用缓存的 Cookie
+ print("\n3. 使用缓存的 Cookie:")
+ success = await manager.ensure_login()
+ print(f" 登录结果: {'成功' if success else '失败'}")
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+async def main():
+ """主函数"""
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="INFO"
+ )
+
+ await demo_login_factory()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/pyproject.toml" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/pyproject.toml"
new file mode 100644
index 0000000..00e8c1f
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/pyproject.toml"
@@ -0,0 +1,25 @@
+[project]
+name = "chapter07-qrcode-sms-login"
+version = "0.1.0"
+description = "第07章:登录认证 - 扫码登录与短信登录技术框架"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ "playwright>=1.45.0",
+ "loguru>=0.7.0",
+]
+
+[project.optional-dependencies]
+qrcode = [
+ "qrcode[pil]>=7.4.0", # 二维码生成和显示
+ "pillow>=10.0.0",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.uv]
+dev-dependencies = []
+
+# 提示:安装后需要运行 playwright install 安装浏览器驱动
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/qrcode_login.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/qrcode_login.py"
new file mode 100644
index 0000000..4a1bd1d
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/qrcode_login.py"
@@ -0,0 +1,313 @@
+# -*- coding: utf-8 -*-
+# @Desc: 扫码登录实现
+
+import asyncio
+from pathlib import Path
+from typing import Optional, Callable, Awaitable
+from playwright.async_api import async_playwright, Page, BrowserContext, Playwright
+from loguru import logger
+from enum import Enum
+
+# 可选的终端二维码显示支持
+try:
+ import qrcode
+ HAS_QRCODE_LIB = True
+except ImportError:
+ HAS_QRCODE_LIB = False
+
+
+class QRCodeStatus(Enum):
+ """二维码状态枚举"""
+ WAITING = "waiting" # 等待扫描
+ SCANNED = "scanned" # 已扫描,等待确认
+ CONFIRMED = "confirmed" # 已确认登录
+ EXPIRED = "expired" # 二维码已过期
+ CANCELED = "canceled" # 用户取消
+
+
+def display_qrcode_in_terminal(data: str):
+ """在终端显示二维码(需要安装 qrcode 库)"""
+ if not HAS_QRCODE_LIB:
+ print("提示: 安装 qrcode 库可在终端显示二维码: pip install qrcode")
+ return
+
+ qr = qrcode.QRCode(
+ version=1,
+ error_correction=qrcode.constants.ERROR_CORRECT_L,
+ box_size=1,
+ border=1
+ )
+ qr.add_data(data)
+ qr.make(fit=True)
+ qr.print_ascii(invert=True)
+
+
+def display_qrcode_image_in_terminal(image_path: str):
+ """
+ 将图片二维码转换为终端可显示的 ASCII 艺术
+
+ 需要安装: pip install pillow
+ """
+ try:
+ from PIL import Image
+
+ img = Image.open(image_path)
+ img = img.convert('L') # 转为灰度
+
+ # 缩小图片以适应终端
+ width = 50
+ ratio = width / img.width
+ height = int(img.height * ratio * 0.5) # 0.5 补偿终端字符高宽比
+ img = img.resize((width, height))
+
+ # 转换为 ASCII
+ chars = " .:-=+*#%@"
+ pixels = img.getdata()
+ ascii_img = ""
+ for i, pixel in enumerate(pixels):
+ if i > 0 and i % width == 0:
+ ascii_img += "\n"
+ char_idx = pixel * len(chars) // 256
+ ascii_img += chars[char_idx]
+
+ print(ascii_img)
+ except ImportError:
+ print(f"二维码已保存到: {image_path}")
+ print("提示: 安装 pillow 库可在终端显示: pip install pillow")
+
+
+class QRCodeLogin:
+ """通用扫码登录实现"""
+
+ def __init__(
+ self,
+ login_url: str,
+ qrcode_selector: str,
+ success_url_pattern: str,
+ timeout: int = 120,
+ poll_interval: float = 2.0
+ ):
+ """
+ Args:
+ login_url: 登录页面 URL
+ qrcode_selector: 二维码元素选择器 (CSS/XPath)
+ success_url_pattern: 登录成功后 URL 的特征字符串
+ timeout: 等待登录的超时时间(秒)
+ poll_interval: 状态轮询间隔(秒)
+ """
+ self.login_url = login_url
+ self.qrcode_selector = qrcode_selector
+ self.success_url_pattern = success_url_pattern
+ self.timeout = timeout
+ self.poll_interval = poll_interval
+
+ self._playwright: Optional[Playwright] = None
+ self._browser = None
+ self._context: Optional[BrowserContext] = None
+ self._page: Optional[Page] = None
+
+ async def start(self, playwright: Playwright, headless: bool = False):
+ """
+ 启动浏览器
+
+ Args:
+ playwright: Playwright 实例
+ headless: 是否无头模式(扫码时通常使用有头模式)
+ """
+ self._playwright = playwright
+ self._browser = await playwright.chromium.launch(
+ headless=headless,
+ args=['--disable-blink-features=AutomationControlled']
+ )
+ self._context = await self._browser.new_context(
+ viewport={'width': 1280, 'height': 720}
+ )
+ self._page = await self._context.new_page()
+ logger.info(f"浏览器已启动 (headless={headless})")
+
+ async def close(self):
+ """关闭浏览器"""
+ if self._browser:
+ await self._browser.close()
+ self._browser = None
+ self._context = None
+ self._page = None
+ logger.info("浏览器已关闭")
+
+ async def navigate_to_login(self):
+ """导航到登录页面"""
+ await self._page.goto(self.login_url, wait_until="networkidle")
+ logger.info(f"已打开登录页面: {self.login_url}")
+
+ async def wait_for_qrcode(self, timeout: int = 10000) -> bool:
+ """等待二维码出现"""
+ try:
+ await self._page.wait_for_selector(
+ self.qrcode_selector,
+ state="visible",
+ timeout=timeout
+ )
+ logger.info("二维码已出现")
+ return True
+ except Exception as e:
+ logger.error(f"等待二维码超时: {e}")
+ return False
+
+ async def save_qrcode(self, filepath: str = "qrcode.png") -> Optional[str]:
+ """
+ 保存二维码图片
+
+ Args:
+ filepath: 保存路径
+
+ Returns:
+ 成功返回文件路径,失败返回 None
+ """
+ if not await self.wait_for_qrcode():
+ return None
+
+ try:
+ qrcode_element = self._page.locator(self.qrcode_selector)
+ await qrcode_element.screenshot(path=filepath)
+ logger.info(f"二维码已保存: {filepath}")
+ return filepath
+ except Exception as e:
+ logger.error(f"保存二维码失败: {e}")
+ return None
+
+ async def wait_for_login(
+ self,
+ on_qrcode_ready: Optional[Callable[[str], Awaitable[None]]] = None,
+ on_status_change: Optional[Callable[[QRCodeStatus], Awaitable[None]]] = None
+ ) -> bool:
+ """
+ 等待用户扫码登录
+
+ Args:
+ on_qrcode_ready: 二维码准备好后的回调
+ on_status_change: 状态变化回调
+
+ Returns:
+ 是否登录成功
+ """
+ # 保存二维码
+ qrcode_path = await self.save_qrcode()
+ if not qrcode_path:
+ return False
+
+ # 通知二维码已准备好
+ if on_qrcode_ready:
+ await on_qrcode_ready(qrcode_path)
+
+ if on_status_change:
+ await on_status_change(QRCodeStatus.WAITING)
+
+ # 等待登录成功(URL 变化)
+ try:
+ await self._page.wait_for_url(
+ f"**{self.success_url_pattern}**",
+ timeout=self.timeout * 1000
+ )
+
+ if on_status_change:
+ await on_status_change(QRCodeStatus.CONFIRMED)
+
+ logger.info("扫码登录成功!")
+ return True
+
+ except Exception as e:
+ logger.warning(f"登录超时或失败: {e}")
+ if on_status_change:
+ await on_status_change(QRCodeStatus.EXPIRED)
+ return False
+
+ async def get_cookies(self) -> list:
+ """获取登录后的 Cookie"""
+ if self._context:
+ return await self._context.cookies()
+ return []
+
+ async def login(
+ self,
+ on_qrcode_ready: Optional[Callable[[str], Awaitable[None]]] = None,
+ on_status_change: Optional[Callable[[QRCodeStatus], Awaitable[None]]] = None
+ ) -> Optional[list]:
+ """
+ 执行完整的扫码登录流程
+
+ Args:
+ on_qrcode_ready: 二维码准备好后的回调
+ on_status_change: 状态变化回调
+
+ Returns:
+ 成功返回 Cookie 列表,失败返回 None
+ """
+ await self.navigate_to_login()
+
+ success = await self.wait_for_login(
+ on_qrcode_ready=on_qrcode_ready,
+ on_status_change=on_status_change
+ )
+
+ if success:
+ cookies = await self.get_cookies()
+ logger.info(f"获取到 {len(cookies)} 个 Cookie")
+ return cookies
+
+ return None
+
+
+async def demo_qrcode_login():
+ """扫码登录演示"""
+ print("=" * 50)
+ print("扫码登录演示")
+ print("=" * 50)
+ print("\n注意: 这是一个演示示例,实际使用需要替换为真实网站的配置")
+
+ # 二维码准备好后的回调
+ async def on_qrcode_ready(path: str):
+ print(f"\n{'='*40}")
+ print(f"二维码已准备好: {path}")
+ print("请使用手机扫描登录")
+ print(f"{'='*40}\n")
+ # 在终端显示
+ display_qrcode_image_in_terminal(path)
+
+ # 状态变化回调
+ async def on_status_change(status: QRCodeStatus):
+ status_text = {
+ QRCodeStatus.WAITING: "等待扫描...",
+ QRCodeStatus.SCANNED: "已扫描,请在手机上确认",
+ QRCodeStatus.CONFIRMED: "登录成功!",
+ QRCodeStatus.EXPIRED: "二维码已过期",
+ QRCodeStatus.CANCELED: "用户取消"
+ }
+ print(f"状态: {status_text.get(status, status.value)}")
+
+ async with async_playwright() as p:
+ # 使用示例网站演示(实际使用时替换)
+ qr_login = QRCodeLogin(
+ login_url="https://quotes.toscrape.com/login", # 示例网站
+ qrcode_selector="form", # 示例选择器
+ success_url_pattern="/",
+ timeout=30
+ )
+
+ await qr_login.start(p, headless=False)
+
+ try:
+ print("\n这是一个演示,不会真正执行扫码登录")
+ print("实际使用时,请配置正确的登录 URL 和选择器\n")
+
+ # 演示导航到页面
+ await qr_login.navigate_to_login()
+ await asyncio.sleep(3)
+
+ print("演示完成")
+
+ finally:
+ await qr_login.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(demo_qrcode_login())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/sms_login.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/sms_login.py"
new file mode 100644
index 0000000..549a988
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/07_\347\231\273\345\275\225\350\256\244\350\257\201_\346\211\253\347\240\201\344\270\216\347\237\255\344\277\241\347\231\273\345\275\225\345\256\236\347\216\260/sms_login.py"
@@ -0,0 +1,371 @@
+# -*- coding: utf-8 -*-
+# @Desc: 短信验证码登录实现
+
+import asyncio
+from typing import Optional, Callable, Awaitable
+from playwright.async_api import async_playwright, Page, BrowserContext, Playwright
+from loguru import logger
+
+
+class SMSLogin:
+ """短信验证码登录"""
+
+ def __init__(
+ self,
+ login_url: str,
+ phone_input_selector: str,
+ send_code_btn_selector: str,
+ code_input_selector: str,
+ submit_btn_selector: str,
+ success_url_pattern: str,
+ timeout: int = 60
+ ):
+ """
+ Args:
+ login_url: 登录页 URL
+ phone_input_selector: 手机号输入框选择器
+ send_code_btn_selector: 发送验证码按钮选择器
+ code_input_selector: 验证码输入框选择器
+ submit_btn_selector: 登录按钮选择器
+ success_url_pattern: 登录成功 URL 特征
+ timeout: 超时时间(秒)
+ """
+ self.login_url = login_url
+ self.phone_input_selector = phone_input_selector
+ self.send_code_btn_selector = send_code_btn_selector
+ self.code_input_selector = code_input_selector
+ self.submit_btn_selector = submit_btn_selector
+ self.success_url_pattern = success_url_pattern
+ self.timeout = timeout
+
+ self._playwright: Optional[Playwright] = None
+ self._browser = None
+ self._context: Optional[BrowserContext] = None
+ self._page: Optional[Page] = None
+
+ async def start(self, playwright: Playwright, headless: bool = False):
+ """启动浏览器"""
+ self._playwright = playwright
+ self._browser = await playwright.chromium.launch(
+ headless=headless,
+ args=['--disable-blink-features=AutomationControlled']
+ )
+ self._context = await self._browser.new_context(
+ viewport={'width': 1280, 'height': 720}
+ )
+ self._page = await self._context.new_page()
+ logger.info(f"浏览器已启动 (headless={headless})")
+
+ async def close(self):
+ """关闭浏览器"""
+ if self._browser:
+ await self._browser.close()
+ self._browser = None
+ self._context = None
+ self._page = None
+ logger.info("浏览器已关闭")
+
+ async def navigate_to_login(self):
+ """导航到登录页面"""
+ await self._page.goto(self.login_url, wait_until="networkidle")
+ logger.info(f"已打开登录页面: {self.login_url}")
+
+ async def input_phone(self, phone: str):
+ """输入手机号"""
+ await self._page.wait_for_selector(self.phone_input_selector)
+ await self._page.fill(self.phone_input_selector, phone)
+ # 隐藏中间四位
+ masked_phone = f"{phone[:3]}****{phone[-4:]}"
+ logger.info(f"已输入手机号: {masked_phone}")
+
+ async def send_verification_code(self) -> bool:
+ """
+ 发送验证码
+
+ Returns:
+ 是否发送成功
+ """
+ try:
+ await self._page.wait_for_selector(self.send_code_btn_selector)
+ await self._page.click(self.send_code_btn_selector)
+ logger.info("验证码发送请求已提交")
+ # 等待一小段时间确保请求发出
+ await asyncio.sleep(1)
+ return True
+ except Exception as e:
+ logger.error(f"发送验证码失败: {e}")
+ return False
+
+ async def input_code(self, code: str):
+ """输入验证码"""
+ await self._page.wait_for_selector(self.code_input_selector)
+ await self._page.fill(self.code_input_selector, code)
+ logger.info(f"已输入验证码: {code}")
+
+ async def submit_login(self) -> bool:
+ """
+ 提交登录
+
+ Returns:
+ 是否登录成功
+ """
+ try:
+ await self._page.click(self.submit_btn_selector)
+
+ # 等待登录成功(URL 变化)
+ await self._page.wait_for_url(
+ f"**{self.success_url_pattern}**",
+ timeout=self.timeout * 1000
+ )
+ logger.info("登录成功!")
+ return True
+
+ except Exception as e:
+ logger.error(f"登录失败: {e}")
+ return False
+
+ async def get_cookies(self) -> list:
+ """获取 Cookie"""
+ if self._context:
+ return await self._context.cookies()
+ return []
+
+ async def login_with_manual_code(
+ self,
+ phone: str,
+ get_code_callback: Callable[[], Awaitable[str]]
+ ) -> Optional[list]:
+ """
+ 使用手动输入验证码的方式登录
+
+ Args:
+ phone: 手机号
+ get_code_callback: 获取验证码的异步回调
+
+ Returns:
+ 成功返回 Cookie,失败返回 None
+ """
+ await self.navigate_to_login()
+ await self.input_phone(phone)
+
+ if not await self.send_verification_code():
+ return None
+
+ # 获取验证码
+ logger.info("等待获取验证码...")
+ code = await get_code_callback()
+
+ await self.input_code(code)
+
+ if await self.submit_login():
+ return await self.get_cookies()
+
+ return None
+
+ async def login_with_code(
+ self,
+ phone: str,
+ code: str
+ ) -> Optional[list]:
+ """
+ 使用指定的验证码登录
+
+ Args:
+ phone: 手机号
+ code: 验证码
+
+ Returns:
+ 成功返回 Cookie,失败返回 None
+ """
+ await self.navigate_to_login()
+ await self.input_phone(phone)
+
+ if not await self.send_verification_code():
+ return None
+
+ await self.input_code(code)
+
+ if await self.submit_login():
+ return await self.get_cookies()
+
+ return None
+
+
+async def get_code_from_user() -> str:
+ """从控制台获取用户输入的验证码"""
+ print("\n请输入收到的验证码: ", end="", flush=True)
+ loop = asyncio.get_event_loop()
+ code = await loop.run_in_executor(None, input)
+ return code.strip()
+
+
+class SMSCodeReceiver:
+ """
+ 短信接码平台接口基类
+
+ 注意:这是一个示意接口,实际使用需要接入具体的接码平台 API
+ """
+
+ def __init__(self, api_key: str, api_url: str):
+ """
+ Args:
+ api_key: API 密钥
+ api_url: API 地址
+ """
+ self.api_key = api_key
+ self.api_url = api_url
+
+ async def get_phone_number(self) -> Optional[str]:
+ """
+ 获取手机号
+
+ Returns:
+ 手机号或 None
+ """
+ # 实际实现时调用接码平台 API
+ raise NotImplementedError("需要实现具体的接码平台接口")
+
+ async def wait_for_code(
+ self,
+ phone: str,
+ timeout: int = 60,
+ poll_interval: float = 2.0
+ ) -> Optional[str]:
+ """
+ 等待接收验证码
+
+ Args:
+ phone: 手机号
+ timeout: 超时时间(秒)
+ poll_interval: 轮询间隔(秒)
+
+ Returns:
+ 验证码或 None
+ """
+ # 实际实现时轮询接码平台获取验证码
+ raise NotImplementedError("需要实现具体的接码平台接口")
+
+ async def release_phone(self, phone: str):
+ """
+ 释放手机号
+
+ Args:
+ phone: 手机号
+ """
+ # 实际实现时调用接码平台释放手机号
+ raise NotImplementedError("需要实现具体的接码平台接口")
+
+
+class MockSMSCodeReceiver(SMSCodeReceiver):
+ """模拟接码平台(用于测试)"""
+
+ def __init__(self):
+ super().__init__("mock_key", "mock_url")
+ self._phones = ["13800138001", "13800138002", "13800138003"]
+ self._used_phones = set()
+
+ async def get_phone_number(self) -> Optional[str]:
+ for phone in self._phones:
+ if phone not in self._used_phones:
+ self._used_phones.add(phone)
+ logger.info(f"[Mock] 获取手机号: {phone}")
+ return phone
+ return None
+
+ async def wait_for_code(
+ self,
+ phone: str,
+ timeout: int = 60,
+ poll_interval: float = 2.0
+ ) -> Optional[str]:
+ logger.info(f"[Mock] 等待验证码 (手机号: {phone})")
+ await asyncio.sleep(2) # 模拟等待
+ code = "123456" # 模拟验证码
+ logger.info(f"[Mock] 收到验证码: {code}")
+ return code
+
+ async def release_phone(self, phone: str):
+ self._used_phones.discard(phone)
+ logger.info(f"[Mock] 释放手机号: {phone}")
+
+
+async def demo_sms_login():
+ """短信登录演示"""
+ print("=" * 50)
+ print("短信验证码登录演示")
+ print("=" * 50)
+ print("\n注意: 这是一个演示示例,实际使用需要替换为真实网站的配置")
+
+ async with async_playwright() as p:
+ # 使用示例网站演示
+ sms_login = SMSLogin(
+ login_url="https://quotes.toscrape.com/login",
+ phone_input_selector="#username",
+ send_code_btn_selector="input[type='submit']", # 示例
+ code_input_selector="#password",
+ submit_btn_selector="input[type='submit']",
+ success_url_pattern="/"
+ )
+
+ await sms_login.start(p, headless=False)
+
+ try:
+ print("\n这是一个演示,使用示例网站的登录表单")
+ print("实际短信登录需要配置正确的选择器\n")
+
+ # 演示导航到页面
+ await sms_login.navigate_to_login()
+
+ # 演示输入(使用示例网站的账号密码)
+ await sms_login._page.fill("#username", "demo_user")
+ await sms_login._page.fill("#password", "demo_pass")
+
+ print("已填写表单(演示)")
+ await asyncio.sleep(3)
+
+ print("演示完成")
+
+ finally:
+ await sms_login.close()
+
+
+async def demo_mock_receiver():
+ """模拟接码平台演示"""
+ print("\n" + "=" * 50)
+ print("模拟接码平台演示")
+ print("=" * 50)
+
+ receiver = MockSMSCodeReceiver()
+
+ # 获取手机号
+ phone = await receiver.get_phone_number()
+ print(f"获取到手机号: {phone}")
+
+ # 等待验证码
+ code = await receiver.wait_for_code(phone)
+ print(f"收到验证码: {code}")
+
+ # 释放手机号
+ await receiver.release_phone(phone)
+ print("手机号已释放")
+
+
+async def main():
+ """主函数"""
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="INFO"
+ )
+
+ await demo_sms_login()
+ await demo_mock_receiver()
+
+ print("\n" + "=" * 50)
+ print("所有演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/README.md" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/README.md"
new file mode 100644
index 0000000..022f22f
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/README.md"
@@ -0,0 +1,37 @@
+# 第08章:验证码识别与处理
+
+展示图片验证码OCR识别、滑块验证码处理、人类轨迹生成等。
+
+## 快速开始
+
+```bash
+cd 08_验证码识别与处理
+
+# 安装基础依赖
+uv sync
+
+# 安装OCR功能
+uv sync --extra ocr
+
+# 安装图像处理功能
+uv sync --extra cv
+
+# 安装验证码生成功能
+uv sync --extra generate
+
+# 安装轨迹可视化功能
+uv sync --extra viz
+
+# 或安装所有功能
+uv sync --extra all
+
+# 运行示例
+uv run python ocr_captcha.py
+uv run python slider_captcha.py
+```
+
+### 可选依赖说明
+- `ddddocr` - OCR识别引擎
+- `opencv-python` - 图像处理(滑块验证码)
+- `captcha` - 本地验证码生成
+- `matplotlib` - 轨迹可视化
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/captcha_service.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/captcha_service.py"
new file mode 100644
index 0000000..d2d0637
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/captcha_service.py"
@@ -0,0 +1,407 @@
+# -*- coding: utf-8 -*-
+# @Desc: 验证码服务 - 整合本地识别和第三方打码平台
+
+import asyncio
+import base64
+from abc import ABC, abstractmethod
+from typing import Optional, Callable, Awaitable
+from datetime import date
+from loguru import logger
+
+try:
+ import httpx
+ HAS_HTTPX = True
+except ImportError:
+ HAS_HTTPX = False
+
+
+class CaptchaServiceBase(ABC):
+ """验证码服务基类"""
+
+ @abstractmethod
+ async def solve_image(self, image_bytes: bytes) -> Optional[str]:
+ """识别图片验证码"""
+ pass
+
+
+class LocalOCRService(CaptchaServiceBase):
+ """本地 OCR 服务"""
+
+ def __init__(self, preprocess: bool = True):
+ from ocr_captcha import OCRCaptchaSolver
+ self.solver = OCRCaptchaSolver(preprocess=preprocess)
+
+ async def solve_image(self, image_bytes: bytes) -> Optional[str]:
+ """使用本地 OCR 识别"""
+ return self.solver.solve(image_bytes)
+
+
+class RemoteCaptchaService(CaptchaServiceBase):
+ """
+ 远程打码平台服务(示例实现)
+
+ 注意:这是一个通用的示例接口,实际使用需要根据具体平台 API 调整
+ """
+
+ def __init__(
+ self,
+ api_key: str,
+ api_url: str,
+ timeout: int = 30
+ ):
+ """
+ Args:
+ api_key: API 密钥
+ api_url: API 地址
+ timeout: 超时时间(秒)
+ """
+ if not HAS_HTTPX:
+ raise ImportError("需要安装 httpx: pip install httpx")
+
+ self.api_key = api_key
+ self.api_url = api_url.rstrip("/")
+ self.timeout = timeout
+ self._last_task_id: Optional[str] = None
+
+ async def solve_image(
+ self,
+ image_bytes: bytes,
+ captcha_type: str = "default"
+ ) -> Optional[str]:
+ """
+ 识别图片验证码
+
+ Args:
+ image_bytes: 图片字节
+ captcha_type: 验证码类型
+
+ Returns:
+ 识别结果
+ """
+ try:
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
+ # 编码图片
+ image_base64 = base64.b64encode(image_bytes).decode()
+
+ # 提交任务
+ resp = await client.post(
+ f"{self.api_url}/create_task",
+ json={
+ "api_key": self.api_key,
+ "image": image_base64,
+ "type": captcha_type
+ }
+ )
+
+ data = resp.json()
+ task_id = data.get("task_id")
+
+ if not task_id:
+ logger.error(f"创建任务失败: {data}")
+ return None
+
+ self._last_task_id = task_id
+ logger.debug(f"任务已创建: {task_id}")
+
+ # 轮询获取结果
+ return await self._poll_result(client, task_id)
+
+ except Exception as e:
+ logger.error(f"打码平台请求失败: {e}")
+ return None
+
+ async def _poll_result(
+ self,
+ client: httpx.AsyncClient,
+ task_id: str,
+ max_attempts: int = 30
+ ) -> Optional[str]:
+ """轮询获取结果"""
+ for attempt in range(max_attempts):
+ try:
+ resp = await client.get(
+ f"{self.api_url}/get_result",
+ params={"task_id": task_id}
+ )
+
+ data = resp.json()
+ status = data.get("status")
+
+ if status == "ready":
+ result = data.get("result")
+ logger.debug(f"识别成功: {result}")
+ return result
+ elif status == "error":
+ logger.error(f"识别错误: {data.get('error')}")
+ return None
+
+ await asyncio.sleep(1)
+
+ except Exception as e:
+ logger.warning(f"轮询异常: {e}")
+ await asyncio.sleep(1)
+
+ logger.error("识别超时")
+ return None
+
+ async def report_error(self, task_id: str = None):
+ """
+ 报告识别错误(用于退款)
+
+ Args:
+ task_id: 任务 ID,不提供则使用最后一个任务
+ """
+ task_id = task_id or self._last_task_id
+ if not task_id:
+ logger.warning("没有可报告的任务")
+ return
+
+ try:
+ async with httpx.AsyncClient() as client:
+ await client.post(
+ f"{self.api_url}/report_error",
+ json={
+ "api_key": self.api_key,
+ "task_id": task_id
+ }
+ )
+ logger.info(f"已报告错误: {task_id}")
+ except Exception as e:
+ logger.warning(f"报告错误失败: {e}")
+
+ async def get_balance(self) -> float:
+ """获取账户余额"""
+ try:
+ async with httpx.AsyncClient() as client:
+ resp = await client.get(
+ f"{self.api_url}/balance",
+ params={"api_key": self.api_key}
+ )
+ data = resp.json()
+ return data.get("balance", 0.0)
+ except Exception as e:
+ logger.error(f"获取余额失败: {e}")
+ return 0.0
+
+
+class CostController:
+ """打码成本控制器"""
+
+ def __init__(
+ self,
+ daily_budget: float,
+ cost_per_captcha: float = 0.01
+ ):
+ """
+ Args:
+ daily_budget: 每日预算
+ cost_per_captcha: 每次识别成本
+ """
+ self.daily_budget = daily_budget
+ self.cost_per_captcha = cost_per_captcha
+ self._daily_spent = 0.0
+ self._last_reset: Optional[date] = None
+ self._usage_count = 0
+
+ def can_use_service(self) -> bool:
+ """是否可以使用打码服务"""
+ self._check_reset()
+ return self._daily_spent < self.daily_budget
+
+ def record_usage(self):
+ """记录一次使用"""
+ self._check_reset()
+ self._daily_spent += self.cost_per_captcha
+ self._usage_count += 1
+ logger.debug(f"记录使用: 已花费 {self._daily_spent:.2f}")
+
+ def _check_reset(self):
+ """检查是否需要重置(跨天)"""
+ today = date.today()
+ if self._last_reset != today:
+ self._daily_spent = 0.0
+ self._usage_count = 0
+ self._last_reset = today
+ logger.info("成本计数器已重置")
+
+ @property
+ def remaining_budget(self) -> float:
+ """剩余预算"""
+ self._check_reset()
+ return max(0, self.daily_budget - self._daily_spent)
+
+ @property
+ def today_usage(self) -> int:
+ """今日使用次数"""
+ self._check_reset()
+ return self._usage_count
+
+ def get_stats(self) -> dict:
+ """获取统计信息"""
+ self._check_reset()
+ return {
+ "daily_budget": self.daily_budget,
+ "daily_spent": self._daily_spent,
+ "remaining": self.remaining_budget,
+ "usage_count": self._usage_count,
+ "cost_per_captcha": self.cost_per_captcha
+ }
+
+
+class CaptchaSolverWithFallback:
+ """带降级策略的验证码解决器"""
+
+ def __init__(
+ self,
+ local_service: CaptchaServiceBase = None,
+ remote_service: CaptchaServiceBase = None,
+ cost_controller: CostController = None
+ ):
+ """
+ Args:
+ local_service: 本地识别服务
+ remote_service: 远程打码服务
+ cost_controller: 成本控制器
+ """
+ self.local_service = local_service
+ self.remote_service = remote_service
+ self.cost_controller = cost_controller
+
+ async def solve(
+ self,
+ image_bytes: bytes,
+ verify_callback: Callable[[str], Awaitable[bool]] = None,
+ prefer_local: bool = True,
+ max_local_retries: int = 2
+ ) -> Optional[str]:
+ """
+ 解决验证码
+
+ Args:
+ image_bytes: 验证码图片
+ verify_callback: 验证回调(可选)
+ prefer_local: 是否优先使用本地识别
+ max_local_retries: 本地识别最大重试次数
+
+ Returns:
+ 识别结果
+ """
+ # 优先本地识别
+ if prefer_local and self.local_service:
+ for attempt in range(max_local_retries):
+ result = await self.local_service.solve_image(image_bytes)
+
+ if result:
+ # 如果有验证回调
+ if verify_callback:
+ if await verify_callback(result):
+ logger.info(f"本地识别成功 (尝试 {attempt + 1})")
+ return result
+ else:
+ return result
+
+ logger.debug(f"本地识别失败,重试 {attempt + 1}/{max_local_retries}")
+
+ # 降级到远程服务
+ if self.remote_service:
+ # 检查成本
+ if self.cost_controller and not self.cost_controller.can_use_service():
+ logger.warning("超出每日预算,无法使用打码服务")
+ return None
+
+ result = await self.remote_service.solve_image(image_bytes)
+
+ if result:
+ if self.cost_controller:
+ self.cost_controller.record_usage()
+ logger.info("远程识别成功")
+ return result
+
+ logger.error("验证码识别失败")
+ return None
+
+
+async def demo():
+ """验证码服务演示"""
+ print("=" * 50)
+ print("验证码服务演示")
+ print("=" * 50)
+
+ # 1. 本地 OCR 服务
+ print("\n1. 本地 OCR 服务:")
+ try:
+ local_service = LocalOCRService(preprocess=True)
+ print(" 本地 OCR 服务已创建")
+ print(" 用法: result = await local_service.solve_image(image_bytes)")
+ except ImportError as e:
+ print(f" 创建失败: {e}")
+
+ # 2. 成本控制器
+ print("\n2. 成本控制器演示:")
+ controller = CostController(
+ daily_budget=10.0,
+ cost_per_captcha=0.01
+ )
+ print(f" 每日预算: {controller.daily_budget}")
+ print(f" 每次成本: {controller.cost_per_captcha}")
+ print(f" 可以使用: {controller.can_use_service()}")
+
+ # 模拟使用
+ for i in range(5):
+ if controller.can_use_service():
+ controller.record_usage()
+
+ stats = controller.get_stats()
+ print(f" 使用后统计: {stats}")
+
+ # 3. 带降级的解决器
+ print("\n3. 带降级策略的解决器:")
+ print("""
+ solver = CaptchaSolverWithFallback(
+ local_service=local_service,
+ remote_service=remote_service,
+ cost_controller=cost_controller
+ )
+
+ result = await solver.solve(
+ image_bytes,
+ verify_callback=verify_func, # 可选
+ prefer_local=True,
+ max_local_retries=2
+ )
+ """)
+
+ # 4. 远程服务示例
+ print("\n4. 远程打码服务(示例配置):")
+ print("""
+ remote_service = RemoteCaptchaService(
+ api_key="your_api_key",
+ api_url="https://api.captcha-service.com",
+ timeout=30
+ )
+
+ # 识别验证码
+ result = await remote_service.solve_image(image_bytes)
+
+ # 报告错误(退款)
+ if not is_correct:
+ await remote_service.report_error()
+
+ # 查询余额
+ balance = await remote_service.get_balance()
+ """)
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ logger.remove()
+ logger.add(
+ lambda msg: print(msg, end=""),
+ format="{time:HH:mm:ss} | {level: <8} | {message}",
+ level="DEBUG"
+ )
+
+ asyncio.run(demo())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/ocr_captcha.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/ocr_captcha.py"
new file mode 100644
index 0000000..74c2580
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/ocr_captcha.py"
@@ -0,0 +1,316 @@
+# -*- coding: utf-8 -*-
+# @Desc: OCR 图片验证码识别
+
+import io
+from typing import Optional
+from loguru import logger
+
+# 可选依赖
+try:
+ import ddddocr
+ HAS_DDDDOCR = True
+except ImportError:
+ HAS_DDDDOCR = False
+ logger.warning("ddddocr 未安装,部分功能不可用。安装: pip install ddddocr")
+
+try:
+ from PIL import Image, ImageFilter, ImageEnhance
+ HAS_PIL = True
+except ImportError:
+ HAS_PIL = False
+ logger.warning("pillow 未安装,图片预处理不可用。安装: pip install pillow")
+
+
+class CaptchaPreprocessor:
+ """验证码图片预处理器"""
+
+ def __init__(self):
+ if not HAS_PIL:
+ raise ImportError("需要安装 pillow: pip install pillow")
+
+ def to_grayscale(self, image_bytes: bytes) -> bytes:
+ """
+ 转为灰度图
+
+ Args:
+ image_bytes: 图片字节
+
+ Returns:
+ 处理后的图片字节
+ """
+ img = Image.open(io.BytesIO(image_bytes))
+ gray = img.convert('L')
+
+ buffer = io.BytesIO()
+ gray.save(buffer, format='PNG')
+ return buffer.getvalue()
+
+ def binarize(self, image_bytes: bytes, threshold: int = 127) -> bytes:
+ """
+ 二值化
+
+ Args:
+ image_bytes: 图片字节
+ threshold: 阈值 (0-255),小于阈值变黑,大于变白
+
+ Returns:
+ 处理后的图片字节
+ """
+ img = Image.open(io.BytesIO(image_bytes))
+ gray = img.convert('L')
+
+ # 二值化
+ binary = gray.point(lambda x: 255 if x > threshold else 0)
+
+ buffer = io.BytesIO()
+ binary.save(buffer, format='PNG')
+ return buffer.getvalue()
+
+ def remove_noise(self, image_bytes: bytes, size: int = 3) -> bytes:
+ """
+ 去噪点(中值滤波)
+
+ Args:
+ image_bytes: 图片字节
+ size: 滤波器大小
+
+ Returns:
+ 处理后的图片字节
+ """
+ img = Image.open(io.BytesIO(image_bytes))
+ denoised = img.filter(ImageFilter.MedianFilter(size=size))
+
+ buffer = io.BytesIO()
+ denoised.save(buffer, format='PNG')
+ return buffer.getvalue()
+
+ def enhance_contrast(self, image_bytes: bytes, factor: float = 2.0) -> bytes:
+ """
+ 增强对比度
+
+ Args:
+ image_bytes: 图片字节
+ factor: 对比度因子,>1 增强,<1 降低
+
+ Returns:
+ 处理后的图片字节
+ """
+ img = Image.open(io.BytesIO(image_bytes))
+ enhancer = ImageEnhance.Contrast(img)
+ enhanced = enhancer.enhance(factor)
+
+ buffer = io.BytesIO()
+ enhanced.save(buffer, format='PNG')
+ return buffer.getvalue()
+
+ def enhance_sharpness(self, image_bytes: bytes, factor: float = 2.0) -> bytes:
+ """
+ 增强锐度
+
+ Args:
+ image_bytes: 图片字节
+ factor: 锐度因子
+
+ Returns:
+ 处理后的图片字节
+ """
+ img = Image.open(io.BytesIO(image_bytes))
+ enhancer = ImageEnhance.Sharpness(img)
+ enhanced = enhancer.enhance(factor)
+
+ buffer = io.BytesIO()
+ enhanced.save(buffer, format='PNG')
+ return buffer.getvalue()
+
+ def resize(self, image_bytes: bytes, scale: float = 2.0) -> bytes:
+ """
+ 缩放图片
+
+ Args:
+ image_bytes: 图片字节
+ scale: 缩放比例
+
+ Returns:
+ 处理后的图片字节
+ """
+ img = Image.open(io.BytesIO(image_bytes))
+ new_size = (int(img.width * scale), int(img.height * scale))
+ resized = img.resize(new_size, Image.Resampling.LANCZOS)
+
+ buffer = io.BytesIO()
+ resized.save(buffer, format='PNG')
+ return buffer.getvalue()
+
+ def full_preprocess(
+ self,
+ image_bytes: bytes,
+ threshold: int = 150
+ ) -> bytes:
+ """
+ 完整预处理流程
+
+ Args:
+ image_bytes: 原始图片
+ threshold: 二值化阈值
+
+ Returns:
+ 处理后的图片
+ """
+ # 灰度化
+ processed = self.to_grayscale(image_bytes)
+ # 增强对比度
+ processed = self.enhance_contrast(processed, factor=1.5)
+ # 去噪
+ processed = self.remove_noise(processed)
+ # 二值化
+ processed = self.binarize(processed, threshold)
+
+ return processed
+
+
+class OCRCaptchaSolver:
+ """OCR 验证码识别器"""
+
+ def __init__(self, preprocess: bool = True, show_ad: bool = False):
+ """
+ Args:
+ preprocess: 是否进行预处理
+ show_ad: 是否显示 ddddocr 广告
+ """
+ if not HAS_DDDDOCR:
+ raise ImportError("需要安装 ddddocr: pip install ddddocr")
+
+ self.ocr = ddddocr.DdddOcr(show_ad=show_ad)
+ self.preprocess = preprocess
+
+ if preprocess and HAS_PIL:
+ self.preprocessor = CaptchaPreprocessor()
+ else:
+ self.preprocessor = None
+
+ def solve(self, image_bytes: bytes) -> Optional[str]:
+ """
+ 识别验证码
+
+ Args:
+ image_bytes: 图片字节
+
+ Returns:
+ 识别结果
+ """
+ try:
+ # 预处理
+ if self.preprocess and self.preprocessor:
+ image_bytes = self.preprocessor.full_preprocess(image_bytes)
+
+ # 识别
+ result = self.ocr.classification(image_bytes)
+ logger.debug(f"验证码识别结果: {result}")
+ return result
+
+ except Exception as e:
+ logger.error(f"验证码识别失败: {e}")
+ return None
+
+ def solve_raw(self, image_bytes: bytes) -> Optional[str]:
+ """
+ 不预处理直接识别
+
+ Args:
+ image_bytes: 图片字节
+
+ Returns:
+ 识别结果
+ """
+ try:
+ return self.ocr.classification(image_bytes)
+ except Exception as e:
+ logger.error(f"验证码识别失败: {e}")
+ return None
+
+ def solve_with_multiple_thresholds(
+ self,
+ image_bytes: bytes,
+ thresholds: list = None,
+ min_length: int = 4
+ ) -> Optional[str]:
+ """
+ 尝试不同阈值识别
+
+ Args:
+ image_bytes: 图片字节
+ thresholds: 要尝试的阈值列表
+ min_length: 最小结果长度
+
+ Returns:
+ 最佳识别结果
+ """
+ if not self.preprocessor:
+ return self.solve_raw(image_bytes)
+
+ thresholds = thresholds or [100, 127, 150, 180]
+ best_result = None
+ best_confidence = 0
+
+ for threshold in thresholds:
+ try:
+ processed = self.preprocessor.full_preprocess(
+ image_bytes,
+ threshold=threshold
+ )
+ result = self.ocr.classification(processed)
+
+ if result and len(result) >= min_length:
+ # 简单的置信度评估:结果长度和字符类型
+ confidence = len(result)
+ if result.isalnum():
+ confidence += 2
+
+ if confidence > best_confidence:
+ best_confidence = confidence
+ best_result = result
+
+ except Exception as e:
+ logger.debug(f"阈值 {threshold} 识别失败: {e}")
+ continue
+
+ return best_result
+
+
+def demo():
+ """演示 OCR 验证码识别"""
+ print("=" * 50)
+ print("OCR 验证码识别演示")
+ print("=" * 50)
+
+ if not HAS_DDDDOCR:
+ print("请先安装 ddddocr: pip install ddddocr")
+ return
+
+ # 创建识别器
+ solver = OCRCaptchaSolver(preprocess=True, show_ad=False)
+
+ # 演示:使用测试图片
+ # 实际使用时,从网页获取验证码图片
+ print("\n1. 基本识别演示:")
+ print(" 实际使用时,传入验证码图片的字节数据")
+ print(" 示例: result = solver.solve(image_bytes)")
+
+ # 演示预处理器
+ if HAS_PIL:
+ print("\n2. 图片预处理演示:")
+ preprocessor = CaptchaPreprocessor()
+ print(" 支持的预处理方法:")
+ print(" - to_grayscale(): 转灰度图")
+ print(" - binarize(): 二值化")
+ print(" - remove_noise(): 去噪点")
+ print(" - enhance_contrast(): 增强对比度")
+ print(" - full_preprocess(): 完整预处理流程")
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ demo()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/pyproject.toml" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/pyproject.toml"
new file mode 100644
index 0000000..6a0f9cb
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/pyproject.toml"
@@ -0,0 +1,37 @@
+[project]
+name = "chapter08-captcha-recognition"
+version = "0.1.0"
+description = "第08章:验证码识别与处理 - 图片验证码OCR、滑块验证码、轨迹生成"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ "loguru>=0.7.0",
+ "pillow>=10.0.0",
+]
+
+[project.optional-dependencies]
+ocr = [
+ "ddddocr>=1.4.0", # OCR识别库
+]
+cv = [
+ "opencv-python>=4.9.0", # 图像处理(滑块验证码)
+]
+generate = [
+ "captcha>=0.5.0", # 本地验证码生成
+]
+viz = [
+ "matplotlib>=3.8.0", # 轨迹可视化
+]
+all = [
+ "ddddocr>=1.4.0",
+ "opencv-python>=4.9.0",
+ "captcha>=0.5.0",
+ "matplotlib>=3.8.0",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.uv]
+dev-dependencies = []
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/slider_captcha.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/slider_captcha.py"
new file mode 100644
index 0000000..27d791c
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/08_\351\252\214\350\257\201\347\240\201\350\257\206\345\210\253\344\270\216\345\244\204\347\220\206/slider_captcha.py"
@@ -0,0 +1,472 @@
+# -*- coding: utf-8 -*-
+# @Desc: 滑块验证码处理
+
+import asyncio
+import random
+import math
+from typing import Optional, List, Tuple
+from loguru import logger
+
+# 可选依赖
+try:
+ import cv2
+ import numpy as np
+ HAS_CV2 = True
+except ImportError:
+ HAS_CV2 = False
+ logger.warning("opencv-python 未安装,缺口检测不可用。安装: pip install opencv-python")
+
+
+class SliderGapDetector:
+ """滑块缺口位置检测器"""
+
+ def __init__(self):
+ if not HAS_CV2:
+ raise ImportError("需要安装 opencv-python: pip install opencv-python")
+
+ def detect_by_template_match(
+ self,
+ background_bytes: bytes,
+ slider_bytes: bytes
+ ) -> Optional[int]:
+ """
+ 通过模板匹配找缺口位置
+
+ Args:
+ background_bytes: 背景图片字节
+ slider_bytes: 滑块图片字节
+
+ Returns:
+ 缺口 x 坐标
+ """
+ # 读取图片
+ bg = cv2.imdecode(
+ np.frombuffer(background_bytes, np.uint8),
+ cv2.IMREAD_COLOR
+ )
+ slider = cv2.imdecode(
+ np.frombuffer(slider_bytes, np.uint8),
+ cv2.IMREAD_COLOR
+ )
+
+ # 转灰度
+ bg_gray = cv2.cvtColor(bg, cv2.COLOR_BGR2GRAY)
+ slider_gray = cv2.cvtColor(slider, cv2.COLOR_BGR2GRAY)
+
+ # 边缘检测
+ bg_edges = cv2.Canny(bg_gray, 100, 200)
+ slider_edges = cv2.Canny(slider_gray, 100, 200)
+
+ # 模板匹配
+ result = cv2.matchTemplate(
+ bg_edges,
+ slider_edges,
+ cv2.TM_CCOEFF_NORMED
+ )
+ _, _, _, max_loc = cv2.minMaxLoc(result)
+
+ gap_x = max_loc[0]
+ logger.debug(f"模板匹配检测到缺口位置: x={gap_x}")
+ return gap_x
+
+ def detect_by_contour(
+ self,
+ background_bytes: bytes,
+ min_area: int = 1000
+ ) -> Optional[int]:
+ """
+ 通过轮廓检测找缺口位置
+
+ 某些滑块验证码的缺口有明显的轮廓
+
+ Args:
+ background_bytes: 背景图片
+ min_area: 最小轮廓面积
+
+ Returns:
+ 缺口 x 坐标
+ """
+ bg = cv2.imdecode(
+ np.frombuffer(background_bytes, np.uint8),
+ cv2.IMREAD_COLOR
+ )
+
+ # 转灰度
+ gray = cv2.cvtColor(bg, cv2.COLOR_BGR2GRAY)
+
+ # 高斯模糊
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
+
+ # 边缘检测
+ edges = cv2.Canny(blurred, 50, 150)
+
+ # 找轮廓
+ contours, _ = cv2.findContours(
+ edges,
+ cv2.RETR_EXTERNAL,
+ cv2.CHAIN_APPROX_SIMPLE
+ )
+
+ # 筛选合适的轮廓
+ candidates = []
+ for contour in contours:
+ area = cv2.contourArea(contour)
+ if area > min_area:
+ x, y, w, h = cv2.boundingRect(contour)
+ # 缺口通常在图片中间偏右
+ if x > bg.shape[1] * 0.2:
+ candidates.append((x, area))
+
+ if candidates:
+ # 选择面积最大的
+ candidates.sort(key=lambda c: c[1], reverse=True)
+ return candidates[0][0]
+
+ return None
+
+ def detect_by_color_diff(
+ self,
+ background_bytes: bytes,
+ threshold: int = 50
+ ) -> Optional[int]:
+ """
+ 通过颜色差异找缺口
+
+ 缺口区域通常颜色较暗
+
+ Args:
+ background_bytes: 背景图片
+ threshold: 颜色阈值
+
+ Returns:
+ 缺口 x 坐标
+ """
+ bg = cv2.imdecode(
+ np.frombuffer(background_bytes, np.uint8),
+ cv2.IMREAD_COLOR
+ )
+
+ # 转灰度
+ gray = cv2.cvtColor(bg, cv2.COLOR_BGR2GRAY)
+
+ # 二值化(找暗色区域)
+ _, binary = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY_INV)
+
+ # 找轮廓
+ contours, _ = cv2.findContours(
+ binary,
+ cv2.RETR_EXTERNAL,
+ cv2.CHAIN_APPROX_SIMPLE
+ )
+
+ if contours:
+ # 找最大轮廓
+ largest = max(contours, key=cv2.contourArea)
+ x, y, w, h = cv2.boundingRect(largest)
+ return x
+
+ return None
+
+
+class HumanTrajectoryGenerator:
+ """人类轨迹生成器"""
+
+ @staticmethod
+ def generate_linear(
+ distance: int,
+ duration: float = 0.5
+ ) -> List[Tuple[int, int, float]]:
+ """
+ 生成线性轨迹(简单但容易被检测)
+
+ Args:
+ distance: 移动距离
+ duration: 持续时间
+
+ Returns:
+ 轨迹点列表 [(x, y, time), ...]
+ """
+ trajectory = []
+ steps = 20
+ step_time = duration / steps
+
+ for i in range(steps + 1):
+ progress = i / steps
+ x = int(distance * progress)
+ y = 0
+ t = step_time * i
+ trajectory.append((x, y, t))
+
+ return trajectory
+
+ @staticmethod
+ def generate_ease_out(
+ distance: int,
+ duration: float = 0.5
+ ) -> List[Tuple[int, int, float]]:
+ """
+ 生成缓出轨迹(先快后慢)
+
+ Args:
+ distance: 移动距离
+ duration: 持续时间
+
+ Returns:
+ 轨迹点列表
+ """
+ trajectory = []
+ steps = random.randint(25, 35)
+ step_time = duration / steps
+
+ for i in range(steps + 1):
+ t = i / steps
+ # 二次缓出
+ eased = t * (2 - t)
+
+ x = int(distance * eased)
+ y = random.randint(-2, 2) # 小幅度 Y 抖动
+ time_point = step_time * i + random.uniform(-0.005, 0.005)
+
+ trajectory.append((x, y, max(0, time_point)))
+
+ return trajectory
+
+ @staticmethod
+ def generate_bezier(
+ distance: int,
+ duration: float = 0.5
+ ) -> List[Tuple[int, int, float]]:
+ """
+ 使用贝塞尔曲线生成自然轨迹
+
+ Args:
+ distance: 移动距离
+ duration: 持续时间
+
+ Returns:
+ 轨迹点列表
+ """
+ trajectory = []
+
+ # 控制点(随机生成更自然)
+ p0 = (0, 0)
+ p1 = (distance * random.uniform(0.2, 0.4), random.randint(-15, 15))
+ p2 = (distance * random.uniform(0.6, 0.8), random.randint(-8, 8))
+ p3 = (distance, 0)
+
+ steps = random.randint(30, 45)
+
+ for i in range(steps + 1):
+ t = i / steps
+
+ # 三阶贝塞尔曲线
+ x = (
+ (1-t)**3 * p0[0] +
+ 3*(1-t)**2*t * p1[0] +
+ 3*(1-t)*t**2 * p2[0] +
+ t**3 * p3[0]
+ )
+ y = (
+ (1-t)**3 * p0[1] +
+ 3*(1-t)**2*t * p1[1] +
+ 3*(1-t)*t**2 * p2[1] +
+ t**3 * p3[1]
+ )
+
+ # 时间加随机偏移
+ time_point = duration * t + random.uniform(-0.003, 0.003)
+ trajectory.append((int(x), int(y), max(0, time_point)))
+
+ return trajectory
+
+ @staticmethod
+ def generate_human_like(
+ distance: int,
+ duration: float = 0.5
+ ) -> List[Tuple[int, int, float]]:
+ """
+ 生成模拟人类的轨迹(综合多种特征)
+
+ 包含:加速、微调、抖动等人类特征
+
+ Args:
+ distance: 移动距离
+ duration: 持续时间
+
+ Returns:
+ 轨迹点列表
+ """
+ trajectory = []
+
+ # 分阶段:加速 -> 匀速 -> 减速 -> 微调
+ phases = [
+ (0.3, 0.2), # 加速阶段
+ (0.5, 0.4), # 匀速阶段
+ (0.15, 0.3), # 减速阶段
+ (0.05, 0.1), # 微调阶段
+ ]
+
+ current_x = 0
+ current_time = 0
+
+ for phase_distance_ratio, phase_duration_ratio in phases:
+ phase_distance = distance * phase_distance_ratio
+ phase_duration = duration * phase_duration_ratio
+ phase_steps = random.randint(5, 10)
+ step_time = phase_duration / phase_steps
+
+ for i in range(phase_steps):
+ progress = (i + 1) / phase_steps
+ x = current_x + int(phase_distance * progress)
+ y = random.randint(-3, 3)
+ t = current_time + step_time * (i + 1) + random.uniform(-0.002, 0.002)
+
+ trajectory.append((x, y, max(0, t)))
+
+ current_x += int(phase_distance)
+ current_time += phase_duration
+
+ # 确保最后到达目标
+ trajectory.append((distance, 0, duration))
+
+ return trajectory
+
+
+class SliderCaptchaSolver:
+ """滑块验证码解决器(需要配合 Playwright 使用)"""
+
+ def __init__(self):
+ if HAS_CV2:
+ self.gap_detector = SliderGapDetector()
+ else:
+ self.gap_detector = None
+
+ self.trajectory_generator = HumanTrajectoryGenerator()
+
+ def detect_gap(
+ self,
+ background_bytes: bytes,
+ slider_bytes: bytes = None
+ ) -> Optional[int]:
+ """
+ 检测缺口位置
+
+ Args:
+ background_bytes: 背景图片
+ slider_bytes: 滑块图片(可选)
+
+ Returns:
+ 缺口 x 坐标
+ """
+ if not self.gap_detector:
+ logger.error("缺口检测需要 opencv-python")
+ return None
+
+ if slider_bytes:
+ return self.gap_detector.detect_by_template_match(
+ background_bytes,
+ slider_bytes
+ )
+ else:
+ return self.gap_detector.detect_by_contour(background_bytes)
+
+ def generate_trajectory(
+ self,
+ distance: int,
+ style: str = "human"
+ ) -> List[Tuple[int, int, float]]:
+ """
+ 生成拖拽轨迹
+
+ Args:
+ distance: 拖拽距离
+ style: 轨迹风格 (linear/ease_out/bezier/human)
+
+ Returns:
+ 轨迹点列表
+ """
+ generators = {
+ "linear": self.trajectory_generator.generate_linear,
+ "ease_out": self.trajectory_generator.generate_ease_out,
+ "bezier": self.trajectory_generator.generate_bezier,
+ "human": self.trajectory_generator.generate_human_like
+ }
+
+ generator = generators.get(style, self.trajectory_generator.generate_human_like)
+ return generator(distance)
+
+
+async def demo():
+ """滑块验证码处理演示"""
+ print("=" * 50)
+ print("滑块验证码处理演示")
+ print("=" * 50)
+
+ # 演示轨迹生成
+ print("\n1. 轨迹生成演示:")
+ generator = HumanTrajectoryGenerator()
+
+ distance = 200 # 200像素
+
+ styles = {
+ "linear": generator.generate_linear,
+ "ease_out": generator.generate_ease_out,
+ "bezier": generator.generate_bezier,
+ "human": generator.generate_human_like
+ }
+
+ for style_name, style_func in styles.items():
+ trajectory = style_func(distance)
+ print(f"\n {style_name} 轨迹:")
+ print(f" - 点数: {len(trajectory)}")
+ print(f" - 起点: {trajectory[0]}")
+ print(f" - 终点: {trajectory[-1]}")
+
+ # 演示缺口检测
+ if HAS_CV2:
+ print("\n2. 缺口检测演示:")
+ print(" 支持的检测方法:")
+ print(" - detect_by_template_match(): 模板匹配")
+ print(" - detect_by_contour(): 轮廓检测")
+ print(" - detect_by_color_diff(): 颜色差异检测")
+ else:
+ print("\n2. 缺口检测需要安装 opencv-python:")
+ print(" pip install opencv-python")
+
+ # 演示完整流程
+ print("\n3. 完整使用流程:")
+ print("""
+ # 1. 获取背景和滑块图片
+ bg_bytes = await page.locator("#bg-image").screenshot()
+ slider_bytes = await page.locator("#slider-image").screenshot()
+
+ # 2. 检测缺口位置
+ solver = SliderCaptchaSolver()
+ gap_x = solver.detect_gap(bg_bytes, slider_bytes)
+
+ # 3. 生成轨迹
+ trajectory = solver.generate_trajectory(gap_x, style="human")
+
+ # 4. 执行拖拽(Playwright)
+ slider = page.locator("#slider-btn")
+ box = await slider.bounding_box()
+ start_x = box['x'] + box['width'] / 2
+ start_y = box['y'] + box['height'] / 2
+
+ await page.mouse.move(start_x, start_y)
+ await page.mouse.down()
+
+ for x, y, t in trajectory:
+ await asyncio.sleep(t - last_t)
+ await page.mouse.move(start_x + x, start_y + y)
+
+ await page.mouse.up()
+ """)
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ asyncio.run(demo())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/README.md" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/README.md"
new file mode 100644
index 0000000..8e9c4f2
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/README.md"
@@ -0,0 +1,42 @@
+# 第09章:数据清洗与预处理
+
+展示文本清洗、数据标准化、去重算法等数据处理技术。
+
+## 快速开始
+
+```bash
+cd 09_数据清洗与预处理
+
+# 安装依赖(本章主要使用Python标准库)
+uv sync
+
+# 安装SimHash去重功能(可选)
+uv sync --extra simhash
+
+# 运行示例
+uv run python text_cleaner.py
+uv run python data_normalizer.py
+uv run python deduplication.py
+```
+
+### 主要功能
+
+- **文本清洗**
+ - HTML标签移除
+ - 空白字符处理
+ - 特殊字符清理
+ - 编码问题修复
+
+- **数据标准化**
+ - 日期时间格式统一
+ - 数值单位换算
+ - 文本规范化
+
+- **去重算法**
+ - 精确去重
+ - 模糊去重(编辑距离、Jaccard相似度)
+ - SimHash去重(大规模文本)
+
+### 依赖说明
+
+本章主要使用Python标准库,`simhash` 为可选依赖,用于大规模文本去重。
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/data_normalizer.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/data_normalizer.py"
new file mode 100644
index 0000000..8a1e4a0
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/data_normalizer.py"
@@ -0,0 +1,480 @@
+# -*- coding: utf-8 -*-
+# @Desc: 数据标准化工具
+
+import re
+from datetime import datetime, timedelta
+from typing import Optional, List, Dict, Any
+from loguru import logger
+
+
+class DateTimeNormalizer:
+ """日期时间标准化器"""
+
+ # 常见日期格式
+ DATE_FORMATS = [
+ '%Y-%m-%d',
+ '%Y/%m/%d',
+ '%Y.%m.%d',
+ '%Y年%m月%d日',
+ '%d-%m-%Y',
+ '%d/%m/%Y',
+ '%m-%d-%Y',
+ '%m/%d/%Y',
+ ]
+
+ # 常见日期时间格式
+ DATETIME_FORMATS = [
+ '%Y-%m-%d %H:%M:%S',
+ '%Y-%m-%d %H:%M',
+ '%Y/%m/%d %H:%M:%S',
+ '%Y/%m/%d %H:%M',
+ '%Y年%m月%d日 %H:%M:%S',
+ '%Y年%m月%d日 %H:%M',
+ '%Y年%m月%d日 %H时%M分',
+ '%Y年%m月%d日 %H时%M分%S秒',
+ '%d/%m/%Y %H:%M:%S',
+ ]
+
+ @classmethod
+ def parse(cls, text: str) -> Optional[datetime]:
+ """
+ 解析日期时间字符串
+
+ Args:
+ text: 日期时间字符串
+
+ Returns:
+ datetime 对象,解析失败返回 None
+ """
+ text = text.strip()
+
+ # 先尝试完整日期时间格式
+ for fmt in cls.DATETIME_FORMATS:
+ try:
+ return datetime.strptime(text, fmt)
+ except ValueError:
+ continue
+
+ # 再尝试日期格式
+ for fmt in cls.DATE_FORMATS:
+ try:
+ return datetime.strptime(text, fmt)
+ except ValueError:
+ continue
+
+ # 尝试解析相对时间
+ relative = cls.parse_relative(text)
+ if relative:
+ return relative
+
+ return None
+
+ @classmethod
+ def parse_relative(cls, text: str) -> Optional[datetime]:
+ """
+ 解析相对时间(如"3小时前")
+
+ Args:
+ text: 相对时间字符串
+
+ Returns:
+ datetime 对象
+ """
+ now = datetime.now()
+ text = text.strip()
+
+ patterns = [
+ (r'(\d+)\s*秒前', lambda m: now - timedelta(seconds=int(m.group(1)))),
+ (r'(\d+)\s*分钟前', lambda m: now - timedelta(minutes=int(m.group(1)))),
+ (r'(\d+)\s*小时前', lambda m: now - timedelta(hours=int(m.group(1)))),
+ (r'(\d+)\s*天前', lambda m: now - timedelta(days=int(m.group(1)))),
+ (r'(\d+)\s*周前', lambda m: now - timedelta(weeks=int(m.group(1)))),
+ (r'(\d+)\s*月前', lambda m: now - timedelta(days=int(m.group(1)) * 30)),
+ (r'(\d+)\s*年前', lambda m: now - timedelta(days=int(m.group(1)) * 365)),
+ (r'刚刚', lambda m: now),
+ (r'刚才', lambda m: now - timedelta(minutes=1)),
+ (r'昨天', lambda m: now - timedelta(days=1)),
+ (r'前天', lambda m: now - timedelta(days=2)),
+ (r'上周', lambda m: now - timedelta(weeks=1)),
+ (r'上个月', lambda m: now - timedelta(days=30)),
+ (r'去年', lambda m: now - timedelta(days=365)),
+ ]
+
+ for pattern, handler in patterns:
+ match = re.search(pattern, text)
+ if match:
+ return handler(match)
+
+ return None
+
+ @classmethod
+ def normalize(
+ cls,
+ text: str,
+ output_format: str = '%Y-%m-%d %H:%M:%S'
+ ) -> str:
+ """
+ 标准化日期时间格式
+
+ Args:
+ text: 日期时间字符串
+ output_format: 输出格式
+
+ Returns:
+ 标准化后的字符串,解析失败返回原字符串
+ """
+ dt = cls.parse(text)
+ if dt:
+ return dt.strftime(output_format)
+ return text
+
+ @classmethod
+ def normalize_date(cls, text: str) -> str:
+ """标准化为日期格式 (YYYY-MM-DD)"""
+ return cls.normalize(text, '%Y-%m-%d')
+
+ @classmethod
+ def normalize_datetime(cls, text: str) -> str:
+ """标准化为日期时间格式 (YYYY-MM-DD HH:MM:SS)"""
+ return cls.normalize(text, '%Y-%m-%d %H:%M:%S')
+
+
+class NumberNormalizer:
+ """数值标准化器"""
+
+ # 中文数字单位
+ CHINESE_UNITS = {
+ '万': 10000,
+ '亿': 100000000,
+ '兆': 1000000000000,
+ }
+
+ # 英文数字单位
+ ENGLISH_UNITS = {
+ 'k': 1000,
+ 'K': 1000,
+ 'm': 1000000,
+ 'M': 1000000,
+ 'b': 1000000000,
+ 'B': 1000000000,
+ }
+
+ @classmethod
+ def parse(cls, text: str) -> float:
+ """
+ 解析数字字符串
+
+ 支持:
+ - 逗号分隔:1,234,567
+ - 中文单位:1.5万、3.2亿
+ - 英文单位:1.5K、3.2M
+
+ Args:
+ text: 数字字符串
+
+ Returns:
+ 数值
+ """
+ if not text:
+ return 0.0
+
+ text = str(text).strip()
+
+ # 检查单位
+ multiplier = 1
+
+ # 中文单位
+ for unit, value in cls.CHINESE_UNITS.items():
+ if unit in text:
+ multiplier = value
+ text = text.replace(unit, '')
+ break
+
+ # 英文单位
+ for unit, value in cls.ENGLISH_UNITS.items():
+ if unit in text:
+ multiplier = value
+ text = text.replace(unit, '')
+ break
+
+ # 移除货币符号
+ text = re.sub(r'[¥$¥€£]', '', text)
+
+ # 移除逗号
+ text = text.replace(',', '')
+
+ # 移除空格
+ text = text.replace(' ', '')
+
+ # 提取数字
+ match = re.search(r'-?\d+\.?\d*', text)
+ if match:
+ return float(match.group()) * multiplier
+
+ return 0.0
+
+ @classmethod
+ def format(
+ cls,
+ value: float,
+ precision: int = 2,
+ use_units: bool = True,
+ lang: str = 'zh'
+ ) -> str:
+ """
+ 格式化数字
+
+ Args:
+ value: 数值
+ precision: 小数位数
+ use_units: 是否使用单位
+ lang: 语言 (zh/en)
+
+ Returns:
+ 格式化后的字符串
+ """
+ if not use_units:
+ return f'{value:.{precision}f}'
+
+ if lang == 'zh':
+ if abs(value) >= 100000000:
+ return f'{value/100000000:.{precision}f}亿'
+ elif abs(value) >= 10000:
+ return f'{value/10000:.{precision}f}万'
+ else:
+ if abs(value) >= 1000000000:
+ return f'{value/1000000000:.{precision}f}B'
+ elif abs(value) >= 1000000:
+ return f'{value/1000000:.{precision}f}M'
+ elif abs(value) >= 1000:
+ return f'{value/1000:.{precision}f}K'
+
+ return f'{value:.{precision}f}'
+
+ @classmethod
+ def format_with_comma(cls, value: float, precision: int = 0) -> str:
+ """
+ 使用逗号分隔的格式
+
+ Args:
+ value: 数值
+ precision: 小数位数
+
+ Returns:
+ 逗号分隔的数字字符串
+ """
+ if precision == 0:
+ return f'{int(value):,}'
+ return f'{value:,.{precision}f}'
+
+
+class TextNormalizer:
+ """文本标准化器"""
+
+ @staticmethod
+ def normalize_case(text: str, case: str = 'lower') -> str:
+ """
+ 标准化大小写
+
+ Args:
+ text: 输入文本
+ case: 大小写类型 (lower/upper/title/capitalize)
+
+ Returns:
+ 标准化后的文本
+ """
+ if case == 'lower':
+ return text.lower()
+ elif case == 'upper':
+ return text.upper()
+ elif case == 'title':
+ return text.title()
+ elif case == 'capitalize':
+ return text.capitalize()
+ return text
+
+ @staticmethod
+ def normalize_punctuation(text: str, style: str = 'english') -> str:
+ """
+ 标准化标点符号
+
+ Args:
+ text: 输入文本
+ style: 标点风格 (chinese/english)
+
+ Returns:
+ 标准化后的文本
+ """
+ if style == 'english':
+ # 中文标点转英文
+ mapping = {
+ ',': ', ',
+ '。': '. ',
+ '!': '! ',
+ '?': '? ',
+ ';': '; ',
+ ':': ': ',
+ '"': '"',
+ '"': '"',
+ ''': "'",
+ ''': "'",
+ '(': '(',
+ ')': ')',
+ '【': '[',
+ '】': ']',
+ }
+ else:
+ # 英文标点转中文
+ mapping = {
+ ',': ',',
+ '.': '。',
+ '!': '!',
+ '?': '?',
+ ';': ';',
+ ':': ':',
+ '"': '"',
+ "'": "'",
+ '(': '(',
+ ')': ')',
+ '[': '【',
+ ']': '】',
+ }
+
+ for old, new in mapping.items():
+ text = text.replace(old, new)
+
+ return text
+
+
+class DataNormalizer:
+ """综合数据标准化器"""
+
+ def __init__(self):
+ self.date_normalizer = DateTimeNormalizer()
+ self.number_normalizer = NumberNormalizer()
+ self.text_normalizer = TextNormalizer()
+
+ def normalize_record(
+ self,
+ record: Dict[str, Any],
+ date_fields: List[str] = None,
+ number_fields: List[str] = None,
+ text_fields: List[str] = None
+ ) -> Dict[str, Any]:
+ """
+ 标准化数据记录
+
+ Args:
+ record: 数据记录
+ date_fields: 需要标准化的日期字段
+ number_fields: 需要标准化的数值字段
+ text_fields: 需要标准化的文本字段
+
+ Returns:
+ 标准化后的记录
+ """
+ result = record.copy()
+
+ # 日期标准化
+ if date_fields:
+ for field in date_fields:
+ if field in result and result[field]:
+ result[field] = DateTimeNormalizer.normalize_datetime(
+ str(result[field])
+ )
+
+ # 数值标准化
+ if number_fields:
+ for field in number_fields:
+ if field in result:
+ result[f'{field}_normalized'] = NumberNormalizer.parse(
+ str(result[field])
+ )
+
+ # 文本标准化
+ if text_fields:
+ for field in text_fields:
+ if field in result and result[field]:
+ result[field] = TextNormalizer.normalize_case(
+ str(result[field]).strip()
+ )
+
+ return result
+
+ def normalize_batch(
+ self,
+ records: List[Dict],
+ **kwargs
+ ) -> List[Dict]:
+ """批量标准化"""
+ return [self.normalize_record(r, **kwargs) for r in records]
+
+
+def demo():
+ """数据标准化演示"""
+ print("=" * 50)
+ print("数据标准化工具演示")
+ print("=" * 50)
+
+ # 1. 日期标准化
+ print("\n1. 日期时间标准化:")
+ date_tests = [
+ "2024年1月15日",
+ "2024/01/15",
+ "15-01-2024",
+ "3小时前",
+ "昨天",
+ ]
+ for dt in date_tests:
+ normalized = DateTimeNormalizer.normalize_datetime(dt)
+ print(f" '{dt}' -> '{normalized}'")
+
+ # 2. 数值标准化
+ print("\n2. 数值标准化:")
+ number_tests = [
+ "1,234,567",
+ "1.5万",
+ "3.2亿",
+ "1.5K",
+ "¥99.00",
+ "2.5M"
+ ]
+ for num in number_tests:
+ parsed = NumberNormalizer.parse(num)
+ formatted = NumberNormalizer.format(parsed)
+ print(f" '{num}' -> {parsed} -> '{formatted}'")
+
+ # 3. 文本标准化
+ print("\n3. 文本标准化:")
+ text = "HELLO world"
+ print(f" 原始: '{text}'")
+ print(f" lower: '{TextNormalizer.normalize_case(text, 'lower')}'")
+ print(f" upper: '{TextNormalizer.normalize_case(text, 'upper')}'")
+ print(f" title: '{TextNormalizer.normalize_case(text, 'title')}'")
+
+ # 4. 综合标准化
+ print("\n4. 综合数据标准化:")
+ test_record = {
+ "title": " Python 教程 ",
+ "date": "2024年1月15日",
+ "views": "1.5万",
+ "price": "¥99.00"
+ }
+ print(f" 原始: {test_record}")
+
+ normalizer = DataNormalizer()
+ normalized = normalizer.normalize_record(
+ test_record,
+ date_fields=["date"],
+ number_fields=["views", "price"],
+ text_fields=["title"]
+ )
+ print(f" 标准化: {normalized}")
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ demo()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/deduplication.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/deduplication.py"
new file mode 100644
index 0000000..c07247b
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/deduplication.py"
@@ -0,0 +1,458 @@
+# -*- coding: utf-8 -*-
+# @Desc: 数据去重工具
+
+import hashlib
+from typing import List, Dict, Any, Callable, Set
+from loguru import logger
+
+
+class ExactDeduplicator:
+ """精确去重器"""
+
+ @staticmethod
+ def dedupe_list(items: List[str]) -> List[str]:
+ """
+ 列表去重(保持顺序)
+
+ Args:
+ items: 字符串列表
+
+ Returns:
+ 去重后的列表
+ """
+ seen: Set[str] = set()
+ result = []
+ for item in items:
+ if item not in seen:
+ seen.add(item)
+ result.append(item)
+ return result
+
+ @staticmethod
+ def dedupe_dicts_by_field(
+ items: List[Dict],
+ key_field: str
+ ) -> List[Dict]:
+ """
+ 根据单个字段去重
+
+ Args:
+ items: 字典列表
+ key_field: 用于去重的字段名
+
+ Returns:
+ 去重后的列表
+ """
+ seen: Set[Any] = set()
+ result = []
+ for item in items:
+ key = item.get(key_field)
+ if key not in seen:
+ seen.add(key)
+ result.append(item)
+ return result
+
+ @staticmethod
+ def dedupe_by_hash(
+ items: List[Dict],
+ fields: List[str]
+ ) -> List[Dict]:
+ """
+ 根据多个字段计算哈希去重
+
+ Args:
+ items: 数据列表
+ fields: 用于计算哈希的字段列表
+
+ Returns:
+ 去重后的列表
+ """
+ seen: Set[str] = set()
+ result = []
+
+ for item in items:
+ # 构建哈希键
+ key_parts = [str(item.get(f, '')) for f in fields]
+ key_str = '|'.join(key_parts)
+ key_hash = hashlib.md5(key_str.encode()).hexdigest()
+
+ if key_hash not in seen:
+ seen.add(key_hash)
+ result.append(item)
+
+ return result
+
+ @staticmethod
+ def dedupe_by_callback(
+ items: List[Any],
+ key_func: Callable[[Any], Any]
+ ) -> List[Any]:
+ """
+ 使用自定义函数生成键进行去重
+
+ Args:
+ items: 数据列表
+ key_func: 生成去重键的函数
+
+ Returns:
+ 去重后的列表
+ """
+ seen: Set[Any] = set()
+ result = []
+
+ for item in items:
+ key = key_func(item)
+ if key not in seen:
+ seen.add(key)
+ result.append(item)
+
+ return result
+
+
+class FuzzyDeduplicator:
+ """模糊去重器(基于相似度)"""
+
+ @staticmethod
+ def levenshtein_distance(s1: str, s2: str) -> int:
+ """
+ 计算编辑距离(Levenshtein Distance)
+
+ 编辑距离是将一个字符串转换为另一个所需的最少操作数
+
+ Args:
+ s1: 字符串1
+ s2: 字符串2
+
+ Returns:
+ 编辑距离
+ """
+ if len(s1) < len(s2):
+ return FuzzyDeduplicator.levenshtein_distance(s2, s1)
+
+ if len(s2) == 0:
+ return len(s1)
+
+ previous_row = list(range(len(s2) + 1))
+
+ for i, c1 in enumerate(s1):
+ current_row = [i + 1]
+ for j, c2 in enumerate(s2):
+ # 插入、删除、替换的代价
+ insertions = previous_row[j + 1] + 1
+ deletions = current_row[j] + 1
+ substitutions = previous_row[j] + (c1 != c2)
+ current_row.append(min(insertions, deletions, substitutions))
+ previous_row = current_row
+
+ return previous_row[-1]
+
+ @staticmethod
+ def similarity(s1: str, s2: str) -> float:
+ """
+ 计算两个字符串的相似度
+
+ 相似度 = 1 - (编辑距离 / 最大长度)
+
+ Args:
+ s1: 字符串1
+ s2: 字符串2
+
+ Returns:
+ 相似度 (0.0 - 1.0)
+ """
+ if not s1 and not s2:
+ return 1.0
+ if not s1 or not s2:
+ return 0.0
+
+ distance = FuzzyDeduplicator.levenshtein_distance(s1, s2)
+ max_len = max(len(s1), len(s2))
+ return 1 - distance / max_len
+
+ @staticmethod
+ def jaccard_similarity(s1: str, s2: str) -> float:
+ """
+ 计算 Jaccard 相似度(基于字符集合)
+
+ Jaccard = |A ∩ B| / |A ∪ B|
+
+ Args:
+ s1: 字符串1
+ s2: 字符串2
+
+ Returns:
+ Jaccard 相似度 (0.0 - 1.0)
+ """
+ if not s1 and not s2:
+ return 1.0
+ if not s1 or not s2:
+ return 0.0
+
+ set1 = set(s1)
+ set2 = set(s2)
+
+ intersection = len(set1 & set2)
+ union = len(set1 | set2)
+
+ return intersection / union if union > 0 else 0.0
+
+ @staticmethod
+ def dedupe_fuzzy(
+ items: List[str],
+ threshold: float = 0.8,
+ similarity_func: str = "levenshtein"
+ ) -> List[str]:
+ """
+ 模糊去重
+
+ Args:
+ items: 字符串列表
+ threshold: 相似度阈值 (0.0 - 1.0)
+ similarity_func: 相似度算法 (levenshtein/jaccard)
+
+ Returns:
+ 去重后的列表
+ """
+ if not items:
+ return []
+
+ # 选择相似度函数
+ if similarity_func == "jaccard":
+ sim_func = FuzzyDeduplicator.jaccard_similarity
+ else:
+ sim_func = FuzzyDeduplicator.similarity
+
+ result = [items[0]]
+
+ for item in items[1:]:
+ is_duplicate = False
+ for existing in result:
+ if sim_func(item, existing) >= threshold:
+ is_duplicate = True
+ break
+
+ if not is_duplicate:
+ result.append(item)
+
+ return result
+
+ @staticmethod
+ def dedupe_dicts_fuzzy(
+ items: List[Dict],
+ text_field: str,
+ threshold: float = 0.8
+ ) -> List[Dict]:
+ """
+ 根据文本字段进行模糊去重
+
+ Args:
+ items: 字典列表
+ text_field: 用于比较的文本字段
+ threshold: 相似度阈值
+
+ Returns:
+ 去重后的列表
+ """
+ if not items:
+ return []
+
+ result = [items[0]]
+
+ for item in items[1:]:
+ item_text = item.get(text_field, '')
+ is_duplicate = False
+
+ for existing in result:
+ existing_text = existing.get(text_field, '')
+ if FuzzyDeduplicator.similarity(item_text, existing_text) >= threshold:
+ is_duplicate = True
+ break
+
+ if not is_duplicate:
+ result.append(item)
+
+ return result
+
+
+class ContentDeduplicator:
+ """基于内容特征的去重器"""
+
+ @staticmethod
+ def get_content_hash(text: str, normalize: bool = True) -> str:
+ """
+ 计算内容哈希
+
+ Args:
+ text: 文本内容
+ normalize: 是否标准化(去除空白等)
+
+ Returns:
+ MD5 哈希值
+ """
+ if normalize:
+ # 移除空白字符
+ text = ''.join(text.split())
+ # 转小写
+ text = text.lower()
+
+ return hashlib.md5(text.encode()).hexdigest()
+
+ @staticmethod
+ def get_simhash(text: str, bits: int = 64) -> int:
+ """
+ 计算 SimHash(用于大规模去重)
+
+ SimHash 是一种局部敏感哈希,相似文本的哈希值相近
+
+ Args:
+ text: 文本内容
+ bits: 哈希位数
+
+ Returns:
+ SimHash 值
+ """
+ # 分词(简单按字符)
+ features = list(text)
+
+ # 计算每个特征的哈希
+ v = [0] * bits
+ for feature in features:
+ h = int(hashlib.md5(feature.encode()).hexdigest(), 16)
+ for i in range(bits):
+ bitmask = 1 << i
+ if h & bitmask:
+ v[i] += 1
+ else:
+ v[i] -= 1
+
+ # 生成最终哈希
+ fingerprint = 0
+ for i in range(bits):
+ if v[i] >= 0:
+ fingerprint |= (1 << i)
+
+ return fingerprint
+
+ @staticmethod
+ def hamming_distance(hash1: int, hash2: int) -> int:
+ """
+ 计算汉明距离
+
+ Args:
+ hash1: 哈希值1
+ hash2: 哈希值2
+
+ Returns:
+ 汉明距离(不同位的数量)
+ """
+ x = hash1 ^ hash2
+ distance = 0
+ while x:
+ distance += 1
+ x &= x - 1
+ return distance
+
+ @staticmethod
+ def dedupe_by_simhash(
+ items: List[Dict],
+ text_field: str,
+ threshold: int = 3
+ ) -> List[Dict]:
+ """
+ 使用 SimHash 去重
+
+ 适用于大规模文本去重
+
+ Args:
+ items: 字典列表
+ text_field: 文本字段名
+ threshold: 汉明距离阈值(小于此值视为重复)
+
+ Returns:
+ 去重后的列表
+ """
+ if not items:
+ return []
+
+ result = []
+ hashes = []
+
+ for item in items:
+ text = item.get(text_field, '')
+ item_hash = ContentDeduplicator.get_simhash(text)
+
+ is_duplicate = False
+ for existing_hash in hashes:
+ if ContentDeduplicator.hamming_distance(item_hash, existing_hash) <= threshold:
+ is_duplicate = True
+ break
+
+ if not is_duplicate:
+ result.append(item)
+ hashes.append(item_hash)
+
+ return result
+
+
+def demo():
+ """去重工具演示"""
+ print("=" * 50)
+ print("数据去重工具演示")
+ print("=" * 50)
+
+ # 1. 精确去重
+ print("\n1. 精确去重:")
+ test_list = ["apple", "banana", "apple", "cherry", "banana"]
+ print(f" 原始: {test_list}")
+ deduped = ExactDeduplicator.dedupe_list(test_list)
+ print(f" 去重: {deduped}")
+
+ # 2. 字典列表去重
+ print("\n2. 字典列表去重:")
+ test_dicts = [
+ {"id": 1, "name": "Alice"},
+ {"id": 2, "name": "Bob"},
+ {"id": 1, "name": "Alice Copy"}, # 重复 ID
+ ]
+ print(f" 原始: {test_dicts}")
+ deduped_dicts = ExactDeduplicator.dedupe_dicts_by_field(test_dicts, "id")
+ print(f" 去重: {deduped_dicts}")
+
+ # 3. 模糊去重
+ print("\n3. 模糊去重:")
+ similar_items = [
+ "Python 爬虫教程",
+ "Python 爬虫入门教程", # 相似
+ "Java 编程指南",
+ "Java 编程入门指南" # 相似
+ ]
+ print(f" 原始: {similar_items}")
+ fuzzy_deduped = FuzzyDeduplicator.dedupe_fuzzy(similar_items, threshold=0.7)
+ print(f" 去重(阈值0.7): {fuzzy_deduped}")
+
+ # 4. 相似度计算
+ print("\n4. 相似度计算:")
+ s1 = "Hello World"
+ s2 = "Hello World!"
+ s3 = "Goodbye World"
+ print(f" '{s1}' vs '{s2}': {FuzzyDeduplicator.similarity(s1, s2):.2f}")
+ print(f" '{s1}' vs '{s3}': {FuzzyDeduplicator.similarity(s1, s3):.2f}")
+
+ # 5. SimHash
+ print("\n5. SimHash 去重:")
+ text1 = "这是一篇关于 Python 的文章"
+ text2 = "这是一篇关于 Python 的教程" # 相似
+ text3 = "Java 是一门编程语言"
+ hash1 = ContentDeduplicator.get_simhash(text1)
+ hash2 = ContentDeduplicator.get_simhash(text2)
+ hash3 = ContentDeduplicator.get_simhash(text3)
+ print(f" text1-text2 汉明距离: {ContentDeduplicator.hamming_distance(hash1, hash2)}")
+ print(f" text1-text3 汉明距离: {ContentDeduplicator.hamming_distance(hash1, hash3)}")
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ demo()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/pyproject.toml" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/pyproject.toml"
new file mode 100644
index 0000000..2d9bfa1
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/pyproject.toml"
@@ -0,0 +1,21 @@
+[project]
+name = "chapter09-data-cleaning"
+version = "0.1.0"
+description = "第09章:数据清洗与预处理 - 文本清洗、数据标准化、去重算法"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ # 本章主要使用标准库,无特殊依赖
+]
+
+[project.optional-dependencies]
+simhash = [
+ "simhash>=2.1.0", # SimHash去重(可选)
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.uv]
+dev-dependencies = []
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/text_cleaner.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/text_cleaner.py"
new file mode 100644
index 0000000..64213cc
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/09_\346\225\260\346\215\256\346\270\205\346\264\227\344\270\216\351\242\204\345\244\204\347\220\206/text_cleaner.py"
@@ -0,0 +1,458 @@
+# -*- coding: utf-8 -*-
+# @Desc: 文本清洗工具
+
+import re
+import unicodedata
+from typing import List
+from loguru import logger
+
+# 可选依赖
+try:
+ from bs4 import BeautifulSoup
+ HAS_BS4 = True
+except ImportError:
+ HAS_BS4 = False
+
+try:
+ import chardet
+ HAS_CHARDET = True
+except ImportError:
+ HAS_CHARDET = False
+
+
+class HTMLCleaner:
+ """HTML 清洗器"""
+
+ # 需要完全移除的标签(包括内容)
+ REMOVE_TAGS = ['script', 'style', 'head', 'meta', 'link', 'noscript']
+
+ @staticmethod
+ def remove_tags(html: str) -> str:
+ """
+ 移除所有 HTML 标签
+
+ Args:
+ html: HTML 文本
+
+ Returns:
+ 纯文本
+ """
+ # 先移除特定标签及其内容
+ for tag in HTMLCleaner.REMOVE_TAGS:
+ pattern = f'<{tag}[^>]*>.*?{tag}>'
+ html = re.sub(pattern, '', html, flags=re.DOTALL | re.IGNORECASE)
+
+ # 移除所有标签
+ html = re.sub(r'<[^>]+>', '', html)
+
+ return html
+
+ @staticmethod
+ def remove_tags_keep_structure(html: str) -> str:
+ """
+ 移除标签但保留结构(块级元素转换行)
+
+ Args:
+ html: HTML 文本
+
+ Returns:
+ 保留换行结构的纯文本
+ """
+ # 处理块级元素,添加换行
+ block_pattern = r'(p|div|br|li|tr|h[1-6]|article|section)>'
+ html = re.sub(block_pattern, '\n', html, flags=re.IGNORECASE)
+
+ # 处理
标签
+ html = re.sub(r'
', '\n', html, flags=re.IGNORECASE)
+
+ # 移除其他标签
+ html = re.sub(r'<[^>]+>', '', html)
+
+ return html
+
+ @staticmethod
+ def decode_entities(text: str) -> str:
+ """
+ 解码 HTML 实体
+
+ Args:
+ text: 包含 HTML 实体的文本
+
+ Returns:
+ 解码后的文本
+ """
+ import html
+ return html.unescape(text)
+
+ @staticmethod
+ def clean_with_bs4(html: str) -> str:
+ """
+ 使用 BeautifulSoup 清洗 HTML(推荐)
+
+ Args:
+ html: HTML 文本
+
+ Returns:
+ 纯文本
+ """
+ if not HAS_BS4:
+ logger.warning("BeautifulSoup 未安装,使用正则清洗")
+ return HTMLCleaner.remove_tags(html)
+
+ soup = BeautifulSoup(html, 'html.parser')
+
+ # 移除脚本和样式
+ for element in soup(['script', 'style', 'head', 'meta', 'link']):
+ element.decompose()
+
+ # 获取文本,使用换行分隔
+ text = soup.get_text(separator='\n')
+
+ return text
+
+
+class WhitespaceCleaner:
+ """空白字符清洗器"""
+
+ @staticmethod
+ def normalize(text: str) -> str:
+ """
+ 标准化空白字符
+
+ - 将制表符、回车等转为空格
+ - 合并多个空格
+ - 合并多个换行
+ - 去除首尾空白
+
+ Args:
+ text: 输入文本
+
+ Returns:
+ 标准化后的文本
+ """
+ # 将各种空白字符转为普通空格
+ text = re.sub(r'[\t\r\f\v]', ' ', text)
+ # 合并多个空格
+ text = re.sub(r' +', ' ', text)
+ # 合并多个换行
+ text = re.sub(r'\n+', '\n', text)
+ # 去除首尾空白
+ return text.strip()
+
+ @staticmethod
+ def remove_all(text: str) -> str:
+ """移除所有空白字符"""
+ return re.sub(r'\s+', '', text)
+
+ @staticmethod
+ def trim_lines(text: str) -> str:
+ """去除每行首尾空白"""
+ lines = text.split('\n')
+ return '\n'.join(line.strip() for line in lines)
+
+ @staticmethod
+ def remove_empty_lines(text: str) -> str:
+ """移除空行"""
+ lines = text.split('\n')
+ return '\n'.join(line for line in lines if line.strip())
+
+ @staticmethod
+ def collapse_whitespace(text: str) -> str:
+ """将所有连续空白合并为单个空格"""
+ return re.sub(r'\s+', ' ', text).strip()
+
+
+class SpecialCharCleaner:
+ """特殊字符清洗器"""
+
+ @staticmethod
+ def remove_control_chars(text: str) -> str:
+ """
+ 移除控制字符
+
+ 控制字符是 Unicode 类别为 'Cc' 的字符
+ """
+ return ''.join(
+ char for char in text
+ if unicodedata.category(char) != 'Cc'
+ )
+
+ @staticmethod
+ def normalize_unicode(text: str, form: str = 'NFKC') -> str:
+ """
+ Unicode 标准化
+
+ Args:
+ text: 输入文本
+ form: 标准化形式
+ - NFC: 标准分解后标准合成
+ - NFD: 标准分解
+ - NFKC: 兼容分解后标准合成(推荐)
+ - NFKD: 兼容分解
+
+ Returns:
+ 标准化后的文本
+ """
+ return unicodedata.normalize(form, text)
+
+ @staticmethod
+ def remove_emojis(text: str) -> str:
+ """移除 emoji 表情"""
+ emoji_pattern = re.compile(
+ "["
+ "\U0001F600-\U0001F64F" # 表情符号
+ "\U0001F300-\U0001F5FF" # 符号和象形文字
+ "\U0001F680-\U0001F6FF" # 交通和地图符号
+ "\U0001F1E0-\U0001F1FF" # 旗帜
+ "\U00002702-\U000027B0" # 装饰符号
+ "\U000024C2-\U0001F251" # 封闭字符
+ "]+",
+ flags=re.UNICODE
+ )
+ return emoji_pattern.sub('', text)
+
+ @staticmethod
+ def to_halfwidth(text: str) -> str:
+ """
+ 全角字符转半角
+
+ 将全角数字、字母、标点转为半角
+ """
+ result = []
+ for char in text:
+ code = ord(char)
+ # 全角空格
+ if code == 0x3000:
+ result.append(' ')
+ # 其他全角字符 (!到~)
+ elif 0xFF01 <= code <= 0xFF5E:
+ result.append(chr(code - 0xFEE0))
+ else:
+ result.append(char)
+ return ''.join(result)
+
+ @staticmethod
+ def to_fullwidth(text: str) -> str:
+ """
+ 半角字符转全角
+
+ 将半角数字、字母、标点转为全角
+ """
+ result = []
+ for char in text:
+ code = ord(char)
+ # 空格
+ if code == 0x20:
+ result.append('\u3000')
+ # 其他半角字符 (!到~)
+ elif 0x21 <= code <= 0x7E:
+ result.append(chr(code + 0xFEE0))
+ else:
+ result.append(char)
+ return ''.join(result)
+
+ @staticmethod
+ def remove_punctuation(text: str, keep_chinese: bool = True) -> str:
+ """
+ 移除标点符号
+
+ Args:
+ text: 输入文本
+ keep_chinese: 是否保留中文标点
+
+ Returns:
+ 移除标点后的文本
+ """
+ if keep_chinese:
+ # 只移除英文标点
+ return re.sub(r'[!"#$%&\'()*+,-./:;<=>?@\[\]\\^_`{|}~]', '', text)
+ else:
+ # 移除所有标点
+ return re.sub(r'[^\w\s]', '', text, flags=re.UNICODE)
+
+
+class EncodingFixer:
+ """编码问题修复器"""
+
+ @staticmethod
+ def detect_encoding(data: bytes) -> str:
+ """
+ 检测字节数据的编码
+
+ Args:
+ data: 字节数据
+
+ Returns:
+ 检测到的编码名称
+ """
+ if not HAS_CHARDET:
+ logger.warning("chardet 未安装,默认使用 utf-8")
+ return 'utf-8'
+
+ result = chardet.detect(data)
+ return result.get('encoding') or 'utf-8'
+
+ @staticmethod
+ def safe_decode(data: bytes, fallback: str = 'utf-8') -> str:
+ """
+ 安全解码字节数据
+
+ 自动检测编码,解码失败则使用 fallback
+
+ Args:
+ data: 字节数据
+ fallback: 备用编码
+
+ Returns:
+ 解码后的字符串
+ """
+ detected = EncodingFixer.detect_encoding(data)
+ try:
+ return data.decode(detected)
+ except (UnicodeDecodeError, TypeError, LookupError):
+ return data.decode(fallback, errors='ignore')
+
+ @staticmethod
+ def fix_mojibake(text: str) -> str:
+ """
+ 修复乱码(尝试常见编码)
+
+ Args:
+ text: 可能乱码的文本
+
+ Returns:
+ 修复后的文本
+ """
+ encodings = ['utf-8', 'gbk', 'gb2312', 'gb18030', 'big5']
+
+ for encoding in encodings:
+ try:
+ # 尝试将文本按 latin1 编码,再用目标编码解码
+ fixed = text.encode('latin1').decode(encoding)
+ # 检查是否包含中文(简单验证)
+ if re.search(r'[\u4e00-\u9fa5]', fixed):
+ return fixed
+ except (UnicodeDecodeError, UnicodeEncodeError):
+ continue
+
+ return text
+
+
+class TextCleaner:
+ """综合文本清洗器"""
+
+ def __init__(
+ self,
+ remove_html: bool = True,
+ normalize_whitespace: bool = True,
+ normalize_unicode: bool = True,
+ to_halfwidth: bool = True,
+ remove_emojis: bool = False,
+ remove_control_chars: bool = True
+ ):
+ """
+ Args:
+ remove_html: 是否移除 HTML 标签
+ normalize_whitespace: 是否标准化空白
+ normalize_unicode: 是否 Unicode 标准化
+ to_halfwidth: 是否全角转半角
+ remove_emojis: 是否移除 emoji
+ remove_control_chars: 是否移除控制字符
+ """
+ self.remove_html = remove_html
+ self.normalize_whitespace = normalize_whitespace
+ self.normalize_unicode = normalize_unicode
+ self.to_halfwidth = to_halfwidth
+ self.remove_emojis = remove_emojis
+ self.remove_control_chars = remove_control_chars
+
+ def clean(self, text: str) -> str:
+ """
+ 执行完整的文本清洗
+
+ Args:
+ text: 原始文本
+
+ Returns:
+ 清洗后的文本
+ """
+ if not text:
+ return ''
+
+ # 1. HTML 清洗
+ if self.remove_html:
+ text = HTMLCleaner.clean_with_bs4(text)
+ text = HTMLCleaner.decode_entities(text)
+
+ # 2. Unicode 标准化
+ if self.normalize_unicode:
+ text = SpecialCharCleaner.normalize_unicode(text, 'NFKC')
+
+ # 3. 移除控制字符
+ if self.remove_control_chars:
+ text = SpecialCharCleaner.remove_control_chars(text)
+
+ # 4. 全角转半角
+ if self.to_halfwidth:
+ text = SpecialCharCleaner.to_halfwidth(text)
+
+ # 5. 移除 emoji
+ if self.remove_emojis:
+ text = SpecialCharCleaner.remove_emojis(text)
+
+ # 6. 空白标准化
+ if self.normalize_whitespace:
+ text = WhitespaceCleaner.normalize(text)
+
+ return text
+
+
+def demo():
+ """演示文本清洗功能"""
+ print("=" * 50)
+ print("文本清洗工具演示")
+ print("=" * 50)
+
+ # 测试数据
+ test_html = """
+
+ 测试
+
+
+ 这是一段 HTML 文本。
+ 包含&特殊<字符>。
+
+
+ """
+
+ print("\n1. HTML 清洗:")
+ print(f" 原始: {test_html[:50]}...")
+ cleaned = HTMLCleaner.clean_with_bs4(test_html)
+ print(f" 清洗后: {cleaned[:50]}...")
+
+ print("\n2. 空白处理:")
+ test_whitespace = " 多个 空格 \n\n\n多个换行 "
+ print(f" 原始: '{test_whitespace}'")
+ normalized = WhitespaceCleaner.normalize(test_whitespace)
+ print(f" 标准化: '{normalized}'")
+
+ print("\n3. 全角转半角:")
+ test_fullwidth = "ABC123 全角字符"
+ print(f" 原始: '{test_fullwidth}'")
+ halfwidth = SpecialCharCleaner.to_halfwidth(test_fullwidth)
+ print(f" 半角: '{halfwidth}'")
+
+ print("\n4. 综合清洗:")
+ cleaner = TextCleaner()
+ test_complex = " 复杂的 HTML&文本
"
+ print(f" 原始: '{test_complex}'")
+ result = cleaner.clean(test_complex)
+ print(f" 清洗: '{result}'")
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ demo()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/README.md" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/README.md"
new file mode 100644
index 0000000..af8b599
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/README.md"
@@ -0,0 +1,27 @@
+# 第10章:数据分析与可视化
+
+展示Pandas数据分析、词云生成、图表制作等数据处理功能。
+
+## 快速开始
+
+```bash
+cd 10_数据分析与可视化
+
+# 安装基础依赖
+uv sync
+
+# 安装交互式图表功能(可选)
+uv sync --extra interactive
+
+# 运行示例
+uv run python pandas_analysis.py
+uv run python wordcloud_generator.py
+uv run python chart_demo.py
+```
+
+### 核心依赖
+- `pandas` - 数据分析
+- `jieba` - 中文分词
+- `wordcloud` - 词云生成
+- `matplotlib` - 图表绘制
+- `pyecharts`(可选)- 交互式图表
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/chart_demo.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/chart_demo.py"
new file mode 100644
index 0000000..36617c1
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/chart_demo.py"
@@ -0,0 +1,582 @@
+# -*- coding: utf-8 -*-
+# @Desc: 数据可视化工具
+
+import os
+from typing import List, Dict, Any, Optional, Tuple
+from loguru import logger
+
+# 可选依赖
+try:
+ import matplotlib.pyplot as plt
+ import matplotlib
+ matplotlib.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans']
+ matplotlib.rcParams['axes.unicode_minus'] = False
+ HAS_MATPLOTLIB = True
+except ImportError:
+ HAS_MATPLOTLIB = False
+ logger.warning("matplotlib 未安装")
+
+try:
+ from pyecharts.charts import Bar, Line, Pie, Scatter, WordCloud as PyWordCloud
+ from pyecharts import options as opts
+ from pyecharts.globals import ThemeType
+ HAS_PYECHARTS = True
+except ImportError:
+ HAS_PYECHARTS = False
+ logger.warning("pyecharts 未安装")
+
+
+class MatplotlibCharts:
+ """Matplotlib 静态图表生成器"""
+
+ def __init__(self, figsize: Tuple[int, int] = (10, 6), dpi: int = 150):
+ """
+ 初始化
+
+ Args:
+ figsize: 图表大小 (宽, 高)
+ dpi: 分辨率
+ """
+ if not HAS_MATPLOTLIB:
+ raise ImportError("请安装 matplotlib: pip install matplotlib")
+
+ self.figsize = figsize
+ self.dpi = dpi
+
+ def line_chart(
+ self,
+ x_data: List,
+ y_data: List,
+ title: str,
+ xlabel: str,
+ ylabel: str,
+ output_path: str,
+ color: str = 'steelblue',
+ marker: str = 'o'
+ ) -> str:
+ """绘制折线图"""
+ plt.figure(figsize=self.figsize)
+ plt.plot(x_data, y_data, marker=marker, linewidth=2, markersize=6, color=color)
+ plt.title(title, fontsize=14)
+ plt.xlabel(xlabel, fontsize=12)
+ plt.ylabel(ylabel, fontsize=12)
+ plt.grid(True, alpha=0.3)
+ plt.tight_layout()
+ plt.savefig(output_path, dpi=self.dpi)
+ plt.close()
+ logger.info(f"折线图已保存: {output_path}")
+ return output_path
+
+ def multi_line_chart(
+ self,
+ x_data: List,
+ y_data_dict: Dict[str, List],
+ title: str,
+ xlabel: str,
+ ylabel: str,
+ output_path: str
+ ) -> str:
+ """绘制多条折线图"""
+ plt.figure(figsize=self.figsize)
+
+ for label, y_data in y_data_dict.items():
+ plt.plot(x_data, y_data, marker='o', label=label, linewidth=2)
+
+ plt.title(title, fontsize=14)
+ plt.xlabel(xlabel, fontsize=12)
+ plt.ylabel(ylabel, fontsize=12)
+ plt.legend(loc='best')
+ plt.grid(True, alpha=0.3)
+ plt.tight_layout()
+ plt.savefig(output_path, dpi=self.dpi)
+ plt.close()
+ logger.info(f"多折线图已保存: {output_path}")
+ return output_path
+
+ def bar_chart(
+ self,
+ categories: List[str],
+ values: List,
+ title: str,
+ xlabel: str,
+ ylabel: str,
+ output_path: str,
+ horizontal: bool = False,
+ color: str = 'steelblue'
+ ) -> str:
+ """绘制柱状图"""
+ plt.figure(figsize=self.figsize)
+
+ if horizontal:
+ plt.barh(categories, values, color=color)
+ plt.xlabel(ylabel)
+ plt.ylabel(xlabel)
+ else:
+ plt.bar(categories, values, color=color)
+ plt.xlabel(xlabel)
+ plt.ylabel(ylabel)
+
+ plt.title(title, fontsize=14)
+ plt.tight_layout()
+ plt.savefig(output_path, dpi=self.dpi)
+ plt.close()
+ logger.info(f"柱状图已保存: {output_path}")
+ return output_path
+
+ def grouped_bar_chart(
+ self,
+ categories: List[str],
+ data_dict: Dict[str, List],
+ title: str,
+ xlabel: str,
+ ylabel: str,
+ output_path: str
+ ) -> str:
+ """绘制分组柱状图"""
+ import numpy as np
+
+ plt.figure(figsize=self.figsize)
+
+ x = np.arange(len(categories))
+ width = 0.8 / len(data_dict)
+
+ for i, (label, values) in enumerate(data_dict.items()):
+ offset = (i - len(data_dict) / 2 + 0.5) * width
+ plt.bar(x + offset, values, width, label=label)
+
+ plt.xlabel(xlabel, fontsize=12)
+ plt.ylabel(ylabel, fontsize=12)
+ plt.title(title, fontsize=14)
+ plt.xticks(x, categories)
+ plt.legend()
+ plt.tight_layout()
+ plt.savefig(output_path, dpi=self.dpi)
+ plt.close()
+ logger.info(f"分组柱状图已保存: {output_path}")
+ return output_path
+
+ def pie_chart(
+ self,
+ labels: List[str],
+ sizes: List,
+ title: str,
+ output_path: str,
+ explode_max: bool = True
+ ) -> str:
+ """绘制饼图"""
+ plt.figure(figsize=(10, 8))
+
+ explode = None
+ if explode_max:
+ max_idx = sizes.index(max(sizes))
+ explode = [0.05 if i == max_idx else 0 for i in range(len(sizes))]
+
+ plt.pie(
+ sizes,
+ labels=labels,
+ explode=explode,
+ autopct='%1.1f%%',
+ startangle=90,
+ colors=plt.cm.Set3.colors[:len(labels)]
+ )
+ plt.title(title, fontsize=14)
+ plt.axis('equal')
+ plt.tight_layout()
+ plt.savefig(output_path, dpi=self.dpi)
+ plt.close()
+ logger.info(f"饼图已保存: {output_path}")
+ return output_path
+
+ def scatter_chart(
+ self,
+ x_data: List,
+ y_data: List,
+ title: str,
+ xlabel: str,
+ ylabel: str,
+ output_path: str,
+ color: str = 'steelblue',
+ alpha: float = 0.6
+ ) -> str:
+ """绘制散点图"""
+ plt.figure(figsize=self.figsize)
+ plt.scatter(x_data, y_data, c=color, alpha=alpha, s=50)
+ plt.title(title, fontsize=14)
+ plt.xlabel(xlabel, fontsize=12)
+ plt.ylabel(ylabel, fontsize=12)
+ plt.grid(True, alpha=0.3)
+ plt.tight_layout()
+ plt.savefig(output_path, dpi=self.dpi)
+ plt.close()
+ logger.info(f"散点图已保存: {output_path}")
+ return output_path
+
+ def histogram(
+ self,
+ data: List,
+ title: str,
+ xlabel: str,
+ ylabel: str,
+ output_path: str,
+ bins: int = 20,
+ color: str = 'steelblue'
+ ) -> str:
+ """绘制直方图"""
+ plt.figure(figsize=self.figsize)
+ plt.hist(data, bins=bins, color=color, edgecolor='white', alpha=0.7)
+ plt.title(title, fontsize=14)
+ plt.xlabel(xlabel, fontsize=12)
+ plt.ylabel(ylabel, fontsize=12)
+ plt.grid(axis='y', alpha=0.3)
+ plt.tight_layout()
+ plt.savefig(output_path, dpi=self.dpi)
+ plt.close()
+ logger.info(f"直方图已保存: {output_path}")
+ return output_path
+
+
+class PyechartsCharts:
+ """Pyecharts 交互式图表生成器"""
+
+ def __init__(self, theme: str = 'light'):
+ """
+ 初始化
+
+ Args:
+ theme: 主题名称
+ """
+ if not HAS_PYECHARTS:
+ raise ImportError("请安装 pyecharts: pip install pyecharts")
+
+ self.theme = getattr(ThemeType, theme.upper(), ThemeType.LIGHT)
+
+ def bar_chart(
+ self,
+ categories: List[str],
+ values: List,
+ title: str,
+ output_path: str,
+ series_name: str = "数量"
+ ) -> str:
+ """创建交互式柱状图"""
+ bar = (
+ Bar(init_opts=opts.InitOpts(theme=self.theme))
+ .add_xaxis(categories)
+ .add_yaxis(series_name, values)
+ .set_global_opts(
+ title_opts=opts.TitleOpts(title=title),
+ toolbox_opts=opts.ToolboxOpts(is_show=True),
+ datazoom_opts=opts.DataZoomOpts(is_show=True)
+ )
+ )
+ bar.render(output_path)
+ logger.info(f"交互式柱状图已保存: {output_path}")
+ return output_path
+
+ def stacked_bar_chart(
+ self,
+ categories: List[str],
+ data_dict: Dict[str, List],
+ title: str,
+ output_path: str
+ ) -> str:
+ """创建堆叠柱状图"""
+ bar = Bar(init_opts=opts.InitOpts(theme=self.theme))
+ bar.add_xaxis(categories)
+
+ for name, values in data_dict.items():
+ bar.add_yaxis(name, values, stack="stack1")
+
+ bar.set_global_opts(
+ title_opts=opts.TitleOpts(title=title),
+ toolbox_opts=opts.ToolboxOpts(is_show=True),
+ legend_opts=opts.LegendOpts(is_show=True)
+ )
+ bar.render(output_path)
+ logger.info(f"堆叠柱状图已保存: {output_path}")
+ return output_path
+
+ def line_chart(
+ self,
+ x_data: List,
+ y_data_dict: Dict[str, List],
+ title: str,
+ output_path: str
+ ) -> str:
+ """创建交互式折线图"""
+ line = Line(init_opts=opts.InitOpts(theme=self.theme))
+ line.add_xaxis(x_data)
+
+ for name, y_data in y_data_dict.items():
+ line.add_yaxis(
+ name,
+ y_data,
+ is_smooth=True,
+ markpoint_opts=opts.MarkPointOpts(
+ data=[
+ opts.MarkPointItem(type_="max", name="最大值"),
+ opts.MarkPointItem(type_="min", name="最小值"),
+ ]
+ ),
+ markline_opts=opts.MarkLineOpts(
+ data=[opts.MarkLineItem(type_="average", name="平均值")]
+ )
+ )
+
+ line.set_global_opts(
+ title_opts=opts.TitleOpts(title=title),
+ tooltip_opts=opts.TooltipOpts(trigger="axis"),
+ toolbox_opts=opts.ToolboxOpts(is_show=True),
+ legend_opts=opts.LegendOpts(is_show=True)
+ )
+ line.render(output_path)
+ logger.info(f"交互式折线图已保存: {output_path}")
+ return output_path
+
+ def pie_chart(
+ self,
+ data: List[Tuple[str, int]],
+ title: str,
+ output_path: str,
+ rose_type: str = None
+ ) -> str:
+ """
+ 创建交互式饼图
+
+ Args:
+ data: [(名称, 数值), ...] 列表
+ title: 标题
+ output_path: 输出路径
+ rose_type: 玫瑰图类型 ('radius' 或 'area')
+ """
+ pie = Pie(init_opts=opts.InitOpts(theme=self.theme))
+
+ if rose_type:
+ pie.add(
+ "",
+ data,
+ radius=["30%", "70%"],
+ rosetype=rose_type
+ )
+ else:
+ pie.add("", data, radius=["30%", "70%"])
+
+ pie.set_global_opts(
+ title_opts=opts.TitleOpts(title=title),
+ legend_opts=opts.LegendOpts(
+ orient="vertical",
+ pos_top="15%",
+ pos_left="2%"
+ )
+ )
+ pie.set_series_opts(
+ label_opts=opts.LabelOpts(formatter="{b}: {d}%")
+ )
+ pie.render(output_path)
+ logger.info(f"交互式饼图已保存: {output_path}")
+ return output_path
+
+ def scatter_chart(
+ self,
+ data: List[Tuple[float, float]],
+ title: str,
+ output_path: str,
+ series_name: str = "数据"
+ ) -> str:
+ """创建交互式散点图"""
+ scatter = (
+ Scatter(init_opts=opts.InitOpts(theme=self.theme))
+ .add_xaxis([d[0] for d in data])
+ .add_yaxis(series_name, [d[1] for d in data])
+ .set_global_opts(
+ title_opts=opts.TitleOpts(title=title),
+ toolbox_opts=opts.ToolboxOpts(is_show=True),
+ xaxis_opts=opts.AxisOpts(type_="value"),
+ )
+ )
+ scatter.render(output_path)
+ logger.info(f"交互式散点图已保存: {output_path}")
+ return output_path
+
+ def wordcloud_chart(
+ self,
+ words: List[Tuple[str, int]],
+ title: str,
+ output_path: str,
+ shape: str = "circle"
+ ) -> str:
+ """
+ 创建交互式词云
+
+ Args:
+ words: [(词语, 频次), ...] 列表
+ title: 标题
+ output_path: 输出路径
+ shape: 形状 ('circle', 'cardioid', 'diamond', 'triangle-forward', 'triangle', 'pentagon', 'star')
+ """
+ wc = (
+ PyWordCloud(init_opts=opts.InitOpts(theme=self.theme))
+ .add(
+ "",
+ words,
+ word_size_range=[20, 100],
+ shape=shape
+ )
+ .set_global_opts(
+ title_opts=opts.TitleOpts(title=title),
+ toolbox_opts=opts.ToolboxOpts(is_show=True)
+ )
+ )
+ wc.render(output_path)
+ logger.info(f"交互式词云已保存: {output_path}")
+ return output_path
+
+
+class ChartFactory:
+ """图表工厂类"""
+
+ def __init__(self, output_dir: str = "./charts"):
+ """
+ 初始化
+
+ Args:
+ output_dir: 输出目录
+ """
+ self.output_dir = output_dir
+ os.makedirs(output_dir, exist_ok=True)
+
+ self.matplotlib = MatplotlibCharts() if HAS_MATPLOTLIB else None
+ self.pyecharts = PyechartsCharts() if HAS_PYECHARTS else None
+
+ def get_output_path(self, filename: str) -> str:
+ """获取输出路径"""
+ return os.path.join(self.output_dir, filename)
+
+ def create_bar(
+ self,
+ categories: List[str],
+ values: List,
+ title: str,
+ filename: str,
+ chart_type: str = 'matplotlib'
+ ) -> str:
+ """创建柱状图"""
+ output_path = self.get_output_path(filename)
+
+ if chart_type == 'matplotlib' and self.matplotlib:
+ return self.matplotlib.bar_chart(
+ categories, values, title, "分类", "数值", output_path
+ )
+ elif chart_type == 'pyecharts' and self.pyecharts:
+ return self.pyecharts.bar_chart(categories, values, title, output_path)
+ else:
+ raise ValueError(f"Chart type '{chart_type}' not available")
+
+ def create_line(
+ self,
+ x_data: List,
+ y_data_dict: Dict[str, List],
+ title: str,
+ filename: str,
+ chart_type: str = 'matplotlib'
+ ) -> str:
+ """创建折线图"""
+ output_path = self.get_output_path(filename)
+
+ if chart_type == 'matplotlib' and self.matplotlib:
+ return self.matplotlib.multi_line_chart(
+ x_data, y_data_dict, title, "X轴", "Y轴", output_path
+ )
+ elif chart_type == 'pyecharts' and self.pyecharts:
+ return self.pyecharts.line_chart(x_data, y_data_dict, title, output_path)
+ else:
+ raise ValueError(f"Chart type '{chart_type}' not available")
+
+ def create_pie(
+ self,
+ data: List[Tuple[str, int]],
+ title: str,
+ filename: str,
+ chart_type: str = 'matplotlib'
+ ) -> str:
+ """创建饼图"""
+ output_path = self.get_output_path(filename)
+
+ if chart_type == 'matplotlib' and self.matplotlib:
+ labels = [d[0] for d in data]
+ sizes = [d[1] for d in data]
+ return self.matplotlib.pie_chart(labels, sizes, title, output_path)
+ elif chart_type == 'pyecharts' and self.pyecharts:
+ return self.pyecharts.pie_chart(data, title, output_path)
+ else:
+ raise ValueError(f"Chart type '{chart_type}' not available")
+
+
+def demo():
+ """演示图表生成功能"""
+ print("=" * 50)
+ print("数据可视化工具演示")
+ print("=" * 50)
+
+ # 测试数据
+ categories = ["Python", "Java", "JavaScript", "Go", "Rust"]
+ values = [85, 70, 75, 45, 30]
+
+ x_data = ["1月", "2月", "3月", "4月", "5月"]
+ y_data = {
+ "浏览量": [1000, 1200, 1500, 1300, 1800],
+ "点赞数": [100, 150, 200, 180, 250]
+ }
+
+ pie_data = [
+ ("技术", 45),
+ ("生活", 30),
+ ("娱乐", 15),
+ ("其他", 10)
+ ]
+
+ print("\n可用的图表类型:")
+
+ if HAS_MATPLOTLIB:
+ print(" - Matplotlib (静态图表): 已安装")
+ print(" 示例: charts.matplotlib.bar_chart(...)")
+ else:
+ print(" - Matplotlib: 未安装 (pip install matplotlib)")
+
+ if HAS_PYECHARTS:
+ print(" - Pyecharts (交互式图表): 已安装")
+ print(" 示例: charts.pyecharts.bar_chart(...)")
+ else:
+ print(" - Pyecharts: 未安装 (pip install pyecharts)")
+
+ print("\n示例数据:")
+ print(f" 柱状图数据: {dict(zip(categories, values))}")
+ print(f" 折线图数据: {y_data}")
+ print(f" 饼图数据: {pie_data}")
+
+ print("\n使用示例:")
+ print("""
+ from chart_demo import ChartFactory
+
+ factory = ChartFactory(output_dir="./output")
+
+ # 创建柱状图
+ factory.create_bar(categories, values, "编程语言流行度", "bar.png")
+
+ # 创建折线图
+ factory.create_line(x_data, y_data, "月度数据趋势", "line.png")
+
+ # 创建饼图
+ factory.create_pie(pie_data, "内容分类分布", "pie.png")
+
+ # 创建交互式图表 (HTML)
+ factory.create_bar(categories, values, "编程语言流行度", "bar.html", chart_type='pyecharts')
+ """)
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ demo()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/pandas_analysis.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/pandas_analysis.py"
new file mode 100644
index 0000000..b4f90e9
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/pandas_analysis.py"
@@ -0,0 +1,353 @@
+# -*- coding: utf-8 -*-
+# @Desc: pandas 数据分析工具
+
+import pandas as pd
+from typing import List, Dict, Any, Optional
+from datetime import datetime
+from loguru import logger
+
+
+class DataFrameAnalyzer:
+ """DataFrame 数据分析器"""
+
+ def __init__(self, data: List[Dict[str, Any]]):
+ """
+ 初始化分析器
+
+ Args:
+ data: 字典列表形式的数据
+ """
+ self.df = pd.DataFrame(data)
+ logger.info(f"加载数据: {len(self.df)} 行, {len(self.df.columns)} 列")
+
+ @classmethod
+ def from_csv(cls, file_path: str, **kwargs) -> 'DataFrameAnalyzer':
+ """从 CSV 文件创建分析器"""
+ df = pd.read_csv(file_path, **kwargs)
+ return cls(df.to_dict('records'))
+
+ @classmethod
+ def from_json(cls, file_path: str, **kwargs) -> 'DataFrameAnalyzer':
+ """从 JSON 文件创建分析器"""
+ df = pd.read_json(file_path, **kwargs)
+ return cls(df.to_dict('records'))
+
+ def info(self) -> Dict[str, Any]:
+ """获取数据集基本信息"""
+ return {
+ "rows": len(self.df),
+ "columns": len(self.df.columns),
+ "column_names": self.df.columns.tolist(),
+ "dtypes": self.df.dtypes.to_dict(),
+ "memory_usage": self.df.memory_usage(deep=True).sum(),
+ "null_counts": self.df.isnull().sum().to_dict(),
+ }
+
+ def describe(self, include: str = 'all') -> pd.DataFrame:
+ """获取描述性统计"""
+ return self.df.describe(include=include)
+
+ def value_counts(self, column: str, top_n: int = 10) -> pd.Series:
+ """统计某列的值分布"""
+ return self.df[column].value_counts().head(top_n)
+
+ def group_by_count(self, group_col: str) -> pd.Series:
+ """按列分组计数"""
+ return self.df.groupby(group_col).size().sort_values(ascending=False)
+
+ def group_by_sum(self, group_col: str, value_col: str) -> pd.Series:
+ """按列分组求和"""
+ return self.df.groupby(group_col)[value_col].sum().sort_values(ascending=False)
+
+ def group_by_mean(self, group_col: str, value_col: str) -> pd.Series:
+ """按列分组求均值"""
+ return self.df.groupby(group_col)[value_col].mean().sort_values(ascending=False)
+
+ def group_by_agg(
+ self,
+ group_col: str,
+ agg_dict: Dict[str, List[str]]
+ ) -> pd.DataFrame:
+ """
+ 按列分组并进行多种聚合
+
+ Args:
+ group_col: 分组列
+ agg_dict: 聚合字典,如 {'views': ['sum', 'mean'], 'likes': ['max', 'min']}
+ """
+ return self.df.groupby(group_col).agg(agg_dict)
+
+ def pivot_table(
+ self,
+ values: str,
+ index: str,
+ columns: str,
+ aggfunc: str = 'sum',
+ fill_value: Any = 0
+ ) -> pd.DataFrame:
+ """创建数据透视表"""
+ return pd.pivot_table(
+ self.df,
+ values=values,
+ index=index,
+ columns=columns,
+ aggfunc=aggfunc,
+ fill_value=fill_value
+ )
+
+ def correlation(self, columns: List[str] = None) -> pd.DataFrame:
+ """计算相关系数矩阵"""
+ if columns:
+ return self.df[columns].corr()
+ return self.df.select_dtypes(include=['number']).corr()
+
+ def filter(self, condition: str) -> 'DataFrameAnalyzer':
+ """
+ 根据条件筛选数据
+
+ Args:
+ condition: 查询条件,如 "views > 1000 and category == '技术'"
+ """
+ filtered_df = self.df.query(condition)
+ return DataFrameAnalyzer(filtered_df.to_dict('records'))
+
+ def sort(self, by: str, ascending: bool = False) -> 'DataFrameAnalyzer':
+ """排序"""
+ sorted_df = self.df.sort_values(by=by, ascending=ascending)
+ return DataFrameAnalyzer(sorted_df.to_dict('records'))
+
+ def top_n(self, n: int, by: str) -> pd.DataFrame:
+ """获取 Top N"""
+ return self.df.nlargest(n, by)
+
+ def bottom_n(self, n: int, by: str) -> pd.DataFrame:
+ """获取 Bottom N"""
+ return self.df.nsmallest(n, by)
+
+
+class TimeSeriesAnalyzer:
+ """时间序列分析器"""
+
+ def __init__(self, df: pd.DataFrame, date_col: str):
+ """
+ 初始化时间序列分析器
+
+ Args:
+ df: 数据框
+ date_col: 日期列名
+ """
+ self.df = df.copy()
+ self.date_col = date_col
+
+ # 转换日期列
+ self.df[date_col] = pd.to_datetime(self.df[date_col], errors='coerce')
+ self.df = self.df.dropna(subset=[date_col])
+ self.df = self.df.set_index(date_col)
+
+ logger.info(f"时间序列分析器初始化: {len(self.df)} 条记录")
+
+ def resample(
+ self,
+ freq: str,
+ value_col: str,
+ agg_func: str = 'sum'
+ ) -> pd.DataFrame:
+ """
+ 重采样
+
+ Args:
+ freq: 频率,如 'D'(天), 'W'(周), 'M'(月)
+ value_col: 值列
+ agg_func: 聚合函数
+ """
+ return self.df.resample(freq)[value_col].agg(agg_func)
+
+ def rolling(
+ self,
+ window: int,
+ value_col: str,
+ agg_func: str = 'mean'
+ ) -> pd.Series:
+ """
+ 滚动窗口统计
+
+ Args:
+ window: 窗口大小
+ value_col: 值列
+ agg_func: 聚合函数
+ """
+ return getattr(self.df[value_col].rolling(window=window), agg_func)()
+
+ def growth_rate(self, value_col: str, periods: int = 1) -> pd.Series:
+ """
+ 计算增长率
+
+ Args:
+ value_col: 值列
+ periods: 周期数
+ """
+ return self.df[value_col].pct_change(periods=periods) * 100
+
+ def trend_analysis(self, value_col: str) -> Dict[str, Any]:
+ """
+ 趋势分析
+
+ Returns:
+ 包含趋势统计信息的字典
+ """
+ series = self.df[value_col]
+ growth = series.pct_change().dropna()
+
+ return {
+ "start_value": series.iloc[0],
+ "end_value": series.iloc[-1],
+ "total_change": series.iloc[-1] - series.iloc[0],
+ "total_change_pct": (series.iloc[-1] - series.iloc[0]) / series.iloc[0] * 100,
+ "avg_growth_rate": growth.mean() * 100,
+ "max_growth_rate": growth.max() * 100,
+ "min_growth_rate": growth.min() * 100,
+ "positive_periods": (growth > 0).sum(),
+ "negative_periods": (growth < 0).sum(),
+ }
+
+ def seasonal_decompose(
+ self,
+ value_col: str,
+ period: int = 7
+ ) -> Dict[str, pd.Series]:
+ """
+ 季节性分解(简化版)
+
+ Args:
+ value_col: 值列
+ period: 周期
+ """
+ series = self.df[value_col]
+
+ # 移动平均作为趋势
+ trend = series.rolling(window=period, center=True).mean()
+
+ # 去趋势
+ detrended = series - trend
+
+ # 季节性(按周期位置平均)
+ seasonal = detrended.groupby(
+ detrended.index.dayofweek
+ ).transform('mean')
+
+ # 残差
+ residual = series - trend - seasonal
+
+ return {
+ "original": series,
+ "trend": trend,
+ "seasonal": seasonal,
+ "residual": residual
+ }
+
+
+class StatisticsCalculator:
+ """统计计算器"""
+
+ @staticmethod
+ def percentile(series: pd.Series, q: float) -> float:
+ """计算分位数"""
+ return series.quantile(q)
+
+ @staticmethod
+ def iqr(series: pd.Series) -> float:
+ """计算四分位距"""
+ return series.quantile(0.75) - series.quantile(0.25)
+
+ @staticmethod
+ def outliers(series: pd.Series, method: str = 'iqr') -> pd.Series:
+ """
+ 检测异常值
+
+ Args:
+ series: 数据序列
+ method: 检测方法 ('iqr' 或 'zscore')
+
+ Returns:
+ 布尔序列,True 表示异常值
+ """
+ if method == 'iqr':
+ q1 = series.quantile(0.25)
+ q3 = series.quantile(0.75)
+ iqr = q3 - q1
+ lower = q1 - 1.5 * iqr
+ upper = q3 + 1.5 * iqr
+ return (series < lower) | (series > upper)
+ elif method == 'zscore':
+ z_scores = (series - series.mean()) / series.std()
+ return abs(z_scores) > 3
+ else:
+ raise ValueError(f"Unknown method: {method}")
+
+ @staticmethod
+ def distribution_stats(series: pd.Series) -> Dict[str, float]:
+ """计算分布统计量"""
+ return {
+ "count": series.count(),
+ "mean": series.mean(),
+ "std": series.std(),
+ "min": series.min(),
+ "q25": series.quantile(0.25),
+ "median": series.median(),
+ "q75": series.quantile(0.75),
+ "max": series.max(),
+ "skewness": series.skew(),
+ "kurtosis": series.kurtosis(),
+ }
+
+
+def demo():
+ """演示 pandas 数据分析功能"""
+ print("=" * 50)
+ print("pandas 数据分析演示")
+ print("=" * 50)
+
+ # 模拟数据
+ data = [
+ {"title": "Python入门", "category": "技术", "views": 15000, "likes": 320, "date": "2024-01-15"},
+ {"title": "爬虫教程", "category": "技术", "views": 12000, "likes": 280, "date": "2024-01-16"},
+ {"title": "美食分享", "category": "生活", "views": 8000, "likes": 450, "date": "2024-01-17"},
+ {"title": "数据分析", "category": "技术", "views": 18000, "likes": 520, "date": "2024-01-18"},
+ {"title": "旅行日记", "category": "生活", "views": 10000, "likes": 380, "date": "2024-01-19"},
+ {"title": "机器学习", "category": "技术", "views": 20000, "likes": 600, "date": "2024-01-20"},
+ ]
+
+ # 创建分析器
+ analyzer = DataFrameAnalyzer(data)
+
+ # 基本信息
+ print("\n1. 数据集信息:")
+ info = analyzer.info()
+ print(f" 行数: {info['rows']}, 列数: {info['columns']}")
+ print(f" 列名: {info['column_names']}")
+
+ # 描述性统计
+ print("\n2. 描述性统计:")
+ print(analyzer.describe())
+
+ # 分组统计
+ print("\n3. 按分类分组统计:")
+ print(analyzer.group_by_agg('category', {'views': ['sum', 'mean'], 'likes': ['sum', 'mean']}))
+
+ # Top N
+ print("\n4. 浏览量 Top 3:")
+ print(analyzer.top_n(3, 'views'))
+
+ # 分布统计
+ print("\n5. 浏览量分布统计:")
+ stats = StatisticsCalculator.distribution_stats(analyzer.df['views'])
+ for key, value in stats.items():
+ print(f" {key}: {value:.2f}")
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ demo()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/pyproject.toml" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/pyproject.toml"
new file mode 100644
index 0000000..4ed32e4
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/pyproject.toml"
@@ -0,0 +1,24 @@
+[project]
+name = "chapter10-data-analysis"
+version = "0.1.0"
+description = "第10章:数据分析与可视化 - Pandas分析、词云生成、图表制作"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ "pandas>=2.2.0",
+ "jieba>=0.42.0",
+ "wordcloud>=1.9.0",
+ "matplotlib>=3.8.0",
+]
+
+[project.optional-dependencies]
+interactive = [
+ "pyecharts>=2.0.0", # 交互式图表
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.uv]
+dev-dependencies = []
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/wordcloud_generator.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/wordcloud_generator.py"
new file mode 100644
index 0000000..10c1d3c
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/10_\346\225\260\346\215\256\345\210\206\346\236\220\344\270\216\345\217\257\350\247\206\345\214\226/wordcloud_generator.py"
@@ -0,0 +1,398 @@
+# -*- coding: utf-8 -*-
+# @Desc: 词云生成工具
+from __future__ import annotations
+
+import os
+from typing import List, Dict, Tuple, Optional
+from collections import Counter
+from loguru import logger
+
+# 可选依赖
+try:
+ import jieba
+ import jieba.analyse
+ HAS_JIEBA = True
+except ImportError:
+ HAS_JIEBA = False
+ logger.warning("jieba 未安装,中文分词功能不可用")
+
+try:
+ from wordcloud import WordCloud, ImageColorGenerator
+ HAS_WORDCLOUD = True
+except ImportError:
+ HAS_WORDCLOUD = False
+ logger.warning("wordcloud 未安装,词云生成功能不可用")
+
+try:
+ import numpy as np
+ from PIL import Image
+ HAS_PIL = True
+except ImportError:
+ HAS_PIL = False
+
+
+class ChineseTokenizer:
+ """中文分词器"""
+
+ # 默认停用词
+ DEFAULT_STOPWORDS = {
+ '的', '是', '在', '了', '和', '与', '或', '有', '个', '人',
+ '这', '那', '就', '都', '也', '为', '对', '到', '从', '把',
+ '被', '让', '给', '向', '往', '于', '及', '以', '等', '不',
+ '很', '会', '能', '可', '要', '我', '你', '他', '她', '它',
+ '啊', '吧', '呢', '呀', '哦', '嗯', '哈', '嘿', '么', '吗',
+ '什么', '怎么', '这样', '那样', '如何', '为什么', '怎样',
+ '没有', '已经', '可以', '一个', '一些', '有些', '还是',
+ '但是', '然后', '如果', '因为', '所以', '虽然', '而且',
+ '或者', '并且', '只是', '只有', '就是', '还有', '这个',
+ '那个', '自己', '什么', '这里', '那里', '这些', '那些',
+ }
+
+ def __init__(self, stopwords: set = None, user_dict: str = None):
+ """
+ 初始化分词器
+
+ Args:
+ stopwords: 自定义停用词集合
+ user_dict: 用户词典路径
+ """
+ if not HAS_JIEBA:
+ raise ImportError("请安装 jieba: pip install jieba")
+
+ self.stopwords = stopwords or self.DEFAULT_STOPWORDS
+
+ if user_dict and os.path.exists(user_dict):
+ jieba.load_userdict(user_dict)
+ logger.info(f"加载用户词典: {user_dict}")
+
+ def add_stopwords(self, words: List[str]):
+ """添加停用词"""
+ self.stopwords.update(words)
+
+ def tokenize(self, text: str, min_length: int = 2) -> List[str]:
+ """
+ 分词
+
+ Args:
+ text: 输入文本
+ min_length: 最小词长度
+
+ Returns:
+ 词语列表
+ """
+ words = jieba.lcut(text)
+ return [
+ w for w in words
+ if w not in self.stopwords
+ and len(w) >= min_length
+ and not w.isspace()
+ ]
+
+ def extract_keywords(
+ self,
+ text: str,
+ top_k: int = 20,
+ method: str = 'tfidf'
+ ) -> List[Tuple[str, float]]:
+ """
+ 提取关键词
+
+ Args:
+ text: 输入文本
+ top_k: 返回关键词数量
+ method: 提取方法 ('tfidf' 或 'textrank')
+
+ Returns:
+ (关键词, 权重) 列表
+ """
+ if method == 'tfidf':
+ return jieba.analyse.extract_tags(text, topK=top_k, withWeight=True)
+ elif method == 'textrank':
+ return jieba.analyse.textrank(text, topK=top_k, withWeight=True)
+ else:
+ raise ValueError(f"Unknown method: {method}")
+
+ def word_frequency(
+ self,
+ texts: List[str],
+ top_n: int = 100
+ ) -> List[Tuple[str, int]]:
+ """
+ 统计词频
+
+ Args:
+ texts: 文本列表
+ top_n: 返回 Top N
+
+ Returns:
+ (词语, 频次) 列表
+ """
+ all_words = []
+ for text in texts:
+ words = self.tokenize(text)
+ all_words.extend(words)
+
+ return Counter(all_words).most_common(top_n)
+
+
+class WordCloudGenerator:
+ """词云生成器"""
+
+ # 默认配色方案
+ COLOR_SCHEMES = {
+ 'default': None,
+ 'viridis': 'viridis',
+ 'plasma': 'plasma',
+ 'inferno': 'inferno',
+ 'magma': 'magma',
+ 'cool': 'cool',
+ 'hot': 'hot',
+ }
+
+ def __init__(
+ self,
+ font_path: str = None,
+ width: int = 800,
+ height: int = 600,
+ background_color: str = 'white',
+ max_words: int = 200,
+ max_font_size: int = 100,
+ min_font_size: int = 10,
+ colormap: str = None
+ ):
+ """
+ 初始化词云生成器
+
+ Args:
+ font_path: 字体路径(中文需要指定)
+ width: 图片宽度
+ height: 图片高度
+ background_color: 背景颜色
+ max_words: 最大词数
+ max_font_size: 最大字体大小
+ min_font_size: 最小字体大小
+ colormap: matplotlib 色彩映射名称
+ """
+ if not HAS_WORDCLOUD:
+ raise ImportError("请安装 wordcloud: pip install wordcloud")
+
+ self.font_path = font_path
+ self.width = width
+ self.height = height
+ self.background_color = background_color
+ self.max_words = max_words
+ self.max_font_size = max_font_size
+ self.min_font_size = min_font_size
+ self.colormap = colormap
+
+ def _create_wordcloud(self, **kwargs) -> WordCloud:
+ """创建 WordCloud 对象"""
+ params = {
+ 'font_path': self.font_path,
+ 'width': self.width,
+ 'height': self.height,
+ 'background_color': self.background_color,
+ 'max_words': self.max_words,
+ 'max_font_size': self.max_font_size,
+ 'min_font_size': self.min_font_size,
+ 'colormap': self.colormap,
+ 'random_state': 42,
+ }
+ params.update(kwargs)
+ return WordCloud(**params)
+
+ def generate_from_text(self, text: str, output_path: str) -> str:
+ """
+ 从文本生成词云
+
+ Args:
+ text: 空格分隔的词语文本
+ output_path: 输出路径
+
+ Returns:
+ 输出文件路径
+ """
+ wc = self._create_wordcloud()
+ wc.generate(text)
+ wc.to_file(output_path)
+ logger.info(f"词云已保存: {output_path}")
+ return output_path
+
+ def generate_from_frequencies(
+ self,
+ frequencies: Dict[str, int],
+ output_path: str
+ ) -> str:
+ """
+ 从词频字典生成词云
+
+ Args:
+ frequencies: {词语: 频次} 字典
+ output_path: 输出路径
+
+ Returns:
+ 输出文件路径
+ """
+ wc = self._create_wordcloud()
+ wc.generate_from_frequencies(frequencies)
+ wc.to_file(output_path)
+ logger.info(f"词云已保存: {output_path}")
+ return output_path
+
+ def generate_shaped(
+ self,
+ text: str,
+ mask_image_path: str,
+ output_path: str,
+ use_mask_colors: bool = True
+ ) -> str:
+ """
+ 生成自定义形状的词云
+
+ Args:
+ text: 词语文本
+ mask_image_path: 形状蒙版图片路径
+ output_path: 输出路径
+ use_mask_colors: 是否使用蒙版图片的颜色
+
+ Returns:
+ 输出文件路径
+ """
+ if not HAS_PIL:
+ raise ImportError("请安装 pillow 和 numpy")
+
+ # 读取蒙版
+ mask = np.array(Image.open(mask_image_path))
+
+ wc = self._create_wordcloud(
+ mask=mask,
+ contour_width=1,
+ contour_color='steelblue'
+ )
+
+ wc.generate(text)
+
+ # 使用蒙版颜色
+ if use_mask_colors:
+ image_colors = ImageColorGenerator(mask)
+ wc.recolor(color_func=image_colors)
+
+ wc.to_file(output_path)
+ logger.info(f"形状词云已保存: {output_path}")
+ return output_path
+
+
+class TextToWordCloud:
+ """从原始文本到词云的完整流程"""
+
+ def __init__(
+ self,
+ font_path: str = None,
+ stopwords: set = None,
+ user_dict: str = None
+ ):
+ """
+ 初始化
+
+ Args:
+ font_path: 字体路径
+ stopwords: 停用词
+ user_dict: 用户词典
+ """
+ self.tokenizer = ChineseTokenizer(stopwords, user_dict)
+ self.generator = WordCloudGenerator(font_path)
+
+ def process(
+ self,
+ texts: List[str],
+ output_path: str,
+ top_words: int = 200
+ ) -> str:
+ """
+ 处理文本并生成词云
+
+ Args:
+ texts: 文本列表
+ output_path: 输出路径
+ top_words: 使用的词数量
+
+ Returns:
+ 输出文件路径
+ """
+ # 1. 统计词频
+ word_freq = self.tokenizer.word_frequency(texts, top_words)
+ logger.info(f"统计完成: {len(word_freq)} 个词")
+
+ # 2. 转换为字典
+ freq_dict = dict(word_freq)
+
+ # 3. 生成词云
+ return self.generator.generate_from_frequencies(freq_dict, output_path)
+
+ def get_word_stats(self, texts: List[str], top_n: int = 20) -> List[Tuple[str, int]]:
+ """
+ 获取词频统计
+
+ Args:
+ texts: 文本列表
+ top_n: 返回数量
+
+ Returns:
+ (词语, 频次) 列表
+ """
+ return self.tokenizer.word_frequency(texts, top_n)
+
+
+def demo():
+ """演示词云生成功能"""
+ print("=" * 50)
+ print("词云生成工具演示")
+ print("=" * 50)
+
+ if not HAS_JIEBA:
+ print("请安装 jieba: pip install jieba")
+ return
+
+ if not HAS_WORDCLOUD:
+ print("请安装 wordcloud: pip install wordcloud")
+ return
+
+ # 测试文本
+ texts = [
+ "Python是一门优雅的编程语言,适合数据分析和机器学习",
+ "爬虫技术可以帮助我们获取互联网上的数据",
+ "数据分析是数据科学的重要组成部分",
+ "机器学习和深度学习是人工智能的核心技术",
+ "Python在数据科学领域有广泛的应用",
+ "爬虫需要注意遵守网站的规则和法律法规",
+ "数据可视化可以帮助我们更好地理解数据",
+ "编程是一项需要不断学习和实践的技能",
+ ]
+
+ # 分词
+ tokenizer = ChineseTokenizer()
+ print("\n1. 分词示例:")
+ sample_words = tokenizer.tokenize(texts[0])
+ print(f" 原文: {texts[0]}")
+ print(f" 分词: {sample_words}")
+
+ # 词频统计
+ print("\n2. 词频统计 Top 10:")
+ word_freq = tokenizer.word_frequency(texts, 10)
+ for word, freq in word_freq:
+ print(f" {word}: {freq}")
+
+ # 生成词云(仅打印说明,不实际生成文件)
+ print("\n3. 词云生成:")
+ print(" 词云生成需要指定中文字体路径")
+ print(" 示例代码:")
+ print(" generator = WordCloudGenerator(font_path='/path/to/font.ttf')")
+ print(" generator.generate_from_frequencies(dict(word_freq), 'wordcloud.png')")
+
+ print("\n" + "=" * 50)
+ print("演示完成")
+ print("=" * 50)
+
+
+if __name__ == "__main__":
+ demo()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/README.md" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/README.md"
new file mode 100644
index 0000000..1e450cf
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/README.md"
@@ -0,0 +1,67 @@
+# 第11章:进阶综合实战项目
+
+完整的书籍电商数据采集工具,爬取 **books.toscrape.com**(专门的爬虫练习网站)。
+
+## 快速开始
+
+### 使用 uv 安装依赖
+
+```bash
+cd 11_进阶综合实战项目
+
+# 安装依赖
+uv sync
+
+# 安装可选功能(配置管理增强)
+uv sync --extra all
+
+# 安装Playwright浏览器驱动
+uv run playwright install chromium
+
+# 运行项目
+uv run python main.py
+```
+
+### 目标网站
+
+- **网站**:http://books.toscrape.com
+- **类型**:专门用于爬虫练习的合法网站
+- **特点**:电商结构完整,50页书籍数据,无需登录
+
+### 核心依赖
+
+- `playwright` - 浏览器自动化
+- `httpx` - HTTP客户端
+- `pydantic` - 数据验证
+- `loguru` - 日志系统
+- `pandas` - 数据分析
+- `jieba` + `wordcloud` - 词云生成
+
+## 项目结构
+
+```
+11_进阶综合实战项目/
+├── config/ # 配置模块
+├── core/ # 核心模块(浏览器管理)
+├── login/ # 登录模块
+├── crawler/ # 爬虫模块
+├── store/ # 存储模块
+├── proxy/ # 代理池模块
+├── analysis/ # 分析模块
+└── main.py # 主程序入口
+```
+
+## 功能特性
+
+- ✅ 浏览器自动化采集
+- ✅ 反检测技术(stealth.js)
+- ✅ 多格式数据存储(JSON/CSV)
+- ✅ 词云和统计报告生成
+- ✅ 代理池支持(可选)
+
+## 运行结果
+
+运行成功后会在 `output/` 目录下生成:
+- `data_*.json` 或 `data_*.csv` - 采集的数据
+- `report.md` - 数据分析报告
+- `wordcloud.png` - 词云图片
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/analysis/__init__.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/analysis/__init__.py"
new file mode 100644
index 0000000..047a1e0
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/analysis/__init__.py"
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+"""数据分析模块"""
+from .report import DataAnalyzer, BilibiliAnalyzer, ReportGenerator, generate_report
+
+__all__ = ['DataAnalyzer', 'BilibiliAnalyzer', 'ReportGenerator', 'generate_report']
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/analysis/report.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/analysis/report.py"
new file mode 100644
index 0000000..7d31de5
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/analysis/report.py"
@@ -0,0 +1,584 @@
+# -*- coding: utf-8 -*-
+"""
+数据分析与报告生成模块
+
+本模块实现了 B站视频数据的分析功能,包括:
+- DataAnalyzer: 数据分析器(基础统计、词频分析、分布统计)
+- BilibiliAnalyzer: B站视频专用分析器
+- ReportGenerator: 报告生成器(Markdown 格式)
+
+参考 MediaCrawler 项目的数据分析实践。
+"""
+
+import os
+from typing import List, Dict, Any, Optional, Union
+from datetime import datetime
+from collections import Counter
+from pathlib import Path
+from loguru import logger
+
+# 尝试导入模型
+try:
+ from models.bilibili import BilibiliVideo
+ HAS_MODEL = True
+except ImportError:
+ HAS_MODEL = False
+
+# 可选依赖
+try:
+ import jieba
+ HAS_JIEBA = True
+except ImportError:
+ HAS_JIEBA = False
+
+try:
+ from wordcloud import WordCloud
+ HAS_WORDCLOUD = True
+except ImportError:
+ HAS_WORDCLOUD = False
+
+try:
+ import pandas as pd
+ HAS_PANDAS = True
+except ImportError:
+ HAS_PANDAS = False
+
+
+class DataAnalyzer:
+ """数据分析器"""
+
+ # 中文停用词
+ STOPWORDS = {
+ '的', '是', '在', '了', '和', '与', '或', '有', '个', '人',
+ '这', '那', '就', '都', '也', '为', '对', '到', '从', '把',
+ '被', '让', '给', '向', '往', '于', '及', '以', '等', '不',
+ '很', '会', '能', '可', '要', '我', '你', '他', '她', '它',
+ '啊', '吧', '呢', '呀', '哦', '嗯', '哈', '嘿', '么', '吗',
+ '什么', '怎么', '这样', '那样', '如何', '为什么', '怎样',
+ '一个', '一些', '一种', '一下', '没有', '还是', '已经',
+ }
+
+ def __init__(self, data: List[Dict], output_dir: str = "./output"):
+ """
+ 初始化数据分析器
+
+ Args:
+ data: 数据列表
+ output_dir: 输出目录
+ """
+ self.data = data
+ self.output_dir = Path(output_dir)
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+
+ if HAS_PANDAS and data:
+ self.df = pd.DataFrame(data)
+ else:
+ self.df = None
+
+ def basic_stats(self) -> Dict[str, Any]:
+ """基础统计"""
+ stats = {
+ 'total_records': len(self.data),
+ 'fields': list(self.data[0].keys()) if self.data else [],
+ }
+
+ if self.df is not None:
+ # 数值列统计
+ numeric_cols = self.df.select_dtypes(include=['number']).columns.tolist()
+ if numeric_cols:
+ stats['numeric_fields'] = numeric_cols
+ for col in numeric_cols:
+ stats[f'{col}_sum'] = float(self.df[col].sum())
+ stats[f'{col}_mean'] = float(self.df[col].mean())
+ stats[f'{col}_max'] = float(self.df[col].max())
+ stats[f'{col}_min'] = float(self.df[col].min())
+
+ return stats
+
+ def word_frequency(
+ self,
+ text_field: str,
+ top_n: int = 50,
+ min_length: int = 2
+ ) -> List[tuple]:
+ """
+ 词频统计
+
+ Args:
+ text_field: 文本字段名
+ top_n: 返回 Top N
+ min_length: 最小词长度
+
+ Returns:
+ (词语, 频次) 列表
+ """
+ if not HAS_JIEBA:
+ logger.warning("jieba 未安装,跳过词频分析")
+ return []
+
+ all_words = []
+ for item in self.data:
+ text = item.get(text_field, '')
+ if text:
+ words = jieba.lcut(str(text))
+ words = [
+ w for w in words
+ if w not in self.STOPWORDS
+ and len(w) >= min_length
+ and not w.isspace()
+ ]
+ all_words.extend(words)
+
+ return Counter(all_words).most_common(top_n)
+
+ def generate_wordcloud(
+ self,
+ text_field: str,
+ output_file: str = "wordcloud.png",
+ font_path: str = None,
+ width: int = 1200,
+ height: int = 800
+ ) -> Optional[str]:
+ """
+ 生成词云
+
+ Args:
+ text_field: 文本字段名
+ output_file: 输出文件名
+ font_path: 字体路径(中文需要指定)
+ width: 图片宽度
+ height: 图片高度
+
+ Returns:
+ 输出文件路径,失败返回 None
+ """
+ if not HAS_WORDCLOUD:
+ logger.warning("wordcloud 未安装,跳过词云生成")
+ return None
+
+ word_freq = self.word_frequency(text_field, 200)
+ if not word_freq:
+ logger.warning("没有词频数据,跳过词云生成")
+ return None
+
+ freq_dict = dict(word_freq)
+
+ try:
+ wc = WordCloud(
+ width=width,
+ height=height,
+ background_color='white',
+ font_path=font_path,
+ max_words=200,
+ max_font_size=150,
+ random_state=42
+ )
+
+ wc.generate_from_frequencies(freq_dict)
+
+ output_path = self.output_dir / output_file
+ wc.to_file(str(output_path))
+ logger.info(f"词云已保存: {output_path}")
+ return str(output_path)
+ except Exception as e:
+ logger.error(f"生成词云失败: {e}")
+ return None
+
+ def value_distribution(self, field: str) -> Dict[Any, int]:
+ """
+ 值分布统计
+
+ Args:
+ field: 字段名
+
+ Returns:
+ {值: 计数} 字典
+ """
+ counter = Counter()
+ for item in self.data:
+ value = item.get(field)
+ if value is not None:
+ counter[value] += 1
+ return dict(counter)
+
+
+class BilibiliAnalyzer(DataAnalyzer):
+ """
+ B站视频数据分析器
+
+ 提供针对 B站视频数据的专用分析方法。
+ """
+
+ def __init__(
+ self,
+ videos: List[Union[Dict, "BilibiliVideo"]],
+ output_dir: str = "./output"
+ ):
+ """
+ 初始化 B站分析器
+
+ Args:
+ videos: 视频数据列表(BilibiliVideo 或字典)
+ output_dir: 输出目录
+ """
+ # 转换为字典列表
+ data = []
+ for video in videos:
+ if HAS_MODEL and isinstance(video, BilibiliVideo):
+ data.append(video.to_dict())
+ elif hasattr(video, 'model_dump'):
+ data.append(video.model_dump())
+ elif isinstance(video, dict):
+ data.append(video)
+ else:
+ data.append(dict(video))
+
+ super().__init__(data, output_dir)
+
+ def video_metrics_stats(self) -> Dict[str, Any]:
+ """
+ 视频指标统计
+
+ 统计播放量、点赞、收藏、投币等指标。
+
+ Returns:
+ 统计结果字典
+ """
+ metrics = {
+ 'play_count': [],
+ 'liked_count': [],
+ 'coin_count': [],
+ 'favorite_count': [],
+ 'share_count': [],
+ 'danmaku_count': [],
+ 'comment_count': [],
+ }
+
+ for item in self.data:
+ for key in metrics.keys():
+ value = item.get(key, 0)
+ if value is not None:
+ try:
+ metrics[key].append(int(value))
+ except (ValueError, TypeError):
+ metrics[key].append(0)
+
+ stats = {}
+ for key, values in metrics.items():
+ if values:
+ stats[key] = {
+ 'total': sum(values),
+ 'avg': sum(values) / len(values),
+ 'max': max(values),
+ 'min': min(values),
+ }
+
+ return stats
+
+ def up_distribution(self, top_n: int = 10) -> List[tuple]:
+ """
+ UP主分布统计
+
+ Args:
+ top_n: 返回 Top N
+
+ Returns:
+ [(UP主昵称, 视频数量)] 列表
+ """
+ counter = Counter()
+ for item in self.data:
+ nickname = item.get('nickname', '未知UP主')
+ if nickname:
+ counter[nickname] += 1
+ return counter.most_common(top_n)
+
+ def keyword_distribution(self) -> Dict[str, int]:
+ """
+ 搜索关键词分布统计
+
+ Returns:
+ {关键词: 视频数量} 字典
+ """
+ counter = Counter()
+ for item in self.data:
+ keyword = item.get('source_keyword', '')
+ if keyword:
+ counter[keyword] += 1
+ return dict(counter)
+
+ def top_videos(
+ self,
+ metric: str = 'play_count',
+ top_n: int = 10
+ ) -> List[Dict]:
+ """
+ 获取指标排名前 N 的视频
+
+ Args:
+ metric: 排序指标(play_count, liked_count 等)
+ top_n: 返回数量
+
+ Returns:
+ 视频列表
+ """
+ sorted_data = sorted(
+ self.data,
+ key=lambda x: x.get(metric, 0) or 0,
+ reverse=True
+ )
+ return sorted_data[:top_n]
+
+ def generate_title_wordcloud(
+ self,
+ output_file: str = "title_wordcloud.png",
+ font_path: str = None
+ ) -> Optional[str]:
+ """
+ 生成视频标题词云
+
+ Args:
+ output_file: 输出文件名
+ font_path: 字体路径
+
+ Returns:
+ 输出文件路径
+ """
+ return self.generate_wordcloud(
+ text_field='title',
+ output_file=output_file,
+ font_path=font_path
+ )
+
+
+class ReportGenerator:
+ """
+ 报告生成器
+
+ 生成 B站视频数据的 Markdown 格式分析报告。
+ """
+
+ def __init__(
+ self,
+ videos: List[Union[Dict, "BilibiliVideo"]],
+ output_dir: str = "./output"
+ ):
+ """
+ 初始化报告生成器
+
+ Args:
+ videos: 视频数据列表
+ output_dir: 输出目录
+ """
+ self.videos = videos
+ self.output_dir = Path(output_dir)
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+
+ # 创建分析器
+ self.analyzer = BilibiliAnalyzer(videos, output_dir)
+
+ def generate(
+ self,
+ font_path: str = None,
+ title: str = "B站视频数据分析报告"
+ ) -> str:
+ """
+ 生成完整分析报告
+
+ Args:
+ font_path: 字体路径(词云使用)
+ title: 报告标题
+
+ Returns:
+ 报告文件路径
+ """
+ lines = []
+
+ # 标题
+ lines.append(f"# {title}")
+ lines.append("")
+ lines.append(f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+ lines.append(f"> 数据来源: B站视频采集")
+ lines.append(f"> 数据量: {len(self.analyzer.data)} 条")
+ lines.append("")
+ lines.append("---")
+ lines.append("")
+
+ # 1. 视频指标统计
+ lines.append("## 1. 视频指标统计")
+ lines.append("")
+ metrics_stats = self.analyzer.video_metrics_stats()
+ if metrics_stats:
+ lines.append("| 指标 | 总计 | 平均 | 最高 | 最低 |")
+ lines.append("| --- | ---: | ---: | ---: | ---: |")
+
+ metric_names = {
+ 'play_count': '播放量',
+ 'liked_count': '点赞数',
+ 'coin_count': '投币数',
+ 'favorite_count': '收藏数',
+ 'share_count': '分享数',
+ 'danmaku_count': '弹幕数',
+ 'comment_count': '评论数',
+ }
+
+ for key, name in metric_names.items():
+ if key in metrics_stats:
+ stat = metrics_stats[key]
+ lines.append(
+ f"| {name} | "
+ f"{stat['total']:,} | "
+ f"{stat['avg']:,.0f} | "
+ f"{stat['max']:,} | "
+ f"{stat['min']:,} |"
+ )
+ lines.append("")
+
+ # 2. 热门视频 TOP 10
+ lines.append("## 2. 热门视频 TOP 10(按播放量)")
+ lines.append("")
+ top_videos = self.analyzer.top_videos('play_count', 10)
+ if top_videos:
+ lines.append("| 排名 | 标题 | UP主 | 播放量 | 点赞 |")
+ lines.append("| --- | --- | --- | ---: | ---: |")
+ for i, video in enumerate(top_videos, 1):
+ title_short = video.get('title', '')[:30]
+ if len(video.get('title', '')) > 30:
+ title_short += '...'
+ lines.append(
+ f"| {i} | {title_short} | "
+ f"{video.get('nickname', '未知')} | "
+ f"{video.get('play_count', 0):,} | "
+ f"{video.get('liked_count', 0):,} |"
+ )
+ lines.append("")
+
+ # 3. UP主分布
+ lines.append("## 3. UP主分布 TOP 10")
+ lines.append("")
+ up_dist = self.analyzer.up_distribution(10)
+ if up_dist:
+ lines.append("| 排名 | UP主 | 视频数 |")
+ lines.append("| --- | --- | ---: |")
+ for i, (name, count) in enumerate(up_dist, 1):
+ lines.append(f"| {i} | {name} | {count} |")
+ lines.append("")
+
+ # 4. 关键词分布
+ keyword_dist = self.analyzer.keyword_distribution()
+ if keyword_dist and len(keyword_dist) > 1:
+ lines.append("## 4. 搜索关键词分布")
+ lines.append("")
+ lines.append("| 关键词 | 视频数 |")
+ lines.append("| --- | ---: |")
+ for keyword, count in sorted(
+ keyword_dist.items(),
+ key=lambda x: x[1],
+ reverse=True
+ ):
+ lines.append(f"| {keyword} | {count} |")
+ lines.append("")
+
+ # 5. 标题热词 TOP 20
+ if HAS_JIEBA:
+ lines.append("## 5. 标题热词 TOP 20")
+ lines.append("")
+ word_freq = self.analyzer.word_frequency('title', 20)
+ if word_freq:
+ lines.append("| 排名 | 词汇 | 频次 |")
+ lines.append("| --- | --- | ---: |")
+ for i, (word, count) in enumerate(word_freq, 1):
+ lines.append(f"| {i} | {word} | {count} |")
+ lines.append("")
+
+ # 生成词云
+ if HAS_WORDCLOUD:
+ wordcloud_path = self.analyzer.generate_title_wordcloud(
+ font_path=font_path
+ )
+ if wordcloud_path:
+ lines.append("### 标题词云")
+ lines.append("")
+ lines.append("")
+ lines.append("")
+
+ # 6. 数据样本
+ lines.append("## 附录: 数据样本 (前5条)")
+ lines.append("")
+ sample_fields = ['bvid', 'title', 'nickname', 'play_count', 'liked_count']
+ sample_data = self.analyzer.data[:5]
+ if sample_data:
+ lines.append("| " + " | ".join(sample_fields) + " |")
+ lines.append("| " + " | ".join(["---"] * len(sample_fields)) + " |")
+ for item in sample_data:
+ row = []
+ for f in sample_fields:
+ val = str(item.get(f, ''))[:40]
+ val = val.replace('|', '\\|').replace('\n', ' ')
+ row.append(val)
+ lines.append("| " + " | ".join(row) + " |")
+ lines.append("")
+
+ # 保存报告
+ report_content = '\n'.join(lines)
+ report_path = self.output_dir / "report.md"
+ with open(report_path, 'w', encoding='utf-8') as f:
+ f.write(report_content)
+
+ logger.info(f"报告已保存: {report_path}")
+ return str(report_path)
+
+
+# 便捷函数
+def generate_report(
+ videos: List[Union[Dict, "BilibiliVideo"]],
+ output_dir: str = "./output",
+ font_path: str = None
+) -> str:
+ """
+ 生成分析报告(便捷函数)
+
+ Args:
+ videos: 视频数据列表
+ output_dir: 输出目录
+ font_path: 字体路径
+
+ Returns:
+ 报告文件路径
+ """
+ generator = ReportGenerator(videos, output_dir)
+ return generator.generate(font_path=font_path)
+
+
+if __name__ == '__main__':
+ # 测试代码
+ test_data = [
+ {
+ 'bvid': 'BV1234567890',
+ 'title': 'Python 爬虫入门教程',
+ 'nickname': '技术UP主',
+ 'play_count': 10000,
+ 'liked_count': 500,
+ 'coin_count': 200,
+ 'favorite_count': 300,
+ 'share_count': 50,
+ 'danmaku_count': 100,
+ 'comment_count': 80,
+ 'source_keyword': 'Python教程',
+ },
+ {
+ 'bvid': 'BV0987654321',
+ 'title': 'Python 数据分析实战',
+ 'nickname': '数据分析师',
+ 'play_count': 8000,
+ 'liked_count': 400,
+ 'coin_count': 150,
+ 'favorite_count': 250,
+ 'share_count': 30,
+ 'danmaku_count': 80,
+ 'comment_count': 60,
+ 'source_keyword': 'Python教程',
+ },
+ ]
+
+ report_path = generate_report(test_data, './test_output')
+ print(f"报告已生成: {report_path}")
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/client/__init__.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/client/__init__.py"
new file mode 100644
index 0000000..79a75e7
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/client/__init__.py"
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+"""
+B站 API 客户端模块
+"""
+from .bilibili_client import BilibiliClient
+
+__all__ = ["BilibiliClient"]
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/client/bilibili_client.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/client/bilibili_client.py"
new file mode 100644
index 0000000..09839f7
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/client/bilibili_client.py"
@@ -0,0 +1,369 @@
+# -*- coding: utf-8 -*-
+"""
+B站 API 客户端
+
+本模块封装了 B站的 API 请求,包括:
+- 视频搜索 API
+- 视频详情 API
+- WBI 签名处理
+
+使用 httpx 作为 HTTP 客户端,支持异步请求。
+
+参考 MediaCrawler 项目的实现:
+- https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/bilibili/client.py
+"""
+
+import json
+from typing import Dict, Optional, List, Callable, Any
+from loguru import logger
+
+# 可选依赖
+try:
+ import httpx
+ HAS_HTTPX = True
+except ImportError:
+ HAS_HTTPX = False
+
+try:
+ from playwright.async_api import BrowserContext, Page
+ HAS_PLAYWRIGHT = True
+except ImportError:
+ HAS_PLAYWRIGHT = False
+
+from tools.sign import BilibiliSign, extract_wbi_keys_from_urls
+from models.bilibili import BilibiliVideo, BilibiliSearchResponse
+from config import bilibili_config
+
+
+class BilibiliClient:
+ """
+ B站 API 客户端
+
+ 封装 B站的 API 请求,支持 WBI 签名。
+
+ 使用示例:
+ ```python
+ client = BilibiliClient()
+ await client.update_cookies(browser_context)
+
+ # 搜索视频
+ videos = await client.search_video_by_keyword("Python教程", page=1)
+
+ # 获取视频详情
+ video = await client.get_video_info(bvid="BV1xx411c7mD")
+ ```
+ """
+
+ def __init__(self):
+ """初始化客户端"""
+ self.headers = bilibili_config.DEFAULT_HEADERS.copy()
+ self.cookie_dict: Dict[str, str] = {}
+ self._signer: Optional[BilibiliSign] = None
+ self._timeout = bilibili_config.REQUEST_TIMEOUT
+
+ async def update_cookies(self, browser_context: "BrowserContext"):
+ """
+ 从浏览器上下文更新 Cookie
+
+ 登录成功后调用此方法,将浏览器的 Cookie 同步到客户端。
+
+ Args:
+ browser_context: Playwright 浏览器上下文
+ """
+ cookies = await browser_context.cookies()
+ cookie_str = "; ".join([f"{c['name']}={c['value']}" for c in cookies])
+ self.headers["Cookie"] = cookie_str
+ self.cookie_dict = {c['name']: c['value'] for c in cookies}
+ logger.info(f"[BilibiliClient] 更新了 {len(cookies)} 个 Cookie")
+
+ async def init_wbi_sign(self, page: "Page"):
+ """
+ 初始化 WBI 签名器
+
+ 从浏览器的 localStorage 中获取 WBI 密钥。
+
+ Args:
+ page: Playwright 页面对象
+ """
+ try:
+ # 从 localStorage 获取 wbi_img_urls
+ wbi_img_urls = await page.evaluate("""
+ () => {
+ return localStorage.getItem('wbi_img_urls');
+ }
+ """)
+
+ if not wbi_img_urls:
+ logger.warning("[BilibiliClient] 未找到 wbi_img_urls,尝试从 API 获取")
+ await self._fetch_wbi_keys()
+ return
+
+ # 解析 JSON
+ wbi_data = json.loads(wbi_img_urls)
+ img_url = wbi_data.get("imgUrl", "")
+ sub_url = wbi_data.get("subUrl", "")
+
+ if img_url and sub_url:
+ img_key, sub_key = extract_wbi_keys_from_urls(img_url, sub_url)
+ self._signer = BilibiliSign(img_key, sub_key)
+ logger.info("[BilibiliClient] WBI 签名器初始化成功")
+ else:
+ logger.warning("[BilibiliClient] wbi_img_urls 数据不完整")
+ await self._fetch_wbi_keys()
+
+ except Exception as e:
+ logger.error(f"[BilibiliClient] 初始化 WBI 签名器失败: {e}")
+ await self._fetch_wbi_keys()
+
+ async def _fetch_wbi_keys(self):
+ """
+ 从 API 获取 WBI 密钥(备用方案)
+ """
+ try:
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
+ response = await client.get(
+ "https://api.bilibili.com/x/web-interface/nav",
+ headers=self.headers
+ )
+ data = response.json()
+
+ if data.get("code") == 0:
+ wbi_img = data.get("data", {}).get("wbi_img", {})
+ img_url = wbi_img.get("img_url", "")
+ sub_url = wbi_img.get("sub_url", "")
+
+ if img_url and sub_url:
+ img_key, sub_key = extract_wbi_keys_from_urls(img_url, sub_url)
+ self._signer = BilibiliSign(img_key, sub_key)
+ logger.info("[BilibiliClient] 从 API 获取 WBI 密钥成功")
+ return
+
+ logger.error("[BilibiliClient] 无法获取 WBI 密钥")
+
+ except Exception as e:
+ logger.error(f"[BilibiliClient] 获取 WBI 密钥失败: {e}")
+
+ async def _request(
+ self,
+ method: str,
+ url: str,
+ params: Optional[Dict] = None,
+ data: Optional[Dict] = None,
+ enable_sign: bool = False
+ ) -> Optional[Dict]:
+ """
+ 发送 HTTP 请求
+
+ Args:
+ method: 请求方法(GET/POST)
+ url: 请求 URL
+ params: URL 参数
+ data: POST 数据
+ enable_sign: 是否启用 WBI 签名
+
+ Returns:
+ Dict: 响应数据
+ """
+ if not HAS_HTTPX:
+ logger.error("[BilibiliClient] httpx 未安装")
+ return None
+
+ try:
+ # 如果需要签名
+ if enable_sign and self._signer and params:
+ params = self._signer.sign(params)
+
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
+ if method.upper() == "GET":
+ response = await client.get(url, params=params, headers=self.headers)
+ else:
+ response = await client.post(url, params=params, data=data, headers=self.headers)
+
+ if response.status_code == 200:
+ return response.json()
+ else:
+ logger.error(f"[BilibiliClient] 请求失败: {response.status_code}")
+ return None
+
+ except Exception as e:
+ logger.error(f"[BilibiliClient] 请求出错: {e}")
+ return None
+
+ async def search_video_by_keyword(
+ self,
+ keyword: str,
+ page: int = 1,
+ page_size: int = 20,
+ order: str = "",
+ ) -> List[BilibiliVideo]:
+ """
+ 按关键词搜索视频
+
+ Args:
+ keyword: 搜索关键词
+ page: 页码(从1开始)
+ page_size: 每页数量(B站固定为20)
+ order: 排序方式(空=综合,click=最多点击,pubdate=最新发布)
+
+ Returns:
+ List[BilibiliVideo]: 视频列表
+ """
+ logger.info(f"[BilibiliClient] 搜索视频: {keyword}, 第 {page} 页")
+
+ params = {
+ "keyword": keyword,
+ "search_type": "video",
+ "page": page,
+ "page_size": page_size,
+ "order": order,
+ }
+
+ data = await self._request(
+ "GET",
+ bilibili_config.SEARCH_URL,
+ params=params,
+ enable_sign=True
+ )
+
+ if not data:
+ return []
+
+ if data.get("code") != 0:
+ logger.error(f"[BilibiliClient] 搜索失败: {data.get('message')}")
+ return []
+
+ # 解析搜索结果
+ result = data.get("data", {})
+ video_list = result.get("result", [])
+
+ videos = []
+ for item in video_list:
+ try:
+ video = BilibiliVideo.from_search_result(item, keyword)
+ videos.append(video)
+ except Exception as e:
+ logger.debug(f"[BilibiliClient] 解析视频失败: {e}")
+ continue
+
+ logger.info(f"[BilibiliClient] 搜索到 {len(videos)} 个视频")
+ return videos
+
+ async def get_video_info(
+ self,
+ aid: Optional[str] = None,
+ bvid: Optional[str] = None
+ ) -> Optional[BilibiliVideo]:
+ """
+ 获取视频详情
+
+ aid 和 bvid 至少提供一个。
+
+ Args:
+ aid: 视频 aid
+ bvid: 视频 BV 号
+
+ Returns:
+ BilibiliVideo: 视频信息
+ """
+ if not aid and not bvid:
+ logger.error("[BilibiliClient] aid 和 bvid 至少提供一个")
+ return None
+
+ params = {}
+ if bvid:
+ params["bvid"] = bvid
+ elif aid:
+ params["aid"] = aid
+
+ logger.info(f"[BilibiliClient] 获取视频详情: {bvid or aid}")
+
+ data = await self._request(
+ "GET",
+ bilibili_config.VIDEO_INFO_URL,
+ params=params,
+ enable_sign=False # 视频详情 API 不需要签名
+ )
+
+ if not data:
+ return None
+
+ if data.get("code") != 0:
+ logger.error(f"[BilibiliClient] 获取视频详情失败: {data.get('message')}")
+ return None
+
+ video_data = data.get("data", {})
+ return BilibiliVideo.from_api_response(video_data)
+
+ async def get_video_info_batch(
+ self,
+ bvid_list: List[str],
+ callback: Optional[Callable[[BilibiliVideo], Any]] = None
+ ) -> List[BilibiliVideo]:
+ """
+ 批量获取视频详情
+
+ Args:
+ bvid_list: BV 号列表
+ callback: 每获取一个视频后的回调函数
+
+ Returns:
+ List[BilibiliVideo]: 视频列表
+ """
+ videos = []
+
+ for bvid in bvid_list:
+ video = await self.get_video_info(bvid=bvid)
+ if video:
+ videos.append(video)
+ if callback:
+ await callback(video) if asyncio.iscoroutinefunction(callback) else callback(video)
+
+ return videos
+
+ async def pong(self) -> bool:
+ """
+ 检查登录状态
+
+ 通过调用 nav API 检查是否已登录。
+
+ Returns:
+ bool: 是否已登录
+ """
+ try:
+ data = await self._request(
+ "GET",
+ "https://api.bilibili.com/x/web-interface/nav",
+ enable_sign=False
+ )
+
+ if data and data.get("code") == 0:
+ user_data = data.get("data", {})
+ if user_data.get("isLogin"):
+ username = user_data.get("uname", "未知用户")
+ logger.info(f"[BilibiliClient] 已登录: {username}")
+ return True
+
+ return False
+
+ except Exception as e:
+ logger.debug(f"[BilibiliClient] 检查登录状态失败: {e}")
+ return False
+
+
+# 为了兼容性,添加 asyncio 导入
+import asyncio
+
+
+if __name__ == '__main__':
+ # 测试代码
+ async def test():
+ client = BilibiliClient()
+
+ # 测试搜索(无需登录,但可能被限制)
+ videos = await client.search_video_by_keyword("Python教程", page=1)
+ print(f"搜索到 {len(videos)} 个视频")
+
+ for video in videos[:3]:
+ print(f" - {video.title} (播放: {video.play_count})")
+
+ asyncio.run(test())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/config/__init__.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/config/__init__.py"
new file mode 100644
index 0000000..668c896
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/config/__init__.py"
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+"""
+配置模块
+"""
+from .settings import settings, Settings, StorageType, LoginType, CrawlerType
+from . import bilibili_config
+
+__all__ = [
+ 'settings',
+ 'Settings',
+ 'StorageType',
+ 'LoginType',
+ 'CrawlerType',
+ 'bilibili_config',
+]
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/config/bilibili_config.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/config/bilibili_config.py"
new file mode 100644
index 0000000..d039bc6
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/config/bilibili_config.py"
@@ -0,0 +1,100 @@
+# -*- coding: utf-8 -*-
+"""
+B站特定配置
+
+本模块包含 B站 API 相关的常量和配置,包括:
+- API 端点地址
+- 请求头配置
+- WBI 签名相关常量
+- 搜索排序类型
+"""
+
+from enum import Enum
+
+
+class SearchOrderType(str, Enum):
+ """搜索排序类型"""
+ DEFAULT = "" # 综合排序
+ MOST_CLICK = "click" # 最多点击
+ LAST_PUBLISH = "pubdate" # 最新发布
+ MOST_DANMU = "dm" # 最多弹幕
+ MOST_MARK = "stow" # 最多收藏
+
+
+# ==================== API 端点 ====================
+
+# B站主站
+BILIBILI_URL = "https://www.bilibili.com"
+
+# 搜索 API
+SEARCH_URL = "https://api.bilibili.com/x/web-interface/wbi/search/type"
+
+# 视频详情 API
+VIDEO_INFO_URL = "https://api.bilibili.com/x/web-interface/view"
+
+# 视频播放地址 API
+VIDEO_PLAY_URL = "https://api.bilibili.com/x/player/playurl"
+
+# 用户信息 API
+USER_INFO_URL = "https://api.bilibili.com/x/space/wbi/acc/info"
+
+
+# ==================== 请求头配置 ====================
+
+DEFAULT_HEADERS = {
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ "Referer": "https://www.bilibili.com",
+ "Accept": "application/json, text/plain, */*",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Origin": "https://www.bilibili.com",
+}
+
+
+# ==================== WBI 签名相关 ====================
+
+# WBI 签名混淆映射表(固定值)
+WBI_MIXIN_KEY_ENC_TAB = [
+ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
+ 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
+ 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
+ 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
+]
+
+
+# ==================== 登录相关 ====================
+
+# 登录页面 URL
+LOGIN_URL = "https://www.bilibili.com"
+
+# 登录按钮选择器
+LOGIN_BUTTON_SELECTOR = "xpath=//div[@class='right-entry__outside go-login-btn']//div"
+
+# 二维码选择器
+QRCODE_SELECTOR = "//div[@class='login-scan-box']//img"
+
+# 登录成功后的 Cookie 关键字段
+LOGIN_COOKIE_KEYS = ["SESSDATA", "DedeUserID", "bili_jct"]
+
+
+# ==================== 页面选择器 ====================
+
+# 搜索结果项选择器
+SEARCH_ITEM_SELECTOR = ".video-list-item"
+
+# 视频标题选择器
+VIDEO_TITLE_SELECTOR = ".title"
+
+# 视频播放量选择器
+VIDEO_PLAY_COUNT_SELECTOR = ".play-count"
+
+
+# ==================== 其他配置 ====================
+
+# 每页搜索结果数量(B站固定值)
+SEARCH_PAGE_SIZE = 20
+
+# 最大重试次数
+MAX_RETRY_COUNT = 3
+
+# 请求超时时间(秒)
+REQUEST_TIMEOUT = 10
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/config/settings.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/config/settings.py"
new file mode 100644
index 0000000..24f7d61
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/config/settings.py"
@@ -0,0 +1,121 @@
+# -*- coding: utf-8 -*-
+"""
+项目配置模块
+
+本模块定义了 B站爬虫项目的所有配置项,包括:
+- 基础配置(应用名称、调试模式)
+- 浏览器配置(无头模式、超时时间)
+- 登录配置(登录方式、Cookie文件)
+- 代理配置(是否启用、API地址)
+- 爬虫配置(最大页数、延迟时间)
+- 存储配置(存储类型、输出目录)
+"""
+
+from typing import Optional, List
+from enum import Enum
+
+# 尝试导入 pydantic-settings,如果不存在则使用简单的配置类
+try:
+ from pydantic_settings import BaseSettings
+ from pydantic import Field
+ HAS_PYDANTIC_SETTINGS = True
+except ImportError:
+ HAS_PYDANTIC_SETTINGS = False
+
+
+class StorageType(str, Enum):
+ """存储类型枚举"""
+ JSON = "json"
+ CSV = "csv"
+
+
+class LoginType(str, Enum):
+ """登录类型枚举"""
+ COOKIE = "cookie"
+ QRCODE = "qrcode"
+
+
+class CrawlerType(str, Enum):
+ """爬虫类型枚举"""
+ SEARCH = "search" # 关键词搜索
+ DETAIL = "detail" # 指定视频详情
+
+
+if HAS_PYDANTIC_SETTINGS:
+ class Settings(BaseSettings):
+ """项目配置(使用 pydantic-settings)"""
+
+ # 基础配置
+ app_name: str = "BilibiliCrawler"
+ debug: bool = False
+
+ # 浏览器配置
+ browser_headless: bool = False # B站扫码登录需要显示浏览器
+ browser_timeout: int = 30000
+ browser_user_data_dir: Optional[str] = "browser_data/bili_user_data"
+
+ # 登录配置
+ login_type: LoginType = LoginType.QRCODE
+ cookie_str: str = "" # Cookie 字符串,当 login_type=cookie 时使用
+ save_login_state: bool = True # 是否保存登录状态
+
+ # 代理配置
+ proxy_enabled: bool = False
+ proxy_api_url: Optional[str] = None
+
+ # B站爬虫配置
+ crawler_type: CrawlerType = CrawlerType.SEARCH
+ keywords: str = "Python教程" # 搜索关键词,多个用逗号分隔
+ specified_id_list: List[str] = [] # 指定视频列表(BV号)
+ max_video_count: int = 20 # 最大爬取视频数量
+ crawl_delay_min: float = 1.0
+ crawl_delay_max: float = 3.0
+ max_concurrency: int = 3 # 最大并发数
+
+ # 存储配置
+ storage_type: StorageType = StorageType.JSON
+ storage_output_dir: str = "./output"
+
+ class Config:
+ env_file = ".env"
+ env_prefix = "BILI_"
+
+else:
+ class Settings:
+ """项目配置(简单实现,无 pydantic-settings 依赖)"""
+
+ def __init__(self):
+ # 基础配置
+ self.app_name = "BilibiliCrawler"
+ self.debug = False
+
+ # 浏览器配置
+ self.browser_headless = False # B站扫码登录需要显示浏览器
+ self.browser_timeout = 30000
+ self.browser_user_data_dir = "browser_data/bili_user_data"
+
+ # 登录配置
+ self.login_type = LoginType.QRCODE
+ self.cookie_str = "" # Cookie 字符串
+ self.save_login_state = True
+
+ # 代理配置
+ self.proxy_enabled = False
+ self.proxy_api_url = None
+
+ # B站爬虫配置
+ self.crawler_type = CrawlerType.SEARCH
+ self.keywords = "Python教程"
+ self.specified_id_list = []
+ self.max_video_count = 20
+ self.crawl_delay_min = 1.0
+ self.crawl_delay_max = 3.0
+ self.max_concurrency = 3
+
+ # 存储配置
+ self.storage_type = StorageType.JSON
+ self.storage_output_dir = "./output"
+
+
+# 全局配置实例
+settings = Settings()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/core/__init__.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/core/__init__.py"
new file mode 100644
index 0000000..db73a96
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/core/__init__.py"
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+from .browser import BrowserManager
+
+__all__ = ['BrowserManager']
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/core/browser.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/core/browser.py"
new file mode 100644
index 0000000..00c76e0
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/core/browser.py"
@@ -0,0 +1,155 @@
+# -*- coding: utf-8 -*-
+# @Desc: 浏览器管理模块
+from __future__ import annotations
+
+from typing import Optional
+from loguru import logger
+
+# 可选依赖
+try:
+ from playwright.async_api import async_playwright, Browser, BrowserContext, Page
+ HAS_PLAYWRIGHT = True
+except ImportError:
+ HAS_PLAYWRIGHT = False
+ logger.warning("playwright 未安装")
+
+# stealth.min.js 内容(简化版)
+STEALTH_JS = """
+// 隐藏 webdriver 属性
+Object.defineProperty(navigator, 'webdriver', {
+ get: () => undefined
+});
+
+// 修改 plugins
+Object.defineProperty(navigator, 'plugins', {
+ get: () => [1, 2, 3, 4, 5]
+});
+
+// 修改 languages
+Object.defineProperty(navigator, 'languages', {
+ get: () => ['zh-CN', 'zh', 'en']
+});
+
+// 修改 platform
+Object.defineProperty(navigator, 'platform', {
+ get: () => 'MacIntel'
+});
+
+// 隐藏 automation 特征
+window.chrome = {
+ runtime: {}
+};
+
+// 修改 permissions
+const originalQuery = window.navigator.permissions.query;
+window.navigator.permissions.query = (parameters) => (
+ parameters.name === 'notifications' ?
+ Promise.resolve({ state: Notification.permission }) :
+ originalQuery(parameters)
+);
+"""
+
+
+class BrowserManager:
+ """浏览器管理器"""
+
+ def __init__(
+ self,
+ headless: bool = True,
+ timeout: int = 30000,
+ user_data_dir: str = None,
+ proxy: str = None
+ ):
+ """
+ 初始化浏览器管理器
+
+ Args:
+ headless: 是否无头模式
+ timeout: 默认超时时间(毫秒)
+ user_data_dir: 用户数据目录
+ proxy: 代理服务器地址
+ """
+ if not HAS_PLAYWRIGHT:
+ raise ImportError("请安装 playwright: pip install playwright && playwright install")
+
+ self.headless = headless
+ self.timeout = timeout
+ self.user_data_dir = user_data_dir
+ self.proxy = proxy
+
+ self._playwright = None
+ self._browser: Optional[Browser] = None
+ self._context: Optional[BrowserContext] = None
+
+ async def start(self) -> BrowserContext:
+ """启动浏览器"""
+ self._playwright = await async_playwright().start()
+
+ # 浏览器启动参数
+ launch_args = [
+ '--disable-blink-features=AutomationControlled',
+ '--no-sandbox',
+ '--disable-dev-shm-usage',
+ ]
+
+ # 启动浏览器
+ self._browser = await self._playwright.chromium.launch(
+ headless=self.headless,
+ args=launch_args
+ )
+
+ # 创建上下文
+ context_options = {
+ 'viewport': {'width': 1920, 'height': 1080},
+ 'user_agent': (
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) '
+ 'AppleWebKit/537.36 (KHTML, like Gecko) '
+ 'Chrome/131.0.0.0 Safari/537.36'
+ ),
+ 'locale': 'zh-CN',
+ 'timezone_id': 'Asia/Shanghai',
+ }
+
+ if self.proxy:
+ context_options['proxy'] = {'server': self.proxy}
+
+ self._context = await self._browser.new_context(**context_options)
+
+ # 注入反检测脚本
+ await self._context.add_init_script(STEALTH_JS)
+
+ logger.info("浏览器启动成功")
+ return self._context
+
+ async def new_page(self) -> 'Page':
+ """创建新页面"""
+ if not self._context:
+ await self.start()
+ page = await self._context.new_page()
+ page.set_default_timeout(self.timeout)
+ return page
+
+ async def close(self):
+ """关闭浏览器"""
+ if self._context:
+ await self._context.close()
+ self._context = None
+ if self._browser:
+ await self._browser.close()
+ self._browser = None
+ if self._playwright:
+ await self._playwright.stop()
+ self._playwright = None
+ logger.info("浏览器已关闭")
+
+ @property
+ def context(self) -> Optional[BrowserContext]:
+ """获取浏览器上下文"""
+ return self._context
+
+ async def __aenter__(self):
+ await self.start()
+ return self
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
+ await self.close()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/crawler/__init__.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/crawler/__init__.py"
new file mode 100644
index 0000000..524d609
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/crawler/__init__.py"
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+"""爬虫模块"""
+from .spider import BilibiliCrawler, run_crawler
+
+__all__ = ['BilibiliCrawler', 'run_crawler']
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/crawler/spider.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/crawler/spider.py"
new file mode 100644
index 0000000..bab1756
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/crawler/spider.py"
@@ -0,0 +1,328 @@
+# -*- coding: utf-8 -*-
+"""
+B站爬虫模块
+
+本模块实现了 B站视频数据的爬取功能,包括:
+- 关键词搜索视频
+- 获取指定视频详情
+- 并发爬取控制
+- 数据存储回调
+
+参考 MediaCrawler 项目的实现:
+- https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/bilibili/core.py
+"""
+
+import asyncio
+import random
+from typing import List, Dict, Optional, Callable, Any
+from loguru import logger
+
+# 可选依赖
+try:
+ from playwright.async_api import async_playwright, Browser, BrowserContext, Page
+ HAS_PLAYWRIGHT = True
+except ImportError:
+ HAS_PLAYWRIGHT = False
+
+from config import settings, CrawlerType
+from config import bilibili_config
+from core.browser import BrowserManager
+from login.auth import BilibiliLogin
+from client.bilibili_client import BilibiliClient
+from models.bilibili import BilibiliVideo
+from tools.sign import parse_video_info_from_url
+
+
+class BilibiliCrawler:
+ """
+ B站爬虫类
+
+ 整合浏览器管理、登录认证、API客户端,实现完整的爬取流程。
+
+ 使用示例:
+ ```python
+ crawler = BilibiliCrawler()
+ videos = await crawler.start()
+ ```
+ """
+
+ def __init__(self):
+ """初始化爬虫"""
+ self.browser_manager: Optional[BrowserManager] = None
+ self.browser_context: Optional[BrowserContext] = None
+ self.context_page: Optional[Page] = None
+ self.bili_client: Optional[BilibiliClient] = None
+
+ # 爬取结果
+ self._results: List[BilibiliVideo] = []
+
+ # 配置
+ self.max_video_count = settings.max_video_count
+ self.max_concurrency = settings.max_concurrency
+ self.delay_min = settings.crawl_delay_min
+ self.delay_max = settings.crawl_delay_max
+
+ async def start(self) -> List[BilibiliVideo]:
+ """
+ 启动爬虫
+
+ 完整流程:
+ 1. 启动浏览器
+ 2. 执行登录
+ 3. 初始化 API 客户端
+ 4. 根据配置执行爬取
+ 5. 关闭浏览器
+
+ Returns:
+ List[BilibiliVideo]: 爬取的视频列表
+ """
+ logger.info(f"[BilibiliCrawler] 启动爬虫,类型: {settings.crawler_type}")
+
+ try:
+ # 1. 启动浏览器
+ await self._init_browser()
+
+ # 2. 执行登录
+ login_success = await self._do_login()
+ if not login_success:
+ logger.error("[BilibiliCrawler] 登录失败,退出")
+ return []
+
+ # 3. 初始化 API 客户端
+ await self._init_client()
+
+ # 4. 根据配置执行爬取
+ if settings.crawler_type == CrawlerType.SEARCH:
+ await self.search_by_keywords()
+ elif settings.crawler_type == CrawlerType.DETAIL:
+ await self.get_specified_videos()
+ else:
+ logger.error(f"[BilibiliCrawler] 不支持的爬取类型: {settings.crawler_type}")
+
+ logger.info(f"[BilibiliCrawler] 爬取完成,共 {len(self._results)} 个视频")
+ return self._results
+
+ except Exception as e:
+ logger.exception(f"[BilibiliCrawler] 爬取出错: {e}")
+ return self._results
+
+ finally:
+ # 5. 关闭浏览器
+ await self.close()
+
+ async def _init_browser(self):
+ """初始化浏览器"""
+ logger.info("[BilibiliCrawler] 初始化浏览器...")
+
+ self.browser_manager = BrowserManager(
+ headless=settings.browser_headless,
+ timeout=settings.browser_timeout,
+ user_data_dir=settings.browser_user_data_dir if settings.save_login_state else None
+ )
+
+ self.browser_context = await self.browser_manager.start()
+ self.context_page = await self.browser_manager.new_page()
+
+ logger.info("[BilibiliCrawler] 浏览器初始化完成")
+
+ async def _do_login(self) -> bool:
+ """
+ 执行登录
+
+ Returns:
+ bool: 是否登录成功
+ """
+ # 先检查是否已登录(通过保存的状态)
+ self.bili_client = BilibiliClient()
+ await self.bili_client.update_cookies(self.browser_context)
+
+ if await self.bili_client.pong():
+ logger.info("[BilibiliCrawler] 已有登录状态,跳过登录")
+ return True
+
+ # 执行登录
+ login = BilibiliLogin(
+ login_type=settings.login_type.value,
+ browser_context=self.browser_context,
+ context_page=self.context_page,
+ cookie_str=settings.cookie_str
+ )
+
+ success = await login.begin()
+
+ if success:
+ # 更新客户端 Cookie
+ await self.bili_client.update_cookies(self.browser_context)
+
+ return success
+
+ async def _init_client(self):
+ """初始化 API 客户端"""
+ # 初始化 WBI 签名器
+ await self.bili_client.init_wbi_sign(self.context_page)
+
+ async def search_by_keywords(self) -> List[BilibiliVideo]:
+ """
+ 按关键词搜索视频
+
+ 支持多个关键词(逗号分隔)。
+
+ Returns:
+ List[BilibiliVideo]: 视频列表
+ """
+ keywords = [kw.strip() for kw in settings.keywords.split(",") if kw.strip()]
+
+ if not keywords:
+ logger.warning("[BilibiliCrawler] 未配置搜索关键词")
+ return []
+
+ logger.info(f"[BilibiliCrawler] 开始搜索,关键词: {keywords}")
+
+ for keyword in keywords:
+ await self._search_single_keyword(keyword)
+
+ # 达到最大数量后停止
+ if len(self._results) >= self.max_video_count:
+ break
+
+ return self._results
+
+ async def _search_single_keyword(self, keyword: str):
+ """
+ 搜索单个关键词
+
+ Args:
+ keyword: 搜索关键词
+ """
+ page = 1
+ page_size = bilibili_config.SEARCH_PAGE_SIZE
+
+ while len(self._results) < self.max_video_count:
+ logger.info(f"[BilibiliCrawler] 搜索 '{keyword}',第 {page} 页")
+
+ # 搜索视频
+ videos = await self.bili_client.search_video_by_keyword(
+ keyword=keyword,
+ page=page,
+ page_size=page_size
+ )
+
+ if not videos:
+ logger.info(f"[BilibiliCrawler] '{keyword}' 第 {page} 页无结果,停止搜索")
+ break
+
+ # 获取视频详情
+ for video in videos:
+ if len(self._results) >= self.max_video_count:
+ break
+
+ # 获取完整视频详情
+ video_detail = await self.bili_client.get_video_info(bvid=video.bvid)
+ if video_detail:
+ video_detail.source_keyword = keyword
+ self._results.append(video_detail)
+ logger.info(f"[BilibiliCrawler] 获取视频: {video_detail.title[:30]}...")
+ else:
+ # 如果获取详情失败,使用搜索结果
+ self._results.append(video)
+
+ # 随机延迟
+ await self._random_delay()
+
+ page += 1
+
+ # 防止无限循环
+ if page > 50:
+ break
+
+ async def get_specified_videos(self) -> List[BilibiliVideo]:
+ """
+ 获取指定视频列表的详情
+
+ 从配置中读取视频列表(BV号或URL)。
+
+ Returns:
+ List[BilibiliVideo]: 视频列表
+ """
+ video_list = settings.specified_id_list
+
+ if not video_list:
+ logger.warning("[BilibiliCrawler] 未配置指定视频列表")
+ return []
+
+ logger.info(f"[BilibiliCrawler] 获取 {len(video_list)} 个指定视频")
+
+ for video_id in video_list:
+ if len(self._results) >= self.max_video_count:
+ break
+
+ # 解析 BV 号
+ try:
+ video_info = parse_video_info_from_url(video_id)
+ bvid = video_info.video_id
+ except ValueError:
+ logger.warning(f"[BilibiliCrawler] 无法解析视频 ID: {video_id}")
+ continue
+
+ # 获取视频详情
+ video = await self.bili_client.get_video_info(bvid=bvid)
+ if video:
+ self._results.append(video)
+ logger.info(f"[BilibiliCrawler] 获取视频: {video.title[:30]}...")
+
+ # 随机延迟
+ await self._random_delay()
+
+ return self._results
+
+ async def _random_delay(self):
+ """随机延迟,避免请求过快"""
+ delay = random.uniform(self.delay_min, self.delay_max)
+ await asyncio.sleep(delay)
+
+ def get_results(self) -> List[BilibiliVideo]:
+ """
+ 获取爬取结果
+
+ Returns:
+ List[BilibiliVideo]: 视频列表
+ """
+ return self._results
+
+ async def close(self):
+ """关闭浏览器"""
+ if self.browser_manager:
+ await self.browser_manager.close()
+ logger.info("[BilibiliCrawler] 浏览器已关闭")
+
+
+async def run_crawler(
+ on_video: Optional[Callable[[BilibiliVideo], Any]] = None
+) -> List[BilibiliVideo]:
+ """
+ 运行爬虫(便捷函数)
+
+ Args:
+ on_video: 每获取一个视频后的回调函数
+
+ Returns:
+ List[BilibiliVideo]: 视频列表
+ """
+ crawler = BilibiliCrawler()
+ return await crawler.start()
+
+
+if __name__ == '__main__':
+ # 测试代码
+ async def test():
+ crawler = BilibiliCrawler()
+ videos = await crawler.start()
+
+ print(f"\n爬取完成,共 {len(videos)} 个视频:")
+ for i, video in enumerate(videos[:5], 1):
+ print(f"{i}. {video.title}")
+ print(f" UP主: {video.nickname}")
+ print(f" 播放: {video.play_count}")
+ print()
+
+ asyncio.run(test())
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/login/__init__.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/login/__init__.py"
new file mode 100644
index 0000000..5923317
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/login/__init__.py"
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+"""登录认证模块"""
+from .auth import (
+ BilibiliLogin,
+ AbstractLogin,
+ convert_cookies_to_str,
+ convert_cookies_to_dict,
+ save_cookies_to_file,
+ load_cookies_from_file,
+)
+
+__all__ = [
+ 'BilibiliLogin',
+ 'AbstractLogin',
+ 'convert_cookies_to_str',
+ 'convert_cookies_to_dict',
+ 'save_cookies_to_file',
+ 'load_cookies_from_file',
+]
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/login/auth.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/login/auth.py"
new file mode 100644
index 0000000..41cd240
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/login/auth.py"
@@ -0,0 +1,487 @@
+# -*- coding: utf-8 -*-
+"""
+B站登录认证模块
+
+本模块实现了 B站的登录认证功能,支持两种登录方式:
+1. 扫码登录:显示二维码,用户使用 B站 APP 扫码登录
+2. Cookie 登录:使用已有的 Cookie 字符串直接登录
+
+登录成功后会将 Cookie 保存到浏览器上下文中,后续请求自动携带。
+
+参考 MediaCrawler 项目的实现:
+- https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/bilibili/login.py
+"""
+
+import asyncio
+import base64
+import json
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import Optional, List, Dict
+from loguru import logger
+
+# 可选依赖
+try:
+ from playwright.async_api import BrowserContext, Page
+ HAS_PLAYWRIGHT = True
+except ImportError:
+ HAS_PLAYWRIGHT = False
+
+# 尝试导入图片显示库
+try:
+ from PIL import Image
+ from io import BytesIO
+ HAS_PIL = True
+except ImportError:
+ HAS_PIL = False
+
+# 尝试导入 httpx
+try:
+ import httpx
+ HAS_HTTPX = True
+except ImportError:
+ HAS_HTTPX = False
+
+
+# ==================== B站登录相关常量 ====================
+
+# B站主页
+BILIBILI_URL = "https://www.bilibili.com"
+
+# 登录按钮选择器
+LOGIN_BUTTON_SELECTOR = "xpath=//div[@class='right-entry__outside go-login-btn']//div"
+
+# 二维码选择器
+QRCODE_SELECTOR = "//div[@class='login-scan-box']//img"
+
+# 登录成功后的 Cookie 关键字段
+LOGIN_COOKIE_KEYS = ["SESSDATA", "DedeUserID", "bili_jct"]
+
+
+class AbstractLogin(ABC):
+ """登录抽象基类"""
+
+ @abstractmethod
+ async def begin(self) -> bool:
+ """开始登录流程"""
+ pass
+
+ @abstractmethod
+ async def login_by_qrcode(self) -> bool:
+ """扫码登录"""
+ pass
+
+ @abstractmethod
+ async def login_by_cookies(self) -> bool:
+ """Cookie 登录"""
+ pass
+
+ @abstractmethod
+ async def check_login_state(self) -> bool:
+ """检查登录状态"""
+ pass
+
+
+class BilibiliLogin(AbstractLogin):
+ """
+ B站登录类
+
+ 支持扫码登录和 Cookie 登录两种方式。
+
+ 使用示例:
+ ```python
+ login = BilibiliLogin(
+ login_type="qrcode",
+ browser_context=context,
+ context_page=page
+ )
+ success = await login.begin()
+ ```
+ """
+
+ def __init__(
+ self,
+ login_type: str,
+ browser_context: "BrowserContext",
+ context_page: "Page",
+ cookie_str: str = "",
+ ):
+ """
+ 初始化 B站登录
+
+ Args:
+ login_type: 登录类型,"qrcode" 或 "cookie"
+ browser_context: Playwright 浏览器上下文
+ context_page: Playwright 页面对象
+ cookie_str: Cookie 字符串(当 login_type="cookie" 时使用)
+ """
+ self.login_type = login_type
+ self.browser_context = browser_context
+ self.context_page = context_page
+ self.cookie_str = cookie_str
+
+ async def begin(self) -> bool:
+ """
+ 开始登录流程
+
+ 根据 login_type 自动选择登录方式。
+
+ Returns:
+ bool: 登录是否成功
+ """
+ logger.info(f"[BilibiliLogin] 开始登录,方式: {self.login_type}")
+
+ if self.login_type == "qrcode":
+ return await self.login_by_qrcode()
+ elif self.login_type == "cookie":
+ return await self.login_by_cookies()
+ else:
+ logger.error(f"[BilibiliLogin] 不支持的登录类型: {self.login_type}")
+ return False
+
+ async def login_by_qrcode(self) -> bool:
+ """
+ 扫码登录
+
+ 流程:
+ 1. 访问 B站首页
+ 2. 点击登录按钮
+ 3. 获取二维码图片并显示
+ 4. 等待用户扫码
+ 5. 检查登录状态
+
+ Returns:
+ bool: 登录是否成功
+ """
+ logger.info("[BilibiliLogin] 开始扫码登录...")
+
+ try:
+ # 1. 访问 B站首页
+ await self.context_page.goto(BILIBILI_URL)
+ await asyncio.sleep(2)
+
+ # 2. 点击登录按钮
+ try:
+ login_button = await self.context_page.wait_for_selector(
+ LOGIN_BUTTON_SELECTOR,
+ timeout=10000
+ )
+ if login_button:
+ await login_button.click()
+ await asyncio.sleep(1)
+ except Exception as e:
+ logger.warning(f"[BilibiliLogin] 点击登录按钮失败: {e}")
+ # 可能已经有登录弹窗,继续尝试
+
+ # 3. 获取二维码
+ qrcode_img = await self._find_login_qrcode()
+ if not qrcode_img:
+ logger.error("[BilibiliLogin] 未找到二维码")
+ return False
+
+ # 4. 显示二维码
+ await self._show_qrcode(qrcode_img)
+
+ # 5. 等待登录成功
+ logger.info("[BilibiliLogin] 请使用 B站 APP 扫描二维码登录...")
+ logger.info("[BilibiliLogin] 等待登录成功(最长等待 120 秒)...")
+
+ # 轮询检查登录状态
+ for _ in range(120): # 最多等待 120 秒
+ if await self.check_login_state():
+ logger.info("[BilibiliLogin] 扫码登录成功!")
+ await asyncio.sleep(2) # 等待页面跳转
+ return True
+ await asyncio.sleep(1)
+
+ logger.error("[BilibiliLogin] 扫码登录超时")
+ return False
+
+ except Exception as e:
+ logger.error(f"[BilibiliLogin] 扫码登录失败: {e}")
+ return False
+
+ async def login_by_cookies(self) -> bool:
+ """
+ Cookie 登录
+
+ 将 Cookie 字符串解析后注入到浏览器上下文中。
+
+ Returns:
+ bool: 登录是否成功
+ """
+ logger.info("[BilibiliLogin] 开始 Cookie 登录...")
+
+ if not self.cookie_str:
+ logger.error("[BilibiliLogin] Cookie 字符串为空")
+ return False
+
+ try:
+ # 解析 Cookie 字符串
+ cookies = self._parse_cookie_str(self.cookie_str)
+ if not cookies:
+ logger.error("[BilibiliLogin] Cookie 解析失败")
+ return False
+
+ # 注入 Cookie
+ await self.browser_context.add_cookies(cookies)
+ logger.info(f"[BilibiliLogin] 成功注入 {len(cookies)} 个 Cookie")
+
+ # 刷新页面验证
+ await self.context_page.goto(BILIBILI_URL)
+ await asyncio.sleep(2)
+
+ # 检查登录状态
+ if await self.check_login_state():
+ logger.info("[BilibiliLogin] Cookie 登录成功!")
+ return True
+ else:
+ logger.error("[BilibiliLogin] Cookie 登录失败,Cookie 可能已过期")
+ return False
+
+ except Exception as e:
+ logger.error(f"[BilibiliLogin] Cookie 登录失败: {e}")
+ return False
+
+ async def check_login_state(self) -> bool:
+ """
+ 检查登录状态
+
+ 通过检查 Cookie 中是否包含关键字段来判断是否已登录。
+ 关键字段:SESSDATA、DedeUserID
+
+ Returns:
+ bool: 是否已登录
+ """
+ try:
+ # 获取当前 Cookie
+ cookies = await self.browser_context.cookies()
+ cookie_dict = {c['name']: c['value'] for c in cookies}
+
+ # 检查关键 Cookie 是否存在
+ for key in ["SESSDATA", "DedeUserID"]:
+ if key in cookie_dict and cookie_dict[key]:
+ return True
+
+ return False
+
+ except Exception as e:
+ logger.debug(f"[BilibiliLogin] 检查登录状态出错: {e}")
+ return False
+
+ async def _find_login_qrcode(self) -> Optional[str]:
+ """
+ 查找登录二维码
+
+ Returns:
+ str: Base64 编码的二维码图片,如果未找到返回 None
+ """
+ try:
+ # 等待二维码出现
+ qrcode_element = await self.context_page.wait_for_selector(
+ QRCODE_SELECTOR,
+ timeout=10000
+ )
+
+ if not qrcode_element:
+ return None
+
+ # 获取二维码图片 src
+ qrcode_src = await qrcode_element.get_attribute("src")
+
+ if not qrcode_src:
+ return None
+
+ # 如果是 URL,下载图片
+ if qrcode_src.startswith("http"):
+ return await self._download_qrcode(qrcode_src)
+
+ # 如果是 base64,直接返回
+ if qrcode_src.startswith("data:image"):
+ return qrcode_src.split(",")[1] if "," in qrcode_src else qrcode_src
+
+ return qrcode_src
+
+ except Exception as e:
+ logger.error(f"[BilibiliLogin] 获取二维码失败: {e}")
+ return None
+
+ async def _download_qrcode(self, url: str) -> Optional[str]:
+ """
+ 下载二维码图片
+
+ Args:
+ url: 二维码图片 URL
+
+ Returns:
+ str: Base64 编码的图片数据
+ """
+ if not HAS_HTTPX:
+ logger.warning("[BilibiliLogin] httpx 未安装,无法下载二维码")
+ return None
+
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(url, timeout=10)
+ if response.status_code == 200:
+ return base64.b64encode(response.content).decode('utf-8')
+ except Exception as e:
+ logger.error(f"[BilibiliLogin] 下载二维码失败: {e}")
+
+ return None
+
+ async def _show_qrcode(self, qrcode_base64: str):
+ """
+ 显示二维码
+
+ 优先使用 PIL 显示,如果不可用则保存到文件。
+
+ Args:
+ qrcode_base64: Base64 编码的二维码图片
+ """
+ # 解码 base64
+ try:
+ qrcode_bytes = base64.b64decode(qrcode_base64)
+ except Exception:
+ logger.error("[BilibiliLogin] 二维码 Base64 解码失败")
+ return
+
+ # 保存到文件
+ qrcode_path = Path("qrcode.png")
+ with open(qrcode_path, 'wb') as f:
+ f.write(qrcode_bytes)
+ logger.info(f"[BilibiliLogin] 二维码已保存到: {qrcode_path.absolute()}")
+
+ # 尝试显示图片
+ if HAS_PIL:
+ try:
+ image = Image.open(BytesIO(qrcode_bytes))
+ image.show()
+ logger.info("[BilibiliLogin] 二维码已显示,请扫码登录")
+ except Exception as e:
+ logger.warning(f"[BilibiliLogin] 无法显示二维码: {e}")
+ logger.info(f"[BilibiliLogin] 请手动打开文件: {qrcode_path.absolute()}")
+ else:
+ logger.info(f"[BilibiliLogin] 请手动打开文件扫码: {qrcode_path.absolute()}")
+
+ # 打印提示
+ print("\n" + "=" * 60)
+ print(" 请使用 B站 APP 扫描二维码登录")
+ print(f" 二维码文件: {qrcode_path.absolute()}")
+ print(" 等待登录中...")
+ print("=" * 60 + "\n")
+
+ def _parse_cookie_str(self, cookie_str: str) -> List[Dict]:
+ """
+ 解析 Cookie 字符串
+
+ Args:
+ cookie_str: Cookie 字符串,格式如 "name1=value1; name2=value2"
+
+ Returns:
+ List[Dict]: Playwright 格式的 Cookie 列表
+ """
+ cookies = []
+
+ for item in cookie_str.split(";"):
+ item = item.strip()
+ if not item or "=" not in item:
+ continue
+
+ parts = item.split("=", 1)
+ name = parts[0].strip()
+ value = parts[1].strip() if len(parts) > 1 else ""
+
+ if name:
+ cookies.append({
+ "name": name,
+ "value": value,
+ "domain": ".bilibili.com",
+ "path": "/"
+ })
+
+ return cookies
+
+
+# ==================== 工具函数 ====================
+
+def convert_cookies_to_str(cookies: List[Dict]) -> str:
+ """
+ 将 Cookie 列表转换为字符串
+
+ Args:
+ cookies: Playwright 格式的 Cookie 列表
+
+ Returns:
+ str: Cookie 字符串
+ """
+ return "; ".join([f"{c['name']}={c['value']}" for c in cookies])
+
+
+def convert_cookies_to_dict(cookies: List[Dict]) -> Dict[str, str]:
+ """
+ 将 Cookie 列表转换为字典
+
+ Args:
+ cookies: Playwright 格式的 Cookie 列表
+
+ Returns:
+ Dict[str, str]: Cookie 字典
+ """
+ return {c['name']: c['value'] for c in cookies}
+
+
+async def save_cookies_to_file(context: "BrowserContext", filepath: str):
+ """
+ 保存 Cookie 到文件
+
+ Args:
+ context: Playwright 浏览器上下文
+ filepath: 保存路径
+ """
+ cookies = await context.cookies()
+ with open(filepath, 'w', encoding='utf-8') as f:
+ json.dump(cookies, f, indent=2, ensure_ascii=False)
+ logger.info(f"[BilibiliLogin] Cookie 已保存到: {filepath}")
+
+
+async def load_cookies_from_file(context: "BrowserContext", filepath: str) -> bool:
+ """
+ 从文件加载 Cookie
+
+ Args:
+ context: Playwright 浏览器上下文
+ filepath: Cookie 文件路径
+
+ Returns:
+ bool: 是否加载成功
+ """
+ path = Path(filepath)
+ if not path.exists():
+ logger.warning(f"[BilibiliLogin] Cookie 文件不存在: {filepath}")
+ return False
+
+ try:
+ with open(filepath, 'r', encoding='utf-8') as f:
+ cookies = json.load(f)
+ await context.add_cookies(cookies)
+ logger.info(f"[BilibiliLogin] 从文件加载了 {len(cookies)} 个 Cookie")
+ return True
+ except Exception as e:
+ logger.error(f"[BilibiliLogin] 加载 Cookie 失败: {e}")
+ return False
+
+
+if __name__ == '__main__':
+ # 测试 Cookie 解析
+ test_cookie_str = "SESSDATA=abc123; DedeUserID=12345; bili_jct=xyz789"
+ login = BilibiliLogin(
+ login_type="cookie",
+ browser_context=None,
+ context_page=None,
+ cookie_str=test_cookie_str
+ )
+ cookies = login._parse_cookie_str(test_cookie_str)
+ print("解析后的 Cookie:")
+ for c in cookies:
+ print(f" {c['name']}: {c['value']}")
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/main.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/main.py"
new file mode 100644
index 0000000..3491b7c
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/main.py"
@@ -0,0 +1,232 @@
+# -*- coding: utf-8 -*-
+"""
+B站视频数据采集工具 - 主程序入口
+
+综合实战项目:B站视频数据采集与分析工具
+
+功能特点:
+- 多种登录方式(扫码登录 / Cookie 登录)
+- 反检测浏览器自动化(Playwright + stealth.js)
+- WBI 签名算法支持(B站 API 签名)
+- 视频搜索和详情获取
+- 多格式数据存储(JSON / CSV)
+- 词云和统计报告自动生成
+
+参考 MediaCrawler 项目的实现:
+- https://github.com/NanmiCoder/MediaCrawler
+
+使用方法:
+ python main.py
+
+配置说明:
+ 修改 config/settings.py 中的配置项:
+ - crawler_type: 爬取类型(search=搜索, detail=指定视频)
+ - keywords: 搜索关键词(逗号分隔)
+ - max_video_count: 最大爬取数量
+ - login_type: 登录方式(qrcode=扫码, cookie=Cookie)
+"""
+
+import asyncio
+import sys
+from pathlib import Path
+from typing import List
+from loguru import logger
+
+# 添加项目根目录到路径
+sys.path.insert(0, str(Path(__file__).parent))
+
+# 导入各模块
+from config import settings, CrawlerType
+from crawler.spider import BilibiliCrawler
+from store.backend import StorageManager
+from analysis.report import ReportGenerator, generate_report
+from models.bilibili import BilibiliVideo
+
+
+# 配置日志
+def setup_logger():
+ """配置日志"""
+ logger.remove()
+ logger.add(
+ sys.stderr,
+ format="{time:YYYY-MM-DD HH:mm:ss} | "
+ "{level: <8} | "
+ "{message}",
+ level="INFO"
+ )
+ logger.add(
+ "logs/bilibili_{time:YYYY-MM-DD}.log",
+ rotation="1 day",
+ retention="7 days",
+ level="DEBUG",
+ encoding="utf-8"
+ )
+
+
+async def run_crawler() -> List[BilibiliVideo]:
+ """
+ 运行爬虫
+
+ 完整流程:
+ 1. 启动浏览器
+ 2. 执行登录(扫码或Cookie)
+ 3. 初始化 API 客户端(获取 WBI 密钥)
+ 4. 根据配置执行爬取(搜索或指定视频)
+ 5. 返回爬取结果
+
+ Returns:
+ List[BilibiliVideo]: 爬取的视频列表
+ """
+ crawler = BilibiliCrawler()
+ return await crawler.start()
+
+
+async def save_data(videos: List[BilibiliVideo]) -> str:
+ """
+ 保存数据
+
+ Args:
+ videos: 视频列表
+
+ Returns:
+ str: 保存的文件路径
+ """
+ if not videos:
+ logger.warning("没有数据需要保存")
+ return ""
+
+ # 转换为字典列表
+ data = [video.to_dict() for video in videos]
+
+ # 创建存储管理器
+ storage = StorageManager(
+ storage_type=settings.storage_type.value,
+ output_dir=settings.storage_output_dir
+ )
+
+ # 保存数据
+ success = await storage.save(data)
+
+ if success:
+ return str(storage.filepath)
+ return ""
+
+
+def generate_analysis_report(videos: List[BilibiliVideo]) -> str:
+ """
+ 生成分析报告
+
+ Args:
+ videos: 视频列表
+
+ Returns:
+ str: 报告文件路径
+ """
+ if not videos:
+ logger.warning("没有数据,跳过报告生成")
+ return ""
+
+ report_path = generate_report(
+ videos=videos,
+ output_dir=settings.storage_output_dir
+ )
+
+ return report_path
+
+
+async def main():
+ """主函数"""
+
+ # 打印欢迎信息
+ print("""
+ ╔══════════════════════════════════════════════════════════╗
+ ║ B站视频数据采集与分析工具 v2.0 ║
+ ║ ║
+ ║ 功能: ║
+ ║ - 视频搜索与详情获取 ║
+ ║ - 扫码登录 / Cookie 登录 ║
+ ║ - JSON / CSV 数据存储 ║
+ ║ - 词云和统计分析报告 ║
+ ║ ║
+ ║ 参考项目:MediaCrawler ║
+ ║ 注意:请遵守 B站的使用条款和法律法规 ║
+ ╚══════════════════════════════════════════════════════════╝
+ """)
+
+ # 显示当前配置
+ logger.info(f"启动 {settings.app_name}")
+ logger.info(f"爬取类型: {settings.crawler_type.value}")
+ logger.info(f"登录方式: {settings.login_type.value}")
+ logger.info(f"最大数量: {settings.max_video_count}")
+ logger.info(f"存储类型: {settings.storage_type.value}")
+
+ if settings.crawler_type == CrawlerType.SEARCH:
+ logger.info(f"搜索关键词: {settings.keywords}")
+ else:
+ logger.info(f"指定视频: {len(settings.specified_id_list)} 个")
+
+ logger.info("=" * 50)
+
+ try:
+ # 1. 运行爬虫
+ logger.info("开始爬取数据...")
+ videos = await run_crawler()
+ logger.info(f"爬取完成: {len(videos)} 条视频")
+
+ if not videos:
+ logger.warning("没有爬取到数据,退出")
+ return
+
+ # 2. 保存数据
+ logger.info("保存数据...")
+ data_path = await save_data(videos)
+ if data_path:
+ logger.info(f"数据已保存: {data_path}")
+
+ # 3. 生成分析报告
+ logger.info("生成分析报告...")
+ report_path = generate_analysis_report(videos)
+ if report_path:
+ logger.info(f"报告已生成: {report_path}")
+
+ # 4. 打印结果摘要
+ logger.info("=" * 50)
+ logger.info("任务完成!")
+ logger.info(f"爬取视频: {len(videos)} 个")
+ if data_path:
+ logger.info(f"数据文件: {data_path}")
+ if report_path:
+ logger.info(f"分析报告: {report_path}")
+ logger.info("=" * 50)
+
+ # 打印部分结果预览
+ print("\n视频预览(前 5 条):")
+ print("-" * 60)
+ for i, video in enumerate(videos[:5], 1):
+ print(f"{i}. {video.title[:40]}...")
+ print(f" UP主: {video.nickname}")
+ print(f" 播放: {video.play_count:,} 点赞: {video.liked_count:,}")
+ print()
+
+ except KeyboardInterrupt:
+ logger.warning("用户中断执行")
+ except Exception as e:
+ logger.exception(f"执行出错: {e}")
+ raise
+
+
+def cli():
+ """命令行入口"""
+ # 设置日志
+ setup_logger()
+
+ # 创建必要的目录
+ Path("logs").mkdir(exist_ok=True)
+ Path(settings.storage_output_dir).mkdir(parents=True, exist_ok=True)
+
+ # 运行主程序
+ asyncio.run(main())
+
+
+if __name__ == "__main__":
+ cli()
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/models/__init__.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/models/__init__.py"
new file mode 100644
index 0000000..c423ca7
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/models/__init__.py"
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+"""
+数据模型模块
+"""
+from .bilibili import BilibiliVideo, BilibiliSearchResponse
+
+__all__ = ["BilibiliVideo", "BilibiliSearchResponse"]
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/models/bilibili.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/models/bilibili.py"
new file mode 100644
index 0000000..baf8994
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/models/bilibili.py"
@@ -0,0 +1,373 @@
+# -*- coding: utf-8 -*-
+"""
+B站数据模型
+
+本模块定义了 B站爬虫使用的 Pydantic 数据模型,用于:
+- 数据验证:确保爬取的数据符合预期格式
+- 数据序列化:方便存储为 JSON、CSV 等格式
+- 类型提示:提供更好的 IDE 支持和代码可读性
+
+数据模型说明:
+- BilibiliVideo: 视频信息模型,包含视频的所有元数据
+- BilibiliSearchResult: 搜索结果模型,对应 API 返回的搜索结果
+"""
+
+from datetime import datetime
+from typing import Optional, List
+from pydantic import BaseModel, Field
+
+
+class BilibiliVideo(BaseModel):
+ """
+ B站视频信息模型
+
+ 该模型对应 B站视频详情 API 返回的数据结构,
+ 包含了视频的基本信息、统计数据和作者信息。
+ """
+
+ # ==================== 视频基本信息 ====================
+
+ video_id: str = Field(
+ description="视频 aid(旧版 ID)"
+ )
+ bvid: str = Field(
+ description="视频 BV 号(新版 ID)"
+ )
+ title: str = Field(
+ description="视频标题"
+ )
+ desc: str = Field(
+ default="",
+ description="视频描述/简介"
+ )
+ cover_url: str = Field(
+ default="",
+ description="视频封面图 URL"
+ )
+ duration: int = Field(
+ default=0,
+ description="视频时长(秒)"
+ )
+ create_time: int = Field(
+ default=0,
+ description="发布时间戳"
+ )
+ pubdate_str: str = Field(
+ default="",
+ description="发布时间(格式化字符串)"
+ )
+
+ # ==================== UP主信息 ====================
+
+ user_id: int = Field(
+ description="UP主 UID"
+ )
+ nickname: str = Field(
+ default="",
+ description="UP主昵称"
+ )
+ avatar: str = Field(
+ default="",
+ description="UP主头像 URL"
+ )
+
+ # ==================== 统计数据 ====================
+
+ play_count: int = Field(
+ default=0,
+ description="播放量"
+ )
+ danmaku_count: int = Field(
+ default=0,
+ description="弹幕数"
+ )
+ comment_count: int = Field(
+ default=0,
+ description="评论数"
+ )
+ liked_count: int = Field(
+ default=0,
+ description="点赞数"
+ )
+ coin_count: int = Field(
+ default=0,
+ description="投币数"
+ )
+ favorite_count: int = Field(
+ default=0,
+ description="收藏数"
+ )
+ share_count: int = Field(
+ default=0,
+ description="分享数"
+ )
+
+ # ==================== 其他信息 ====================
+
+ video_url: str = Field(
+ default="",
+ description="视频页面 URL"
+ )
+ tname: str = Field(
+ default="",
+ description="视频分区名称"
+ )
+ source_keyword: str = Field(
+ default="",
+ description="搜索来源关键词"
+ )
+ crawl_time: str = Field(
+ default="",
+ description="爬取时间"
+ )
+
+ def __init__(self, **data):
+ """初始化时自动设置默认值"""
+ # 如果没有提供 crawl_time,使用当前时间
+ if 'crawl_time' not in data or not data['crawl_time']:
+ data['crawl_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+
+ # 如果没有提供 video_url,根据 bvid 生成
+ if 'video_url' not in data or not data['video_url']:
+ if 'bvid' in data:
+ data['video_url'] = f"https://www.bilibili.com/video/{data['bvid']}"
+
+ # 如果没有提供 pubdate_str,根据 create_time 生成
+ if 'pubdate_str' not in data or not data['pubdate_str']:
+ if 'create_time' in data and data['create_time']:
+ try:
+ dt = datetime.fromtimestamp(data['create_time'])
+ data['pubdate_str'] = dt.strftime('%Y-%m-%d %H:%M:%S')
+ except (ValueError, OSError):
+ pass
+
+ super().__init__(**data)
+
+ @classmethod
+ def from_api_response(cls, data: dict, source_keyword: str = "") -> "BilibiliVideo":
+ """
+ 从 API 响应数据创建 BilibiliVideo 实例
+
+ Args:
+ data: API 返回的视频详情数据
+ source_keyword: 搜索来源关键词
+
+ Returns:
+ BilibiliVideo: 视频信息模型实例
+ """
+ # 提取作者信息
+ owner = data.get("owner", {})
+
+ # 提取统计信息
+ stat = data.get("stat", {})
+
+ return cls(
+ video_id=str(data.get("aid", "")),
+ bvid=data.get("bvid", ""),
+ title=data.get("title", ""),
+ desc=data.get("desc", ""),
+ cover_url=data.get("pic", ""),
+ duration=data.get("duration", 0),
+ create_time=data.get("pubdate", 0),
+ user_id=owner.get("mid", 0),
+ nickname=owner.get("name", ""),
+ avatar=owner.get("face", ""),
+ play_count=stat.get("view", 0),
+ danmaku_count=stat.get("danmaku", 0),
+ comment_count=stat.get("reply", 0),
+ liked_count=stat.get("like", 0),
+ coin_count=stat.get("coin", 0),
+ favorite_count=stat.get("favorite", 0),
+ share_count=stat.get("share", 0),
+ tname=data.get("tname", ""),
+ source_keyword=source_keyword,
+ )
+
+ @classmethod
+ def from_search_result(cls, data: dict, source_keyword: str = "") -> "BilibiliVideo":
+ """
+ 从搜索结果数据创建 BilibiliVideo 实例
+
+ 搜索结果的数据结构与视频详情略有不同,需要单独处理。
+
+ Args:
+ data: API 返回的搜索结果数据
+ source_keyword: 搜索关键词
+
+ Returns:
+ BilibiliVideo: 视频信息模型实例
+ """
+ return cls(
+ video_id=str(data.get("aid", "")),
+ bvid=data.get("bvid", ""),
+ title=data.get("title", "").replace("", "").replace("", ""),
+ desc=data.get("description", ""),
+ cover_url="https:" + data.get("pic", "") if data.get("pic", "").startswith("//") else data.get("pic", ""),
+ duration=cls._parse_duration(data.get("duration", "0:00")),
+ create_time=data.get("pubdate", 0),
+ user_id=data.get("mid", 0),
+ nickname=data.get("author", ""),
+ avatar="", # 搜索结果中没有头像
+ play_count=data.get("play", 0),
+ danmaku_count=data.get("danmaku", 0),
+ comment_count=data.get("review", 0),
+ liked_count=data.get("like", 0),
+ favorite_count=data.get("favorites", 0),
+ tname=data.get("typename", ""),
+ source_keyword=source_keyword,
+ )
+
+ @staticmethod
+ def _parse_duration(duration_str: str) -> int:
+ """
+ 解析时长字符串为秒数
+
+ Args:
+ duration_str: 时长字符串,如 "3:45" 或 "1:23:45"
+
+ Returns:
+ int: 时长(秒)
+ """
+ if isinstance(duration_str, int):
+ return duration_str
+
+ try:
+ parts = str(duration_str).split(":")
+ if len(parts) == 2:
+ return int(parts[0]) * 60 + int(parts[1])
+ elif len(parts) == 3:
+ return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
+ return 0
+ except (ValueError, TypeError):
+ return 0
+
+ def to_dict(self) -> dict:
+ """转换为字典格式"""
+ return self.model_dump()
+
+ def to_csv_row(self) -> dict:
+ """
+ 转换为适合 CSV 存储的行格式
+
+ Returns:
+ dict: CSV 行数据
+ """
+ return {
+ "BV号": self.bvid,
+ "标题": self.title,
+ "UP主": self.nickname,
+ "UP主ID": self.user_id,
+ "播放量": self.play_count,
+ "点赞数": self.liked_count,
+ "投币数": self.coin_count,
+ "收藏数": self.favorite_count,
+ "分享数": self.share_count,
+ "弹幕数": self.danmaku_count,
+ "评论数": self.comment_count,
+ "发布时间": self.pubdate_str,
+ "视频时长(秒)": self.duration,
+ "分区": self.tname,
+ "描述": self.desc[:100] + "..." if len(self.desc) > 100 else self.desc,
+ "视频链接": self.video_url,
+ "搜索关键词": self.source_keyword,
+ "爬取时间": self.crawl_time,
+ }
+
+
+class BilibiliSearchResponse(BaseModel):
+ """
+ B站搜索响应模型
+
+ 对应搜索 API 的完整响应结构。
+ """
+
+ seid: str = Field(default="", description="搜索会话 ID")
+ page: int = Field(default=1, description="当前页码")
+ pagesize: int = Field(default=20, description="每页数量")
+ numResults: int = Field(default=0, description="搜索结果总数")
+ numPages: int = Field(default=0, description="总页数")
+ result: List[dict] = Field(default_factory=list, description="搜索结果列表")
+
+ @property
+ def has_more(self) -> bool:
+ """是否还有更多结果"""
+ return self.page < self.numPages
+
+ def get_videos(self, source_keyword: str = "") -> List[BilibiliVideo]:
+ """
+ 获取视频列表
+
+ Args:
+ source_keyword: 搜索关键词
+
+ Returns:
+ List[BilibiliVideo]: 视频列表
+ """
+ videos = []
+ for item in self.result:
+ try:
+ video = BilibiliVideo.from_search_result(item, source_keyword)
+ videos.append(video)
+ except Exception:
+ continue
+ return videos
+
+
+if __name__ == '__main__':
+ # 测试代码
+ print("=" * 50)
+ print("数据模型测试")
+ print("=" * 50)
+
+ # 测试创建视频模型
+ video = BilibiliVideo(
+ video_id="123456",
+ bvid="BV1xx411c7mD",
+ title="测试视频标题",
+ user_id=12345,
+ nickname="测试UP主",
+ play_count=10000,
+ liked_count=500,
+ )
+
+ print(f"视频信息: {video}")
+ print(f"视频 URL: {video.video_url}")
+ print(f"爬取时间: {video.crawl_time}")
+
+ # 测试 CSV 行格式
+ print(f"\nCSV 行格式:")
+ for key, value in video.to_csv_row().items():
+ print(f" {key}: {value}")
+
+ # 测试从 API 响应创建
+ mock_api_response = {
+ "aid": 789012,
+ "bvid": "BV1yy411a7bC",
+ "title": "Python 入门教程",
+ "desc": "这是一个 Python 入门教程视频",
+ "pic": "https://example.com/cover.jpg",
+ "duration": 600,
+ "pubdate": 1640000000,
+ "owner": {
+ "mid": 54321,
+ "name": "Python老师",
+ "face": "https://example.com/avatar.jpg"
+ },
+ "stat": {
+ "view": 50000,
+ "danmaku": 200,
+ "reply": 100,
+ "like": 2000,
+ "coin": 500,
+ "favorite": 1000,
+ "share": 300
+ },
+ "tname": "知识"
+ }
+
+ video2 = BilibiliVideo.from_api_response(mock_api_response, "Python教程")
+ print(f"\n从 API 响应创建的视频:")
+ print(f" 标题: {video2.title}")
+ print(f" UP主: {video2.nickname}")
+ print(f" 播放量: {video2.play_count}")
+ print(f" 发布时间: {video2.pubdate_str}")
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/proxy/__init__.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/proxy/__init__.py"
new file mode 100644
index 0000000..c607dbc
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/proxy/__init__.py"
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+from .pool import ProxyPool, ProxyInfo
+
+__all__ = ['ProxyPool', 'ProxyInfo']
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/proxy/pool.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/proxy/pool.py"
new file mode 100644
index 0000000..2868386
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/proxy/pool.py"
@@ -0,0 +1,213 @@
+# -*- coding: utf-8 -*-
+# @Desc: 代理池模块
+
+import random
+import asyncio
+from typing import Optional, List
+from dataclasses import dataclass, field
+from datetime import datetime
+from loguru import logger
+
+# 可选依赖
+try:
+ import httpx
+ HAS_HTTPX = True
+except ImportError:
+ HAS_HTTPX = False
+
+
+@dataclass
+class ProxyInfo:
+ """代理信息"""
+ ip: str
+ port: int
+ protocol: str = "http"
+ username: str = None
+ password: str = None
+ expire_time: datetime = None
+ fail_count: int = 0
+ success_count: int = 0
+
+ @property
+ def url(self) -> str:
+ """获取代理 URL"""
+ if self.username and self.password:
+ return f"{self.protocol}://{self.username}:{self.password}@{self.ip}:{self.port}"
+ return f"{self.protocol}://{self.ip}:{self.port}"
+
+ @property
+ def is_valid(self) -> bool:
+ """检查是否有效"""
+ if self.expire_time and datetime.now() > self.expire_time:
+ return False
+ return self.fail_count < 3
+
+ def __str__(self) -> str:
+ return f"{self.ip}:{self.port}"
+
+
+class ProxyPool:
+ """代理池"""
+
+ def __init__(self, api_url: str = None):
+ """
+ 初始化代理池
+
+ Args:
+ api_url: 代理 API 地址(可选)
+ """
+ self.api_url = api_url
+ self._proxies: List[ProxyInfo] = []
+ self._lock = asyncio.Lock()
+
+ async def fetch_proxies(self, count: int = 10) -> List[ProxyInfo]:
+ """
+ 从 API 获取代理
+
+ Args:
+ count: 获取数量
+
+ Returns:
+ 代理列表
+ """
+ if not self.api_url:
+ logger.warning("未配置代理 API")
+ return []
+
+ if not HAS_HTTPX:
+ logger.warning("httpx 未安装,无法获取代理")
+ return []
+
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ self.api_url,
+ params={'count': count},
+ timeout=10
+ )
+
+ if response.status_code != 200:
+ logger.error(f"获取代理失败: HTTP {response.status_code}")
+ return []
+
+ data = response.json()
+
+ proxies = []
+ # 支持多种 API 响应格式
+ items = data.get('data', data.get('proxies', data))
+ if isinstance(items, list):
+ for item in items:
+ if isinstance(item, dict):
+ proxy = ProxyInfo(
+ ip=item.get('ip', item.get('host', '')),
+ port=int(item.get('port', 0)),
+ protocol=item.get('protocol', item.get('type', 'http')),
+ expire_time=datetime.fromisoformat(item['expire_time'])
+ if 'expire_time' in item else None
+ )
+ if proxy.ip and proxy.port:
+ proxies.append(proxy)
+
+ logger.info(f"获取 {len(proxies)} 个代理")
+ return proxies
+ except Exception as e:
+ logger.error(f"获取代理失败: {e}")
+ return []
+
+ async def add_proxy(self, proxy: ProxyInfo):
+ """添加单个代理"""
+ async with self._lock:
+ self._proxies.append(proxy)
+
+ async def add_proxies(self, proxies: List[ProxyInfo]):
+ """添加多个代理"""
+ async with self._lock:
+ self._proxies.extend(proxies)
+ logger.info(f"添加 {len(proxies)} 个代理到池中")
+
+ async def add_proxy_from_url(self, url: str):
+ """从 URL 添加代理"""
+ try:
+ # 解析 URL,格式:protocol://[user:pass@]ip:port
+ from urllib.parse import urlparse
+ parsed = urlparse(url)
+
+ proxy = ProxyInfo(
+ ip=parsed.hostname,
+ port=parsed.port,
+ protocol=parsed.scheme or 'http',
+ username=parsed.username,
+ password=parsed.password
+ )
+ await self.add_proxy(proxy)
+ except Exception as e:
+ logger.error(f"解析代理 URL 失败: {e}")
+
+ async def get_proxy(self) -> Optional[ProxyInfo]:
+ """
+ 获取一个可用代理
+
+ Returns:
+ 代理信息,如果没有可用代理返回 None
+ """
+ async with self._lock:
+ # 过滤有效代理
+ valid_proxies = [p for p in self._proxies if p.is_valid]
+
+ if not valid_proxies:
+ # 尝试获取新代理
+ if self.api_url:
+ new_proxies = await self.fetch_proxies()
+ if new_proxies:
+ self._proxies = new_proxies
+ valid_proxies = new_proxies
+
+ if not valid_proxies:
+ return None
+
+ # 随机选择一个
+ return random.choice(valid_proxies)
+
+ async def report_success(self, proxy: ProxyInfo):
+ """报告代理成功"""
+ proxy.success_count += 1
+ proxy.fail_count = 0
+ logger.debug(f"代理 {proxy} 成功,总成功次数: {proxy.success_count}")
+
+ async def report_failure(self, proxy: ProxyInfo):
+ """报告代理失败"""
+ proxy.fail_count += 1
+ logger.debug(f"代理 {proxy} 失败,失败次数: {proxy.fail_count}")
+
+ async def remove_invalid(self):
+ """移除无效代理"""
+ async with self._lock:
+ before = len(self._proxies)
+ self._proxies = [p for p in self._proxies if p.is_valid]
+ removed = before - len(self._proxies)
+ if removed > 0:
+ logger.info(f"移除 {removed} 个无效代理")
+
+ async def clear(self):
+ """清空代理池"""
+ async with self._lock:
+ self._proxies.clear()
+ logger.info("代理池已清空")
+
+ @property
+ def size(self) -> int:
+ """代理池大小"""
+ return len(self._proxies)
+
+ @property
+ def valid_size(self) -> int:
+ """有效代理数量"""
+ return len([p for p in self._proxies if p.is_valid])
+
+ def get_stats(self) -> dict:
+ """获取代理池统计信息"""
+ return {
+ "total": self.size,
+ "valid": self.valid_size,
+ "invalid": self.size - self.valid_size
+ }
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/pyproject.toml" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/pyproject.toml"
new file mode 100644
index 0000000..4e98bf3
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/pyproject.toml"
@@ -0,0 +1,50 @@
+[project]
+name = "bilibili-crawler"
+version = "0.1.0"
+description = "第11章:进阶综合实战项目 - B站视频数据采集与分析工具"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ # 核心依赖
+ "playwright>=1.45.0",
+ "httpx>=0.27.0",
+ "pydantic>=2.0.0",
+ "pydantic-settings>=2.0.0",
+ "loguru>=0.7.0",
+
+ # 数据分析(可选,但默认安装)
+ "pandas>=2.2.0",
+ "jieba>=0.42.0",
+ "wordcloud>=1.9.0",
+ "pillow>=10.0.0",
+]
+
+[project.scripts]
+bilibili-crawler = "main:cli"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+# 不打包为 wheel,仅作为脚本项目运行
+packages = [
+ "config",
+ "core",
+ "login",
+ "client",
+ "crawler",
+ "store",
+ "proxy",
+ "models",
+ "tools",
+ "analysis",
+]
+
+[tool.uv]
+dev-dependencies = []
+
+# 提示:
+# 1. 安装后需要运行 playwright install chromium 安装浏览器驱动
+# 2. 目标网站:https://www.bilibili.com
+# 3. 运行方式:python main.py 或 uv run python main.py
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/qrcode.png" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/qrcode.png"
new file mode 100644
index 0000000..bf513be
Binary files /dev/null and "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/qrcode.png" differ
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/store/__init__.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/store/__init__.py"
new file mode 100644
index 0000000..6a8ec73
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/store/__init__.py"
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+from .backend import StorageManager, JSONStorage, CSVStorage
+
+__all__ = ['StorageManager', 'JSONStorage', 'CSVStorage']
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/store/backend.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/store/backend.py"
new file mode 100644
index 0000000..38849b8
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/store/backend.py"
@@ -0,0 +1,222 @@
+# -*- coding: utf-8 -*-
+"""
+数据存储后端模块
+
+本模块实现了多种数据存储后端,包括:
+- JSONStorage: JSON 文件存储
+- CSVStorage: CSV 文件存储
+- StorageManager: 统一存储管理器
+
+支持保存 BilibiliVideo 模型数据,自动转换为适合存储的格式。
+
+参考 MediaCrawler 项目的实现:
+- https://github.com/NanmiCoder/MediaCrawler/blob/main/store/bilibili/_store_impl.py
+"""
+
+import json
+import csv
+from abc import ABC, abstractmethod
+from datetime import datetime
+from pathlib import Path
+from typing import List, Dict, Any, Optional, Union
+from loguru import logger
+
+# 尝试导入模型
+try:
+ from models.bilibili import BilibiliVideo
+ HAS_MODEL = True
+except ImportError:
+ HAS_MODEL = False
+
+
+class BaseStorage(ABC):
+ """存储基类"""
+
+ @abstractmethod
+ async def save(self, data: List[Dict]) -> bool:
+ """保存数据"""
+ pass
+
+ @abstractmethod
+ async def load(self) -> List[Dict]:
+ """加载数据"""
+ pass
+
+ def _convert_to_dict(self, item: Any) -> Dict:
+ """
+ 将对象转换为字典
+
+ 支持 BilibiliVideo 模型和普通字典。
+ """
+ if HAS_MODEL and isinstance(item, BilibiliVideo):
+ return item.to_dict()
+ elif hasattr(item, 'model_dump'):
+ return item.model_dump()
+ elif hasattr(item, 'dict'):
+ return item.dict()
+ elif isinstance(item, dict):
+ return item
+ else:
+ return dict(item)
+
+
+class JSONStorage(BaseStorage):
+ """JSON 存储"""
+
+ def __init__(self, output_dir: str, filename: str = None):
+ """
+ 初始化 JSON 存储
+
+ Args:
+ output_dir: 输出目录
+ filename: 文件名(可选,默认按时间戳生成)
+ """
+ self.output_dir = Path(output_dir)
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+
+ if filename:
+ self.filepath = self.output_dir / filename
+ else:
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+ self.filepath = self.output_dir / f"data_{timestamp}.json"
+
+ async def save(self, data: List[Dict]) -> bool:
+ """保存数据到 JSON 文件"""
+ try:
+ with open(self.filepath, 'w', encoding='utf-8') as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ logger.info(f"数据已保存到: {self.filepath} ({len(data)} 条)")
+ return True
+ except Exception as e:
+ logger.error(f"保存失败: {e}")
+ return False
+
+ async def load(self) -> List[Dict]:
+ """从 JSON 文件加载数据"""
+ if not self.filepath.exists():
+ return []
+ try:
+ with open(self.filepath, 'r', encoding='utf-8') as f:
+ return json.load(f)
+ except Exception as e:
+ logger.error(f"加载失败: {e}")
+ return []
+
+ async def append(self, data: Dict) -> bool:
+ """追加单条数据"""
+ existing = await self.load()
+ existing.append(data)
+ return await self.save(existing)
+
+ async def append_batch(self, data: List[Dict]) -> bool:
+ """追加多条数据"""
+ existing = await self.load()
+ existing.extend(data)
+ return await self.save(existing)
+
+
+class CSVStorage(BaseStorage):
+ """CSV 存储"""
+
+ def __init__(
+ self,
+ output_dir: str,
+ filename: str = None,
+ fields: List[str] = None
+ ):
+ """
+ 初始化 CSV 存储
+
+ Args:
+ output_dir: 输出目录
+ filename: 文件名(可选)
+ fields: 字段列表(可选,默认从数据推断)
+ """
+ self.output_dir = Path(output_dir)
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+
+ if filename:
+ self.filepath = self.output_dir / filename
+ else:
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
+ self.filepath = self.output_dir / f"data_{timestamp}.csv"
+
+ self.fields = fields
+
+ async def save(self, data: List[Dict]) -> bool:
+ """保存数据到 CSV 文件"""
+ if not data:
+ logger.warning("没有数据需要保存")
+ return True
+
+ try:
+ # 确定字段列表
+ fields = self.fields or list(data[0].keys())
+
+ with open(self.filepath, 'w', encoding='utf-8-sig', newline='') as f:
+ writer = csv.DictWriter(f, fieldnames=fields, extrasaction='ignore')
+ writer.writeheader()
+ writer.writerows(data)
+
+ logger.info(f"数据已保存到: {self.filepath} ({len(data)} 条)")
+ return True
+ except Exception as e:
+ logger.error(f"保存失败: {e}")
+ return False
+
+ async def load(self) -> List[Dict]:
+ """从 CSV 文件加载数据"""
+ if not self.filepath.exists():
+ return []
+ try:
+ with open(self.filepath, 'r', encoding='utf-8-sig') as f:
+ reader = csv.DictReader(f)
+ return list(reader)
+ except Exception as e:
+ logger.error(f"加载失败: {e}")
+ return []
+
+
+class StorageManager:
+ """存储管理器"""
+
+ def __init__(
+ self,
+ storage_type: str,
+ output_dir: str,
+ filename: str = None,
+ **kwargs
+ ):
+ """
+ 初始化存储管理器
+
+ Args:
+ storage_type: 存储类型 ('json' 或 'csv')
+ output_dir: 输出目录
+ filename: 文件名(可选)
+ **kwargs: 传递给具体存储类的参数
+ """
+ self.output_dir = output_dir
+
+ if storage_type == 'json':
+ self._storage = JSONStorage(output_dir, filename)
+ elif storage_type == 'csv':
+ self._storage = CSVStorage(output_dir, filename, **kwargs)
+ else:
+ raise ValueError(f"不支持的存储类型: {storage_type}")
+
+ self.storage_type = storage_type
+ logger.info(f"存储管理器初始化: {storage_type} -> {output_dir}")
+
+ async def save(self, data: List[Dict]) -> bool:
+ """保存数据"""
+ return await self._storage.save(data)
+
+ async def load(self) -> List[Dict]:
+ """加载数据"""
+ return await self._storage.load()
+
+ @property
+ def filepath(self) -> Path:
+ """获取文件路径"""
+ return self._storage.filepath
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/test_quick.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/test_quick.py"
new file mode 100644
index 0000000..3328c3e
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/test_quick.py"
@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+# 快速测试脚本 - 只爬取1页数据验证功能
+
+import asyncio
+import sys
+from pathlib import Path
+from loguru import logger
+
+# 添加项目根目录到路径
+sys.path.insert(0, str(Path(__file__).parent))
+
+from core.browser import BrowserManager
+from crawler.spider import ContentCrawler
+
+# 配置简单日志
+logger.remove()
+logger.add(sys.stderr, level="INFO")
+
+
+async def quick_test():
+ """快速测试"""
+ logger.info("开始快速测试 books.toscrape.com")
+
+ browser = BrowserManager(headless=True, timeout=30000)
+
+ try:
+ async with browser:
+ context = await browser.start()
+ page = await browser.new_page()
+
+ # 创建爬虫 - 只爬取1页数据
+ crawler = ContentCrawler(
+ start_url="http://books.toscrape.com/catalogue/page-1.html",
+ item_selector="article.product_pod",
+ fields={
+ "title": "h3 a|title",
+ "price": ".price_color",
+ "rating": ".star-rating|class",
+ "availability": ".availability",
+ "link": "h3 a|href",
+ },
+ next_page_selector=".next a",
+ max_pages=1, # 只爬取1页
+ delay_min=0.5,
+ delay_max=1.0
+ )
+
+ logger.info("开始爬取...")
+ results = await crawler.crawl(page)
+
+ logger.info(f"爬取完成: {len(results)} 条数据")
+
+ if results:
+ logger.info("第一条数据示例:")
+ logger.info(results[0])
+ logger.success("✅ 测试成功!")
+ return True
+ else:
+ logger.error("❌ 没有爬取到数据")
+ return False
+
+ except Exception as e:
+ logger.exception(f"测试失败: {e}")
+ return False
+
+
+if __name__ == "__main__":
+ result = asyncio.run(quick_test())
+ sys.exit(0 if result else 1)
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/tools/__init__.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/tools/__init__.py"
new file mode 100644
index 0000000..a2f7497
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/tools/__init__.py"
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+"""
+工具模块
+"""
+from .sign import BilibiliSign
+
+__all__ = ["BilibiliSign"]
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/tools/sign.py" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/tools/sign.py"
new file mode 100644
index 0000000..1756cb4
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/tools/sign.py"
@@ -0,0 +1,273 @@
+# -*- coding: utf-8 -*-
+"""
+B站 WBI 签名工具
+
+本模块实现了 B站 API 请求的 WBI 签名算法,用于防止接口被滥用。
+WBI 签名是 B站 API 的一种保护机制,需要在请求参数中添加 wts(时间戳)和 w_rid(签名)。
+
+签名算法说明:
+1. 从浏览器的 localStorage 中获取 wbi_img_urls,包含 img_url 和 sub_url
+2. 从这两个 URL 中提取 img_key 和 sub_key
+3. 将两个 key 拼接后,按照混淆映射表重新排列,取前32位作为 salt
+4. 将请求参数按 key 排序,URL 编码后与 salt 拼接
+5. 计算 MD5 哈希值作为 w_rid
+
+参考资料:
+- https://socialsisteryi.github.io/bilibili-API-collect/docs/misc/sign/wbi.html
+"""
+
+import re
+import time
+import urllib.parse
+from hashlib import md5
+from typing import Dict, Optional
+from dataclasses import dataclass
+
+
+# WBI 签名混淆映射表(固定值,从 B站 JS 代码中逆向得到)
+WBI_MIXIN_KEY_ENC_TAB = [
+ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
+ 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
+ 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
+ 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
+]
+
+
+@dataclass
+class VideoUrlInfo:
+ """视频 URL 信息"""
+ video_id: str # BV 号
+ video_type: str = "video"
+
+
+@dataclass
+class CreatorUrlInfo:
+ """创作者 URL 信息"""
+ creator_id: str # UID
+
+
+class BilibiliSign:
+ """
+ B站 WBI 签名类
+
+ 使用方法:
+ ```python
+ # 创建签名实例(需要先从浏览器获取 img_key 和 sub_key)
+ signer = BilibiliSign(img_key="xxx", sub_key="yyy")
+
+ # 对请求参数进行签名
+ params = {"keyword": "Python教程", "page": 1}
+ signed_params = signer.sign(params)
+ # signed_params 会包含 wts 和 w_rid
+ ```
+ """
+
+ def __init__(self, img_key: str, sub_key: str):
+ """
+ 初始化签名器
+
+ Args:
+ img_key: 从 wbi_img_urls 中提取的 img_key
+ sub_key: 从 wbi_img_urls 中提取的 sub_key
+ """
+ self.img_key = img_key
+ self.sub_key = sub_key
+
+ def get_salt(self) -> str:
+ """
+ 生成混淆后的 salt
+
+ 算法:
+ 1. 将 img_key 和 sub_key 拼接
+ 2. 按照映射表重新排列字符
+ 3. 取前 32 位作为 salt
+
+ Returns:
+ str: 32位的 salt 字符串
+ """
+ salt = ""
+ mixin_key = self.img_key + self.sub_key
+ for index in WBI_MIXIN_KEY_ENC_TAB:
+ salt += mixin_key[index]
+ return salt[:32]
+
+ def sign(self, req_data: Dict) -> Dict:
+ """
+ 对请求参数进行签名
+
+ 签名流程:
+ 1. 添加当前时间戳 wts
+ 2. 按 key 字典序排序
+ 3. 过滤特殊字符
+ 4. URL 编码后与 salt 拼接
+ 5. 计算 MD5 作为 w_rid
+
+ Args:
+ req_data: 原始请求参数
+
+ Returns:
+ Dict: 签名后的请求参数(包含 wts 和 w_rid)
+ """
+ # 复制参数,避免修改原始数据
+ params = req_data.copy()
+
+ # 添加当前时间戳(秒级)
+ current_ts = int(time.time())
+ params["wts"] = current_ts
+
+ # 按 key 字典序排序
+ params = dict(sorted(params.items()))
+
+ # 过滤特殊字符 !'()*
+ params = {
+ k: ''.join(filter(lambda ch: ch not in "!'()*", str(v)))
+ for k, v in params.items()
+ }
+
+ # URL 编码
+ query = urllib.parse.urlencode(params)
+
+ # 获取 salt 并计算签名
+ salt = self.get_salt()
+ w_rid = md5((query + salt).encode()).hexdigest()
+
+ # 添加签名到参数
+ params['w_rid'] = w_rid
+
+ return params
+
+
+def extract_wbi_keys_from_urls(img_url: str, sub_url: str) -> tuple:
+ """
+ 从 wbi_img_urls 中提取 img_key 和 sub_key
+
+ Args:
+ img_url: img_url 完整地址
+ sub_url: sub_url 完整地址
+
+ Returns:
+ tuple: (img_key, sub_key)
+
+ Example:
+ img_url = "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png"
+ sub_url = "https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png"
+ # 返回 ("7cd084941338484aae1ad9425b84077c", "4932caff0ff746eab6f01bf08b70ac45")
+ """
+ # 从 URL 中提取文件名(不含扩展名)
+ img_key = img_url.rsplit('/', 1)[-1].split('.')[0]
+ sub_key = sub_url.rsplit('/', 1)[-1].split('.')[0]
+ return img_key, sub_key
+
+
+def parse_video_info_from_url(url: str) -> VideoUrlInfo:
+ """
+ 从 B站视频 URL 中解析视频 ID
+
+ 支持的格式:
+ - https://www.bilibili.com/video/BV1dwuKzmE26/?spm_id_from=...
+ - https://www.bilibili.com/video/BV1d54y1g7db
+ - BV1d54y1g7db(直接传入 BV 号)
+
+ Args:
+ url: B站视频链接或 BV 号
+
+ Returns:
+ VideoUrlInfo: 包含视频 ID 的对象
+
+ Raises:
+ ValueError: 无法解析视频 ID
+ """
+ # 如果直接是 BV 号,直接返回
+ if url.startswith("BV"):
+ return VideoUrlInfo(video_id=url)
+
+ # 使用正则提取 BV 号
+ bv_pattern = r'/video/(BV[a-zA-Z0-9]+)'
+ match = re.search(bv_pattern, url)
+
+ if match:
+ video_id = match.group(1)
+ return VideoUrlInfo(video_id=video_id)
+
+ raise ValueError(f"无法从 URL 解析视频 ID: {url}")
+
+
+def parse_creator_info_from_url(url: str) -> CreatorUrlInfo:
+ """
+ 从 B站用户空间 URL 中解析用户 ID
+
+ 支持的格式:
+ - https://space.bilibili.com/434377496?spm_id_from=...
+ - https://space.bilibili.com/20813884
+ - 434377496(直接传入 UID)
+
+ Args:
+ url: B站用户空间链接或 UID
+
+ Returns:
+ CreatorUrlInfo: 包含用户 ID 的对象
+
+ Raises:
+ ValueError: 无法解析用户 ID
+ """
+ # 如果直接是数字 ID,直接返回
+ if url.isdigit():
+ return CreatorUrlInfo(creator_id=url)
+
+ # 使用正则提取 UID
+ uid_pattern = r'space\.bilibili\.com/(\d+)'
+ match = re.search(uid_pattern, url)
+
+ if match:
+ creator_id = match.group(1)
+ return CreatorUrlInfo(creator_id=creator_id)
+
+ raise ValueError(f"无法从 URL 解析用户 ID: {url}")
+
+
+if __name__ == '__main__':
+ # 测试代码
+ print("=" * 50)
+ print("WBI 签名测试")
+ print("=" * 50)
+
+ # 模拟从浏览器获取的 key
+ test_img_key = "7cd084941338484aae1ad9425b84077c"
+ test_sub_key = "4932caff0ff746eab6f01bf08b70ac45"
+
+ signer = BilibiliSign(test_img_key, test_sub_key)
+ print(f"Salt: {signer.get_salt()}")
+
+ # 测试签名
+ test_params = {"keyword": "Python教程", "page": 1, "search_type": "video"}
+ signed_params = signer.sign(test_params)
+ print(f"原始参数: {test_params}")
+ print(f"签名参数: {signed_params}")
+
+ print("\n" + "=" * 50)
+ print("URL 解析测试")
+ print("=" * 50)
+
+ # 测试视频 URL 解析
+ video_urls = [
+ "https://www.bilibili.com/video/BV1dwuKzmE26/?spm_id_from=333.1387",
+ "BV1d54y1g7db",
+ ]
+ for url in video_urls:
+ try:
+ info = parse_video_info_from_url(url)
+ print(f"视频 URL: {url} -> {info}")
+ except ValueError as e:
+ print(f"解析失败: {e}")
+
+ # 测试创作者 URL 解析
+ creator_urls = [
+ "https://space.bilibili.com/434377496?spm_id_from=333.1007.0.0",
+ "20813884",
+ ]
+ for url in creator_urls:
+ try:
+ info = parse_creator_info_from_url(url)
+ print(f"创作者 URL: {url} -> {info}")
+ except ValueError as e:
+ print(f"解析失败: {e}")
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/uv.lock" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/uv.lock"
new file mode 100644
index 0000000..ab2e405
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/11_\350\277\233\351\230\266\347\273\274\345\220\210\345\256\236\346\210\230\351\241\271\347\233\256/uv.lock"
@@ -0,0 +1,1011 @@
+version = 1
+revision = 1
+requires-python = ">=3.11"
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version < '3.12'",
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
+]
+
+[[package]]
+name = "anyio"
+version = "4.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 },
+]
+
+[[package]]
+name = "bilibili-crawler"
+version = "0.1.0"
+source = { editable = "." }
+dependencies = [
+ { name = "httpx" },
+ { name = "jieba" },
+ { name = "loguru" },
+ { name = "pandas" },
+ { name = "pillow" },
+ { name = "playwright" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "wordcloud" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "httpx", specifier = ">=0.27.0" },
+ { name = "jieba", specifier = ">=0.42.0" },
+ { name = "loguru", specifier = ">=0.7.0" },
+ { name = "pandas", specifier = ">=2.2.0" },
+ { name = "pillow", specifier = ">=10.0.0" },
+ { name = "playwright", specifier = ">=1.45.0" },
+ { name = "pydantic", specifier = ">=2.0.0" },
+ { name = "pydantic-settings", specifier = ">=2.0.0" },
+ { name = "wordcloud", specifier = ">=1.9.0" },
+]
+
+[package.metadata.requires-dev]
+dev = []
+
+[[package]]
+name = "certifi"
+version = "2026.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900 },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+]
+
+[[package]]
+name = "contourpy"
+version = "1.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773 },
+ { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149 },
+ { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222 },
+ { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234 },
+ { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555 },
+ { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238 },
+ { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218 },
+ { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867 },
+ { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677 },
+ { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234 },
+ { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123 },
+ { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419 },
+ { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979 },
+ { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653 },
+ { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536 },
+ { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397 },
+ { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601 },
+ { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288 },
+ { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386 },
+ { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018 },
+ { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567 },
+ { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655 },
+ { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257 },
+ { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034 },
+ { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672 },
+ { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234 },
+ { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169 },
+ { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859 },
+ { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062 },
+ { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932 },
+ { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024 },
+ { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578 },
+ { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524 },
+ { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730 },
+ { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897 },
+ { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751 },
+ { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486 },
+ { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106 },
+ { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548 },
+ { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297 },
+ { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023 },
+ { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157 },
+ { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570 },
+ { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713 },
+ { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189 },
+ { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251 },
+ { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810 },
+ { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871 },
+ { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264 },
+ { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819 },
+ { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650 },
+ { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833 },
+ { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692 },
+ { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424 },
+ { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300 },
+ { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769 },
+ { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892 },
+ { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748 },
+ { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554 },
+ { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118 },
+ { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555 },
+ { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295 },
+ { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027 },
+ { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428 },
+ { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331 },
+ { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831 },
+ { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809 },
+ { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593 },
+ { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202 },
+ { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207 },
+ { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315 },
+]
+
+[[package]]
+name = "cycler"
+version = "0.12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 },
+]
+
+[[package]]
+name = "fonttools"
+version = "4.61.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213 },
+ { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689 },
+ { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809 },
+ { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039 },
+ { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714 },
+ { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648 },
+ { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681 },
+ { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951 },
+ { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593 },
+ { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231 },
+ { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103 },
+ { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295 },
+ { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109 },
+ { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598 },
+ { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060 },
+ { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078 },
+ { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454 },
+ { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191 },
+ { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410 },
+ { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460 },
+ { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800 },
+ { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859 },
+ { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821 },
+ { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169 },
+ { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094 },
+ { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589 },
+ { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892 },
+ { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884 },
+ { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405 },
+ { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553 },
+ { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915 },
+ { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487 },
+ { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571 },
+ { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317 },
+ { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124 },
+ { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391 },
+ { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800 },
+ { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426 },
+ { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377 },
+ { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613 },
+ { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996 },
+]
+
+[[package]]
+name = "greenlet"
+version = "3.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908 },
+ { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113 },
+ { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338 },
+ { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098 },
+ { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206 },
+ { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668 },
+ { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483 },
+ { url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164 },
+ { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379 },
+ { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294 },
+ { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742 },
+ { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297 },
+ { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885 },
+ { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424 },
+ { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017 },
+ { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964 },
+ { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140 },
+ { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219 },
+ { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211 },
+ { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311 },
+ { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833 },
+ { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256 },
+ { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483 },
+ { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833 },
+ { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671 },
+ { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360 },
+ { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160 },
+ { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388 },
+ { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166 },
+ { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193 },
+ { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653 },
+ { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387 },
+ { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638 },
+ { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145 },
+ { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236 },
+ { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506 },
+ { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783 },
+ { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857 },
+ { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034 },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 },
+]
+
+[[package]]
+name = "jieba"
+version = "0.42.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c6/cb/18eeb235f833b726522d7ebed54f2278ce28ba9438e3135ab0278d9792a2/jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2", size = 19214172 }
+
+[[package]]
+name = "kiwisolver"
+version = "1.4.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167 },
+ { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579 },
+ { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309 },
+ { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596 },
+ { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548 },
+ { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618 },
+ { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437 },
+ { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742 },
+ { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810 },
+ { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579 },
+ { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071 },
+ { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840 },
+ { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159 },
+ { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686 },
+ { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460 },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952 },
+ { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756 },
+ { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404 },
+ { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410 },
+ { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631 },
+ { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963 },
+ { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295 },
+ { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987 },
+ { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817 },
+ { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895 },
+ { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992 },
+ { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681 },
+ { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464 },
+ { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961 },
+ { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607 },
+ { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546 },
+ { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482 },
+ { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720 },
+ { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907 },
+ { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334 },
+ { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313 },
+ { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970 },
+ { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894 },
+ { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995 },
+ { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510 },
+ { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903 },
+ { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402 },
+ { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135 },
+ { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409 },
+ { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763 },
+ { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643 },
+ { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818 },
+ { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963 },
+ { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639 },
+ { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741 },
+ { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646 },
+ { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806 },
+ { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605 },
+ { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925 },
+ { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414 },
+ { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272 },
+ { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578 },
+ { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607 },
+ { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150 },
+ { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979 },
+ { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456 },
+ { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621 },
+ { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417 },
+ { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582 },
+ { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514 },
+ { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905 },
+ { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399 },
+ { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197 },
+ { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125 },
+ { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612 },
+ { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990 },
+ { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601 },
+ { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041 },
+ { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897 },
+ { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835 },
+ { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988 },
+ { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260 },
+ { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104 },
+ { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592 },
+ { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281 },
+ { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009 },
+ { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929 },
+]
+
+[[package]]
+name = "loguru"
+version = "0.7.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "win32-setctime", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 },
+]
+
+[[package]]
+name = "matplotlib"
+version = "3.10.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "contourpy" },
+ { name = "cycler" },
+ { name = "fonttools" },
+ { name = "kiwisolver" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pillow" },
+ { name = "pyparsing" },
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215 },
+ { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625 },
+ { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614 },
+ { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997 },
+ { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825 },
+ { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090 },
+ { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377 },
+ { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453 },
+ { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321 },
+ { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944 },
+ { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099 },
+ { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040 },
+ { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717 },
+ { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751 },
+ { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076 },
+ { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794 },
+ { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474 },
+ { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637 },
+ { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678 },
+ { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686 },
+ { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917 },
+ { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679 },
+ { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336 },
+ { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653 },
+ { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356 },
+ { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000 },
+ { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043 },
+ { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075 },
+ { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481 },
+ { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473 },
+ { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896 },
+ { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193 },
+ { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444 },
+ { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719 },
+ { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205 },
+ { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785 },
+ { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361 },
+ { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357 },
+ { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610 },
+ { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011 },
+ { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801 },
+ { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560 },
+ { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198 },
+ { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817 },
+ { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867 },
+]
+
+[[package]]
+name = "numpy"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/34/2b1bc18424f3ad9af577f6ce23600319968a70575bd7db31ce66731bbef9/numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", size = 16944563 },
+ { url = "https://files.pythonhosted.org/packages/2c/57/26e5f97d075aef3794045a6ca9eada6a4ed70eb9a40e7a4a93f9ac80d704/numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", size = 12645658 },
+ { url = "https://files.pythonhosted.org/packages/8e/ba/80fc0b1e3cb2fd5c6143f00f42eb67762aa043eaa05ca924ecc3222a7849/numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", size = 5474132 },
+ { url = "https://files.pythonhosted.org/packages/40/ae/0a5b9a397f0e865ec171187c78d9b57e5588afc439a04ba9cab1ebb2c945/numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", size = 6804159 },
+ { url = "https://files.pythonhosted.org/packages/86/9c/841c15e691c7085caa6fd162f063eff494099c8327aeccd509d1ab1e36ab/numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", size = 14708058 },
+ { url = "https://files.pythonhosted.org/packages/5d/9d/7862db06743f489e6a502a3b93136d73aea27d97b2cf91504f70a27501d6/numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", size = 16651501 },
+ { url = "https://files.pythonhosted.org/packages/a6/9c/6fc34ebcbd4015c6e5f0c0ce38264010ce8a546cb6beacb457b84a75dfc8/numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", size = 16492627 },
+ { url = "https://files.pythonhosted.org/packages/aa/63/2494a8597502dacda439f61b3c0db4da59928150e62be0e99395c3ad23c5/numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", size = 18585052 },
+ { url = "https://files.pythonhosted.org/packages/6a/93/098e1162ae7522fc9b618d6272b77404c4656c72432ecee3abc029aa3de0/numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", size = 6236575 },
+ { url = "https://files.pythonhosted.org/packages/8c/de/f5e79650d23d9e12f38a7bc6b03ea0835b9575494f8ec94c11c6e773b1b1/numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", size = 12604479 },
+ { url = "https://files.pythonhosted.org/packages/dd/65/e1097a7047cff12ce3369bd003811516b20ba1078dbdec135e1cd7c16c56/numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", size = 10578325 },
+ { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888 },
+ { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956 },
+ { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567 },
+ { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459 },
+ { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859 },
+ { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419 },
+ { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131 },
+ { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342 },
+ { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015 },
+ { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730 },
+ { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166 },
+ { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495 },
+ { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657 },
+ { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256 },
+ { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212 },
+ { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871 },
+ { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305 },
+ { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909 },
+ { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380 },
+ { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089 },
+ { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230 },
+ { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125 },
+ { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156 },
+ { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663 },
+ { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224 },
+ { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352 },
+ { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279 },
+ { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316 },
+ { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884 },
+ { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138 },
+ { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478 },
+ { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981 },
+ { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046 },
+ { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858 },
+ { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417 },
+ { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643 },
+ { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963 },
+ { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811 },
+ { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643 },
+ { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601 },
+ { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722 },
+ { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590 },
+ { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180 },
+ { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774 },
+ { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274 },
+ { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306 },
+ { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653 },
+ { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144 },
+ { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425 },
+ { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053 },
+ { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482 },
+ { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117 },
+ { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121 },
+ { url = "https://files.pythonhosted.org/packages/1e/48/d86f97919e79314a1cdee4c832178763e6e98e623e123d0bada19e92c15a/numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", size = 16822202 },
+ { url = "https://files.pythonhosted.org/packages/51/e9/1e62a7f77e0f37dcfb0ad6a9744e65df00242b6ea37dfafb55debcbf5b55/numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", size = 12569985 },
+ { url = "https://files.pythonhosted.org/packages/c7/7e/914d54f0c801342306fdcdce3e994a56476f1b818c46c47fc21ae968088c/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", size = 5398484 },
+ { url = "https://files.pythonhosted.org/packages/1c/d8/9570b68584e293a33474e7b5a77ca404f1dcc655e40050a600dee81d27fb/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", size = 6713216 },
+ { url = "https://files.pythonhosted.org/packages/33/9b/9dd6e2db8d49eb24f86acaaa5258e5f4c8ed38209a4ee9de2d1a0ca25045/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", size = 14538937 },
+ { url = "https://files.pythonhosted.org/packages/53/87/d5bd995b0f798a37105b876350d346eea5838bd8f77ea3d7a48392f3812b/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", size = 16479830 },
+ { url = "https://files.pythonhosted.org/packages/5b/c7/b801bf98514b6ae6475e941ac05c58e6411dd863ea92916bfd6d510b08c1/numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", size = 12492579 },
+]
+
+[[package]]
+name = "packaging"
+version = "25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
+]
+
+[[package]]
+name = "pandas"
+version = "2.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "python-dateutil" },
+ { name = "pytz" },
+ { name = "tzdata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790 },
+ { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831 },
+ { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267 },
+ { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281 },
+ { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453 },
+ { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361 },
+ { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702 },
+ { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846 },
+ { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618 },
+ { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212 },
+ { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693 },
+ { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002 },
+ { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971 },
+ { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722 },
+ { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671 },
+ { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807 },
+ { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872 },
+ { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371 },
+ { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333 },
+ { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120 },
+ { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991 },
+ { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227 },
+ { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056 },
+ { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189 },
+ { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912 },
+ { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160 },
+ { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233 },
+ { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635 },
+ { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079 },
+ { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049 },
+ { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638 },
+ { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834 },
+ { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925 },
+ { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071 },
+ { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504 },
+ { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702 },
+ { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535 },
+ { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582 },
+ { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963 },
+ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175 },
+]
+
+[[package]]
+name = "pillow"
+version = "12.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057 },
+ { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811 },
+ { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243 },
+ { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872 },
+ { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398 },
+ { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667 },
+ { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743 },
+ { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342 },
+ { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655 },
+ { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469 },
+ { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515 },
+ { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642 },
+ { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464 },
+ { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878 },
+ { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868 },
+ { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468 },
+ { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518 },
+ { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829 },
+ { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756 },
+ { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770 },
+ { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406 },
+ { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612 },
+ { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543 },
+ { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373 },
+ { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241 },
+ { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410 },
+ { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312 },
+ { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605 },
+ { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617 },
+ { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509 },
+ { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117 },
+ { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151 },
+ { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534 },
+ { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551 },
+ { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087 },
+ { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470 },
+ { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816 },
+ { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472 },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974 },
+ { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070 },
+ { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176 },
+ { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061 },
+ { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824 },
+ { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911 },
+ { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445 },
+ { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354 },
+ { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547 },
+ { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533 },
+ { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546 },
+ { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163 },
+ { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086 },
+ { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344 },
+ { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114 },
+ { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708 },
+ { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762 },
+ { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265 },
+ { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341 },
+ { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395 },
+ { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413 },
+ { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779 },
+ { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105 },
+ { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571 },
+ { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426 },
+ { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908 },
+ { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733 },
+ { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431 },
+ { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529 },
+ { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981 },
+ { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878 },
+ { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703 },
+ { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927 },
+ { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104 },
+ { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605 },
+ { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245 },
+ { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593 },
+ { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008 },
+ { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824 },
+ { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278 },
+ { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809 },
+]
+
+[[package]]
+name = "playwright"
+version = "1.57.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "greenlet" },
+ { name = "pyee" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ed/b6/e17543cea8290ae4dced10be21d5a43c360096aa2cce0aa7039e60c50df3/playwright-1.57.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9351c1ac3dfd9b3820fe7fc4340d96c0d3736bb68097b9b7a69bd45d25e9370c", size = 41985039 },
+ { url = "https://files.pythonhosted.org/packages/8b/04/ef95b67e1ff59c080b2effd1a9a96984d6953f667c91dfe9d77c838fc956/playwright-1.57.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4a9d65027bce48eeba842408bcc1421502dfd7e41e28d207e94260fa93ca67e", size = 40775575 },
+ { url = "https://files.pythonhosted.org/packages/60/bd/5563850322a663956c927eefcf1457d12917e8f118c214410e815f2147d1/playwright-1.57.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:99104771abc4eafee48f47dac2369e0015516dc1ce8c409807d2dd440828b9a4", size = 41985042 },
+ { url = "https://files.pythonhosted.org/packages/56/61/3a803cb5ae0321715bfd5247ea871d25b32c8f372aeb70550a90c5f586df/playwright-1.57.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:284ed5a706b7c389a06caa431b2f0ba9ac4130113c3a779767dda758c2497bb1", size = 45975252 },
+ { url = "https://files.pythonhosted.org/packages/83/d7/b72eb59dfbea0013a7f9731878df8c670f5f35318cedb010c8a30292c118/playwright-1.57.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a1bae6c0a07839cdeaddbc0756b3b2b85e476c07945f64ece08f1f956a86f1", size = 45706917 },
+ { url = "https://files.pythonhosted.org/packages/e4/09/3fc9ebd7c95ee54ba6a68d5c0bc23e449f7235f4603fc60534a364934c16/playwright-1.57.0-py3-none-win32.whl", hash = "sha256:1dd93b265688da46e91ecb0606d36f777f8eadcf7fbef12f6426b20bf0c9137c", size = 36553860 },
+ { url = "https://files.pythonhosted.org/packages/58/d4/dcdfd2a33096aeda6ca0d15584800443dd2be64becca8f315634044b135b/playwright-1.57.0-py3-none-win_amd64.whl", hash = "sha256:6caefb08ed2c6f29d33b8088d05d09376946e49a73be19271c8cd5384b82b14c", size = 36553864 },
+ { url = "https://files.pythonhosted.org/packages/6a/60/fe31d7e6b8907789dcb0584f88be741ba388413e4fbce35f1eba4e3073de/playwright-1.57.0-py3-none-win_arm64.whl", hash = "sha256:5f065f5a133dbc15e6e7c71e7bc04f258195755b1c32a432b792e28338c8335e", size = 32837940 },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.12.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.41.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 },
+ { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 },
+ { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 },
+ { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890 },
+ { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740 },
+ { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021 },
+ { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378 },
+ { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761 },
+ { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303 },
+ { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355 },
+ { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875 },
+ { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549 },
+ { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305 },
+ { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902 },
+ { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 },
+ { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 },
+ { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 },
+ { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 },
+ { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 },
+ { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 },
+ { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 },
+ { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 },
+ { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 },
+ { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 },
+ { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 },
+ { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 },
+ { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 },
+ { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 },
+ { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 },
+ { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 },
+ { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 },
+ { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 },
+ { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 },
+ { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 },
+ { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 },
+ { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 },
+ { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 },
+ { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 },
+ { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 },
+ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 },
+ { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 },
+ { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 },
+ { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 },
+ { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 },
+ { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 },
+ { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 },
+ { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 },
+ { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 },
+ { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 },
+ { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 },
+ { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 },
+ { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 },
+ { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 },
+ { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 },
+ { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 },
+ { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 },
+ { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 },
+ { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 },
+ { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 },
+ { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 },
+ { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 },
+ { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 },
+ { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 },
+ { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 },
+ { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 },
+ { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 },
+ { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 },
+ { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 },
+ { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 },
+ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 },
+ { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441 },
+ { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291 },
+ { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632 },
+ { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905 },
+ { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495 },
+ { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 },
+ { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 },
+ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 },
+ { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 },
+ { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 },
+ { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 },
+ { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762 },
+ { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141 },
+ { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317 },
+ { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992 },
+ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302 },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880 },
+]
+
+[[package]]
+name = "pyee"
+version = "13.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730 },
+]
+
+[[package]]
+name = "pyparsing"
+version = "3.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/c1/1d9de9aeaa1b89b0186e5fe23294ff6517fce1bc69149185577cd31016b2/pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c", size = 1550512 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793 },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 },
+]
+
+[[package]]
+name = "pytz"
+version = "2025.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 },
+]
+
+[[package]]
+name = "tzdata"
+version = "2025.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521 },
+]
+
+[[package]]
+name = "win32-setctime"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 },
+]
+
+[[package]]
+name = "wordcloud"
+version = "1.9.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "matplotlib" },
+ { name = "numpy" },
+ { name = "pillow" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1f/a2/108cd319f6315931708a7c03d0824cd8684eb56e0af56e375e61785e4b3c/wordcloud-1.9.5.tar.gz", hash = "sha256:6ac7c1378f2886d7e849600a306febd41d0d46b15ce876d665a3e549f5403b0b", size = 27563652 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7d/a2/319d4fac92cc9a943d86fd1feb39077e6ca74dfeca8b0bc5a5be409d235f/wordcloud-1.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4156064e1d20a553125fef32f7bb5b2333a1966661f71688e714380a88ca4ea", size = 168771 },
+ { url = "https://files.pythonhosted.org/packages/b7/0c/d4a1510749489b1ac1390ec15f8f814923ae65014367533ebbc167222cc5/wordcloud-1.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6ab1913657a2189b84ae9ab7eacb50516e37586f420ccdc6abdfaa23512e4424", size = 168402 },
+ { url = "https://files.pythonhosted.org/packages/e9/03/1ff71d1ba850aa15f2c1959ead7142db781e4a767bb95045358fd0927290/wordcloud-1.9.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed6dbd945c3e4a18b39823a7ffea839499e684c85f10d25cd7693d1b9892c52a", size = 547684 },
+ { url = "https://files.pythonhosted.org/packages/bf/0c/606dde0beb4abd952a8d1631e4630fac70c51daedd94a204aa0086f3da6e/wordcloud-1.9.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55c0a6d38ba4fef29738e4725142b4c0c3769f301047758d480eb110e6796060", size = 551670 },
+ { url = "https://files.pythonhosted.org/packages/4f/74/2ebe53a215e7e88b89983f16b14fb85ab75ba94817fa11e466f51808ac12/wordcloud-1.9.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b31c2568af3201806193e7ca5c05e7d66b60ba2bcd854b08501b2496d77ca2ff", size = 544097 },
+ { url = "https://files.pythonhosted.org/packages/06/65/83525a140ed7b26b367e56010846a3e655f26053eb336112d8b6559e87a2/wordcloud-1.9.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1a630bfff5614b90e3cbb4b6d8a74bb296dc247126868fe40c1e5678c3f2ca58", size = 555467 },
+ { url = "https://files.pythonhosted.org/packages/41/6a/8d22301bc03756652cc7081f2f196614c1cd505e8cd22d771e6bc7530d6e/wordcloud-1.9.5-cp311-cp311-win32.whl", hash = "sha256:69ff2f262ca349ae59482b647aef63222d19aeba54f38a705ffeca558847826c", size = 295608 },
+ { url = "https://files.pythonhosted.org/packages/14/9e/6d5357fe58af3a1c6f7e58eaa88e77fb86556ff4027e8ea90032a6185ba0/wordcloud-1.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:ded40e3ebaaa96eaa7ae86df0bbe89da7ebf5301efcfe9e429145a2a04dc72fb", size = 306082 },
+ { url = "https://files.pythonhosted.org/packages/f0/af/8ca23d9a29c7e646e9e21ca0c3f798b08dd3e58ea61b32f4431a13d27d41/wordcloud-1.9.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ac56a1bdd961253f528d48044650ede52534a2fc47427c16729e8386a2beba29", size = 170100 },
+ { url = "https://files.pythonhosted.org/packages/3a/18/4239c7a209a55a1dbb58d2bfca215d9a53500eab5b4386d1b5c44d47a073/wordcloud-1.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:daf0a1a80fdd8bb60619fa3e6855ecf862efaa6149e1c86fcbefe5db02354cd4", size = 168920 },
+ { url = "https://files.pythonhosted.org/packages/bf/4f/dc24ca5c366a7f5ff2d7ea510cf50ed5b3773825ceba56af1cf9b803437e/wordcloud-1.9.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d36983701f194828fa99c1162cd16610f43959a5fe09f9f8b7b6619ab7390051", size = 548944 },
+ { url = "https://files.pythonhosted.org/packages/90/99/a4bc45e087f7f3f11893b0a4feea5d9d72ecd75d9c615341e04de069023b/wordcloud-1.9.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b86143325c12479f02c7f994cf19afcc5bc194a2d74456adf03d71c91215bb99", size = 555208 },
+ { url = "https://files.pythonhosted.org/packages/10/c0/021e86f11fe660adb88d58e7b3f66658ae9a93b02f1d75c5fb036d4a7359/wordcloud-1.9.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:599ff3e40121ad6b2714daf3fb19c094d3248de69429792a674321079dc93bfe", size = 539344 },
+ { url = "https://files.pythonhosted.org/packages/7d/ef/a08ea52eb7649d9296abbc6319634b1ece7bc14b1080ac92a460d725a410/wordcloud-1.9.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f32fbe231518534b20703dadc7e397cf6edee82c0ff572ce9193447e4418cf94", size = 554955 },
+ { url = "https://files.pythonhosted.org/packages/a0/42/116c6f3365ad2f0d882ad68c33122e3a82c7503a6eadfff1286a59121efa/wordcloud-1.9.5-cp312-cp312-win32.whl", hash = "sha256:5a8954b28d5c9d515944343adecd9dfde8dbe723815768fdc5ae5eb541426f1e", size = 296178 },
+ { url = "https://files.pythonhosted.org/packages/ba/66/04e0f33135d7b8d76bd1721c1c7a42a0cbe748ff48588ab6ea01316e1ab0/wordcloud-1.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:790cf92513a1f5e4d65c801d9fe35c607a4219079075f342bb2fe32d427d64ce", size = 307255 },
+ { url = "https://files.pythonhosted.org/packages/04/14/261b76055dda37c4adda27d81b4c4917c0c8c0beeb82bc17cc929112fd19/wordcloud-1.9.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a8468c5943ca2c95a5785314dd8b9c2aff622a16459ec16f44fbabfdb47ed68d", size = 169342 },
+ { url = "https://files.pythonhosted.org/packages/50/14/3d60c08364ae1a8c54ab7b1f326f69c681e7c59dff32081bf75adb2b2b26/wordcloud-1.9.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc68fc27f2fc613a903271c03ff9c47801bce9e4663cf940831d434cb8e7aae8", size = 168295 },
+ { url = "https://files.pythonhosted.org/packages/08/55/275ded21f0b815c93d12d66b0425b1ee549b19bb5c5e8d60924e43f05b7e/wordcloud-1.9.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:000c8ee667a572334763de28d49838456a12642f272b97c5883550ba143f2d93", size = 543738 },
+ { url = "https://files.pythonhosted.org/packages/e8/56/72d77bc4416a6aa97ffbba633ac6d8f75156cd593e5f559cad8d84553be4/wordcloud-1.9.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5ae9697c4a6674fd19b16178f1c40b8f3445eb6324fa06cbe0ff053c03c3d61", size = 551843 },
+ { url = "https://files.pythonhosted.org/packages/cb/6f/937f53365cc67f98325057490d63a27749c8526fd8bea8e4a1fbe74045e3/wordcloud-1.9.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a16c7bc45b1625374620d0a859c0c6dea0b86a7161b054a9949deaabac5da3dc", size = 536626 },
+ { url = "https://files.pythonhosted.org/packages/63/cc/d566ab24da637787a381a1bc9999a166932b53d2b73b12b989c8fb1fa595/wordcloud-1.9.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecd0400835ee92d9d335aac3d73b3a50cb44f3ba408f03132d5d0bd915b8feef", size = 552904 },
+ { url = "https://files.pythonhosted.org/packages/fb/96/47bfce33702be594bad4c1f652e7e16c1d8efd5b8163f9389f38dfa4b3fa/wordcloud-1.9.5-cp313-cp313-win32.whl", hash = "sha256:dc2027675d9c8a72e6565844e442736ff55ce670421ddb02c77d5d9178a9a798", size = 296069 },
+ { url = "https://files.pythonhosted.org/packages/c1/b9/440cf09c98680f15ccd83aa31a71d8789ad70ee65a731ead23a6ba8b169c/wordcloud-1.9.5-cp313-cp313-win_amd64.whl", hash = "sha256:b02205ff66f81bc6be1c418c98da08c353847196f2ccf945bd3ea5a52f22aec7", size = 307047 },
+ { url = "https://files.pythonhosted.org/packages/29/44/59372ad37df4c93a662f405689ad35039a40c6a0e0dc72e420a6b9bf8aab/wordcloud-1.9.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8cc6baa2f2a64cf6c65eb0034997b54e2a7538e14833582ad91f3686826ebb14", size = 169693 },
+ { url = "https://files.pythonhosted.org/packages/72/b3/60481e917ce07f3139961eb1dc32f45746dda132fc919d27f2aefa6ca2a2/wordcloud-1.9.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3e744e70d99b2083f7a544c77fea59e0df92053c3d310669c43235bbdaa9a6b9", size = 168940 },
+ { url = "https://files.pythonhosted.org/packages/6a/c5/dd5e409a9ecb2e1fa026b061703b16282257b350dd5a15c5b8de682ecc04/wordcloud-1.9.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:508a569826751f52db1225c91e01f3ea4c725421431338a6cb17768aa29b3d15", size = 543226 },
+ { url = "https://files.pythonhosted.org/packages/01/ab/9c24089ea2883403a3a1586745f827cb6866bd43b41276a0d00d12a4b978/wordcloud-1.9.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e09563dd90aeb42d2ca034b39ba691298f783be373dc7d7c2d7430e729c1f41", size = 547357 },
+ { url = "https://files.pythonhosted.org/packages/03/c2/7d24d1fdddf329da69a2c72a6204e40250e42478037c593545d70691539e/wordcloud-1.9.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:97d0ac557dc80710e56609760f733ae4f382fc93e4c807c4abcfacbfb1a7a4c3", size = 535710 },
+ { url = "https://files.pythonhosted.org/packages/ba/b9/c474a1d651fe2d941a6f92ad65e8debaebc8da7df9773182002ccef9787a/wordcloud-1.9.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7addfa9623317ff7b59de1afe308bfb42db575ef804b271b11777797c0699e02", size = 549386 },
+ { url = "https://files.pythonhosted.org/packages/12/9c/026fee4942a6e21074d19929b4833b6d4c1019e7e225d73370e85cba3e91/wordcloud-1.9.5-cp314-cp314-win32.whl", hash = "sha256:0e8c8aab33d9b495656b1315715a31c222f1ec8c9ca40d719372e200fee0204c", size = 297175 },
+ { url = "https://files.pythonhosted.org/packages/6b/4e/938315f85438df0e225cb613d783301585bf1adf8d5fe869dca18b029e71/wordcloud-1.9.5-cp314-cp314-win_amd64.whl", hash = "sha256:cda8de69df5fac5a90aea3646993b03b4a920d8aa6454b6f6e58c341397b9ca6", size = 308691 },
+ { url = "https://files.pythonhosted.org/packages/55/a2/d04ca5669acddefe29faeb3d7103b6f735b23ebaad82cf73a067561c906b/wordcloud-1.9.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6cee7e8668b1905844a4901597f2dc12ddb97f58586dc520eaf1016a6949cd6e", size = 174155 },
+ { url = "https://files.pythonhosted.org/packages/68/05/f77b6ceb7eead741a3b2abbdbad5cb404f1d1297a9708766f452a115341b/wordcloud-1.9.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4c3d3c2477613a61ea671ace84b0acde3b00d0e9afd49636d4bf3e504a3a8a05", size = 174214 },
+ { url = "https://files.pythonhosted.org/packages/a6/2c/265936f5efc0edcd9204107f3f5ecf224514370fe4886fe1b7ae35018b63/wordcloud-1.9.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f81f7978cf247a981764b15e90c46adc90a1af8c8983e376dbddd6a94137862", size = 559691 },
+ { url = "https://files.pythonhosted.org/packages/31/74/ee7ea5117554e36fcb2ed878d4a271ef8c0af0c3cd4727694d67814c131a/wordcloud-1.9.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c71a569259983733a496df6918c72fbecb480929f8b6514fbe754030b41ab7a", size = 552045 },
+ { url = "https://files.pythonhosted.org/packages/55/71/674f39d3a766b1d89c56a9671746653e169fd84251617a848258167a4936/wordcloud-1.9.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c3c1c9b865b8ddf22a9eb529d83395f73151117d06e3e9aeaa1ef0f6db1979af", size = 542971 },
+ { url = "https://files.pythonhosted.org/packages/27/8c/613b2f63ed3231ac536e1efb45c0ab73037e53b5f00449ece9664df6b31f/wordcloud-1.9.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c9bd4cbd662774e4d83ffb70cfb4a5de4107ae468247e8b68c4cc87a81dd4efc", size = 546190 },
+ { url = "https://files.pythonhosted.org/packages/92/ca/0041d90657e2422c76d75a1f32cced0152251bf9d2e9005975cc69b3c953/wordcloud-1.9.5-cp314-cp314t-win32.whl", hash = "sha256:de4749944686c5cfc10143f718d24c965bfbff48d920273cd5b15e889b89a3ae", size = 306425 },
+ { url = "https://files.pythonhosted.org/packages/ea/47/5f27d088000e301d174d33a6dd852f7ea6bae6e914e9971d24a9460fa35e/wordcloud-1.9.5-cp314-cp314t-win_amd64.whl", hash = "sha256:e19c3883165967ad4e0cb7baa9208fdca758cfd0f75d68743a9390269180d47a", size = 320479 },
+]
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/README.md" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/README.md"
new file mode 100644
index 0000000..347e13e
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/README.md"
@@ -0,0 +1,176 @@
+# Python 爬虫进阶教程 - 源代码
+
+本目录包含11个进阶章节的完整示例代码,每个章节都是独立的 uv 项目。
+
+## 🚀 快速开始
+
+每个章节目录都配置了独立的 `pyproject.toml`,可以使用 uv 快速安装依赖:
+
+```bash
+# 进入任意章节目录
+cd 01_工程化爬虫开发规范
+
+# 安装依赖
+uv sync
+
+# 运行示例代码
+uv run python main.py
+```
+
+## 📚 章节目录
+
+### [01_工程化爬虫开发规范](./01_工程化爬虫开发规范/)
+**技术栈**:httpx, pydantic, parsel, loguru
+**内容**:日志系统、配置管理、异常处理、项目结构规范
+
+### [02_反爬虫对抗基础_请求伪装](./02_反爬虫对抗基础_请求伪装/)
+**技术栈**:httpx, loguru, fake-useragent
+**内容**:User-Agent轮换、请求头伪装、速率控制、TLS指纹
+
+### [03_代理IP的使用与管理](./03_代理IP的使用与管理/)
+**技术栈**:httpx, loguru
+**内容**:代理池设计、代理检测、多URL测试、代理轮换
+**新增**:✨ 添加了 ipify.org 和 ip-api.com 多URL测试
+
+### [04_Playwright浏览器自动化入门](./04_Playwright浏览器自动化入门/)
+**技术栈**:playwright, loguru
+**内容**:页面操作、元素定位、等待策略、SPA爬取
+**目标网站**:quotes.toscrape.com
+
+### [05_Playwright进阶_反检测与性能优化](./05_Playwright进阶_反检测与性能优化/)
+**技术栈**:playwright, loguru
+**内容**:stealth.js注入、CDP模式、资源拦截、上下文池
+**测试网站**:bot.sannysoft.com
+
+### [06_登录认证_Cookie与Session管理](./06_登录认证_Cookie与Session管理/)
+**技术栈**:httpx, loguru
+**内容**:Cookie管理、登录状态检测、Cookie轮换
+**新增**:✨ 添加了 quotes.toscrape.com 真实登录演示
+
+### [07_登录认证_扫码与短信登录实现](./07_登录认证_扫码与短信登录实现/)
+**技术栈**:playwright, loguru
+**内容**:扫码登录框架、短信登录框架、登录工厂模式
+**说明**:⚠️ 技术框架示例,需根据实际网站适配
+
+### [08_验证码识别与处理](./08_验证码识别与处理/)
+**技术栈**:loguru, pillow, ddddocr(可选), opencv(可选)
+**内容**:图片验证码OCR、滑块验证码、人类轨迹生成
+
+### [09_数据清洗与预处理](./09_数据清洗与预处理/)
+**技术栈**:Python标准库
+**内容**:文本清洗、数据标准化、精确/模糊去重
+
+### [10_数据分析与可视化](./10_数据分析与可视化/)
+**技术栈**:pandas, jieba, wordcloud, matplotlib
+**内容**:数据统计分析、词云生成、图表绘制
+
+### [11_进阶综合实战项目](./11_进阶综合实战项目/) ⭐
+**技术栈**:playwright, httpx, pydantic, pandas, wordcloud
+**内容**:完整的电商数据采集工具
+**目标网站**:✅ books.toscrape.com(专门的爬虫练习网站)
+**更新**:✨ 已替换为真实可用的练习网站
+
+## 📦 依赖安装
+
+### 基础用法
+
+```bash
+# 1. 进入章节目录
+cd 01_工程化爬虫开发规范
+
+# 2. 安装依赖
+uv sync
+
+# 3. 运行代码
+uv run python main.py
+```
+
+### 可选依赖
+
+某些章节提供了可选功能,可以按需安装:
+
+```bash
+# 章节02 - 安装TLS指纹伪装
+uv sync --extra advanced
+
+# 章节08 - 安装所有验证码识别功能
+uv sync --extra all
+
+# 章节10 - 安装交互式图表功能
+uv sync --extra interactive
+```
+
+### Playwright 特别说明
+
+使用 Playwright 的章节(04, 05, 07, 11)需要额外安装浏览器驱动:
+
+```bash
+uv sync
+uv run playwright install chromium
+```
+
+## 🎯 重点更新说明
+
+### ✨ 章节03 - 代理IP管理
+新增了多URL测试功能(`demo_multi_url_test()`):
+- httpbin.org/ip - 通用HTTP测试
+- api.ipify.org - 专门IP服务
+- ip-api.com/json/ - IP地理位置信息
+
+### ✨ 章节06 - Cookie管理
+新增了真实登录演示(`demo_real_login()`):
+- 演示 quotes.toscrape.com 完整登录流程
+- Cookie保存和复用
+- CookieManager集成
+
+### ✨ 章节11 - 综合实战项目
+**重大更新**:将 example.com 替换为真实可用的练习网站:
+- **新目标**:http://books.toscrape.com
+- **特点**:50页书籍数据,1000+条记录
+- **字段**:标题、价格、评分、库存、链接
+- **状态**:✅ 代码可直接运行
+
+## 📖 使用建议
+
+1. **按顺序学习**:章节之间有递进关系,建议按顺序学习
+2. **动手实践**:每章都提供了可运行的示例代码
+3. **查看文档**:每个章节的 README.md 提供了详细说明
+4. **遵守规范**:爬虫技术仅用于学习研究,请遵守法律法规
+
+## 🛠️ 系统要求
+
+- **Python**:>= 3.11
+- **uv**:最新版本(用于依赖管理)
+- **操作系统**:支持 macOS / Linux / Windows
+
+## 📝 常见问题
+
+### Q: 如何安装 uv?
+```bash
+# macOS/Linux
+curl -LsSf https://astral.sh/uv/install.sh | sh
+
+# Windows
+powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
+```
+
+### Q: Playwright 浏览器安装失败?
+```bash
+# 单独安装 Chromium
+uv run playwright install chromium
+
+# 如果网络问题,可以设置镜像
+export PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright/
+uv run playwright install chromium
+```
+
+### Q: 某些库无法安装?
+部分库可能需要系统级依赖,请查看各章节 README 的说明。
+
+## 🤝 贡献
+
+欢迎提交 Issue 和 Pull Request 改进教程代码!
+
+## 📄 许可证
+
+本教程代码仅供学习参考使用。
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/pyproject.toml" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/pyproject.toml"
new file mode 100644
index 0000000..39f07a5
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/pyproject.toml"
@@ -0,0 +1,43 @@
+[project]
+name = "crawler-advanced-tutorial"
+version = "0.1.0"
+description = "爬虫进阶教程 - 包含11个章节的完整学习代码"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ # 核心依赖(所有章节共用)
+ "loguru>=0.7.0",
+ "httpx>=0.27.0",
+ "pydantic>=2.0.0",
+ "pydantic-settings>=2.0.0",
+
+ # 数据解析
+ "parsel>=1.9.0",
+
+ # 反爬虫对抗
+ "fake-useragent>=1.5.0",
+ "curl-cffi>=0.7.0",
+
+ # 浏览器自动化
+ "playwright>=1.45.0",
+
+ # 验证码处理
+ "pillow>=10.0.0",
+ "ddddocr>=1.4.0",
+ "opencv-python>=4.9.0",
+
+ # 数据分析与可视化
+ "pandas>=2.2.0",
+ "jieba>=0.42.0",
+ "wordcloud>=1.9.0",
+ "matplotlib>=3.8.0",
+ "pyecharts>=2.0.0",
+
+ # 其他工具
+ "cryptography>=42.0.0",
+ "qrcode[pil]>=7.4.0",
+ "simhash>=2.1.0",
+]
+
+[tool.uv]
+dev-dependencies = []
diff --git "a/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/uv.lock" "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/uv.lock"
new file mode 100644
index 0000000..83f21ed
--- /dev/null
+++ "b/\346\272\220\344\273\243\347\240\201/\347\210\254\350\231\253\350\277\233\351\230\266/uv.lock"
@@ -0,0 +1,1674 @@
+version = 1
+revision = 1
+requires-python = ">=3.11"
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version < '3.12'",
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
+]
+
+[[package]]
+name = "anyio"
+version = "4.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900 },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 },
+ { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 },
+ { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 },
+ { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 },
+ { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 },
+ { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 },
+ { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 },
+ { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 },
+ { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 },
+ { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 },
+ { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 },
+ { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 },
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 },
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 },
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 },
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 },
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 },
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 },
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 },
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+]
+
+[[package]]
+name = "coloredlogs"
+version = "15.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "humanfriendly" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018 },
+]
+
+[[package]]
+name = "contourpy"
+version = "1.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773 },
+ { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149 },
+ { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222 },
+ { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234 },
+ { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555 },
+ { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238 },
+ { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218 },
+ { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867 },
+ { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677 },
+ { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234 },
+ { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123 },
+ { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419 },
+ { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979 },
+ { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653 },
+ { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536 },
+ { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397 },
+ { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601 },
+ { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288 },
+ { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386 },
+ { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018 },
+ { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567 },
+ { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655 },
+ { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257 },
+ { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034 },
+ { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672 },
+ { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234 },
+ { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169 },
+ { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859 },
+ { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062 },
+ { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932 },
+ { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024 },
+ { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578 },
+ { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524 },
+ { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730 },
+ { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897 },
+ { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751 },
+ { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486 },
+ { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106 },
+ { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548 },
+ { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297 },
+ { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023 },
+ { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157 },
+ { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570 },
+ { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713 },
+ { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189 },
+ { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251 },
+ { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810 },
+ { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871 },
+ { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264 },
+ { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819 },
+ { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650 },
+ { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833 },
+ { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692 },
+ { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424 },
+ { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300 },
+ { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769 },
+ { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892 },
+ { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748 },
+ { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554 },
+ { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118 },
+ { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555 },
+ { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295 },
+ { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027 },
+ { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428 },
+ { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331 },
+ { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831 },
+ { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809 },
+ { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593 },
+ { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202 },
+ { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207 },
+ { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315 },
+]
+
+[[package]]
+name = "crawler-advanced-tutorial"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "cryptography" },
+ { name = "curl-cffi" },
+ { name = "ddddocr" },
+ { name = "fake-useragent" },
+ { name = "httpx" },
+ { name = "jieba" },
+ { name = "loguru" },
+ { name = "matplotlib" },
+ { name = "opencv-python" },
+ { name = "pandas" },
+ { name = "parsel" },
+ { name = "pillow" },
+ { name = "playwright" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "pyecharts" },
+ { name = "qrcode", extra = ["pil"] },
+ { name = "simhash" },
+ { name = "wordcloud" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "cryptography", specifier = ">=42.0.0" },
+ { name = "curl-cffi", specifier = ">=0.7.0" },
+ { name = "ddddocr", specifier = ">=1.4.0" },
+ { name = "fake-useragent", specifier = ">=1.5.0" },
+ { name = "httpx", specifier = ">=0.27.0" },
+ { name = "jieba", specifier = ">=0.42.0" },
+ { name = "loguru", specifier = ">=0.7.0" },
+ { name = "matplotlib", specifier = ">=3.8.0" },
+ { name = "opencv-python", specifier = ">=4.9.0" },
+ { name = "pandas", specifier = ">=2.2.0" },
+ { name = "parsel", specifier = ">=1.9.0" },
+ { name = "pillow", specifier = ">=10.0.0" },
+ { name = "playwright", specifier = ">=1.45.0" },
+ { name = "pydantic", specifier = ">=2.0.0" },
+ { name = "pydantic-settings", specifier = ">=2.0.0" },
+ { name = "pyecharts", specifier = ">=2.0.0" },
+ { name = "qrcode", extras = ["pil"], specifier = ">=7.4.0" },
+ { name = "simhash", specifier = ">=2.1.0" },
+ { name = "wordcloud", specifier = ">=1.9.0" },
+]
+
+[package.metadata.requires-dev]
+dev = []
+
+[[package]]
+name = "cryptography"
+version = "46.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004 },
+ { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667 },
+ { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807 },
+ { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615 },
+ { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800 },
+ { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707 },
+ { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541 },
+ { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464 },
+ { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838 },
+ { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596 },
+ { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782 },
+ { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381 },
+ { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988 },
+ { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451 },
+ { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007 },
+ { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012 },
+ { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728 },
+ { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078 },
+ { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460 },
+ { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237 },
+ { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344 },
+ { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564 },
+ { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415 },
+ { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457 },
+ { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074 },
+ { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569 },
+ { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941 },
+ { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339 },
+ { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315 },
+ { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331 },
+ { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248 },
+ { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089 },
+ { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029 },
+ { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222 },
+ { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280 },
+ { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958 },
+ { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714 },
+ { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970 },
+ { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236 },
+ { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642 },
+ { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126 },
+ { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573 },
+ { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695 },
+ { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720 },
+ { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740 },
+ { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132 },
+ { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992 },
+ { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944 },
+ { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957 },
+ { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447 },
+ { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528 },
+]
+
+[[package]]
+name = "cssselect"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/0a/c3ea9573b1dc2e151abfe88c7fe0c26d1892fe6ed02d0cdb30f0d57029d5/cssselect-1.3.0.tar.gz", hash = "sha256:57f8a99424cfab289a1b6a816a43075a4b00948c86b4dcf3ef4ee7e15f7ab0c7", size = 42870 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786 },
+]
+
+[[package]]
+name = "curl-cffi"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277 },
+ { url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650 },
+ { url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918 },
+ { url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624 },
+ { url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654 },
+ { url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969 },
+ { url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133 },
+ { url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167 },
+ { url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464 },
+ { url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416 },
+ { url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067 },
+]
+
+[[package]]
+name = "cycler"
+version = "0.12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 },
+]
+
+[[package]]
+name = "ddddocr"
+version = "1.5.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "onnxruntime" },
+ { name = "opencv-python-headless" },
+ { name = "pillow" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0e/cf/1243d5f0d03763a287375366f68eadb5c14418f5b3df00c09eb971e526a7/ddddocr-1.5.6.tar.gz", hash = "sha256:2839a940bfabe02e3284ef3f9d2a037292aa9f641f355b43a9b70bece9e1b73d", size = 75825027 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/74/418c1c0be49463799f9eeb307a8aa4013ff5fca5e0387f0ef2762fcdb4e2/ddddocr-1.5.6-py3-none-any.whl", hash = "sha256:f13865b00e42de5c2507c1889ba73c2bacd218a49d15b928c2a5c82667062ac5", size = 75868010 },
+]
+
+[[package]]
+name = "fake-useragent"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/43/948d10bf42735709edb5ae51e23297d034086f17fc7279fef385a7acb473/fake_useragent-2.2.0.tar.gz", hash = "sha256:4e6ab6571e40cc086d788523cf9e018f618d07f9050f822ff409a4dfe17c16b2", size = 158898 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/37/b3ea9cd5558ff4cb51957caca2193981c6b0ff30bd0d2630ac62505d99d0/fake_useragent-2.2.0-py3-none-any.whl", hash = "sha256:67f35ca4d847b0d298187443aaf020413746e56acd985a611908c73dba2daa24", size = 161695 },
+]
+
+[[package]]
+name = "flatbuffers"
+version = "25.12.19"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661 },
+]
+
+[[package]]
+name = "fonttools"
+version = "4.61.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213 },
+ { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689 },
+ { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809 },
+ { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039 },
+ { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714 },
+ { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648 },
+ { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681 },
+ { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951 },
+ { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593 },
+ { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231 },
+ { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103 },
+ { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295 },
+ { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109 },
+ { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598 },
+ { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060 },
+ { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078 },
+ { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454 },
+ { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191 },
+ { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410 },
+ { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460 },
+ { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800 },
+ { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859 },
+ { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821 },
+ { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169 },
+ { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094 },
+ { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589 },
+ { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892 },
+ { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884 },
+ { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405 },
+ { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553 },
+ { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915 },
+ { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487 },
+ { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571 },
+ { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317 },
+ { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124 },
+ { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391 },
+ { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800 },
+ { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426 },
+ { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377 },
+ { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613 },
+ { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996 },
+]
+
+[[package]]
+name = "greenlet"
+version = "3.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908 },
+ { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113 },
+ { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338 },
+ { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098 },
+ { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206 },
+ { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668 },
+ { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483 },
+ { url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164 },
+ { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379 },
+ { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294 },
+ { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742 },
+ { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297 },
+ { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885 },
+ { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424 },
+ { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017 },
+ { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964 },
+ { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140 },
+ { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219 },
+ { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211 },
+ { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311 },
+ { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833 },
+ { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256 },
+ { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483 },
+ { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833 },
+ { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671 },
+ { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360 },
+ { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160 },
+ { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388 },
+ { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166 },
+ { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193 },
+ { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653 },
+ { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387 },
+ { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638 },
+ { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145 },
+ { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236 },
+ { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506 },
+ { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783 },
+ { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857 },
+ { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034 },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
+]
+
+[[package]]
+name = "humanfriendly"
+version = "10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyreadline3", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794 },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 },
+]
+
+[[package]]
+name = "jieba"
+version = "0.42.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c6/cb/18eeb235f833b726522d7ebed54f2278ce28ba9438e3135ab0278d9792a2/jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2", size = 19214172 }
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
+]
+
+[[package]]
+name = "jmespath"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 },
+]
+
+[[package]]
+name = "kiwisolver"
+version = "1.4.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167 },
+ { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579 },
+ { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309 },
+ { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596 },
+ { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548 },
+ { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618 },
+ { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437 },
+ { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742 },
+ { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810 },
+ { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579 },
+ { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071 },
+ { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840 },
+ { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159 },
+ { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686 },
+ { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460 },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952 },
+ { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756 },
+ { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404 },
+ { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410 },
+ { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631 },
+ { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963 },
+ { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295 },
+ { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987 },
+ { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817 },
+ { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895 },
+ { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992 },
+ { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681 },
+ { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464 },
+ { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961 },
+ { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607 },
+ { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546 },
+ { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482 },
+ { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720 },
+ { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907 },
+ { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334 },
+ { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313 },
+ { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970 },
+ { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894 },
+ { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995 },
+ { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510 },
+ { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903 },
+ { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402 },
+ { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135 },
+ { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409 },
+ { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763 },
+ { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643 },
+ { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818 },
+ { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963 },
+ { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639 },
+ { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741 },
+ { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646 },
+ { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806 },
+ { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605 },
+ { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925 },
+ { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414 },
+ { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272 },
+ { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578 },
+ { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607 },
+ { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150 },
+ { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979 },
+ { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456 },
+ { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621 },
+ { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417 },
+ { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582 },
+ { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514 },
+ { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905 },
+ { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399 },
+ { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197 },
+ { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125 },
+ { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612 },
+ { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990 },
+ { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601 },
+ { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041 },
+ { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897 },
+ { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835 },
+ { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988 },
+ { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260 },
+ { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104 },
+ { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592 },
+ { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281 },
+ { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009 },
+ { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929 },
+]
+
+[[package]]
+name = "loguru"
+version = "0.7.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "win32-setctime", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 },
+]
+
+[[package]]
+name = "lxml"
+version = "6.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365 },
+ { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793 },
+ { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362 },
+ { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152 },
+ { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539 },
+ { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853 },
+ { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133 },
+ { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944 },
+ { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535 },
+ { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343 },
+ { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419 },
+ { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008 },
+ { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906 },
+ { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357 },
+ { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583 },
+ { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591 },
+ { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887 },
+ { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818 },
+ { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807 },
+ { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179 },
+ { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044 },
+ { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685 },
+ { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127 },
+ { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958 },
+ { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541 },
+ { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426 },
+ { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917 },
+ { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795 },
+ { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759 },
+ { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666 },
+ { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989 },
+ { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456 },
+ { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793 },
+ { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836 },
+ { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494 },
+ { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146 },
+ { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932 },
+ { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060 },
+ { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000 },
+ { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496 },
+ { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779 },
+ { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072 },
+ { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675 },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171 },
+ { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175 },
+ { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688 },
+ { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655 },
+ { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695 },
+ { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841 },
+ { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700 },
+ { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347 },
+ { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248 },
+ { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801 },
+ { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403 },
+ { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974 },
+ { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953 },
+ { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054 },
+ { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421 },
+ { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684 },
+ { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463 },
+ { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437 },
+ { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890 },
+ { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185 },
+ { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895 },
+ { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246 },
+ { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797 },
+ { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404 },
+ { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072 },
+ { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617 },
+ { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930 },
+ { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380 },
+ { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632 },
+ { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171 },
+ { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109 },
+ { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061 },
+ { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233 },
+ { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739 },
+ { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119 },
+ { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665 },
+ { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997 },
+ { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957 },
+ { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372 },
+ { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653 },
+ { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795 },
+ { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023 },
+ { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420 },
+ { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837 },
+ { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205 },
+ { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829 },
+ { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277 },
+ { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433 },
+ { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119 },
+ { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314 },
+ { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768 },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 },
+ { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 },
+ { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 },
+ { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 },
+ { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 },
+ { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 },
+ { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 },
+ { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 },
+ { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 },
+ { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 },
+ { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 },
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 },
+]
+
+[[package]]
+name = "matplotlib"
+version = "3.10.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "contourpy" },
+ { name = "cycler" },
+ { name = "fonttools" },
+ { name = "kiwisolver" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pillow" },
+ { name = "pyparsing" },
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215 },
+ { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625 },
+ { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614 },
+ { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997 },
+ { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825 },
+ { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090 },
+ { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377 },
+ { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453 },
+ { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321 },
+ { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944 },
+ { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099 },
+ { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040 },
+ { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717 },
+ { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751 },
+ { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076 },
+ { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794 },
+ { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474 },
+ { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637 },
+ { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678 },
+ { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686 },
+ { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917 },
+ { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679 },
+ { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336 },
+ { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653 },
+ { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356 },
+ { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000 },
+ { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043 },
+ { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075 },
+ { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481 },
+ { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473 },
+ { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896 },
+ { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193 },
+ { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444 },
+ { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719 },
+ { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205 },
+ { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785 },
+ { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361 },
+ { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357 },
+ { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610 },
+ { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011 },
+ { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801 },
+ { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560 },
+ { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198 },
+ { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817 },
+ { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867 },
+]
+
+[[package]]
+name = "mpmath"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 },
+]
+
+[[package]]
+name = "numpy"
+version = "2.2.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963 },
+ { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743 },
+ { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616 },
+ { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579 },
+ { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005 },
+ { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570 },
+ { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548 },
+ { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521 },
+ { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866 },
+ { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455 },
+ { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348 },
+ { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362 },
+ { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103 },
+ { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382 },
+ { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462 },
+ { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618 },
+ { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511 },
+ { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783 },
+ { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506 },
+ { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190 },
+ { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828 },
+ { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006 },
+ { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765 },
+ { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736 },
+ { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719 },
+ { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072 },
+ { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213 },
+ { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632 },
+ { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532 },
+ { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885 },
+ { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467 },
+ { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144 },
+ { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217 },
+ { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014 },
+ { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935 },
+ { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122 },
+ { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143 },
+ { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260 },
+ { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225 },
+ { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374 },
+]
+
+[[package]]
+name = "onnxruntime"
+version = "1.23.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coloredlogs" },
+ { name = "flatbuffers" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "protobuf" },
+ { name = "sympy" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113 },
+ { url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857 },
+ { url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095 },
+ { url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080 },
+ { url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349 },
+ { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929 },
+ { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705 },
+ { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915 },
+ { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649 },
+ { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528 },
+ { url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337 },
+ { url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691 },
+ { url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898 },
+ { url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518 },
+ { url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276 },
+ { url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610 },
+ { url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184 },
+]
+
+[[package]]
+name = "opencv-python"
+version = "4.12.0.88"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/71/25c98e634b6bdeca4727c7f6d6927b056080668c5008ad3c8fc9e7f8f6ec/opencv-python-4.12.0.88.tar.gz", hash = "sha256:8b738389cede219405f6f3880b851efa3415ccd674752219377353f017d2994d", size = 95373294 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/85/68/3da40142e7c21e9b1d4e7ddd6c58738feb013203e6e4b803d62cdd9eb96b/opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:f9a1f08883257b95a5764bf517a32d75aec325319c8ed0f89739a57fae9e92a5", size = 37877727 },
+ { url = "https://files.pythonhosted.org/packages/33/7c/042abe49f58d6ee7e1028eefc3334d98ca69b030e3b567fe245a2b28ea6f/opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:812eb116ad2b4de43ee116fcd8991c3a687f099ada0b04e68f64899c09448e81", size = 57326471 },
+ { url = "https://files.pythonhosted.org/packages/62/3a/440bd64736cf8116f01f3b7f9f2e111afb2e02beb2ccc08a6458114a6b5d/opencv_python-4.12.0.88-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:51fd981c7df6af3e8f70b1556696b05224c4e6b6777bdd2a46b3d4fb09de1a92", size = 45887139 },
+ { url = "https://files.pythonhosted.org/packages/68/1f/795e7f4aa2eacc59afa4fb61a2e35e510d06414dd5a802b51a012d691b37/opencv_python-4.12.0.88-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:092c16da4c5a163a818f120c22c5e4a2f96e0db4f24e659c701f1fe629a690f9", size = 67041680 },
+ { url = "https://files.pythonhosted.org/packages/02/96/213fea371d3cb2f1d537612a105792aa0a6659fb2665b22cad709a75bd94/opencv_python-4.12.0.88-cp37-abi3-win32.whl", hash = "sha256:ff554d3f725b39878ac6a2e1fa232ec509c36130927afc18a1719ebf4fbf4357", size = 30284131 },
+ { url = "https://files.pythonhosted.org/packages/fa/80/eb88edc2e2b11cd2dd2e56f1c80b5784d11d6e6b7f04a1145df64df40065/opencv_python-4.12.0.88-cp37-abi3-win_amd64.whl", hash = "sha256:d98edb20aa932fd8ebd276a72627dad9dc097695b3d435a4257557bbb49a79d2", size = 39000307 },
+]
+
+[[package]]
+name = "opencv-python-headless"
+version = "4.12.0.88"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a4/63/6861102ec149c3cd298f4d1ea7ce9d6adbc7529221606ff1dab991a19adb/opencv-python-headless-4.12.0.88.tar.gz", hash = "sha256:cfdc017ddf2e59b6c2f53bc12d74b6b0be7ded4ec59083ea70763921af2b6c09", size = 95379675 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/7d/414e243c5c8216a5277afd104a319cc1291c5e23f5eeef512db5629ee7f4/opencv_python_headless-4.12.0.88-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1e58d664809b3350c1123484dd441e1667cd7bed3086db1b9ea1b6f6cb20b50e", size = 37877864 },
+ { url = "https://files.pythonhosted.org/packages/05/14/7e162714beed1cd5e7b5eb66fcbcba2f065c51b1d9da2463024c84d2f7c0/opencv_python_headless-4.12.0.88-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:365bb2e486b50feffc2d07a405b953a8f3e8eaa63865bc650034e5c71e7a5154", size = 57326608 },
+ { url = "https://files.pythonhosted.org/packages/69/4e/116720df7f1f7f3b59abc608ca30fbec9d2b3ae810afe4e4d26483d9dfa0/opencv_python_headless-4.12.0.88-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:aeb4b13ecb8b4a0beb2668ea07928160ea7c2cd2d9b5ef571bbee6bafe9cc8d0", size = 33145800 },
+ { url = "https://files.pythonhosted.org/packages/89/53/e19c21e0c4eb1275c3e2c97b081103b6dfb3938172264d283a519bf728b9/opencv_python_headless-4.12.0.88-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:236c8df54a90f4d02076e6f9c1cc763d794542e886c576a6fee46ec8ff75a7a9", size = 54023419 },
+ { url = "https://files.pythonhosted.org/packages/bf/9c/a76fd5414de6ec9f21f763a600058a0c3e290053cea87e0275692b1375c0/opencv_python_headless-4.12.0.88-cp37-abi3-win32.whl", hash = "sha256:fde2cf5c51e4def5f2132d78e0c08f9c14783cd67356922182c6845b9af87dbd", size = 30225230 },
+ { url = "https://files.pythonhosted.org/packages/f2/35/0858e9e71b36948eafbc5e835874b63e515179dc3b742cbe3d76bc683439/opencv_python_headless-4.12.0.88-cp37-abi3-win_amd64.whl", hash = "sha256:86b413bdd6c6bf497832e346cd5371995de148e579b9774f8eba686dee3f5528", size = 38923559 },
+]
+
+[[package]]
+name = "packaging"
+version = "25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
+]
+
+[[package]]
+name = "pandas"
+version = "2.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "python-dateutil" },
+ { name = "pytz" },
+ { name = "tzdata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790 },
+ { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831 },
+ { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267 },
+ { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281 },
+ { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453 },
+ { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361 },
+ { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702 },
+ { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846 },
+ { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618 },
+ { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212 },
+ { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693 },
+ { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002 },
+ { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971 },
+ { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722 },
+ { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671 },
+ { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807 },
+ { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872 },
+ { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371 },
+ { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333 },
+ { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120 },
+ { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991 },
+ { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227 },
+ { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056 },
+ { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189 },
+ { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912 },
+ { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160 },
+ { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233 },
+ { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635 },
+ { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079 },
+ { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049 },
+ { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638 },
+ { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834 },
+ { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925 },
+ { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071 },
+ { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504 },
+ { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702 },
+ { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535 },
+ { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582 },
+ { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963 },
+ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175 },
+]
+
+[[package]]
+name = "parsel"
+version = "1.10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cssselect" },
+ { name = "jmespath" },
+ { name = "lxml" },
+ { name = "packaging" },
+ { name = "w3lib" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f6/df/acd504c154c0b9028b0d8491a77fdd5f86e9c06ee04f986abf85e36d9a5f/parsel-1.10.0.tar.gz", hash = "sha256:14f17db9559f51b43357b9dfe43cec870a8efb5ea4857abb624ec6ff80d8a080", size = 51421 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/18/35d1d947553d24909dca37e2ff11720eecb601360d1bac8d7a9a1bc7eb08/parsel-1.10.0-py2.py3-none-any.whl", hash = "sha256:6a0c28bd81f9df34ba665884c88efa0b18b8d2c44c81f64e27f2f0cb37d46169", size = 17266 },
+]
+
+[[package]]
+name = "pillow"
+version = "12.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057 },
+ { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811 },
+ { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243 },
+ { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872 },
+ { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398 },
+ { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667 },
+ { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743 },
+ { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342 },
+ { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655 },
+ { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469 },
+ { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515 },
+ { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642 },
+ { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464 },
+ { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878 },
+ { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868 },
+ { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468 },
+ { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518 },
+ { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829 },
+ { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756 },
+ { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770 },
+ { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406 },
+ { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612 },
+ { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543 },
+ { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373 },
+ { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241 },
+ { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410 },
+ { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312 },
+ { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605 },
+ { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617 },
+ { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509 },
+ { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117 },
+ { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151 },
+ { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534 },
+ { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551 },
+ { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087 },
+ { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470 },
+ { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816 },
+ { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472 },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974 },
+ { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070 },
+ { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176 },
+ { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061 },
+ { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824 },
+ { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911 },
+ { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445 },
+ { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354 },
+ { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547 },
+ { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533 },
+ { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546 },
+ { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163 },
+ { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086 },
+ { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344 },
+ { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114 },
+ { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708 },
+ { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762 },
+ { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265 },
+ { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341 },
+ { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395 },
+ { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413 },
+ { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779 },
+ { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105 },
+ { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571 },
+ { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426 },
+ { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908 },
+ { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733 },
+ { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431 },
+ { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529 },
+ { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981 },
+ { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878 },
+ { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703 },
+ { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927 },
+ { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104 },
+ { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605 },
+ { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245 },
+ { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593 },
+ { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008 },
+ { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824 },
+ { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278 },
+ { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809 },
+]
+
+[[package]]
+name = "playwright"
+version = "1.57.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "greenlet" },
+ { name = "pyee" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ed/b6/e17543cea8290ae4dced10be21d5a43c360096aa2cce0aa7039e60c50df3/playwright-1.57.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9351c1ac3dfd9b3820fe7fc4340d96c0d3736bb68097b9b7a69bd45d25e9370c", size = 41985039 },
+ { url = "https://files.pythonhosted.org/packages/8b/04/ef95b67e1ff59c080b2effd1a9a96984d6953f667c91dfe9d77c838fc956/playwright-1.57.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4a9d65027bce48eeba842408bcc1421502dfd7e41e28d207e94260fa93ca67e", size = 40775575 },
+ { url = "https://files.pythonhosted.org/packages/60/bd/5563850322a663956c927eefcf1457d12917e8f118c214410e815f2147d1/playwright-1.57.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:99104771abc4eafee48f47dac2369e0015516dc1ce8c409807d2dd440828b9a4", size = 41985042 },
+ { url = "https://files.pythonhosted.org/packages/56/61/3a803cb5ae0321715bfd5247ea871d25b32c8f372aeb70550a90c5f586df/playwright-1.57.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:284ed5a706b7c389a06caa431b2f0ba9ac4130113c3a779767dda758c2497bb1", size = 45975252 },
+ { url = "https://files.pythonhosted.org/packages/83/d7/b72eb59dfbea0013a7f9731878df8c670f5f35318cedb010c8a30292c118/playwright-1.57.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a1bae6c0a07839cdeaddbc0756b3b2b85e476c07945f64ece08f1f956a86f1", size = 45706917 },
+ { url = "https://files.pythonhosted.org/packages/e4/09/3fc9ebd7c95ee54ba6a68d5c0bc23e449f7235f4603fc60534a364934c16/playwright-1.57.0-py3-none-win32.whl", hash = "sha256:1dd93b265688da46e91ecb0606d36f777f8eadcf7fbef12f6426b20bf0c9137c", size = 36553860 },
+ { url = "https://files.pythonhosted.org/packages/58/d4/dcdfd2a33096aeda6ca0d15584800443dd2be64becca8f315634044b135b/playwright-1.57.0-py3-none-win_amd64.whl", hash = "sha256:6caefb08ed2c6f29d33b8088d05d09376946e49a73be19271c8cd5384b82b14c", size = 36553864 },
+ { url = "https://files.pythonhosted.org/packages/6a/60/fe31d7e6b8907789dcb0584f88be741ba388413e4fbce35f1eba4e3073de/playwright-1.57.0-py3-none-win_arm64.whl", hash = "sha256:5f065f5a133dbc15e6e7c71e7bc04f258195755b1c32a432b792e28338c8335e", size = 32837940 },
+]
+
+[[package]]
+name = "prettytable"
+version = "3.17.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl", hash = "sha256:aad69b294ddbe3e1f95ef8886a060ed1666a0b83018bbf56295f6f226c43d287", size = 34433 },
+]
+
+[[package]]
+name = "protobuf"
+version = "6.33.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/b8/cda15d9d46d03d4aa3a67cb6bffe05173440ccf86a9541afaf7ac59a1b6b/protobuf-6.33.4.tar.gz", hash = "sha256:dc2e61bca3b10470c1912d166fe0af67bfc20eb55971dcef8dfa48ce14f0ed91", size = 444346 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/be/24ef9f3095bacdf95b458543334d0c4908ccdaee5130420bf064492c325f/protobuf-6.33.4-cp310-abi3-win32.whl", hash = "sha256:918966612c8232fc6c24c78e1cd89784307f5814ad7506c308ee3cf86662850d", size = 425612 },
+ { url = "https://files.pythonhosted.org/packages/31/ad/e5693e1974a28869e7cd244302911955c1cebc0161eb32dfa2b25b6e96f0/protobuf-6.33.4-cp310-abi3-win_amd64.whl", hash = "sha256:8f11ffae31ec67fc2554c2ef891dcb561dae9a2a3ed941f9e134c2db06657dbc", size = 436962 },
+ { url = "https://files.pythonhosted.org/packages/66/15/6ee23553b6bfd82670207ead921f4d8ef14c107e5e11443b04caeb5ab5ec/protobuf-6.33.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2fe67f6c014c84f655ee06f6f66213f9254b3a8b6bda6cda0ccd4232c73c06f0", size = 427612 },
+ { url = "https://files.pythonhosted.org/packages/2b/48/d301907ce6d0db75f959ca74f44b475a9caa8fcba102d098d3c3dd0f2d3f/protobuf-6.33.4-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:757c978f82e74d75cba88eddec479df9b99a42b31193313b75e492c06a51764e", size = 324484 },
+ { url = "https://files.pythonhosted.org/packages/92/1c/e53078d3f7fe710572ab2dcffd993e1e3b438ae71cfc031b71bae44fcb2d/protobuf-6.33.4-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c7c64f259c618f0bef7bee042075e390debbf9682334be2b67408ec7c1c09ee6", size = 339256 },
+ { url = "https://files.pythonhosted.org/packages/e8/8e/971c0edd084914f7ee7c23aa70ba89e8903918adca179319ee94403701d5/protobuf-6.33.4-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:3df850c2f8db9934de4cf8f9152f8dc2558f49f298f37f90c517e8e5c84c30e9", size = 323311 },
+ { url = "https://files.pythonhosted.org/packages/75/b1/1dc83c2c661b4c62d56cc081706ee33a4fc2835bd90f965baa2663ef7676/protobuf-6.33.4-py3-none-any.whl", hash = "sha256:1fe3730068fcf2e595816a6c34fe66eeedd37d51d0400b72fabc848811fdc1bc", size = 170532 },
+]
+
+[[package]]
+name = "pycparser"
+version = "2.23"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140 },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.12.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.41.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 },
+ { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 },
+ { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 },
+ { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890 },
+ { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740 },
+ { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021 },
+ { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378 },
+ { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761 },
+ { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303 },
+ { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355 },
+ { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875 },
+ { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549 },
+ { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305 },
+ { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902 },
+ { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 },
+ { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 },
+ { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 },
+ { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 },
+ { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 },
+ { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 },
+ { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 },
+ { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 },
+ { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 },
+ { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 },
+ { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 },
+ { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 },
+ { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 },
+ { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 },
+ { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 },
+ { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 },
+ { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 },
+ { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 },
+ { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 },
+ { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 },
+ { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 },
+ { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 },
+ { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 },
+ { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 },
+ { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 },
+ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 },
+ { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 },
+ { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 },
+ { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 },
+ { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 },
+ { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 },
+ { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 },
+ { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 },
+ { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 },
+ { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 },
+ { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 },
+ { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 },
+ { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 },
+ { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 },
+ { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 },
+ { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 },
+ { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 },
+ { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 },
+ { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 },
+ { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 },
+ { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 },
+ { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 },
+ { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 },
+ { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 },
+ { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 },
+ { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 },
+ { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 },
+ { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 },
+ { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 },
+ { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 },
+ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 },
+ { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441 },
+ { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291 },
+ { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632 },
+ { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905 },
+ { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495 },
+ { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 },
+ { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 },
+ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 },
+ { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 },
+ { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 },
+ { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 },
+ { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762 },
+ { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141 },
+ { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317 },
+ { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992 },
+ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302 },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880 },
+]
+
+[[package]]
+name = "pyecharts"
+version = "2.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "prettytable" },
+ { name = "simplejson" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/99/34/4b051acc2339b1767bcb95f2a22154f5f7fa8cfcb1def092cff47415f06a/pyecharts-2.0.9.tar.gz", hash = "sha256:7b4e8620809c22e32a19d613542f47ea6efa02f1189e00f91134cb5225e8f3ec", size = 166212 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/b4/c95a324b12885a110dcb340bad5043a397bc58ecbbf920416eafebc5cd42/pyecharts-2.0.9-py3-none-any.whl", hash = "sha256:c2adada56931c29669bcf3a972137231625136337f874dbd617ccd752f1477b8", size = 153862 },
+]
+
+[[package]]
+name = "pyee"
+version = "13.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730 },
+]
+
+[[package]]
+name = "pyparsing"
+version = "3.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/c1/1d9de9aeaa1b89b0186e5fe23294ff6517fce1bc69149185577cd31016b2/pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c", size = 1550512 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793 },
+]
+
+[[package]]
+name = "pyreadline3"
+version = "3.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178 },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 },
+]
+
+[[package]]
+name = "pytz"
+version = "2025.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 },
+]
+
+[[package]]
+name = "qrcode"
+version = "8.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986 },
+]
+
+[package.optional-dependencies]
+pil = [
+ { name = "pillow" },
+]
+
+[[package]]
+name = "simhash"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/84/2a/2bed94a174fb53931c67c3581869d9c485db7ea64e0c7e8a7d6018c6defe/simhash-2.1.2.tar.gz", hash = "sha256:533bc8cf41e4e6dd83f0b1847363516bf3323e0fa92e63d9e6df4e281e882e1b", size = 4660 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/86/0f4a39a50fcf510753b7abd98b728e66c14fefe89bad9ef5824745bf7b9f/simhash-2.1.2-py3-none-any.whl", hash = "sha256:968de16c82c227a631aa00e57ab58f9e4ee9e47e8408486199a9eb59c1d6979b", size = 4714 },
+]
+
+[[package]]
+name = "simplejson"
+version = "3.20.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/f4/a1ac5ed32f7ed9a088d62a59d410d4c204b3b3815722e2ccfb491fa8251b/simplejson-3.20.2.tar.gz", hash = "sha256:5fe7a6ce14d1c300d80d08695b7f7e633de6cd72c80644021874d985b3393649", size = 85784 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b9/3e/96898c6c66d9dca3f9bd14d7487bf783b4acc77471b42f979babbb68d4ca/simplejson-3.20.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:06190b33cd7849efc413a5738d3da00b90e4a5382fd3d584c841ac20fb828c6f", size = 92633 },
+ { url = "https://files.pythonhosted.org/packages/6b/a2/cd2e10b880368305d89dd540685b8bdcc136df2b3c76b5ddd72596254539/simplejson-3.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4ad4eac7d858947a30d2c404e61f16b84d16be79eb6fb316341885bdde864fa8", size = 75309 },
+ { url = "https://files.pythonhosted.org/packages/5d/02/290f7282eaa6ebe945d35c47e6534348af97472446951dce0d144e013f4c/simplejson-3.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b392e11c6165d4a0fde41754a0e13e1d88a5ad782b245a973dd4b2bdb4e5076a", size = 75308 },
+ { url = "https://files.pythonhosted.org/packages/43/91/43695f17b69e70c4b0b03247aa47fb3989d338a70c4b726bbdc2da184160/simplejson-3.20.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51eccc4e353eed3c50e0ea2326173acdc05e58f0c110405920b989d481287e51", size = 143733 },
+ { url = "https://files.pythonhosted.org/packages/9b/4b/fdcaf444ac1c3cbf1c52bf00320c499e1cf05d373a58a3731ae627ba5e2d/simplejson-3.20.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306e83d7c331ad833d2d43c76a67f476c4b80c4a13334f6e34bb110e6105b3bd", size = 153397 },
+ { url = "https://files.pythonhosted.org/packages/c4/83/21550f81a50cd03599f048a2d588ffb7f4c4d8064ae091511e8e5848eeaa/simplejson-3.20.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f820a6ac2ef0bc338ae4963f4f82ccebdb0824fe9caf6d660670c578abe01013", size = 141654 },
+ { url = "https://files.pythonhosted.org/packages/cf/54/d76c0e72ad02450a3e723b65b04f49001d0e73218ef6a220b158a64639cb/simplejson-3.20.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21e7a066528a5451433eb3418184f05682ea0493d14e9aae690499b7e1eb6b81", size = 144913 },
+ { url = "https://files.pythonhosted.org/packages/3f/49/976f59b42a6956d4aeb075ada16ad64448a985704bc69cd427a2245ce835/simplejson-3.20.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:438680ddde57ea87161a4824e8de04387b328ad51cfdf1eaf723623a3014b7aa", size = 144568 },
+ { url = "https://files.pythonhosted.org/packages/60/c7/30bae30424ace8cd791ca660fed454ed9479233810fe25c3f3eab3d9dc7b/simplejson-3.20.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cac78470ae68b8d8c41b6fca97f5bf8e024ca80d5878c7724e024540f5cdaadb", size = 146239 },
+ { url = "https://files.pythonhosted.org/packages/79/3e/7f3b7b97351c53746e7b996fcd106986cda1954ab556fd665314756618d2/simplejson-3.20.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7524e19c2da5ef281860a3d74668050c6986be15c9dd99966034ba47c68828c2", size = 154497 },
+ { url = "https://files.pythonhosted.org/packages/1d/48/7241daa91d0bf19126589f6a8dcbe8287f4ed3d734e76fd4a092708947be/simplejson-3.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e9b6d845a603b2eef3394eb5e21edb8626cd9ae9a8361d14e267eb969dbe413", size = 148069 },
+ { url = "https://files.pythonhosted.org/packages/e6/f4/ef18d2962fe53e7be5123d3784e623859eec7ed97060c9c8536c69d34836/simplejson-3.20.2-cp311-cp311-win32.whl", hash = "sha256:47d8927e5ac927fdd34c99cc617938abb3624b06ff86e8e219740a86507eb961", size = 74158 },
+ { url = "https://files.pythonhosted.org/packages/35/fd/3d1158ecdc573fdad81bf3cc78df04522bf3959758bba6597ba4c956c74d/simplejson-3.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:ba4edf3be8e97e4713d06c3d302cba1ff5c49d16e9d24c209884ac1b8455520c", size = 75911 },
+ { url = "https://files.pythonhosted.org/packages/9d/9e/1a91e7614db0416885eab4136d49b7303de20528860ffdd798ce04d054db/simplejson-3.20.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4376d5acae0d1e91e78baeba4ee3cf22fbf6509d81539d01b94e0951d28ec2b6", size = 93523 },
+ { url = "https://files.pythonhosted.org/packages/5e/2b/d2413f5218fc25608739e3d63fe321dfa85c5f097aa6648dbe72513a5f12/simplejson-3.20.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f8fe6de652fcddae6dec8f281cc1e77e4e8f3575249e1800090aab48f73b4259", size = 75844 },
+ { url = "https://files.pythonhosted.org/packages/ad/f1/efd09efcc1e26629e120fef59be059ce7841cc6e1f949a4db94f1ae8a918/simplejson-3.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25ca2663d99328d51e5a138f22018e54c9162438d831e26cfc3458688616eca8", size = 75655 },
+ { url = "https://files.pythonhosted.org/packages/97/ec/5c6db08e42f380f005d03944be1af1a6bd501cc641175429a1cbe7fb23b9/simplejson-3.20.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12a6b2816b6cab6c3fd273d43b1948bc9acf708272074c8858f579c394f4cbc9", size = 150335 },
+ { url = "https://files.pythonhosted.org/packages/81/f5/808a907485876a9242ec67054da7cbebefe0ee1522ef1c0be3bfc90f96f6/simplejson-3.20.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac20dc3fcdfc7b8415bfc3d7d51beccd8695c3f4acb7f74e3a3b538e76672868", size = 158519 },
+ { url = "https://files.pythonhosted.org/packages/66/af/b8a158246834645ea890c36136584b0cc1c0e4b83a73b11ebd9c2a12877c/simplejson-3.20.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db0804d04564e70862ef807f3e1ace2cc212ef0e22deb1b3d6f80c45e5882c6b", size = 148571 },
+ { url = "https://files.pythonhosted.org/packages/20/05/ed9b2571bbf38f1a2425391f18e3ac11cb1e91482c22d644a1640dea9da7/simplejson-3.20.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:979ce23ea663895ae39106946ef3d78527822d918a136dbc77b9e2b7f006237e", size = 152367 },
+ { url = "https://files.pythonhosted.org/packages/81/2c/bad68b05dd43e93f77994b920505634d31ed239418eb6a88997d06599983/simplejson-3.20.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a2ba921b047bb029805726800819675249ef25d2f65fd0edb90639c5b1c3033c", size = 150205 },
+ { url = "https://files.pythonhosted.org/packages/69/46/90c7fc878061adafcf298ce60cecdee17a027486e9dce507e87396d68255/simplejson-3.20.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:12d3d4dc33770069b780cc8f5abef909fe4a3f071f18f55f6d896a370fd0f970", size = 151823 },
+ { url = "https://files.pythonhosted.org/packages/ab/27/b85b03349f825ae0f5d4f780cdde0bbccd4f06c3d8433f6a3882df887481/simplejson-3.20.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aff032a59a201b3683a34be1169e71ddda683d9c3b43b261599c12055349251e", size = 158997 },
+ { url = "https://files.pythonhosted.org/packages/71/ad/d7f3c331fb930638420ac6d236db68e9f4c28dab9c03164c3cd0e7967e15/simplejson-3.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e590e133b06773f0dc9c3f82e567463df40598b660b5adf53eb1c488202544", size = 154367 },
+ { url = "https://files.pythonhosted.org/packages/f0/46/5c67324addd40fa2966f6e886cacbbe0407c03a500db94fb8bb40333fcdf/simplejson-3.20.2-cp312-cp312-win32.whl", hash = "sha256:8d7be7c99939cc58e7c5bcf6bb52a842a58e6c65e1e9cdd2a94b697b24cddb54", size = 74285 },
+ { url = "https://files.pythonhosted.org/packages/fa/c9/5cc2189f4acd3a6e30ffa9775bf09b354302dbebab713ca914d7134d0f29/simplejson-3.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:2c0b4a67e75b945489052af6590e7dca0ed473ead5d0f3aad61fa584afe814ab", size = 75969 },
+ { url = "https://files.pythonhosted.org/packages/5e/9e/f326d43f6bf47f4e7704a4426c36e044c6bedfd24e072fb8e27589a373a5/simplejson-3.20.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90d311ba8fcd733a3677e0be21804827226a57144130ba01c3c6a325e887dd86", size = 93530 },
+ { url = "https://files.pythonhosted.org/packages/35/28/5a4b8f3483fbfb68f3f460bc002cef3a5735ef30950e7c4adce9c8da15c7/simplejson-3.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feed6806f614bdf7f5cb6d0123cb0c1c5f40407ef103aa935cffaa694e2e0c74", size = 75846 },
+ { url = "https://files.pythonhosted.org/packages/7a/4d/30dfef83b9ac48afae1cf1ab19c2867e27b8d22b5d9f8ca7ce5a0a157d8c/simplejson-3.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b1d8d7c3e1a205c49e1aee6ba907dcb8ccea83651e6c3e2cb2062f1e52b0726", size = 75661 },
+ { url = "https://files.pythonhosted.org/packages/09/1d/171009bd35c7099d72ef6afd4bb13527bab469965c968a17d69a203d62a6/simplejson-3.20.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:552f55745044a24c3cb7ec67e54234be56d5d6d0e054f2e4cf4fb3e297429be5", size = 150579 },
+ { url = "https://files.pythonhosted.org/packages/61/ae/229bbcf90a702adc6bfa476e9f0a37e21d8c58e1059043038797cbe75b8c/simplejson-3.20.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2da97ac65165d66b0570c9e545786f0ac7b5de5854d3711a16cacbcaa8c472d", size = 158797 },
+ { url = "https://files.pythonhosted.org/packages/90/c5/fefc0ac6b86b9108e302e0af1cf57518f46da0baedd60a12170791d56959/simplejson-3.20.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f59a12966daa356bf68927fca5a67bebac0033cd18b96de9c2d426cd11756cd0", size = 148851 },
+ { url = "https://files.pythonhosted.org/packages/43/f1/b392952200f3393bb06fbc4dd975fc63a6843261705839355560b7264eb2/simplejson-3.20.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133ae2098a8e162c71da97cdab1f383afdd91373b7ff5fe65169b04167da976b", size = 152598 },
+ { url = "https://files.pythonhosted.org/packages/f4/b4/d6b7279e52a3e9c0fa8c032ce6164e593e8d9cf390698ee981ed0864291b/simplejson-3.20.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7977640af7b7d5e6a852d26622057d428706a550f7f5083e7c4dd010a84d941f", size = 150498 },
+ { url = "https://files.pythonhosted.org/packages/62/22/ec2490dd859224326d10c2fac1353e8ad5c84121be4837a6dd6638ba4345/simplejson-3.20.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b530ad6d55e71fa9e93e1109cf8182f427a6355848a4ffa09f69cc44e1512522", size = 152129 },
+ { url = "https://files.pythonhosted.org/packages/33/ce/b60214d013e93dd9e5a705dcb2b88b6c72bada442a97f79828332217f3eb/simplejson-3.20.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bd96a7d981bf64f0e42345584768da4435c05b24fd3c364663f5fbc8fabf82e3", size = 159359 },
+ { url = "https://files.pythonhosted.org/packages/99/21/603709455827cdf5b9d83abe726343f542491ca8dc6a2528eb08de0cf034/simplejson-3.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f28ee755fadb426ba2e464d6fcf25d3f152a05eb6b38e0b4f790352f5540c769", size = 154717 },
+ { url = "https://files.pythonhosted.org/packages/3c/f9/dc7f7a4bac16cf7eb55a4df03ad93190e11826d2a8950052949d3dfc11e2/simplejson-3.20.2-cp313-cp313-win32.whl", hash = "sha256:472785b52e48e3eed9b78b95e26a256f59bb1ee38339be3075dad799e2e1e661", size = 74289 },
+ { url = "https://files.pythonhosted.org/packages/87/10/d42ad61230436735c68af1120622b28a782877146a83d714da7b6a2a1c4e/simplejson-3.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:a1a85013eb33e4820286139540accbe2c98d2da894b2dcefd280209db508e608", size = 75972 },
+ { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309 },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
+]
+
+[[package]]
+name = "sympy"
+version = "1.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mpmath" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353 },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 },
+]
+
+[[package]]
+name = "tzdata"
+version = "2025.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521 },
+]
+
+[[package]]
+name = "w3lib"
+version = "2.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bf/7d/1172cfaa1e29beb9bf938e484c122b3bdc82e8e37b17a4f753ba6d6e009f/w3lib-2.3.1.tar.gz", hash = "sha256:5c8ac02a3027576174c2b61eb9a2170ba1b197cae767080771b6f1febda249a4", size = 49531 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/58/dd/56f0d8af71e475ed194d702f8b4cf9cea812c95e82ad823d239023c6558c/w3lib-2.3.1-py3-none-any.whl", hash = "sha256:9ccd2ae10c8c41c7279cd8ad4fe65f834be894fe7bfdd7304b991fd69325847b", size = 21751 },
+]
+
+[[package]]
+name = "wcwidth"
+version = "0.2.14"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286 },
+]
+
+[[package]]
+name = "win32-setctime"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 },
+]
+
+[[package]]
+name = "wordcloud"
+version = "1.9.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "matplotlib" },
+ { name = "numpy" },
+ { name = "pillow" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1f/a2/108cd319f6315931708a7c03d0824cd8684eb56e0af56e375e61785e4b3c/wordcloud-1.9.5.tar.gz", hash = "sha256:6ac7c1378f2886d7e849600a306febd41d0d46b15ce876d665a3e549f5403b0b", size = 27563652 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7d/a2/319d4fac92cc9a943d86fd1feb39077e6ca74dfeca8b0bc5a5be409d235f/wordcloud-1.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4156064e1d20a553125fef32f7bb5b2333a1966661f71688e714380a88ca4ea", size = 168771 },
+ { url = "https://files.pythonhosted.org/packages/b7/0c/d4a1510749489b1ac1390ec15f8f814923ae65014367533ebbc167222cc5/wordcloud-1.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6ab1913657a2189b84ae9ab7eacb50516e37586f420ccdc6abdfaa23512e4424", size = 168402 },
+ { url = "https://files.pythonhosted.org/packages/e9/03/1ff71d1ba850aa15f2c1959ead7142db781e4a767bb95045358fd0927290/wordcloud-1.9.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed6dbd945c3e4a18b39823a7ffea839499e684c85f10d25cd7693d1b9892c52a", size = 547684 },
+ { url = "https://files.pythonhosted.org/packages/bf/0c/606dde0beb4abd952a8d1631e4630fac70c51daedd94a204aa0086f3da6e/wordcloud-1.9.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55c0a6d38ba4fef29738e4725142b4c0c3769f301047758d480eb110e6796060", size = 551670 },
+ { url = "https://files.pythonhosted.org/packages/4f/74/2ebe53a215e7e88b89983f16b14fb85ab75ba94817fa11e466f51808ac12/wordcloud-1.9.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b31c2568af3201806193e7ca5c05e7d66b60ba2bcd854b08501b2496d77ca2ff", size = 544097 },
+ { url = "https://files.pythonhosted.org/packages/06/65/83525a140ed7b26b367e56010846a3e655f26053eb336112d8b6559e87a2/wordcloud-1.9.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1a630bfff5614b90e3cbb4b6d8a74bb296dc247126868fe40c1e5678c3f2ca58", size = 555467 },
+ { url = "https://files.pythonhosted.org/packages/41/6a/8d22301bc03756652cc7081f2f196614c1cd505e8cd22d771e6bc7530d6e/wordcloud-1.9.5-cp311-cp311-win32.whl", hash = "sha256:69ff2f262ca349ae59482b647aef63222d19aeba54f38a705ffeca558847826c", size = 295608 },
+ { url = "https://files.pythonhosted.org/packages/14/9e/6d5357fe58af3a1c6f7e58eaa88e77fb86556ff4027e8ea90032a6185ba0/wordcloud-1.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:ded40e3ebaaa96eaa7ae86df0bbe89da7ebf5301efcfe9e429145a2a04dc72fb", size = 306082 },
+ { url = "https://files.pythonhosted.org/packages/f0/af/8ca23d9a29c7e646e9e21ca0c3f798b08dd3e58ea61b32f4431a13d27d41/wordcloud-1.9.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ac56a1bdd961253f528d48044650ede52534a2fc47427c16729e8386a2beba29", size = 170100 },
+ { url = "https://files.pythonhosted.org/packages/3a/18/4239c7a209a55a1dbb58d2bfca215d9a53500eab5b4386d1b5c44d47a073/wordcloud-1.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:daf0a1a80fdd8bb60619fa3e6855ecf862efaa6149e1c86fcbefe5db02354cd4", size = 168920 },
+ { url = "https://files.pythonhosted.org/packages/bf/4f/dc24ca5c366a7f5ff2d7ea510cf50ed5b3773825ceba56af1cf9b803437e/wordcloud-1.9.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d36983701f194828fa99c1162cd16610f43959a5fe09f9f8b7b6619ab7390051", size = 548944 },
+ { url = "https://files.pythonhosted.org/packages/90/99/a4bc45e087f7f3f11893b0a4feea5d9d72ecd75d9c615341e04de069023b/wordcloud-1.9.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b86143325c12479f02c7f994cf19afcc5bc194a2d74456adf03d71c91215bb99", size = 555208 },
+ { url = "https://files.pythonhosted.org/packages/10/c0/021e86f11fe660adb88d58e7b3f66658ae9a93b02f1d75c5fb036d4a7359/wordcloud-1.9.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:599ff3e40121ad6b2714daf3fb19c094d3248de69429792a674321079dc93bfe", size = 539344 },
+ { url = "https://files.pythonhosted.org/packages/7d/ef/a08ea52eb7649d9296abbc6319634b1ece7bc14b1080ac92a460d725a410/wordcloud-1.9.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f32fbe231518534b20703dadc7e397cf6edee82c0ff572ce9193447e4418cf94", size = 554955 },
+ { url = "https://files.pythonhosted.org/packages/a0/42/116c6f3365ad2f0d882ad68c33122e3a82c7503a6eadfff1286a59121efa/wordcloud-1.9.5-cp312-cp312-win32.whl", hash = "sha256:5a8954b28d5c9d515944343adecd9dfde8dbe723815768fdc5ae5eb541426f1e", size = 296178 },
+ { url = "https://files.pythonhosted.org/packages/ba/66/04e0f33135d7b8d76bd1721c1c7a42a0cbe748ff48588ab6ea01316e1ab0/wordcloud-1.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:790cf92513a1f5e4d65c801d9fe35c607a4219079075f342bb2fe32d427d64ce", size = 307255 },
+ { url = "https://files.pythonhosted.org/packages/04/14/261b76055dda37c4adda27d81b4c4917c0c8c0beeb82bc17cc929112fd19/wordcloud-1.9.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a8468c5943ca2c95a5785314dd8b9c2aff622a16459ec16f44fbabfdb47ed68d", size = 169342 },
+ { url = "https://files.pythonhosted.org/packages/50/14/3d60c08364ae1a8c54ab7b1f326f69c681e7c59dff32081bf75adb2b2b26/wordcloud-1.9.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc68fc27f2fc613a903271c03ff9c47801bce9e4663cf940831d434cb8e7aae8", size = 168295 },
+ { url = "https://files.pythonhosted.org/packages/08/55/275ded21f0b815c93d12d66b0425b1ee549b19bb5c5e8d60924e43f05b7e/wordcloud-1.9.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:000c8ee667a572334763de28d49838456a12642f272b97c5883550ba143f2d93", size = 543738 },
+ { url = "https://files.pythonhosted.org/packages/e8/56/72d77bc4416a6aa97ffbba633ac6d8f75156cd593e5f559cad8d84553be4/wordcloud-1.9.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5ae9697c4a6674fd19b16178f1c40b8f3445eb6324fa06cbe0ff053c03c3d61", size = 551843 },
+ { url = "https://files.pythonhosted.org/packages/cb/6f/937f53365cc67f98325057490d63a27749c8526fd8bea8e4a1fbe74045e3/wordcloud-1.9.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a16c7bc45b1625374620d0a859c0c6dea0b86a7161b054a9949deaabac5da3dc", size = 536626 },
+ { url = "https://files.pythonhosted.org/packages/63/cc/d566ab24da637787a381a1bc9999a166932b53d2b73b12b989c8fb1fa595/wordcloud-1.9.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecd0400835ee92d9d335aac3d73b3a50cb44f3ba408f03132d5d0bd915b8feef", size = 552904 },
+ { url = "https://files.pythonhosted.org/packages/fb/96/47bfce33702be594bad4c1f652e7e16c1d8efd5b8163f9389f38dfa4b3fa/wordcloud-1.9.5-cp313-cp313-win32.whl", hash = "sha256:dc2027675d9c8a72e6565844e442736ff55ce670421ddb02c77d5d9178a9a798", size = 296069 },
+ { url = "https://files.pythonhosted.org/packages/c1/b9/440cf09c98680f15ccd83aa31a71d8789ad70ee65a731ead23a6ba8b169c/wordcloud-1.9.5-cp313-cp313-win_amd64.whl", hash = "sha256:b02205ff66f81bc6be1c418c98da08c353847196f2ccf945bd3ea5a52f22aec7", size = 307047 },
+ { url = "https://files.pythonhosted.org/packages/29/44/59372ad37df4c93a662f405689ad35039a40c6a0e0dc72e420a6b9bf8aab/wordcloud-1.9.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8cc6baa2f2a64cf6c65eb0034997b54e2a7538e14833582ad91f3686826ebb14", size = 169693 },
+ { url = "https://files.pythonhosted.org/packages/72/b3/60481e917ce07f3139961eb1dc32f45746dda132fc919d27f2aefa6ca2a2/wordcloud-1.9.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3e744e70d99b2083f7a544c77fea59e0df92053c3d310669c43235bbdaa9a6b9", size = 168940 },
+ { url = "https://files.pythonhosted.org/packages/6a/c5/dd5e409a9ecb2e1fa026b061703b16282257b350dd5a15c5b8de682ecc04/wordcloud-1.9.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:508a569826751f52db1225c91e01f3ea4c725421431338a6cb17768aa29b3d15", size = 543226 },
+ { url = "https://files.pythonhosted.org/packages/01/ab/9c24089ea2883403a3a1586745f827cb6866bd43b41276a0d00d12a4b978/wordcloud-1.9.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e09563dd90aeb42d2ca034b39ba691298f783be373dc7d7c2d7430e729c1f41", size = 547357 },
+ { url = "https://files.pythonhosted.org/packages/03/c2/7d24d1fdddf329da69a2c72a6204e40250e42478037c593545d70691539e/wordcloud-1.9.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:97d0ac557dc80710e56609760f733ae4f382fc93e4c807c4abcfacbfb1a7a4c3", size = 535710 },
+ { url = "https://files.pythonhosted.org/packages/ba/b9/c474a1d651fe2d941a6f92ad65e8debaebc8da7df9773182002ccef9787a/wordcloud-1.9.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7addfa9623317ff7b59de1afe308bfb42db575ef804b271b11777797c0699e02", size = 549386 },
+ { url = "https://files.pythonhosted.org/packages/12/9c/026fee4942a6e21074d19929b4833b6d4c1019e7e225d73370e85cba3e91/wordcloud-1.9.5-cp314-cp314-win32.whl", hash = "sha256:0e8c8aab33d9b495656b1315715a31c222f1ec8c9ca40d719372e200fee0204c", size = 297175 },
+ { url = "https://files.pythonhosted.org/packages/6b/4e/938315f85438df0e225cb613d783301585bf1adf8d5fe869dca18b029e71/wordcloud-1.9.5-cp314-cp314-win_amd64.whl", hash = "sha256:cda8de69df5fac5a90aea3646993b03b4a920d8aa6454b6f6e58c341397b9ca6", size = 308691 },
+ { url = "https://files.pythonhosted.org/packages/55/a2/d04ca5669acddefe29faeb3d7103b6f735b23ebaad82cf73a067561c906b/wordcloud-1.9.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6cee7e8668b1905844a4901597f2dc12ddb97f58586dc520eaf1016a6949cd6e", size = 174155 },
+ { url = "https://files.pythonhosted.org/packages/68/05/f77b6ceb7eead741a3b2abbdbad5cb404f1d1297a9708766f452a115341b/wordcloud-1.9.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4c3d3c2477613a61ea671ace84b0acde3b00d0e9afd49636d4bf3e504a3a8a05", size = 174214 },
+ { url = "https://files.pythonhosted.org/packages/a6/2c/265936f5efc0edcd9204107f3f5ecf224514370fe4886fe1b7ae35018b63/wordcloud-1.9.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f81f7978cf247a981764b15e90c46adc90a1af8c8983e376dbddd6a94137862", size = 559691 },
+ { url = "https://files.pythonhosted.org/packages/31/74/ee7ea5117554e36fcb2ed878d4a271ef8c0af0c3cd4727694d67814c131a/wordcloud-1.9.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c71a569259983733a496df6918c72fbecb480929f8b6514fbe754030b41ab7a", size = 552045 },
+ { url = "https://files.pythonhosted.org/packages/55/71/674f39d3a766b1d89c56a9671746653e169fd84251617a848258167a4936/wordcloud-1.9.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c3c1c9b865b8ddf22a9eb529d83395f73151117d06e3e9aeaa1ef0f6db1979af", size = 542971 },
+ { url = "https://files.pythonhosted.org/packages/27/8c/613b2f63ed3231ac536e1efb45c0ab73037e53b5f00449ece9664df6b31f/wordcloud-1.9.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c9bd4cbd662774e4d83ffb70cfb4a5de4107ae468247e8b68c4cc87a81dd4efc", size = 546190 },
+ { url = "https://files.pythonhosted.org/packages/92/ca/0041d90657e2422c76d75a1f32cced0152251bf9d2e9005975cc69b3c953/wordcloud-1.9.5-cp314-cp314t-win32.whl", hash = "sha256:de4749944686c5cfc10143f718d24c965bfbff48d920273cd5b15e889b89a3ae", size = 306425 },
+ { url = "https://files.pythonhosted.org/packages/ea/47/5f27d088000e301d174d33a6dd852f7ea6bae6e914e9971d24a9460fa35e/wordcloud-1.9.5-cp314-cp314t-win_amd64.whl", hash = "sha256:e19c3883165967ad4e0cb7baa9208fdca758cfd0f75d68743a9390269180d47a", size = 320479 },
+]
diff --git "a/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260.md" "b/\347\210\254\350\231\253\345\205\245\351\227\250/10_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2303_\346\225\260\346\215\256\345\255\230\345\202\250\345\256\236\347\216\260.md"
deleted file mode 100644
index e69de29..0000000
diff --git "a/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260.md" "b/\347\210\254\350\231\253\345\205\245\351\227\250/11_\347\210\254\350\231\253\345\205\245\351\227\250\345\256\236\346\210\2304_\351\253\230\346\225\210\347\216\207\347\232\204\347\210\254\350\231\253\345\256\236\347\216\260.md"
deleted file mode 100644
index e69de29..0000000
diff --git "a/\347\210\254\350\231\253\350\277\233\351\230\266/readme.md" "b/\347\210\254\350\231\253\350\277\233\351\230\266/readme.md"
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ "b/\347\210\254\350\231\253\350\277\233\351\230\266/readme.md"
@@ -0,0 +1 @@
+
diff --git "a/\351\253\230\347\272\247\347\210\254\350\231\253/README.md" "b/\351\253\230\347\272\247\347\210\254\350\231\253/README.md"
deleted file mode 100644
index e69de29..0000000