From 0d5bbb186473cf510ce36f6ef315f2f8c071edec Mon Sep 17 00:00:00 2001 From: sjk <2513533895@qq.com> Date: Fri, 19 Dec 2025 22:48:58 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20=E7=99=BE=E5=AE=B6=E5=8F=B7?= =?UTF-8?q?=E6=96=87=E7=AB=A0=E9=87=87=E9=9B=86=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 110 ++ DATABASE_MIGRATION.md | 177 +++ QUEUE_USAGE.md | 211 +++ README.md | 247 ++++ TASKWORKER_TROUBLESHOOTING.md | 179 +++ app.py | 1123 +++++++++++++++ baidu_api.py | 633 +++++++++ check_taskworker.py | 222 +++ database.py | 413 ++++++ demo_python.py | 43 + fix_taskworker.sh | 43 + gunicorn_config.py | 168 +++ install_service.sh | 51 + migrate_database.py | 85 ++ migrate_database_v2.py | 59 + remove_selenium.py | 35 + requirements.txt | 12 + restart.sh | 12 + scrapy_proxy.py | 32 + start.sh | 356 +++++ static/css/bootstrap-icons.css | 2078 ++++++++++++++++++++++++++++ static/css/bootstrap-icons.min.css | 5 + static/css/icons-local.css | 81 ++ static/css/style.css | 1050 ++++++++++++++ static/js/jquery.min.js | 2 + static/js/main.js | 381 +++++ stop.sh | 83 ++ task_queue.py | 357 +++++ task_worker.py | 487 +++++++ taskworker_monitor.py | 218 +++ templates/index.html | 292 ++++ templates/login.html | 382 +++++ templates/queue.html | 1508 ++++++++++++++++++++ test2.py | 526 +++++++ test_database.py | 90 ++ test_html.py | 23 + test_selenium.py | 0 37 files changed, 11774 insertions(+) create mode 100644 .gitignore create mode 100644 DATABASE_MIGRATION.md create mode 100644 QUEUE_USAGE.md create mode 100644 README.md create mode 100644 TASKWORKER_TROUBLESHOOTING.md create mode 100644 app.py create mode 100644 baidu_api.py create mode 100644 check_taskworker.py create mode 100644 database.py create mode 100644 demo_python.py create mode 100644 fix_taskworker.sh create mode 100644 gunicorn_config.py create mode 100644 install_service.sh create mode 100644 migrate_database.py create mode 100644 migrate_database_v2.py create mode 100644 remove_selenium.py create mode 100644 requirements.txt create mode 100644 restart.sh create mode 100644 scrapy_proxy.py create mode 100644 start.sh create mode 100644 static/css/bootstrap-icons.css create mode 100644 static/css/bootstrap-icons.min.css create mode 100644 static/css/icons-local.css create mode 100644 static/css/style.css create mode 100644 static/js/jquery.min.js create mode 100644 static/js/main.js create mode 100644 stop.sh create mode 100644 task_queue.py create mode 100644 task_worker.py create mode 100644 taskworker_monitor.py create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/queue.html create mode 100644 test2.py create mode 100644 test_database.py create mode 100644 test_html.py create mode 100644 test_selenium.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8de12cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,110 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +venv/ +ENV/ +env/ +.venv + +# PyInstaller +*.manifest +*.spec + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.pytest_cache/ + +# Flask +instance/ +.webassets-cache + +# Scrapy +.scrapy + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Database +*.db +*.sqlite +*.sqlite3 + +# Data files (如果不需要提交数据) +data/*.json +data/results/*.xlsx +baidu_data.json +baijiahao_data.json +*.backup +app.py.backup + +# Excel exports +exports/*.xlsx + +# Zip archives +*.zip + +# Log files +*.log +logs/ + + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Temporary files +tmp/ +temp/ +*.tmp +*.bak +*.swp + +# Service files (系统特定) +*.service + +# Process IDs +*.pid diff --git a/DATABASE_MIGRATION.md b/DATABASE_MIGRATION.md new file mode 100644 index 0000000..82b0c96 --- /dev/null +++ b/DATABASE_MIGRATION.md @@ -0,0 +1,177 @@ +# SQLite 数据库迁移说明 + +## 概述 + +系统已从 JSON 文件存储迁移到 SQLite 数据库,提供更好的性能、并发支持和数据完整性。 + +## 主要变更 + +### 1. 新增文件 +- `database.py` - SQLite 数据库管理模块 +- `test_database.py` - 数据库功能测试脚本 +- `data/baijiahao.db` - SQLite 数据库文件(自动创建) + +### 2. 修改文件 +- `task_queue.py` - 使用 SQLite 替代 JSON 文件存储 + +### 3. 数据迁移 +- 旧数据自动从 `data/task_queue.json` 迁移到数据库 +- 迁移成功后会创建备份文件 `data/task_queue.json.backup` +- 原 JSON 文件保留,可安全删除 + +## 数据库结构 + +### tasks 表 +```sql +CREATE TABLE tasks ( + task_id TEXT PRIMARY KEY, -- 任务ID + url TEXT NOT NULL, -- 百家号URL + months REAL NOT NULL, -- 获取月数 + use_proxy INTEGER NOT NULL, -- 是否使用代理 (0/1) + proxy_api_url TEXT, -- 代理API地址 + username TEXT, -- 用户名 + status TEXT NOT NULL, -- 任务状态 + created_at TEXT NOT NULL, -- 创建时间 + started_at TEXT, -- 开始时间 + completed_at TEXT, -- 完成时间 + progress INTEGER DEFAULT 0, -- 进度 (0-100) + current_step TEXT, -- 当前步骤 + total_articles INTEGER DEFAULT 0, -- 总文章数 + processed_articles INTEGER DEFAULT 0, -- 已处理文章数 + error TEXT, -- 错误信息 + result_file TEXT -- 结果文件路径 +); +``` + +### 索引 +- `idx_tasks_status` - 状态索引(加速状态查询) +- `idx_tasks_username` - 用户名索引(加速用户过滤) +- `idx_tasks_created_at` - 创建时间索引(加速时间排序) + +## 优势 + +### 1. 性能提升 +- 索引支持,查询速度更快 +- 优化的 SQL 查询,减少内存占用 +- 不再需要每次操作都读写整个文件 + +### 2. 并发安全 +- 线程安全的连接管理 +- 数据库级别的事务支持 +- 避免文件锁冲突 + +### 3. 数据完整性 +- 主键约束防止重复 +- 事务支持确保数据一致性 +- 异常回滚机制 + +### 4. 可扩展性 +- 易于添加新字段和索引 +- 支持复杂查询和统计 +- 便于后续功能扩展 + +## 使用说明 + +### 测试数据库功能 +```bash +python test_database.py +``` + +### 手动迁移数据 +```python +from database import migrate_from_json + +# 从 JSON 迁移到数据库 +count = migrate_from_json("data/task_queue.json") +print(f"迁移了 {count} 个任务") +``` + +### 直接使用数据库 +```python +from database import get_database + +db = get_database() +with db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM tasks") + tasks = cursor.fetchall() +``` + +## 向后兼容 + +- 原有 API 接口不变 +- 无需修改调用代码 +- 自动迁移旧数据 +- 保留原 JSON 文件 + +## 注意事项 + +1. **首次启动**:系统会自动创建数据库和迁移数据 +2. **备份**:迁移成功后建议备份 `data/baijiahao.db` +3. **清理**:确认迁移成功后可删除 `data/task_queue.json` +4. **性能**:大量任务时数据库性能优势明显 + +## 故障排除 + +### 数据库锁定 +如果遇到 "database is locked" 错误: +- 检查是否有多个进程同时访问 +- 重启应用程序 +- 增加超时时间(已设置为30秒) + +### 迁移失败 +如果迁移失败: +- 检查 `data/task_queue.json` 格式 +- 查看日志错误信息 +- 手动运行 `test_database.py` 测试 + +### 数据丢失 +- 检查 `data/task_queue.json.backup` 备份文件 +- 从备份恢复后重新迁移 +- 使用 SQLite 工具直接查看数据库 + +## 性能优化 + +### 已实施的优化 +- 添加索引加速查询 +- 使用事务批量操作 +- 连接池复用连接 +- Row factory 减少转换 + +### 未来优化方向 +- 定期清理旧任务 +- 数据归档机制 +- 分表策略 +- 查询缓存 + +## 相关命令 + +### 查看数据库 +```bash +# 使用 SQLite 命令行工具 +sqlite3 data/baijiahao.db + +# 查看表结构 +.schema tasks + +# 查看所有任务 +SELECT * FROM tasks; + +# 统计任务数量 +SELECT status, COUNT(*) FROM tasks GROUP BY status; +``` + +### 备份数据库 +```bash +# 简单备份 +cp data/baijiahao.db data/baijiahao.db.backup + +# 使用 SQLite 工具备份 +sqlite3 data/baijiahao.db ".backup data/baijiahao.db.backup" +``` + +### 恢复数据库 +```bash +# 从备份恢复 +cp data/baijiahao.db.backup data/baijiahao.db +``` diff --git a/QUEUE_USAGE.md b/QUEUE_USAGE.md new file mode 100644 index 0000000..3ad2af5 --- /dev/null +++ b/QUEUE_USAGE.md @@ -0,0 +1,211 @@ +# 任务队列功能使用说明 + +## 📋 功能概述 + +新增了任务队列系统,支持**离线处理**、**进度跟踪**和**结果汇总导出**。 + +## 🚀 启动服务 + +```bash +# 开发环境 +python app.py + +# 生产环境(推荐) +bash start.sh +``` + +启动时会自动: +- ✅ 创建必要的目录(data/, data/results/) +- ✅ 启动后台任务处理器 +- ✅ 自动处理队列中的任务 + +## 💡 使用方式 + +### 方式1:即时导出(原有功能) +1. 填写百家号URL和参数 +2. 点击**"即时导出"**按钮 +3. 同步等待处理完成 +4. 立即下载结果 + +**适用场景:** 需要立即获取结果 + +--- + +### 方式2:队列处理(新功能)⭐ +1. 填写百家号URL和参数 +2. 点击**"添加到队列"**按钮 +3. 任务加入队列,后台慢慢处理 +4. 可以继续添加其他任务 +5. 在"任务队列"页面查看进度 +6. 完成后下载结果 + +**适用场景:** +- 批量处理多个账号 +- 不需要立即获取结果 +- 避免长时间等待阻塞 + +## 📊 任务队列管理页面 + +点击顶部的**"任务队列"**按钮进入管理页面,可以: + +### 1. 查看统计信息 +- 总任务数 +- 等待中任务数 +- 处理中任务数 +- 已完成任务数 +- 失败任务数 + +### 2. 筛选任务 +- 全部任务 +- 等待中 +- 处理中 +- 已完成 +- 失败 + +### 3. 查看任务详情 +每个任务显示: +- 百家号URL +- 任务状态 +- 处理进度(0-100%) +- 当前步骤说明 +- 时间范围 +- 创建时间 +- 文章总数 +- 是否使用代理 + +### 4. 下载结果 +- 已完成的任务显示"下载结果"按钮 +- 点击即可下载Excel文件 + +### 5. 自动刷新 +- 页面每5秒自动刷新一次 +- 实时查看最新进度 + +## 🗂️ 数据存储 + +### 任务队列文件 +``` +data/task_queue.json +``` +存储所有任务的状态、进度、配置等信息 + +### 导出结果文件 +``` +data/results/百家号文章_{app_id}_{timestamp}.xlsx +``` +每个任务的Excel结果文件 + +## 📝 任务处理流程 + +``` +用户添加任务 + ↓ +加入队列(pending) + ↓ +后台处理器检测到任务 + ↓ +标记为处理中(processing) + ↓ +步骤1: 解析URL获取UK (10%) + ↓ +步骤2: 初始化爬虫 (20%) + ↓ +步骤3: 获取文章列表 (30%) + ↓ +步骤4: 处理文章数据 (50%-90%) + ↓ +步骤5: 生成Excel文件 (90%) + ↓ +标记为已完成(completed) + ↓ +用户下载结果 +``` + +## 🔄 任务状态说明 + +| 状态 | 说明 | 颜色标识 | +|------|------|---------| +| pending | 等待处理 | 黄色 | +| processing | 正在处理 | 蓝色 | +| completed | 处理完成 | 绿色 | +| failed | 处理失败 | 红色 | + +## ⚙️ 技术特性 + +### 1. 离线处理 +- ✅ 添加任务后无需等待 +- ✅ 后台自动处理 +- ✅ 支持批量添加 + +### 2. 进度跟踪 +- ✅ 实时显示进度百分比 +- ✅ 显示当前处理步骤 +- ✅ 显示已处理文章数 + +### 3. 错误处理 +- ✅ 失败任务显示错误信息 +- ✅ 代理失败自动切换IP +- ✅ 反爬检测自动重试 + +### 4. 数据持久化 +- ✅ 任务状态保存到本地JSON +- ✅ 服务重启后继续处理 +- ✅ 结果文件永久保存 + +### 5. 用户隔离 +- ✅ 每个用户只能看到自己的任务 +- ✅ 统计信息按用户过滤 +- ✅ 下载权限校验 + +## 🎯 最佳实践 + +1. **大批量采集** + - 使用"添加到队列" + - 一次性添加多个账号 + - 让系统慢慢处理 + +2. **紧急需求** + - 使用"即时导出" + - 实时获取结果 + +3. **代理配置** + - 默认启用代理IP池 + - 系统自动处理反爬 + - 检测到反爬立即切换IP + +4. **定期清理** + - 系统会保留7天内的已完成任务 + - 可手动删除旧任务(功能可扩展) + +## 🐛 常见问题 + +**Q: 任务一直处于"等待中"状态?** +A: 检查后台处理器是否启动,查看控制台日志 + +**Q: 任务失败了怎么办?** +A: 查看失败原因,修改参数后重新添加任务 + +**Q: 可以同时处理多少个任务?** +A: 目前每次处理1个任务,按队列顺序依次处理 + +**Q: 结果文件在哪里?** +A: `data/results/` 目录下,文件名包含app_id和时间戳 + +## 🔧 开发说明 + +### 核心文件 +- `task_queue.py` - 任务队列管理 +- `task_worker.py` - 后台处理器 +- `templates/queue.html` - 队列管理页面 +- `data/task_queue.json` - 任务数据存储 + +### API接口 +- `POST /api/queue/add` - 添加任务 +- `GET /api/queue/tasks` - 获取任务列表 +- `GET /api/queue/task/` - 获取任务详情 +- `GET /api/queue/stats` - 获取统计信息 +- `GET /api/queue/download/` - 下载结果 + +--- + +**享受高效的批量处理!** 🎉 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4a5a8f --- /dev/null +++ b/README.md @@ -0,0 +1,247 @@ +# 百家号文章导出工具 + +一个用于导出百家号作者指定时间内发文信息的Web工具。 + +## 快速启动 + +### 方式1:使用 Gunicorn 启动(推荐生产环境) + +```bash +# 赋予执行权限(首次运行) +chmod +x start.sh stop.sh + +# 安装 gunicorn(如果未安装) +pip install gunicorn + +# 使用 Gunicorn 启动(默认) +./start.sh +# 或明确指定 +./start.sh gunicorn + +# 停止服务 +./stop.sh +``` + +### 方式2:使用 nohup 启动(开发测试) + +```bash +# 使用 nohup 模式启动 +./start.sh nohup + +# 停止服务 +./stop.sh +``` + +### 方式3:手动启动 + +```bash +# 1. 创建虚拟环境(首次运行) +python3 -m venv .venv + +# 2. 激活虚拟环境 +source .venv/bin/activate + +# 3. 安装依赖(首次运行) +pip install -r requirements.txt + +# 4. 启动服务 +python app.py +``` + +服务将在 `http://127.0.0.1:8030` 启动 + +## 功能特点 + +- 📝 导出百家号作者指定时间内的文章信息 +- 📋 任务队列功能,支持离线处理 +- 🔄 动态并发处理,智能调整线程数 +- 📊 生成Excel格式文件 +- 🎯 包含文章标题、链接和发布时间 +- 🎨 简洁美观的Web界面(钉钉科技蓝风格) +- 🔐 用户登录权限系统 + +## 技术栈 + +- **后端**: Python + Flask +- **前端**: HTML + CSS + jQuery +- **数据处理**: Pandas + BeautifulSoup4 +- **Excel导出**: OpenPyXL + +## 安装步骤 + +### 1. 克隆项目 + +```bash +git clone +cd ai_baijiahao +``` + +### 2. 创建虚拟环境 + +```bash +python3 -m venv .venv +source .venv/bin/activate # Linux/Mac +# 或 +.venv\Scripts\activate # Windows +``` + +### 3. 安装依赖 + +```bash +pip install -r requirements.txt +``` + +### 4. 启动服务 + +```bash +# 使用启动脚本(Linux/Mac) +chmod +x start.sh +./start.sh + +# 或手动启动 +python app.py +``` + +服务将在 `http://127.0.0.1:8030` 启动 + +## 使用说明 + +### 登录系统 + +1. 首次访问需要注册账号 +2. 输入用户名和密码登录 + +### 即时导出 + +1. 在浏览器中打开百家号作者主页,复制完整的URL地址 + - 例如: `https://baijiahao.baidu.com/u?app_id=1700253559210167` + +2. 在工具页面输入URL地址,选择时间范围 + +3. 点击"开始导出"按钮,等待数据获取完成 + +4. 导出成功后,点击"下载Excel文件"保存文件 + +### 队列导出 + +1. 点击"任务队列"菜单 + +2. 添加多个导出任务到队列 + +3. 系统会自动并发处理(动态调整1-3个线程) + +4. 任务完成后,点击"查看"按钮下载Excel文件 + +## 生产环境部署 + +### 方案1:systemd 服务(推荐) + +**优点**: +- ✅ 自动重启(进程崩溃时) +- ✅ 开机自启 +- ✅ 资源限制 +- ✅ 日志管理 +- ✅ 服务监控 + +**安装步骤**: + +```bash +# 1. 安装服务 +sudo chmod +x install_service.sh +sudo ./install_service.sh + +# 2. 启动服务 +sudo systemctl start baijiahao + +# 3. 查看状态 +sudo systemctl status baijiahao + +# 4. 查看日志 +sudo journalctl -u baijiahao -f +``` + +**常用命令**: + +```bash +# 启动/停止/重启 +sudo systemctl start baijiahao +sudo systemctl stop baijiahao +sudo systemctl restart baijiahao + +# 查看状态和日志 +sudo systemctl status baijiahao +sudo journalctl -u baijiahao -f + +# 禁用/启用开机自启 +sudo systemctl disable baijiahao +sudo systemctl enable baijiahao +``` + +### 方案2:nohup(简单场景) + +**优点**:简单快速 +**缺点**:无自动重启、无开机自启、管理困难 + +```bash +# 使用项目提供的启动脚本 +./start.sh + +# 或手动使用 nohup +nohup python app.py > logs/app.log 2>&1 & +``` + +### 方案3:Supervisor(备选) + +安装 Supervisor: + +```bash +sudo apt-get install supervisor +``` + +创建配置文件 `/etc/supervisor/conf.d/baijiahao.conf`: + +```ini +[program:baijiahao] +command=/var/www/ai_baijiahao/.venv/bin/python app.py +directory=/var/www/ai_baijiahao +user=www-data +autostart=true +autorestart=true +stdout_logfile=/var/www/ai_baijiahao/logs/app.log +stderr_logfile=/var/www/ai_baijiahao/logs/error.log +``` + +启动服务: + +```bash +sudo supervisorctl reread +sudo supervisorctl update +sudo supervisorctl start baijiahao +``` + +## 项目结构 + +``` +ai_baijiahao/ +├── app.py # Flask后端服务 +├── requirements.txt # Python依赖 +├── templates/ # HTML模板 +│ └── index.html +├── static/ # 静态资源 +│ ├── css/ +│ │ └── style.css +│ └── js/ +│ └── main.js +└── exports/ # Excel导出目录(自动创建) +``` + +## 注意事项 + +- 请确保输入的是有效的百家号作者主页地址 +- 导出过程可能需要一些时间,请耐心等待 +- 如果文章数量较多,导出时间会相应延长 +- 本工具仅供学习交流使用 + +## 许可证 + +仅供学习交流使用 diff --git a/TASKWORKER_TROUBLESHOOTING.md b/TASKWORKER_TROUBLESHOOTING.md new file mode 100644 index 0000000..f1c4e80 --- /dev/null +++ b/TASKWORKER_TROUBLESHOOTING.md @@ -0,0 +1,179 @@ +# TaskWorker 卡住问题解决方案 + +## 问题现象 +线上部署时,所有任务都在"等待中"状态卡住,无法被处理。 + +## 根本原因 +在使用 Gunicorn 部署时,TaskWorker 可能因为以下原因未能正常启动或中途崩溃: +1. **多进程竞争**:多个 worker 进程同时启动导致冲突 +2. **锁文件失效**:进程异常退出后锁文件未清理 +3. **线程崩溃**:工作线程因异常而停止 + +## 解决方案 + +### 方案1:使用诊断工具(推荐) + +已创建专门的诊断和修复工具 `check_taskworker.py` + +#### 检查状态 +```bash +python check_taskworker.py +``` + +#### 自动修复 +```bash +python check_taskworker.py --fix +``` + +### 方案2:手动重启服务 + +```bash +# 停止 Gunicorn +kill -TERM $(cat gunicorn.pid) + +# 清理锁文件 +rm -f data/taskworker.lock + +# 重新启动 +gunicorn -c gunicorn_config.py app:app +``` + +### 方案3:使用自动监控守护进程(生产环境推荐) + +启动自动监控程序,会定期检查并自动修复: + +```bash +# 后台运行 +nohup python taskworker_monitor.py > logs/monitor.out 2>&1 & + +# 或使用 systemd 管理(推荐) +sudo systemctl start baijiahao-monitor +``` + +## 预防措施 + +### 1. 优化的 Gunicorn 配置 +已更新 `gunicorn_config.py`,使用文件锁(fcntl)替代简单的存在性检查,避免竞争条件。 + +### 2. 添加健康检查 +在 `app.py` 中添加健康检查接口: + +```python +@app.route('/health/taskworker') +def health_taskworker(): + """TaskWorker 健康检查""" + try: + from task_worker import get_task_worker + worker = get_task_worker() + alive_threads = sum(1 for t in worker.worker_threads if t and t.is_alive()) + + return jsonify({ + 'status': 'healthy' if worker.running and alive_threads > 0 else 'unhealthy', + 'running': worker.running, + 'alive_threads': alive_threads, + 'current_workers': worker.current_workers, + 'processing_tasks': len(worker.processing_tasks) + }) + except Exception as e: + return jsonify({'status': 'error', 'message': str(e)}), 500 +``` + +### 3. 使用 Supervisor 管理(可选) + +创建 `supervisor.conf`: + +```ini +[program:baijiahao] +command=/path/to/venv/bin/gunicorn -c gunicorn_config.py app:app +directory=/path/to/ai_baijiahao +user=www-data +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/path/to/ai_baijiahao/logs/supervisor.log + +[program:baijiahao-monitor] +command=/path/to/venv/bin/python taskworker_monitor.py +directory=/path/to/ai_baijiahao +user=www-data +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/path/to/ai_baijiahao/logs/monitor_supervisor.log +``` + +## 日常维护 + +### 查看日志 +```bash +# TaskWorker 日志 +tail -f logs/gunicorn_error.log | grep TaskWorker + +# 监控日志 +tail -f logs/taskworker_monitor.log +``` + +### 定期清理 +```bash +# 清理旧的任务结果(保留最近30天) +find data/results -name "*.xlsx" -mtime +30 -delete + +# 清理旧日志 +find logs -name "*.log" -mtime +30 -delete +``` + +## 监控告警 + +可以结合监控系统(如 Prometheus + Grafana)监控以下指标: + +1. **TaskWorker 运行状态**:`/health/taskworker` +2. **待处理任务数**:通过 API 获取队列统计 +3. **处理中任务数** +4. **平均任务处理时间** + +## 常见问题 + +### Q1: 重启后任务会丢失吗? +**A**: 不会。所有任务都存储在 SQLite 数据库中,重启后会自动继续处理。 + +### Q2: 如何调整并发数? +**A**: 修改 `task_worker.py` 中的 `TaskWorker` 初始化参数: +```python +worker = TaskWorker(min_workers=1, max_workers=3) # 调整这两个参数 +``` + +### Q3: 监控程序占用资源吗? +**A**: 极低。监控程序每60秒检查一次,几乎不占用 CPU 和内存。 + +## 升级说明 + +本次更新包含以下改进: + +1. ✅ **更健壮的文件锁机制**:使用 `fcntl` 替代简单的文件存在性检查 +2. ✅ **状态验证**:启动后验证 TaskWorker 是否真正运行 +3. ✅ **诊断工具**:`check_taskworker.py` 快速定位问题 +4. ✅ **自动监控**:`taskworker_monitor.py` 自动检测和修复 +5. ✅ **详细日志**:记录启动过程和异常信息 + +## 联系支持 + +如果问题仍然存在,请提供以下信息: + +1. `logs/gunicorn_error.log` 的最近日志 +2. `python check_taskworker.py` 的输出 +3. 数据库中待处理任务的数量和状态 + +```bash +# 快速诊断命令 +echo "=== Gunicorn 进程 ===" +ps aux | grep gunicorn + +echo "=== TaskWorker 锁文件 ===" +ls -lh data/taskworker.lock + +echo "=== 任务统计 ===" +python check_taskworker.py + +echo "=== 最近日志 ===" +tail -n 50 logs/gunicorn_error.log +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..8d22ab8 --- /dev/null +++ b/app.py @@ -0,0 +1,1123 @@ +# -*- coding: utf-8 -*- +import json +import logging +import os +import re +from datetime import datetime, timedelta +from functools import wraps + +import pandas as pd +import requests +from flask import Flask, render_template, request, jsonify, send_file, session, redirect, url_for +from flask_cors import CORS +from flask_socketio import SocketIO, emit +from task_queue import get_task_queue +from task_worker import start_task_worker + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = Flask(__name__, static_folder='static', template_folder='templates') +app.secret_key = 'your-secret-key-change-this-in-production' # 请在生产环境中修改为随机密钥 +CORS(app) + +# 初始化 SocketIO +socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading') + +# 简单的用户数据库(生产环境应使用数据库) +USERS = { + 'admin': 'admin123', # 用户名: 密码 +} + +# 登录验证装饰器 +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'username' not in session: + return jsonify({'success': False, 'message': '请先登录', 'need_login': True}), 401 + return f(*args, **kwargs) + return decorated_function + + +class BaijiahaoScraper: + def __init__(self, uk, cookies=None, use_proxy=False, proxy_api_url=None): + self.uk = uk + self.api_url = 'https://mbd.baidu.com/webpage' + self.headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', + 'Referer': 'https://baijiahao.baidu.com/', + 'Accept': '*/*', + } + self.session = requests.Session() + self.session.keep_alive = False # 禁用长连接 + if cookies: + self.session.cookies.update(cookies) + + # 代理配置 + self.use_proxy = use_proxy + self.proxy_api_url = proxy_api_url or 'http://api.tianqiip.com/getip?secret=lu29e593&num=1&type=txt&port=1&mr=1&sign=4b81a62eaed89ba802a8f34053e2c964' + self.current_proxy = None + + def get_proxy(self): + """从代理池获取一个代理IP""" + try: + print(f"正在从代理池获取IP: {self.proxy_api_url}") + response = requests.get(self.proxy_api_url, timeout=5) # 优化超时为5秒 + content = response.content.decode("utf-8").strip() + print(f"代理API响应: {content}") + + # 检查是否是JSON错误响应 + if content.startswith('{'): + try: + import json + error_data = json.loads(content) + error_msg = error_data.get('msg', '未知错误') + print(f"❌ 代理IP池错误: {error_msg}") + raise Exception(f"代理IP池错误: {error_msg}") + except json.JSONDecodeError: + pass # 不是JSON,继续按IP处理 + + # 解析IP和端口 + if ':' in content: + sj = content.strip().split(":", 1) + sj1 = sj[0] # IP + sj2 = sj[1] # 端口 + print(f"IP: {sj1}, 端口: {sj2}") + + # 构建代理配置 + proxy_meta = f"http://{sj1}:{sj2}" + proxies = { + 'http': proxy_meta, + 'https': proxy_meta + } + self.current_proxy = proxies + print(f"代理配置成功: {proxies}") + return proxies + else: + print("代理IP格式错误") + raise Exception(f"代理IP格式错误: {content}") + except Exception as e: + print(f"获取代理IP失败: {e}") + raise + + def make_request(self, url, **kwargs): + """使用代理发起请求,失败时自动切换IP""" + max_retries = 3 # 最大重试次数 + retry_count = 0 + last_was_anti_crawl = False # 标记上次是否因反爬重试 + + while retry_count < max_retries: + if self.use_proxy: + # 如果没有当前代理或需要刷新,获取新代理 + if not self.current_proxy or retry_count > 0: + print(f"{'立即切换' if retry_count > 0 else ''}获取代理IP(第{retry_count + 1}次)") + proxy = self.get_proxy() + if not proxy: + raise Exception("启用了代理但无法获取代理IP,拒绝使用本机IP") + + # 必须有代理才请求 + if not self.current_proxy: + raise Exception("启用了代理但当前无代理IP,拒绝使用本机IP") + kwargs['proxies'] = self.current_proxy + + # 设置超时 + if 'timeout' not in kwargs: + kwargs['timeout'] = 20 + + try: + import time + start = int(round(time.time() * 1000)) + response = self.session.get(url, **kwargs) + cost_time = int(round(time.time() * 1000)) - start + print(f"请求耗时: {cost_time}ms") + + # 检查是否被反爬 + if self._check_anti_spider(response): + print("⚠️ 检测到反爬,立即切换IP(无需等待)") + self.current_proxy = None # 清空当前代理 + retry_count += 1 + last_was_anti_crawl = True + if retry_count < max_retries: + continue # 立即重试,不等待 + else: + raise Exception("多次重试后仍被反爬拦截,请稍后再试") + + return response + except requests.exceptions.ProxyError as e: + print(f"代理错误: {e},立即切换IP(无需等待)") + self.current_proxy = None + retry_count += 1 + last_was_anti_crawl = True + if retry_count < max_retries: + continue # 立即重试,不等待 + else: + raise + except requests.exceptions.HTTPError as e: + # 检查是否是407错误(代理IP池限流) + if e.response and e.response.status_code == 407: + print(f"⚠️ 检测到407错误(代理IP池限流),等待3秒后重新获取IP...") + import time + time.sleep(3) # 等待3秒 + if self.use_proxy: + print("重新获取代理IP...") + self.current_proxy = None + retry_count += 1 + if retry_count < max_retries: + continue + raise + else: + print(f"请求失败: {e}") + raise + except Exception as e: + print(f"请求失败: {e}") + # 如果使用代理失败,尝试重新获取代理 + if self.use_proxy: + print("立即切换代理(无需等待)") + self.current_proxy = None + retry_count += 1 + if retry_count < max_retries: + continue # 立即重试,不等待 + raise + + raise Exception(f"请求失败,已重试{max_retries}次") + + def _check_anti_spider(self, response): + """检查响应是否被反爬拦截""" + # 检查状态码 + if response.status_code in [403, 429, 503]: + return True + + # 尝试解析JSON响应,检查is_need_foe字段 + try: + data = response.json() + if data.get('data', {}).get('foe', {}).get('is_need_foe') == True: + print("检测到is_need_foe=True,需要切换IP") + return True + except: + pass # 如果不是JSON响应,继续检查文本内容 + + # 检查响应内容中的反爬特征 + content = response.text.lower() + anti_spider_keywords = [ + '验证码', + 'captcha', + '请输入验证码', + '访问频繁', + '异常访问', + '请稍后再试', + 'access denied', + 'forbidden', + '安全验证', + '人机验证' + ] + + for keyword in anti_spider_keywords: + if keyword in content: + print(f"检测到反爬关键词: {keyword}") + return True + + return False + + @staticmethod + def get_uk_from_app_id(app_id, use_proxy=False, proxy_api_url=None): + """从baijiahao主页提取uk参数""" + url = f"https://baijiahao.baidu.com/u?app_id={app_id}" + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + } + + print(f"正在从主页获取uk: {url}") + + max_retries = 3 # 最大重试次数 + retry_count = 0 + + while retry_count < max_retries: + # 准备代理配置 + kwargs = {'headers': headers, 'timeout': 30} + + if use_proxy: + proxy_url = proxy_api_url or 'http://api.tianqiip.com/getip?secret=lu29e593&num=1&type=txt&port=1&mr=1&sign=4b81a62eaed89ba802a8f34053e2c964' + try: + print(f"{'重新' if retry_count > 0 else ''}使用代理获取uk(第{retry_count + 1}次)") + proxy_response = requests.get(proxy_url, timeout=5) # 优化超时为5秒 + content = proxy_response.content.decode("utf-8").strip() + print(f"代理API响应: {content}") + + # 检查是否是JSON错误响应 + if content.startswith('{'): + try: + import json + error_data = json.loads(content) + error_msg = error_data.get('msg', '未知错误') + raise Exception(f"代理IP池错误: {error_msg}") + except json.JSONDecodeError: + pass # 不是JSON,继续按IP处理 + + if ':' in content: + sj = content.strip().split(":", 1) + proxy_meta = f"http://{sj[0]}:{sj[1]}" + kwargs['proxies'] = { + 'http': proxy_meta, + 'https': proxy_meta + } + print(f"使用代理: {proxy_meta}") + else: + raise Exception(f"代理IP格式错误: {content}") + except Exception as e: + # 启用了代理但获取失败,不使用本机IP,直接抛出异常 + raise Exception(f"启用了代理但获取代理IP失败,拒绝使用本机IP: {e}") + + try: + response = requests.get(url, **kwargs) + + if response.status_code != 200: + print(f"访问主页失败,状态码: {response.status_code}") + retry_count += 1 + if retry_count < max_retries: + print(f"立即重试(无需等待)") + continue # 立即重试 + else: + raise Exception(f"访问主页失败,状态码: {response.status_code}") + + # 检查是否被反爬 + content_lower = response.text.lower() + anti_spider_detected = False + + # 检查is_need_foe字段 + try: + data = response.json() + if data.get('data', {}).get('foe', {}).get('is_need_foe') == True: + print("⚠️ 检测到is_need_foe=True,需要切换IP") + anti_spider_detected = True + except: + pass # 不是JSON响应,继续检查其他特征 + + # 检查反爬关键词 + if not anti_spider_detected: + anti_spider_keywords = ['验证码', 'captcha', '访问频繁', '异常访问', 'access denied'] + for keyword in anti_spider_keywords: + if keyword in content_lower: + print(f"⚠️ 检测到反爬关键词: {keyword}") + anti_spider_detected = True + break + + if anti_spider_detected and use_proxy: + retry_count += 1 + if retry_count < max_retries: + print(f"检测到反爬,立即切换IP重试(无需等待)") + continue # 立即重试,不等待 + else: + raise Exception("多次重试后仍被反爬拦截") + + # 使用正则表达式提取uk + uk_match = re.search(r'"uk"\s*:\s*"([^"]+)"', response.text) + + if not uk_match: + # 尝试另一种模式 + uk_match = re.search(r'uk=([^&\s"]+)', response.text) + + if uk_match: + uk = uk_match.group(1) + print(f">> 成功获取UK: {uk}") + + # 获取Cookie + cookies = response.cookies + print(f">> 成功获取Cookie") + + return uk, cookies + else: + raise Exception("无法从页面中提取UK参数") + + except requests.exceptions.ProxyError as e: + print(f"代理错误: {e},立即切换代理重试(无需等待)") + retry_count += 1 + if retry_count < max_retries and use_proxy: + continue # 立即重试,不等待 + else: + raise + except requests.exceptions.HTTPError as e: + # 检查是否是407错误(代理IP池限流) + if e.response and e.response.status_code == 407: + print(f"⚠️ 检测到407错误(代理IP池限流),等待3秒后重新获取IP...") + import time + time.sleep(3) # 等待3秒 + retry_count += 1 + if retry_count < max_retries and use_proxy: + print("重新获取代理IP,继续重试...") + continue + raise + else: + print(f"请求失败: {e}") + raise + except Exception as e: + if retry_count < max_retries - 1: + retry_count += 1 + print(f"错误: {e},立即重试(无需等待)") + continue # 立即重试,不等待 + else: + print(f"获取UK失败: {e}") + raise + + def get_articles(self, months=6, app_id=None, articles_only=True, task_id=None, on_page_fetched=None, + start_page=1, start_ctime=None): + """使用API接口获取文章列表(调用baidu_api.py的get_baidu_data_sync) + + Args: + months: 获取近几个月的数据 + app_id: 百家号app_id + articles_only: 是否仅爬取文章(跳过视频) + task_id: 任务ID(用于数据库缓存) + on_page_fetched: 每页数据获取后的回调函数 + start_page: 起始页码(断点续传) + start_ctime: 起始分页参数(断点续传) + + Returns: + dict: {'last_page': 最后页码, 'last_ctime': 分页参数, 'completed': 是否完成} + """ + import sys + import os + + # 导入baidu_api.py中的同步函数 + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from baidu_api import get_baidu_data_sync + from datetime import datetime, timedelta + + # 支持小数月份(如0.33个月10天) + days = int(months * 30) + target_date = datetime.now() - timedelta(days=days) + + print(f"\n{'=' * 50}") + print(f"开始获取百家号作者(uk={self.uk})的文章...") + if start_page > 1: + print(f"🔄 断点续传:从第{start_page}页开始") + print(f"{'=' * 50}\n") + + # 定义处理每页数据的回调函数 + def process_page(page, items, ctime): + """处理每页的原始数据,提取文章并调用上层回调""" + processed_articles = [] + + for item in items: + item_data = item.get('itemData', {}) + + # 如果启用articles_only,过滤掉视频内容 + if articles_only: + meta_type = item_data.get('meta_type', '') + if meta_type == 'video': + print(f" ✖ 跳过视频: {item_data.get('title', '[无标题]')[:50]}...") + continue + + title = item_data.get('title', '') + article_url = item_data.get('url', '') + + # 优先使用ctime(Unix时间戳),更准确 + publish_time = '未知时间' + if 'ctime' in item_data and item_data['ctime']: + try: + timestamp = int(item_data['ctime']) + article_date = datetime.fromtimestamp(timestamp) + publish_time = article_date.strftime('%Y-%m-%d %H:%M:%S') + except Exception as e: + print(f" ✗ 解析ctime失败: {e}") + time_str = item_data.get('time', '未知时间') + if time_str != '未知时间': + article_date = self._parse_article_date(time_str) + if article_date: + publish_time = article_date.strftime('%Y-%m-%d %H:%M:%S') + else: + time_str = item_data.get('time', '未知时间') + if time_str != '未知时间': + article_date = self._parse_article_date(time_str) + if article_date: + publish_time = article_date.strftime('%Y-%m-%d %H:%M:%S') + + processed_articles.append({ + '标题': title.strip().split('\n')[0][:500] if title else '无标题', + '链接': article_url, + '发布时间': publish_time + }) + + print(f"第{page}页处理完成:{len(processed_articles)}篇文章") + + # 调用上层回调(如果提供) + if on_page_fetched: + on_page_fetched(page, processed_articles, ctime) + + try: + # 调用爬虫,传入回调函数 + if months < 1: + print(f"调用get_baidu_data_sync获取数据(近{days}天)...\n") + else: + print(f"调用get_baidu_data_sync获取数据(近{int(months)}个月)...\n") + + # 传递代理参数和回调函数 + result = get_baidu_data_sync( + uk=self.uk, + months=months, + use_proxy=self.use_proxy, + proxy_api_url=self.proxy_api_url if self.proxy_api_url else None, + on_page_fetched=process_page, + start_page=start_page, + start_ctime=start_ctime + ) + + if not result: + print("\n✗ 未获取到数据") + return {'last_page': start_page, 'last_ctime': start_ctime, 'completed': False} + + print(f"\n✓ 爬取完成!") + return result + + except Exception as e: + print(f"\n✗ 调用get_baidu_data_sync失败: {e}") + import traceback + traceback.print_exc() + return {'last_page': start_page, 'last_ctime': start_ctime, 'completed': False} + + def _parse_article_date(self, time_str): + """解析文章时间字符串,返回datetime对象""" + from datetime import datetime, timedelta + import re + + if not time_str or time_str == '未知': + return None + + current_year = datetime.now().year + now = datetime.now() + + # 格式1: 相对时间 "1天前", "2小时前", "30分钟前", "1个月前" + if '前' in time_str: + # 天前 + match = re.search(r'(\d+)\s*天', time_str) + if match: + days = int(match.group(1)) + return now - timedelta(days=days) + + # 小时前 + match = re.search(r'(\d+)\s*小时', time_str) + if match: + hours = int(match.group(1)) + return now - timedelta(hours=hours) + + # 分钟前 + match = re.search(r'(\d+)\s*分钟', time_str) + if match: + minutes = int(match.group(1)) + return now - timedelta(minutes=minutes) + + # 月前 + match = re.search(r'(\d+)\s*个?月', time_str) + if match: + months = int(match.group(1)) + return now - timedelta(days=months * 30) # 近似计算 + + # 格式2: "11-29 07:23" 或 "11-29" + match = re.match(r'(\d{1,2})-(\d{1,2})', time_str) + if match: + month = int(match.group(1)) + day = int(match.group(2)) + return datetime(current_year, month, day) + + # 格式3: "2024-11-29" + match = re.match(r'(\d{4})-(\d{1,2})-(\d{1,2})', time_str) + if match: + year = int(match.group(1)) + month = int(match.group(2)) + day = int(match.group(3)) + return datetime(year, month, day) + + return None + + def get_articles_from_html(self, months=6, app_id=None): + """直接解析HTML获取文章(不依赖Selenium)""" + if not app_id: + raise Exception("需要提供app_id") + + articles = [] + + try: + url = f"https://baijiahao.baidu.com/u?app_id={app_id}" + print(f"访问页面: {url}") + + headers = { + 'User-Agent': self.headers['User-Agent'], + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9', + 'Connection': 'keep-alive', + } + + response = self.session.get(url, headers=headers, timeout=30) + + if response.status_code != 200: + raise Exception(f"访问失败,状态码: {response.status_code}") + + html = response.text + + # 使用简单的字符串查找方式提取文章 + # 查找所有包含 /s?id= 的链接 + import re + links = re.findall(r'href="(/s\?id=[^"]+)"', html) + links += re.findall(r"href='(/s\?id=[^']+)'", html) + + # 尝试提取标题 + for link in set(links): # 去重 + # 尝试找到这个链接对应的title + title_pattern = f'href="{link}"[^>]*title="([^"]+)"' + title_match = re.search(title_pattern, html) + if not title_match: + title_pattern = f'title="([^"]+)"[^>]*href="{link}"' + title_match = re.search(title_pattern, html) + + title = title_match.group(1) if title_match else "未知标题" + + full_link = 'https://baijiahao.baidu.com' + link + articles.append({ + '标题': title.strip(), + '链接': full_link, + '发布时间': '未知' + }) + + print(f"成功提取 {len(articles)} 篇文章") + + except Exception as e: + print(f"HTML解析失败: {e}") + raise + + return articles + + +# ========== SocketIO 事件处理 ========== + +@socketio.on('connect') +def handle_connect(): + """客户端连接时触发""" + logger.info(f"客户端已连接: {request.sid}") + emit('connected', {'message': '已连接到服务器'}) + + +@socketio.on('disconnect') +def handle_disconnect(): + """客户端断开时触发""" + logger.info(f"客户端已断开: {request.sid}") + + +@socketio.on('subscribe_task') +def handle_subscribe_task(data): + """客户端订阅任务进度""" + task_id = data.get('task_id') + logger.info(f"客户端 {request.sid} 订阅任务: {task_id}") + emit('subscribed', {'task_id': task_id}) + + +def emit_task_log(task_id, message, level='info'): + """发送任务日志到前端""" + socketio.emit('task_log', { + 'task_id': task_id, + 'message': message, + 'level': level, + 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + }) + + +def emit_task_progress(task_id, progress, current_step='', **kwargs): + """发送任务进度到前端""" + data = { + 'task_id': task_id, + 'progress': progress, + 'current_step': current_step, + 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + data.update(kwargs) + socketio.emit('task_progress', data) + + +# ========== Flask 路由 ========== + +@app.route('/') +def index(): + """首页""" + # 如果未登录,重定向到登录页 + if 'username' not in session: + return redirect(url_for('login_page')) + return render_template('index.html', username=session.get('username')) + + +@app.route('/queue') +def queue_page(): + """任务队列页面""" + # 如果未登录,重定向到登录页 + if 'username' not in session: + return redirect(url_for('login_page')) + return render_template('queue.html', username=session.get('username')) + + +@app.route('/login') +def login_page(): + """登录页面""" + # 如果已登录,重定向到首页 + if 'username' in session: + return redirect(url_for('index')) + return render_template('login.html') + + +@app.route('/api/login', methods=['POST']) +def login(): + """登录接口""" + try: + data = request.get_json() + username = data.get('username', '').strip() + password = data.get('password', '').strip() + + if not username or not password: + return jsonify({'success': False, 'message': '请输入用户名和密码'}) + + # 验证用户名和密码 + if username in USERS and USERS[username] == password: + session['username'] = username + return jsonify({'success': True, 'message': '登录成功'}) + else: + return jsonify({'success': False, 'message': '用户名或密码错误'}) + except Exception as e: + return jsonify({'success': False, 'message': f'登录失败: {str(e)}'}) + + +@app.route('/api/logout', methods=['POST']) +def logout(): + """登出接口""" + session.pop('username', None) + return jsonify({'success': True, 'message': '已登出'}) + + +@app.route('/api/export', methods=['POST']) +@login_required +def export_articles(): + """导出文章到Excel""" + try: + data = request.get_json() + url = data.get('url', '') + cookies_str = data.get('cookies', '') # 从前端获取Cookie + months = data.get('months', 6) # 获取时间范围,默认6个月 + + # 从URL中提取app_id,并转换为uk参数 + app_id_match = re.search(r'app_id=(\d+)', url) + if not app_id_match: + return jsonify({'success': False, 'message': 'URL格式不正确,无法提取app_id'}) + + app_id = app_id_match.group(1) + print(f"开始导出,app_id={app_id}") + print(f"Cookie长度: {len(cookies_str) if cookies_str else 0}") + + # 检查是否使用代理 + use_proxy = data.get('use_proxy', False) + proxy_api_url = data.get('proxy_api_url', '') + articles_only = data.get('articles_only', True) # 获取是否仅爬取文章 + + # 从主页获取uk和Cookie + uk, auto_cookies = BaijiahaoScraper.get_uk_from_app_id(app_id, use_proxy=use_proxy, proxy_api_url=proxy_api_url if proxy_api_url else None) + + if not uk: + return jsonify({'success': False, 'message': '无法获取用户UK,请检查URL是否正确'}) + + print(f"成功获取uk={uk}") + + # 如果用户提供了Cookie,使用用户的Cookie + if cookies_str: + print("使用用户提供的Cookie") + # 解析Cookie字符串 + cookies_dict = {} + for item in cookies_str.split(';'): + item = item.strip() + if '=' in item: + key, value = item.split('=', 1) + cookies_dict[key.strip()] = value.strip() + + # 转换为requests.cookies.RequestsCookieJar + from requests.cookies import cookiejar_from_dict + user_cookies = cookiejar_from_dict(cookies_dict) + scraper = BaijiahaoScraper(uk, user_cookies, use_proxy=use_proxy, proxy_api_url=proxy_api_url if proxy_api_url else None) + else: + print("使用自动获取的Cookie") + scraper = BaijiahaoScraper(uk, auto_cookies, use_proxy=use_proxy, proxy_api_url=proxy_api_url if proxy_api_url else None) + + # 使用 API 方式获取文章(仅保留代理IP池 + API 方式) + print(f"使用 API 方式获取文章(近{months}个月)...") + try: + articles = scraper.get_articles(months=months, app_id=app_id, articles_only=articles_only) + except Exception as e: + print(f"API 方式失败: {e}") + articles = [] + + if not articles: + return jsonify({ + 'success': False, + 'message': ( + '未能获取到文章数据。\n\n' + '请确保:\n' + '1. URL正确且该作者有发布过文章\n' + '2. 网络连接正常\n' + '3. 如需使用代理,请配置代理IP池' + ) + }) + + # 创建Excel文件 + df = pd.DataFrame(articles) + + # 确保输出目录存在 + output_dir = os.path.join(os.path.dirname(__file__), 'exports') + os.makedirs(output_dir, exist_ok=True) + + # 生成文件名 + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f'baijiahao_articles_{app_id}_{timestamp}.xlsx' + filepath = os.path.join(output_dir, filename) + + # 保存Excel + with pd.ExcelWriter(filepath, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='文章列表') + + # 调整列宽 + worksheet = writer.sheets['文章列表'] + worksheet.column_dimensions['A'].width = 80 # 标题列 + worksheet.column_dimensions['B'].width = 20 # 时间列 + + return jsonify({ + 'success': True, + 'message': f'成功导出{len(articles)}篇文章', + 'filename': filename, + 'count': len(articles), + 'articles': articles[:100] # 返回前100篇文章用于预览 + }) + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'success': False, 'message': f'导出失败: {str(e)}'}) + + +@app.route('/api/download/') +@login_required +def download_file(filename): + """下载Excel文件""" + try: + filepath = os.path.join(os.path.dirname(__file__), 'exports', filename) + if os.path.exists(filepath): + return send_file(filepath, as_attachment=True, download_name=filename) + else: + return jsonify({'success': False, 'message': '文件不存在'}) + except Exception as e: + return jsonify({'success': False, 'message': f'下载失败: {str(e)}'}) + + +# ==================== 任务队列相关API ==================== + +@app.route('/api/queue/add', methods=['POST']) +@login_required +def add_task_to_queue(): + """添加任务到队列""" + try: + data = request.get_json() + url = data.get('url', '') + months = data.get('months', 6) + use_proxy = data.get('use_proxy', False) + proxy_api_url = data.get('proxy_api_url', '') + articles_only = data.get('articles_only', True) # 获取是否仅爬取文章 + + if not url: + return jsonify({'success': False, 'message': 'URL不能为空'}) + + # 添加任务到队列 + queue = get_task_queue() + task_id = queue.add_task( + url=url, + months=months, + use_proxy=use_proxy, + proxy_api_url=proxy_api_url if proxy_api_url else None, + username=session.get('username'), + articles_only=articles_only + ) + + return jsonify({ + 'success': True, + 'message': '任务已添加到队列', + 'task_id': task_id + }) + + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'success': False, 'message': f'添加任务失败: {str(e)}'}) + + +@app.route('/api/queue/tasks', methods=['GET']) +@login_required +def get_tasks(): + """获取任务列表""" + try: + queue = get_task_queue() + username = session.get('username') + + # 获取用户的所有任务 + tasks = queue.get_all_tasks(username=username) + + # 按创建时间倒序排列 + tasks.sort(key=lambda x: x.get('created_at', ''), reverse=True) + + return jsonify({ + 'success': True, + 'tasks': tasks + }) + + except Exception as e: + return jsonify({'success': False, 'message': f'获取任务列表失败: {str(e)}'}) + + +@app.route('/api/queue/task/', methods=['GET']) +@login_required +def get_task_detail(task_id): + """获取任务详情""" + try: + queue = get_task_queue() + task = queue.get_task(task_id) + + if not task: + return jsonify({'success': False, 'message': '任务不存在'}) + + # 检查权限 + if task.get('username') != session.get('username'): + return jsonify({'success': False, 'message': '无权查看此任务'}) + + return jsonify({ + 'success': True, + 'task': task + }) + + except Exception as e: + return jsonify({'success': False, 'message': f'获取任务详情失败: {str(e)}'}) + + +@app.route('/api/queue/stats', methods=['GET']) +@login_required +def get_queue_stats(): + """获取队列统计信息""" + try: + queue = get_task_queue() + username = session.get('username') + + stats = queue.get_queue_stats(username=username) + + return jsonify({ + 'success': True, + 'stats': stats + }) + + except Exception as e: + return jsonify({'success': False, 'message': f'获取统计信息失败: {str(e)}'}) + + +@app.route('/api/queue/download/', methods=['GET']) +@login_required +def download_task_result(task_id): + """下载任务结果文件(通过任务ID)""" + try: + queue = get_task_queue() + task = queue.get_task(task_id) + + if not task: + return jsonify({'success': False, 'message': '任务不存在'}) + + # 检查权限 + if task.get('username') != session.get('username'): + return jsonify({'success': False, 'message': '无权下载此文件'}) + + if task.get('status') != 'completed': + return jsonify({'success': False, 'message': '任务未完成'}) + + result_file = task.get('result_file') + if not result_file: + return jsonify({'success': False, 'message': '结果文件不存在'}) + + filepath = os.path.join(queue.results_dir, result_file) + if not os.path.exists(filepath): + return jsonify({'success': False, 'message': '文件不存在'}) + + return send_file(filepath, as_attachment=True, download_name=result_file) + + except Exception as e: + return jsonify({'success': False, 'message': f'下载失败: {str(e)}'}) + + +@app.route('/api/queue/task//delete', methods=['POST']) +@login_required +def delete_task(task_id): + """删除任务(自动终止后删除)""" + queue = get_task_queue() + task = queue.get_task(task_id) + if not task: + return jsonify({'success': False, 'message': '任务不存在'}) + if task.get('username') != session.get('username'): + return jsonify({'success': False, 'message': '无权删除此任务'}) + ok = queue.delete_task(task_id) + return jsonify({'success': ok}) + + +@app.route('/api/queue/task//cancel', methods=['POST']) +@login_required +def cancel_task(task_id): + """终止任务(等待中或处理中)""" + queue = get_task_queue() + task = queue.get_task(task_id) + if not task: + return jsonify({'success': False, 'message': '任务不存在'}) + if task.get('username') != session.get('username'): + return jsonify({'success': False, 'message': '无权终止此任务'}) + if task.get('status') not in ['pending', 'processing']: + return jsonify({'success': False, 'message': '仅可终止等待中或处理中任务'}) + ok = queue.cancel_task(task_id) + return jsonify({'success': ok}) + + +@app.route('/api/queue/task//logs', methods=['GET']) +@login_required +def get_task_logs(task_id): + """获取任务的历史日志""" + try: + queue = get_task_queue() + task = queue.get_task(task_id) + + if not task: + return jsonify({'success': False, 'message': '任务不存在'}) + + # 检查权限 + if task.get('username') != session.get('username'): + return jsonify({'success': False, 'message': '无权查看此任务日志'}) + + # 从数据库获取日志 + from database import get_database + db = get_database() + logs = db.get_task_logs(task_id) + + return jsonify({ + 'success': True, + 'logs': logs + }) + + except Exception as e: + return jsonify({'success': False, 'message': f'获取日志失败: {str(e)}'}) + + +@app.route('/health/taskworker') +def health_taskworker(): + """TaskWorker 健康检查接口""" + try: + from task_worker import get_task_worker + from task_queue import get_task_queue + + worker = get_task_worker() + queue = get_task_queue() + + # 统计信息 + tasks = queue.get_all_tasks() + pending_count = len([t for t in tasks if t.get('status') == 'pending']) + processing_count = len([t for t in tasks if t.get('status') == 'processing']) + + # Worker 状态 + alive_threads = sum(1 for t in worker.worker_threads if t and t.is_alive()) + + status = 'healthy' if worker.running and alive_threads > 0 else 'unhealthy' + + return jsonify({ + 'status': status, + 'worker': { + 'running': worker.running, + 'alive_threads': alive_threads, + 'current_workers': worker.current_workers, + 'max_workers': worker.max_workers, + 'processing_tasks': len(worker.processing_tasks) + }, + 'queue': { + 'pending': pending_count, + 'processing': processing_count, + 'total': len(tasks) + }, + 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + }) + + except Exception as e: + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 500 + + +if __name__ == '__main__': + import sys + + + # 检查并安装依赖 + def check_dependencies(): + """检查关键依赖是否安装""" + missing = [] + try: + import flask + except ImportError: + missing.append('flask') + try: + import pandas + except ImportError: + missing.append('pandas') + try: + import openpyxl + except ImportError: + missing.append('openpyxl') + + if missing: + print(f"\n⚠️ 缺少依赖: {', '.join(missing)}") + print("请运行: pip install -r requirements.txt\n") + return False + return True + + + if not check_dependencies(): + sys.exit(1) + + # 创建必要的目录 + os.makedirs('exports', exist_ok=True) + os.makedirs('templates', exist_ok=True) + os.makedirs('static/css', exist_ok=True) + os.makedirs('static/js', exist_ok=True) + os.makedirs('data', exist_ok=True) + os.makedirs('data/results', exist_ok=True) + + # 启动任务处理器 + print('🔧 启动任务处理器...') + + # 设置 SocketIO 实例到 task_worker + from task_worker import set_socketio + set_socketio(socketio) + + start_task_worker() + print('✅ 任务处理器已启动') + + # 检测是否是生产环境 + is_production = os.environ.get('FLASK_ENV') == 'production' + + if is_production: + print('✅ 生产环境启动') + print('请使用 gunicorn 或 uwsgi 运行:') + print(' gunicorn -w 4 -b 0.0.0.0:5001 app:app') + print('\n如果要直接运行,请使用: python app.py --dev') + + if '--dev' not in sys.argv: + sys.exit(1) + + print('🚀 服务器启动成功!') + print('请访问: http://127.0.0.1:8030') + + # 开发环境使用 SocketIO 服务器 + socketio.run( + app, + debug=not is_production, + host='0.0.0.0', + port=8030, + allow_unsafe_werkzeug=True + ) diff --git a/baidu_api.py b/baidu_api.py new file mode 100644 index 0000000..1c8f04b --- /dev/null +++ b/baidu_api.py @@ -0,0 +1,633 @@ +import asyncio +import json +import random +import time +from typing import Dict, Any, Optional +from urllib.parse import quote + +import aiohttp +from playwright.async_api import async_playwright +from fake_useragent import UserAgent +import logging + +from test2 import display_simple_data + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class BaiduBJHSpider: + def __init__(self, use_proxy: bool = False, proxy_api_url: str = None, proxy_username: str = None, proxy_password: str = None): + self.ua = UserAgent() + self.use_proxy = use_proxy + self.proxy_api_url = proxy_api_url or 'http://api.tianqiip.com/getip?secret=lu29e593&num=1&type=txt&port=1&mr=1&sign=4b81a62eaed89ba802a8f34053e2c964' + self.proxy_username = proxy_username + self.proxy_password = proxy_password + self.current_proxy = None + self.session_cookie = None + + def get_proxy(self): + """从代理池获取一个代理IP""" + if not self.use_proxy: + return None + + try: + import requests + logger.info(f"从代理池获取IP: {self.proxy_api_url}") + response = requests.get(self.proxy_api_url, timeout=5) # 优化超时为5秒 + content = response.content.decode("utf-8").strip() + logger.info(f"提取代理IP: {content}") + + if ':' in content: + ip, port = content.strip().split(":", 1) + + # 如果有认证信息,添加到代理URL中 + if self.proxy_username and self.proxy_password: + proxy_url = f"http://{self.proxy_username}:{self.proxy_password}@{ip}:{port}" + logger.info(f"代理配置成功(带认证): http://{self.proxy_username}:****@{ip}:{port}") + else: + proxy_url = f"http://{ip}:{port}" + logger.info(f"代理配置成功: {proxy_url}") + + self.current_proxy = proxy_url + return proxy_url + else: + logger.error("代理IP格式错误") + return None + except Exception as e: + logger.error(f"获取代理IP失败: {e}") + return None + + async def init_browser(self): + """初始化浏览器环境获取Cookie""" + playwright = await async_playwright().start() + + # 配置浏览器参数 + browser_args = [ + '--disable-blink-features=AutomationControlled', + '--disable-web-security', + '--disable-features=IsolateOrigins,site-per-process', + '--no-sandbox', + '--disable-setuid-sandbox', + ] + + # 启动浏览器 + browser = await playwright.chromium.launch( + headless=True, # 设置为True可以无头模式运行 + args=browser_args + ) + + # 创建上下文 + context = await browser.new_context( + viewport={'width': 1920, 'height': 1080}, + user_agent=self.ua.random, + locale='zh-CN', + timezone_id='Asia/Shanghai' + ) + + # 设置额外的HTTP头 + await context.set_extra_http_headers({ + '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', + }) + + page = await context.new_page() + + # 首先访问百度首页获取基础Cookie + await page.goto('https://www.baidu.com', wait_until='networkidle') + await asyncio.sleep(random.uniform(2, 4)) + + # 访问百家号页面 + await page.goto('https://baijiahao.baidu.com/', wait_until='networkidle') + await asyncio.sleep(random.uniform(3, 5)) + + # 获取Cookie + cookies = await context.cookies() + self.session_cookie = '; '.join([f"{c['name']}={c['value']}" for c in cookies]) + + logger.info(f"获取到Cookie: {self.session_cookie[:50]}...") + + await browser.close() + await playwright.stop() + + return cookies + + def build_headers(self, referer: str = "https://baijiahao.baidu.com/") -> Dict: + """构建请求头""" + timestamp = int(time.time() * 1000) + + headers = { + 'User-Agent': self.ua.random, + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7', + 'Accept-Encoding': 'gzip, deflate, br', + 'Referer': referer, + 'Connection': 'keep-alive', + 'Sec-Fetch-Dest': 'script', + 'Sec-Fetch-Mode': 'no-cors', + 'Sec-Fetch-Site': 'same-site', + 'Pragma': 'no-cache', + 'Cache-Control': 'no-cache', + } + + if self.session_cookie: + headers['Cookie'] = self.session_cookie + + return headers + + def generate_callback_name(self) -> str: + """生成随机的callback函数名""" + timestamp = int(time.time() * 1000) + return f"__jsonp{timestamp}" + + async def fetch_data_directly(self, uk: str = "ntHidnLhrlfclJar2z8wBg", use_browser: bool = False, num: int = 10, + ctime: str = None) -> Optional[Dict]: + """直接请求接口(可能需要多次尝试) + + Args: + uk: 作者UK + use_browser: 是否使用浏览器获取Cookie,默认False不启动浏览器 + num: 请求数据条数,API固定为10 + ctime: 分页参数,上一次请求返回的query.ctime值 + """ + # 只在use_browser=True时才初始化浏览器获取Cookie + if use_browser: + await self.init_browser() + + # 如果启用代理,必须先获取一个代理IP(失败则抛出异常,不使用本机IP) + if self.use_proxy: + if not self.current_proxy: + proxy = self.get_proxy() + if not proxy: + raise Exception("启用了代理但无法获取代理IP,拒绝使用本机IP") + + async with aiohttp.ClientSession() as session: + for attempt in range(10): # 增加到10次重试,应对IP池限流 + try: + callback_name = self.generate_callback_name() + timestamp = int(time.time() * 1000) + + # 构建URL参数 + params = { + 'tab': 'main', + 'num': '10', # API固定为10 + 'uk': uk, + 'source': 'pc', + 'type': 'newhome', + 'action': 'dynamic', + 'format': 'jsonp', + 'callback': callback_name, + 'otherext': f'h5_{time.strftime("%Y%m%d%H%M%S")}', + 'Tenger-Mhor': str(timestamp), + '_': str(timestamp) # 添加时间戳参数 + } + + # 如果有ctime参数,添加到请求中(用于分页) + if ctime: + params['ctime'] = ctime + + url = "https://mbd.baidu.com/webpage" + headers = self.build_headers() + + logger.info(f"尝试第{attempt + 1}次请求,URL: {url}") + + # 准备请求参数 + request_kwargs = { + 'params': params, + 'headers': headers, + 'timeout': aiohttp.ClientTimeout(total=30) + } + + # 如果使用代理,添加代理配置(必须有代理才请求) + if self.use_proxy: + if not self.current_proxy: + raise Exception("启用了代理但当前无代理IP,拒绝使用本机IP") + logger.info(f"使用代理: {self.current_proxy}") + request_kwargs['proxy'] = self.current_proxy + + async with session.get(url, **request_kwargs) as response: + text = await response.text() + # 提取JSONP数据 + if text.startswith(callback_name + '(') and text.endswith(')'): + json_str = text[len(callback_name) + 1:-1] + data = json.loads(json_str) + + # 检查是否被反爬 + if data.get('data', {}).get('foe', {}).get('is_need_foe') == True: + logger.warning(f"检测到反爬标识(is_need_foe=True),尝试第{attempt + 1}次") + # 如果启用了代理,立即切换IP + if self.use_proxy: + logger.info("检测到反爬,立即切换代理IP(无需等待)") + self.get_proxy() + # 继续重试 + if attempt < 9: # 还有重试机会(总共10次) + continue + + return data + + except aiohttp.ClientConnectorError as e: + logger.error(f"❌ 网络连接失败 (尝试{attempt + 1}/10): {type(e).__name__} - {str(e)[:100]}") + if self.use_proxy: + logger.info("🔄 网络错误,立即切换代理IP") + self.get_proxy() + except asyncio.TimeoutError as e: + logger.error(f"❌ 请求超时 (尝试{attempt + 1}/10): 代理响应超过30秒") + if self.use_proxy: + logger.info("🔄 超时,立即切换代理IP") + self.get_proxy() + except aiohttp.ClientProxyConnectionError as e: + logger.error(f"❌ 代理连接失败 (尝试{attempt + 1}/10): {e}") + # 代理失败,立即重新获取 + if self.use_proxy: + logger.info("🔄 代理失败,立即切换代理IP(无需等待)") + self.get_proxy() + # 代理错误不需要等待,直接重试 + except aiohttp.ClientResponseError as e: + # 检查是否是407错误(代理认证失败/IP池限流) + if e.status == 407: + logger.warning(f"检测到407错误(代理IP池限流),等待10秒后重新获取IP...") + await asyncio.sleep(10) # 增加到10秒,给IP池缓冲时间 + if self.use_proxy: + logger.info("重新获取代理IP...") + self.get_proxy() + # 继续重试 + else: + logger.error(f"❌ HTTP错误 (尝试{attempt + 1}/10): {e.status}, {e.message}") + await asyncio.sleep(random.uniform(1, 2)) + except Exception as e: + logger.error(f"❌ 未知错误 (尝试{attempt + 1}/10): {type(e).__name__} - {str(e)[:100]}") + await asyncio.sleep(random.uniform(1, 2)) # 减少到1-2秒 + + # 10次重试全部失败 + logger.error("请求失败:已经重试10次仍然失败,可能是IP池限流或网络问题") + return None + + async def fetch_via_browser(self, uk: str = "ntHidnLhrlfclJar2z8wBg") -> Optional[Dict]: + """通过浏览器直接执行获取数据(最可靠的方法)""" + playwright = await async_playwright().start() + + try: + browser = await playwright.chromium.launch( + headless=False, # 调试时可设为False + args=[ + '--disable-blink-features=AutomationControlled', + '--no-sandbox' + ] + ) + + context = await browser.new_context( + viewport={'width': 1920, 'height': 1080}, + user_agent=self.ua.random, + locale='zh-CN' + ) + + page = await context.new_page() + + # 监听网络请求 + results = [] + + def handle_response(response): + if "mbd.baidu.com/webpage" in response.url and "format=jsonp" in response.url: + try: + # 尝试提取JSONP数据 + text = response.text() + if "callback=" in response.url: + # 从URL提取callback名称 + import re + match = re.search(r'callback=([^&]+)', response.url) + if match: + callback = match.group(1) + if text.startswith(callback + '(') and text.endswith(')'): + json_str = text[len(callback) + 1:-1] + data = json.loads(json_str) + results.append(data) + except: + pass + + page.on("response", handle_response) + + # 访问百家号页面 + await page.goto(f"https://baijiahao.baidu.com/u?app_id={uk}", wait_until='networkidle') + + # 模拟用户滚动 + for _ in range(3): + await page.evaluate("window.scrollBy(0, window.innerHeight)") + await asyncio.sleep(random.uniform(1, 2)) + + # 等待数据加载 + await asyncio.sleep(5) + + await browser.close() + + if results: + return results[0] + + except Exception as e: + logger.error(f"浏览器方式获取失败: {e}") + finally: + await playwright.stop() + + return None + + async def fetch_with_signature(self, uk: str = "ntHidnLhrlfclJar2z8wBg") -> Optional[Dict]: + """尝试使用签名参数请求""" + # 百度接口可能需要特定的签名参数 + # 这里需要分析JavaScript找到签名算法 + + async with aiohttp.ClientSession() as session: + # 先获取必要的token + token_url = "https://mbd.baidu.com/staticx/search/dynamic/config" + + headers = { + 'User-Agent': self.ua.random, + 'Referer': 'https://baijiahao.baidu.com/', + } + + try: + # 获取配置信息 + async with session.get(token_url, headers=headers) as resp: + config_text = await resp.text() + logger.info(f"配置响应: {config_text[:200]}") + + # 构建完整请求 + timestamp = int(time.time() * 1000) + params = { + 'tab': 'main', + 'num': '10', + 'uk': uk, + 'source': 'pc', + 'type': 'newhome', + 'action': 'dynamic', + 'format': 'json', + 't': str(timestamp), + 'callback': f'__jsonp{timestamp}', + } + + # 尝试JSON格式(非JSONP) + params['format'] = 'json' + del params['callback'] + + url = "https://mbd.baidu.com/webpage" + + async with session.get(url, params=params, headers=headers) as response: + text = await response.text() + logger.info(f"JSON响应: {text[:500]}") + + try: + return json.loads(text) + except: + return None + + except Exception as e: + logger.error(f"签名方式失败: {e}") + return None + + +async def fetch_baidu_data(uk: str = "ntHidnLhrlfclJar2z8wBg", months: int = 6, use_proxy: bool = False, proxy_api_url: str = None, + on_page_fetched=None, start_page: int = 1, start_ctime: str = None) -> Optional[Dict]: + """获取百家号数据的主函数 + + Args: + uk: 作者UK + months: 获取近几个月的数据,默认6个月(支持小数,如0.33代衡10天) + use_proxy: 是否启用代理IP池 + proxy_api_url: 代理API地址,留空使用默认 + on_page_fetched: 回调函数,每页数据抽取后调用,signature: (page, items, ctime) -> None + start_page: 起始页码(断点续传) + start_ctime: 起始分页参数(断点续传) + """ + from datetime import datetime, timedelta + import re + + spider = BaiduBJHSpider(use_proxy=use_proxy, proxy_api_url=proxy_api_url) + + # 计算目标日期(支持小数月份) + days = int(months * 30) + target_date = datetime.now() - timedelta(days=days) + + # 日志输出优化 + if months < 1: + logger.info(f"开始获取百家号数据(近{days}天, 目标日期: {target_date.strftime('%Y-%m-%d')})") + else: + logger.info(f"开始获取百家号数据(近{int(months)}个月, 目标日期: {target_date.strftime('%Y-%m-%d')})") + + # 先获取第一页数据(每次固定10条) + # 注意:不再使用 all_articles 累加,每页直接通过回调保存 + page = start_page # 支持从指定页码开始 + current_ctime = start_ctime # 支持使用之前的分页参数 + + # 如果是断点续传,直接跳过第一页,使用保存的ctime + if start_page > 1 and start_ctime: + logger.info(f"断点续传:从第{start_page}页开始,ctime={start_ctime}") + data = None # 不需要第一页的data结构 + else: + # 优化: 直接请求API,只有失败时才启动浏览器 + logger.info("尝试直接请求API(不启动浏览器)...") + data = await spider.fetch_data_directly(uk, use_browser=False, ctime=current_ctime) + + # 如果第一次失败,再启动浏览器重试 + if not data or not data.get('data', {}).get('list'): + if start_page == 1: # 只有第一页才需要浏览器重试 + logger.warning("直接请求失败,启动浏览器获取Cookie...") + # 打印第一次请求的返回数据 + if data: + logger.warning(f"第一次请求返回数据: {json.dumps(data, ensure_ascii=False, indent=2)}") + else: + logger.warning("第一次请求返回数据: None") + + data = await spider.fetch_data_directly(uk, use_browser=True) + + if not data or not data.get('data', {}).get('list'): + logger.error("启动浏览器后仍然失败,放弃") + # 打印最终的返回数据 + if data: + logger.error(f"最终返回数据: {json.dumps(data, ensure_ascii=False, indent=2)}") + else: + logger.error("最终返回数据: None") + return None + + # 第一次请求成功,处理数据(只有非断点续传时) + if data and data.get('data', {}).get('list'): + items = data.get('data', {}).get('list', []) + logger.info(f"第{page}页获取成功,数据条数: {len(items)}") + + # 调用回调保存第一页数据 + if on_page_fetched: + on_page_fetched(page, items, current_ctime) + + # 提取第一页的ctime用于分页 - 注意路径是 data.data.query.ctime + current_ctime = data.get('data', {}).get('query', {}).get('ctime', current_ctime) + if current_ctime: + logger.info(f"获取到分页参数 ctime={current_ctime}") + else: + logger.warning("未获取到ctime分页参数") + + # 使用ctime(Unix时间戳)进行时间判断,更准确 + def get_article_datetime(item_data: dict) -> datetime: + """从ittemData中提取文章时间 + + 优先使用ctime(Unix时间戳),更准确 + """ + # 优先使用ctime(秒级Unix时间戳) + if 'ctime' in item_data and item_data['ctime']: + try: + timestamp = int(item_data['ctime']) + return datetime.fromtimestamp(timestamp) + except: + pass + + # 备用: 使用time字段(相对时间或绝对时间) + time_str = item_data.get('time', '') + if not time_str: + return datetime.now() + + now = datetime.now() + if '分钟前' in time_str: + minutes = int(re.search(r'(\d+)', time_str).group(1)) + return now - timedelta(minutes=minutes) + elif '小时前' in time_str: + hours = int(re.search(r'(\d+)', time_str).group(1)) + return now - timedelta(hours=hours) + elif '天前' in time_str or '昨天' in time_str: + if '昨天' in time_str: + days = 1 + else: + days = int(re.search(r'(\d+)', time_str).group(1)) + return now - timedelta(days=days) + elif '-' in time_str: # 绝对时间格式 + try: + return datetime.strptime(time_str, '%Y-%m-%d %H:%M') + except: + try: + return datetime.strptime(time_str, '%Y-%m-%d') + except: + return now + return now + + # 检查最后一篇文章的时间,判断是否需要继续请求 + need_more = True + if data and data.get('data', {}).get('list'): + items = data.get('data', {}).get('list', []) + if items: + last_item = items[-1] + item_data = last_item.get('itemData', {}) + article_date = get_article_datetime(item_data) + logger.info(f"最后一篇文章时间: {article_date.strftime('%Y-%m-%d %H:%M:%S')}") + if article_date < target_date: + need_more = False + if months < 1: + logger.info( + f"最后一篇文章时间: {article_date.strftime('%Y-%m-%d %H:%M:%S')}, 已超出{days}天范围,停止请求") + else: + logger.info( + f"最后一篇文章时间: {article_date.strftime('%Y-%m-%d %H:%M:%S')}, 已超出{int(months)}个月范围,停止请求") + else: + need_more = False + elif start_page > 1: + # 断点续传时,默认需要继续 + need_more = True + else: + need_more = False + + # 循环请求后续页面,直到达到目标日期或无数据(不限制页数) + while need_more: + page += 1 + logger.info(f"需要更多数据,请求第{page}页...") + + # 优化:使用随机延迟8-12秒,避免被识别为机器行为 + delay = random.uniform(8, 12) + logger.info(f"等待 {delay:.1f} 秒后请求...") + await asyncio.sleep(delay) + + # 继续请求(使用上一次返回的ctime作为分页参数) + next_data = await spider.fetch_data_directly(uk, use_browser=False, ctime=current_ctime) + + # fetch_data_directly已经处理了反爬检测和重试,这里只需检查是否成功获取数据 + if not next_data or not next_data.get('data', {}).get('list'): + # 如果还是失败,检查是否因为反爬 + if next_data and next_data.get('data', {}).get('foe', {}).get('is_need_foe') == True: + logger.error(f"第{page}页多次重试后仍然触发反爬,停止请求") + logger.error(f"返回数据: {json.dumps(next_data, ensure_ascii=False, indent=2)}") + else: + logger.warning(f"第{page}页无数据,停止请求") + # 打印完整的返回结果以便调试 + if next_data: + logger.warning(f"返回数据: {json.dumps(next_data, ensure_ascii=False, indent=2)}") + else: + logger.warning("返回数据: None") + break + + next_items = next_data.get('data', {}).get('list', []) + logger.info(f"第{page}页获取成功,数据条数: {len(next_items)}") + + # 调用回调保存这一页数据 + if on_page_fetched: + on_page_fetched(page, next_items, current_ctime) + + # 更新ctime为下一次请求做准备 - 注意路径是 data.data.query.ctime + current_ctime = next_data.get('data', {}).get('query', {}).get('ctime', current_ctime) + if current_ctime: + logger.info(f"更新分页参数 ctime={current_ctime}") + + # 检查最后一篇文章的时间 + if next_items: + last_item = next_items[-1] + item_data = last_item.get('itemData', {}) + article_date = get_article_datetime(item_data) + logger.info(f"最后一篇文章时间: {article_date.strftime('%Y-%m-%d %H:%M:%S')}") + + if article_date < target_date: + need_more = False + if months < 1: + logger.info( + f"最后一篇文章时间: {article_date.strftime('%Y-%m-%d %H:%M:%S')}, 已超出{days}天范围,停止请求") + else: + logger.info( + f"最后一篇文章时间: {article_date.strftime('%Y-%m-%d %H:%M:%S')}, 已超出{int(months)}个月范围,停止请求") + else: + need_more = False + + # 返回最后的分页信息(用于断点续传) + result = { + 'last_page': page, + 'last_ctime': current_ctime, + 'completed': not need_more # 是否已完成 + } + + logger.info(f"抓取完成,最后页码: {page}, ctime: {current_ctime}") + return result + + +# 同步包装函数(便于在同步代码中调用) +def get_baidu_data_sync(uk: str = "ntHidnLhrlfclJar2z8wBg", months: int = 6, use_proxy: bool = False, + proxy_api_url: str = None, on_page_fetched=None, + start_page: int = 1, start_ctime: str = None) -> Optional[Dict]: + """同步方式获取数据 + + Args: + uk: 作者UK + months: 获取近几个月的数据,默认6个月 + use_proxy: 是否启用代理IP池 + proxy_api_url: 代理API地址,留空使用默认 + on_page_fetched: 回调函数,每页数据抽取后调用 + start_page: 起始页码(断点续传) + start_ctime: 起始分页参数(断点续传) + """ + return asyncio.run(fetch_baidu_data(uk, months, use_proxy, proxy_api_url, + on_page_fetched, start_page, start_ctime)) + + +# 保留原有的main函数用于测试 +async def main(): + data = await fetch_baidu_data() + if data: + print(json.dumps(data, ensure_ascii=False, indent=2)) + from test2 import display_simple_data + display_simple_data(data) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/check_taskworker.py b/check_taskworker.py new file mode 100644 index 0000000..ffa416d --- /dev/null +++ b/check_taskworker.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +TaskWorker 状态检查和修复工具 +用于诊断和解决任务卡在等待中的问题 +""" + +import os +import sys +import logging +import psutil +import time + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s' +) +logger = logging.getLogger(__name__) + + +def check_taskworker_lock(): + """检查 TaskWorker 锁文件""" + lock_file = 'data/taskworker.lock' + + if os.path.exists(lock_file): + try: + with open(lock_file, 'r') as f: + pid = f.read().strip() + + logger.info(f"发现锁文件,记录的PID: {pid}") + + # 检查进程是否存在 + try: + pid_int = int(pid) + if psutil.pid_exists(pid_int): + proc = psutil.Process(pid_int) + logger.info(f"进程 {pid} 存在: {proc.name()} - {proc.status()}") + return True, pid_int + else: + logger.warning(f"进程 {pid} 不存在,锁文件已失效") + return False, None + except ValueError: + logger.error(f"锁文件内容无效: {pid}") + return False, None + except Exception as e: + logger.error(f"读取锁文件失败: {e}") + return False, None + else: + logger.info("未发现锁文件") + return False, None + + +def check_pending_tasks(): + """检查等待中的任务数量""" + try: + from task_queue import get_task_queue + queue = get_task_queue() + tasks = queue.get_all_tasks() + + pending_tasks = [t for t in tasks if t.get('status') == 'pending'] + processing_tasks = [t for t in tasks if t.get('status') == 'processing'] + + logger.info(f"待处理任务: {len(pending_tasks)} 个") + logger.info(f"处理中任务: {len(processing_tasks)} 个") + + if pending_tasks: + logger.info("待处理任务列表:") + for task in pending_tasks[:5]: # 只显示前5个 + logger.info(f" - {task['task_id']}: {task.get('url', 'N/A')[:50]}") + + return len(pending_tasks), len(processing_tasks) + except Exception as e: + logger.error(f"检查任务失败: {e}") + return 0, 0 + + +def check_worker_threads(): + """检查 TaskWorker 线程是否运行""" + try: + from task_worker import get_task_worker + worker = get_task_worker() + + logger.info(f"TaskWorker 运行状态: {worker.running}") + logger.info(f"当前并发数: {worker.current_workers}/{worker.max_workers}") + logger.info(f"工作线程数: {len(worker.worker_threads)}") + logger.info(f"正在处理的任务: {len(worker.processing_tasks)}") + + # 检查线程是否活跃 + alive_threads = sum(1 for t in worker.worker_threads if t and t.is_alive()) + logger.info(f"活跃线程数: {alive_threads}") + + return worker.running, alive_threads + except Exception as e: + logger.error(f"检查 TaskWorker 失败: {e}") + import traceback + logger.error(traceback.format_exc()) + return False, 0 + + +def restart_taskworker(): + """重启 TaskWorker""" + logger.info("正在重启 TaskWorker...") + + try: + from task_worker import get_task_worker + worker = get_task_worker() + + # 停止现有 worker + if worker.running: + logger.info("停止现有 TaskWorker...") + worker.stop() + time.sleep(2) + + # 启动新的 worker + logger.info("启动新的 TaskWorker...") + worker.start() + time.sleep(1) + + # 验证启动状态 + running, alive_threads = check_worker_threads() + if running and alive_threads > 0: + logger.info("✅ TaskWorker 重启成功") + return True + else: + logger.error("❌ TaskWorker 重启失败") + return False + except Exception as e: + logger.error(f"重启 TaskWorker 失败: {e}") + import traceback + logger.error(traceback.format_exc()) + return False + + +def clean_stale_lock(): + """清理失效的锁文件""" + lock_file = 'data/taskworker.lock' + + if os.path.exists(lock_file): + try: + os.remove(lock_file) + logger.info("✅ 已清理失效的锁文件") + return True + except Exception as e: + logger.error(f"清理锁文件失败: {e}") + return False + return True + + +def main(): + """主函数""" + print("=" * 60) + print("TaskWorker 状态检查工具") + print("=" * 60) + + # 1. 检查锁文件 + print("\n[1] 检查锁文件...") + lock_exists, lock_pid = check_taskworker_lock() + + # 2. 检查待处理任务 + print("\n[2] 检查任务队列...") + pending_count, processing_count = check_pending_tasks() + + # 3. 检查 Worker 线程 + print("\n[3] 检查 TaskWorker 状态...") + try: + is_running, alive_threads = check_worker_threads() + except: + is_running, alive_threads = False, 0 + + # 4. 诊断和修复 + print("\n[4] 诊断结果:") + print("-" * 60) + + need_fix = False + + if pending_count > 0 and alive_threads == 0: + print("❌ 问题: 有待处理任务,但没有活跃的工作线程") + need_fix = True + + if lock_exists and not lock_pid: + print("⚠️ 警告: 锁文件存在但进程不存在(僵尸锁)") + need_fix = True + + if not is_running: + print("❌ 问题: TaskWorker 未运行") + need_fix = True + + if not need_fix: + print("✅ TaskWorker 运行正常") + return + + # 5. 修复 + print("\n[5] 开始修复...") + print("-" * 60) + + if '--fix' in sys.argv or '--auto-fix' in sys.argv: + # 清理失效的锁文件 + clean_stale_lock() + + # 重启 TaskWorker + if restart_taskworker(): + print("\n✅ 修复完成!") + print("\n重新检查状态...") + time.sleep(2) + check_worker_threads() + check_pending_tasks() + else: + print("\n❌ 修复失败,请手动重启服务") + else: + print("\n提示: 使用 --fix 参数自动修复问题") + print("示例: python check_taskworker.py --fix") + + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + print("\n\n用户中断") + except Exception as e: + logger.error(f"执行失败: {e}") + import traceback + traceback.print_exc() diff --git a/database.py b/database.py new file mode 100644 index 0000000..1df646a --- /dev/null +++ b/database.py @@ -0,0 +1,413 @@ +# -*- coding: utf-8 -*- +""" +SQLite 数据库管理模块 +用于替换原有的 JSON 文件存储方式 +""" +import sqlite3 +import os +import logging +from datetime import datetime +from contextlib import contextmanager +import threading + +logger = logging.getLogger(__name__) + + +class Database: + """SQLite 数据库管理器""" + + def __init__(self, db_path="data/baijiahao.db"): + self.db_path = db_path + self._local = threading.local() + self._ensure_database() + + def _ensure_database(self): + """确保数据库文件和表结构存在""" + os.makedirs(os.path.dirname(self.db_path), exist_ok=True) + + with self.get_connection() as conn: + cursor = conn.cursor() + + # 创建任务表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS tasks ( + task_id TEXT PRIMARY KEY, + url TEXT NOT NULL, + months REAL NOT NULL, + use_proxy INTEGER NOT NULL, + proxy_api_url TEXT, + username TEXT, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + paused_at TEXT, + progress INTEGER DEFAULT 0, + current_step TEXT, + total_articles INTEGER DEFAULT 0, + processed_articles INTEGER DEFAULT 0, + error TEXT, + result_file TEXT, + retry_count INTEGER DEFAULT 0, + last_error TEXT, + articles_only INTEGER DEFAULT 1, + last_page INTEGER DEFAULT 0, + last_ctime TEXT + ) + ''') + + # 创建任务日志表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS task_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + timestamp TEXT NOT NULL, + message TEXT NOT NULL, + level TEXT DEFAULT 'info', + FOREIGN KEY (task_id) REFERENCES tasks(task_id) ON DELETE CASCADE + ) + ''') + + # 创建文章缓存表(用于断点续传) + cursor.execute(''' + CREATE TABLE IF NOT EXISTS article_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + title TEXT NOT NULL, + url TEXT, + publish_time TEXT, + page_num INTEGER, + created_at TEXT NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(task_id) ON DELETE CASCADE + ) + ''') + + # 创建索引提升查询性能 + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_tasks_status + ON tasks(status) + ''') + + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_tasks_username + ON tasks(username) + ''') + + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_tasks_created_at + ON tasks(created_at DESC) + ''') + + # 为日志表创建索引 + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_task_logs_task_id + ON task_logs(task_id) + ''') + + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_task_logs_timestamp + ON task_logs(timestamp) + ''') + + # 为文章缓存表创建索引 + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_article_cache_task_id + ON article_cache(task_id) + ''') + + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_article_cache_page + ON article_cache(task_id, page_num) + ''') + + conn.commit() + logger.info(f"数据库初始化完成: {self.db_path}") + + @contextmanager + def get_connection(self): + """获取线程安全的数据库连接(上下文管理器)""" + if not hasattr(self._local, 'conn') or self._local.conn is None: + self._local.conn = sqlite3.connect( + self.db_path, + check_same_thread=False, + timeout=30.0 + ) + # 设置返回字典而不是元组 + self._local.conn.row_factory = sqlite3.Row + + try: + yield self._local.conn + except Exception as e: + self._local.conn.rollback() + logger.error(f"数据库操作失败: {e}") + raise + + def close(self): + """关闭当前线程的数据库连接""" + if hasattr(self._local, 'conn') and self._local.conn is not None: + self._local.conn.close() + self._local.conn = None + + def add_task_log(self, task_id, message, level='info', timestamp=None): + """添加任务日志 + + Args: + task_id: 任务ID + message: 日志消息 + level: 日志级别 (info/success/warning/error) + timestamp: 时间戳,默认为当前时间 + """ + if timestamp is None: + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO task_logs (task_id, timestamp, message, level) + VALUES (?, ?, ?, ?) + ''', (task_id, timestamp, message, level)) + conn.commit() + + def get_task_logs(self, task_id, limit=None): + """获取任务的所有日志 + + Args: + task_id: 任务ID + limit: 限制返回数量,默认返回所有 + + Returns: + list: 日志列表,按时间顺序 + """ + with self.get_connection() as conn: + cursor = conn.cursor() + + if limit: + cursor.execute(''' + SELECT task_id, timestamp, message, level + FROM task_logs + WHERE task_id = ? + ORDER BY id ASC + LIMIT ? + ''', (task_id, limit)) + else: + cursor.execute(''' + SELECT task_id, timestamp, message, level + FROM task_logs + WHERE task_id = ? + ORDER BY id ASC + ''', (task_id,)) + + rows = cursor.fetchall() + return [dict(row) for row in rows] + + def clear_task_logs(self, task_id): + """清除任务的所有日志 + + Args: + task_id: 任务ID + """ + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM task_logs WHERE task_id = ?', (task_id,)) + conn.commit() + + def save_articles_batch(self, task_id, articles, page_num): + """批量保存文章到缓存表 + + Args: + task_id: 任务ID + articles: 文章列表 [{'title': ..., 'url': ..., 'publish_time': ...}, ...] + page_num: 页码 + """ + if not articles: + return + + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + with self.get_connection() as conn: + cursor = conn.cursor() + + # 批量插入 + data = [ + (task_id, + article.get('标题', article.get('title', '')), + article.get('链接', article.get('url', '')), + article.get('发布时间', article.get('publish_time', '')), + page_num, + timestamp) + for article in articles + ] + + cursor.executemany(''' + INSERT INTO article_cache (task_id, title, url, publish_time, page_num, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ''', data) + + conn.commit() + logger.debug(f"保存 {len(articles)} 篇文章到缓存(任务{task_id},第{page_num}页)") + + def get_cached_articles(self, task_id): + """获取任务的所有缓存文章 + + Args: + task_id: 任务ID + + Returns: + list: 文章列表 + """ + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT title, url, publish_time, page_num + FROM article_cache + WHERE task_id = ? + ORDER BY id ASC + ''', (task_id,)) + + rows = cursor.fetchall() + return [ + { + '标题': row['title'], + '链接': row['url'], + '发布时间': row['publish_time'], + 'page_num': row['page_num'] + } + for row in rows + ] + + def get_cached_article_count(self, task_id): + """获取任务已缓存的文章数量 + + Args: + task_id: 任务ID + + Returns: + int: 文章数量 + """ + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + SELECT COUNT(*) as count FROM article_cache WHERE task_id = ? + ''', (task_id,)) + + result = cursor.fetchone() + return result['count'] if result else 0 + + def clear_article_cache(self, task_id): + """清除任务的所有缓存文章 + + Args: + task_id: 任务ID + """ + with self.get_connection() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM article_cache WHERE task_id = ?', (task_id,)) + conn.commit() + logger.debug(f"清除任务 {task_id} 的所有缓存文章") + + +# 全局数据库实例 +_db_instance = None +_db_lock = threading.Lock() + + +def get_database(): + """获取全局数据库实例(单例模式)""" + global _db_instance + if _db_instance is None: + with _db_lock: + if _db_instance is None: + _db_instance = Database() + return _db_instance + + +def migrate_from_json(json_file="data/task_queue.json"): + """从 JSON 文件迁移数据到 SQLite 数据库 + + Args: + json_file: 原 JSON 文件路径 + + Returns: + migrated_count: 成功迁移的任务数量 + """ + import json + + if not os.path.exists(json_file): + logger.info("未找到旧的 JSON 文件,跳过数据迁移") + return 0 + + try: + # 读取 JSON 数据 + with open(json_file, 'r', encoding='utf-8') as f: + tasks = json.load(f) + + if not tasks: + logger.info("JSON 文件中没有任务数据") + return 0 + + db = get_database() + migrated_count = 0 + + with db.get_connection() as conn: + cursor = conn.cursor() + + for task in tasks: + try: + # 检查任务是否已存在 + cursor.execute( + "SELECT task_id FROM tasks WHERE task_id = ?", + (task["task_id"],) + ) + + if cursor.fetchone(): + logger.debug(f"任务 {task['task_id']} 已存在,跳过") + continue + + # 插入任务数据 + cursor.execute(''' + INSERT INTO tasks ( + task_id, url, months, use_proxy, proxy_api_url, + username, status, created_at, started_at, completed_at, + progress, current_step, total_articles, processed_articles, + error, result_file + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + task["task_id"], + task["url"], + task["months"], + 1 if task["use_proxy"] else 0, + task.get("proxy_api_url"), + task.get("username"), + task["status"], + task["created_at"], + task.get("started_at"), + task.get("completed_at"), + task.get("progress", 0), + task.get("current_step"), + task.get("total_articles", 0), + task.get("processed_articles", 0), + task.get("error"), + task.get("result_file") + )) + + migrated_count += 1 + + except Exception as e: + logger.error(f"迁移任务 {task.get('task_id')} 失败: {e}") + + conn.commit() + + logger.info(f"成功迁移 {migrated_count} 个任务到数据库") + + # 备份原 JSON 文件 + backup_file = json_file + ".backup" + if migrated_count > 0: + import shutil + shutil.copy2(json_file, backup_file) + logger.info(f"原 JSON 文件已备份到: {backup_file}") + + return migrated_count + + except Exception as e: + logger.error(f"数据迁移失败: {e}") + return 0 diff --git a/demo_python.py b/demo_python.py new file mode 100644 index 0000000..856334d --- /dev/null +++ b/demo_python.py @@ -0,0 +1,43 @@ +import requests +import time +if __name__ == '__main__': + # 客户ip提取链接,每次提取1个,提取链接可以换成自己购买的 + url = 'http://api.tianqiip.com/getip?secret=lu29e593&num=1&type=txt&port=1&mr=1&sign=4b81a62eaed89ba802a8f34053e2c964' + # 访问的目标地址 + targeturl = 'https://mbd.baidu.com/webpage?tab=main&num=10&uk=ntHidnLhrlfclJar2z8wBg&source=pc&type=newhome&action=dynamic&format=jsonp&otherext=h5_20251126173230&Tenger-Mhor=3659421940&callback=__jsonp01765201579331' + response = requests.get(url) + content =response.content.decode("utf-8").strip() + print('提取IP:' + content) + nowtime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + print('提取IP时间:' + nowtime) + sj = content.strip().split(":", 1) + sj1 = sj[0] + print("IP:", sj1) + sj2 = sj[1] + print("端口:", sj2) + try: + #proxyMeta = "http://nfd0p2:bHQAp5iW@%(host)s:%(port)s" % { # 账密验证,需要购买的代理套餐开通才可使用账密验证,此种情况无需加白名单 + proxyMeta = f"http://{sj1}:{sj2}" + + print("代理1:", proxyMeta) + proxysdata = { + 'http': proxyMeta, + 'https': proxyMeta + } + print("代理2:", proxysdata) + headers = { + "user-agent": 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.' + } + start = int(round(time.time() * 1000)) + resp = requests.get(targeturl, proxies=proxysdata, headers=headers, timeout=20) + costTime = int(round(time.time() * 1000)) - start + print("耗时:" + str(costTime) + "ms") + print("返回:",resp.text) + s = requests.session() + s.keep_alive = False + except Exception as e: + print("异常:",e) + + + + diff --git a/fix_taskworker.sh b/fix_taskworker.sh new file mode 100644 index 0000000..f3b8819 --- /dev/null +++ b/fix_taskworker.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# 快速诊断和修复脚本 + +echo "======================================" +echo " TaskWorker 快速诊断和修复工具" +echo "======================================" +echo "" + +# 检查 Python 环境 +if ! command -v python3 &> /dev/null; then + echo "❌ Python3 未找到" + exit 1 +fi + +# 进入脚本所在目录 +cd "$(dirname "$0")" + +echo "[1] 检查 TaskWorker 状态..." +python3 check_taskworker.py + +echo "" +read -p "是否需要修复? (y/n): " -n 1 -r +echo "" + +if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "" + echo "[2] 正在修复..." + python3 check_taskworker.py --fix + + echo "" + echo "[3] 重新检查状态..." + sleep 2 + python3 check_taskworker.py + + echo "" + echo "✅ 修复完成!" +else + echo "" + echo "已取消修复" +fi + +echo "" +echo "======================================" diff --git a/gunicorn_config.py b/gunicorn_config.py new file mode 100644 index 0000000..19ec8c6 --- /dev/null +++ b/gunicorn_config.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +""" +Gunicorn 配置文件 +""" + +import multiprocessing +import os + +# 服务器绑定地址 +bind = "0.0.0.0:8030" + +# 工作进程数(建议:CPU核心数 * 2 + 1) +workers = multiprocessing.cpu_count() * 2 + 1 + +# 工作模式(gevent 适合 I/O 密集型应用,如爬虫) +# 需要安装: pip install gevent +# worker_class = 'gevent' + +# 或使用线程模式(适合任务队列) +worker_class = 'gthread' +threads = 2 + +# 最大并发请求数 +worker_connections = 1000 + +# 工作进程超时时间(秒) +timeout = 300 + +# 优雅重启超时时间 +graceful_timeout = 30 + +# Keep-alive 时间 +keepalive = 5 + +# 守护进程模式(后台运行) +# 注意:调试时可以设置为False查看详细日志 +daemon = False + +# 进程 PID 文件 +pidfile = 'gunicorn.pid' + +# 日志配置 +accesslog = 'logs/gunicorn_access.log' +errorlog = 'logs/gunicorn_error.log' +loglevel = 'info' + +# 访问日志格式 +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s' + +# 进程名称 +proc_name = 'baijiahao_scraper' + +# 最大请求数(防止内存泄漏) +max_requests = 1000 +max_requests_jitter = 50 + +# 预加载应用(节省内存) +# 注意:由于TaskWorker需要在worker进程中启动,设置为False +preload_app = False + +# 环境变量 +raw_env = [ + 'FLASK_ENV=production', +] + +# 工作进程启动时的回调 +def on_starting(server): + """服务器启动时""" + import os + print("=" * 50) + print("Gunicorn 服务启动中...") + print(f"绑定地址: {bind}") + print(f"工作进程数: {workers}") + print(f"工作模式: {worker_class}") + + # 清理旧的TaskWorker锁文件 + lock_file = 'data/taskworker.lock' + if os.path.exists(lock_file): + try: + os.remove(lock_file) + print("✓ 已清理旧的TaskWorker锁文件") + except: + pass + + print("=" * 50) + +def when_ready(server): + """服务器就绪时""" + print("✓ 服务器已就绪,可以接受请求") + +def post_worker_init(worker): + """worker进程初始化后的钩子 - 只在第一个worker中启动TaskWorker""" + import os + import sys + import logging + import time + import fcntl # 用于文件锁 + + # 设置日志,直接输出到gunicorn error log + logger = logging.getLogger('gunicorn.error') + + # 创建必要的目录 + os.makedirs('exports', exist_ok=True) + os.makedirs('data', exist_ok=True) + os.makedirs('data/results', exist_ok=True) + os.makedirs('logs', exist_ok=True) + + # 使用文件锁确保只有一个worker启动TaskWorker + lock_file_path = 'data/taskworker.lock' + lock_file = None + + try: + # 打开锁文件(不存在则创建) + lock_file = open(lock_file_path, 'w') + + # 尝试获取排他锁(非阻塞) + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + # 成功获得锁,启动TaskWorker + logger.info(f"[Worker {worker.pid}] 获得锁,准备启动TaskWorker...") + lock_file.write(str(worker.pid)) + lock_file.flush() + + try: + from task_worker import start_task_worker, get_task_worker + start_task_worker() + + # 验证启动状态 + time.sleep(1) + task_worker = get_task_worker() + if task_worker.running: + logger.info(f"[Worker {worker.pid}] ✅ TaskWorker已成功启动(主 worker)") + logger.info(f"[Worker {worker.pid}] 并发数: {task_worker.current_workers}/{task_worker.max_workers}") + else: + logger.error(f"[Worker {worker.pid}] ⚠️ TaskWorker启动后未运行") + + except Exception as e: + logger.error(f"[Worker {worker.pid}] TaskWorker启动失败: {e}") + import traceback + logger.error(traceback.format_exc()) + # 释放锁 + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + lock_file.close() + + except IOError: + # 锁已被其他进程持有 + logger.info(f"[Worker {worker.pid}] 跳过TaskWorker启动(其他worker已启动)") + lock_file.close() + + except Exception as e: + logger.error(f"[Worker {worker.pid}] TaskWorker启动异常: {e}") + import traceback + logger.error(traceback.format_exc()) + if lock_file: + lock_file.close() + +def on_exit(server): + """服务器退出时""" + import os + # 清理TaskWorker锁文件 + lock_file = 'data/taskworker.lock' + if os.path.exists(lock_file): + try: + os.remove(lock_file) + except: + pass + print("✓ Gunicorn 服务已停止") diff --git a/install_service.sh b/install_service.sh new file mode 100644 index 0000000..b6dc9b9 --- /dev/null +++ b/install_service.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +############################################################################### +# systemd 服务安装脚本 +############################################################################### + +# 颜色定义 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +echo -e "${YELLOW}=========================================${NC}" +echo -e "${YELLOW} 安装 systemd 服务${NC}" +echo -e "${YELLOW}=========================================${NC}" +echo "" + +# 检查是否为root +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}请使用 sudo 运行此脚本${NC}" + exit 1 +fi + +PROJECT_DIR="/var/www/ai_baijiahao" +SERVICE_FILE="baijiahao.service" + +# 复制服务文件 +echo "复制服务文件到 /etc/systemd/system/..." +cp ${PROJECT_DIR}/${SERVICE_FILE} /etc/systemd/system/ + +# 重载 systemd +echo "重载 systemd 配置..." +systemctl daemon-reload + +# 启用服务(开机自启) +echo "启用服务开机自启..." +systemctl enable baijiahao + +echo "" +echo -e "${GREEN}=========================================${NC}" +echo -e "${GREEN} 服务安装完成!${NC}" +echo -e "${GREEN}=========================================${NC}" +echo "" +echo "常用命令:" +echo -e " 启动服务: ${YELLOW}sudo systemctl start baijiahao${NC}" +echo -e " 停止服务: ${YELLOW}sudo systemctl stop baijiahao${NC}" +echo -e " 重启服务: ${YELLOW}sudo systemctl restart baijiahao${NC}" +echo -e " 查看状态: ${YELLOW}sudo systemctl status baijiahao${NC}" +echo -e " 查看日志: ${YELLOW}sudo journalctl -u baijiahao -f${NC}" +echo -e " 禁用自启: ${YELLOW}sudo systemctl disable baijiahao${NC}" +echo "" diff --git a/migrate_database.py b/migrate_database.py new file mode 100644 index 0000000..a7c0775 --- /dev/null +++ b/migrate_database.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +数据库迁移脚本 +为tasks表添加新字段:paused_at, retry_count, last_error, articles_only +""" +import sqlite3 +import os +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def migrate_database(): + """执行数据库迁移""" + db_path = "data/baijiahao.db" + + if not os.path.exists(db_path): + logger.info("数据库文件不存在,无需迁移") + return + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # 检查表是否存在 + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'") + if not cursor.fetchone(): + logger.info("tasks表不存在,无需迁移") + conn.close() + return + + # 获取当前表结构 + cursor.execute("PRAGMA table_info(tasks)") + columns = {row[1]: row for row in cursor.fetchall()} + + logger.info("开始数据库迁移...") + + # 添加 paused_at 字段 + if 'paused_at' not in columns: + logger.info("添加 paused_at 字段...") + cursor.execute("ALTER TABLE tasks ADD COLUMN paused_at TEXT") + logger.info("✓ paused_at 字段添加成功") + else: + logger.info("✓ paused_at 字段已存在") + + # 添加 retry_count 字段 + if 'retry_count' not in columns: + logger.info("添加 retry_count 字段...") + cursor.execute("ALTER TABLE tasks ADD COLUMN retry_count INTEGER DEFAULT 0") + logger.info("✓ retry_count 字段添加成功") + else: + logger.info("✓ retry_count 字段已存在") + + # 添加 last_error 字段 + if 'last_error' not in columns: + logger.info("添加 last_error 字段...") + cursor.execute("ALTER TABLE tasks ADD COLUMN last_error TEXT") + logger.info("✓ last_error 字段添加成功") + else: + logger.info("✓ last_error 字段已存在") + + # 添加 articles_only 字段 + if 'articles_only' not in columns: + logger.info("添加 articles_only 字段...") + cursor.execute("ALTER TABLE tasks ADD COLUMN articles_only INTEGER DEFAULT 1") + logger.info("✓ articles_only 字段添加成功") + else: + logger.info("✓ articles_only 字段已存在") + + conn.commit() + conn.close() + + logger.info("=" * 50) + logger.info("✅ 数据库迁移完成!") + logger.info("=" * 50) + + except Exception as e: + logger.error(f"❌ 数据库迁移失败: {e}") + import traceback + traceback.print_exc() + raise + +if __name__ == "__main__": + migrate_database() diff --git a/migrate_database_v2.py b/migrate_database_v2.py new file mode 100644 index 0000000..aa62fcb --- /dev/null +++ b/migrate_database_v2.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +""" +数据库迁移脚本 V2 +添加断点续传支持字段 +""" +import sqlite3 +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def migrate_database(): + """执行数据库迁移""" + db_path = "data/baijiahao.db" + + logger.info("=" * 50) + logger.info("开始数据库迁移 V2...") + logger.info("=" * 50) + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + # 获取当前表结构 + cursor.execute("PRAGMA table_info(tasks)") + columns = {row[1]: row for row in cursor.fetchall()} + + # 添加 last_page 字段(最后抓取的页码) + if 'last_page' not in columns: + logger.info("添加 last_page 字段...") + cursor.execute("ALTER TABLE tasks ADD COLUMN last_page INTEGER DEFAULT 0") + logger.info("✓ last_page 字段添加成功") + else: + logger.info("✓ last_page 字段已存在") + + # 添加 last_ctime 字段(最后的分页参数) + if 'last_ctime' not in columns: + logger.info("添加 last_ctime 字段...") + cursor.execute("ALTER TABLE tasks ADD COLUMN last_ctime TEXT") + logger.info("✓ last_ctime 字段添加成功") + else: + logger.info("✓ last_ctime 字段已存在") + + conn.commit() + logger.info("=" * 50) + logger.info("✅ 数据库迁移 V2 完成!") + logger.info("=" * 50) + + except Exception as e: + logger.error(f"❌ 迁移失败: {e}") + conn.rollback() + raise + finally: + conn.close() + + +if __name__ == "__main__": + migrate_database() diff --git a/remove_selenium.py b/remove_selenium.py new file mode 100644 index 0000000..6c50e64 --- /dev/null +++ b/remove_selenium.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# 移除 app.py 中的 Selenium 相关代码 + +with open('app.py', 'r', encoding='utf-8') as f: + lines = f.readlines() + +# 定义要删除的行范围(包含开始和结束) +# get_articles_with_selenium_api: 451-665 +# get_articles_with_selenium: 666-1038 +# _extract_articles_from_page: 1093-1211 + +delete_ranges = [ + (451, 665), # get_articles_with_selenium_api + (666, 1038), # get_articles_with_selenium + (1093, 1211) # _extract_articles_from_page +] + +output_lines = [] +for i, line in enumerate(lines, 1): + should_keep = True + for start, end in delete_ranges: + if start <= i <= end: + should_keep = False + break + if should_keep: + output_lines.append(line) + +with open('app_refactored.py', 'w', encoding='utf-8') as f: + f.writelines(output_lines) + +print(f"✅ 原始行数: {len(lines)}") +print(f"✅ 删除后行数: {len(output_lines)}") +print(f"✅ 已删除: {len(lines) - len(output_lines)} 行") +print(f"✅ 新文件已保存为: app_refactored.py") + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3e25391 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +Flask==3.0.0 +flask-cors==4.0.0 +flask-socketio==5.3.6 +python-socketio==5.11.0 +requests==2.31.0 +pandas==2.1.4 +openpyxl==3.1.2 +psutil==5.9.6 +gunicorn==21.2.0 +aiohttp==3.9.1 +fake-useragent==1.4.0 +playwright==1.40.0 diff --git a/restart.sh b/restart.sh new file mode 100644 index 0000000..f5fe43d --- /dev/null +++ b/restart.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# 重启服务 +echo "正在停止服务..." +bash stop.sh + +sleep 2 + +echo "正在启动服务..." +bash start.sh + +echo "服务已重启" diff --git a/scrapy_proxy.py b/scrapy_proxy.py new file mode 100644 index 0000000..b4e1552 --- /dev/null +++ b/scrapy_proxy.py @@ -0,0 +1,32 @@ +import scrapy + +class MimvpSpider(scrapy.spiders.Spider): + name = "mimvp" + allowed_domains = ["mimvp.com"] + start_urls = [ + "http://proxy.mimvp.com/exist.php", + "https://proxy.mimvp.com/exist.php", + ] + + ## ÷ʽ1ֱڴ + def start_requests(self): + urls = [ + "http://proxy.mimvp.com/exist.php", + "https://proxy.mimvp.com/exist.php", + ] + for url in urls: + meta_proxy = "" + if url.startswith("http://"): + meta_proxy = "http://180.96.27.12:88" # http + elif url.startswith("https://"): + meta_proxy = "http://109.108.87.136:53281" # https + + yield scrapy.Request(url=url, callback=self.parse, meta={'proxy': meta_proxy}) + + + def parse(self, response): + mimvp_url = response.url # ȡʱurl + body = response.body # ҳ + + print("mimvp_url : " + str(mimvp_url)) + print("body : " + str(body)) \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..c07f052 --- /dev/null +++ b/start.sh @@ -0,0 +1,356 @@ +#!/bin/bash + +############################################################################### +# 百家号爬虫系统 - 快速启动脚本 +# 功能:杀死旧进程 -> 激活虚拟环境 -> 启动服务 -> 检查状态 +# 支持:nohup / gunicorn 两种启动方式 +# 新增:TaskWorker 监控和健康检查 +############################################################################### + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# 项目配置 +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" +VENV_DIR="${PROJECT_DIR}/venv" +APP_PORT=8030 +PID_FILE="${PROJECT_DIR}/app.pid" +GUNICORN_PID_FILE="${PROJECT_DIR}/gunicorn.pid" +MONITOR_PID_FILE="${PROJECT_DIR}/monitor.pid" + +# 启动模式(默认使用 gunicorn) +START_MODE="${1:-gunicorn}" # gunicorn 或 nohup +ENABLE_MONITOR="${2:-yes}" # 是否启动监控(yes/no) + +echo -e "${BLUE}=========================================${NC}" +echo -e "${BLUE} 百家号爬虫系统 - 启动服务${NC}" +echo -e "${BLUE} 启动模式: ${START_MODE}${NC}" +echo -e "${BLUE} 自动监控: ${ENABLE_MONITOR}${NC}" +echo -e "${BLUE}=========================================${NC}" +echo "" + +############################################################################### +# 1. 杀死旧进程 +############################################################################### + +echo -e "${YELLOW}[1/3]${NC} 检查并停止旧服务..." + +# 通过 PID 文件停止 +if [[ -f "${PID_FILE}" ]]; then + OLD_PID=$(cat ${PID_FILE}) + if ps -p ${OLD_PID} > /dev/null 2>&1; then + echo " 发现旧进程 (PID: ${OLD_PID}),正在停止..." + kill ${OLD_PID} 2>/dev/null + sleep 2 + + # 如果还在运行,强制终止 + if ps -p ${OLD_PID} > /dev/null 2>&1; then + echo " 强制终止进程..." + kill -9 ${OLD_PID} 2>/dev/null + fi + echo -e " ${GREEN}✓${NC} 旧进程已停止" + fi + rm -f ${PID_FILE} +fi + +# 停止 Gunicorn +if [[ -f "${GUNICORN_PID_FILE}" ]]; then + GUNICORN_PID=$(cat ${GUNICORN_PID_FILE}) + if ps -p ${GUNICORN_PID} > /dev/null 2>&1; then + echo " 发现 Gunicorn 进程 (PID: ${GUNICORN_PID}),正在停止..." + kill ${GUNICORN_PID} 2>/dev/null + sleep 2 + + if ps -p ${GUNICORN_PID} > /dev/null 2>&1; then + echo " 强制终止 Gunicorn..." + kill -9 ${GUNICORN_PID} 2>/dev/null + fi + echo -e " ${GREEN}✓${NC} Gunicorn 进程已停止" + fi + rm -f ${GUNICORN_PID_FILE} +fi + +# 杀死所有 app.py 相关进程(包括任务线程) +APP_PIDS=$(ps aux | grep "[p]ython.*app.py" | awk '{print $2}') +if [[ -n "${APP_PIDS}" ]]; then + echo " 发现运行中的 Python 进程,正在清理..." + for pid in ${APP_PIDS}; do + echo " 停止进程 ${pid}..." + kill ${pid} 2>/dev/null + done + sleep 2 + + # 强制清理残留进程 + APP_PIDS=$(ps aux | grep "[p]ython.*app.py" | awk '{print $2}') + if [[ -n "${APP_PIDS}" ]]; then + echo " 强制清理残留进程..." + for pid in ${APP_PIDS}; do + kill -9 ${pid} 2>/dev/null + done + fi + echo -e " ${GREEN}✓${NC} 所有旧进程已清理" +else + echo -e " ${GREEN}✓${NC} 未发现运行中的进程" +fi + +# 清理端口占用 +PORT_PID=$(lsof -ti:${APP_PORT} 2>/dev/null) +if [[ -n "${PORT_PID}" ]]; then + echo " 端口 ${APP_PORT} 被占用 (PID: ${PORT_PID}),正在释放..." + kill -9 ${PORT_PID} 2>/dev/null + echo -e " ${GREEN}✓${NC} 端口已释放" +fi + +# 停止监控进程 +if [[ -f "${MONITOR_PID_FILE}" ]]; then + MONITOR_PID=$(cat ${MONITOR_PID_FILE}) + if ps -p ${MONITOR_PID} > /dev/null 2>&1; then + echo " 发现监控进程 (PID: ${MONITOR_PID}),正在停止..." + kill ${MONITOR_PID} 2>/dev/null + sleep 1 + echo -e " ${GREEN}✓${NC} 监控进程已停止" + fi + rm -f ${MONITOR_PID_FILE} +fi + +# 清理 TaskWorker 锁文件 +if [[ -f "data/taskworker.lock" ]]; then + echo " 清理 TaskWorker 锁文件..." + rm -f data/taskworker.lock + echo -e " ${GREEN}✓${NC} 锁文件已清理" +fi + +echo "" + +############################################################################### +# 2. 激活虚拟环境 +############################################################################### + +echo -e "${YELLOW}[2/3]${NC} 激活虚拟环境..." + +if [[ ! -d "${VENV_DIR}" ]]; then + echo -e " ${RED}✗${NC} 虚拟环境不存在: ${VENV_DIR}" + echo " 请先创建虚拟环境:" + echo " python3 -m venv .venv" + echo " source .venv/bin/activate" + echo " pip install -r requirements.txt" + exit 1 +fi + +source ${VENV_DIR}/bin/activate + +if [[ "$VIRTUAL_ENV" != "" ]]; then + echo -e " ${GREEN}✓${NC} 虚拟环境已激活: ${VIRTUAL_ENV}" +else + echo -e " ${RED}✗${NC} 虚拟环境激活失败" + exit 1 +fi + +echo "" + +############################################################################### +# 3. 启动服务 +############################################################################### + +echo -e "${YELLOW}[3/5]${NC} 启动服务..." + +cd ${PROJECT_DIR} + +if [[ "${START_MODE}" == "gunicorn" ]]; then + # 使用 Gunicorn 启动 + echo " 使用 Gunicorn 启动服务..." + + # 检查 gunicorn 是否安装 + if ! command -v gunicorn &> /dev/null; then + echo -e " ${RED}✗${NC} Gunicorn 未安装,请先安装:" + echo " pip install gunicorn" + exit 1 + fi + + # 后台启动 Gunicorn + gunicorn -c gunicorn_config.py app:app + + # 等待服务启动(daemon模式需要更长时间) + echo " 等待服务启动..." + sleep 5 + + # 检查服务是否成功启动 + if [[ -f "${GUNICORN_PID_FILE}" ]]; then + GUNICORN_PID=$(cat ${GUNICORN_PID_FILE}) + if ps -p ${GUNICORN_PID} > /dev/null 2>&1; then + echo "" + echo -e "${GREEN}=========================================${NC}" + echo -e "${GREEN} Gunicorn 服务启动成功!${NC}" + echo -e "${GREEN}=========================================${NC}" + echo "" + echo -e " PID: ${GUNICORN_PID}" + echo -e " 端口: ${APP_PORT}" + echo -e " 访问地址: http://0.0.0.0:${APP_PORT}" + echo -e " 访问日志: logs/gunicorn_access.log" + echo -e " 错误日志: logs/gunicorn_error.log" + echo "" + echo -e "查看日志: ${BLUE}tail -f logs/gunicorn_error.log${NC}" + echo -e "停止服务: ${BLUE}./stop.sh${NC}" + echo -e "重启服务: ${BLUE}./restart.sh${NC}" + echo -e "检查状态: ${BLUE}python check_taskworker.py${NC}" + echo "" + else + echo "" + echo -e "${RED}=========================================${NC}" + echo -e "${RED} Gunicorn 启动失败!${NC}" + echo -e "${RED}=========================================${NC}" + echo "" + echo "请检查日志文件:" + echo " tail -n 50 logs/gunicorn_error.log" + echo "" + exit 1 + fi + else + # 尝试通过端口检查服务是否启动 + echo " PID文件未生成,检查端口占用..." + sleep 2 + + if lsof -i:${APP_PORT} > /dev/null 2>&1; then + echo "" + echo -e "${GREEN}=========================================${NC}" + echo -e "${GREEN} Gunicorn 服务已启动!${NC}" + echo -e "${GREEN}=========================================${NC}" + echo "" + echo -e " 端口: ${APP_PORT}" + echo -e " 访问地址: http://0.0.0.0:${APP_PORT}" + echo -e " 访问日志: logs/gunicorn_access.log" + echo -e " 错误日志: logs/gunicorn_error.log" + echo "" + echo -e "查看日志: ${BLUE}tail -f logs/gunicorn_error.log${NC}" + echo -e "停止服务: ${BLUE}./stop.sh${NC}" + echo -e "检查状态: ${BLUE}python check_taskworker.py${NC}" + echo "" + else + echo -e "${RED}✗${NC} 服务启动失败,请检查错误日志:" + echo " tail -n 50 logs/gunicorn_error.log" + exit 1 + fi + fi + +else + # 使用 nohup 启动 + echo " 使用 nohup 启动服务..." + + # 后台启动 + nohup python app.py > logs/app.log 2>&1 & + NEW_PID=$! + + # 保存 PID + echo ${NEW_PID} > ${PID_FILE} + + # 等待服务启动 + echo " 等待服务启动..." + sleep 3 + + # 检查服务是否成功启动 + if ps -p ${NEW_PID} > /dev/null 2>&1; then + echo "" + echo -e "${GREEN}=========================================${NC}" + echo -e "${GREEN} 服务启动成功!${NC}" + echo -e "${GREEN}=========================================${NC}" + echo "" + echo -e " PID: ${NEW_PID}" + echo -e " 端口: ${APP_PORT}" + echo -e " 访问地址: http://127.0.0.1:${APP_PORT}" + echo -e " 日志文件: logs/app.log" + echo "" + echo -e "查看日志: ${BLUE}tail -f logs/app.log${NC}" + echo -e "停止服务: ${BLUE}kill ${NEW_PID}${NC}" + echo -e "检查状态: ${BLUE}python check_taskworker.py${NC}" + echo "" + else + echo "" + echo -e "${RED}=========================================${NC}" + echo -e "${RED} 服务启动失败!${NC}" + echo -e "${RED}=========================================${NC}" + echo "" + echo "请检查日志文件:" + echo " tail -n 50 logs/app.log" + echo "" + exit 1 + fi +fi + +echo "" + +############################################################################### +# 4. 检查 TaskWorker 状态 +############################################################################### + +echo -e "${YELLOW}[4/5]${NC} 检查 TaskWorker 状态..." + +# 等待服务完全启动 +sleep 3 + +# 检查健康状态 +if command -v curl &> /dev/null; then + HEALTH_CHECK=$(curl -s http://localhost:${APP_PORT}/health/taskworker 2>/dev/null) + + if [[ -n "${HEALTH_CHECK}" ]]; then + STATUS=$(echo ${HEALTH_CHECK} | grep -o '"status":"[^"]*"' | cut -d'"' -f4) + + if [[ "${STATUS}" == "healthy" ]]; then + echo -e " ${GREEN}✓${NC} TaskWorker 状态: ${GREEN}healthy${NC}" + + # 提取详细信息 + ALIVE_THREADS=$(echo ${HEALTH_CHECK} | grep -o '"alive_threads":[0-9]*' | cut -d':' -f2) + PENDING=$(echo ${HEALTH_CHECK} | grep -o '"pending":[0-9]*' | cut -d':' -f2) + + echo " 活跃线程: ${ALIVE_THREADS}" + echo " 待处理任务: ${PENDING}" + else + echo -e " ${YELLOW}⚠${NC} TaskWorker 状态: ${YELLOW}${STATUS}${NC}" + echo " 建议运行: python check_taskworker.py --fix" + fi + else + echo -e " ${YELLOW}⚠${NC} 无法连接到服务,稍后重试" + fi +else + echo " 跳过健康检查(curl 未安装)" +fi + +echo "" + +############################################################################### +# 5. 启动监控进程(可选) +############################################################################### + +if [[ "${ENABLE_MONITOR}" == "yes" ]]; then + echo -e "${YELLOW}[5/5]${NC} 启动 TaskWorker 自动监控..." + + if [[ -f "taskworker_monitor.py" ]]; then + # 后台启动监控 + nohup python taskworker_monitor.py > logs/monitor.out 2>&1 & + MONITOR_PID=$! + echo ${MONITOR_PID} > ${MONITOR_PID_FILE} + + sleep 1 + + if ps -p ${MONITOR_PID} > /dev/null 2>&1; then + echo -e " ${GREEN}✓${NC} 监控进程已启动 (PID: ${MONITOR_PID})" + echo " 监控日志: logs/taskworker_monitor.log" + echo " 输出日志: logs/monitor.out" + else + echo -e " ${YELLOW}⚠${NC} 监控进程启动失败" + fi + else + echo -e " ${YELLOW}⚠${NC} 监控脚本不存在: taskworker_monitor.py" + fi +else + echo -e "${YELLOW}[5/5]${NC} 跳过自动监控(使用 './start.sh gunicorn yes' 启用)" +fi + +echo "" +echo -e "${GREEN}=========================================${NC}" +echo -e "${GREEN} 服务启动完成!${NC}" +echo -e "${GREEN}=========================================${NC}" +echo "" diff --git a/static/css/bootstrap-icons.css b/static/css/bootstrap-icons.css new file mode 100644 index 0000000..bc84a5f --- /dev/null +++ b/static/css/bootstrap-icons.css @@ -0,0 +1,2078 @@ +/*! + * Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/) + * Copyright 2019-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) + */ + +@font-face { + font-display: block; + font-family: "bootstrap-icons"; + src: url("./fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"), +url("./fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff"); +} + +.bi::before, +[class^="bi-"]::before, +[class*=" bi-"]::before { + display: inline-block; + font-family: bootstrap-icons !important; + font-style: normal; + font-weight: normal !important; + font-variant: normal; + text-transform: none; + line-height: 1; + vertical-align: -.125em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.bi-123::before { content: "\f67f"; } +.bi-alarm-fill::before { content: "\f101"; } +.bi-alarm::before { content: "\f102"; } +.bi-align-bottom::before { content: "\f103"; } +.bi-align-center::before { content: "\f104"; } +.bi-align-end::before { content: "\f105"; } +.bi-align-middle::before { content: "\f106"; } +.bi-align-start::before { content: "\f107"; } +.bi-align-top::before { content: "\f108"; } +.bi-alt::before { content: "\f109"; } +.bi-app-indicator::before { content: "\f10a"; } +.bi-app::before { content: "\f10b"; } +.bi-archive-fill::before { content: "\f10c"; } +.bi-archive::before { content: "\f10d"; } +.bi-arrow-90deg-down::before { content: "\f10e"; } +.bi-arrow-90deg-left::before { content: "\f10f"; } +.bi-arrow-90deg-right::before { content: "\f110"; } +.bi-arrow-90deg-up::before { content: "\f111"; } +.bi-arrow-bar-down::before { content: "\f112"; } +.bi-arrow-bar-left::before { content: "\f113"; } +.bi-arrow-bar-right::before { content: "\f114"; } +.bi-arrow-bar-up::before { content: "\f115"; } +.bi-arrow-clockwise::before { content: "\f116"; } +.bi-arrow-counterclockwise::before { content: "\f117"; } +.bi-arrow-down-circle-fill::before { content: "\f118"; } +.bi-arrow-down-circle::before { content: "\f119"; } +.bi-arrow-down-left-circle-fill::before { content: "\f11a"; } +.bi-arrow-down-left-circle::before { content: "\f11b"; } +.bi-arrow-down-left-square-fill::before { content: "\f11c"; } +.bi-arrow-down-left-square::before { content: "\f11d"; } +.bi-arrow-down-left::before { content: "\f11e"; } +.bi-arrow-down-right-circle-fill::before { content: "\f11f"; } +.bi-arrow-down-right-circle::before { content: "\f120"; } +.bi-arrow-down-right-square-fill::before { content: "\f121"; } +.bi-arrow-down-right-square::before { content: "\f122"; } +.bi-arrow-down-right::before { content: "\f123"; } +.bi-arrow-down-short::before { content: "\f124"; } +.bi-arrow-down-square-fill::before { content: "\f125"; } +.bi-arrow-down-square::before { content: "\f126"; } +.bi-arrow-down-up::before { content: "\f127"; } +.bi-arrow-down::before { content: "\f128"; } +.bi-arrow-left-circle-fill::before { content: "\f129"; } +.bi-arrow-left-circle::before { content: "\f12a"; } +.bi-arrow-left-right::before { content: "\f12b"; } +.bi-arrow-left-short::before { content: "\f12c"; } +.bi-arrow-left-square-fill::before { content: "\f12d"; } +.bi-arrow-left-square::before { content: "\f12e"; } +.bi-arrow-left::before { content: "\f12f"; } +.bi-arrow-repeat::before { content: "\f130"; } +.bi-arrow-return-left::before { content: "\f131"; } +.bi-arrow-return-right::before { content: "\f132"; } +.bi-arrow-right-circle-fill::before { content: "\f133"; } +.bi-arrow-right-circle::before { content: "\f134"; } +.bi-arrow-right-short::before { content: "\f135"; } +.bi-arrow-right-square-fill::before { content: "\f136"; } +.bi-arrow-right-square::before { content: "\f137"; } +.bi-arrow-right::before { content: "\f138"; } +.bi-arrow-up-circle-fill::before { content: "\f139"; } +.bi-arrow-up-circle::before { content: "\f13a"; } +.bi-arrow-up-left-circle-fill::before { content: "\f13b"; } +.bi-arrow-up-left-circle::before { content: "\f13c"; } +.bi-arrow-up-left-square-fill::before { content: "\f13d"; } +.bi-arrow-up-left-square::before { content: "\f13e"; } +.bi-arrow-up-left::before { content: "\f13f"; } +.bi-arrow-up-right-circle-fill::before { content: "\f140"; } +.bi-arrow-up-right-circle::before { content: "\f141"; } +.bi-arrow-up-right-square-fill::before { content: "\f142"; } +.bi-arrow-up-right-square::before { content: "\f143"; } +.bi-arrow-up-right::before { content: "\f144"; } +.bi-arrow-up-short::before { content: "\f145"; } +.bi-arrow-up-square-fill::before { content: "\f146"; } +.bi-arrow-up-square::before { content: "\f147"; } +.bi-arrow-up::before { content: "\f148"; } +.bi-arrows-angle-contract::before { content: "\f149"; } +.bi-arrows-angle-expand::before { content: "\f14a"; } +.bi-arrows-collapse::before { content: "\f14b"; } +.bi-arrows-expand::before { content: "\f14c"; } +.bi-arrows-fullscreen::before { content: "\f14d"; } +.bi-arrows-move::before { content: "\f14e"; } +.bi-aspect-ratio-fill::before { content: "\f14f"; } +.bi-aspect-ratio::before { content: "\f150"; } +.bi-asterisk::before { content: "\f151"; } +.bi-at::before { content: "\f152"; } +.bi-award-fill::before { content: "\f153"; } +.bi-award::before { content: "\f154"; } +.bi-back::before { content: "\f155"; } +.bi-backspace-fill::before { content: "\f156"; } +.bi-backspace-reverse-fill::before { content: "\f157"; } +.bi-backspace-reverse::before { content: "\f158"; } +.bi-backspace::before { content: "\f159"; } +.bi-badge-3d-fill::before { content: "\f15a"; } +.bi-badge-3d::before { content: "\f15b"; } +.bi-badge-4k-fill::before { content: "\f15c"; } +.bi-badge-4k::before { content: "\f15d"; } +.bi-badge-8k-fill::before { content: "\f15e"; } +.bi-badge-8k::before { content: "\f15f"; } +.bi-badge-ad-fill::before { content: "\f160"; } +.bi-badge-ad::before { content: "\f161"; } +.bi-badge-ar-fill::before { content: "\f162"; } +.bi-badge-ar::before { content: "\f163"; } +.bi-badge-cc-fill::before { content: "\f164"; } +.bi-badge-cc::before { content: "\f165"; } +.bi-badge-hd-fill::before { content: "\f166"; } +.bi-badge-hd::before { content: "\f167"; } +.bi-badge-tm-fill::before { content: "\f168"; } +.bi-badge-tm::before { content: "\f169"; } +.bi-badge-vo-fill::before { content: "\f16a"; } +.bi-badge-vo::before { content: "\f16b"; } +.bi-badge-vr-fill::before { content: "\f16c"; } +.bi-badge-vr::before { content: "\f16d"; } +.bi-badge-wc-fill::before { content: "\f16e"; } +.bi-badge-wc::before { content: "\f16f"; } +.bi-bag-check-fill::before { content: "\f170"; } +.bi-bag-check::before { content: "\f171"; } +.bi-bag-dash-fill::before { content: "\f172"; } +.bi-bag-dash::before { content: "\f173"; } +.bi-bag-fill::before { content: "\f174"; } +.bi-bag-plus-fill::before { content: "\f175"; } +.bi-bag-plus::before { content: "\f176"; } +.bi-bag-x-fill::before { content: "\f177"; } +.bi-bag-x::before { content: "\f178"; } +.bi-bag::before { content: "\f179"; } +.bi-bar-chart-fill::before { content: "\f17a"; } +.bi-bar-chart-line-fill::before { content: "\f17b"; } +.bi-bar-chart-line::before { content: "\f17c"; } +.bi-bar-chart-steps::before { content: "\f17d"; } +.bi-bar-chart::before { content: "\f17e"; } +.bi-basket-fill::before { content: "\f17f"; } +.bi-basket::before { content: "\f180"; } +.bi-basket2-fill::before { content: "\f181"; } +.bi-basket2::before { content: "\f182"; } +.bi-basket3-fill::before { content: "\f183"; } +.bi-basket3::before { content: "\f184"; } +.bi-battery-charging::before { content: "\f185"; } +.bi-battery-full::before { content: "\f186"; } +.bi-battery-half::before { content: "\f187"; } +.bi-battery::before { content: "\f188"; } +.bi-bell-fill::before { content: "\f189"; } +.bi-bell::before { content: "\f18a"; } +.bi-bezier::before { content: "\f18b"; } +.bi-bezier2::before { content: "\f18c"; } +.bi-bicycle::before { content: "\f18d"; } +.bi-binoculars-fill::before { content: "\f18e"; } +.bi-binoculars::before { content: "\f18f"; } +.bi-blockquote-left::before { content: "\f190"; } +.bi-blockquote-right::before { content: "\f191"; } +.bi-book-fill::before { content: "\f192"; } +.bi-book-half::before { content: "\f193"; } +.bi-book::before { content: "\f194"; } +.bi-bookmark-check-fill::before { content: "\f195"; } +.bi-bookmark-check::before { content: "\f196"; } +.bi-bookmark-dash-fill::before { content: "\f197"; } +.bi-bookmark-dash::before { content: "\f198"; } +.bi-bookmark-fill::before { content: "\f199"; } +.bi-bookmark-heart-fill::before { content: "\f19a"; } +.bi-bookmark-heart::before { content: "\f19b"; } +.bi-bookmark-plus-fill::before { content: "\f19c"; } +.bi-bookmark-plus::before { content: "\f19d"; } +.bi-bookmark-star-fill::before { content: "\f19e"; } +.bi-bookmark-star::before { content: "\f19f"; } +.bi-bookmark-x-fill::before { content: "\f1a0"; } +.bi-bookmark-x::before { content: "\f1a1"; } +.bi-bookmark::before { content: "\f1a2"; } +.bi-bookmarks-fill::before { content: "\f1a3"; } +.bi-bookmarks::before { content: "\f1a4"; } +.bi-bookshelf::before { content: "\f1a5"; } +.bi-bootstrap-fill::before { content: "\f1a6"; } +.bi-bootstrap-reboot::before { content: "\f1a7"; } +.bi-bootstrap::before { content: "\f1a8"; } +.bi-border-all::before { content: "\f1a9"; } +.bi-border-bottom::before { content: "\f1aa"; } +.bi-border-center::before { content: "\f1ab"; } +.bi-border-inner::before { content: "\f1ac"; } +.bi-border-left::before { content: "\f1ad"; } +.bi-border-middle::before { content: "\f1ae"; } +.bi-border-outer::before { content: "\f1af"; } +.bi-border-right::before { content: "\f1b0"; } +.bi-border-style::before { content: "\f1b1"; } +.bi-border-top::before { content: "\f1b2"; } +.bi-border-width::before { content: "\f1b3"; } +.bi-border::before { content: "\f1b4"; } +.bi-bounding-box-circles::before { content: "\f1b5"; } +.bi-bounding-box::before { content: "\f1b6"; } +.bi-box-arrow-down-left::before { content: "\f1b7"; } +.bi-box-arrow-down-right::before { content: "\f1b8"; } +.bi-box-arrow-down::before { content: "\f1b9"; } +.bi-box-arrow-in-down-left::before { content: "\f1ba"; } +.bi-box-arrow-in-down-right::before { content: "\f1bb"; } +.bi-box-arrow-in-down::before { content: "\f1bc"; } +.bi-box-arrow-in-left::before { content: "\f1bd"; } +.bi-box-arrow-in-right::before { content: "\f1be"; } +.bi-box-arrow-in-up-left::before { content: "\f1bf"; } +.bi-box-arrow-in-up-right::before { content: "\f1c0"; } +.bi-box-arrow-in-up::before { content: "\f1c1"; } +.bi-box-arrow-left::before { content: "\f1c2"; } +.bi-box-arrow-right::before { content: "\f1c3"; } +.bi-box-arrow-up-left::before { content: "\f1c4"; } +.bi-box-arrow-up-right::before { content: "\f1c5"; } +.bi-box-arrow-up::before { content: "\f1c6"; } +.bi-box-seam::before { content: "\f1c7"; } +.bi-box::before { content: "\f1c8"; } +.bi-braces::before { content: "\f1c9"; } +.bi-bricks::before { content: "\f1ca"; } +.bi-briefcase-fill::before { content: "\f1cb"; } +.bi-briefcase::before { content: "\f1cc"; } +.bi-brightness-alt-high-fill::before { content: "\f1cd"; } +.bi-brightness-alt-high::before { content: "\f1ce"; } +.bi-brightness-alt-low-fill::before { content: "\f1cf"; } +.bi-brightness-alt-low::before { content: "\f1d0"; } +.bi-brightness-high-fill::before { content: "\f1d1"; } +.bi-brightness-high::before { content: "\f1d2"; } +.bi-brightness-low-fill::before { content: "\f1d3"; } +.bi-brightness-low::before { content: "\f1d4"; } +.bi-broadcast-pin::before { content: "\f1d5"; } +.bi-broadcast::before { content: "\f1d6"; } +.bi-brush-fill::before { content: "\f1d7"; } +.bi-brush::before { content: "\f1d8"; } +.bi-bucket-fill::before { content: "\f1d9"; } +.bi-bucket::before { content: "\f1da"; } +.bi-bug-fill::before { content: "\f1db"; } +.bi-bug::before { content: "\f1dc"; } +.bi-building::before { content: "\f1dd"; } +.bi-bullseye::before { content: "\f1de"; } +.bi-calculator-fill::before { content: "\f1df"; } +.bi-calculator::before { content: "\f1e0"; } +.bi-calendar-check-fill::before { content: "\f1e1"; } +.bi-calendar-check::before { content: "\f1e2"; } +.bi-calendar-date-fill::before { content: "\f1e3"; } +.bi-calendar-date::before { content: "\f1e4"; } +.bi-calendar-day-fill::before { content: "\f1e5"; } +.bi-calendar-day::before { content: "\f1e6"; } +.bi-calendar-event-fill::before { content: "\f1e7"; } +.bi-calendar-event::before { content: "\f1e8"; } +.bi-calendar-fill::before { content: "\f1e9"; } +.bi-calendar-minus-fill::before { content: "\f1ea"; } +.bi-calendar-minus::before { content: "\f1eb"; } +.bi-calendar-month-fill::before { content: "\f1ec"; } +.bi-calendar-month::before { content: "\f1ed"; } +.bi-calendar-plus-fill::before { content: "\f1ee"; } +.bi-calendar-plus::before { content: "\f1ef"; } +.bi-calendar-range-fill::before { content: "\f1f0"; } +.bi-calendar-range::before { content: "\f1f1"; } +.bi-calendar-week-fill::before { content: "\f1f2"; } +.bi-calendar-week::before { content: "\f1f3"; } +.bi-calendar-x-fill::before { content: "\f1f4"; } +.bi-calendar-x::before { content: "\f1f5"; } +.bi-calendar::before { content: "\f1f6"; } +.bi-calendar2-check-fill::before { content: "\f1f7"; } +.bi-calendar2-check::before { content: "\f1f8"; } +.bi-calendar2-date-fill::before { content: "\f1f9"; } +.bi-calendar2-date::before { content: "\f1fa"; } +.bi-calendar2-day-fill::before { content: "\f1fb"; } +.bi-calendar2-day::before { content: "\f1fc"; } +.bi-calendar2-event-fill::before { content: "\f1fd"; } +.bi-calendar2-event::before { content: "\f1fe"; } +.bi-calendar2-fill::before { content: "\f1ff"; } +.bi-calendar2-minus-fill::before { content: "\f200"; } +.bi-calendar2-minus::before { content: "\f201"; } +.bi-calendar2-month-fill::before { content: "\f202"; } +.bi-calendar2-month::before { content: "\f203"; } +.bi-calendar2-plus-fill::before { content: "\f204"; } +.bi-calendar2-plus::before { content: "\f205"; } +.bi-calendar2-range-fill::before { content: "\f206"; } +.bi-calendar2-range::before { content: "\f207"; } +.bi-calendar2-week-fill::before { content: "\f208"; } +.bi-calendar2-week::before { content: "\f209"; } +.bi-calendar2-x-fill::before { content: "\f20a"; } +.bi-calendar2-x::before { content: "\f20b"; } +.bi-calendar2::before { content: "\f20c"; } +.bi-calendar3-event-fill::before { content: "\f20d"; } +.bi-calendar3-event::before { content: "\f20e"; } +.bi-calendar3-fill::before { content: "\f20f"; } +.bi-calendar3-range-fill::before { content: "\f210"; } +.bi-calendar3-range::before { content: "\f211"; } +.bi-calendar3-week-fill::before { content: "\f212"; } +.bi-calendar3-week::before { content: "\f213"; } +.bi-calendar3::before { content: "\f214"; } +.bi-calendar4-event::before { content: "\f215"; } +.bi-calendar4-range::before { content: "\f216"; } +.bi-calendar4-week::before { content: "\f217"; } +.bi-calendar4::before { content: "\f218"; } +.bi-camera-fill::before { content: "\f219"; } +.bi-camera-reels-fill::before { content: "\f21a"; } +.bi-camera-reels::before { content: "\f21b"; } +.bi-camera-video-fill::before { content: "\f21c"; } +.bi-camera-video-off-fill::before { content: "\f21d"; } +.bi-camera-video-off::before { content: "\f21e"; } +.bi-camera-video::before { content: "\f21f"; } +.bi-camera::before { content: "\f220"; } +.bi-camera2::before { content: "\f221"; } +.bi-capslock-fill::before { content: "\f222"; } +.bi-capslock::before { content: "\f223"; } +.bi-card-checklist::before { content: "\f224"; } +.bi-card-heading::before { content: "\f225"; } +.bi-card-image::before { content: "\f226"; } +.bi-card-list::before { content: "\f227"; } +.bi-card-text::before { content: "\f228"; } +.bi-caret-down-fill::before { content: "\f229"; } +.bi-caret-down-square-fill::before { content: "\f22a"; } +.bi-caret-down-square::before { content: "\f22b"; } +.bi-caret-down::before { content: "\f22c"; } +.bi-caret-left-fill::before { content: "\f22d"; } +.bi-caret-left-square-fill::before { content: "\f22e"; } +.bi-caret-left-square::before { content: "\f22f"; } +.bi-caret-left::before { content: "\f230"; } +.bi-caret-right-fill::before { content: "\f231"; } +.bi-caret-right-square-fill::before { content: "\f232"; } +.bi-caret-right-square::before { content: "\f233"; } +.bi-caret-right::before { content: "\f234"; } +.bi-caret-up-fill::before { content: "\f235"; } +.bi-caret-up-square-fill::before { content: "\f236"; } +.bi-caret-up-square::before { content: "\f237"; } +.bi-caret-up::before { content: "\f238"; } +.bi-cart-check-fill::before { content: "\f239"; } +.bi-cart-check::before { content: "\f23a"; } +.bi-cart-dash-fill::before { content: "\f23b"; } +.bi-cart-dash::before { content: "\f23c"; } +.bi-cart-fill::before { content: "\f23d"; } +.bi-cart-plus-fill::before { content: "\f23e"; } +.bi-cart-plus::before { content: "\f23f"; } +.bi-cart-x-fill::before { content: "\f240"; } +.bi-cart-x::before { content: "\f241"; } +.bi-cart::before { content: "\f242"; } +.bi-cart2::before { content: "\f243"; } +.bi-cart3::before { content: "\f244"; } +.bi-cart4::before { content: "\f245"; } +.bi-cash-stack::before { content: "\f246"; } +.bi-cash::before { content: "\f247"; } +.bi-cast::before { content: "\f248"; } +.bi-chat-dots-fill::before { content: "\f249"; } +.bi-chat-dots::before { content: "\f24a"; } +.bi-chat-fill::before { content: "\f24b"; } +.bi-chat-left-dots-fill::before { content: "\f24c"; } +.bi-chat-left-dots::before { content: "\f24d"; } +.bi-chat-left-fill::before { content: "\f24e"; } +.bi-chat-left-quote-fill::before { content: "\f24f"; } +.bi-chat-left-quote::before { content: "\f250"; } +.bi-chat-left-text-fill::before { content: "\f251"; } +.bi-chat-left-text::before { content: "\f252"; } +.bi-chat-left::before { content: "\f253"; } +.bi-chat-quote-fill::before { content: "\f254"; } +.bi-chat-quote::before { content: "\f255"; } +.bi-chat-right-dots-fill::before { content: "\f256"; } +.bi-chat-right-dots::before { content: "\f257"; } +.bi-chat-right-fill::before { content: "\f258"; } +.bi-chat-right-quote-fill::before { content: "\f259"; } +.bi-chat-right-quote::before { content: "\f25a"; } +.bi-chat-right-text-fill::before { content: "\f25b"; } +.bi-chat-right-text::before { content: "\f25c"; } +.bi-chat-right::before { content: "\f25d"; } +.bi-chat-square-dots-fill::before { content: "\f25e"; } +.bi-chat-square-dots::before { content: "\f25f"; } +.bi-chat-square-fill::before { content: "\f260"; } +.bi-chat-square-quote-fill::before { content: "\f261"; } +.bi-chat-square-quote::before { content: "\f262"; } +.bi-chat-square-text-fill::before { content: "\f263"; } +.bi-chat-square-text::before { content: "\f264"; } +.bi-chat-square::before { content: "\f265"; } +.bi-chat-text-fill::before { content: "\f266"; } +.bi-chat-text::before { content: "\f267"; } +.bi-chat::before { content: "\f268"; } +.bi-check-all::before { content: "\f269"; } +.bi-check-circle-fill::before { content: "\f26a"; } +.bi-check-circle::before { content: "\f26b"; } +.bi-check-square-fill::before { content: "\f26c"; } +.bi-check-square::before { content: "\f26d"; } +.bi-check::before { content: "\f26e"; } +.bi-check2-all::before { content: "\f26f"; } +.bi-check2-circle::before { content: "\f270"; } +.bi-check2-square::before { content: "\f271"; } +.bi-check2::before { content: "\f272"; } +.bi-chevron-bar-contract::before { content: "\f273"; } +.bi-chevron-bar-down::before { content: "\f274"; } +.bi-chevron-bar-expand::before { content: "\f275"; } +.bi-chevron-bar-left::before { content: "\f276"; } +.bi-chevron-bar-right::before { content: "\f277"; } +.bi-chevron-bar-up::before { content: "\f278"; } +.bi-chevron-compact-down::before { content: "\f279"; } +.bi-chevron-compact-left::before { content: "\f27a"; } +.bi-chevron-compact-right::before { content: "\f27b"; } +.bi-chevron-compact-up::before { content: "\f27c"; } +.bi-chevron-contract::before { content: "\f27d"; } +.bi-chevron-double-down::before { content: "\f27e"; } +.bi-chevron-double-left::before { content: "\f27f"; } +.bi-chevron-double-right::before { content: "\f280"; } +.bi-chevron-double-up::before { content: "\f281"; } +.bi-chevron-down::before { content: "\f282"; } +.bi-chevron-expand::before { content: "\f283"; } +.bi-chevron-left::before { content: "\f284"; } +.bi-chevron-right::before { content: "\f285"; } +.bi-chevron-up::before { content: "\f286"; } +.bi-circle-fill::before { content: "\f287"; } +.bi-circle-half::before { content: "\f288"; } +.bi-circle-square::before { content: "\f289"; } +.bi-circle::before { content: "\f28a"; } +.bi-clipboard-check::before { content: "\f28b"; } +.bi-clipboard-data::before { content: "\f28c"; } +.bi-clipboard-minus::before { content: "\f28d"; } +.bi-clipboard-plus::before { content: "\f28e"; } +.bi-clipboard-x::before { content: "\f28f"; } +.bi-clipboard::before { content: "\f290"; } +.bi-clock-fill::before { content: "\f291"; } +.bi-clock-history::before { content: "\f292"; } +.bi-clock::before { content: "\f293"; } +.bi-cloud-arrow-down-fill::before { content: "\f294"; } +.bi-cloud-arrow-down::before { content: "\f295"; } +.bi-cloud-arrow-up-fill::before { content: "\f296"; } +.bi-cloud-arrow-up::before { content: "\f297"; } +.bi-cloud-check-fill::before { content: "\f298"; } +.bi-cloud-check::before { content: "\f299"; } +.bi-cloud-download-fill::before { content: "\f29a"; } +.bi-cloud-download::before { content: "\f29b"; } +.bi-cloud-drizzle-fill::before { content: "\f29c"; } +.bi-cloud-drizzle::before { content: "\f29d"; } +.bi-cloud-fill::before { content: "\f29e"; } +.bi-cloud-fog-fill::before { content: "\f29f"; } +.bi-cloud-fog::before { content: "\f2a0"; } +.bi-cloud-fog2-fill::before { content: "\f2a1"; } +.bi-cloud-fog2::before { content: "\f2a2"; } +.bi-cloud-hail-fill::before { content: "\f2a3"; } +.bi-cloud-hail::before { content: "\f2a4"; } +.bi-cloud-haze-fill::before { content: "\f2a6"; } +.bi-cloud-haze::before { content: "\f2a7"; } +.bi-cloud-haze2-fill::before { content: "\f2a8"; } +.bi-cloud-lightning-fill::before { content: "\f2a9"; } +.bi-cloud-lightning-rain-fill::before { content: "\f2aa"; } +.bi-cloud-lightning-rain::before { content: "\f2ab"; } +.bi-cloud-lightning::before { content: "\f2ac"; } +.bi-cloud-minus-fill::before { content: "\f2ad"; } +.bi-cloud-minus::before { content: "\f2ae"; } +.bi-cloud-moon-fill::before { content: "\f2af"; } +.bi-cloud-moon::before { content: "\f2b0"; } +.bi-cloud-plus-fill::before { content: "\f2b1"; } +.bi-cloud-plus::before { content: "\f2b2"; } +.bi-cloud-rain-fill::before { content: "\f2b3"; } +.bi-cloud-rain-heavy-fill::before { content: "\f2b4"; } +.bi-cloud-rain-heavy::before { content: "\f2b5"; } +.bi-cloud-rain::before { content: "\f2b6"; } +.bi-cloud-slash-fill::before { content: "\f2b7"; } +.bi-cloud-slash::before { content: "\f2b8"; } +.bi-cloud-sleet-fill::before { content: "\f2b9"; } +.bi-cloud-sleet::before { content: "\f2ba"; } +.bi-cloud-snow-fill::before { content: "\f2bb"; } +.bi-cloud-snow::before { content: "\f2bc"; } +.bi-cloud-sun-fill::before { content: "\f2bd"; } +.bi-cloud-sun::before { content: "\f2be"; } +.bi-cloud-upload-fill::before { content: "\f2bf"; } +.bi-cloud-upload::before { content: "\f2c0"; } +.bi-cloud::before { content: "\f2c1"; } +.bi-clouds-fill::before { content: "\f2c2"; } +.bi-clouds::before { content: "\f2c3"; } +.bi-cloudy-fill::before { content: "\f2c4"; } +.bi-cloudy::before { content: "\f2c5"; } +.bi-code-slash::before { content: "\f2c6"; } +.bi-code-square::before { content: "\f2c7"; } +.bi-code::before { content: "\f2c8"; } +.bi-collection-fill::before { content: "\f2c9"; } +.bi-collection-play-fill::before { content: "\f2ca"; } +.bi-collection-play::before { content: "\f2cb"; } +.bi-collection::before { content: "\f2cc"; } +.bi-columns-gap::before { content: "\f2cd"; } +.bi-columns::before { content: "\f2ce"; } +.bi-command::before { content: "\f2cf"; } +.bi-compass-fill::before { content: "\f2d0"; } +.bi-compass::before { content: "\f2d1"; } +.bi-cone-striped::before { content: "\f2d2"; } +.bi-cone::before { content: "\f2d3"; } +.bi-controller::before { content: "\f2d4"; } +.bi-cpu-fill::before { content: "\f2d5"; } +.bi-cpu::before { content: "\f2d6"; } +.bi-credit-card-2-back-fill::before { content: "\f2d7"; } +.bi-credit-card-2-back::before { content: "\f2d8"; } +.bi-credit-card-2-front-fill::before { content: "\f2d9"; } +.bi-credit-card-2-front::before { content: "\f2da"; } +.bi-credit-card-fill::before { content: "\f2db"; } +.bi-credit-card::before { content: "\f2dc"; } +.bi-crop::before { content: "\f2dd"; } +.bi-cup-fill::before { content: "\f2de"; } +.bi-cup-straw::before { content: "\f2df"; } +.bi-cup::before { content: "\f2e0"; } +.bi-cursor-fill::before { content: "\f2e1"; } +.bi-cursor-text::before { content: "\f2e2"; } +.bi-cursor::before { content: "\f2e3"; } +.bi-dash-circle-dotted::before { content: "\f2e4"; } +.bi-dash-circle-fill::before { content: "\f2e5"; } +.bi-dash-circle::before { content: "\f2e6"; } +.bi-dash-square-dotted::before { content: "\f2e7"; } +.bi-dash-square-fill::before { content: "\f2e8"; } +.bi-dash-square::before { content: "\f2e9"; } +.bi-dash::before { content: "\f2ea"; } +.bi-diagram-2-fill::before { content: "\f2eb"; } +.bi-diagram-2::before { content: "\f2ec"; } +.bi-diagram-3-fill::before { content: "\f2ed"; } +.bi-diagram-3::before { content: "\f2ee"; } +.bi-diamond-fill::before { content: "\f2ef"; } +.bi-diamond-half::before { content: "\f2f0"; } +.bi-diamond::before { content: "\f2f1"; } +.bi-dice-1-fill::before { content: "\f2f2"; } +.bi-dice-1::before { content: "\f2f3"; } +.bi-dice-2-fill::before { content: "\f2f4"; } +.bi-dice-2::before { content: "\f2f5"; } +.bi-dice-3-fill::before { content: "\f2f6"; } +.bi-dice-3::before { content: "\f2f7"; } +.bi-dice-4-fill::before { content: "\f2f8"; } +.bi-dice-4::before { content: "\f2f9"; } +.bi-dice-5-fill::before { content: "\f2fa"; } +.bi-dice-5::before { content: "\f2fb"; } +.bi-dice-6-fill::before { content: "\f2fc"; } +.bi-dice-6::before { content: "\f2fd"; } +.bi-disc-fill::before { content: "\f2fe"; } +.bi-disc::before { content: "\f2ff"; } +.bi-discord::before { content: "\f300"; } +.bi-display-fill::before { content: "\f301"; } +.bi-display::before { content: "\f302"; } +.bi-distribute-horizontal::before { content: "\f303"; } +.bi-distribute-vertical::before { content: "\f304"; } +.bi-door-closed-fill::before { content: "\f305"; } +.bi-door-closed::before { content: "\f306"; } +.bi-door-open-fill::before { content: "\f307"; } +.bi-door-open::before { content: "\f308"; } +.bi-dot::before { content: "\f309"; } +.bi-download::before { content: "\f30a"; } +.bi-droplet-fill::before { content: "\f30b"; } +.bi-droplet-half::before { content: "\f30c"; } +.bi-droplet::before { content: "\f30d"; } +.bi-earbuds::before { content: "\f30e"; } +.bi-easel-fill::before { content: "\f30f"; } +.bi-easel::before { content: "\f310"; } +.bi-egg-fill::before { content: "\f311"; } +.bi-egg-fried::before { content: "\f312"; } +.bi-egg::before { content: "\f313"; } +.bi-eject-fill::before { content: "\f314"; } +.bi-eject::before { content: "\f315"; } +.bi-emoji-angry-fill::before { content: "\f316"; } +.bi-emoji-angry::before { content: "\f317"; } +.bi-emoji-dizzy-fill::before { content: "\f318"; } +.bi-emoji-dizzy::before { content: "\f319"; } +.bi-emoji-expressionless-fill::before { content: "\f31a"; } +.bi-emoji-expressionless::before { content: "\f31b"; } +.bi-emoji-frown-fill::before { content: "\f31c"; } +.bi-emoji-frown::before { content: "\f31d"; } +.bi-emoji-heart-eyes-fill::before { content: "\f31e"; } +.bi-emoji-heart-eyes::before { content: "\f31f"; } +.bi-emoji-laughing-fill::before { content: "\f320"; } +.bi-emoji-laughing::before { content: "\f321"; } +.bi-emoji-neutral-fill::before { content: "\f322"; } +.bi-emoji-neutral::before { content: "\f323"; } +.bi-emoji-smile-fill::before { content: "\f324"; } +.bi-emoji-smile-upside-down-fill::before { content: "\f325"; } +.bi-emoji-smile-upside-down::before { content: "\f326"; } +.bi-emoji-smile::before { content: "\f327"; } +.bi-emoji-sunglasses-fill::before { content: "\f328"; } +.bi-emoji-sunglasses::before { content: "\f329"; } +.bi-emoji-wink-fill::before { content: "\f32a"; } +.bi-emoji-wink::before { content: "\f32b"; } +.bi-envelope-fill::before { content: "\f32c"; } +.bi-envelope-open-fill::before { content: "\f32d"; } +.bi-envelope-open::before { content: "\f32e"; } +.bi-envelope::before { content: "\f32f"; } +.bi-eraser-fill::before { content: "\f330"; } +.bi-eraser::before { content: "\f331"; } +.bi-exclamation-circle-fill::before { content: "\f332"; } +.bi-exclamation-circle::before { content: "\f333"; } +.bi-exclamation-diamond-fill::before { content: "\f334"; } +.bi-exclamation-diamond::before { content: "\f335"; } +.bi-exclamation-octagon-fill::before { content: "\f336"; } +.bi-exclamation-octagon::before { content: "\f337"; } +.bi-exclamation-square-fill::before { content: "\f338"; } +.bi-exclamation-square::before { content: "\f339"; } +.bi-exclamation-triangle-fill::before { content: "\f33a"; } +.bi-exclamation-triangle::before { content: "\f33b"; } +.bi-exclamation::before { content: "\f33c"; } +.bi-exclude::before { content: "\f33d"; } +.bi-eye-fill::before { content: "\f33e"; } +.bi-eye-slash-fill::before { content: "\f33f"; } +.bi-eye-slash::before { content: "\f340"; } +.bi-eye::before { content: "\f341"; } +.bi-eyedropper::before { content: "\f342"; } +.bi-eyeglasses::before { content: "\f343"; } +.bi-facebook::before { content: "\f344"; } +.bi-file-arrow-down-fill::before { content: "\f345"; } +.bi-file-arrow-down::before { content: "\f346"; } +.bi-file-arrow-up-fill::before { content: "\f347"; } +.bi-file-arrow-up::before { content: "\f348"; } +.bi-file-bar-graph-fill::before { content: "\f349"; } +.bi-file-bar-graph::before { content: "\f34a"; } +.bi-file-binary-fill::before { content: "\f34b"; } +.bi-file-binary::before { content: "\f34c"; } +.bi-file-break-fill::before { content: "\f34d"; } +.bi-file-break::before { content: "\f34e"; } +.bi-file-check-fill::before { content: "\f34f"; } +.bi-file-check::before { content: "\f350"; } +.bi-file-code-fill::before { content: "\f351"; } +.bi-file-code::before { content: "\f352"; } +.bi-file-diff-fill::before { content: "\f353"; } +.bi-file-diff::before { content: "\f354"; } +.bi-file-earmark-arrow-down-fill::before { content: "\f355"; } +.bi-file-earmark-arrow-down::before { content: "\f356"; } +.bi-file-earmark-arrow-up-fill::before { content: "\f357"; } +.bi-file-earmark-arrow-up::before { content: "\f358"; } +.bi-file-earmark-bar-graph-fill::before { content: "\f359"; } +.bi-file-earmark-bar-graph::before { content: "\f35a"; } +.bi-file-earmark-binary-fill::before { content: "\f35b"; } +.bi-file-earmark-binary::before { content: "\f35c"; } +.bi-file-earmark-break-fill::before { content: "\f35d"; } +.bi-file-earmark-break::before { content: "\f35e"; } +.bi-file-earmark-check-fill::before { content: "\f35f"; } +.bi-file-earmark-check::before { content: "\f360"; } +.bi-file-earmark-code-fill::before { content: "\f361"; } +.bi-file-earmark-code::before { content: "\f362"; } +.bi-file-earmark-diff-fill::before { content: "\f363"; } +.bi-file-earmark-diff::before { content: "\f364"; } +.bi-file-earmark-easel-fill::before { content: "\f365"; } +.bi-file-earmark-easel::before { content: "\f366"; } +.bi-file-earmark-excel-fill::before { content: "\f367"; } +.bi-file-earmark-excel::before { content: "\f368"; } +.bi-file-earmark-fill::before { content: "\f369"; } +.bi-file-earmark-font-fill::before { content: "\f36a"; } +.bi-file-earmark-font::before { content: "\f36b"; } +.bi-file-earmark-image-fill::before { content: "\f36c"; } +.bi-file-earmark-image::before { content: "\f36d"; } +.bi-file-earmark-lock-fill::before { content: "\f36e"; } +.bi-file-earmark-lock::before { content: "\f36f"; } +.bi-file-earmark-lock2-fill::before { content: "\f370"; } +.bi-file-earmark-lock2::before { content: "\f371"; } +.bi-file-earmark-medical-fill::before { content: "\f372"; } +.bi-file-earmark-medical::before { content: "\f373"; } +.bi-file-earmark-minus-fill::before { content: "\f374"; } +.bi-file-earmark-minus::before { content: "\f375"; } +.bi-file-earmark-music-fill::before { content: "\f376"; } +.bi-file-earmark-music::before { content: "\f377"; } +.bi-file-earmark-person-fill::before { content: "\f378"; } +.bi-file-earmark-person::before { content: "\f379"; } +.bi-file-earmark-play-fill::before { content: "\f37a"; } +.bi-file-earmark-play::before { content: "\f37b"; } +.bi-file-earmark-plus-fill::before { content: "\f37c"; } +.bi-file-earmark-plus::before { content: "\f37d"; } +.bi-file-earmark-post-fill::before { content: "\f37e"; } +.bi-file-earmark-post::before { content: "\f37f"; } +.bi-file-earmark-ppt-fill::before { content: "\f380"; } +.bi-file-earmark-ppt::before { content: "\f381"; } +.bi-file-earmark-richtext-fill::before { content: "\f382"; } +.bi-file-earmark-richtext::before { content: "\f383"; } +.bi-file-earmark-ruled-fill::before { content: "\f384"; } +.bi-file-earmark-ruled::before { content: "\f385"; } +.bi-file-earmark-slides-fill::before { content: "\f386"; } +.bi-file-earmark-slides::before { content: "\f387"; } +.bi-file-earmark-spreadsheet-fill::before { content: "\f388"; } +.bi-file-earmark-spreadsheet::before { content: "\f389"; } +.bi-file-earmark-text-fill::before { content: "\f38a"; } +.bi-file-earmark-text::before { content: "\f38b"; } +.bi-file-earmark-word-fill::before { content: "\f38c"; } +.bi-file-earmark-word::before { content: "\f38d"; } +.bi-file-earmark-x-fill::before { content: "\f38e"; } +.bi-file-earmark-x::before { content: "\f38f"; } +.bi-file-earmark-zip-fill::before { content: "\f390"; } +.bi-file-earmark-zip::before { content: "\f391"; } +.bi-file-earmark::before { content: "\f392"; } +.bi-file-easel-fill::before { content: "\f393"; } +.bi-file-easel::before { content: "\f394"; } +.bi-file-excel-fill::before { content: "\f395"; } +.bi-file-excel::before { content: "\f396"; } +.bi-file-fill::before { content: "\f397"; } +.bi-file-font-fill::before { content: "\f398"; } +.bi-file-font::before { content: "\f399"; } +.bi-file-image-fill::before { content: "\f39a"; } +.bi-file-image::before { content: "\f39b"; } +.bi-file-lock-fill::before { content: "\f39c"; } +.bi-file-lock::before { content: "\f39d"; } +.bi-file-lock2-fill::before { content: "\f39e"; } +.bi-file-lock2::before { content: "\f39f"; } +.bi-file-medical-fill::before { content: "\f3a0"; } +.bi-file-medical::before { content: "\f3a1"; } +.bi-file-minus-fill::before { content: "\f3a2"; } +.bi-file-minus::before { content: "\f3a3"; } +.bi-file-music-fill::before { content: "\f3a4"; } +.bi-file-music::before { content: "\f3a5"; } +.bi-file-person-fill::before { content: "\f3a6"; } +.bi-file-person::before { content: "\f3a7"; } +.bi-file-play-fill::before { content: "\f3a8"; } +.bi-file-play::before { content: "\f3a9"; } +.bi-file-plus-fill::before { content: "\f3aa"; } +.bi-file-plus::before { content: "\f3ab"; } +.bi-file-post-fill::before { content: "\f3ac"; } +.bi-file-post::before { content: "\f3ad"; } +.bi-file-ppt-fill::before { content: "\f3ae"; } +.bi-file-ppt::before { content: "\f3af"; } +.bi-file-richtext-fill::before { content: "\f3b0"; } +.bi-file-richtext::before { content: "\f3b1"; } +.bi-file-ruled-fill::before { content: "\f3b2"; } +.bi-file-ruled::before { content: "\f3b3"; } +.bi-file-slides-fill::before { content: "\f3b4"; } +.bi-file-slides::before { content: "\f3b5"; } +.bi-file-spreadsheet-fill::before { content: "\f3b6"; } +.bi-file-spreadsheet::before { content: "\f3b7"; } +.bi-file-text-fill::before { content: "\f3b8"; } +.bi-file-text::before { content: "\f3b9"; } +.bi-file-word-fill::before { content: "\f3ba"; } +.bi-file-word::before { content: "\f3bb"; } +.bi-file-x-fill::before { content: "\f3bc"; } +.bi-file-x::before { content: "\f3bd"; } +.bi-file-zip-fill::before { content: "\f3be"; } +.bi-file-zip::before { content: "\f3bf"; } +.bi-file::before { content: "\f3c0"; } +.bi-files-alt::before { content: "\f3c1"; } +.bi-files::before { content: "\f3c2"; } +.bi-film::before { content: "\f3c3"; } +.bi-filter-circle-fill::before { content: "\f3c4"; } +.bi-filter-circle::before { content: "\f3c5"; } +.bi-filter-left::before { content: "\f3c6"; } +.bi-filter-right::before { content: "\f3c7"; } +.bi-filter-square-fill::before { content: "\f3c8"; } +.bi-filter-square::before { content: "\f3c9"; } +.bi-filter::before { content: "\f3ca"; } +.bi-flag-fill::before { content: "\f3cb"; } +.bi-flag::before { content: "\f3cc"; } +.bi-flower1::before { content: "\f3cd"; } +.bi-flower2::before { content: "\f3ce"; } +.bi-flower3::before { content: "\f3cf"; } +.bi-folder-check::before { content: "\f3d0"; } +.bi-folder-fill::before { content: "\f3d1"; } +.bi-folder-minus::before { content: "\f3d2"; } +.bi-folder-plus::before { content: "\f3d3"; } +.bi-folder-symlink-fill::before { content: "\f3d4"; } +.bi-folder-symlink::before { content: "\f3d5"; } +.bi-folder-x::before { content: "\f3d6"; } +.bi-folder::before { content: "\f3d7"; } +.bi-folder2-open::before { content: "\f3d8"; } +.bi-folder2::before { content: "\f3d9"; } +.bi-fonts::before { content: "\f3da"; } +.bi-forward-fill::before { content: "\f3db"; } +.bi-forward::before { content: "\f3dc"; } +.bi-front::before { content: "\f3dd"; } +.bi-fullscreen-exit::before { content: "\f3de"; } +.bi-fullscreen::before { content: "\f3df"; } +.bi-funnel-fill::before { content: "\f3e0"; } +.bi-funnel::before { content: "\f3e1"; } +.bi-gear-fill::before { content: "\f3e2"; } +.bi-gear-wide-connected::before { content: "\f3e3"; } +.bi-gear-wide::before { content: "\f3e4"; } +.bi-gear::before { content: "\f3e5"; } +.bi-gem::before { content: "\f3e6"; } +.bi-geo-alt-fill::before { content: "\f3e7"; } +.bi-geo-alt::before { content: "\f3e8"; } +.bi-geo-fill::before { content: "\f3e9"; } +.bi-geo::before { content: "\f3ea"; } +.bi-gift-fill::before { content: "\f3eb"; } +.bi-gift::before { content: "\f3ec"; } +.bi-github::before { content: "\f3ed"; } +.bi-globe::before { content: "\f3ee"; } +.bi-globe2::before { content: "\f3ef"; } +.bi-google::before { content: "\f3f0"; } +.bi-graph-down::before { content: "\f3f1"; } +.bi-graph-up::before { content: "\f3f2"; } +.bi-grid-1x2-fill::before { content: "\f3f3"; } +.bi-grid-1x2::before { content: "\f3f4"; } +.bi-grid-3x2-gap-fill::before { content: "\f3f5"; } +.bi-grid-3x2-gap::before { content: "\f3f6"; } +.bi-grid-3x2::before { content: "\f3f7"; } +.bi-grid-3x3-gap-fill::before { content: "\f3f8"; } +.bi-grid-3x3-gap::before { content: "\f3f9"; } +.bi-grid-3x3::before { content: "\f3fa"; } +.bi-grid-fill::before { content: "\f3fb"; } +.bi-grid::before { content: "\f3fc"; } +.bi-grip-horizontal::before { content: "\f3fd"; } +.bi-grip-vertical::before { content: "\f3fe"; } +.bi-hammer::before { content: "\f3ff"; } +.bi-hand-index-fill::before { content: "\f400"; } +.bi-hand-index-thumb-fill::before { content: "\f401"; } +.bi-hand-index-thumb::before { content: "\f402"; } +.bi-hand-index::before { content: "\f403"; } +.bi-hand-thumbs-down-fill::before { content: "\f404"; } +.bi-hand-thumbs-down::before { content: "\f405"; } +.bi-hand-thumbs-up-fill::before { content: "\f406"; } +.bi-hand-thumbs-up::before { content: "\f407"; } +.bi-handbag-fill::before { content: "\f408"; } +.bi-handbag::before { content: "\f409"; } +.bi-hash::before { content: "\f40a"; } +.bi-hdd-fill::before { content: "\f40b"; } +.bi-hdd-network-fill::before { content: "\f40c"; } +.bi-hdd-network::before { content: "\f40d"; } +.bi-hdd-rack-fill::before { content: "\f40e"; } +.bi-hdd-rack::before { content: "\f40f"; } +.bi-hdd-stack-fill::before { content: "\f410"; } +.bi-hdd-stack::before { content: "\f411"; } +.bi-hdd::before { content: "\f412"; } +.bi-headphones::before { content: "\f413"; } +.bi-headset::before { content: "\f414"; } +.bi-heart-fill::before { content: "\f415"; } +.bi-heart-half::before { content: "\f416"; } +.bi-heart::before { content: "\f417"; } +.bi-heptagon-fill::before { content: "\f418"; } +.bi-heptagon-half::before { content: "\f419"; } +.bi-heptagon::before { content: "\f41a"; } +.bi-hexagon-fill::before { content: "\f41b"; } +.bi-hexagon-half::before { content: "\f41c"; } +.bi-hexagon::before { content: "\f41d"; } +.bi-hourglass-bottom::before { content: "\f41e"; } +.bi-hourglass-split::before { content: "\f41f"; } +.bi-hourglass-top::before { content: "\f420"; } +.bi-hourglass::before { content: "\f421"; } +.bi-house-door-fill::before { content: "\f422"; } +.bi-house-door::before { content: "\f423"; } +.bi-house-fill::before { content: "\f424"; } +.bi-house::before { content: "\f425"; } +.bi-hr::before { content: "\f426"; } +.bi-hurricane::before { content: "\f427"; } +.bi-image-alt::before { content: "\f428"; } +.bi-image-fill::before { content: "\f429"; } +.bi-image::before { content: "\f42a"; } +.bi-images::before { content: "\f42b"; } +.bi-inbox-fill::before { content: "\f42c"; } +.bi-inbox::before { content: "\f42d"; } +.bi-inboxes-fill::before { content: "\f42e"; } +.bi-inboxes::before { content: "\f42f"; } +.bi-info-circle-fill::before { content: "\f430"; } +.bi-info-circle::before { content: "\f431"; } +.bi-info-square-fill::before { content: "\f432"; } +.bi-info-square::before { content: "\f433"; } +.bi-info::before { content: "\f434"; } +.bi-input-cursor-text::before { content: "\f435"; } +.bi-input-cursor::before { content: "\f436"; } +.bi-instagram::before { content: "\f437"; } +.bi-intersect::before { content: "\f438"; } +.bi-journal-album::before { content: "\f439"; } +.bi-journal-arrow-down::before { content: "\f43a"; } +.bi-journal-arrow-up::before { content: "\f43b"; } +.bi-journal-bookmark-fill::before { content: "\f43c"; } +.bi-journal-bookmark::before { content: "\f43d"; } +.bi-journal-check::before { content: "\f43e"; } +.bi-journal-code::before { content: "\f43f"; } +.bi-journal-medical::before { content: "\f440"; } +.bi-journal-minus::before { content: "\f441"; } +.bi-journal-plus::before { content: "\f442"; } +.bi-journal-richtext::before { content: "\f443"; } +.bi-journal-text::before { content: "\f444"; } +.bi-journal-x::before { content: "\f445"; } +.bi-journal::before { content: "\f446"; } +.bi-journals::before { content: "\f447"; } +.bi-joystick::before { content: "\f448"; } +.bi-justify-left::before { content: "\f449"; } +.bi-justify-right::before { content: "\f44a"; } +.bi-justify::before { content: "\f44b"; } +.bi-kanban-fill::before { content: "\f44c"; } +.bi-kanban::before { content: "\f44d"; } +.bi-key-fill::before { content: "\f44e"; } +.bi-key::before { content: "\f44f"; } +.bi-keyboard-fill::before { content: "\f450"; } +.bi-keyboard::before { content: "\f451"; } +.bi-ladder::before { content: "\f452"; } +.bi-lamp-fill::before { content: "\f453"; } +.bi-lamp::before { content: "\f454"; } +.bi-laptop-fill::before { content: "\f455"; } +.bi-laptop::before { content: "\f456"; } +.bi-layer-backward::before { content: "\f457"; } +.bi-layer-forward::before { content: "\f458"; } +.bi-layers-fill::before { content: "\f459"; } +.bi-layers-half::before { content: "\f45a"; } +.bi-layers::before { content: "\f45b"; } +.bi-layout-sidebar-inset-reverse::before { content: "\f45c"; } +.bi-layout-sidebar-inset::before { content: "\f45d"; } +.bi-layout-sidebar-reverse::before { content: "\f45e"; } +.bi-layout-sidebar::before { content: "\f45f"; } +.bi-layout-split::before { content: "\f460"; } +.bi-layout-text-sidebar-reverse::before { content: "\f461"; } +.bi-layout-text-sidebar::before { content: "\f462"; } +.bi-layout-text-window-reverse::before { content: "\f463"; } +.bi-layout-text-window::before { content: "\f464"; } +.bi-layout-three-columns::before { content: "\f465"; } +.bi-layout-wtf::before { content: "\f466"; } +.bi-life-preserver::before { content: "\f467"; } +.bi-lightbulb-fill::before { content: "\f468"; } +.bi-lightbulb-off-fill::before { content: "\f469"; } +.bi-lightbulb-off::before { content: "\f46a"; } +.bi-lightbulb::before { content: "\f46b"; } +.bi-lightning-charge-fill::before { content: "\f46c"; } +.bi-lightning-charge::before { content: "\f46d"; } +.bi-lightning-fill::before { content: "\f46e"; } +.bi-lightning::before { content: "\f46f"; } +.bi-link-45deg::before { content: "\f470"; } +.bi-link::before { content: "\f471"; } +.bi-linkedin::before { content: "\f472"; } +.bi-list-check::before { content: "\f473"; } +.bi-list-nested::before { content: "\f474"; } +.bi-list-ol::before { content: "\f475"; } +.bi-list-stars::before { content: "\f476"; } +.bi-list-task::before { content: "\f477"; } +.bi-list-ul::before { content: "\f478"; } +.bi-list::before { content: "\f479"; } +.bi-lock-fill::before { content: "\f47a"; } +.bi-lock::before { content: "\f47b"; } +.bi-mailbox::before { content: "\f47c"; } +.bi-mailbox2::before { content: "\f47d"; } +.bi-map-fill::before { content: "\f47e"; } +.bi-map::before { content: "\f47f"; } +.bi-markdown-fill::before { content: "\f480"; } +.bi-markdown::before { content: "\f481"; } +.bi-mask::before { content: "\f482"; } +.bi-megaphone-fill::before { content: "\f483"; } +.bi-megaphone::before { content: "\f484"; } +.bi-menu-app-fill::before { content: "\f485"; } +.bi-menu-app::before { content: "\f486"; } +.bi-menu-button-fill::before { content: "\f487"; } +.bi-menu-button-wide-fill::before { content: "\f488"; } +.bi-menu-button-wide::before { content: "\f489"; } +.bi-menu-button::before { content: "\f48a"; } +.bi-menu-down::before { content: "\f48b"; } +.bi-menu-up::before { content: "\f48c"; } +.bi-mic-fill::before { content: "\f48d"; } +.bi-mic-mute-fill::before { content: "\f48e"; } +.bi-mic-mute::before { content: "\f48f"; } +.bi-mic::before { content: "\f490"; } +.bi-minecart-loaded::before { content: "\f491"; } +.bi-minecart::before { content: "\f492"; } +.bi-moisture::before { content: "\f493"; } +.bi-moon-fill::before { content: "\f494"; } +.bi-moon-stars-fill::before { content: "\f495"; } +.bi-moon-stars::before { content: "\f496"; } +.bi-moon::before { content: "\f497"; } +.bi-mouse-fill::before { content: "\f498"; } +.bi-mouse::before { content: "\f499"; } +.bi-mouse2-fill::before { content: "\f49a"; } +.bi-mouse2::before { content: "\f49b"; } +.bi-mouse3-fill::before { content: "\f49c"; } +.bi-mouse3::before { content: "\f49d"; } +.bi-music-note-beamed::before { content: "\f49e"; } +.bi-music-note-list::before { content: "\f49f"; } +.bi-music-note::before { content: "\f4a0"; } +.bi-music-player-fill::before { content: "\f4a1"; } +.bi-music-player::before { content: "\f4a2"; } +.bi-newspaper::before { content: "\f4a3"; } +.bi-node-minus-fill::before { content: "\f4a4"; } +.bi-node-minus::before { content: "\f4a5"; } +.bi-node-plus-fill::before { content: "\f4a6"; } +.bi-node-plus::before { content: "\f4a7"; } +.bi-nut-fill::before { content: "\f4a8"; } +.bi-nut::before { content: "\f4a9"; } +.bi-octagon-fill::before { content: "\f4aa"; } +.bi-octagon-half::before { content: "\f4ab"; } +.bi-octagon::before { content: "\f4ac"; } +.bi-option::before { content: "\f4ad"; } +.bi-outlet::before { content: "\f4ae"; } +.bi-paint-bucket::before { content: "\f4af"; } +.bi-palette-fill::before { content: "\f4b0"; } +.bi-palette::before { content: "\f4b1"; } +.bi-palette2::before { content: "\f4b2"; } +.bi-paperclip::before { content: "\f4b3"; } +.bi-paragraph::before { content: "\f4b4"; } +.bi-patch-check-fill::before { content: "\f4b5"; } +.bi-patch-check::before { content: "\f4b6"; } +.bi-patch-exclamation-fill::before { content: "\f4b7"; } +.bi-patch-exclamation::before { content: "\f4b8"; } +.bi-patch-minus-fill::before { content: "\f4b9"; } +.bi-patch-minus::before { content: "\f4ba"; } +.bi-patch-plus-fill::before { content: "\f4bb"; } +.bi-patch-plus::before { content: "\f4bc"; } +.bi-patch-question-fill::before { content: "\f4bd"; } +.bi-patch-question::before { content: "\f4be"; } +.bi-pause-btn-fill::before { content: "\f4bf"; } +.bi-pause-btn::before { content: "\f4c0"; } +.bi-pause-circle-fill::before { content: "\f4c1"; } +.bi-pause-circle::before { content: "\f4c2"; } +.bi-pause-fill::before { content: "\f4c3"; } +.bi-pause::before { content: "\f4c4"; } +.bi-peace-fill::before { content: "\f4c5"; } +.bi-peace::before { content: "\f4c6"; } +.bi-pen-fill::before { content: "\f4c7"; } +.bi-pen::before { content: "\f4c8"; } +.bi-pencil-fill::before { content: "\f4c9"; } +.bi-pencil-square::before { content: "\f4ca"; } +.bi-pencil::before { content: "\f4cb"; } +.bi-pentagon-fill::before { content: "\f4cc"; } +.bi-pentagon-half::before { content: "\f4cd"; } +.bi-pentagon::before { content: "\f4ce"; } +.bi-people-fill::before { content: "\f4cf"; } +.bi-people::before { content: "\f4d0"; } +.bi-percent::before { content: "\f4d1"; } +.bi-person-badge-fill::before { content: "\f4d2"; } +.bi-person-badge::before { content: "\f4d3"; } +.bi-person-bounding-box::before { content: "\f4d4"; } +.bi-person-check-fill::before { content: "\f4d5"; } +.bi-person-check::before { content: "\f4d6"; } +.bi-person-circle::before { content: "\f4d7"; } +.bi-person-dash-fill::before { content: "\f4d8"; } +.bi-person-dash::before { content: "\f4d9"; } +.bi-person-fill::before { content: "\f4da"; } +.bi-person-lines-fill::before { content: "\f4db"; } +.bi-person-plus-fill::before { content: "\f4dc"; } +.bi-person-plus::before { content: "\f4dd"; } +.bi-person-square::before { content: "\f4de"; } +.bi-person-x-fill::before { content: "\f4df"; } +.bi-person-x::before { content: "\f4e0"; } +.bi-person::before { content: "\f4e1"; } +.bi-phone-fill::before { content: "\f4e2"; } +.bi-phone-landscape-fill::before { content: "\f4e3"; } +.bi-phone-landscape::before { content: "\f4e4"; } +.bi-phone-vibrate-fill::before { content: "\f4e5"; } +.bi-phone-vibrate::before { content: "\f4e6"; } +.bi-phone::before { content: "\f4e7"; } +.bi-pie-chart-fill::before { content: "\f4e8"; } +.bi-pie-chart::before { content: "\f4e9"; } +.bi-pin-angle-fill::before { content: "\f4ea"; } +.bi-pin-angle::before { content: "\f4eb"; } +.bi-pin-fill::before { content: "\f4ec"; } +.bi-pin::before { content: "\f4ed"; } +.bi-pip-fill::before { content: "\f4ee"; } +.bi-pip::before { content: "\f4ef"; } +.bi-play-btn-fill::before { content: "\f4f0"; } +.bi-play-btn::before { content: "\f4f1"; } +.bi-play-circle-fill::before { content: "\f4f2"; } +.bi-play-circle::before { content: "\f4f3"; } +.bi-play-fill::before { content: "\f4f4"; } +.bi-play::before { content: "\f4f5"; } +.bi-plug-fill::before { content: "\f4f6"; } +.bi-plug::before { content: "\f4f7"; } +.bi-plus-circle-dotted::before { content: "\f4f8"; } +.bi-plus-circle-fill::before { content: "\f4f9"; } +.bi-plus-circle::before { content: "\f4fa"; } +.bi-plus-square-dotted::before { content: "\f4fb"; } +.bi-plus-square-fill::before { content: "\f4fc"; } +.bi-plus-square::before { content: "\f4fd"; } +.bi-plus::before { content: "\f4fe"; } +.bi-power::before { content: "\f4ff"; } +.bi-printer-fill::before { content: "\f500"; } +.bi-printer::before { content: "\f501"; } +.bi-puzzle-fill::before { content: "\f502"; } +.bi-puzzle::before { content: "\f503"; } +.bi-question-circle-fill::before { content: "\f504"; } +.bi-question-circle::before { content: "\f505"; } +.bi-question-diamond-fill::before { content: "\f506"; } +.bi-question-diamond::before { content: "\f507"; } +.bi-question-octagon-fill::before { content: "\f508"; } +.bi-question-octagon::before { content: "\f509"; } +.bi-question-square-fill::before { content: "\f50a"; } +.bi-question-square::before { content: "\f50b"; } +.bi-question::before { content: "\f50c"; } +.bi-rainbow::before { content: "\f50d"; } +.bi-receipt-cutoff::before { content: "\f50e"; } +.bi-receipt::before { content: "\f50f"; } +.bi-reception-0::before { content: "\f510"; } +.bi-reception-1::before { content: "\f511"; } +.bi-reception-2::before { content: "\f512"; } +.bi-reception-3::before { content: "\f513"; } +.bi-reception-4::before { content: "\f514"; } +.bi-record-btn-fill::before { content: "\f515"; } +.bi-record-btn::before { content: "\f516"; } +.bi-record-circle-fill::before { content: "\f517"; } +.bi-record-circle::before { content: "\f518"; } +.bi-record-fill::before { content: "\f519"; } +.bi-record::before { content: "\f51a"; } +.bi-record2-fill::before { content: "\f51b"; } +.bi-record2::before { content: "\f51c"; } +.bi-reply-all-fill::before { content: "\f51d"; } +.bi-reply-all::before { content: "\f51e"; } +.bi-reply-fill::before { content: "\f51f"; } +.bi-reply::before { content: "\f520"; } +.bi-rss-fill::before { content: "\f521"; } +.bi-rss::before { content: "\f522"; } +.bi-rulers::before { content: "\f523"; } +.bi-save-fill::before { content: "\f524"; } +.bi-save::before { content: "\f525"; } +.bi-save2-fill::before { content: "\f526"; } +.bi-save2::before { content: "\f527"; } +.bi-scissors::before { content: "\f528"; } +.bi-screwdriver::before { content: "\f529"; } +.bi-search::before { content: "\f52a"; } +.bi-segmented-nav::before { content: "\f52b"; } +.bi-server::before { content: "\f52c"; } +.bi-share-fill::before { content: "\f52d"; } +.bi-share::before { content: "\f52e"; } +.bi-shield-check::before { content: "\f52f"; } +.bi-shield-exclamation::before { content: "\f530"; } +.bi-shield-fill-check::before { content: "\f531"; } +.bi-shield-fill-exclamation::before { content: "\f532"; } +.bi-shield-fill-minus::before { content: "\f533"; } +.bi-shield-fill-plus::before { content: "\f534"; } +.bi-shield-fill-x::before { content: "\f535"; } +.bi-shield-fill::before { content: "\f536"; } +.bi-shield-lock-fill::before { content: "\f537"; } +.bi-shield-lock::before { content: "\f538"; } +.bi-shield-minus::before { content: "\f539"; } +.bi-shield-plus::before { content: "\f53a"; } +.bi-shield-shaded::before { content: "\f53b"; } +.bi-shield-slash-fill::before { content: "\f53c"; } +.bi-shield-slash::before { content: "\f53d"; } +.bi-shield-x::before { content: "\f53e"; } +.bi-shield::before { content: "\f53f"; } +.bi-shift-fill::before { content: "\f540"; } +.bi-shift::before { content: "\f541"; } +.bi-shop-window::before { content: "\f542"; } +.bi-shop::before { content: "\f543"; } +.bi-shuffle::before { content: "\f544"; } +.bi-signpost-2-fill::before { content: "\f545"; } +.bi-signpost-2::before { content: "\f546"; } +.bi-signpost-fill::before { content: "\f547"; } +.bi-signpost-split-fill::before { content: "\f548"; } +.bi-signpost-split::before { content: "\f549"; } +.bi-signpost::before { content: "\f54a"; } +.bi-sim-fill::before { content: "\f54b"; } +.bi-sim::before { content: "\f54c"; } +.bi-skip-backward-btn-fill::before { content: "\f54d"; } +.bi-skip-backward-btn::before { content: "\f54e"; } +.bi-skip-backward-circle-fill::before { content: "\f54f"; } +.bi-skip-backward-circle::before { content: "\f550"; } +.bi-skip-backward-fill::before { content: "\f551"; } +.bi-skip-backward::before { content: "\f552"; } +.bi-skip-end-btn-fill::before { content: "\f553"; } +.bi-skip-end-btn::before { content: "\f554"; } +.bi-skip-end-circle-fill::before { content: "\f555"; } +.bi-skip-end-circle::before { content: "\f556"; } +.bi-skip-end-fill::before { content: "\f557"; } +.bi-skip-end::before { content: "\f558"; } +.bi-skip-forward-btn-fill::before { content: "\f559"; } +.bi-skip-forward-btn::before { content: "\f55a"; } +.bi-skip-forward-circle-fill::before { content: "\f55b"; } +.bi-skip-forward-circle::before { content: "\f55c"; } +.bi-skip-forward-fill::before { content: "\f55d"; } +.bi-skip-forward::before { content: "\f55e"; } +.bi-skip-start-btn-fill::before { content: "\f55f"; } +.bi-skip-start-btn::before { content: "\f560"; } +.bi-skip-start-circle-fill::before { content: "\f561"; } +.bi-skip-start-circle::before { content: "\f562"; } +.bi-skip-start-fill::before { content: "\f563"; } +.bi-skip-start::before { content: "\f564"; } +.bi-slack::before { content: "\f565"; } +.bi-slash-circle-fill::before { content: "\f566"; } +.bi-slash-circle::before { content: "\f567"; } +.bi-slash-square-fill::before { content: "\f568"; } +.bi-slash-square::before { content: "\f569"; } +.bi-slash::before { content: "\f56a"; } +.bi-sliders::before { content: "\f56b"; } +.bi-smartwatch::before { content: "\f56c"; } +.bi-snow::before { content: "\f56d"; } +.bi-snow2::before { content: "\f56e"; } +.bi-snow3::before { content: "\f56f"; } +.bi-sort-alpha-down-alt::before { content: "\f570"; } +.bi-sort-alpha-down::before { content: "\f571"; } +.bi-sort-alpha-up-alt::before { content: "\f572"; } +.bi-sort-alpha-up::before { content: "\f573"; } +.bi-sort-down-alt::before { content: "\f574"; } +.bi-sort-down::before { content: "\f575"; } +.bi-sort-numeric-down-alt::before { content: "\f576"; } +.bi-sort-numeric-down::before { content: "\f577"; } +.bi-sort-numeric-up-alt::before { content: "\f578"; } +.bi-sort-numeric-up::before { content: "\f579"; } +.bi-sort-up-alt::before { content: "\f57a"; } +.bi-sort-up::before { content: "\f57b"; } +.bi-soundwave::before { content: "\f57c"; } +.bi-speaker-fill::before { content: "\f57d"; } +.bi-speaker::before { content: "\f57e"; } +.bi-speedometer::before { content: "\f57f"; } +.bi-speedometer2::before { content: "\f580"; } +.bi-spellcheck::before { content: "\f581"; } +.bi-square-fill::before { content: "\f582"; } +.bi-square-half::before { content: "\f583"; } +.bi-square::before { content: "\f584"; } +.bi-stack::before { content: "\f585"; } +.bi-star-fill::before { content: "\f586"; } +.bi-star-half::before { content: "\f587"; } +.bi-star::before { content: "\f588"; } +.bi-stars::before { content: "\f589"; } +.bi-stickies-fill::before { content: "\f58a"; } +.bi-stickies::before { content: "\f58b"; } +.bi-sticky-fill::before { content: "\f58c"; } +.bi-sticky::before { content: "\f58d"; } +.bi-stop-btn-fill::before { content: "\f58e"; } +.bi-stop-btn::before { content: "\f58f"; } +.bi-stop-circle-fill::before { content: "\f590"; } +.bi-stop-circle::before { content: "\f591"; } +.bi-stop-fill::before { content: "\f592"; } +.bi-stop::before { content: "\f593"; } +.bi-stoplights-fill::before { content: "\f594"; } +.bi-stoplights::before { content: "\f595"; } +.bi-stopwatch-fill::before { content: "\f596"; } +.bi-stopwatch::before { content: "\f597"; } +.bi-subtract::before { content: "\f598"; } +.bi-suit-club-fill::before { content: "\f599"; } +.bi-suit-club::before { content: "\f59a"; } +.bi-suit-diamond-fill::before { content: "\f59b"; } +.bi-suit-diamond::before { content: "\f59c"; } +.bi-suit-heart-fill::before { content: "\f59d"; } +.bi-suit-heart::before { content: "\f59e"; } +.bi-suit-spade-fill::before { content: "\f59f"; } +.bi-suit-spade::before { content: "\f5a0"; } +.bi-sun-fill::before { content: "\f5a1"; } +.bi-sun::before { content: "\f5a2"; } +.bi-sunglasses::before { content: "\f5a3"; } +.bi-sunrise-fill::before { content: "\f5a4"; } +.bi-sunrise::before { content: "\f5a5"; } +.bi-sunset-fill::before { content: "\f5a6"; } +.bi-sunset::before { content: "\f5a7"; } +.bi-symmetry-horizontal::before { content: "\f5a8"; } +.bi-symmetry-vertical::before { content: "\f5a9"; } +.bi-table::before { content: "\f5aa"; } +.bi-tablet-fill::before { content: "\f5ab"; } +.bi-tablet-landscape-fill::before { content: "\f5ac"; } +.bi-tablet-landscape::before { content: "\f5ad"; } +.bi-tablet::before { content: "\f5ae"; } +.bi-tag-fill::before { content: "\f5af"; } +.bi-tag::before { content: "\f5b0"; } +.bi-tags-fill::before { content: "\f5b1"; } +.bi-tags::before { content: "\f5b2"; } +.bi-telegram::before { content: "\f5b3"; } +.bi-telephone-fill::before { content: "\f5b4"; } +.bi-telephone-forward-fill::before { content: "\f5b5"; } +.bi-telephone-forward::before { content: "\f5b6"; } +.bi-telephone-inbound-fill::before { content: "\f5b7"; } +.bi-telephone-inbound::before { content: "\f5b8"; } +.bi-telephone-minus-fill::before { content: "\f5b9"; } +.bi-telephone-minus::before { content: "\f5ba"; } +.bi-telephone-outbound-fill::before { content: "\f5bb"; } +.bi-telephone-outbound::before { content: "\f5bc"; } +.bi-telephone-plus-fill::before { content: "\f5bd"; } +.bi-telephone-plus::before { content: "\f5be"; } +.bi-telephone-x-fill::before { content: "\f5bf"; } +.bi-telephone-x::before { content: "\f5c0"; } +.bi-telephone::before { content: "\f5c1"; } +.bi-terminal-fill::before { content: "\f5c2"; } +.bi-terminal::before { content: "\f5c3"; } +.bi-text-center::before { content: "\f5c4"; } +.bi-text-indent-left::before { content: "\f5c5"; } +.bi-text-indent-right::before { content: "\f5c6"; } +.bi-text-left::before { content: "\f5c7"; } +.bi-text-paragraph::before { content: "\f5c8"; } +.bi-text-right::before { content: "\f5c9"; } +.bi-textarea-resize::before { content: "\f5ca"; } +.bi-textarea-t::before { content: "\f5cb"; } +.bi-textarea::before { content: "\f5cc"; } +.bi-thermometer-half::before { content: "\f5cd"; } +.bi-thermometer-high::before { content: "\f5ce"; } +.bi-thermometer-low::before { content: "\f5cf"; } +.bi-thermometer-snow::before { content: "\f5d0"; } +.bi-thermometer-sun::before { content: "\f5d1"; } +.bi-thermometer::before { content: "\f5d2"; } +.bi-three-dots-vertical::before { content: "\f5d3"; } +.bi-three-dots::before { content: "\f5d4"; } +.bi-toggle-off::before { content: "\f5d5"; } +.bi-toggle-on::before { content: "\f5d6"; } +.bi-toggle2-off::before { content: "\f5d7"; } +.bi-toggle2-on::before { content: "\f5d8"; } +.bi-toggles::before { content: "\f5d9"; } +.bi-toggles2::before { content: "\f5da"; } +.bi-tools::before { content: "\f5db"; } +.bi-tornado::before { content: "\f5dc"; } +.bi-trash-fill::before { content: "\f5dd"; } +.bi-trash::before { content: "\f5de"; } +.bi-trash2-fill::before { content: "\f5df"; } +.bi-trash2::before { content: "\f5e0"; } +.bi-tree-fill::before { content: "\f5e1"; } +.bi-tree::before { content: "\f5e2"; } +.bi-triangle-fill::before { content: "\f5e3"; } +.bi-triangle-half::before { content: "\f5e4"; } +.bi-triangle::before { content: "\f5e5"; } +.bi-trophy-fill::before { content: "\f5e6"; } +.bi-trophy::before { content: "\f5e7"; } +.bi-tropical-storm::before { content: "\f5e8"; } +.bi-truck-flatbed::before { content: "\f5e9"; } +.bi-truck::before { content: "\f5ea"; } +.bi-tsunami::before { content: "\f5eb"; } +.bi-tv-fill::before { content: "\f5ec"; } +.bi-tv::before { content: "\f5ed"; } +.bi-twitch::before { content: "\f5ee"; } +.bi-twitter::before { content: "\f5ef"; } +.bi-type-bold::before { content: "\f5f0"; } +.bi-type-h1::before { content: "\f5f1"; } +.bi-type-h2::before { content: "\f5f2"; } +.bi-type-h3::before { content: "\f5f3"; } +.bi-type-italic::before { content: "\f5f4"; } +.bi-type-strikethrough::before { content: "\f5f5"; } +.bi-type-underline::before { content: "\f5f6"; } +.bi-type::before { content: "\f5f7"; } +.bi-ui-checks-grid::before { content: "\f5f8"; } +.bi-ui-checks::before { content: "\f5f9"; } +.bi-ui-radios-grid::before { content: "\f5fa"; } +.bi-ui-radios::before { content: "\f5fb"; } +.bi-umbrella-fill::before { content: "\f5fc"; } +.bi-umbrella::before { content: "\f5fd"; } +.bi-union::before { content: "\f5fe"; } +.bi-unlock-fill::before { content: "\f5ff"; } +.bi-unlock::before { content: "\f600"; } +.bi-upc-scan::before { content: "\f601"; } +.bi-upc::before { content: "\f602"; } +.bi-upload::before { content: "\f603"; } +.bi-vector-pen::before { content: "\f604"; } +.bi-view-list::before { content: "\f605"; } +.bi-view-stacked::before { content: "\f606"; } +.bi-vinyl-fill::before { content: "\f607"; } +.bi-vinyl::before { content: "\f608"; } +.bi-voicemail::before { content: "\f609"; } +.bi-volume-down-fill::before { content: "\f60a"; } +.bi-volume-down::before { content: "\f60b"; } +.bi-volume-mute-fill::before { content: "\f60c"; } +.bi-volume-mute::before { content: "\f60d"; } +.bi-volume-off-fill::before { content: "\f60e"; } +.bi-volume-off::before { content: "\f60f"; } +.bi-volume-up-fill::before { content: "\f610"; } +.bi-volume-up::before { content: "\f611"; } +.bi-vr::before { content: "\f612"; } +.bi-wallet-fill::before { content: "\f613"; } +.bi-wallet::before { content: "\f614"; } +.bi-wallet2::before { content: "\f615"; } +.bi-watch::before { content: "\f616"; } +.bi-water::before { content: "\f617"; } +.bi-whatsapp::before { content: "\f618"; } +.bi-wifi-1::before { content: "\f619"; } +.bi-wifi-2::before { content: "\f61a"; } +.bi-wifi-off::before { content: "\f61b"; } +.bi-wifi::before { content: "\f61c"; } +.bi-wind::before { content: "\f61d"; } +.bi-window-dock::before { content: "\f61e"; } +.bi-window-sidebar::before { content: "\f61f"; } +.bi-window::before { content: "\f620"; } +.bi-wrench::before { content: "\f621"; } +.bi-x-circle-fill::before { content: "\f622"; } +.bi-x-circle::before { content: "\f623"; } +.bi-x-diamond-fill::before { content: "\f624"; } +.bi-x-diamond::before { content: "\f625"; } +.bi-x-octagon-fill::before { content: "\f626"; } +.bi-x-octagon::before { content: "\f627"; } +.bi-x-square-fill::before { content: "\f628"; } +.bi-x-square::before { content: "\f629"; } +.bi-x::before { content: "\f62a"; } +.bi-youtube::before { content: "\f62b"; } +.bi-zoom-in::before { content: "\f62c"; } +.bi-zoom-out::before { content: "\f62d"; } +.bi-bank::before { content: "\f62e"; } +.bi-bank2::before { content: "\f62f"; } +.bi-bell-slash-fill::before { content: "\f630"; } +.bi-bell-slash::before { content: "\f631"; } +.bi-cash-coin::before { content: "\f632"; } +.bi-check-lg::before { content: "\f633"; } +.bi-coin::before { content: "\f634"; } +.bi-currency-bitcoin::before { content: "\f635"; } +.bi-currency-dollar::before { content: "\f636"; } +.bi-currency-euro::before { content: "\f637"; } +.bi-currency-exchange::before { content: "\f638"; } +.bi-currency-pound::before { content: "\f639"; } +.bi-currency-yen::before { content: "\f63a"; } +.bi-dash-lg::before { content: "\f63b"; } +.bi-exclamation-lg::before { content: "\f63c"; } +.bi-file-earmark-pdf-fill::before { content: "\f63d"; } +.bi-file-earmark-pdf::before { content: "\f63e"; } +.bi-file-pdf-fill::before { content: "\f63f"; } +.bi-file-pdf::before { content: "\f640"; } +.bi-gender-ambiguous::before { content: "\f641"; } +.bi-gender-female::before { content: "\f642"; } +.bi-gender-male::before { content: "\f643"; } +.bi-gender-trans::before { content: "\f644"; } +.bi-headset-vr::before { content: "\f645"; } +.bi-info-lg::before { content: "\f646"; } +.bi-mastodon::before { content: "\f647"; } +.bi-messenger::before { content: "\f648"; } +.bi-piggy-bank-fill::before { content: "\f649"; } +.bi-piggy-bank::before { content: "\f64a"; } +.bi-pin-map-fill::before { content: "\f64b"; } +.bi-pin-map::before { content: "\f64c"; } +.bi-plus-lg::before { content: "\f64d"; } +.bi-question-lg::before { content: "\f64e"; } +.bi-recycle::before { content: "\f64f"; } +.bi-reddit::before { content: "\f650"; } +.bi-safe-fill::before { content: "\f651"; } +.bi-safe2-fill::before { content: "\f652"; } +.bi-safe2::before { content: "\f653"; } +.bi-sd-card-fill::before { content: "\f654"; } +.bi-sd-card::before { content: "\f655"; } +.bi-skype::before { content: "\f656"; } +.bi-slash-lg::before { content: "\f657"; } +.bi-translate::before { content: "\f658"; } +.bi-x-lg::before { content: "\f659"; } +.bi-safe::before { content: "\f65a"; } +.bi-apple::before { content: "\f65b"; } +.bi-microsoft::before { content: "\f65d"; } +.bi-windows::before { content: "\f65e"; } +.bi-behance::before { content: "\f65c"; } +.bi-dribbble::before { content: "\f65f"; } +.bi-line::before { content: "\f660"; } +.bi-medium::before { content: "\f661"; } +.bi-paypal::before { content: "\f662"; } +.bi-pinterest::before { content: "\f663"; } +.bi-signal::before { content: "\f664"; } +.bi-snapchat::before { content: "\f665"; } +.bi-spotify::before { content: "\f666"; } +.bi-stack-overflow::before { content: "\f667"; } +.bi-strava::before { content: "\f668"; } +.bi-wordpress::before { content: "\f669"; } +.bi-vimeo::before { content: "\f66a"; } +.bi-activity::before { content: "\f66b"; } +.bi-easel2-fill::before { content: "\f66c"; } +.bi-easel2::before { content: "\f66d"; } +.bi-easel3-fill::before { content: "\f66e"; } +.bi-easel3::before { content: "\f66f"; } +.bi-fan::before { content: "\f670"; } +.bi-fingerprint::before { content: "\f671"; } +.bi-graph-down-arrow::before { content: "\f672"; } +.bi-graph-up-arrow::before { content: "\f673"; } +.bi-hypnotize::before { content: "\f674"; } +.bi-magic::before { content: "\f675"; } +.bi-person-rolodex::before { content: "\f676"; } +.bi-person-video::before { content: "\f677"; } +.bi-person-video2::before { content: "\f678"; } +.bi-person-video3::before { content: "\f679"; } +.bi-person-workspace::before { content: "\f67a"; } +.bi-radioactive::before { content: "\f67b"; } +.bi-webcam-fill::before { content: "\f67c"; } +.bi-webcam::before { content: "\f67d"; } +.bi-yin-yang::before { content: "\f67e"; } +.bi-bandaid-fill::before { content: "\f680"; } +.bi-bandaid::before { content: "\f681"; } +.bi-bluetooth::before { content: "\f682"; } +.bi-body-text::before { content: "\f683"; } +.bi-boombox::before { content: "\f684"; } +.bi-boxes::before { content: "\f685"; } +.bi-dpad-fill::before { content: "\f686"; } +.bi-dpad::before { content: "\f687"; } +.bi-ear-fill::before { content: "\f688"; } +.bi-ear::before { content: "\f689"; } +.bi-envelope-check-fill::before { content: "\f68b"; } +.bi-envelope-check::before { content: "\f68c"; } +.bi-envelope-dash-fill::before { content: "\f68e"; } +.bi-envelope-dash::before { content: "\f68f"; } +.bi-envelope-exclamation-fill::before { content: "\f691"; } +.bi-envelope-exclamation::before { content: "\f692"; } +.bi-envelope-plus-fill::before { content: "\f693"; } +.bi-envelope-plus::before { content: "\f694"; } +.bi-envelope-slash-fill::before { content: "\f696"; } +.bi-envelope-slash::before { content: "\f697"; } +.bi-envelope-x-fill::before { content: "\f699"; } +.bi-envelope-x::before { content: "\f69a"; } +.bi-explicit-fill::before { content: "\f69b"; } +.bi-explicit::before { content: "\f69c"; } +.bi-git::before { content: "\f69d"; } +.bi-infinity::before { content: "\f69e"; } +.bi-list-columns-reverse::before { content: "\f69f"; } +.bi-list-columns::before { content: "\f6a0"; } +.bi-meta::before { content: "\f6a1"; } +.bi-nintendo-switch::before { content: "\f6a4"; } +.bi-pc-display-horizontal::before { content: "\f6a5"; } +.bi-pc-display::before { content: "\f6a6"; } +.bi-pc-horizontal::before { content: "\f6a7"; } +.bi-pc::before { content: "\f6a8"; } +.bi-playstation::before { content: "\f6a9"; } +.bi-plus-slash-minus::before { content: "\f6aa"; } +.bi-projector-fill::before { content: "\f6ab"; } +.bi-projector::before { content: "\f6ac"; } +.bi-qr-code-scan::before { content: "\f6ad"; } +.bi-qr-code::before { content: "\f6ae"; } +.bi-quora::before { content: "\f6af"; } +.bi-quote::before { content: "\f6b0"; } +.bi-robot::before { content: "\f6b1"; } +.bi-send-check-fill::before { content: "\f6b2"; } +.bi-send-check::before { content: "\f6b3"; } +.bi-send-dash-fill::before { content: "\f6b4"; } +.bi-send-dash::before { content: "\f6b5"; } +.bi-send-exclamation-fill::before { content: "\f6b7"; } +.bi-send-exclamation::before { content: "\f6b8"; } +.bi-send-fill::before { content: "\f6b9"; } +.bi-send-plus-fill::before { content: "\f6ba"; } +.bi-send-plus::before { content: "\f6bb"; } +.bi-send-slash-fill::before { content: "\f6bc"; } +.bi-send-slash::before { content: "\f6bd"; } +.bi-send-x-fill::before { content: "\f6be"; } +.bi-send-x::before { content: "\f6bf"; } +.bi-send::before { content: "\f6c0"; } +.bi-steam::before { content: "\f6c1"; } +.bi-terminal-dash::before { content: "\f6c3"; } +.bi-terminal-plus::before { content: "\f6c4"; } +.bi-terminal-split::before { content: "\f6c5"; } +.bi-ticket-detailed-fill::before { content: "\f6c6"; } +.bi-ticket-detailed::before { content: "\f6c7"; } +.bi-ticket-fill::before { content: "\f6c8"; } +.bi-ticket-perforated-fill::before { content: "\f6c9"; } +.bi-ticket-perforated::before { content: "\f6ca"; } +.bi-ticket::before { content: "\f6cb"; } +.bi-tiktok::before { content: "\f6cc"; } +.bi-window-dash::before { content: "\f6cd"; } +.bi-window-desktop::before { content: "\f6ce"; } +.bi-window-fullscreen::before { content: "\f6cf"; } +.bi-window-plus::before { content: "\f6d0"; } +.bi-window-split::before { content: "\f6d1"; } +.bi-window-stack::before { content: "\f6d2"; } +.bi-window-x::before { content: "\f6d3"; } +.bi-xbox::before { content: "\f6d4"; } +.bi-ethernet::before { content: "\f6d5"; } +.bi-hdmi-fill::before { content: "\f6d6"; } +.bi-hdmi::before { content: "\f6d7"; } +.bi-usb-c-fill::before { content: "\f6d8"; } +.bi-usb-c::before { content: "\f6d9"; } +.bi-usb-fill::before { content: "\f6da"; } +.bi-usb-plug-fill::before { content: "\f6db"; } +.bi-usb-plug::before { content: "\f6dc"; } +.bi-usb-symbol::before { content: "\f6dd"; } +.bi-usb::before { content: "\f6de"; } +.bi-boombox-fill::before { content: "\f6df"; } +.bi-displayport::before { content: "\f6e1"; } +.bi-gpu-card::before { content: "\f6e2"; } +.bi-memory::before { content: "\f6e3"; } +.bi-modem-fill::before { content: "\f6e4"; } +.bi-modem::before { content: "\f6e5"; } +.bi-motherboard-fill::before { content: "\f6e6"; } +.bi-motherboard::before { content: "\f6e7"; } +.bi-optical-audio-fill::before { content: "\f6e8"; } +.bi-optical-audio::before { content: "\f6e9"; } +.bi-pci-card::before { content: "\f6ea"; } +.bi-router-fill::before { content: "\f6eb"; } +.bi-router::before { content: "\f6ec"; } +.bi-thunderbolt-fill::before { content: "\f6ef"; } +.bi-thunderbolt::before { content: "\f6f0"; } +.bi-usb-drive-fill::before { content: "\f6f1"; } +.bi-usb-drive::before { content: "\f6f2"; } +.bi-usb-micro-fill::before { content: "\f6f3"; } +.bi-usb-micro::before { content: "\f6f4"; } +.bi-usb-mini-fill::before { content: "\f6f5"; } +.bi-usb-mini::before { content: "\f6f6"; } +.bi-cloud-haze2::before { content: "\f6f7"; } +.bi-device-hdd-fill::before { content: "\f6f8"; } +.bi-device-hdd::before { content: "\f6f9"; } +.bi-device-ssd-fill::before { content: "\f6fa"; } +.bi-device-ssd::before { content: "\f6fb"; } +.bi-displayport-fill::before { content: "\f6fc"; } +.bi-mortarboard-fill::before { content: "\f6fd"; } +.bi-mortarboard::before { content: "\f6fe"; } +.bi-terminal-x::before { content: "\f6ff"; } +.bi-arrow-through-heart-fill::before { content: "\f700"; } +.bi-arrow-through-heart::before { content: "\f701"; } +.bi-badge-sd-fill::before { content: "\f702"; } +.bi-badge-sd::before { content: "\f703"; } +.bi-bag-heart-fill::before { content: "\f704"; } +.bi-bag-heart::before { content: "\f705"; } +.bi-balloon-fill::before { content: "\f706"; } +.bi-balloon-heart-fill::before { content: "\f707"; } +.bi-balloon-heart::before { content: "\f708"; } +.bi-balloon::before { content: "\f709"; } +.bi-box2-fill::before { content: "\f70a"; } +.bi-box2-heart-fill::before { content: "\f70b"; } +.bi-box2-heart::before { content: "\f70c"; } +.bi-box2::before { content: "\f70d"; } +.bi-braces-asterisk::before { content: "\f70e"; } +.bi-calendar-heart-fill::before { content: "\f70f"; } +.bi-calendar-heart::before { content: "\f710"; } +.bi-calendar2-heart-fill::before { content: "\f711"; } +.bi-calendar2-heart::before { content: "\f712"; } +.bi-chat-heart-fill::before { content: "\f713"; } +.bi-chat-heart::before { content: "\f714"; } +.bi-chat-left-heart-fill::before { content: "\f715"; } +.bi-chat-left-heart::before { content: "\f716"; } +.bi-chat-right-heart-fill::before { content: "\f717"; } +.bi-chat-right-heart::before { content: "\f718"; } +.bi-chat-square-heart-fill::before { content: "\f719"; } +.bi-chat-square-heart::before { content: "\f71a"; } +.bi-clipboard-check-fill::before { content: "\f71b"; } +.bi-clipboard-data-fill::before { content: "\f71c"; } +.bi-clipboard-fill::before { content: "\f71d"; } +.bi-clipboard-heart-fill::before { content: "\f71e"; } +.bi-clipboard-heart::before { content: "\f71f"; } +.bi-clipboard-minus-fill::before { content: "\f720"; } +.bi-clipboard-plus-fill::before { content: "\f721"; } +.bi-clipboard-pulse::before { content: "\f722"; } +.bi-clipboard-x-fill::before { content: "\f723"; } +.bi-clipboard2-check-fill::before { content: "\f724"; } +.bi-clipboard2-check::before { content: "\f725"; } +.bi-clipboard2-data-fill::before { content: "\f726"; } +.bi-clipboard2-data::before { content: "\f727"; } +.bi-clipboard2-fill::before { content: "\f728"; } +.bi-clipboard2-heart-fill::before { content: "\f729"; } +.bi-clipboard2-heart::before { content: "\f72a"; } +.bi-clipboard2-minus-fill::before { content: "\f72b"; } +.bi-clipboard2-minus::before { content: "\f72c"; } +.bi-clipboard2-plus-fill::before { content: "\f72d"; } +.bi-clipboard2-plus::before { content: "\f72e"; } +.bi-clipboard2-pulse-fill::before { content: "\f72f"; } +.bi-clipboard2-pulse::before { content: "\f730"; } +.bi-clipboard2-x-fill::before { content: "\f731"; } +.bi-clipboard2-x::before { content: "\f732"; } +.bi-clipboard2::before { content: "\f733"; } +.bi-emoji-kiss-fill::before { content: "\f734"; } +.bi-emoji-kiss::before { content: "\f735"; } +.bi-envelope-heart-fill::before { content: "\f736"; } +.bi-envelope-heart::before { content: "\f737"; } +.bi-envelope-open-heart-fill::before { content: "\f738"; } +.bi-envelope-open-heart::before { content: "\f739"; } +.bi-envelope-paper-fill::before { content: "\f73a"; } +.bi-envelope-paper-heart-fill::before { content: "\f73b"; } +.bi-envelope-paper-heart::before { content: "\f73c"; } +.bi-envelope-paper::before { content: "\f73d"; } +.bi-filetype-aac::before { content: "\f73e"; } +.bi-filetype-ai::before { content: "\f73f"; } +.bi-filetype-bmp::before { content: "\f740"; } +.bi-filetype-cs::before { content: "\f741"; } +.bi-filetype-css::before { content: "\f742"; } +.bi-filetype-csv::before { content: "\f743"; } +.bi-filetype-doc::before { content: "\f744"; } +.bi-filetype-docx::before { content: "\f745"; } +.bi-filetype-exe::before { content: "\f746"; } +.bi-filetype-gif::before { content: "\f747"; } +.bi-filetype-heic::before { content: "\f748"; } +.bi-filetype-html::before { content: "\f749"; } +.bi-filetype-java::before { content: "\f74a"; } +.bi-filetype-jpg::before { content: "\f74b"; } +.bi-filetype-js::before { content: "\f74c"; } +.bi-filetype-jsx::before { content: "\f74d"; } +.bi-filetype-key::before { content: "\f74e"; } +.bi-filetype-m4p::before { content: "\f74f"; } +.bi-filetype-md::before { content: "\f750"; } +.bi-filetype-mdx::before { content: "\f751"; } +.bi-filetype-mov::before { content: "\f752"; } +.bi-filetype-mp3::before { content: "\f753"; } +.bi-filetype-mp4::before { content: "\f754"; } +.bi-filetype-otf::before { content: "\f755"; } +.bi-filetype-pdf::before { content: "\f756"; } +.bi-filetype-php::before { content: "\f757"; } +.bi-filetype-png::before { content: "\f758"; } +.bi-filetype-ppt::before { content: "\f75a"; } +.bi-filetype-psd::before { content: "\f75b"; } +.bi-filetype-py::before { content: "\f75c"; } +.bi-filetype-raw::before { content: "\f75d"; } +.bi-filetype-rb::before { content: "\f75e"; } +.bi-filetype-sass::before { content: "\f75f"; } +.bi-filetype-scss::before { content: "\f760"; } +.bi-filetype-sh::before { content: "\f761"; } +.bi-filetype-svg::before { content: "\f762"; } +.bi-filetype-tiff::before { content: "\f763"; } +.bi-filetype-tsx::before { content: "\f764"; } +.bi-filetype-ttf::before { content: "\f765"; } +.bi-filetype-txt::before { content: "\f766"; } +.bi-filetype-wav::before { content: "\f767"; } +.bi-filetype-woff::before { content: "\f768"; } +.bi-filetype-xls::before { content: "\f76a"; } +.bi-filetype-xml::before { content: "\f76b"; } +.bi-filetype-yml::before { content: "\f76c"; } +.bi-heart-arrow::before { content: "\f76d"; } +.bi-heart-pulse-fill::before { content: "\f76e"; } +.bi-heart-pulse::before { content: "\f76f"; } +.bi-heartbreak-fill::before { content: "\f770"; } +.bi-heartbreak::before { content: "\f771"; } +.bi-hearts::before { content: "\f772"; } +.bi-hospital-fill::before { content: "\f773"; } +.bi-hospital::before { content: "\f774"; } +.bi-house-heart-fill::before { content: "\f775"; } +.bi-house-heart::before { content: "\f776"; } +.bi-incognito::before { content: "\f777"; } +.bi-magnet-fill::before { content: "\f778"; } +.bi-magnet::before { content: "\f779"; } +.bi-person-heart::before { content: "\f77a"; } +.bi-person-hearts::before { content: "\f77b"; } +.bi-phone-flip::before { content: "\f77c"; } +.bi-plugin::before { content: "\f77d"; } +.bi-postage-fill::before { content: "\f77e"; } +.bi-postage-heart-fill::before { content: "\f77f"; } +.bi-postage-heart::before { content: "\f780"; } +.bi-postage::before { content: "\f781"; } +.bi-postcard-fill::before { content: "\f782"; } +.bi-postcard-heart-fill::before { content: "\f783"; } +.bi-postcard-heart::before { content: "\f784"; } +.bi-postcard::before { content: "\f785"; } +.bi-search-heart-fill::before { content: "\f786"; } +.bi-search-heart::before { content: "\f787"; } +.bi-sliders2-vertical::before { content: "\f788"; } +.bi-sliders2::before { content: "\f789"; } +.bi-trash3-fill::before { content: "\f78a"; } +.bi-trash3::before { content: "\f78b"; } +.bi-valentine::before { content: "\f78c"; } +.bi-valentine2::before { content: "\f78d"; } +.bi-wrench-adjustable-circle-fill::before { content: "\f78e"; } +.bi-wrench-adjustable-circle::before { content: "\f78f"; } +.bi-wrench-adjustable::before { content: "\f790"; } +.bi-filetype-json::before { content: "\f791"; } +.bi-filetype-pptx::before { content: "\f792"; } +.bi-filetype-xlsx::before { content: "\f793"; } +.bi-1-circle-fill::before { content: "\f796"; } +.bi-1-circle::before { content: "\f797"; } +.bi-1-square-fill::before { content: "\f798"; } +.bi-1-square::before { content: "\f799"; } +.bi-2-circle-fill::before { content: "\f79c"; } +.bi-2-circle::before { content: "\f79d"; } +.bi-2-square-fill::before { content: "\f79e"; } +.bi-2-square::before { content: "\f79f"; } +.bi-3-circle-fill::before { content: "\f7a2"; } +.bi-3-circle::before { content: "\f7a3"; } +.bi-3-square-fill::before { content: "\f7a4"; } +.bi-3-square::before { content: "\f7a5"; } +.bi-4-circle-fill::before { content: "\f7a8"; } +.bi-4-circle::before { content: "\f7a9"; } +.bi-4-square-fill::before { content: "\f7aa"; } +.bi-4-square::before { content: "\f7ab"; } +.bi-5-circle-fill::before { content: "\f7ae"; } +.bi-5-circle::before { content: "\f7af"; } +.bi-5-square-fill::before { content: "\f7b0"; } +.bi-5-square::before { content: "\f7b1"; } +.bi-6-circle-fill::before { content: "\f7b4"; } +.bi-6-circle::before { content: "\f7b5"; } +.bi-6-square-fill::before { content: "\f7b6"; } +.bi-6-square::before { content: "\f7b7"; } +.bi-7-circle-fill::before { content: "\f7ba"; } +.bi-7-circle::before { content: "\f7bb"; } +.bi-7-square-fill::before { content: "\f7bc"; } +.bi-7-square::before { content: "\f7bd"; } +.bi-8-circle-fill::before { content: "\f7c0"; } +.bi-8-circle::before { content: "\f7c1"; } +.bi-8-square-fill::before { content: "\f7c2"; } +.bi-8-square::before { content: "\f7c3"; } +.bi-9-circle-fill::before { content: "\f7c6"; } +.bi-9-circle::before { content: "\f7c7"; } +.bi-9-square-fill::before { content: "\f7c8"; } +.bi-9-square::before { content: "\f7c9"; } +.bi-airplane-engines-fill::before { content: "\f7ca"; } +.bi-airplane-engines::before { content: "\f7cb"; } +.bi-airplane-fill::before { content: "\f7cc"; } +.bi-airplane::before { content: "\f7cd"; } +.bi-alexa::before { content: "\f7ce"; } +.bi-alipay::before { content: "\f7cf"; } +.bi-android::before { content: "\f7d0"; } +.bi-android2::before { content: "\f7d1"; } +.bi-box-fill::before { content: "\f7d2"; } +.bi-box-seam-fill::before { content: "\f7d3"; } +.bi-browser-chrome::before { content: "\f7d4"; } +.bi-browser-edge::before { content: "\f7d5"; } +.bi-browser-firefox::before { content: "\f7d6"; } +.bi-browser-safari::before { content: "\f7d7"; } +.bi-c-circle-fill::before { content: "\f7da"; } +.bi-c-circle::before { content: "\f7db"; } +.bi-c-square-fill::before { content: "\f7dc"; } +.bi-c-square::before { content: "\f7dd"; } +.bi-capsule-pill::before { content: "\f7de"; } +.bi-capsule::before { content: "\f7df"; } +.bi-car-front-fill::before { content: "\f7e0"; } +.bi-car-front::before { content: "\f7e1"; } +.bi-cassette-fill::before { content: "\f7e2"; } +.bi-cassette::before { content: "\f7e3"; } +.bi-cc-circle-fill::before { content: "\f7e6"; } +.bi-cc-circle::before { content: "\f7e7"; } +.bi-cc-square-fill::before { content: "\f7e8"; } +.bi-cc-square::before { content: "\f7e9"; } +.bi-cup-hot-fill::before { content: "\f7ea"; } +.bi-cup-hot::before { content: "\f7eb"; } +.bi-currency-rupee::before { content: "\f7ec"; } +.bi-dropbox::before { content: "\f7ed"; } +.bi-escape::before { content: "\f7ee"; } +.bi-fast-forward-btn-fill::before { content: "\f7ef"; } +.bi-fast-forward-btn::before { content: "\f7f0"; } +.bi-fast-forward-circle-fill::before { content: "\f7f1"; } +.bi-fast-forward-circle::before { content: "\f7f2"; } +.bi-fast-forward-fill::before { content: "\f7f3"; } +.bi-fast-forward::before { content: "\f7f4"; } +.bi-filetype-sql::before { content: "\f7f5"; } +.bi-fire::before { content: "\f7f6"; } +.bi-google-play::before { content: "\f7f7"; } +.bi-h-circle-fill::before { content: "\f7fa"; } +.bi-h-circle::before { content: "\f7fb"; } +.bi-h-square-fill::before { content: "\f7fc"; } +.bi-h-square::before { content: "\f7fd"; } +.bi-indent::before { content: "\f7fe"; } +.bi-lungs-fill::before { content: "\f7ff"; } +.bi-lungs::before { content: "\f800"; } +.bi-microsoft-teams::before { content: "\f801"; } +.bi-p-circle-fill::before { content: "\f804"; } +.bi-p-circle::before { content: "\f805"; } +.bi-p-square-fill::before { content: "\f806"; } +.bi-p-square::before { content: "\f807"; } +.bi-pass-fill::before { content: "\f808"; } +.bi-pass::before { content: "\f809"; } +.bi-prescription::before { content: "\f80a"; } +.bi-prescription2::before { content: "\f80b"; } +.bi-r-circle-fill::before { content: "\f80e"; } +.bi-r-circle::before { content: "\f80f"; } +.bi-r-square-fill::before { content: "\f810"; } +.bi-r-square::before { content: "\f811"; } +.bi-repeat-1::before { content: "\f812"; } +.bi-repeat::before { content: "\f813"; } +.bi-rewind-btn-fill::before { content: "\f814"; } +.bi-rewind-btn::before { content: "\f815"; } +.bi-rewind-circle-fill::before { content: "\f816"; } +.bi-rewind-circle::before { content: "\f817"; } +.bi-rewind-fill::before { content: "\f818"; } +.bi-rewind::before { content: "\f819"; } +.bi-train-freight-front-fill::before { content: "\f81a"; } +.bi-train-freight-front::before { content: "\f81b"; } +.bi-train-front-fill::before { content: "\f81c"; } +.bi-train-front::before { content: "\f81d"; } +.bi-train-lightrail-front-fill::before { content: "\f81e"; } +.bi-train-lightrail-front::before { content: "\f81f"; } +.bi-truck-front-fill::before { content: "\f820"; } +.bi-truck-front::before { content: "\f821"; } +.bi-ubuntu::before { content: "\f822"; } +.bi-unindent::before { content: "\f823"; } +.bi-unity::before { content: "\f824"; } +.bi-universal-access-circle::before { content: "\f825"; } +.bi-universal-access::before { content: "\f826"; } +.bi-virus::before { content: "\f827"; } +.bi-virus2::before { content: "\f828"; } +.bi-wechat::before { content: "\f829"; } +.bi-yelp::before { content: "\f82a"; } +.bi-sign-stop-fill::before { content: "\f82b"; } +.bi-sign-stop-lights-fill::before { content: "\f82c"; } +.bi-sign-stop-lights::before { content: "\f82d"; } +.bi-sign-stop::before { content: "\f82e"; } +.bi-sign-turn-left-fill::before { content: "\f82f"; } +.bi-sign-turn-left::before { content: "\f830"; } +.bi-sign-turn-right-fill::before { content: "\f831"; } +.bi-sign-turn-right::before { content: "\f832"; } +.bi-sign-turn-slight-left-fill::before { content: "\f833"; } +.bi-sign-turn-slight-left::before { content: "\f834"; } +.bi-sign-turn-slight-right-fill::before { content: "\f835"; } +.bi-sign-turn-slight-right::before { content: "\f836"; } +.bi-sign-yield-fill::before { content: "\f837"; } +.bi-sign-yield::before { content: "\f838"; } +.bi-ev-station-fill::before { content: "\f839"; } +.bi-ev-station::before { content: "\f83a"; } +.bi-fuel-pump-diesel-fill::before { content: "\f83b"; } +.bi-fuel-pump-diesel::before { content: "\f83c"; } +.bi-fuel-pump-fill::before { content: "\f83d"; } +.bi-fuel-pump::before { content: "\f83e"; } +.bi-0-circle-fill::before { content: "\f83f"; } +.bi-0-circle::before { content: "\f840"; } +.bi-0-square-fill::before { content: "\f841"; } +.bi-0-square::before { content: "\f842"; } +.bi-rocket-fill::before { content: "\f843"; } +.bi-rocket-takeoff-fill::before { content: "\f844"; } +.bi-rocket-takeoff::before { content: "\f845"; } +.bi-rocket::before { content: "\f846"; } +.bi-stripe::before { content: "\f847"; } +.bi-subscript::before { content: "\f848"; } +.bi-superscript::before { content: "\f849"; } +.bi-trello::before { content: "\f84a"; } +.bi-envelope-at-fill::before { content: "\f84b"; } +.bi-envelope-at::before { content: "\f84c"; } +.bi-regex::before { content: "\f84d"; } +.bi-text-wrap::before { content: "\f84e"; } +.bi-sign-dead-end-fill::before { content: "\f84f"; } +.bi-sign-dead-end::before { content: "\f850"; } +.bi-sign-do-not-enter-fill::before { content: "\f851"; } +.bi-sign-do-not-enter::before { content: "\f852"; } +.bi-sign-intersection-fill::before { content: "\f853"; } +.bi-sign-intersection-side-fill::before { content: "\f854"; } +.bi-sign-intersection-side::before { content: "\f855"; } +.bi-sign-intersection-t-fill::before { content: "\f856"; } +.bi-sign-intersection-t::before { content: "\f857"; } +.bi-sign-intersection-y-fill::before { content: "\f858"; } +.bi-sign-intersection-y::before { content: "\f859"; } +.bi-sign-intersection::before { content: "\f85a"; } +.bi-sign-merge-left-fill::before { content: "\f85b"; } +.bi-sign-merge-left::before { content: "\f85c"; } +.bi-sign-merge-right-fill::before { content: "\f85d"; } +.bi-sign-merge-right::before { content: "\f85e"; } +.bi-sign-no-left-turn-fill::before { content: "\f85f"; } +.bi-sign-no-left-turn::before { content: "\f860"; } +.bi-sign-no-parking-fill::before { content: "\f861"; } +.bi-sign-no-parking::before { content: "\f862"; } +.bi-sign-no-right-turn-fill::before { content: "\f863"; } +.bi-sign-no-right-turn::before { content: "\f864"; } +.bi-sign-railroad-fill::before { content: "\f865"; } +.bi-sign-railroad::before { content: "\f866"; } +.bi-building-add::before { content: "\f867"; } +.bi-building-check::before { content: "\f868"; } +.bi-building-dash::before { content: "\f869"; } +.bi-building-down::before { content: "\f86a"; } +.bi-building-exclamation::before { content: "\f86b"; } +.bi-building-fill-add::before { content: "\f86c"; } +.bi-building-fill-check::before { content: "\f86d"; } +.bi-building-fill-dash::before { content: "\f86e"; } +.bi-building-fill-down::before { content: "\f86f"; } +.bi-building-fill-exclamation::before { content: "\f870"; } +.bi-building-fill-gear::before { content: "\f871"; } +.bi-building-fill-lock::before { content: "\f872"; } +.bi-building-fill-slash::before { content: "\f873"; } +.bi-building-fill-up::before { content: "\f874"; } +.bi-building-fill-x::before { content: "\f875"; } +.bi-building-fill::before { content: "\f876"; } +.bi-building-gear::before { content: "\f877"; } +.bi-building-lock::before { content: "\f878"; } +.bi-building-slash::before { content: "\f879"; } +.bi-building-up::before { content: "\f87a"; } +.bi-building-x::before { content: "\f87b"; } +.bi-buildings-fill::before { content: "\f87c"; } +.bi-buildings::before { content: "\f87d"; } +.bi-bus-front-fill::before { content: "\f87e"; } +.bi-bus-front::before { content: "\f87f"; } +.bi-ev-front-fill::before { content: "\f880"; } +.bi-ev-front::before { content: "\f881"; } +.bi-globe-americas::before { content: "\f882"; } +.bi-globe-asia-australia::before { content: "\f883"; } +.bi-globe-central-south-asia::before { content: "\f884"; } +.bi-globe-europe-africa::before { content: "\f885"; } +.bi-house-add-fill::before { content: "\f886"; } +.bi-house-add::before { content: "\f887"; } +.bi-house-check-fill::before { content: "\f888"; } +.bi-house-check::before { content: "\f889"; } +.bi-house-dash-fill::before { content: "\f88a"; } +.bi-house-dash::before { content: "\f88b"; } +.bi-house-down-fill::before { content: "\f88c"; } +.bi-house-down::before { content: "\f88d"; } +.bi-house-exclamation-fill::before { content: "\f88e"; } +.bi-house-exclamation::before { content: "\f88f"; } +.bi-house-gear-fill::before { content: "\f890"; } +.bi-house-gear::before { content: "\f891"; } +.bi-house-lock-fill::before { content: "\f892"; } +.bi-house-lock::before { content: "\f893"; } +.bi-house-slash-fill::before { content: "\f894"; } +.bi-house-slash::before { content: "\f895"; } +.bi-house-up-fill::before { content: "\f896"; } +.bi-house-up::before { content: "\f897"; } +.bi-house-x-fill::before { content: "\f898"; } +.bi-house-x::before { content: "\f899"; } +.bi-person-add::before { content: "\f89a"; } +.bi-person-down::before { content: "\f89b"; } +.bi-person-exclamation::before { content: "\f89c"; } +.bi-person-fill-add::before { content: "\f89d"; } +.bi-person-fill-check::before { content: "\f89e"; } +.bi-person-fill-dash::before { content: "\f89f"; } +.bi-person-fill-down::before { content: "\f8a0"; } +.bi-person-fill-exclamation::before { content: "\f8a1"; } +.bi-person-fill-gear::before { content: "\f8a2"; } +.bi-person-fill-lock::before { content: "\f8a3"; } +.bi-person-fill-slash::before { content: "\f8a4"; } +.bi-person-fill-up::before { content: "\f8a5"; } +.bi-person-fill-x::before { content: "\f8a6"; } +.bi-person-gear::before { content: "\f8a7"; } +.bi-person-lock::before { content: "\f8a8"; } +.bi-person-slash::before { content: "\f8a9"; } +.bi-person-up::before { content: "\f8aa"; } +.bi-scooter::before { content: "\f8ab"; } +.bi-taxi-front-fill::before { content: "\f8ac"; } +.bi-taxi-front::before { content: "\f8ad"; } +.bi-amd::before { content: "\f8ae"; } +.bi-database-add::before { content: "\f8af"; } +.bi-database-check::before { content: "\f8b0"; } +.bi-database-dash::before { content: "\f8b1"; } +.bi-database-down::before { content: "\f8b2"; } +.bi-database-exclamation::before { content: "\f8b3"; } +.bi-database-fill-add::before { content: "\f8b4"; } +.bi-database-fill-check::before { content: "\f8b5"; } +.bi-database-fill-dash::before { content: "\f8b6"; } +.bi-database-fill-down::before { content: "\f8b7"; } +.bi-database-fill-exclamation::before { content: "\f8b8"; } +.bi-database-fill-gear::before { content: "\f8b9"; } +.bi-database-fill-lock::before { content: "\f8ba"; } +.bi-database-fill-slash::before { content: "\f8bb"; } +.bi-database-fill-up::before { content: "\f8bc"; } +.bi-database-fill-x::before { content: "\f8bd"; } +.bi-database-fill::before { content: "\f8be"; } +.bi-database-gear::before { content: "\f8bf"; } +.bi-database-lock::before { content: "\f8c0"; } +.bi-database-slash::before { content: "\f8c1"; } +.bi-database-up::before { content: "\f8c2"; } +.bi-database-x::before { content: "\f8c3"; } +.bi-database::before { content: "\f8c4"; } +.bi-houses-fill::before { content: "\f8c5"; } +.bi-houses::before { content: "\f8c6"; } +.bi-nvidia::before { content: "\f8c7"; } +.bi-person-vcard-fill::before { content: "\f8c8"; } +.bi-person-vcard::before { content: "\f8c9"; } +.bi-sina-weibo::before { content: "\f8ca"; } +.bi-tencent-qq::before { content: "\f8cb"; } +.bi-wikipedia::before { content: "\f8cc"; } +.bi-alphabet-uppercase::before { content: "\f2a5"; } +.bi-alphabet::before { content: "\f68a"; } +.bi-amazon::before { content: "\f68d"; } +.bi-arrows-collapse-vertical::before { content: "\f690"; } +.bi-arrows-expand-vertical::before { content: "\f695"; } +.bi-arrows-vertical::before { content: "\f698"; } +.bi-arrows::before { content: "\f6a2"; } +.bi-ban-fill::before { content: "\f6a3"; } +.bi-ban::before { content: "\f6b6"; } +.bi-bing::before { content: "\f6c2"; } +.bi-cake::before { content: "\f6e0"; } +.bi-cake2::before { content: "\f6ed"; } +.bi-cookie::before { content: "\f6ee"; } +.bi-copy::before { content: "\f759"; } +.bi-crosshair::before { content: "\f769"; } +.bi-crosshair2::before { content: "\f794"; } +.bi-emoji-astonished-fill::before { content: "\f795"; } +.bi-emoji-astonished::before { content: "\f79a"; } +.bi-emoji-grimace-fill::before { content: "\f79b"; } +.bi-emoji-grimace::before { content: "\f7a0"; } +.bi-emoji-grin-fill::before { content: "\f7a1"; } +.bi-emoji-grin::before { content: "\f7a6"; } +.bi-emoji-surprise-fill::before { content: "\f7a7"; } +.bi-emoji-surprise::before { content: "\f7ac"; } +.bi-emoji-tear-fill::before { content: "\f7ad"; } +.bi-emoji-tear::before { content: "\f7b2"; } +.bi-envelope-arrow-down-fill::before { content: "\f7b3"; } +.bi-envelope-arrow-down::before { content: "\f7b8"; } +.bi-envelope-arrow-up-fill::before { content: "\f7b9"; } +.bi-envelope-arrow-up::before { content: "\f7be"; } +.bi-feather::before { content: "\f7bf"; } +.bi-feather2::before { content: "\f7c4"; } +.bi-floppy-fill::before { content: "\f7c5"; } +.bi-floppy::before { content: "\f7d8"; } +.bi-floppy2-fill::before { content: "\f7d9"; } +.bi-floppy2::before { content: "\f7e4"; } +.bi-gitlab::before { content: "\f7e5"; } +.bi-highlighter::before { content: "\f7f8"; } +.bi-marker-tip::before { content: "\f802"; } +.bi-nvme-fill::before { content: "\f803"; } +.bi-nvme::before { content: "\f80c"; } +.bi-opencollective::before { content: "\f80d"; } +.bi-pci-card-network::before { content: "\f8cd"; } +.bi-pci-card-sound::before { content: "\f8ce"; } +.bi-radar::before { content: "\f8cf"; } +.bi-send-arrow-down-fill::before { content: "\f8d0"; } +.bi-send-arrow-down::before { content: "\f8d1"; } +.bi-send-arrow-up-fill::before { content: "\f8d2"; } +.bi-send-arrow-up::before { content: "\f8d3"; } +.bi-sim-slash-fill::before { content: "\f8d4"; } +.bi-sim-slash::before { content: "\f8d5"; } +.bi-sourceforge::before { content: "\f8d6"; } +.bi-substack::before { content: "\f8d7"; } +.bi-threads-fill::before { content: "\f8d8"; } +.bi-threads::before { content: "\f8d9"; } +.bi-transparency::before { content: "\f8da"; } +.bi-twitter-x::before { content: "\f8db"; } +.bi-type-h4::before { content: "\f8dc"; } +.bi-type-h5::before { content: "\f8dd"; } +.bi-type-h6::before { content: "\f8de"; } +.bi-backpack-fill::before { content: "\f8df"; } +.bi-backpack::before { content: "\f8e0"; } +.bi-backpack2-fill::before { content: "\f8e1"; } +.bi-backpack2::before { content: "\f8e2"; } +.bi-backpack3-fill::before { content: "\f8e3"; } +.bi-backpack3::before { content: "\f8e4"; } +.bi-backpack4-fill::before { content: "\f8e5"; } +.bi-backpack4::before { content: "\f8e6"; } +.bi-brilliance::before { content: "\f8e7"; } +.bi-cake-fill::before { content: "\f8e8"; } +.bi-cake2-fill::before { content: "\f8e9"; } +.bi-duffle-fill::before { content: "\f8ea"; } +.bi-duffle::before { content: "\f8eb"; } +.bi-exposure::before { content: "\f8ec"; } +.bi-gender-neuter::before { content: "\f8ed"; } +.bi-highlights::before { content: "\f8ee"; } +.bi-luggage-fill::before { content: "\f8ef"; } +.bi-luggage::before { content: "\f8f0"; } +.bi-mailbox-flag::before { content: "\f8f1"; } +.bi-mailbox2-flag::before { content: "\f8f2"; } +.bi-noise-reduction::before { content: "\f8f3"; } +.bi-passport-fill::before { content: "\f8f4"; } +.bi-passport::before { content: "\f8f5"; } +.bi-person-arms-up::before { content: "\f8f6"; } +.bi-person-raised-hand::before { content: "\f8f7"; } +.bi-person-standing-dress::before { content: "\f8f8"; } +.bi-person-standing::before { content: "\f8f9"; } +.bi-person-walking::before { content: "\f8fa"; } +.bi-person-wheelchair::before { content: "\f8fb"; } +.bi-shadows::before { content: "\f8fc"; } +.bi-suitcase-fill::before { content: "\f8fd"; } +.bi-suitcase-lg-fill::before { content: "\f8fe"; } +.bi-suitcase-lg::before { content: "\f8ff"; } +.bi-suitcase::before { content: "\f900"; } +.bi-suitcase2-fill::before { content: "\f901"; } +.bi-suitcase2::before { content: "\f902"; } +.bi-vignette::before { content: "\f903"; } diff --git a/static/css/bootstrap-icons.min.css b/static/css/bootstrap-icons.min.css new file mode 100644 index 0000000..088ba56 --- /dev/null +++ b/static/css/bootstrap-icons.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap Icons v1.10.5 (https://icons.getbootstrap.com/) + * Copyright 2019-2023 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) + */@font-face{font-display:block;font-family:bootstrap-icons;src:url("fonts/bootstrap-icons.woff2?1fa40e8900654d2863d011707b9fb6f2") format("woff2"),url("fonts/bootstrap-icons.woff?1fa40e8900654d2863d011707b9fb6f2") format("woff")}.bi::before,[class*=" bi-"]::before,[class^=bi-]::before{display:inline-block;font-family:bootstrap-icons!important;font-style:normal;font-weight:400!important;font-variant:normal;text-transform:none;line-height:1;vertical-align:-.125em;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.bi-123::before{content:"\f67f"}.bi-alarm-fill::before{content:"\f101"}.bi-alarm::before{content:"\f102"}.bi-align-bottom::before{content:"\f103"}.bi-align-center::before{content:"\f104"}.bi-align-end::before{content:"\f105"}.bi-align-middle::before{content:"\f106"}.bi-align-start::before{content:"\f107"}.bi-align-top::before{content:"\f108"}.bi-alt::before{content:"\f109"}.bi-app-indicator::before{content:"\f10a"}.bi-app::before{content:"\f10b"}.bi-archive-fill::before{content:"\f10c"}.bi-archive::before{content:"\f10d"}.bi-arrow-90deg-down::before{content:"\f10e"}.bi-arrow-90deg-left::before{content:"\f10f"}.bi-arrow-90deg-right::before{content:"\f110"}.bi-arrow-90deg-up::before{content:"\f111"}.bi-arrow-bar-down::before{content:"\f112"}.bi-arrow-bar-left::before{content:"\f113"}.bi-arrow-bar-right::before{content:"\f114"}.bi-arrow-bar-up::before{content:"\f115"}.bi-arrow-clockwise::before{content:"\f116"}.bi-arrow-counterclockwise::before{content:"\f117"}.bi-arrow-down-circle-fill::before{content:"\f118"}.bi-arrow-down-circle::before{content:"\f119"}.bi-arrow-down-left-circle-fill::before{content:"\f11a"}.bi-arrow-down-left-circle::before{content:"\f11b"}.bi-arrow-down-left-square-fill::before{content:"\f11c"}.bi-arrow-down-left-square::before{content:"\f11d"}.bi-arrow-down-left::before{content:"\f11e"}.bi-arrow-down-right-circle-fill::before{content:"\f11f"}.bi-arrow-down-right-circle::before{content:"\f120"}.bi-arrow-down-right-square-fill::before{content:"\f121"}.bi-arrow-down-right-square::before{content:"\f122"}.bi-arrow-down-right::before{content:"\f123"}.bi-arrow-down-short::before{content:"\f124"}.bi-arrow-down-square-fill::before{content:"\f125"}.bi-arrow-down-square::before{content:"\f126"}.bi-arrow-down-up::before{content:"\f127"}.bi-arrow-down::before{content:"\f128"}.bi-arrow-left-circle-fill::before{content:"\f129"}.bi-arrow-left-circle::before{content:"\f12a"}.bi-arrow-left-right::before{content:"\f12b"}.bi-arrow-left-short::before{content:"\f12c"}.bi-arrow-left-square-fill::before{content:"\f12d"}.bi-arrow-left-square::before{content:"\f12e"}.bi-arrow-left::before{content:"\f12f"}.bi-arrow-repeat::before{content:"\f130"}.bi-arrow-return-left::before{content:"\f131"}.bi-arrow-return-right::before{content:"\f132"}.bi-arrow-right-circle-fill::before{content:"\f133"}.bi-arrow-right-circle::before{content:"\f134"}.bi-arrow-right-short::before{content:"\f135"}.bi-arrow-right-square-fill::before{content:"\f136"}.bi-arrow-right-square::before{content:"\f137"}.bi-arrow-right::before{content:"\f138"}.bi-arrow-up-circle-fill::before{content:"\f139"}.bi-arrow-up-circle::before{content:"\f13a"}.bi-arrow-up-left-circle-fill::before{content:"\f13b"}.bi-arrow-up-left-circle::before{content:"\f13c"}.bi-arrow-up-left-square-fill::before{content:"\f13d"}.bi-arrow-up-left-square::before{content:"\f13e"}.bi-arrow-up-left::before{content:"\f13f"}.bi-arrow-up-right-circle-fill::before{content:"\f140"}.bi-arrow-up-right-circle::before{content:"\f141"}.bi-arrow-up-right-square-fill::before{content:"\f142"}.bi-arrow-up-right-square::before{content:"\f143"}.bi-arrow-up-right::before{content:"\f144"}.bi-arrow-up-short::before{content:"\f145"}.bi-arrow-up-square-fill::before{content:"\f146"}.bi-arrow-up-square::before{content:"\f147"}.bi-arrow-up::before{content:"\f148"}.bi-arrows-angle-contract::before{content:"\f149"}.bi-arrows-angle-expand::before{content:"\f14a"}.bi-arrows-collapse::before{content:"\f14b"}.bi-arrows-expand::before{content:"\f14c"}.bi-arrows-fullscreen::before{content:"\f14d"}.bi-arrows-move::before{content:"\f14e"}.bi-aspect-ratio-fill::before{content:"\f14f"}.bi-aspect-ratio::before{content:"\f150"}.bi-asterisk::before{content:"\f151"}.bi-at::before{content:"\f152"}.bi-award-fill::before{content:"\f153"}.bi-award::before{content:"\f154"}.bi-back::before{content:"\f155"}.bi-backspace-fill::before{content:"\f156"}.bi-backspace-reverse-fill::before{content:"\f157"}.bi-backspace-reverse::before{content:"\f158"}.bi-backspace::before{content:"\f159"}.bi-badge-3d-fill::before{content:"\f15a"}.bi-badge-3d::before{content:"\f15b"}.bi-badge-4k-fill::before{content:"\f15c"}.bi-badge-4k::before{content:"\f15d"}.bi-badge-8k-fill::before{content:"\f15e"}.bi-badge-8k::before{content:"\f15f"}.bi-badge-ad-fill::before{content:"\f160"}.bi-badge-ad::before{content:"\f161"}.bi-badge-ar-fill::before{content:"\f162"}.bi-badge-ar::before{content:"\f163"}.bi-badge-cc-fill::before{content:"\f164"}.bi-badge-cc::before{content:"\f165"}.bi-badge-hd-fill::before{content:"\f166"}.bi-badge-hd::before{content:"\f167"}.bi-badge-tm-fill::before{content:"\f168"}.bi-badge-tm::before{content:"\f169"}.bi-badge-vo-fill::before{content:"\f16a"}.bi-badge-vo::before{content:"\f16b"}.bi-badge-vr-fill::before{content:"\f16c"}.bi-badge-vr::before{content:"\f16d"}.bi-badge-wc-fill::before{content:"\f16e"}.bi-badge-wc::before{content:"\f16f"}.bi-bag-check-fill::before{content:"\f170"}.bi-bag-check::before{content:"\f171"}.bi-bag-dash-fill::before{content:"\f172"}.bi-bag-dash::before{content:"\f173"}.bi-bag-fill::before{content:"\f174"}.bi-bag-plus-fill::before{content:"\f175"}.bi-bag-plus::before{content:"\f176"}.bi-bag-x-fill::before{content:"\f177"}.bi-bag-x::before{content:"\f178"}.bi-bag::before{content:"\f179"}.bi-bar-chart-fill::before{content:"\f17a"}.bi-bar-chart-line-fill::before{content:"\f17b"}.bi-bar-chart-line::before{content:"\f17c"}.bi-bar-chart-steps::before{content:"\f17d"}.bi-bar-chart::before{content:"\f17e"}.bi-basket-fill::before{content:"\f17f"}.bi-basket::before{content:"\f180"}.bi-basket2-fill::before{content:"\f181"}.bi-basket2::before{content:"\f182"}.bi-basket3-fill::before{content:"\f183"}.bi-basket3::before{content:"\f184"}.bi-battery-charging::before{content:"\f185"}.bi-battery-full::before{content:"\f186"}.bi-battery-half::before{content:"\f187"}.bi-battery::before{content:"\f188"}.bi-bell-fill::before{content:"\f189"}.bi-bell::before{content:"\f18a"}.bi-bezier::before{content:"\f18b"}.bi-bezier2::before{content:"\f18c"}.bi-bicycle::before{content:"\f18d"}.bi-binoculars-fill::before{content:"\f18e"}.bi-binoculars::before{content:"\f18f"}.bi-blockquote-left::before{content:"\f190"}.bi-blockquote-right::before{content:"\f191"}.bi-book-fill::before{content:"\f192"}.bi-book-half::before{content:"\f193"}.bi-book::before{content:"\f194"}.bi-bookmark-check-fill::before{content:"\f195"}.bi-bookmark-check::before{content:"\f196"}.bi-bookmark-dash-fill::before{content:"\f197"}.bi-bookmark-dash::before{content:"\f198"}.bi-bookmark-fill::before{content:"\f199"}.bi-bookmark-heart-fill::before{content:"\f19a"}.bi-bookmark-heart::before{content:"\f19b"}.bi-bookmark-plus-fill::before{content:"\f19c"}.bi-bookmark-plus::before{content:"\f19d"}.bi-bookmark-star-fill::before{content:"\f19e"}.bi-bookmark-star::before{content:"\f19f"}.bi-bookmark-x-fill::before{content:"\f1a0"}.bi-bookmark-x::before{content:"\f1a1"}.bi-bookmark::before{content:"\f1a2"}.bi-bookmarks-fill::before{content:"\f1a3"}.bi-bookmarks::before{content:"\f1a4"}.bi-bookshelf::before{content:"\f1a5"}.bi-bootstrap-fill::before{content:"\f1a6"}.bi-bootstrap-reboot::before{content:"\f1a7"}.bi-bootstrap::before{content:"\f1a8"}.bi-border-all::before{content:"\f1a9"}.bi-border-bottom::before{content:"\f1aa"}.bi-border-center::before{content:"\f1ab"}.bi-border-inner::before{content:"\f1ac"}.bi-border-left::before{content:"\f1ad"}.bi-border-middle::before{content:"\f1ae"}.bi-border-outer::before{content:"\f1af"}.bi-border-right::before{content:"\f1b0"}.bi-border-style::before{content:"\f1b1"}.bi-border-top::before{content:"\f1b2"}.bi-border-width::before{content:"\f1b3"}.bi-border::before{content:"\f1b4"}.bi-bounding-box-circles::before{content:"\f1b5"}.bi-bounding-box::before{content:"\f1b6"}.bi-box-arrow-down-left::before{content:"\f1b7"}.bi-box-arrow-down-right::before{content:"\f1b8"}.bi-box-arrow-down::before{content:"\f1b9"}.bi-box-arrow-in-down-left::before{content:"\f1ba"}.bi-box-arrow-in-down-right::before{content:"\f1bb"}.bi-box-arrow-in-down::before{content:"\f1bc"}.bi-box-arrow-in-left::before{content:"\f1bd"}.bi-box-arrow-in-right::before{content:"\f1be"}.bi-box-arrow-in-up-left::before{content:"\f1bf"}.bi-box-arrow-in-up-right::before{content:"\f1c0"}.bi-box-arrow-in-up::before{content:"\f1c1"}.bi-box-arrow-left::before{content:"\f1c2"}.bi-box-arrow-right::before{content:"\f1c3"}.bi-box-arrow-up-left::before{content:"\f1c4"}.bi-box-arrow-up-right::before{content:"\f1c5"}.bi-box-arrow-up::before{content:"\f1c6"}.bi-box-seam::before{content:"\f1c7"}.bi-box::before{content:"\f1c8"}.bi-braces::before{content:"\f1c9"}.bi-bricks::before{content:"\f1ca"}.bi-briefcase-fill::before{content:"\f1cb"}.bi-briefcase::before{content:"\f1cc"}.bi-brightness-alt-high-fill::before{content:"\f1cd"}.bi-brightness-alt-high::before{content:"\f1ce"}.bi-brightness-alt-low-fill::before{content:"\f1cf"}.bi-brightness-alt-low::before{content:"\f1d0"}.bi-brightness-high-fill::before{content:"\f1d1"}.bi-brightness-high::before{content:"\f1d2"}.bi-brightness-low-fill::before{content:"\f1d3"}.bi-brightness-low::before{content:"\f1d4"}.bi-broadcast-pin::before{content:"\f1d5"}.bi-broadcast::before{content:"\f1d6"}.bi-brush-fill::before{content:"\f1d7"}.bi-brush::before{content:"\f1d8"}.bi-bucket-fill::before{content:"\f1d9"}.bi-bucket::before{content:"\f1da"}.bi-bug-fill::before{content:"\f1db"}.bi-bug::before{content:"\f1dc"}.bi-building::before{content:"\f1dd"}.bi-bullseye::before{content:"\f1de"}.bi-calculator-fill::before{content:"\f1df"}.bi-calculator::before{content:"\f1e0"}.bi-calendar-check-fill::before{content:"\f1e1"}.bi-calendar-check::before{content:"\f1e2"}.bi-calendar-date-fill::before{content:"\f1e3"}.bi-calendar-date::before{content:"\f1e4"}.bi-calendar-day-fill::before{content:"\f1e5"}.bi-calendar-day::before{content:"\f1e6"}.bi-calendar-event-fill::before{content:"\f1e7"}.bi-calendar-event::before{content:"\f1e8"}.bi-calendar-fill::before{content:"\f1e9"}.bi-calendar-minus-fill::before{content:"\f1ea"}.bi-calendar-minus::before{content:"\f1eb"}.bi-calendar-month-fill::before{content:"\f1ec"}.bi-calendar-month::before{content:"\f1ed"}.bi-calendar-plus-fill::before{content:"\f1ee"}.bi-calendar-plus::before{content:"\f1ef"}.bi-calendar-range-fill::before{content:"\f1f0"}.bi-calendar-range::before{content:"\f1f1"}.bi-calendar-week-fill::before{content:"\f1f2"}.bi-calendar-week::before{content:"\f1f3"}.bi-calendar-x-fill::before{content:"\f1f4"}.bi-calendar-x::before{content:"\f1f5"}.bi-calendar::before{content:"\f1f6"}.bi-calendar2-check-fill::before{content:"\f1f7"}.bi-calendar2-check::before{content:"\f1f8"}.bi-calendar2-date-fill::before{content:"\f1f9"}.bi-calendar2-date::before{content:"\f1fa"}.bi-calendar2-day-fill::before{content:"\f1fb"}.bi-calendar2-day::before{content:"\f1fc"}.bi-calendar2-event-fill::before{content:"\f1fd"}.bi-calendar2-event::before{content:"\f1fe"}.bi-calendar2-fill::before{content:"\f1ff"}.bi-calendar2-minus-fill::before{content:"\f200"}.bi-calendar2-minus::before{content:"\f201"}.bi-calendar2-month-fill::before{content:"\f202"}.bi-calendar2-month::before{content:"\f203"}.bi-calendar2-plus-fill::before{content:"\f204"}.bi-calendar2-plus::before{content:"\f205"}.bi-calendar2-range-fill::before{content:"\f206"}.bi-calendar2-range::before{content:"\f207"}.bi-calendar2-week-fill::before{content:"\f208"}.bi-calendar2-week::before{content:"\f209"}.bi-calendar2-x-fill::before{content:"\f20a"}.bi-calendar2-x::before{content:"\f20b"}.bi-calendar2::before{content:"\f20c"}.bi-calendar3-event-fill::before{content:"\f20d"}.bi-calendar3-event::before{content:"\f20e"}.bi-calendar3-fill::before{content:"\f20f"}.bi-calendar3-range-fill::before{content:"\f210"}.bi-calendar3-range::before{content:"\f211"}.bi-calendar3-week-fill::before{content:"\f212"}.bi-calendar3-week::before{content:"\f213"}.bi-calendar3::before{content:"\f214"}.bi-calendar4-event::before{content:"\f215"}.bi-calendar4-range::before{content:"\f216"}.bi-calendar4-week::before{content:"\f217"}.bi-calendar4::before{content:"\f218"}.bi-camera-fill::before{content:"\f219"}.bi-camera-reels-fill::before{content:"\f21a"}.bi-camera-reels::before{content:"\f21b"}.bi-camera-video-fill::before{content:"\f21c"}.bi-camera-video-off-fill::before{content:"\f21d"}.bi-camera-video-off::before{content:"\f21e"}.bi-camera-video::before{content:"\f21f"}.bi-camera::before{content:"\f220"}.bi-camera2::before{content:"\f221"}.bi-capslock-fill::before{content:"\f222"}.bi-capslock::before{content:"\f223"}.bi-card-checklist::before{content:"\f224"}.bi-card-heading::before{content:"\f225"}.bi-card-image::before{content:"\f226"}.bi-card-list::before{content:"\f227"}.bi-card-text::before{content:"\f228"}.bi-caret-down-fill::before{content:"\f229"}.bi-caret-down-square-fill::before{content:"\f22a"}.bi-caret-down-square::before{content:"\f22b"}.bi-caret-down::before{content:"\f22c"}.bi-caret-left-fill::before{content:"\f22d"}.bi-caret-left-square-fill::before{content:"\f22e"}.bi-caret-left-square::before{content:"\f22f"}.bi-caret-left::before{content:"\f230"}.bi-caret-right-fill::before{content:"\f231"}.bi-caret-right-square-fill::before{content:"\f232"}.bi-caret-right-square::before{content:"\f233"}.bi-caret-right::before{content:"\f234"}.bi-caret-up-fill::before{content:"\f235"}.bi-caret-up-square-fill::before{content:"\f236"}.bi-caret-up-square::before{content:"\f237"}.bi-caret-up::before{content:"\f238"}.bi-cart-check-fill::before{content:"\f239"}.bi-cart-check::before{content:"\f23a"}.bi-cart-dash-fill::before{content:"\f23b"}.bi-cart-dash::before{content:"\f23c"}.bi-cart-fill::before{content:"\f23d"}.bi-cart-plus-fill::before{content:"\f23e"}.bi-cart-plus::before{content:"\f23f"}.bi-cart-x-fill::before{content:"\f240"}.bi-cart-x::before{content:"\f241"}.bi-cart::before{content:"\f242"}.bi-cart2::before{content:"\f243"}.bi-cart3::before{content:"\f244"}.bi-cart4::before{content:"\f245"}.bi-cash-stack::before{content:"\f246"}.bi-cash::before{content:"\f247"}.bi-cast::before{content:"\f248"}.bi-chat-dots-fill::before{content:"\f249"}.bi-chat-dots::before{content:"\f24a"}.bi-chat-fill::before{content:"\f24b"}.bi-chat-left-dots-fill::before{content:"\f24c"}.bi-chat-left-dots::before{content:"\f24d"}.bi-chat-left-fill::before{content:"\f24e"}.bi-chat-left-quote-fill::before{content:"\f24f"}.bi-chat-left-quote::before{content:"\f250"}.bi-chat-left-text-fill::before{content:"\f251"}.bi-chat-left-text::before{content:"\f252"}.bi-chat-left::before{content:"\f253"}.bi-chat-quote-fill::before{content:"\f254"}.bi-chat-quote::before{content:"\f255"}.bi-chat-right-dots-fill::before{content:"\f256"}.bi-chat-right-dots::before{content:"\f257"}.bi-chat-right-fill::before{content:"\f258"}.bi-chat-right-quote-fill::before{content:"\f259"}.bi-chat-right-quote::before{content:"\f25a"}.bi-chat-right-text-fill::before{content:"\f25b"}.bi-chat-right-text::before{content:"\f25c"}.bi-chat-right::before{content:"\f25d"}.bi-chat-square-dots-fill::before{content:"\f25e"}.bi-chat-square-dots::before{content:"\f25f"}.bi-chat-square-fill::before{content:"\f260"}.bi-chat-square-quote-fill::before{content:"\f261"}.bi-chat-square-quote::before{content:"\f262"}.bi-chat-square-text-fill::before{content:"\f263"}.bi-chat-square-text::before{content:"\f264"}.bi-chat-square::before{content:"\f265"}.bi-chat-text-fill::before{content:"\f266"}.bi-chat-text::before{content:"\f267"}.bi-chat::before{content:"\f268"}.bi-check-all::before{content:"\f269"}.bi-check-circle-fill::before{content:"\f26a"}.bi-check-circle::before{content:"\f26b"}.bi-check-square-fill::before{content:"\f26c"}.bi-check-square::before{content:"\f26d"}.bi-check::before{content:"\f26e"}.bi-check2-all::before{content:"\f26f"}.bi-check2-circle::before{content:"\f270"}.bi-check2-square::before{content:"\f271"}.bi-check2::before{content:"\f272"}.bi-chevron-bar-contract::before{content:"\f273"}.bi-chevron-bar-down::before{content:"\f274"}.bi-chevron-bar-expand::before{content:"\f275"}.bi-chevron-bar-left::before{content:"\f276"}.bi-chevron-bar-right::before{content:"\f277"}.bi-chevron-bar-up::before{content:"\f278"}.bi-chevron-compact-down::before{content:"\f279"}.bi-chevron-compact-left::before{content:"\f27a"}.bi-chevron-compact-right::before{content:"\f27b"}.bi-chevron-compact-up::before{content:"\f27c"}.bi-chevron-contract::before{content:"\f27d"}.bi-chevron-double-down::before{content:"\f27e"}.bi-chevron-double-left::before{content:"\f27f"}.bi-chevron-double-right::before{content:"\f280"}.bi-chevron-double-up::before{content:"\f281"}.bi-chevron-down::before{content:"\f282"}.bi-chevron-expand::before{content:"\f283"}.bi-chevron-left::before{content:"\f284"}.bi-chevron-right::before{content:"\f285"}.bi-chevron-up::before{content:"\f286"}.bi-circle-fill::before{content:"\f287"}.bi-circle-half::before{content:"\f288"}.bi-circle-square::before{content:"\f289"}.bi-circle::before{content:"\f28a"}.bi-clipboard-check::before{content:"\f28b"}.bi-clipboard-data::before{content:"\f28c"}.bi-clipboard-minus::before{content:"\f28d"}.bi-clipboard-plus::before{content:"\f28e"}.bi-clipboard-x::before{content:"\f28f"}.bi-clipboard::before{content:"\f290"}.bi-clock-fill::before{content:"\f291"}.bi-clock-history::before{content:"\f292"}.bi-clock::before{content:"\f293"}.bi-cloud-arrow-down-fill::before{content:"\f294"}.bi-cloud-arrow-down::before{content:"\f295"}.bi-cloud-arrow-up-fill::before{content:"\f296"}.bi-cloud-arrow-up::before{content:"\f297"}.bi-cloud-check-fill::before{content:"\f298"}.bi-cloud-check::before{content:"\f299"}.bi-cloud-download-fill::before{content:"\f29a"}.bi-cloud-download::before{content:"\f29b"}.bi-cloud-drizzle-fill::before{content:"\f29c"}.bi-cloud-drizzle::before{content:"\f29d"}.bi-cloud-fill::before{content:"\f29e"}.bi-cloud-fog-fill::before{content:"\f29f"}.bi-cloud-fog::before{content:"\f2a0"}.bi-cloud-fog2-fill::before{content:"\f2a1"}.bi-cloud-fog2::before{content:"\f2a2"}.bi-cloud-hail-fill::before{content:"\f2a3"}.bi-cloud-hail::before{content:"\f2a4"}.bi-cloud-haze-fill::before{content:"\f2a6"}.bi-cloud-haze::before{content:"\f2a7"}.bi-cloud-haze2-fill::before{content:"\f2a8"}.bi-cloud-lightning-fill::before{content:"\f2a9"}.bi-cloud-lightning-rain-fill::before{content:"\f2aa"}.bi-cloud-lightning-rain::before{content:"\f2ab"}.bi-cloud-lightning::before{content:"\f2ac"}.bi-cloud-minus-fill::before{content:"\f2ad"}.bi-cloud-minus::before{content:"\f2ae"}.bi-cloud-moon-fill::before{content:"\f2af"}.bi-cloud-moon::before{content:"\f2b0"}.bi-cloud-plus-fill::before{content:"\f2b1"}.bi-cloud-plus::before{content:"\f2b2"}.bi-cloud-rain-fill::before{content:"\f2b3"}.bi-cloud-rain-heavy-fill::before{content:"\f2b4"}.bi-cloud-rain-heavy::before{content:"\f2b5"}.bi-cloud-rain::before{content:"\f2b6"}.bi-cloud-slash-fill::before{content:"\f2b7"}.bi-cloud-slash::before{content:"\f2b8"}.bi-cloud-sleet-fill::before{content:"\f2b9"}.bi-cloud-sleet::before{content:"\f2ba"}.bi-cloud-snow-fill::before{content:"\f2bb"}.bi-cloud-snow::before{content:"\f2bc"}.bi-cloud-sun-fill::before{content:"\f2bd"}.bi-cloud-sun::before{content:"\f2be"}.bi-cloud-upload-fill::before{content:"\f2bf"}.bi-cloud-upload::before{content:"\f2c0"}.bi-cloud::before{content:"\f2c1"}.bi-clouds-fill::before{content:"\f2c2"}.bi-clouds::before{content:"\f2c3"}.bi-cloudy-fill::before{content:"\f2c4"}.bi-cloudy::before{content:"\f2c5"}.bi-code-slash::before{content:"\f2c6"}.bi-code-square::before{content:"\f2c7"}.bi-code::before{content:"\f2c8"}.bi-collection-fill::before{content:"\f2c9"}.bi-collection-play-fill::before{content:"\f2ca"}.bi-collection-play::before{content:"\f2cb"}.bi-collection::before{content:"\f2cc"}.bi-columns-gap::before{content:"\f2cd"}.bi-columns::before{content:"\f2ce"}.bi-command::before{content:"\f2cf"}.bi-compass-fill::before{content:"\f2d0"}.bi-compass::before{content:"\f2d1"}.bi-cone-striped::before{content:"\f2d2"}.bi-cone::before{content:"\f2d3"}.bi-controller::before{content:"\f2d4"}.bi-cpu-fill::before{content:"\f2d5"}.bi-cpu::before{content:"\f2d6"}.bi-credit-card-2-back-fill::before{content:"\f2d7"}.bi-credit-card-2-back::before{content:"\f2d8"}.bi-credit-card-2-front-fill::before{content:"\f2d9"}.bi-credit-card-2-front::before{content:"\f2da"}.bi-credit-card-fill::before{content:"\f2db"}.bi-credit-card::before{content:"\f2dc"}.bi-crop::before{content:"\f2dd"}.bi-cup-fill::before{content:"\f2de"}.bi-cup-straw::before{content:"\f2df"}.bi-cup::before{content:"\f2e0"}.bi-cursor-fill::before{content:"\f2e1"}.bi-cursor-text::before{content:"\f2e2"}.bi-cursor::before{content:"\f2e3"}.bi-dash-circle-dotted::before{content:"\f2e4"}.bi-dash-circle-fill::before{content:"\f2e5"}.bi-dash-circle::before{content:"\f2e6"}.bi-dash-square-dotted::before{content:"\f2e7"}.bi-dash-square-fill::before{content:"\f2e8"}.bi-dash-square::before{content:"\f2e9"}.bi-dash::before{content:"\f2ea"}.bi-diagram-2-fill::before{content:"\f2eb"}.bi-diagram-2::before{content:"\f2ec"}.bi-diagram-3-fill::before{content:"\f2ed"}.bi-diagram-3::before{content:"\f2ee"}.bi-diamond-fill::before{content:"\f2ef"}.bi-diamond-half::before{content:"\f2f0"}.bi-diamond::before{content:"\f2f1"}.bi-dice-1-fill::before{content:"\f2f2"}.bi-dice-1::before{content:"\f2f3"}.bi-dice-2-fill::before{content:"\f2f4"}.bi-dice-2::before{content:"\f2f5"}.bi-dice-3-fill::before{content:"\f2f6"}.bi-dice-3::before{content:"\f2f7"}.bi-dice-4-fill::before{content:"\f2f8"}.bi-dice-4::before{content:"\f2f9"}.bi-dice-5-fill::before{content:"\f2fa"}.bi-dice-5::before{content:"\f2fb"}.bi-dice-6-fill::before{content:"\f2fc"}.bi-dice-6::before{content:"\f2fd"}.bi-disc-fill::before{content:"\f2fe"}.bi-disc::before{content:"\f2ff"}.bi-discord::before{content:"\f300"}.bi-display-fill::before{content:"\f301"}.bi-display::before{content:"\f302"}.bi-distribute-horizontal::before{content:"\f303"}.bi-distribute-vertical::before{content:"\f304"}.bi-door-closed-fill::before{content:"\f305"}.bi-door-closed::before{content:"\f306"}.bi-door-open-fill::before{content:"\f307"}.bi-door-open::before{content:"\f308"}.bi-dot::before{content:"\f309"}.bi-download::before{content:"\f30a"}.bi-droplet-fill::before{content:"\f30b"}.bi-droplet-half::before{content:"\f30c"}.bi-droplet::before{content:"\f30d"}.bi-earbuds::before{content:"\f30e"}.bi-easel-fill::before{content:"\f30f"}.bi-easel::before{content:"\f310"}.bi-egg-fill::before{content:"\f311"}.bi-egg-fried::before{content:"\f312"}.bi-egg::before{content:"\f313"}.bi-eject-fill::before{content:"\f314"}.bi-eject::before{content:"\f315"}.bi-emoji-angry-fill::before{content:"\f316"}.bi-emoji-angry::before{content:"\f317"}.bi-emoji-dizzy-fill::before{content:"\f318"}.bi-emoji-dizzy::before{content:"\f319"}.bi-emoji-expressionless-fill::before{content:"\f31a"}.bi-emoji-expressionless::before{content:"\f31b"}.bi-emoji-frown-fill::before{content:"\f31c"}.bi-emoji-frown::before{content:"\f31d"}.bi-emoji-heart-eyes-fill::before{content:"\f31e"}.bi-emoji-heart-eyes::before{content:"\f31f"}.bi-emoji-laughing-fill::before{content:"\f320"}.bi-emoji-laughing::before{content:"\f321"}.bi-emoji-neutral-fill::before{content:"\f322"}.bi-emoji-neutral::before{content:"\f323"}.bi-emoji-smile-fill::before{content:"\f324"}.bi-emoji-smile-upside-down-fill::before{content:"\f325"}.bi-emoji-smile-upside-down::before{content:"\f326"}.bi-emoji-smile::before{content:"\f327"}.bi-emoji-sunglasses-fill::before{content:"\f328"}.bi-emoji-sunglasses::before{content:"\f329"}.bi-emoji-wink-fill::before{content:"\f32a"}.bi-emoji-wink::before{content:"\f32b"}.bi-envelope-fill::before{content:"\f32c"}.bi-envelope-open-fill::before{content:"\f32d"}.bi-envelope-open::before{content:"\f32e"}.bi-envelope::before{content:"\f32f"}.bi-eraser-fill::before{content:"\f330"}.bi-eraser::before{content:"\f331"}.bi-exclamation-circle-fill::before{content:"\f332"}.bi-exclamation-circle::before{content:"\f333"}.bi-exclamation-diamond-fill::before{content:"\f334"}.bi-exclamation-diamond::before{content:"\f335"}.bi-exclamation-octagon-fill::before{content:"\f336"}.bi-exclamation-octagon::before{content:"\f337"}.bi-exclamation-square-fill::before{content:"\f338"}.bi-exclamation-square::before{content:"\f339"}.bi-exclamation-triangle-fill::before{content:"\f33a"}.bi-exclamation-triangle::before{content:"\f33b"}.bi-exclamation::before{content:"\f33c"}.bi-exclude::before{content:"\f33d"}.bi-eye-fill::before{content:"\f33e"}.bi-eye-slash-fill::before{content:"\f33f"}.bi-eye-slash::before{content:"\f340"}.bi-eye::before{content:"\f341"}.bi-eyedropper::before{content:"\f342"}.bi-eyeglasses::before{content:"\f343"}.bi-facebook::before{content:"\f344"}.bi-file-arrow-down-fill::before{content:"\f345"}.bi-file-arrow-down::before{content:"\f346"}.bi-file-arrow-up-fill::before{content:"\f347"}.bi-file-arrow-up::before{content:"\f348"}.bi-file-bar-graph-fill::before{content:"\f349"}.bi-file-bar-graph::before{content:"\f34a"}.bi-file-binary-fill::before{content:"\f34b"}.bi-file-binary::before{content:"\f34c"}.bi-file-break-fill::before{content:"\f34d"}.bi-file-break::before{content:"\f34e"}.bi-file-check-fill::before{content:"\f34f"}.bi-file-check::before{content:"\f350"}.bi-file-code-fill::before{content:"\f351"}.bi-file-code::before{content:"\f352"}.bi-file-diff-fill::before{content:"\f353"}.bi-file-diff::before{content:"\f354"}.bi-file-earmark-arrow-down-fill::before{content:"\f355"}.bi-file-earmark-arrow-down::before{content:"\f356"}.bi-file-earmark-arrow-up-fill::before{content:"\f357"}.bi-file-earmark-arrow-up::before{content:"\f358"}.bi-file-earmark-bar-graph-fill::before{content:"\f359"}.bi-file-earmark-bar-graph::before{content:"\f35a"}.bi-file-earmark-binary-fill::before{content:"\f35b"}.bi-file-earmark-binary::before{content:"\f35c"}.bi-file-earmark-break-fill::before{content:"\f35d"}.bi-file-earmark-break::before{content:"\f35e"}.bi-file-earmark-check-fill::before{content:"\f35f"}.bi-file-earmark-check::before{content:"\f360"}.bi-file-earmark-code-fill::before{content:"\f361"}.bi-file-earmark-code::before{content:"\f362"}.bi-file-earmark-diff-fill::before{content:"\f363"}.bi-file-earmark-diff::before{content:"\f364"}.bi-file-earmark-easel-fill::before{content:"\f365"}.bi-file-earmark-easel::before{content:"\f366"}.bi-file-earmark-excel-fill::before{content:"\f367"}.bi-file-earmark-excel::before{content:"\f368"}.bi-file-earmark-fill::before{content:"\f369"}.bi-file-earmark-font-fill::before{content:"\f36a"}.bi-file-earmark-font::before{content:"\f36b"}.bi-file-earmark-image-fill::before{content:"\f36c"}.bi-file-earmark-image::before{content:"\f36d"}.bi-file-earmark-lock-fill::before{content:"\f36e"}.bi-file-earmark-lock::before{content:"\f36f"}.bi-file-earmark-lock2-fill::before{content:"\f370"}.bi-file-earmark-lock2::before{content:"\f371"}.bi-file-earmark-medical-fill::before{content:"\f372"}.bi-file-earmark-medical::before{content:"\f373"}.bi-file-earmark-minus-fill::before{content:"\f374"}.bi-file-earmark-minus::before{content:"\f375"}.bi-file-earmark-music-fill::before{content:"\f376"}.bi-file-earmark-music::before{content:"\f377"}.bi-file-earmark-person-fill::before{content:"\f378"}.bi-file-earmark-person::before{content:"\f379"}.bi-file-earmark-play-fill::before{content:"\f37a"}.bi-file-earmark-play::before{content:"\f37b"}.bi-file-earmark-plus-fill::before{content:"\f37c"}.bi-file-earmark-plus::before{content:"\f37d"}.bi-file-earmark-post-fill::before{content:"\f37e"}.bi-file-earmark-post::before{content:"\f37f"}.bi-file-earmark-ppt-fill::before{content:"\f380"}.bi-file-earmark-ppt::before{content:"\f381"}.bi-file-earmark-richtext-fill::before{content:"\f382"}.bi-file-earmark-richtext::before{content:"\f383"}.bi-file-earmark-ruled-fill::before{content:"\f384"}.bi-file-earmark-ruled::before{content:"\f385"}.bi-file-earmark-slides-fill::before{content:"\f386"}.bi-file-earmark-slides::before{content:"\f387"}.bi-file-earmark-spreadsheet-fill::before{content:"\f388"}.bi-file-earmark-spreadsheet::before{content:"\f389"}.bi-file-earmark-text-fill::before{content:"\f38a"}.bi-file-earmark-text::before{content:"\f38b"}.bi-file-earmark-word-fill::before{content:"\f38c"}.bi-file-earmark-word::before{content:"\f38d"}.bi-file-earmark-x-fill::before{content:"\f38e"}.bi-file-earmark-x::before{content:"\f38f"}.bi-file-earmark-zip-fill::before{content:"\f390"}.bi-file-earmark-zip::before{content:"\f391"}.bi-file-earmark::before{content:"\f392"}.bi-file-easel-fill::before{content:"\f393"}.bi-file-easel::before{content:"\f394"}.bi-file-excel-fill::before{content:"\f395"}.bi-file-excel::before{content:"\f396"}.bi-file-fill::before{content:"\f397"}.bi-file-font-fill::before{content:"\f398"}.bi-file-font::before{content:"\f399"}.bi-file-image-fill::before{content:"\f39a"}.bi-file-image::before{content:"\f39b"}.bi-file-lock-fill::before{content:"\f39c"}.bi-file-lock::before{content:"\f39d"}.bi-file-lock2-fill::before{content:"\f39e"}.bi-file-lock2::before{content:"\f39f"}.bi-file-medical-fill::before{content:"\f3a0"}.bi-file-medical::before{content:"\f3a1"}.bi-file-minus-fill::before{content:"\f3a2"}.bi-file-minus::before{content:"\f3a3"}.bi-file-music-fill::before{content:"\f3a4"}.bi-file-music::before{content:"\f3a5"}.bi-file-person-fill::before{content:"\f3a6"}.bi-file-person::before{content:"\f3a7"}.bi-file-play-fill::before{content:"\f3a8"}.bi-file-play::before{content:"\f3a9"}.bi-file-plus-fill::before{content:"\f3aa"}.bi-file-plus::before{content:"\f3ab"}.bi-file-post-fill::before{content:"\f3ac"}.bi-file-post::before{content:"\f3ad"}.bi-file-ppt-fill::before{content:"\f3ae"}.bi-file-ppt::before{content:"\f3af"}.bi-file-richtext-fill::before{content:"\f3b0"}.bi-file-richtext::before{content:"\f3b1"}.bi-file-ruled-fill::before{content:"\f3b2"}.bi-file-ruled::before{content:"\f3b3"}.bi-file-slides-fill::before{content:"\f3b4"}.bi-file-slides::before{content:"\f3b5"}.bi-file-spreadsheet-fill::before{content:"\f3b6"}.bi-file-spreadsheet::before{content:"\f3b7"}.bi-file-text-fill::before{content:"\f3b8"}.bi-file-text::before{content:"\f3b9"}.bi-file-word-fill::before{content:"\f3ba"}.bi-file-word::before{content:"\f3bb"}.bi-file-x-fill::before{content:"\f3bc"}.bi-file-x::before{content:"\f3bd"}.bi-file-zip-fill::before{content:"\f3be"}.bi-file-zip::before{content:"\f3bf"}.bi-file::before{content:"\f3c0"}.bi-files-alt::before{content:"\f3c1"}.bi-files::before{content:"\f3c2"}.bi-film::before{content:"\f3c3"}.bi-filter-circle-fill::before{content:"\f3c4"}.bi-filter-circle::before{content:"\f3c5"}.bi-filter-left::before{content:"\f3c6"}.bi-filter-right::before{content:"\f3c7"}.bi-filter-square-fill::before{content:"\f3c8"}.bi-filter-square::before{content:"\f3c9"}.bi-filter::before{content:"\f3ca"}.bi-flag-fill::before{content:"\f3cb"}.bi-flag::before{content:"\f3cc"}.bi-flower1::before{content:"\f3cd"}.bi-flower2::before{content:"\f3ce"}.bi-flower3::before{content:"\f3cf"}.bi-folder-check::before{content:"\f3d0"}.bi-folder-fill::before{content:"\f3d1"}.bi-folder-minus::before{content:"\f3d2"}.bi-folder-plus::before{content:"\f3d3"}.bi-folder-symlink-fill::before{content:"\f3d4"}.bi-folder-symlink::before{content:"\f3d5"}.bi-folder-x::before{content:"\f3d6"}.bi-folder::before{content:"\f3d7"}.bi-folder2-open::before{content:"\f3d8"}.bi-folder2::before{content:"\f3d9"}.bi-fonts::before{content:"\f3da"}.bi-forward-fill::before{content:"\f3db"}.bi-forward::before{content:"\f3dc"}.bi-front::before{content:"\f3dd"}.bi-fullscreen-exit::before{content:"\f3de"}.bi-fullscreen::before{content:"\f3df"}.bi-funnel-fill::before{content:"\f3e0"}.bi-funnel::before{content:"\f3e1"}.bi-gear-fill::before{content:"\f3e2"}.bi-gear-wide-connected::before{content:"\f3e3"}.bi-gear-wide::before{content:"\f3e4"}.bi-gear::before{content:"\f3e5"}.bi-gem::before{content:"\f3e6"}.bi-geo-alt-fill::before{content:"\f3e7"}.bi-geo-alt::before{content:"\f3e8"}.bi-geo-fill::before{content:"\f3e9"}.bi-geo::before{content:"\f3ea"}.bi-gift-fill::before{content:"\f3eb"}.bi-gift::before{content:"\f3ec"}.bi-github::before{content:"\f3ed"}.bi-globe::before{content:"\f3ee"}.bi-globe2::before{content:"\f3ef"}.bi-google::before{content:"\f3f0"}.bi-graph-down::before{content:"\f3f1"}.bi-graph-up::before{content:"\f3f2"}.bi-grid-1x2-fill::before{content:"\f3f3"}.bi-grid-1x2::before{content:"\f3f4"}.bi-grid-3x2-gap-fill::before{content:"\f3f5"}.bi-grid-3x2-gap::before{content:"\f3f6"}.bi-grid-3x2::before{content:"\f3f7"}.bi-grid-3x3-gap-fill::before{content:"\f3f8"}.bi-grid-3x3-gap::before{content:"\f3f9"}.bi-grid-3x3::before{content:"\f3fa"}.bi-grid-fill::before{content:"\f3fb"}.bi-grid::before{content:"\f3fc"}.bi-grip-horizontal::before{content:"\f3fd"}.bi-grip-vertical::before{content:"\f3fe"}.bi-hammer::before{content:"\f3ff"}.bi-hand-index-fill::before{content:"\f400"}.bi-hand-index-thumb-fill::before{content:"\f401"}.bi-hand-index-thumb::before{content:"\f402"}.bi-hand-index::before{content:"\f403"}.bi-hand-thumbs-down-fill::before{content:"\f404"}.bi-hand-thumbs-down::before{content:"\f405"}.bi-hand-thumbs-up-fill::before{content:"\f406"}.bi-hand-thumbs-up::before{content:"\f407"}.bi-handbag-fill::before{content:"\f408"}.bi-handbag::before{content:"\f409"}.bi-hash::before{content:"\f40a"}.bi-hdd-fill::before{content:"\f40b"}.bi-hdd-network-fill::before{content:"\f40c"}.bi-hdd-network::before{content:"\f40d"}.bi-hdd-rack-fill::before{content:"\f40e"}.bi-hdd-rack::before{content:"\f40f"}.bi-hdd-stack-fill::before{content:"\f410"}.bi-hdd-stack::before{content:"\f411"}.bi-hdd::before{content:"\f412"}.bi-headphones::before{content:"\f413"}.bi-headset::before{content:"\f414"}.bi-heart-fill::before{content:"\f415"}.bi-heart-half::before{content:"\f416"}.bi-heart::before{content:"\f417"}.bi-heptagon-fill::before{content:"\f418"}.bi-heptagon-half::before{content:"\f419"}.bi-heptagon::before{content:"\f41a"}.bi-hexagon-fill::before{content:"\f41b"}.bi-hexagon-half::before{content:"\f41c"}.bi-hexagon::before{content:"\f41d"}.bi-hourglass-bottom::before{content:"\f41e"}.bi-hourglass-split::before{content:"\f41f"}.bi-hourglass-top::before{content:"\f420"}.bi-hourglass::before{content:"\f421"}.bi-house-door-fill::before{content:"\f422"}.bi-house-door::before{content:"\f423"}.bi-house-fill::before{content:"\f424"}.bi-house::before{content:"\f425"}.bi-hr::before{content:"\f426"}.bi-hurricane::before{content:"\f427"}.bi-image-alt::before{content:"\f428"}.bi-image-fill::before{content:"\f429"}.bi-image::before{content:"\f42a"}.bi-images::before{content:"\f42b"}.bi-inbox-fill::before{content:"\f42c"}.bi-inbox::before{content:"\f42d"}.bi-inboxes-fill::before{content:"\f42e"}.bi-inboxes::before{content:"\f42f"}.bi-info-circle-fill::before{content:"\f430"}.bi-info-circle::before{content:"\f431"}.bi-info-square-fill::before{content:"\f432"}.bi-info-square::before{content:"\f433"}.bi-info::before{content:"\f434"}.bi-input-cursor-text::before{content:"\f435"}.bi-input-cursor::before{content:"\f436"}.bi-instagram::before{content:"\f437"}.bi-intersect::before{content:"\f438"}.bi-journal-album::before{content:"\f439"}.bi-journal-arrow-down::before{content:"\f43a"}.bi-journal-arrow-up::before{content:"\f43b"}.bi-journal-bookmark-fill::before{content:"\f43c"}.bi-journal-bookmark::before{content:"\f43d"}.bi-journal-check::before{content:"\f43e"}.bi-journal-code::before{content:"\f43f"}.bi-journal-medical::before{content:"\f440"}.bi-journal-minus::before{content:"\f441"}.bi-journal-plus::before{content:"\f442"}.bi-journal-richtext::before{content:"\f443"}.bi-journal-text::before{content:"\f444"}.bi-journal-x::before{content:"\f445"}.bi-journal::before{content:"\f446"}.bi-journals::before{content:"\f447"}.bi-joystick::before{content:"\f448"}.bi-justify-left::before{content:"\f449"}.bi-justify-right::before{content:"\f44a"}.bi-justify::before{content:"\f44b"}.bi-kanban-fill::before{content:"\f44c"}.bi-kanban::before{content:"\f44d"}.bi-key-fill::before{content:"\f44e"}.bi-key::before{content:"\f44f"}.bi-keyboard-fill::before{content:"\f450"}.bi-keyboard::before{content:"\f451"}.bi-ladder::before{content:"\f452"}.bi-lamp-fill::before{content:"\f453"}.bi-lamp::before{content:"\f454"}.bi-laptop-fill::before{content:"\f455"}.bi-laptop::before{content:"\f456"}.bi-layer-backward::before{content:"\f457"}.bi-layer-forward::before{content:"\f458"}.bi-layers-fill::before{content:"\f459"}.bi-layers-half::before{content:"\f45a"}.bi-layers::before{content:"\f45b"}.bi-layout-sidebar-inset-reverse::before{content:"\f45c"}.bi-layout-sidebar-inset::before{content:"\f45d"}.bi-layout-sidebar-reverse::before{content:"\f45e"}.bi-layout-sidebar::before{content:"\f45f"}.bi-layout-split::before{content:"\f460"}.bi-layout-text-sidebar-reverse::before{content:"\f461"}.bi-layout-text-sidebar::before{content:"\f462"}.bi-layout-text-window-reverse::before{content:"\f463"}.bi-layout-text-window::before{content:"\f464"}.bi-layout-three-columns::before{content:"\f465"}.bi-layout-wtf::before{content:"\f466"}.bi-life-preserver::before{content:"\f467"}.bi-lightbulb-fill::before{content:"\f468"}.bi-lightbulb-off-fill::before{content:"\f469"}.bi-lightbulb-off::before{content:"\f46a"}.bi-lightbulb::before{content:"\f46b"}.bi-lightning-charge-fill::before{content:"\f46c"}.bi-lightning-charge::before{content:"\f46d"}.bi-lightning-fill::before{content:"\f46e"}.bi-lightning::before{content:"\f46f"}.bi-link-45deg::before{content:"\f470"}.bi-link::before{content:"\f471"}.bi-linkedin::before{content:"\f472"}.bi-list-check::before{content:"\f473"}.bi-list-nested::before{content:"\f474"}.bi-list-ol::before{content:"\f475"}.bi-list-stars::before{content:"\f476"}.bi-list-task::before{content:"\f477"}.bi-list-ul::before{content:"\f478"}.bi-list::before{content:"\f479"}.bi-lock-fill::before{content:"\f47a"}.bi-lock::before{content:"\f47b"}.bi-mailbox::before{content:"\f47c"}.bi-mailbox2::before{content:"\f47d"}.bi-map-fill::before{content:"\f47e"}.bi-map::before{content:"\f47f"}.bi-markdown-fill::before{content:"\f480"}.bi-markdown::before{content:"\f481"}.bi-mask::before{content:"\f482"}.bi-megaphone-fill::before{content:"\f483"}.bi-megaphone::before{content:"\f484"}.bi-menu-app-fill::before{content:"\f485"}.bi-menu-app::before{content:"\f486"}.bi-menu-button-fill::before{content:"\f487"}.bi-menu-button-wide-fill::before{content:"\f488"}.bi-menu-button-wide::before{content:"\f489"}.bi-menu-button::before{content:"\f48a"}.bi-menu-down::before{content:"\f48b"}.bi-menu-up::before{content:"\f48c"}.bi-mic-fill::before{content:"\f48d"}.bi-mic-mute-fill::before{content:"\f48e"}.bi-mic-mute::before{content:"\f48f"}.bi-mic::before{content:"\f490"}.bi-minecart-loaded::before{content:"\f491"}.bi-minecart::before{content:"\f492"}.bi-moisture::before{content:"\f493"}.bi-moon-fill::before{content:"\f494"}.bi-moon-stars-fill::before{content:"\f495"}.bi-moon-stars::before{content:"\f496"}.bi-moon::before{content:"\f497"}.bi-mouse-fill::before{content:"\f498"}.bi-mouse::before{content:"\f499"}.bi-mouse2-fill::before{content:"\f49a"}.bi-mouse2::before{content:"\f49b"}.bi-mouse3-fill::before{content:"\f49c"}.bi-mouse3::before{content:"\f49d"}.bi-music-note-beamed::before{content:"\f49e"}.bi-music-note-list::before{content:"\f49f"}.bi-music-note::before{content:"\f4a0"}.bi-music-player-fill::before{content:"\f4a1"}.bi-music-player::before{content:"\f4a2"}.bi-newspaper::before{content:"\f4a3"}.bi-node-minus-fill::before{content:"\f4a4"}.bi-node-minus::before{content:"\f4a5"}.bi-node-plus-fill::before{content:"\f4a6"}.bi-node-plus::before{content:"\f4a7"}.bi-nut-fill::before{content:"\f4a8"}.bi-nut::before{content:"\f4a9"}.bi-octagon-fill::before{content:"\f4aa"}.bi-octagon-half::before{content:"\f4ab"}.bi-octagon::before{content:"\f4ac"}.bi-option::before{content:"\f4ad"}.bi-outlet::before{content:"\f4ae"}.bi-paint-bucket::before{content:"\f4af"}.bi-palette-fill::before{content:"\f4b0"}.bi-palette::before{content:"\f4b1"}.bi-palette2::before{content:"\f4b2"}.bi-paperclip::before{content:"\f4b3"}.bi-paragraph::before{content:"\f4b4"}.bi-patch-check-fill::before{content:"\f4b5"}.bi-patch-check::before{content:"\f4b6"}.bi-patch-exclamation-fill::before{content:"\f4b7"}.bi-patch-exclamation::before{content:"\f4b8"}.bi-patch-minus-fill::before{content:"\f4b9"}.bi-patch-minus::before{content:"\f4ba"}.bi-patch-plus-fill::before{content:"\f4bb"}.bi-patch-plus::before{content:"\f4bc"}.bi-patch-question-fill::before{content:"\f4bd"}.bi-patch-question::before{content:"\f4be"}.bi-pause-btn-fill::before{content:"\f4bf"}.bi-pause-btn::before{content:"\f4c0"}.bi-pause-circle-fill::before{content:"\f4c1"}.bi-pause-circle::before{content:"\f4c2"}.bi-pause-fill::before{content:"\f4c3"}.bi-pause::before{content:"\f4c4"}.bi-peace-fill::before{content:"\f4c5"}.bi-peace::before{content:"\f4c6"}.bi-pen-fill::before{content:"\f4c7"}.bi-pen::before{content:"\f4c8"}.bi-pencil-fill::before{content:"\f4c9"}.bi-pencil-square::before{content:"\f4ca"}.bi-pencil::before{content:"\f4cb"}.bi-pentagon-fill::before{content:"\f4cc"}.bi-pentagon-half::before{content:"\f4cd"}.bi-pentagon::before{content:"\f4ce"}.bi-people-fill::before{content:"\f4cf"}.bi-people::before{content:"\f4d0"}.bi-percent::before{content:"\f4d1"}.bi-person-badge-fill::before{content:"\f4d2"}.bi-person-badge::before{content:"\f4d3"}.bi-person-bounding-box::before{content:"\f4d4"}.bi-person-check-fill::before{content:"\f4d5"}.bi-person-check::before{content:"\f4d6"}.bi-person-circle::before{content:"\f4d7"}.bi-person-dash-fill::before{content:"\f4d8"}.bi-person-dash::before{content:"\f4d9"}.bi-person-fill::before{content:"\f4da"}.bi-person-lines-fill::before{content:"\f4db"}.bi-person-plus-fill::before{content:"\f4dc"}.bi-person-plus::before{content:"\f4dd"}.bi-person-square::before{content:"\f4de"}.bi-person-x-fill::before{content:"\f4df"}.bi-person-x::before{content:"\f4e0"}.bi-person::before{content:"\f4e1"}.bi-phone-fill::before{content:"\f4e2"}.bi-phone-landscape-fill::before{content:"\f4e3"}.bi-phone-landscape::before{content:"\f4e4"}.bi-phone-vibrate-fill::before{content:"\f4e5"}.bi-phone-vibrate::before{content:"\f4e6"}.bi-phone::before{content:"\f4e7"}.bi-pie-chart-fill::before{content:"\f4e8"}.bi-pie-chart::before{content:"\f4e9"}.bi-pin-angle-fill::before{content:"\f4ea"}.bi-pin-angle::before{content:"\f4eb"}.bi-pin-fill::before{content:"\f4ec"}.bi-pin::before{content:"\f4ed"}.bi-pip-fill::before{content:"\f4ee"}.bi-pip::before{content:"\f4ef"}.bi-play-btn-fill::before{content:"\f4f0"}.bi-play-btn::before{content:"\f4f1"}.bi-play-circle-fill::before{content:"\f4f2"}.bi-play-circle::before{content:"\f4f3"}.bi-play-fill::before{content:"\f4f4"}.bi-play::before{content:"\f4f5"}.bi-plug-fill::before{content:"\f4f6"}.bi-plug::before{content:"\f4f7"}.bi-plus-circle-dotted::before{content:"\f4f8"}.bi-plus-circle-fill::before{content:"\f4f9"}.bi-plus-circle::before{content:"\f4fa"}.bi-plus-square-dotted::before{content:"\f4fb"}.bi-plus-square-fill::before{content:"\f4fc"}.bi-plus-square::before{content:"\f4fd"}.bi-plus::before{content:"\f4fe"}.bi-power::before{content:"\f4ff"}.bi-printer-fill::before{content:"\f500"}.bi-printer::before{content:"\f501"}.bi-puzzle-fill::before{content:"\f502"}.bi-puzzle::before{content:"\f503"}.bi-question-circle-fill::before{content:"\f504"}.bi-question-circle::before{content:"\f505"}.bi-question-diamond-fill::before{content:"\f506"}.bi-question-diamond::before{content:"\f507"}.bi-question-octagon-fill::before{content:"\f508"}.bi-question-octagon::before{content:"\f509"}.bi-question-square-fill::before{content:"\f50a"}.bi-question-square::before{content:"\f50b"}.bi-question::before{content:"\f50c"}.bi-rainbow::before{content:"\f50d"}.bi-receipt-cutoff::before{content:"\f50e"}.bi-receipt::before{content:"\f50f"}.bi-reception-0::before{content:"\f510"}.bi-reception-1::before{content:"\f511"}.bi-reception-2::before{content:"\f512"}.bi-reception-3::before{content:"\f513"}.bi-reception-4::before{content:"\f514"}.bi-record-btn-fill::before{content:"\f515"}.bi-record-btn::before{content:"\f516"}.bi-record-circle-fill::before{content:"\f517"}.bi-record-circle::before{content:"\f518"}.bi-record-fill::before{content:"\f519"}.bi-record::before{content:"\f51a"}.bi-record2-fill::before{content:"\f51b"}.bi-record2::before{content:"\f51c"}.bi-reply-all-fill::before{content:"\f51d"}.bi-reply-all::before{content:"\f51e"}.bi-reply-fill::before{content:"\f51f"}.bi-reply::before{content:"\f520"}.bi-rss-fill::before{content:"\f521"}.bi-rss::before{content:"\f522"}.bi-rulers::before{content:"\f523"}.bi-save-fill::before{content:"\f524"}.bi-save::before{content:"\f525"}.bi-save2-fill::before{content:"\f526"}.bi-save2::before{content:"\f527"}.bi-scissors::before{content:"\f528"}.bi-screwdriver::before{content:"\f529"}.bi-search::before{content:"\f52a"}.bi-segmented-nav::before{content:"\f52b"}.bi-server::before{content:"\f52c"}.bi-share-fill::before{content:"\f52d"}.bi-share::before{content:"\f52e"}.bi-shield-check::before{content:"\f52f"}.bi-shield-exclamation::before{content:"\f530"}.bi-shield-fill-check::before{content:"\f531"}.bi-shield-fill-exclamation::before{content:"\f532"}.bi-shield-fill-minus::before{content:"\f533"}.bi-shield-fill-plus::before{content:"\f534"}.bi-shield-fill-x::before{content:"\f535"}.bi-shield-fill::before{content:"\f536"}.bi-shield-lock-fill::before{content:"\f537"}.bi-shield-lock::before{content:"\f538"}.bi-shield-minus::before{content:"\f539"}.bi-shield-plus::before{content:"\f53a"}.bi-shield-shaded::before{content:"\f53b"}.bi-shield-slash-fill::before{content:"\f53c"}.bi-shield-slash::before{content:"\f53d"}.bi-shield-x::before{content:"\f53e"}.bi-shield::before{content:"\f53f"}.bi-shift-fill::before{content:"\f540"}.bi-shift::before{content:"\f541"}.bi-shop-window::before{content:"\f542"}.bi-shop::before{content:"\f543"}.bi-shuffle::before{content:"\f544"}.bi-signpost-2-fill::before{content:"\f545"}.bi-signpost-2::before{content:"\f546"}.bi-signpost-fill::before{content:"\f547"}.bi-signpost-split-fill::before{content:"\f548"}.bi-signpost-split::before{content:"\f549"}.bi-signpost::before{content:"\f54a"}.bi-sim-fill::before{content:"\f54b"}.bi-sim::before{content:"\f54c"}.bi-skip-backward-btn-fill::before{content:"\f54d"}.bi-skip-backward-btn::before{content:"\f54e"}.bi-skip-backward-circle-fill::before{content:"\f54f"}.bi-skip-backward-circle::before{content:"\f550"}.bi-skip-backward-fill::before{content:"\f551"}.bi-skip-backward::before{content:"\f552"}.bi-skip-end-btn-fill::before{content:"\f553"}.bi-skip-end-btn::before{content:"\f554"}.bi-skip-end-circle-fill::before{content:"\f555"}.bi-skip-end-circle::before{content:"\f556"}.bi-skip-end-fill::before{content:"\f557"}.bi-skip-end::before{content:"\f558"}.bi-skip-forward-btn-fill::before{content:"\f559"}.bi-skip-forward-btn::before{content:"\f55a"}.bi-skip-forward-circle-fill::before{content:"\f55b"}.bi-skip-forward-circle::before{content:"\f55c"}.bi-skip-forward-fill::before{content:"\f55d"}.bi-skip-forward::before{content:"\f55e"}.bi-skip-start-btn-fill::before{content:"\f55f"}.bi-skip-start-btn::before{content:"\f560"}.bi-skip-start-circle-fill::before{content:"\f561"}.bi-skip-start-circle::before{content:"\f562"}.bi-skip-start-fill::before{content:"\f563"}.bi-skip-start::before{content:"\f564"}.bi-slack::before{content:"\f565"}.bi-slash-circle-fill::before{content:"\f566"}.bi-slash-circle::before{content:"\f567"}.bi-slash-square-fill::before{content:"\f568"}.bi-slash-square::before{content:"\f569"}.bi-slash::before{content:"\f56a"}.bi-sliders::before{content:"\f56b"}.bi-smartwatch::before{content:"\f56c"}.bi-snow::before{content:"\f56d"}.bi-snow2::before{content:"\f56e"}.bi-snow3::before{content:"\f56f"}.bi-sort-alpha-down-alt::before{content:"\f570"}.bi-sort-alpha-down::before{content:"\f571"}.bi-sort-alpha-up-alt::before{content:"\f572"}.bi-sort-alpha-up::before{content:"\f573"}.bi-sort-down-alt::before{content:"\f574"}.bi-sort-down::before{content:"\f575"}.bi-sort-numeric-down-alt::before{content:"\f576"}.bi-sort-numeric-down::before{content:"\f577"}.bi-sort-numeric-up-alt::before{content:"\f578"}.bi-sort-numeric-up::before{content:"\f579"}.bi-sort-up-alt::before{content:"\f57a"}.bi-sort-up::before{content:"\f57b"}.bi-soundwave::before{content:"\f57c"}.bi-speaker-fill::before{content:"\f57d"}.bi-speaker::before{content:"\f57e"}.bi-speedometer::before{content:"\f57f"}.bi-speedometer2::before{content:"\f580"}.bi-spellcheck::before{content:"\f581"}.bi-square-fill::before{content:"\f582"}.bi-square-half::before{content:"\f583"}.bi-square::before{content:"\f584"}.bi-stack::before{content:"\f585"}.bi-star-fill::before{content:"\f586"}.bi-star-half::before{content:"\f587"}.bi-star::before{content:"\f588"}.bi-stars::before{content:"\f589"}.bi-stickies-fill::before{content:"\f58a"}.bi-stickies::before{content:"\f58b"}.bi-sticky-fill::before{content:"\f58c"}.bi-sticky::before{content:"\f58d"}.bi-stop-btn-fill::before{content:"\f58e"}.bi-stop-btn::before{content:"\f58f"}.bi-stop-circle-fill::before{content:"\f590"}.bi-stop-circle::before{content:"\f591"}.bi-stop-fill::before{content:"\f592"}.bi-stop::before{content:"\f593"}.bi-stoplights-fill::before{content:"\f594"}.bi-stoplights::before{content:"\f595"}.bi-stopwatch-fill::before{content:"\f596"}.bi-stopwatch::before{content:"\f597"}.bi-subtract::before{content:"\f598"}.bi-suit-club-fill::before{content:"\f599"}.bi-suit-club::before{content:"\f59a"}.bi-suit-diamond-fill::before{content:"\f59b"}.bi-suit-diamond::before{content:"\f59c"}.bi-suit-heart-fill::before{content:"\f59d"}.bi-suit-heart::before{content:"\f59e"}.bi-suit-spade-fill::before{content:"\f59f"}.bi-suit-spade::before{content:"\f5a0"}.bi-sun-fill::before{content:"\f5a1"}.bi-sun::before{content:"\f5a2"}.bi-sunglasses::before{content:"\f5a3"}.bi-sunrise-fill::before{content:"\f5a4"}.bi-sunrise::before{content:"\f5a5"}.bi-sunset-fill::before{content:"\f5a6"}.bi-sunset::before{content:"\f5a7"}.bi-symmetry-horizontal::before{content:"\f5a8"}.bi-symmetry-vertical::before{content:"\f5a9"}.bi-table::before{content:"\f5aa"}.bi-tablet-fill::before{content:"\f5ab"}.bi-tablet-landscape-fill::before{content:"\f5ac"}.bi-tablet-landscape::before{content:"\f5ad"}.bi-tablet::before{content:"\f5ae"}.bi-tag-fill::before{content:"\f5af"}.bi-tag::before{content:"\f5b0"}.bi-tags-fill::before{content:"\f5b1"}.bi-tags::before{content:"\f5b2"}.bi-telegram::before{content:"\f5b3"}.bi-telephone-fill::before{content:"\f5b4"}.bi-telephone-forward-fill::before{content:"\f5b5"}.bi-telephone-forward::before{content:"\f5b6"}.bi-telephone-inbound-fill::before{content:"\f5b7"}.bi-telephone-inbound::before{content:"\f5b8"}.bi-telephone-minus-fill::before{content:"\f5b9"}.bi-telephone-minus::before{content:"\f5ba"}.bi-telephone-outbound-fill::before{content:"\f5bb"}.bi-telephone-outbound::before{content:"\f5bc"}.bi-telephone-plus-fill::before{content:"\f5bd"}.bi-telephone-plus::before{content:"\f5be"}.bi-telephone-x-fill::before{content:"\f5bf"}.bi-telephone-x::before{content:"\f5c0"}.bi-telephone::before{content:"\f5c1"}.bi-terminal-fill::before{content:"\f5c2"}.bi-terminal::before{content:"\f5c3"}.bi-text-center::before{content:"\f5c4"}.bi-text-indent-left::before{content:"\f5c5"}.bi-text-indent-right::before{content:"\f5c6"}.bi-text-left::before{content:"\f5c7"}.bi-text-paragraph::before{content:"\f5c8"}.bi-text-right::before{content:"\f5c9"}.bi-textarea-resize::before{content:"\f5ca"}.bi-textarea-t::before{content:"\f5cb"}.bi-textarea::before{content:"\f5cc"}.bi-thermometer-half::before{content:"\f5cd"}.bi-thermometer-high::before{content:"\f5ce"}.bi-thermometer-low::before{content:"\f5cf"}.bi-thermometer-snow::before{content:"\f5d0"}.bi-thermometer-sun::before{content:"\f5d1"}.bi-thermometer::before{content:"\f5d2"}.bi-three-dots-vertical::before{content:"\f5d3"}.bi-three-dots::before{content:"\f5d4"}.bi-toggle-off::before{content:"\f5d5"}.bi-toggle-on::before{content:"\f5d6"}.bi-toggle2-off::before{content:"\f5d7"}.bi-toggle2-on::before{content:"\f5d8"}.bi-toggles::before{content:"\f5d9"}.bi-toggles2::before{content:"\f5da"}.bi-tools::before{content:"\f5db"}.bi-tornado::before{content:"\f5dc"}.bi-trash-fill::before{content:"\f5dd"}.bi-trash::before{content:"\f5de"}.bi-trash2-fill::before{content:"\f5df"}.bi-trash2::before{content:"\f5e0"}.bi-tree-fill::before{content:"\f5e1"}.bi-tree::before{content:"\f5e2"}.bi-triangle-fill::before{content:"\f5e3"}.bi-triangle-half::before{content:"\f5e4"}.bi-triangle::before{content:"\f5e5"}.bi-trophy-fill::before{content:"\f5e6"}.bi-trophy::before{content:"\f5e7"}.bi-tropical-storm::before{content:"\f5e8"}.bi-truck-flatbed::before{content:"\f5e9"}.bi-truck::before{content:"\f5ea"}.bi-tsunami::before{content:"\f5eb"}.bi-tv-fill::before{content:"\f5ec"}.bi-tv::before{content:"\f5ed"}.bi-twitch::before{content:"\f5ee"}.bi-twitter::before{content:"\f5ef"}.bi-type-bold::before{content:"\f5f0"}.bi-type-h1::before{content:"\f5f1"}.bi-type-h2::before{content:"\f5f2"}.bi-type-h3::before{content:"\f5f3"}.bi-type-italic::before{content:"\f5f4"}.bi-type-strikethrough::before{content:"\f5f5"}.bi-type-underline::before{content:"\f5f6"}.bi-type::before{content:"\f5f7"}.bi-ui-checks-grid::before{content:"\f5f8"}.bi-ui-checks::before{content:"\f5f9"}.bi-ui-radios-grid::before{content:"\f5fa"}.bi-ui-radios::before{content:"\f5fb"}.bi-umbrella-fill::before{content:"\f5fc"}.bi-umbrella::before{content:"\f5fd"}.bi-union::before{content:"\f5fe"}.bi-unlock-fill::before{content:"\f5ff"}.bi-unlock::before{content:"\f600"}.bi-upc-scan::before{content:"\f601"}.bi-upc::before{content:"\f602"}.bi-upload::before{content:"\f603"}.bi-vector-pen::before{content:"\f604"}.bi-view-list::before{content:"\f605"}.bi-view-stacked::before{content:"\f606"}.bi-vinyl-fill::before{content:"\f607"}.bi-vinyl::before{content:"\f608"}.bi-voicemail::before{content:"\f609"}.bi-volume-down-fill::before{content:"\f60a"}.bi-volume-down::before{content:"\f60b"}.bi-volume-mute-fill::before{content:"\f60c"}.bi-volume-mute::before{content:"\f60d"}.bi-volume-off-fill::before{content:"\f60e"}.bi-volume-off::before{content:"\f60f"}.bi-volume-up-fill::before{content:"\f610"}.bi-volume-up::before{content:"\f611"}.bi-vr::before{content:"\f612"}.bi-wallet-fill::before{content:"\f613"}.bi-wallet::before{content:"\f614"}.bi-wallet2::before{content:"\f615"}.bi-watch::before{content:"\f616"}.bi-water::before{content:"\f617"}.bi-whatsapp::before{content:"\f618"}.bi-wifi-1::before{content:"\f619"}.bi-wifi-2::before{content:"\f61a"}.bi-wifi-off::before{content:"\f61b"}.bi-wifi::before{content:"\f61c"}.bi-wind::before{content:"\f61d"}.bi-window-dock::before{content:"\f61e"}.bi-window-sidebar::before{content:"\f61f"}.bi-window::before{content:"\f620"}.bi-wrench::before{content:"\f621"}.bi-x-circle-fill::before{content:"\f622"}.bi-x-circle::before{content:"\f623"}.bi-x-diamond-fill::before{content:"\f624"}.bi-x-diamond::before{content:"\f625"}.bi-x-octagon-fill::before{content:"\f626"}.bi-x-octagon::before{content:"\f627"}.bi-x-square-fill::before{content:"\f628"}.bi-x-square::before{content:"\f629"}.bi-x::before{content:"\f62a"}.bi-youtube::before{content:"\f62b"}.bi-zoom-in::before{content:"\f62c"}.bi-zoom-out::before{content:"\f62d"}.bi-bank::before{content:"\f62e"}.bi-bank2::before{content:"\f62f"}.bi-bell-slash-fill::before{content:"\f630"}.bi-bell-slash::before{content:"\f631"}.bi-cash-coin::before{content:"\f632"}.bi-check-lg::before{content:"\f633"}.bi-coin::before{content:"\f634"}.bi-currency-bitcoin::before{content:"\f635"}.bi-currency-dollar::before{content:"\f636"}.bi-currency-euro::before{content:"\f637"}.bi-currency-exchange::before{content:"\f638"}.bi-currency-pound::before{content:"\f639"}.bi-currency-yen::before{content:"\f63a"}.bi-dash-lg::before{content:"\f63b"}.bi-exclamation-lg::before{content:"\f63c"}.bi-file-earmark-pdf-fill::before{content:"\f63d"}.bi-file-earmark-pdf::before{content:"\f63e"}.bi-file-pdf-fill::before{content:"\f63f"}.bi-file-pdf::before{content:"\f640"}.bi-gender-ambiguous::before{content:"\f641"}.bi-gender-female::before{content:"\f642"}.bi-gender-male::before{content:"\f643"}.bi-gender-trans::before{content:"\f644"}.bi-headset-vr::before{content:"\f645"}.bi-info-lg::before{content:"\f646"}.bi-mastodon::before{content:"\f647"}.bi-messenger::before{content:"\f648"}.bi-piggy-bank-fill::before{content:"\f649"}.bi-piggy-bank::before{content:"\f64a"}.bi-pin-map-fill::before{content:"\f64b"}.bi-pin-map::before{content:"\f64c"}.bi-plus-lg::before{content:"\f64d"}.bi-question-lg::before{content:"\f64e"}.bi-recycle::before{content:"\f64f"}.bi-reddit::before{content:"\f650"}.bi-safe-fill::before{content:"\f651"}.bi-safe2-fill::before{content:"\f652"}.bi-safe2::before{content:"\f653"}.bi-sd-card-fill::before{content:"\f654"}.bi-sd-card::before{content:"\f655"}.bi-skype::before{content:"\f656"}.bi-slash-lg::before{content:"\f657"}.bi-translate::before{content:"\f658"}.bi-x-lg::before{content:"\f659"}.bi-safe::before{content:"\f65a"}.bi-apple::before{content:"\f65b"}.bi-microsoft::before{content:"\f65d"}.bi-windows::before{content:"\f65e"}.bi-behance::before{content:"\f65c"}.bi-dribbble::before{content:"\f65f"}.bi-line::before{content:"\f660"}.bi-medium::before{content:"\f661"}.bi-paypal::before{content:"\f662"}.bi-pinterest::before{content:"\f663"}.bi-signal::before{content:"\f664"}.bi-snapchat::before{content:"\f665"}.bi-spotify::before{content:"\f666"}.bi-stack-overflow::before{content:"\f667"}.bi-strava::before{content:"\f668"}.bi-wordpress::before{content:"\f669"}.bi-vimeo::before{content:"\f66a"}.bi-activity::before{content:"\f66b"}.bi-easel2-fill::before{content:"\f66c"}.bi-easel2::before{content:"\f66d"}.bi-easel3-fill::before{content:"\f66e"}.bi-easel3::before{content:"\f66f"}.bi-fan::before{content:"\f670"}.bi-fingerprint::before{content:"\f671"}.bi-graph-down-arrow::before{content:"\f672"}.bi-graph-up-arrow::before{content:"\f673"}.bi-hypnotize::before{content:"\f674"}.bi-magic::before{content:"\f675"}.bi-person-rolodex::before{content:"\f676"}.bi-person-video::before{content:"\f677"}.bi-person-video2::before{content:"\f678"}.bi-person-video3::before{content:"\f679"}.bi-person-workspace::before{content:"\f67a"}.bi-radioactive::before{content:"\f67b"}.bi-webcam-fill::before{content:"\f67c"}.bi-webcam::before{content:"\f67d"}.bi-yin-yang::before{content:"\f67e"}.bi-bandaid-fill::before{content:"\f680"}.bi-bandaid::before{content:"\f681"}.bi-bluetooth::before{content:"\f682"}.bi-body-text::before{content:"\f683"}.bi-boombox::before{content:"\f684"}.bi-boxes::before{content:"\f685"}.bi-dpad-fill::before{content:"\f686"}.bi-dpad::before{content:"\f687"}.bi-ear-fill::before{content:"\f688"}.bi-ear::before{content:"\f689"}.bi-envelope-check-fill::before{content:"\f68b"}.bi-envelope-check::before{content:"\f68c"}.bi-envelope-dash-fill::before{content:"\f68e"}.bi-envelope-dash::before{content:"\f68f"}.bi-envelope-exclamation-fill::before{content:"\f691"}.bi-envelope-exclamation::before{content:"\f692"}.bi-envelope-plus-fill::before{content:"\f693"}.bi-envelope-plus::before{content:"\f694"}.bi-envelope-slash-fill::before{content:"\f696"}.bi-envelope-slash::before{content:"\f697"}.bi-envelope-x-fill::before{content:"\f699"}.bi-envelope-x::before{content:"\f69a"}.bi-explicit-fill::before{content:"\f69b"}.bi-explicit::before{content:"\f69c"}.bi-git::before{content:"\f69d"}.bi-infinity::before{content:"\f69e"}.bi-list-columns-reverse::before{content:"\f69f"}.bi-list-columns::before{content:"\f6a0"}.bi-meta::before{content:"\f6a1"}.bi-nintendo-switch::before{content:"\f6a4"}.bi-pc-display-horizontal::before{content:"\f6a5"}.bi-pc-display::before{content:"\f6a6"}.bi-pc-horizontal::before{content:"\f6a7"}.bi-pc::before{content:"\f6a8"}.bi-playstation::before{content:"\f6a9"}.bi-plus-slash-minus::before{content:"\f6aa"}.bi-projector-fill::before{content:"\f6ab"}.bi-projector::before{content:"\f6ac"}.bi-qr-code-scan::before{content:"\f6ad"}.bi-qr-code::before{content:"\f6ae"}.bi-quora::before{content:"\f6af"}.bi-quote::before{content:"\f6b0"}.bi-robot::before{content:"\f6b1"}.bi-send-check-fill::before{content:"\f6b2"}.bi-send-check::before{content:"\f6b3"}.bi-send-dash-fill::before{content:"\f6b4"}.bi-send-dash::before{content:"\f6b5"}.bi-send-exclamation-fill::before{content:"\f6b7"}.bi-send-exclamation::before{content:"\f6b8"}.bi-send-fill::before{content:"\f6b9"}.bi-send-plus-fill::before{content:"\f6ba"}.bi-send-plus::before{content:"\f6bb"}.bi-send-slash-fill::before{content:"\f6bc"}.bi-send-slash::before{content:"\f6bd"}.bi-send-x-fill::before{content:"\f6be"}.bi-send-x::before{content:"\f6bf"}.bi-send::before{content:"\f6c0"}.bi-steam::before{content:"\f6c1"}.bi-terminal-dash::before{content:"\f6c3"}.bi-terminal-plus::before{content:"\f6c4"}.bi-terminal-split::before{content:"\f6c5"}.bi-ticket-detailed-fill::before{content:"\f6c6"}.bi-ticket-detailed::before{content:"\f6c7"}.bi-ticket-fill::before{content:"\f6c8"}.bi-ticket-perforated-fill::before{content:"\f6c9"}.bi-ticket-perforated::before{content:"\f6ca"}.bi-ticket::before{content:"\f6cb"}.bi-tiktok::before{content:"\f6cc"}.bi-window-dash::before{content:"\f6cd"}.bi-window-desktop::before{content:"\f6ce"}.bi-window-fullscreen::before{content:"\f6cf"}.bi-window-plus::before{content:"\f6d0"}.bi-window-split::before{content:"\f6d1"}.bi-window-stack::before{content:"\f6d2"}.bi-window-x::before{content:"\f6d3"}.bi-xbox::before{content:"\f6d4"}.bi-ethernet::before{content:"\f6d5"}.bi-hdmi-fill::before{content:"\f6d6"}.bi-hdmi::before{content:"\f6d7"}.bi-usb-c-fill::before{content:"\f6d8"}.bi-usb-c::before{content:"\f6d9"}.bi-usb-fill::before{content:"\f6da"}.bi-usb-plug-fill::before{content:"\f6db"}.bi-usb-plug::before{content:"\f6dc"}.bi-usb-symbol::before{content:"\f6dd"}.bi-usb::before{content:"\f6de"}.bi-boombox-fill::before{content:"\f6df"}.bi-displayport::before{content:"\f6e1"}.bi-gpu-card::before{content:"\f6e2"}.bi-memory::before{content:"\f6e3"}.bi-modem-fill::before{content:"\f6e4"}.bi-modem::before{content:"\f6e5"}.bi-motherboard-fill::before{content:"\f6e6"}.bi-motherboard::before{content:"\f6e7"}.bi-optical-audio-fill::before{content:"\f6e8"}.bi-optical-audio::before{content:"\f6e9"}.bi-pci-card::before{content:"\f6ea"}.bi-router-fill::before{content:"\f6eb"}.bi-router::before{content:"\f6ec"}.bi-thunderbolt-fill::before{content:"\f6ef"}.bi-thunderbolt::before{content:"\f6f0"}.bi-usb-drive-fill::before{content:"\f6f1"}.bi-usb-drive::before{content:"\f6f2"}.bi-usb-micro-fill::before{content:"\f6f3"}.bi-usb-micro::before{content:"\f6f4"}.bi-usb-mini-fill::before{content:"\f6f5"}.bi-usb-mini::before{content:"\f6f6"}.bi-cloud-haze2::before{content:"\f6f7"}.bi-device-hdd-fill::before{content:"\f6f8"}.bi-device-hdd::before{content:"\f6f9"}.bi-device-ssd-fill::before{content:"\f6fa"}.bi-device-ssd::before{content:"\f6fb"}.bi-displayport-fill::before{content:"\f6fc"}.bi-mortarboard-fill::before{content:"\f6fd"}.bi-mortarboard::before{content:"\f6fe"}.bi-terminal-x::before{content:"\f6ff"}.bi-arrow-through-heart-fill::before{content:"\f700"}.bi-arrow-through-heart::before{content:"\f701"}.bi-badge-sd-fill::before{content:"\f702"}.bi-badge-sd::before{content:"\f703"}.bi-bag-heart-fill::before{content:"\f704"}.bi-bag-heart::before{content:"\f705"}.bi-balloon-fill::before{content:"\f706"}.bi-balloon-heart-fill::before{content:"\f707"}.bi-balloon-heart::before{content:"\f708"}.bi-balloon::before{content:"\f709"}.bi-box2-fill::before{content:"\f70a"}.bi-box2-heart-fill::before{content:"\f70b"}.bi-box2-heart::before{content:"\f70c"}.bi-box2::before{content:"\f70d"}.bi-braces-asterisk::before{content:"\f70e"}.bi-calendar-heart-fill::before{content:"\f70f"}.bi-calendar-heart::before{content:"\f710"}.bi-calendar2-heart-fill::before{content:"\f711"}.bi-calendar2-heart::before{content:"\f712"}.bi-chat-heart-fill::before{content:"\f713"}.bi-chat-heart::before{content:"\f714"}.bi-chat-left-heart-fill::before{content:"\f715"}.bi-chat-left-heart::before{content:"\f716"}.bi-chat-right-heart-fill::before{content:"\f717"}.bi-chat-right-heart::before{content:"\f718"}.bi-chat-square-heart-fill::before{content:"\f719"}.bi-chat-square-heart::before{content:"\f71a"}.bi-clipboard-check-fill::before{content:"\f71b"}.bi-clipboard-data-fill::before{content:"\f71c"}.bi-clipboard-fill::before{content:"\f71d"}.bi-clipboard-heart-fill::before{content:"\f71e"}.bi-clipboard-heart::before{content:"\f71f"}.bi-clipboard-minus-fill::before{content:"\f720"}.bi-clipboard-plus-fill::before{content:"\f721"}.bi-clipboard-pulse::before{content:"\f722"}.bi-clipboard-x-fill::before{content:"\f723"}.bi-clipboard2-check-fill::before{content:"\f724"}.bi-clipboard2-check::before{content:"\f725"}.bi-clipboard2-data-fill::before{content:"\f726"}.bi-clipboard2-data::before{content:"\f727"}.bi-clipboard2-fill::before{content:"\f728"}.bi-clipboard2-heart-fill::before{content:"\f729"}.bi-clipboard2-heart::before{content:"\f72a"}.bi-clipboard2-minus-fill::before{content:"\f72b"}.bi-clipboard2-minus::before{content:"\f72c"}.bi-clipboard2-plus-fill::before{content:"\f72d"}.bi-clipboard2-plus::before{content:"\f72e"}.bi-clipboard2-pulse-fill::before{content:"\f72f"}.bi-clipboard2-pulse::before{content:"\f730"}.bi-clipboard2-x-fill::before{content:"\f731"}.bi-clipboard2-x::before{content:"\f732"}.bi-clipboard2::before{content:"\f733"}.bi-emoji-kiss-fill::before{content:"\f734"}.bi-emoji-kiss::before{content:"\f735"}.bi-envelope-heart-fill::before{content:"\f736"}.bi-envelope-heart::before{content:"\f737"}.bi-envelope-open-heart-fill::before{content:"\f738"}.bi-envelope-open-heart::before{content:"\f739"}.bi-envelope-paper-fill::before{content:"\f73a"}.bi-envelope-paper-heart-fill::before{content:"\f73b"}.bi-envelope-paper-heart::before{content:"\f73c"}.bi-envelope-paper::before{content:"\f73d"}.bi-filetype-aac::before{content:"\f73e"}.bi-filetype-ai::before{content:"\f73f"}.bi-filetype-bmp::before{content:"\f740"}.bi-filetype-cs::before{content:"\f741"}.bi-filetype-css::before{content:"\f742"}.bi-filetype-csv::before{content:"\f743"}.bi-filetype-doc::before{content:"\f744"}.bi-filetype-docx::before{content:"\f745"}.bi-filetype-exe::before{content:"\f746"}.bi-filetype-gif::before{content:"\f747"}.bi-filetype-heic::before{content:"\f748"}.bi-filetype-html::before{content:"\f749"}.bi-filetype-java::before{content:"\f74a"}.bi-filetype-jpg::before{content:"\f74b"}.bi-filetype-js::before{content:"\f74c"}.bi-filetype-jsx::before{content:"\f74d"}.bi-filetype-key::before{content:"\f74e"}.bi-filetype-m4p::before{content:"\f74f"}.bi-filetype-md::before{content:"\f750"}.bi-filetype-mdx::before{content:"\f751"}.bi-filetype-mov::before{content:"\f752"}.bi-filetype-mp3::before{content:"\f753"}.bi-filetype-mp4::before{content:"\f754"}.bi-filetype-otf::before{content:"\f755"}.bi-filetype-pdf::before{content:"\f756"}.bi-filetype-php::before{content:"\f757"}.bi-filetype-png::before{content:"\f758"}.bi-filetype-ppt::before{content:"\f75a"}.bi-filetype-psd::before{content:"\f75b"}.bi-filetype-py::before{content:"\f75c"}.bi-filetype-raw::before{content:"\f75d"}.bi-filetype-rb::before{content:"\f75e"}.bi-filetype-sass::before{content:"\f75f"}.bi-filetype-scss::before{content:"\f760"}.bi-filetype-sh::before{content:"\f761"}.bi-filetype-svg::before{content:"\f762"}.bi-filetype-tiff::before{content:"\f763"}.bi-filetype-tsx::before{content:"\f764"}.bi-filetype-ttf::before{content:"\f765"}.bi-filetype-txt::before{content:"\f766"}.bi-filetype-wav::before{content:"\f767"}.bi-filetype-woff::before{content:"\f768"}.bi-filetype-xls::before{content:"\f76a"}.bi-filetype-xml::before{content:"\f76b"}.bi-filetype-yml::before{content:"\f76c"}.bi-heart-arrow::before{content:"\f76d"}.bi-heart-pulse-fill::before{content:"\f76e"}.bi-heart-pulse::before{content:"\f76f"}.bi-heartbreak-fill::before{content:"\f770"}.bi-heartbreak::before{content:"\f771"}.bi-hearts::before{content:"\f772"}.bi-hospital-fill::before{content:"\f773"}.bi-hospital::before{content:"\f774"}.bi-house-heart-fill::before{content:"\f775"}.bi-house-heart::before{content:"\f776"}.bi-incognito::before{content:"\f777"}.bi-magnet-fill::before{content:"\f778"}.bi-magnet::before{content:"\f779"}.bi-person-heart::before{content:"\f77a"}.bi-person-hearts::before{content:"\f77b"}.bi-phone-flip::before{content:"\f77c"}.bi-plugin::before{content:"\f77d"}.bi-postage-fill::before{content:"\f77e"}.bi-postage-heart-fill::before{content:"\f77f"}.bi-postage-heart::before{content:"\f780"}.bi-postage::before{content:"\f781"}.bi-postcard-fill::before{content:"\f782"}.bi-postcard-heart-fill::before{content:"\f783"}.bi-postcard-heart::before{content:"\f784"}.bi-postcard::before{content:"\f785"}.bi-search-heart-fill::before{content:"\f786"}.bi-search-heart::before{content:"\f787"}.bi-sliders2-vertical::before{content:"\f788"}.bi-sliders2::before{content:"\f789"}.bi-trash3-fill::before{content:"\f78a"}.bi-trash3::before{content:"\f78b"}.bi-valentine::before{content:"\f78c"}.bi-valentine2::before{content:"\f78d"}.bi-wrench-adjustable-circle-fill::before{content:"\f78e"}.bi-wrench-adjustable-circle::before{content:"\f78f"}.bi-wrench-adjustable::before{content:"\f790"}.bi-filetype-json::before{content:"\f791"}.bi-filetype-pptx::before{content:"\f792"}.bi-filetype-xlsx::before{content:"\f793"}.bi-1-circle-fill::before{content:"\f796"}.bi-1-circle::before{content:"\f797"}.bi-1-square-fill::before{content:"\f798"}.bi-1-square::before{content:"\f799"}.bi-2-circle-fill::before{content:"\f79c"}.bi-2-circle::before{content:"\f79d"}.bi-2-square-fill::before{content:"\f79e"}.bi-2-square::before{content:"\f79f"}.bi-3-circle-fill::before{content:"\f7a2"}.bi-3-circle::before{content:"\f7a3"}.bi-3-square-fill::before{content:"\f7a4"}.bi-3-square::before{content:"\f7a5"}.bi-4-circle-fill::before{content:"\f7a8"}.bi-4-circle::before{content:"\f7a9"}.bi-4-square-fill::before{content:"\f7aa"}.bi-4-square::before{content:"\f7ab"}.bi-5-circle-fill::before{content:"\f7ae"}.bi-5-circle::before{content:"\f7af"}.bi-5-square-fill::before{content:"\f7b0"}.bi-5-square::before{content:"\f7b1"}.bi-6-circle-fill::before{content:"\f7b4"}.bi-6-circle::before{content:"\f7b5"}.bi-6-square-fill::before{content:"\f7b6"}.bi-6-square::before{content:"\f7b7"}.bi-7-circle-fill::before{content:"\f7ba"}.bi-7-circle::before{content:"\f7bb"}.bi-7-square-fill::before{content:"\f7bc"}.bi-7-square::before{content:"\f7bd"}.bi-8-circle-fill::before{content:"\f7c0"}.bi-8-circle::before{content:"\f7c1"}.bi-8-square-fill::before{content:"\f7c2"}.bi-8-square::before{content:"\f7c3"}.bi-9-circle-fill::before{content:"\f7c6"}.bi-9-circle::before{content:"\f7c7"}.bi-9-square-fill::before{content:"\f7c8"}.bi-9-square::before{content:"\f7c9"}.bi-airplane-engines-fill::before{content:"\f7ca"}.bi-airplane-engines::before{content:"\f7cb"}.bi-airplane-fill::before{content:"\f7cc"}.bi-airplane::before{content:"\f7cd"}.bi-alexa::before{content:"\f7ce"}.bi-alipay::before{content:"\f7cf"}.bi-android::before{content:"\f7d0"}.bi-android2::before{content:"\f7d1"}.bi-box-fill::before{content:"\f7d2"}.bi-box-seam-fill::before{content:"\f7d3"}.bi-browser-chrome::before{content:"\f7d4"}.bi-browser-edge::before{content:"\f7d5"}.bi-browser-firefox::before{content:"\f7d6"}.bi-browser-safari::before{content:"\f7d7"}.bi-c-circle-fill::before{content:"\f7da"}.bi-c-circle::before{content:"\f7db"}.bi-c-square-fill::before{content:"\f7dc"}.bi-c-square::before{content:"\f7dd"}.bi-capsule-pill::before{content:"\f7de"}.bi-capsule::before{content:"\f7df"}.bi-car-front-fill::before{content:"\f7e0"}.bi-car-front::before{content:"\f7e1"}.bi-cassette-fill::before{content:"\f7e2"}.bi-cassette::before{content:"\f7e3"}.bi-cc-circle-fill::before{content:"\f7e6"}.bi-cc-circle::before{content:"\f7e7"}.bi-cc-square-fill::before{content:"\f7e8"}.bi-cc-square::before{content:"\f7e9"}.bi-cup-hot-fill::before{content:"\f7ea"}.bi-cup-hot::before{content:"\f7eb"}.bi-currency-rupee::before{content:"\f7ec"}.bi-dropbox::before{content:"\f7ed"}.bi-escape::before{content:"\f7ee"}.bi-fast-forward-btn-fill::before{content:"\f7ef"}.bi-fast-forward-btn::before{content:"\f7f0"}.bi-fast-forward-circle-fill::before{content:"\f7f1"}.bi-fast-forward-circle::before{content:"\f7f2"}.bi-fast-forward-fill::before{content:"\f7f3"}.bi-fast-forward::before{content:"\f7f4"}.bi-filetype-sql::before{content:"\f7f5"}.bi-fire::before{content:"\f7f6"}.bi-google-play::before{content:"\f7f7"}.bi-h-circle-fill::before{content:"\f7fa"}.bi-h-circle::before{content:"\f7fb"}.bi-h-square-fill::before{content:"\f7fc"}.bi-h-square::before{content:"\f7fd"}.bi-indent::before{content:"\f7fe"}.bi-lungs-fill::before{content:"\f7ff"}.bi-lungs::before{content:"\f800"}.bi-microsoft-teams::before{content:"\f801"}.bi-p-circle-fill::before{content:"\f804"}.bi-p-circle::before{content:"\f805"}.bi-p-square-fill::before{content:"\f806"}.bi-p-square::before{content:"\f807"}.bi-pass-fill::before{content:"\f808"}.bi-pass::before{content:"\f809"}.bi-prescription::before{content:"\f80a"}.bi-prescription2::before{content:"\f80b"}.bi-r-circle-fill::before{content:"\f80e"}.bi-r-circle::before{content:"\f80f"}.bi-r-square-fill::before{content:"\f810"}.bi-r-square::before{content:"\f811"}.bi-repeat-1::before{content:"\f812"}.bi-repeat::before{content:"\f813"}.bi-rewind-btn-fill::before{content:"\f814"}.bi-rewind-btn::before{content:"\f815"}.bi-rewind-circle-fill::before{content:"\f816"}.bi-rewind-circle::before{content:"\f817"}.bi-rewind-fill::before{content:"\f818"}.bi-rewind::before{content:"\f819"}.bi-train-freight-front-fill::before{content:"\f81a"}.bi-train-freight-front::before{content:"\f81b"}.bi-train-front-fill::before{content:"\f81c"}.bi-train-front::before{content:"\f81d"}.bi-train-lightrail-front-fill::before{content:"\f81e"}.bi-train-lightrail-front::before{content:"\f81f"}.bi-truck-front-fill::before{content:"\f820"}.bi-truck-front::before{content:"\f821"}.bi-ubuntu::before{content:"\f822"}.bi-unindent::before{content:"\f823"}.bi-unity::before{content:"\f824"}.bi-universal-access-circle::before{content:"\f825"}.bi-universal-access::before{content:"\f826"}.bi-virus::before{content:"\f827"}.bi-virus2::before{content:"\f828"}.bi-wechat::before{content:"\f829"}.bi-yelp::before{content:"\f82a"}.bi-sign-stop-fill::before{content:"\f82b"}.bi-sign-stop-lights-fill::before{content:"\f82c"}.bi-sign-stop-lights::before{content:"\f82d"}.bi-sign-stop::before{content:"\f82e"}.bi-sign-turn-left-fill::before{content:"\f82f"}.bi-sign-turn-left::before{content:"\f830"}.bi-sign-turn-right-fill::before{content:"\f831"}.bi-sign-turn-right::before{content:"\f832"}.bi-sign-turn-slight-left-fill::before{content:"\f833"}.bi-sign-turn-slight-left::before{content:"\f834"}.bi-sign-turn-slight-right-fill::before{content:"\f835"}.bi-sign-turn-slight-right::before{content:"\f836"}.bi-sign-yield-fill::before{content:"\f837"}.bi-sign-yield::before{content:"\f838"}.bi-ev-station-fill::before{content:"\f839"}.bi-ev-station::before{content:"\f83a"}.bi-fuel-pump-diesel-fill::before{content:"\f83b"}.bi-fuel-pump-diesel::before{content:"\f83c"}.bi-fuel-pump-fill::before{content:"\f83d"}.bi-fuel-pump::before{content:"\f83e"}.bi-0-circle-fill::before{content:"\f83f"}.bi-0-circle::before{content:"\f840"}.bi-0-square-fill::before{content:"\f841"}.bi-0-square::before{content:"\f842"}.bi-rocket-fill::before{content:"\f843"}.bi-rocket-takeoff-fill::before{content:"\f844"}.bi-rocket-takeoff::before{content:"\f845"}.bi-rocket::before{content:"\f846"}.bi-stripe::before{content:"\f847"}.bi-subscript::before{content:"\f848"}.bi-superscript::before{content:"\f849"}.bi-trello::before{content:"\f84a"}.bi-envelope-at-fill::before{content:"\f84b"}.bi-envelope-at::before{content:"\f84c"}.bi-regex::before{content:"\f84d"}.bi-text-wrap::before{content:"\f84e"}.bi-sign-dead-end-fill::before{content:"\f84f"}.bi-sign-dead-end::before{content:"\f850"}.bi-sign-do-not-enter-fill::before{content:"\f851"}.bi-sign-do-not-enter::before{content:"\f852"}.bi-sign-intersection-fill::before{content:"\f853"}.bi-sign-intersection-side-fill::before{content:"\f854"}.bi-sign-intersection-side::before{content:"\f855"}.bi-sign-intersection-t-fill::before{content:"\f856"}.bi-sign-intersection-t::before{content:"\f857"}.bi-sign-intersection-y-fill::before{content:"\f858"}.bi-sign-intersection-y::before{content:"\f859"}.bi-sign-intersection::before{content:"\f85a"}.bi-sign-merge-left-fill::before{content:"\f85b"}.bi-sign-merge-left::before{content:"\f85c"}.bi-sign-merge-right-fill::before{content:"\f85d"}.bi-sign-merge-right::before{content:"\f85e"}.bi-sign-no-left-turn-fill::before{content:"\f85f"}.bi-sign-no-left-turn::before{content:"\f860"}.bi-sign-no-parking-fill::before{content:"\f861"}.bi-sign-no-parking::before{content:"\f862"}.bi-sign-no-right-turn-fill::before{content:"\f863"}.bi-sign-no-right-turn::before{content:"\f864"}.bi-sign-railroad-fill::before{content:"\f865"}.bi-sign-railroad::before{content:"\f866"}.bi-building-add::before{content:"\f867"}.bi-building-check::before{content:"\f868"}.bi-building-dash::before{content:"\f869"}.bi-building-down::before{content:"\f86a"}.bi-building-exclamation::before{content:"\f86b"}.bi-building-fill-add::before{content:"\f86c"}.bi-building-fill-check::before{content:"\f86d"}.bi-building-fill-dash::before{content:"\f86e"}.bi-building-fill-down::before{content:"\f86f"}.bi-building-fill-exclamation::before{content:"\f870"}.bi-building-fill-gear::before{content:"\f871"}.bi-building-fill-lock::before{content:"\f872"}.bi-building-fill-slash::before{content:"\f873"}.bi-building-fill-up::before{content:"\f874"}.bi-building-fill-x::before{content:"\f875"}.bi-building-fill::before{content:"\f876"}.bi-building-gear::before{content:"\f877"}.bi-building-lock::before{content:"\f878"}.bi-building-slash::before{content:"\f879"}.bi-building-up::before{content:"\f87a"}.bi-building-x::before{content:"\f87b"}.bi-buildings-fill::before{content:"\f87c"}.bi-buildings::before{content:"\f87d"}.bi-bus-front-fill::before{content:"\f87e"}.bi-bus-front::before{content:"\f87f"}.bi-ev-front-fill::before{content:"\f880"}.bi-ev-front::before{content:"\f881"}.bi-globe-americas::before{content:"\f882"}.bi-globe-asia-australia::before{content:"\f883"}.bi-globe-central-south-asia::before{content:"\f884"}.bi-globe-europe-africa::before{content:"\f885"}.bi-house-add-fill::before{content:"\f886"}.bi-house-add::before{content:"\f887"}.bi-house-check-fill::before{content:"\f888"}.bi-house-check::before{content:"\f889"}.bi-house-dash-fill::before{content:"\f88a"}.bi-house-dash::before{content:"\f88b"}.bi-house-down-fill::before{content:"\f88c"}.bi-house-down::before{content:"\f88d"}.bi-house-exclamation-fill::before{content:"\f88e"}.bi-house-exclamation::before{content:"\f88f"}.bi-house-gear-fill::before{content:"\f890"}.bi-house-gear::before{content:"\f891"}.bi-house-lock-fill::before{content:"\f892"}.bi-house-lock::before{content:"\f893"}.bi-house-slash-fill::before{content:"\f894"}.bi-house-slash::before{content:"\f895"}.bi-house-up-fill::before{content:"\f896"}.bi-house-up::before{content:"\f897"}.bi-house-x-fill::before{content:"\f898"}.bi-house-x::before{content:"\f899"}.bi-person-add::before{content:"\f89a"}.bi-person-down::before{content:"\f89b"}.bi-person-exclamation::before{content:"\f89c"}.bi-person-fill-add::before{content:"\f89d"}.bi-person-fill-check::before{content:"\f89e"}.bi-person-fill-dash::before{content:"\f89f"}.bi-person-fill-down::before{content:"\f8a0"}.bi-person-fill-exclamation::before{content:"\f8a1"}.bi-person-fill-gear::before{content:"\f8a2"}.bi-person-fill-lock::before{content:"\f8a3"}.bi-person-fill-slash::before{content:"\f8a4"}.bi-person-fill-up::before{content:"\f8a5"}.bi-person-fill-x::before{content:"\f8a6"}.bi-person-gear::before{content:"\f8a7"}.bi-person-lock::before{content:"\f8a8"}.bi-person-slash::before{content:"\f8a9"}.bi-person-up::before{content:"\f8aa"}.bi-scooter::before{content:"\f8ab"}.bi-taxi-front-fill::before{content:"\f8ac"}.bi-taxi-front::before{content:"\f8ad"}.bi-amd::before{content:"\f8ae"}.bi-database-add::before{content:"\f8af"}.bi-database-check::before{content:"\f8b0"}.bi-database-dash::before{content:"\f8b1"}.bi-database-down::before{content:"\f8b2"}.bi-database-exclamation::before{content:"\f8b3"}.bi-database-fill-add::before{content:"\f8b4"}.bi-database-fill-check::before{content:"\f8b5"}.bi-database-fill-dash::before{content:"\f8b6"}.bi-database-fill-down::before{content:"\f8b7"}.bi-database-fill-exclamation::before{content:"\f8b8"}.bi-database-fill-gear::before{content:"\f8b9"}.bi-database-fill-lock::before{content:"\f8ba"}.bi-database-fill-slash::before{content:"\f8bb"}.bi-database-fill-up::before{content:"\f8bc"}.bi-database-fill-x::before{content:"\f8bd"}.bi-database-fill::before{content:"\f8be"}.bi-database-gear::before{content:"\f8bf"}.bi-database-lock::before{content:"\f8c0"}.bi-database-slash::before{content:"\f8c1"}.bi-database-up::before{content:"\f8c2"}.bi-database-x::before{content:"\f8c3"}.bi-database::before{content:"\f8c4"}.bi-houses-fill::before{content:"\f8c5"}.bi-houses::before{content:"\f8c6"}.bi-nvidia::before{content:"\f8c7"}.bi-person-vcard-fill::before{content:"\f8c8"}.bi-person-vcard::before{content:"\f8c9"}.bi-sina-weibo::before{content:"\f8ca"}.bi-tencent-qq::before{content:"\f8cb"}.bi-wikipedia::before{content:"\f8cc"} \ No newline at end of file diff --git a/static/css/icons-local.css b/static/css/icons-local.css new file mode 100644 index 0000000..3e88c49 --- /dev/null +++ b/static/css/icons-local.css @@ -0,0 +1,81 @@ +/* Bootstrap Icons - 本地精简版 */ +/* 使用多CDN备份策略确保字体文件加载 */ + +@font-face { + font-display: block; + font-family: "bootstrap-icons"; + src: + url("https://unpkg.com/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff2") format("woff2"), + url("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff2") format("woff2"), + url("https://cdn.bootcdn.net/ajax/libs/bootstrap-icons/1.11.3/font/bootstrap-icons.woff2") format("woff2"), + url("https://unpkg.com/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff") format("woff"), + url("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff") format("woff"); +} + +.bi::before, +[class^="bi-"]::before, +[class*=" bi-"]::before { + display: inline-block; + font-family: bootstrap-icons !important; + font-style: normal; + font-weight: normal !important; + font-variant: normal; + text-transform: none; + line-height: 1; + vertical-align: -.125em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* 项目中使用的图标 */ +.bi-shield-lock-fill::before { content: "\f621"; } +.bi-person-fill::before { content: "\f4da"; } +.bi-key-fill::before { content: "\f494"; } +.bi-box-arrow-in-right::before { content: "\f1cb"; } +.bi-hourglass-split::before { content: "\f47e"; } +.bi-file-earmark-text::before { content: "\f32a"; } +.bi-person-circle::before { content: "\f4d6"; } +.bi-box-arrow-right::before { content: "\f1cd"; } +.bi-link-45deg::before { content: "\f4b3"; } +.bi-info-circle::before { content: "\f489"; } +.bi-cookie::before { content: "\f2a0"; } +.bi-calendar-range::before { content: "\f1e9"; } +.bi-shield-check::before { content: "\f61c"; } +.bi-file-earmark-spreadsheet::before { content: "\f324"; } +.bi-card-list::before { content: "\f1ed"; } +.bi-download::before { content: "\f30b"; } +.bi-plus-circle::before { content: "\f512"; } +.bi-1-circle::before { content: "\f657"; } +.bi-2-circle::before { content: "\f658"; } +.bi-3-circle::before { content: "\f659"; } +.bi-4-circle::before { content: "\f65a"; } +.bi-5-circle::before { content: "\f65b"; } +.bi-6-circle::before { content: "\f65c"; } +.bi-file-arrow-down::before { content: "\f310"; } +.bi-list-ul::before { content: "\f4bc"; } +.bi-clock::before { content: "\f279"; } +.bi-check-circle-fill::before { content: "\f26b"; } +.bi-three-dots::before { content: "\f62d"; } +.bi-book::before { content: "\f194"; } +.bi-exclamation-triangle::before { content: "\f33c"; } +.bi-inbox::before { content: "\f486"; } +.bi-list-task::before { content: "\f4ba"; } +.bi-cloud-download::before { content: "\f265"; } +.bi-file-earmark-arrow-down::before { content: "\f30e"; } +.bi-newspaper::before { content: "\f4ca"; } +.bi-kanban::before { content: "\f48d"; } +.bi-list-check::before { content: "\f4b6"; } +.bi-collection::before { content: "\f285"; } +.bi-arrow-repeat::before { content: "\f130"; } +.bi-check-circle::before { content: "\f26a"; } +.bi-x-circle::before { content: "\f623"; } +.bi-pause-circle::before { content: "\f4c2"; } +.bi-list::before { content: "\f4b4"; } +.bi-eye::before { content: "\f341"; } +.bi-trash::before { content: "\f5de"; } +.bi-file-text::before { content: "\f32d"; } +.bi-chevron-bar-left::before { content: "\f276"; } +.bi-chevron-left::before { content: "\f284"; } +.bi-chevron-right::before { content: "\f285"; } +.bi-chevron-bar-right::before { content: "\f277"; } +.bi-x::before { content: "\f62a"; } diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..1bcd433 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,1050 @@ +/* 管理系统布局 - 钉钉科技蓝配色 + 插画风格 */ +:root { + --primary-color: #0052D9; /* 钉钉科技蓝 */ + --primary-hover: #0041B8; + --primary-light: #E7F3FF; + --primary-gradient: linear-gradient(135deg, #0052D9 0%, #0084FF 100%); + --success-color: #00A870; + --warning-color: #ED7B2F; + --error-color: #E34D59; + --text-primary: #000000; + --text-secondary: #666666; + --text-placeholder: #BBBBBB; + --bg-color: #F5F7FA; + --sidebar-bg: #FFFFFF; + --sidebar-width: 240px; + --border-color: #E5E6EB; + --card-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + --hover-shadow: 0 4px 20px rgba(0, 82, 217, 0.15); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'PingFang SC', 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg-color); + min-height: 100vh; + color: var(--text-primary); + line-height: 1.5; +} + +/* 主布局容器 */ +.app-container { + display: flex; + min-height: 100vh; +} + +/* 左侧菜单栏 */ +.sidebar { + width: var(--sidebar-width); + background: var(--sidebar-bg); + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05); + display: flex; + flex-direction: column; + position: fixed; + left: 0; + top: 0; + bottom: 0; + z-index: 1000; +} + +/* Logo区域 */ +.sidebar-logo { + padding: 24px 20px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 12px; +} + +.sidebar-logo-icon { + width: 40px; + height: 40px; + background: var(--primary-gradient); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 24px; +} + +.sidebar-logo-text { + display: flex; + flex-direction: column; +} + +.sidebar-logo-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.sidebar-logo-subtitle { + font-size: 12px; + color: var(--text-secondary); +} + +/* 菜单导航 */ +.sidebar-nav { + flex: 1; + padding: 20px 12px; + overflow-y: auto; +} + +.nav-menu { + list-style: none; +} + +.nav-item { + margin-bottom: 4px; +} + +.nav-link { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + color: var(--text-secondary); + text-decoration: none; + border-radius: 8px; + transition: all 0.3s ease; + cursor: pointer; + font-size: 14px; +} + +.nav-link:hover { + background: var(--primary-light); + color: var(--primary-color); +} + +.nav-link.active { + background: var(--primary-gradient); + color: white; + font-weight: 500; +} + +.nav-link i { + font-size: 18px; +} + +.nav-badge { + margin-left: auto; + background: var(--error-color); + color: white; + font-size: 12px; + padding: 2px 8px; + border-radius: 10px; + min-width: 20px; + text-align: center; +} + +/* 用户信息区域 */ +.sidebar-user { + padding: 16px; + border-top: 1px solid var(--border-color); +} + +.user-info-card { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--bg-color); + border-radius: 8px; +} + +.user-avatar { + width: 40px; + height: 40px; + background: var(--primary-gradient); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 20px; +} + +.user-details { + flex: 1; +} + +.user-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.user-role { + font-size: 12px; + color: var(--text-secondary); +} + +.logout-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 18px; + padding: 4px; + transition: all 0.3s ease; +} + +.logout-btn:hover { + color: var(--error-color); +} + +/* 主内容区域 */ +.main-content { + flex: 1; + margin-left: var(--sidebar-width); + display: flex; + flex-direction: column; +} + +/* 顶部导航栏 */ +.top-navbar { + background: white; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + padding: 16px 32px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.navbar-title { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); +} + +.navbar-actions { + display: flex; + gap: 12px; +} + +/* 内容区域 */ +.content-area { + flex: 1; + padding: 32px; + overflow-y: auto; +} + +/* 页面头部 */ +.page-header { + margin-bottom: 32px; +} + +.page-title { + font-size: 24px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 12px; +} + +.page-title i { + color: var(--primary-color); +} + +.page-description { + font-size: 14px; + color: var(--text-secondary); +} + +/* 插画背景 */ +.illustration-bg { + position: relative; + background: linear-gradient(135deg, #E7F3FF 0%, #F5F7FA 100%); + border-radius: 16px; + padding: 40px; + margin-bottom: 24px; + overflow: hidden; +} + +.illustration-bg::before { + content: ''; + position: absolute; + top: -50%; + right: -20%; + width: 400px; + height: 400px; + background: radial-gradient(circle, rgba(0, 82, 217, 0.1) 0%, transparent 70%); + border-radius: 50%; +} + +.illustration-bg::after { + content: ''; + position: absolute; + bottom: -30%; + left: -10%; + width: 300px; + height: 300px; + background: radial-gradient(circle, rgba(0, 132, 255, 0.08) 0%, transparent 70%); + border-radius: 50%; +} + +/* 卡片样式 */ +.card { + background: white; + border-radius: 12px; + box-shadow: var(--card-shadow); + margin-bottom: 24px; + transition: all 0.3s ease; + position: relative; + z-index: 1; +} + +.card:hover { + box-shadow: var(--hover-shadow); + transform: translateY(-2px); +} + +.card-header { + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.card-header i { + color: var(--primary-color); + font-size: 20px; +} + +.card-body { + padding: 24px; +} + +/* 表单样式 */ +.form-group { + margin-bottom: 24px; +} + +.form-group:last-child { + margin-bottom: 0; +} + +.form-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 8px; +} + +.label-icon { + color: var(--primary-color); + font-size: 16px; +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 12px 16px; + font-size: 14px; + border: 1px solid var(--border-color); + border-radius: 6px; + transition: all 0.3s ease; + background: white; + color: var(--text-primary); +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(0, 82, 217, 0.1); +} + +.form-input::placeholder, +.form-textarea::placeholder { + color: var(--text-placeholder); +} + +.form-textarea { + resize: vertical; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 12px; +} + +.form-select { + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 36px; + appearance: none; +} + +.input-hint { + display: flex; + align-items: flex-start; + gap: 6px; + margin-top: 8px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.6; +} + +.input-hint i { + flex-shrink: 0; + margin-top: 2px; + color: var(--primary-color); + opacity: 0.6; +} + +/* 复选框组 */ +.checkbox-group { + display: flex; + flex-direction: column; + gap: 12px; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + padding: 12px; + background: #F5F7FA; + border-radius: 8px; + transition: all 0.3s ease; + user-select: none; +} + +.checkbox-label:hover { + background: var(--primary-light); +} + +.checkbox-label input[type="checkbox"] { + width: 20px; + height: 20px; + cursor: pointer; + accent-color: var(--primary-color); +} + +.checkbox-text { + font-size: 14px; + color: var(--text-primary); + font-weight: 500; +} + +/* 信息卡片 */ +.info-card { + background: linear-gradient(135deg, #E7F3FF 0%, #F0F8FF 100%); + border: 1px solid rgba(0, 82, 217, 0.2); +} + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; +} + +.info-item { + display: flex; + align-items: center; + gap: 12px; +} + +.info-icon { + font-size: 24px; + color: var(--primary-color); + flex-shrink: 0; +} + +.info-content { + flex: 1; +} + +.info-label { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 4px; +} + +.info-value { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +/* 按钮样式 */ +.btn { + padding: 12px 24px; + font-size: 14px; + font-weight: 500; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.btn i { + font-size: 16px; +} + +.btn-primary { + background: var(--primary-gradient); + color: white; +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 82, 217, 0.3); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-secondary { + background: white; + color: var(--primary-color); + border: 2px solid var(--primary-color); +} + +.btn-secondary:hover { + background: var(--primary-light); + transform: translateY(-2px); +} + +.btn-success { + background: linear-gradient(135deg, #00A870 0%, #00D68F 100%); + color: white; +} + +.btn-success:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 168, 112, 0.3); +} + +/* 使用说明步骤 */ +.info-steps { + display: flex; + flex-direction: column; + gap: 20px; +} + +.info-step { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.step-number { + width: 40px; + height: 40px; + background: var(--primary-gradient); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 600; + flex-shrink: 0; +} + +.step-content { + flex: 1; +} + +.step-content h4 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; +} + +.step-content p { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; +} + +/* 加载状态 */ +.loading-box { + text-align: center; + padding: 40px 20px; + background: white; + border-radius: 12px; + box-shadow: var(--card-shadow); + margin-bottom: 24px; +} + +.loading-spinner { + width: 48px; + height: 48px; + border: 4px solid var(--primary-light); + border-top-color: var(--primary-color); + border-radius: 50%; + margin: 0 auto 16px; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.loading-text { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 20px; +} + +/* 进度详情 */ +.progress-details { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--border-color); +} + +.progress-bar-container { + width: 100%; + height: 8px; + background: var(--primary-light); + border-radius: 4px; + overflow: hidden; + margin-bottom: 12px; +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--primary-color), #0078FF); + width: 0%; + transition: width 0.3s ease; + border-radius: 4px; + box-shadow: 0 0 10px rgba(0, 82, 217, 0.5); +} + +.progress-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.progress-message { + font-size: 14px; + color: var(--text-primary); + font-weight: 500; +} + +.progress-percent { + font-size: 14px; + color: var(--primary-color); + font-weight: 600; +} + +/* 步骤指示器 */ +.progress-steps { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 12px; + margin-top: 20px; +} + +.step-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 12px 8px; + background: var(--bg-color); + border-radius: 8px; + transition: all 0.3s ease; +} + +.step-item.active { + background: var(--primary-light); + border: 2px solid var(--primary-color); + transform: scale(1.05); +} + +.step-item.completed { + background: #E8F8F2; + border: 2px solid var(--success-color); +} + +.step-item i { + font-size: 24px; + color: var(--text-placeholder); + transition: color 0.3s ease; +} + +.step-item.active i { + color: var(--primary-color); + animation: pulse 1.5s ease-in-out infinite; +} + +.step-item.completed i { + color: var(--success-color); +} + +@keyframes pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } +} + +.step-item span { + font-size: 12px; + color: var(--text-secondary); + text-align: center; +} + +.step-item.active span { + color: var(--primary-color); + font-weight: 600; +} + +.step-item.completed span { + color: var(--success-color); + font-weight: 500; +} + +/* 结果显示 */ +.result-box { + background: white; + border-radius: 12px; + box-shadow: var(--card-shadow); + padding: 24px; + margin-bottom: 24px; +} + +.result-message { + padding: 16px 20px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; +} + +.result-message.success { + background: #E8F8F2; + color: var(--success-color); + border: 1px solid rgba(0, 168, 112, 0.2); +} + +.result-message.success::before { + content: '\f26a'; + font-family: 'bootstrap-icons'; + font-size: 18px; +} + +.result-message.error { + background: #FFECE8; + color: var(--error-color); + border: 1px solid rgba(227, 77, 89, 0.2); +} + +.result-message.error::before { + content: '\f339'; + font-family: 'bootstrap-icons'; + font-size: 18px; +} + +/* 文章预览列表 */ +.article-preview { + max-height: 600px; + margin-bottom: 24px; +} + +.article-preview .card-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.article-count { + background: var(--primary-color); + color: white; + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; +} + +.article-list { + max-height: 480px; + overflow-y: auto; +} + +.article-list::-webkit-scrollbar { + width: 6px; +} + +.article-list::-webkit-scrollbar-track { + background: var(--bg-color); + border-radius: 3px; +} + +.article-list::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +.article-list::-webkit-scrollbar-thumb:hover { + background: var(--text-placeholder); +} + +.article-item { + padding: 16px; + border-bottom: 1px solid var(--border-color); + transition: all 0.3s ease; + animation: slideIn 0.3s ease-out; +} + +.article-item:last-child { + border-bottom: none; +} + +.article-item:hover { + background: var(--bg-color); + transform: translateX(4px); +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.article-item-header { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 8px; +} + +.article-number { + flex-shrink: 0; + width: 28px; + height: 28px; + background: var(--primary-light); + color: var(--primary-color); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; +} + +.article-title { + flex: 1; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.6; + word-break: break-word; +} + +.article-meta { + display: flex; + align-items: center; + gap: 16px; + margin-left: 40px; + font-size: 12px; + color: var(--text-secondary); +} + +.article-time { + display: flex; + align-items: center; + gap: 4px; +} + +.article-time i { + color: var(--primary-color); + opacity: 0.6; +} + +.article-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: #E8F8F2; + color: var(--success-color); + border-radius: 4px; + font-size: 11px; +} + +.article-badge i { + font-size: 10px; +} + +/* 空状态 */ +.article-empty { + text-align: center; + padding: 40px 20px; + color: var(--text-secondary); +} + +.article-empty i { + font-size: 48px; + color: var(--text-placeholder); + margin-bottom: 12px; +} + +.article-empty p { + font-size: 14px; + margin: 0; +} + +/* 使用说明 */ +.instructions-card { + background: white; +} + +.instructions-list { + margin: 0; + padding-left: 24px; + color: var(--text-primary); +} + +.instructions-list li { + margin-bottom: 12px; + line-height: 1.8; +} + +.instructions-list li:last-child { + margin-bottom: 0; +} + +.tips-box { + margin-top: 20px; + padding: 16px; + background: #FFF9E6; + border-left: 4px solid var(--warning-color); + border-radius: 6px; +} + +.tips-header { + font-weight: 600; + color: var(--warning-color); + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 6px; +} + +.tips-list { + margin: 0; + padding-left: 20px; + color: var(--text-secondary); +} + +.tips-list li { + margin-bottom: 8px; + line-height: 1.6; +} + +.tips-list li:last-child { + margin-bottom: 0; +} + +/* 插画装饰 */ +.illustration { + position: fixed; + bottom: 20px; + right: 20px; + width: 300px; + height: 225px; + opacity: 0.3; + pointer-events: none; + z-index: 0; +} + +.illustration svg { + width: 100%; + height: 100%; +} + +/* 页脚 */ +.footer { + text-align: center; + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 12px; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .container { + padding: 20px 16px; + } + + .header h1 { + font-size: 24px; + } + + .header-icon { + font-size: 48px; + } + + .card-body { + padding: 16px; + } + + .info-grid { + grid-template-columns: 1fr; + } + + .illustration { + display: none; + } +} + +/* 动画效果 */ +.card { + animation: fadeInUp 0.5s ease-out; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/static/js/jquery.min.js b/static/js/jquery.min.js new file mode 100644 index 0000000..c4c6022 --- /dev/null +++ b/static/js/jquery.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.6.0 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.0",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var _t,zt=[],Ut=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=zt.pop()||S.expando+"_"+wt.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Ut.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Ut.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Ut,"$1"+r):!1!==e.jsonp&&(e.url+=(Tt.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,zt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((_t=E.implementation.createHTMLDocument("").body).innerHTML="
",2===_t.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=Fe(y.pixelPosition,function(e,t){if(t)return t=We(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0 0) { + $('#queueBadge').text(total).show(); + } else { + $('#queueBadge').hide(); + } + } + }, + error: function() { + // 忽略错误,不显示徽章 + $('#queueBadge').hide(); + } + }); + } + + // 初始加载徽章 + updateQueueBadge(); + + // 每10秒更新一次徽章 + setInterval(updateQueueBadge, 10000); + + // 导出按钮点击事件 + $('#exportBtn').click(function() { + const url = $('#authorUrl').val().trim(); + const cookies = $('#cookieInput').val().trim(); + const months = parseFloat($('#monthsSelect').val()); // 改为parseFloat支持小数 + const articlesOnly = $('#articlesOnlyCheckbox').is(':checked'); // 获取是否只爬取文章 + + // 验证URL + if (!url) { + showError('请输入百家号作者主页地址'); + return; + } + + if (!url.includes('baijiahao.baidu.com') || !url.includes('app_id=')) { + showError('URL格式不正确,请输入完整的百家号作者主页地址'); + return; + } + + // 开始导出(始终使用默认代理) + startExport(url, cookies, months, articlesOnly, true, ''); + }); + + // 下载按钮点击事件 + $('#downloadBtn').click(function() { + if (currentFilename) { + window.location.href = `/api/download/${currentFilename}`; + } + }); + + // 输入框回车事件 + $('#authorUrl').keypress(function(e) { + if (e.which === 13) { + $('#exportBtn').click(); + } + }); + + // 开始导出 + function startExport(url, cookies, months, articlesOnly, useProxy, proxyApiUrl) { + // 隐藏结果框和文章列表 + $('#resultBox').hide(); + $('#downloadBtn').hide(); + $('#articlePreview').hide(); + + // 显示加载框 + $('#loadingBox').show(); + $('#progressDetails').show(); + updateProgress('开始初始化...', 0); + + // 启动进度模拟 + startProgressSimulation(); + + // 禁用按钮 + $('#exportBtn').prop('disabled', true); + + // 构建请求数据(始终启用代理) + const requestData = { + url: url, + cookies: cookies || '', + months: months, // 直接使用months,不要用 || 6 因为0.33是有效值 + use_proxy: useProxy, // 始终启用代理 + proxy_api_url: proxyApiUrl || '', + articles_only: articlesOnly // 仅爬取文章 + }; + + // 发送请求 + $.ajax({ + url: '/api/export', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(requestData), + success: function(response) { + if (response.success) { + currentFilename = response.filename; + completeAllSteps(); + + // 显示文章列表 + if (response.articles && response.articles.length > 0) { + displayArticles(response.articles, response.count); + } + + setTimeout(function() { + showSuccess(`导出成功!共获取到 ${response.count} 篇文章`); + $('#downloadBtn').show(); + }, 500); + } else { + stopProgressSimulation(); + showError(response.message || '导出失败'); + } + }, + error: function(xhr, status, error) { + stopProgressSimulation(); + + // 检查是否需要登录 + if (xhr.status === 401 || (xhr.responseJSON && xhr.responseJSON.need_login)) { + alert('登录已过期,请重新登录'); + window.location.href = '/login'; + return; + } + + let errorMessage = '导出失败,请检查网络连接或稍后重试'; + + if (xhr.responseJSON && xhr.responseJSON.message) { + errorMessage = xhr.responseJSON.message; + } else if (xhr.status === 0) { + errorMessage = '无法连接到服务器,请确保后端服务已启动'; + } else if (xhr.status === 500) { + errorMessage = '服务器内部错误,请稍后重试'; + } + + showError(errorMessage); + }, + complete: function() { + // 隐藏加载框 + $('#loadingBox').hide(); + $('#progressDetails').hide(); + + // 启用按钮 + $('#exportBtn').prop('disabled', false); + } + }); + } + + // 更新进度显示 + function updateProgress(message, percent) { + $('#progressMessage').text(message); + $('#progressBar').css('width', percent + '%'); + $('#progressPercent').text(Math.round(percent) + '%'); + } + + // 启动进度模拟 + function startProgressSimulation() { + currentStep = 0; + $('.step-item').removeClass('active completed'); + + progressInterval = setInterval(function() { + if (currentStep < steps.length) { + // 标记当前步骤为活跃 + $('.step-item').eq(currentStep).addClass('active'); + + // 更新进度 + updateProgress(steps[currentStep].name + '...', steps[currentStep].percent); + + // 模拟步骤完成 + setTimeout(function() { + let step = currentStep; + $('.step-item').eq(step).removeClass('active').addClass('completed'); + }, 1000); + + currentStep++; + } + }, 2000); // 每2秒一个步骤 + } + + // 停止进度模拟 + function stopProgressSimulation() { + if (progressInterval) { + clearInterval(progressInterval); + progressInterval = null; + } + } + + // 完成所有步骤 + function completeAllSteps() { + stopProgressSimulation(); + $('.step-item').removeClass('active').addClass('completed'); + updateProgress('导出完成!', 100); + } + + // 显示文章列表 + function displayArticles(articles, totalCount) { + $('#articleCount').text(totalCount + '篇'); + $('#articleList').empty(); + + if (articles.length === 0) { + $('#articleList').html(` +
+ +

暂无文章数据

+
+ `); + } else { + articles.forEach(function(article, index) { + const articleHtml = ` +
+
+
${index + 1}
+
${escapeHtml(article['标题'] || '未知标题')}
+
+ +
+ `; + $('#articleList').append(articleHtml); + }); + + // 如果总数大于显示数,显示提示 + if (totalCount > articles.length) { + $('#articleList').append(` +
+ + 还有 ${totalCount - articles.length} 篇文章未显示,请下载Excel查看完整列表 +
+ `); + } + } + + $('#articlePreview').fadeIn(); + + // 滚动到文章列表 + setTimeout(function() { + $('html, body').animate({ + scrollTop: $('#articlePreview').offset().top - 20 + }, 500); + }, 300); + } + + // HTML转义 + function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, function(m) { return map[m]; }); + } + + // 显示成功消息 + function showSuccess(message) { + $('#resultMessage') + .removeClass('error') + .addClass('success') + .html(`✅ ${message}`); + $('#resultBox').fadeIn(); + } + + // 显示错误消息 + function showError(message) { + $('#resultMessage') + .removeClass('success') + .addClass('error') + .html(`❌ ${message}`); + $('#resultBox').fadeIn(); + $('#downloadBtn').hide(); + } + + // 添加输入框焦点效果 + $('#authorUrl').focus(function() { + $(this).parent().addClass('focused'); + }).blur(function() { + $(this).parent().removeClass('focused'); + }); + + // 添加到队列按钮点击事件 + $('#addToQueueBtn').click(function() { + const url = $('#authorUrl').val().trim(); + const months = parseFloat($('#monthsSelect').val()); + const articlesOnly = $('#articlesOnlyCheckbox').is(':checked'); // 获取是否只爬取文章 + + // 验证URL + if (!url) { + showError('请输入百家号作者主页地址'); + return; + } + + if (!url.includes('baijiahao.baidu.com') || !url.includes('app_id=')) { + showError('URL格式不正确,请输入完整的百家号作者主页地址'); + return; + } + + // 添加到队列(始终启用默认代理) + $.ajax({ + url: '/api/queue/add', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + url: url, + months: months, + use_proxy: true, // 始终启用代理 + proxy_api_url: '', // 使用默认代理API + articles_only: articlesOnly // 仅爬取文章 + }), + success: function(response) { + if (response.success) { + showSuccess('任务已添加到队列,系统将后台处理'); + // 3秒后跳转到队列页面 + setTimeout(function() { + window.location.href = '/queue'; + }, 3000); + } else { + showError(response.message || '添加任务失败'); + } + }, + error: function(xhr) { + if (xhr.status === 401) { + alert('登录已过期,请重新登录'); + window.location.href = '/login'; + return; + } + showError('添加任务失败,请稍后重试'); + } + }); + }); + }); +} diff --git a/stop.sh b/stop.sh new file mode 100644 index 0000000..566ba0a --- /dev/null +++ b/stop.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +############################################################################### +# 百家号爬虫系统 - 停止脚本 +# 功能:停止服务及所有任务线程 +############################################################################### + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# 项目配置 +PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_PORT=8030 +PID_FILE="${PROJECT_DIR}/app.pid" +GUNICORN_PID_FILE="${PROJECT_DIR}/gunicorn.pid" + +echo -e "${YELLOW}=========================================${NC}" +echo -e "${YELLOW} 停止百家号爬虫服务${NC}" +echo -e "${YELLOW}=========================================${NC}" +echo "" + +# 通过 PID 文件停止 +if [[ -f "${PID_FILE}" ]]; then + PID=$(cat ${PID_FILE}) + if ps -p ${PID} > /dev/null 2>&1; then + echo "停止主进程 (PID: ${PID})..." + kill ${PID} 2>/dev/null + sleep 2 + + # 如果还在运行,强制终止 + if ps -p ${PID} > /dev/null 2>&1; then + echo "强制终止主进程..." + kill -9 ${PID} 2>/dev/null + fi + echo -e "${GREEN}✓${NC} 主进程已停止" + fi + rm -f ${PID_FILE} +fi + +# 停止 Gunicorn +if [[ -f "${GUNICORN_PID_FILE}" ]]; then + GUNICORN_PID=$(cat ${GUNICORN_PID_FILE}) + if ps -p ${GUNICORN_PID} > /dev/null 2>&1; then + echo "停止 Gunicorn 进程 (PID: ${GUNICORN_PID})..." + kill ${GUNICORN_PID} 2>/dev/null + sleep 2 + + if ps -p ${GUNICORN_PID} > /dev/null 2>&1; then + echo "强制终止 Gunicorn..." + kill -9 ${GUNICORN_PID} 2>/dev/null + fi + echo -e "${GREEN}✓${NC} Gunicorn 进程已停止" + fi + rm -f ${GUNICORN_PID_FILE} +fi + +# 清理所有 app.py 进程 +APP_PIDS=$(ps aux | grep "[p]ython.*app.py" | awk '{print $2}') +if [[ -n "${APP_PIDS}" ]]; then + echo "清理所有相关进程..." + for pid in ${APP_PIDS}; do + echo " 停止进程 ${pid}..." + kill -9 ${pid} 2>/dev/null + done + echo -e "${GREEN}✓${NC} 所有进程已清理" +else + echo -e "${GREEN}✓${NC} 未发现运行中的进程" +fi + +# 清理端口占用 +PORT_PID=$(lsof -ti:${APP_PORT} 2>/dev/null) +if [[ -n "${PORT_PID}" ]]; then + echo "释放端口 ${APP_PORT}..." + kill -9 ${PORT_PID} 2>/dev/null + echo -e "${GREEN}✓${NC} 端口已释放" +fi + +echo "" +echo -e "${GREEN}服务已完全停止!${NC}" +echo "" diff --git a/task_queue.py b/task_queue.py new file mode 100644 index 0000000..0fd2b47 --- /dev/null +++ b/task_queue.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +""" +任务队列管理模块 +支持离线处理、进度跟踪、结果导出 +使用 SQLite 数据库存储(替代原 JSON 文件) +""" +import os +import threading +import time +from datetime import datetime +from enum import Enum +import logging +from database import get_database, migrate_from_json + +logger = logging.getLogger(__name__) + + +class TaskStatus(Enum): + """任务状态""" + PENDING = "pending" # 就绪(准备好了,等待工作线程) + PROCESSING = "processing" # 进行中 + COMPLETED = "completed" # 完成 + FAILED = "failed" # 失败 + PAUSED = "paused" # 暂停(将在指定时间后自动恢复) + + +class TaskQueue: + """任务队列管理器(使用 SQLite 数据库)""" + + def __init__(self, queue_file="data/task_queue.json", results_dir="data/results"): + self.results_dir = results_dir + self.lock = threading.Lock() + self.db = get_database() + self._ensure_dirs() + + # 从旧 JSON 文件迁移数据(只执行一次) + if os.path.exists(queue_file): + migrate_from_json(queue_file) + + def _ensure_dirs(self): + """确保必要的目录存在""" + os.makedirs(self.results_dir, exist_ok=True) + + def add_task(self, url, months=6, use_proxy=False, proxy_api_url=None, username=None, articles_only=True): + """添加新任务到队列 + + Args: + url: 百家号URL + months: 获取月数 + use_proxy: 是否使用代理 + proxy_api_url: 代理API地址 + username: 用户名 + articles_only: 是否仅爬取文章(跳过视频) + + Returns: + task_id: 任务ID + """ + with self.lock: + task_id = f"task_{int(time.time() * 1000)}" + created_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + with self.db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO tasks ( + task_id, url, months, use_proxy, proxy_api_url, + username, status, created_at, progress, current_step, + total_articles, processed_articles, articles_only + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + task_id, url, months, 1 if use_proxy else 0, proxy_api_url, + username, TaskStatus.PENDING.value, created_at, 0, "等待处理", + 0, 0, 1 if articles_only else 0 + )) + conn.commit() + + logger.info(f"添加任务: {task_id}") + return task_id + + def get_task(self, task_id): + """获取任务信息""" + with self.lock: + with self.db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM tasks WHERE task_id = ?", + (task_id,) + ) + row = cursor.fetchone() + + if row: + task = dict(row) + # 将 use_proxy 从整数转换为布尔值 + task['use_proxy'] = bool(task['use_proxy']) + # 将 articles_only 从整数转换为布尔值 + task['articles_only'] = bool(task.get('articles_only', 1)) + return task + return None + + def get_all_tasks(self, username=None): + """获取所有任务(可按用户过滤)""" + with self.lock: + with self.db.get_connection() as conn: + cursor = conn.cursor() + + if username: + cursor.execute( + "SELECT * FROM tasks WHERE username = ? ORDER BY created_at DESC", + (username,) + ) + else: + cursor.execute("SELECT * FROM tasks ORDER BY created_at DESC") + + rows = cursor.fetchall() + tasks = [] + for row in rows: + task = dict(row) + # 将 use_proxy 从整数转换为布尔值 + task['use_proxy'] = bool(task['use_proxy']) + # 将 articles_only 从整数转换为布尔值 + task['articles_only'] = bool(task.get('articles_only', 1)) + tasks.append(task) + return tasks + + def get_pending_task(self): + """获取下一个待处理的任务(包括检查暂停任务是否可恢复)""" + with self.lock: + with self.db.get_connection() as conn: + cursor = conn.cursor() + + # 首先检查是否有暂停任务需要恢复 + from datetime import datetime, timedelta + current_time = datetime.now() + + cursor.execute( + "SELECT * FROM tasks WHERE status = ? ORDER BY paused_at ASC", + (TaskStatus.PAUSED.value,) + ) + paused_tasks = cursor.fetchall() + + for row in paused_tasks: + paused_at_str = row['paused_at'] + if paused_at_str: + paused_at = datetime.strptime(paused_at_str, '%Y-%m-%d %H:%M:%S') + # 检查是否已经暂停超过10分钟 + if current_time - paused_at >= timedelta(minutes=10): + task_id = row['task_id'] + # 恢复任务为待处理状态(保留 last_page 和 last_ctime) + cursor.execute(""" + UPDATE tasks SET + status = ?, + current_step = ?, + retry_count = 0, + paused_at = NULL + WHERE task_id = ? + """, (TaskStatus.PENDING.value, "等待处理(从断点继续)", task_id)) + conn.commit() + logger.info(f"任务 {task_id} 已从暂停状态恢复,将从第{row.get('last_page', 1)}页继续") + + # 获取待处理任务 + cursor.execute( + "SELECT * FROM tasks WHERE status = ? ORDER BY created_at ASC LIMIT 1", + (TaskStatus.PENDING.value,) + ) + row = cursor.fetchone() + + if row: + task = dict(row) + # 将 use_proxy 从整数转换为布尔值 + task['use_proxy'] = bool(task['use_proxy']) + # 将 articles_only 从整数转换为布尔值 + task['articles_only'] = bool(task.get('articles_only', 1)) + return task + return None + + def update_task_status(self, task_id, status, **kwargs): + """更新任务状态 + + Args: + task_id: 任务ID + status: 新状态 + **kwargs: 其他要更新的字段 + """ + with self.lock: + status_value = status.value if isinstance(status, TaskStatus) else status + + # 准备更新字段 + update_fields = {"status": status_value} + + # 更新时间戳 + if status == TaskStatus.PROCESSING: + update_fields["started_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + elif status in [TaskStatus.COMPLETED, TaskStatus.FAILED]: + update_fields["completed_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 合并其他字段 + update_fields.update(kwargs) + + # 构建 SQL 更新语句 + set_clause = ", ".join([f"{key} = ?" for key in update_fields.keys()]) + values = list(update_fields.values()) + [task_id] + + with self.db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + f"UPDATE tasks SET {set_clause} WHERE task_id = ?", + values + ) + conn.commit() + return cursor.rowcount > 0 + + def update_task_progress(self, task_id, progress, current_step=None, processed_articles=None): + """更新任务进度 + + Args: + task_id: 任务ID + progress: 进度百分比 (0-100) + current_step: 当前步骤描述 + processed_articles: 已处理文章数 + """ + with self.lock: + update_fields = { + "progress": min(100, max(0, progress)) + } + + if current_step is not None: + update_fields["current_step"] = current_step + if processed_articles is not None: + update_fields["processed_articles"] = processed_articles + + set_clause = ", ".join([f"{key} = ?" for key in update_fields.keys()]) + values = list(update_fields.values()) + [task_id] + + with self.db.get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + f"UPDATE tasks SET {set_clause} WHERE task_id = ?", + values + ) + conn.commit() + return cursor.rowcount > 0 + + def get_queue_stats(self, username=None): + """获取队列统计信息""" + with self.lock: + with self.db.get_connection() as conn: + cursor = conn.cursor() + + # 基础查询 + if username: + base_query = "SELECT status, COUNT(*) as count FROM tasks WHERE username = ? GROUP BY status" + cursor.execute(base_query, (username,)) + else: + base_query = "SELECT status, COUNT(*) as count FROM tasks GROUP BY status" + cursor.execute(base_query) + + # 统计各状态数量 + status_counts = {row["status"]: row["count"] for row in cursor.fetchall()} + + # 获取总数 + if username: + cursor.execute("SELECT COUNT(*) as total FROM tasks WHERE username = ?", (username,)) + else: + cursor.execute("SELECT COUNT(*) as total FROM tasks") + total = cursor.fetchone()["total"] + + stats = { + "total": total, + "pending": status_counts.get(TaskStatus.PENDING.value, 0), + "processing": status_counts.get(TaskStatus.PROCESSING.value, 0), + "completed": status_counts.get(TaskStatus.COMPLETED.value, 0), + "failed": status_counts.get(TaskStatus.FAILED.value, 0), + "paused": status_counts.get(TaskStatus.PAUSED.value, 0) + } + + return stats + + def delete_task(self, task_id): + """删除任务(先自动终止再删除)""" + with self.lock: + with self.db.get_connection() as conn: + cursor = conn.cursor() + + # 检查任务是否存在 + cursor.execute("SELECT status FROM tasks WHERE task_id = ?", (task_id,)) + row = cursor.fetchone() + + if not row: + return False + + # 如果任务还在运行,先终止 + if row["status"] in [TaskStatus.PENDING.value, TaskStatus.PROCESSING.value]: + cursor.execute(''' + UPDATE tasks SET + status = ?, + error = ?, + current_step = ?, + completed_at = ? + WHERE task_id = ? + ''', ( + TaskStatus.FAILED.value, + "任务已被用户删除", + "任务已终止", + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + task_id + )) + conn.commit() + logger.info(f"终止任务: {task_id}") + + # 然后从数据库中删除 + cursor.execute("DELETE FROM tasks WHERE task_id = ?", (task_id,)) + conn.commit() + + if cursor.rowcount > 0: + logger.info(f"删除任务: {task_id}") + return True + return False + + def cancel_task(self, task_id): + """终止任务(将等待中或处理中任务标记为失败)""" + with self.lock: + with self.db.get_connection() as conn: + cursor = conn.cursor() + + # 检查任务状态 + cursor.execute("SELECT status FROM tasks WHERE task_id = ?", (task_id,)) + row = cursor.fetchone() + + if row and row["status"] in [TaskStatus.PENDING.value, TaskStatus.PROCESSING.value]: + cursor.execute(''' + UPDATE tasks SET + status = ?, + error = ?, + current_step = ?, + completed_at = ? + WHERE task_id = ? + ''', ( + TaskStatus.FAILED.value, + "任务已被用户终止", + "任务已终止", + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + task_id + )) + conn.commit() + return cursor.rowcount > 0 + return False + +# 全局队列实例 +_task_queue = None + + +def get_task_queue(): + """获取全局任务队列实例""" + global _task_queue + if _task_queue is None: + _task_queue = TaskQueue() + return _task_queue diff --git a/task_worker.py b/task_worker.py new file mode 100644 index 0000000..82ef22e --- /dev/null +++ b/task_worker.py @@ -0,0 +1,487 @@ +# -*- coding: utf-8 -*- +""" +任务处理器 - 后台并发处理队列中的任务 +支持动态调整并发数,通过 SocketIO 实时推送进度和日志 +""" +import threading +import time +import logging +import traceback +import psutil +from task_queue import get_task_queue, TaskStatus + +logger = logging.getLogger(__name__) + +# 全局变量,用于存储 socketio 实例 +_socketio_instance = None + +def set_socketio(socketio): + """设置 SocketIO 实例""" + global _socketio_instance + _socketio_instance = socketio + +def emit_log(task_id, message, level='info'): + """保存日志到数据库""" + from datetime import datetime + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + # 保存到数据库 + try: + from database import get_database + db = get_database() + db.add_task_log(task_id, message, level, timestamp) + except Exception as e: + logger.error(f"保存日志到数据库失败: {e}") + + logger.info(f"[{task_id}] {message}") + +def emit_progress(task_id, progress, current_step='', **kwargs): + """更新任务进度""" + logger.info(f"[{task_id}] 进度: {progress}% - {current_step}") + + +class TaskWorker: + """任务处理工作线程池(支持动态并发)""" + + def __init__(self, min_workers=1, max_workers=3): + self.queue = get_task_queue() + self.running = False + self.min_workers = min_workers # 最小并发数 + self.max_workers = max_workers # 最大并发数 + self.current_workers = min_workers # 当前并发数 + self.worker_threads = [] # 工作线程列表 + self.processing_tasks = set() # 正在处理的任务ID集合 + self.lock = threading.Lock() + + def start(self): + """启动工作线程池""" + if self.running: + logger.warning("工作线程池已经在运行") + return + + self.running = True + + # 启动初始工作线程 + for i in range(self.min_workers): + self._start_worker(i) + + # 启动动态调整线程 + self.adjust_thread = threading.Thread(target=self._adjust_workers, daemon=True) + self.adjust_thread.start() + + logger.info(f"任务处理器已启动(初始并发数: {self.min_workers},最大并发数: {self.max_workers})") + + def _start_worker(self, worker_id): + """启动一个工作线程""" + thread = threading.Thread(target=self._work_loop, args=(worker_id,), daemon=True) + thread.start() + with self.lock: + self.worker_threads.append(thread) + logger.info(f"工作线程 #{worker_id} 已启动") + + def stop(self): + """停止工作线程池""" + self.running = False + for thread in self.worker_threads: + if thread and thread.is_alive(): + thread.join(timeout=5) + logger.info("任务处理器已停止") + + def _work_loop(self, worker_id): + """工作循环(单个线程)""" + logger.info(f"工作线程 #{worker_id} 进入循环") + + while self.running: + try: + # 获取待处理任务 + task = self.queue.get_pending_task() + + if task: + task_id = task["task_id"] + + # 检查是否已经有其他线程在处理这个任务 + with self.lock: + if task_id in self.processing_tasks: + continue + self.processing_tasks.add(task_id) + + try: + logger.info(f"工作线程 #{worker_id} 开始处理任务: {task_id}") + self._process_task(task, worker_id) + finally: + # 处理完成后从集合中移除 + with self.lock: + self.processing_tasks.discard(task_id) + else: + # 没有任务,休息一会 + time.sleep(2) + + except Exception as e: + logger.error(f"工作线程 #{worker_id} 错误: {e}") + logger.error(traceback.format_exc()) + time.sleep(5) + + logger.info(f"工作线程 #{worker_id} 退出循环") + + def _adjust_workers(self): + """动态调整工作线程数量""" + logger.info("动态调整线程已启动") + + while self.running: + try: + time.sleep(10) # 每10秒检查一次 + + # 获取系统资源信息 + cpu_percent = psutil.cpu_percent(interval=1) + memory_percent = psutil.virtual_memory().percent + + # 获取队列信息 + pending_count = len([t for t in self.queue.get_all_tasks() if t.get('status') == 'pending']) + processing_count = len(self.processing_tasks) + + # 决策逻辑 + target_workers = self._calculate_target_workers( + pending_count, + processing_count, + cpu_percent, + memory_percent + ) + + # 调整线程数 + if target_workers > self.current_workers: + # 增加线程 + for i in range(self.current_workers, target_workers): + self._start_worker(i) + logger.info(f"增加工作线程: {self.current_workers} -> {target_workers}") + self.current_workers = target_workers + elif target_workers < self.current_workers: + # 减少线程(自然退出,不强制终止) + logger.info(f"准备减少工作线程: {self.current_workers} -> {target_workers}") + self.current_workers = target_workers + + except Exception as e: + logger.error(f"调整线程数错误: {e}") + logger.error(traceback.format_exc()) + time.sleep(30) + + def _calculate_target_workers(self, pending_count, processing_count, cpu_percent, memory_percent): + """计算目标线程数""" + # 基本逻辑: + # 1. 如果没有待处理任务,保持最小线程数 + # 2. 如果有很多待处理任务,且系统资源充足,增加线程 + # 3. 如果系统资源紧张,减少线程 + + if pending_count == 0: + return self.min_workers + + # 系统资源紧张(CPU>80% 或 内存>85%) + if cpu_percent > 80 or memory_percent > 85: + logger.warning(f"系统资源紧张 (CPU: {cpu_percent}%, 内存: {memory_percent}%)") + return max(self.min_workers, self.current_workers - 1) + + # 系统资源充足 + if cpu_percent < 50 and memory_percent < 70: + # 根据待处理任务数决定线程数 + if pending_count >= 3: + return min(self.max_workers, self.current_workers + 1) + elif pending_count >= 1: + return min(self.max_workers, max(2, self.current_workers)) + + # 默认保持当前线程数 + return self.current_workers + + def _process_task(self, task, worker_id): + """处理单个任务""" + task_id = task["task_id"] + logger.info(f"工作线程 #{worker_id} 开始处理任务: {task_id}") + emit_log(task_id, f"任务开始处理 (Worker #{worker_id})") + + # 获取当前重试次数 + retry_count = task.get("retry_count", 0) + + try: + # 更新状态为处理中 + self.queue.update_task_status( + task_id, + TaskStatus.PROCESSING, + current_step="准备处理" + ) + emit_progress(task_id, 5, "准备处理") + + # 导入必要的模块 + from app import BaijiahaoScraper + import pandas as pd + import os + + # 步骤1: 解析URL获取UK + self.queue.update_task_progress(task_id, 10, "解析URL获取UK") + emit_progress(task_id, 10, "解析URL获取UK") + emit_log(task_id, f"URL: {task['url']}") + + url = task["url"] + use_proxy = task.get("use_proxy", False) + proxy_api_url = task.get("proxy_api_url") + articles_only = task.get("articles_only", True) # 获取是否仅爬取文章 + + if use_proxy: + emit_log(task_id, "已启用代理IP池", "info") + + if articles_only: + emit_log(task_id, "已启用文章过滤(跳过视频内容)", "info") + + # 提取app_id + import re + app_id_match = re.search(r'app_id=([^&\s]+)', url) + if not app_id_match: + raise Exception("无法从 URL 中提取 app_id") + + app_id = app_id_match.group(1) + emit_log(task_id, f"解析到 app_id: {app_id}") + + # 获取UK + emit_log(task_id, "正在获取用户 UK...") + try: + uk, cookies = BaijiahaoScraper.get_uk_from_app_id( + app_id, + use_proxy=use_proxy, + proxy_api_url=proxy_api_url + ) + emit_log(task_id, f"成功获取 UK: {uk[:20]}...") + except Exception as uk_error: + emit_log(task_id, f"获取UK失败: {str(uk_error)}", "error") + raise + + # 步骤2: 初始化爬虫 + self.queue.update_task_progress(task_id, 20, "初始化爬虫") + emit_progress(task_id, 20, "初始化爬虫") + emit_log(task_id, "初始化爬虫实例...") + + scraper = BaijiahaoScraper( + uk=uk, + cookies=cookies, + use_proxy=use_proxy, + proxy_api_url=proxy_api_url + ) + + # 步骤3: 获取文章列表 + months = task.get("months", 6) + + # 检查是否有断点续传数据 + last_page = task.get("last_page", 0) + last_ctime = task.get("last_ctime") + start_page = 1 + start_ctime = None + + if last_page > 0 and last_ctime: + # 断点续传 + start_page = last_page + start_ctime = last_ctime + emit_log(task_id, f"🔄 检测到断点数据,从第{start_page}页继续爬取", "info") + + # 检查缓存中是否有数据 + from database import get_database + db = get_database() + cached_count = db.get_cached_article_count(task_id) + if cached_count > 0: + emit_log(task_id, f"💾 已缓存 {cached_count} 篇文章,将继续爬取...", "info") + else: + # 新任务,清除之前的缓存(如果有) + from database import get_database + db = get_database() + db.clear_article_cache(task_id) + + self.queue.update_task_progress(task_id, 30, f"获取文章列表(近{months}个月)") + emit_progress(task_id, 30, f"获取文章列表(近{months}个月)") + emit_log(task_id, f"开始获取近 {months} 个月的文章...") + emit_log(task_id, "提示:抓取过程较慢(8-12秒/页),请耐心等待...", "info") + emit_log(task_id, "系统正在使用代理IP池抓取数据,过程中会自动切换IP应对反爬...", "info") + + # 定义保存回调函数:每页数据立即保存到数据库 + from database import get_database + db = get_database() + + def save_page_to_db(page, articles, ctime): + """保存每页数据到数据库缓存""" + if articles: + db.save_articles_batch(task_id, articles, page) + emit_log(task_id, f"💾 第{page}页数据已保存,{len(articles)}篇文章", "success") + + # 更新任务的断点信息 + self.queue.update_task_status( + task_id, + TaskStatus.PROCESSING, + last_page=page, + last_ctime=ctime + ) + + # 更新进度(粗略估计,30-80%区间) + total_cached = db.get_cached_article_count(task_id) + progress = min(30 + int(page * 2), 80) # 每页增加2%,最多80% + self.queue.update_task_progress( + task_id, + progress, + f"正在抓取第{page}页...", + processed_articles=total_cached + ) + emit_progress(task_id, progress, f"正在抓取第{page}页...", processed_articles=total_cached) + + # 调用 get_articles,传入回调函数和断点参数 + result = scraper.get_articles( + months=months, + app_id=app_id, + articles_only=articles_only, + task_id=task_id, + on_page_fetched=save_page_to_db, + start_page=start_page, + start_ctime=start_ctime + ) + + if not result or not result.get('completed'): + # 未完成,保留断点信息以便续传 + raise Exception(f"抓取未完成,已保存到第{result.get('last_page', start_page)}页") + + # 从数据库读取全部缓存文章 + articles = db.get_cached_articles(task_id) + + if not articles: + raise Exception("未获取到文章数据") + + # 更新总文章数 + total = len(articles) + self.queue.update_task_status( + task_id, + TaskStatus.PROCESSING, + total_articles=total + ) + emit_log(task_id, f"成功获取 {total} 篇文章", "success") + + # 步骤4: 生成Excel(直接使用数据库中的数据) + self.queue.update_task_progress(task_id, 90, "生成Excel文件") + emit_progress(task_id, 90, "生成Excel文件") + emit_log(task_id, "正在生成 Excel 文件...") + + df = pd.DataFrame(articles) + + # 生成文件名 + timestamp = time.strftime("%Y%m%d_%H%M%S") + filename = f"百家号文章_{app_id}_{timestamp}.xlsx" + result_file = os.path.join(self.queue.results_dir, filename) + + # 保存Excel + with pd.ExcelWriter(result_file, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='文章列表') + + # 调整列宽 + worksheet = writer.sheets['文章列表'] + worksheet.column_dimensions['A'].width = 80 # 标题列 + worksheet.column_dimensions['B'].width = 20 # 时间列 + + emit_log(task_id, f"Excel 文件已生成: {filename}") + + # 清除缓存数据(任务已完成) + db.clear_article_cache(task_id) + emit_log(task_id, "🗑️ 已清除缓存数据", "info") + + # 步骤5: 完成 + self.queue.update_task_status( + task_id, + TaskStatus.COMPLETED, + progress=100, + current_step="处理完成", + result_file=filename, + processed_articles=total + ) + emit_progress(task_id, 100, "处理完成", result_file=filename) + emit_log(task_id, f"✅ 任务完成!导出 {total} 篇文章", "success") + + logger.info(f"工作线程 #{worker_id} 任务完成: {task_id}, 导出 {total} 篇文章") + + except Exception as e: + error_msg = str(e) + logger.error(f"工作线程 #{worker_id} 任务失败: {task_id}, 错误: {error_msg}") + + # 记录详细错误堆栈 + error_traceback = traceback.format_exc() + logger.error(error_traceback) + + # 将错误堆栈也推送到前端(分行推送) + emit_log(task_id, f"❌ 任务失败: {error_msg}", "error") + + # 推送错误详情(每行作为独立日志) + for line in error_traceback.split('\n'): + if line.strip(): + emit_log(task_id, line, "error") + + # 判断是否需要重试或暂停 + retry_count += 1 + + # 检查是否有缓存数据(如果有,说明部分成功) + from database import get_database + db = get_database() + cached_count = db.get_cached_article_count(task_id) + + # 如果已经有缓存数据,说明部分成功,增加重试次数 + max_retries = 10 if cached_count > 0 else 3 # 有缓存时允许10次重试 + + # 如果连续失败超过上限,暂停任务10分钟 + if retry_count >= max_retries: + from datetime import datetime + paused_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + if cached_count > 0: + emit_log(task_id, f"⚠️ 连续失败{retry_count}次,已缓存{cached_count}篇文章,10分钟后继续尝试", "warning") + else: + emit_log(task_id, f"⚠️ 连续失败{retry_count}次,任务将暂停10分钟后自动重试", "warning") + + self.queue.update_task_status( + task_id, + TaskStatus.PAUSED, + error=error_msg, + last_error=error_msg, + retry_count=retry_count, + paused_at=paused_at, + current_step=f"暂停中(10分钟后重试) - 错误: {error_msg}" + ) + emit_progress(task_id, 0, f"暂停中(10分钟后重试)") + + logger.warning(f"任务 {task_id} 已暂停,将在 {paused_at} 后10分钟恢复,已缓存{cached_count}篇") + else: + # 重试次数未达到上限,标记为待处理状态,等待下次重试 + if cached_count > 0: + emit_log(task_id, f"⚠️ 任务失败,将进行第{retry_count + 1}次重试(已缓存{cached_count}篇)", "warning") + else: + emit_log(task_id, f"⚠️ 任务失败,将进行第{retry_count + 1}次重试", "warning") + + self.queue.update_task_status( + task_id, + TaskStatus.PENDING, + error=error_msg, + last_error=error_msg, + retry_count=retry_count, + current_step=f"等待重试 (已失败{retry_count}次) - {error_msg}" + ) + emit_progress(task_id, 0, f"等待重试") + + +# 全局工作线程 +_worker = None + + +def get_task_worker(): + """获取全局任务处理器实例""" + global _worker + if _worker is None: + _worker = TaskWorker() + return _worker + + +def start_task_worker(): + """启动任务处理器""" + worker = get_task_worker() + worker.start() + + +def stop_task_worker(): + """停止任务处理器""" + worker = get_task_worker() + worker.stop() diff --git a/taskworker_monitor.py b/taskworker_monitor.py new file mode 100644 index 0000000..6b69de3 --- /dev/null +++ b/taskworker_monitor.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +TaskWorker 自动监控和恢复守护进程 +用于生产环境中自动检测和修复任务卡住的问题 +""" + +import os +import sys +import time +import logging +import signal +import threading +from datetime import datetime + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s', + handlers=[ + logging.FileHandler('logs/taskworker_monitor.log'), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + + +class TaskWorkerMonitor: + """TaskWorker 监控器""" + + def __init__(self, check_interval=60): + """ + Args: + check_interval: 检查间隔(秒),默认60秒 + """ + self.check_interval = check_interval + self.running = False + self.monitor_thread = None + + def check_worker_status(self): + """检查 TaskWorker 状态""" + try: + from task_worker import get_task_worker + from task_queue import get_task_queue + + worker = get_task_worker() + queue = get_task_queue() + + # 获取任务统计 + tasks = queue.get_all_tasks() + pending_count = len([t for t in tasks if t.get('status') == 'pending']) + processing_count = len([t for t in tasks if t.get('status') == 'processing']) + + # 检查 worker 状态 + is_running = worker.running + alive_threads = sum(1 for t in worker.worker_threads if t and t.is_alive()) + + logger.info(f"状态检查 - 运行:{is_running} 活跃线程:{alive_threads} " + f"待处理:{pending_count} 处理中:{processing_count}") + + # 判断是否需要修复 + need_fix = False + reason = "" + + if not is_running: + need_fix = True + reason = "TaskWorker 未运行" + elif alive_threads == 0 and pending_count > 0: + need_fix = True + reason = f"有 {pending_count} 个待处理任务,但没有活跃线程" + elif processing_count > 0: + # 检查处理中的任务是否长时间未更新 + # 这里可以添加更复杂的逻辑 + pass + + return need_fix, reason, { + 'running': is_running, + 'alive_threads': alive_threads, + 'pending_count': pending_count, + 'processing_count': processing_count + } + + except Exception as e: + logger.error(f"检查状态失败: {e}") + import traceback + logger.error(traceback.format_exc()) + return True, f"检查失败: {e}", {} + + def restart_worker(self): + """重启 TaskWorker""" + logger.warning("正在重启 TaskWorker...") + + try: + from task_worker import get_task_worker + worker = get_task_worker() + + # 停止现有 worker + if worker.running: + logger.info("停止现有 TaskWorker...") + worker.stop() + time.sleep(2) + + # 启动新的 worker + logger.info("启动新的 TaskWorker...") + worker.start() + time.sleep(2) + + # 验证启动状态 + if worker.running: + alive_threads = sum(1 for t in worker.worker_threads if t and t.is_alive()) + logger.info(f"✅ TaskWorker 重启成功,活跃线程: {alive_threads}") + return True + else: + logger.error("❌ TaskWorker 重启后未运行") + return False + + except Exception as e: + logger.error(f"重启 TaskWorker 失败: {e}") + import traceback + logger.error(traceback.format_exc()) + return False + + def monitor_loop(self): + """监控循环""" + logger.info(f"监控循环启动,检查间隔: {self.check_interval}秒") + + consecutive_failures = 0 + max_consecutive_failures = 3 + + while self.running: + try: + # 检查状态 + need_fix, reason, status = self.check_worker_status() + + if need_fix: + logger.warning(f"⚠️ 检测到问题: {reason}") + logger.info(f"状态详情: {status}") + + # 尝试修复 + if self.restart_worker(): + logger.info("✅ 自动修复成功") + consecutive_failures = 0 + else: + consecutive_failures += 1 + logger.error(f"❌ 自动修复失败 (连续失败 {consecutive_failures} 次)") + + if consecutive_failures >= max_consecutive_failures: + logger.critical(f"连续修复失败 {consecutive_failures} 次,请人工介入!") + # 可以在这里发送告警通知 + else: + consecutive_failures = 0 + + # 等待下次检查 + time.sleep(self.check_interval) + + except Exception as e: + logger.error(f"监控循环错误: {e}") + import traceback + logger.error(traceback.format_exc()) + time.sleep(self.check_interval) + + logger.info("监控循环已停止") + + def start(self): + """启动监控""" + if self.running: + logger.warning("监控已在运行") + return + + self.running = True + self.monitor_thread = threading.Thread(target=self.monitor_loop, daemon=True) + self.monitor_thread.start() + logger.info("TaskWorker 监控器已启动") + + def stop(self): + """停止监控""" + self.running = False + if self.monitor_thread: + self.monitor_thread.join(timeout=5) + logger.info("TaskWorker 监控器已停止") + + +def signal_handler(signum, frame): + """信号处理器""" + logger.info(f"收到信号 {signum},正在停止...") + if monitor: + monitor.stop() + sys.exit(0) + + +if __name__ == '__main__': + # 创建日志目录 + os.makedirs('logs', exist_ok=True) + + # 注册信号处理器 + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # 创建监控器 + monitor = TaskWorkerMonitor(check_interval=60) # 每60秒检查一次 + + print("=" * 60) + print("TaskWorker 自动监控守护进程") + print("=" * 60) + print(f"检查间隔: {monitor.check_interval} 秒") + print("按 Ctrl+C 停止") + print("=" * 60) + + # 启动监控 + monitor.start() + + # 保持运行 + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + logger.info("用户中断") + monitor.stop() diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..097cb0c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,292 @@ + + + + + + 文章导出 - 百家号管理系统 + + + + + + +
+ + + + +
+ +
+

+ + 文章导出 +

+
+ + +
+ + + +
+
+
+ + +
+ + 请输入完整的百家号作者主页URL地址 +
+
+ +
+ + +
+ + 获取方法:打开百家号 → F12开发者工具 → Network → 刷新页面 → 点击任意请求 → 复制Request Headers中的Cookie +
+
+ +
+ + +
+ +
+ +
+ +
+
+ + 勾选后将过滤掉所有视频类型的内容,只保留文章 +
+
+
+
+ + +
+
+
+
+ +
+
导出格式
+
Excel (.xlsx)
+
+
+
+ +
+
导出内容
+
文章标题、发布时间
+
+
+
+
+
+ + +
+ + +
+ + + + + + + + + + + +
+
+ + 使用说明 +
+
+
+
+
1
+
+

复制URL

+

在浏览器中打开百家号作者主页,复制完整的URL地址

+
+
+
+
2
+
+

配置参数

+

选择时间范围和代理设置,如需要可填写Cookie

+
+
+
+
3
+
+

导出文章

+

点击“即时导出”或“添加到队列”,等待处理完成

+
+
+
+
+
+
+
+
+ + + + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..19b4125 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,382 @@ + + + + + + 登录 - 百家号文章导出工具 + + + + + + + + + + + diff --git a/templates/queue.html b/templates/queue.html new file mode 100644 index 0000000..6c48e2d --- /dev/null +++ b/templates/queue.html @@ -0,0 +1,1508 @@ + + + + + + 任务队列 - 百家号管理系统 + + + + + + + +
+ + + + +
+ +
+

+ + 任务队列 +

+
+ + +
+ + + +
+
+
+ 总任务数 +
+
0
+
+
+
+ 等待中 +
+
0
+
+
+
+ 处理中 +
+
0
+
+
+
+ 已完成 +
+
0
+
+
+
+ 失败 +
+
0
+
+
+
+ 暂停 +
+
0
+
+
+ + +
+ + + + + + +
+ + +
+ +
+ + + + + + + + +
+
+
加载中...
+
+
+ + +
+
+
+
+ + 任务日志 +
+ +
+
+
+ +
+
+
+
+
+ + + + + + + + + + + + diff --git a/test2.py b/test2.py new file mode 100644 index 0000000..a9c614e --- /dev/null +++ b/test2.py @@ -0,0 +1,526 @@ +import json +import random +import time +from typing import Dict, Any, Optional +import logging +from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError +from fake_useragent import UserAgent +import requests +import re + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +class BaiduBJHSpider: + def __init__(self, use_proxy: bool = False): + self.ua = UserAgent() + self.use_proxy = use_proxy + self.proxies = [] # 如果需要代理,这里填你的代理列表 + self.session_cookie = None + self.session = requests.Session() + # 设置请求超时和重试 + self.session.mount('http://', requests.adapters.HTTPAdapter(max_retries=3)) + self.session.mount('https://', requests.adapters.HTTPAdapter(max_retries=3)) + + def init_browser(self, timeout: int = 15000): + """初始化浏览器环境获取Cookie""" + playwright = sync_playwright().start() + + try: + # 配置浏览器参数 + browser_args = [ + '--disable-blink-features=AutomationControlled', + '--disable-web-security', + '--disable-features=IsolateOrigins,site-per-process', + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + ] + + # 启动浏览器 + browser = playwright.chromium.launch( + headless=True, # 改为True,无头模式更快 + args=browser_args, + timeout=timeout + ) + + # 创建上下文 + context = browser.new_context( + viewport={'width': 1920, 'height': 1080}, + user_agent=self.ua.random, + locale='zh-CN', + timezone_id='Asia/Shanghai', + # 设置超时 + navigation_timeout=timeout, + java_script_enabled=True, + bypass_csp=True + ) + + # 设置额外的HTTP头 + context.set_extra_http_headers({ + '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', + }) + + page = context.new_page() + + # 1. 首先访问百度首页获取基础Cookie + logger.info("访问百度首页...") + try: + page.goto('https://www.baidu.com', wait_until='domcontentloaded', timeout=10000) + time.sleep(random.uniform(1, 2)) + except PlaywrightTimeoutError: + logger.warning("百度首页加载超时,继续执行...") + + # 2. 访问百家号页面 + logger.info("访问百家号页面...") + try: + # 使用更宽松的等待条件 + page.goto('https://baijiahao.baidu.com/', + wait_until='domcontentloaded', # 改为domcontentloaded,更快 + timeout=10000) + time.sleep(random.uniform(2, 3)) + except PlaywrightTimeoutError: + logger.warning("百家号页面加载超时,尝试继续...") + # 即使超时,也尝试获取Cookie + + # 获取Cookie + cookies = context.cookies() + self.session_cookie = '; '.join([f"{c['name']}={c['value']}" for c in cookies]) + + # 将Cookie添加到requests session中 + for cookie in cookies: + self.session.cookies.set(cookie['name'], cookie['value']) + + if cookies: + logger.info(f"成功获取到 {len(cookies)} 个Cookie") + else: + logger.warning("未获取到Cookie") + + browser.close() + return cookies + + except Exception as e: + logger.error(f"初始化浏览器失败: {e}") + return None + finally: + playwright.stop() + + def build_headers(self, referer: str = "https://baijiahao.baidu.com/") -> Dict: + """构建请求头""" + headers = { + 'User-Agent': self.ua.random, + 'Accept': '*/*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7', + 'Accept-Encoding': 'gzip, deflate', + 'Referer': referer, + 'Connection': 'keep-alive', + 'Pragma': 'no-cache', + 'Cache-Control': 'no-cache', + } + + if self.session_cookie: + headers['Cookie'] = self.session_cookie + + return headers + + def generate_callback_name(self) -> str: + """生成随机的callback函数名""" + timestamp = int(time.time() * 1000) + return f"__jsonp{timestamp}" + + def fetch_data_directly(self, uk: str = "ntHidnLhrlfclJar2z8wBg") -> Optional[Dict]: + """直接请求接口(可能需要多次尝试)""" + # 先初始化浏览器获取Cookie + logger.info("初始化浏览器获取Cookie...") + cookies = self.init_browser() + + if not cookies: + logger.warning("未能获取到Cookie,尝试继续请求...") + + for attempt in range(3): # 尝试3次 + try: + callback_name = self.generate_callback_name() + timestamp = int(time.time() * 1000) + + # 构建URL参数 - 使用更简单的参数 + params = { + 'tab': 'main', + 'num': '10', + 'uk': uk, + 'source': 'pc', + 'type': 'newhome', + 'action': 'dynamic', + 'format': 'jsonp', + 'callback': callback_name, + '_': str(timestamp) # 时间戳参数 + } + + url = "https://mbd.baidu.com/webpage" + + headers = self.build_headers() + + logger.info(f"尝试第{attempt + 1}次请求...") + + # 随机延迟 + time.sleep(random.uniform(1, 2)) + + # 设置代理(如果需要) + proxies = None + if self.use_proxy and self.proxies: + proxy = random.choice(self.proxies) + proxies = { + 'http': proxy, + 'https': proxy + } + + response = self.session.get( + url, + params=params, + headers=headers, + timeout=15, # 缩短超时时间 + proxies=proxies + ) + + # 提取JSONP数据 + text = response.text + if text.startswith(callback_name + '(') and text.endswith(')'): + json_str = text[len(callback_name) + 1:-1] + data = json.loads(json_str) + logger.info(f"成功获取JSON数据") + return data + else: + # 尝试直接解析为JSON(可能是JSON格式) + try: + data = json.loads(text) + logger.info("直接解析JSON成功") + return data + except: + pass + + except requests.exceptions.Timeout: + logger.error(f"请求超时 (尝试{attempt + 1})") + except Exception as e: + logger.error(f"请求失败 (尝试{attempt + 1}): {e}") + + # 等待后重试 + if attempt < 2: # 如果不是最后一次尝试 + time.sleep(random.uniform(2, 3)) + + return None + + def fetch_via_browser(self, uk: str = "ntHidnLhrlfclJar2z8wBg", timeout: int = 15000) -> Optional[Dict]: + """通过浏览器直接执行获取数据""" + playwright = sync_playwright().start() + + try: + browser = playwright.chromium.launch( + headless=True, # 无头模式 + args=[ + '--disable-blink-features=AutomationControlled', + '--no-sandbox', + '--disable-dev-shm-usage' + ], + timeout=timeout + ) + + context = browser.new_context( + viewport={'width': 1920, 'height': 1080}, + user_agent=self.ua.random, + locale='zh-CN', + navigation_timeout=timeout + ) + + page = context.new_page() + + # 监听网络请求 + results = [] + + def handle_response(response): + url = response.url + if "mbd.baidu.com/webpage" in url and "format=jsonp" in url: + try: + # 获取响应文本 + text = response.text() + logger.info(f"捕获到请求: {url}") + + # 从URL提取callback名称 + import urllib.parse + parsed_url = urllib.parse.urlparse(url) + query_params = urllib.parse.parse_qs(parsed_url.query) + + if 'callback' in query_params: + callback = query_params['callback'][0] + if text.startswith(callback + '(') and text.endswith(')'): + json_str = text[len(callback) + 1:-1] + data = json.loads(json_str) + results.append(data) + logger.info("成功解析JSONP数据") + except Exception as e: + logger.debug(f"处理响应失败: {e}") + + page.on("response", handle_response) + + # 访问百家号页面 + target_url = f"https://baijiahao.baidu.com/u?app_id={uk}" + logger.info(f"访问页面: {target_url}") + + try: + page.goto(target_url, wait_until='domcontentloaded', timeout=10000) + time.sleep(random.uniform(2, 3)) + + # 简单滚动 + page.evaluate("window.scrollBy(0, 500)") + time.sleep(1) + page.evaluate("window.scrollBy(0, 500)") + time.sleep(1) + + # 等待数据加载 + time.sleep(2) + + except PlaywrightTimeoutError: + logger.warning("页面加载超时,继续处理已捕获的数据...") + + browser.close() + + if results: + logger.info(f"通过浏览器捕获到 {len(results)} 个结果") + return results[0] + + except Exception as e: + logger.error(f"浏览器方式获取失败: {e}") + finally: + playwright.stop() + + return None + + def fetch_with_ajax(self, uk: str = "ntHidnLhrlfclJar2z8wBg") -> Optional[Dict]: + """使用简化参数直接请求""" + try: + timestamp = int(time.time() * 1000) + + # 使用更简单的参数 + params = { + 'action': 'dynamic', + 'uk': uk, + 'type': 'newhome', + 'num': '10', + 'format': 'json', + '_': str(timestamp) + } + + url = "https://mbd.baidu.com/webpage" + + headers = { + 'User-Agent': self.ua.random, + 'Referer': 'https://baijiahao.baidu.com/', + 'Accept': 'application/json, text/javascript, */*; q=0.01', + 'X-Requested-With': 'XMLHttpRequest' + } + + logger.info("尝试AJAX方式请求...") + + response = self.session.get( + url, + params=params, + headers=headers, + timeout=10 + ) + + logger.info(f"AJAX响应状态: {response.status_code}") + + try: + data = json.loads(response.text) + logger.info("AJAX方式成功获取数据") + return data + except json.JSONDecodeError as e: + logger.error(f"JSON解析失败: {e}") + logger.info(f"响应内容: {response.text[:200]}") + return None + + except Exception as e: + logger.error(f"AJAX方式失败: {e}") + return None + + def fetch_all_methods(self, uk: str = "ntHidnLhrlfclJar2z8wBg") -> Optional[Dict]: + """尝试所有方法获取数据""" + logger.info("=" * 50) + logger.info(f"开始获取百家号数据,UK: {uk}") + logger.info("=" * 50) + + # 方法1:直接请求 + logger.info("\n方法1:直接请求接口...") + data = self.fetch_data_directly(uk) + + if data and data.get("errno") == "0" and data.get("data", {}).get("list") is not None: + logger.info(f"✓ 方法1成功,获取到 {len(data['data']['list'])} 条数据") + return data + else: + logger.info("✗ 方法1失败或数据为空") + + # 方法2:通过浏览器获取 + logger.info("\n方法2:浏览器模拟获取...") + data = self.fetch_via_browser(uk) + + if data and data.get("errno") == "0" and data.get("data", {}).get("list") is not None: + logger.info(f"✓ 方法2成功,获取到 {len(data['data']['list'])} 条数据") + return data + else: + logger.info("✗ 方法2失败或数据为空") + + # 方法3:AJAX请求 + logger.info("\n方法3:AJAX请求...") + data = self.fetch_with_ajax(uk) + + if data and data.get("errno") == "0" and data.get("data", {}).get("list") is not None: + logger.info(f"✓ 方法3成功,获取到 {len(data['data']['list'])} 条数据") + return data + else: + logger.info("✗ 方法3失败或数据为空") + + # 方法4:备用请求 + logger.info("\n方法4:尝试备用请求方式...") + data = self.try_backup_method(uk) + + if data: + logger.info("✓ 方法4成功获取数据") + return data + else: + logger.error("所有方法都失败了") + return None + + def try_backup_method(self, uk: str) -> Optional[Dict]: + """备用方法:尝试不同的URL和参数""" + backup_urls = [ + "https://author.baidu.com/rest/2.0/ugc/dynamic", + "https://mbd.baidu.com/dynamic/api", + "https://baijiahao.baidu.com/builder/api" + ] + + for url in backup_urls: + try: + params = { + 'action': 'list', + 'uk': uk, + 'page': '1', + 'page_size': '10', + '_': str(int(time.time() * 1000)) + } + + headers = { + 'User-Agent': self.ua.random, + 'Referer': 'https://baijiahao.baidu.com/' + } + + response = requests.get(url, params=params, headers=headers, timeout=10) + + if response.status_code == 200: + try: + data = response.json() + if data: + logger.info(f"备用URL {url} 成功") + return data + except: + pass + + except Exception as e: + logger.debug(f"备用URL {url} 失败: {e}") + + return None + + +def display_simple_data(data): + """简单展示数据""" + if not data or "data" not in data or "list" not in data["data"]: + print("没有有效的数据") + return + + articles = data["data"]["list"] + print(f"\n获取到 {len(articles)} 篇文章:") + + for idx, article in enumerate(articles[:10]): # 显示前10条 + print(f"\n{'=' * 60}") + print(f"文章 {idx + 1}:") + + item_data = article.get("itemData", {}) + + # 标题 + title = item_data.get("title", "无标题") + # 清理标题中的换行符 + title = title.replace('\n', ' ').strip() + if not title or title == "无标题": + # 尝试获取origin_title + title = item_data.get("origin_title", "无标题").replace('\n', ' ').strip() + print(f"标题: {title[:100]}{'...' if len(title) > 100 else ''}") + + # 作者 + display_info = item_data.get("displaytype_exinfo", "") + author = "未知作者" + if display_info: + try: + info = json.loads(display_info) + author = info.get("name", info.get("display_name", "未知作者")) + except: + # 尝试正则匹配 + name_match = re.search(r'"name":"([^"]+)"', display_info) + if name_match: + author = name_match.group(1) + print(f"作者: {author}") + + # 发布时间 + time_str = item_data.get("time", item_data.get("cst_time", "未知时间")) + print(f"发布时间: {time_str}") + + # 文章ID + thread_id = item_data.get("thread_id", article.get("thread_id", "未知")) + print(f"文章ID: {thread_id}") + + # 图片信息 + img_src = item_data.get("imgSrc", []) + if img_src: + print(f"包含图片: {len(img_src)} 张") + + # 标签/话题 + targets = item_data.get("target", []) + if targets: + tags = [t.get("key", "") for t in targets if t.get("key")] + if tags: + print(f"标签: {', '.join(tags)}") + + +def main(): + """主函数""" + spider = BaiduBJHSpider() + + # 获取数据 + data = spider.fetch_all_methods() + + if data: + # 保存完整数据到文件 + filename = f'baijiahao_data_{int(time.time())}.json' + with open(filename, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + logger.info(f"完整数据已保存到 {filename}") + + # 简单展示数据 + display_simple_data(data) + + else: + print("未能获取到数据,建议:") + print("1. 检查网络连接") + print("2. 尝试使用代理") + print("3. 等待一段时间后重试") + print("4. 检查目标页面是否可正常访问") + + +if __name__ == "__main__": + # 设置更详细的日志 + logging.getLogger("playwright").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + + main() \ No newline at end of file diff --git a/test_database.py b/test_database.py new file mode 100644 index 0000000..940f2c7 --- /dev/null +++ b/test_database.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +""" +测试 SQLite 数据库迁移和功能 +""" +import os +import sys +from database import get_database, migrate_from_json +from task_queue import get_task_queue, TaskStatus + +def test_database(): + """测试数据库功能""" + print("=" * 60) + print("开始测试 SQLite 数据库功能") + print("=" * 60) + + # 1. 测试数据库初始化 + print("\n1. 测试数据库初始化...") + db = get_database() + print(f"✓ 数据库初始化成功: {db.db_path}") + + # 2. 测试从 JSON 迁移数据 + print("\n2. 测试数据迁移...") + json_file = "data/task_queue.json" + if os.path.exists(json_file): + count = migrate_from_json(json_file) + print(f"✓ 迁移了 {count} 个任务") + else: + print("! 未找到旧 JSON 文件,跳过迁移") + + # 3. 测试任务队列功能 + print("\n3. 测试任务队列功能...") + queue = get_task_queue() + + # 3.1 添加测试任务 + print("\n3.1 添加测试任务...") + task_id = queue.add_task( + url="https://baijiahao.baidu.com/u?app_id=test123", + months=3, + use_proxy=True, + username="test_user" + ) + print(f"✓ 添加任务成功: {task_id}") + + # 3.2 获取任务 + print("\n3.2 获取任务...") + task = queue.get_task(task_id) + if task: + print(f"✓ 获取任务成功:") + print(f" - URL: {task['url']}") + print(f" - 状态: {task['status']}") + print(f" - 创建时间: {task['created_at']}") + + # 3.3 更新任务状态 + print("\n3.3 更新任务状态...") + queue.update_task_status(task_id, TaskStatus.PROCESSING) + task = queue.get_task(task_id) + print(f"✓ 更新状态成功: {task['status']}") + + # 3.4 更新任务进度 + print("\n3.4 更新任务进度...") + queue.update_task_progress(task_id, 50, "正在处理中...", 25) + task = queue.get_task(task_id) + print(f"✓ 更新进度成功: {task['progress']}%") + + # 3.5 获取队列统计 + print("\n3.5 获取队列统计...") + stats = queue.get_queue_stats() + print(f"✓ 队列统计:") + print(f" - 总任务数: {stats['total']}") + print(f" - 等待中: {stats['pending']}") + print(f" - 处理中: {stats['processing']}") + print(f" - 已完成: {stats['completed']}") + print(f" - 失败: {stats['failed']}") + + # 3.6 获取所有任务 + print("\n3.6 获取所有任务...") + all_tasks = queue.get_all_tasks() + print(f"✓ 获取所有任务成功,共 {len(all_tasks)} 个任务") + + # 3.7 删除测试任务 + print("\n3.7 删除测试任务...") + queue.delete_task(task_id) + print(f"✓ 删除任务成功: {task_id}") + + print("\n" + "=" * 60) + print("所有测试通过!SQLite 数据库运行正常") + print("=" * 60) + +if __name__ == "__main__": + test_database() diff --git a/test_html.py b/test_html.py new file mode 100644 index 0000000..292753d --- /dev/null +++ b/test_html.py @@ -0,0 +1,23 @@ +from app import BaijiahaoScraper + +app_id = "1700253559210167" + +print(f"测试app_id: {app_id}\n") + +uk, cookies = BaijiahaoScraper.get_uk_from_app_id(app_id) +print(f"UK: {uk}\n") + +scraper = BaijiahaoScraper(uk, cookies) + +# 测试HTML解析方式 +print("使用HTML解析方式:") +articles = scraper.get_articles_from_html(app_id=app_id) + +if articles: + print(f"\n成功! 获取到 {len(articles)} 篇文章") + print("\n前3篇:") + for i, article in enumerate(articles[:3], 1): + print(f"{i}. {article['标题']}") + print(f" {article['链接'][:80]}...") +else: + print("未获取到文章") diff --git a/test_selenium.py b/test_selenium.py new file mode 100644 index 0000000..e69de29