Repository: NanmiCoder/MediaCrawler Branch: main Commit: 2b049d05a3aa Files: 212 Total size: 3.0 MB Directory structure: gitextract_6yb3d2nb/ ├── .gitattributes ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── quesiton.md │ └── workflows/ │ └── deploy.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── LICENSE ├── README.md ├── README_en.md ├── README_es.md ├── api/ │ ├── __init__.py │ ├── main.py │ ├── routers/ │ │ ├── __init__.py │ │ ├── crawler.py │ │ ├── data.py │ │ └── websocket.py │ ├── schemas/ │ │ ├── __init__.py │ │ └── crawler.py │ ├── services/ │ │ ├── __init__.py │ │ └── crawler_manager.py │ └── webui/ │ ├── assets/ │ │ ├── index-DvClRayq.js │ │ └── index-OiBmsgXF.css │ └── index.html ├── base/ │ ├── __init__.py │ └── base_crawler.py ├── cache/ │ ├── __init__.py │ ├── abs_cache.py │ ├── cache_factory.py │ ├── local_cache.py │ └── redis_cache.py ├── cmd_arg/ │ ├── __init__.py │ └── arg.py ├── config/ │ ├── __init__.py │ ├── base_config.py │ ├── bilibili_config.py │ ├── db_config.py │ ├── dy_config.py │ ├── ks_config.py │ ├── tieba_config.py │ ├── weibo_config.py │ ├── xhs_config.py │ └── zhihu_config.py ├── constant/ │ ├── __init__.py │ ├── baidu_tieba.py │ └── zhihu.py ├── database/ │ ├── __init__.py │ ├── db.py │ ├── db_session.py │ ├── models.py │ └── mongodb_store_base.py ├── docs/ │ ├── .vitepress/ │ │ ├── config.mjs │ │ └── theme/ │ │ ├── DynamicAds.vue │ │ ├── MyLayout.vue │ │ ├── custom.css │ │ └── index.js │ ├── CDP模式使用指南.md │ ├── data_storage_guide.md │ ├── excel_export_guide.md │ ├── hit_stopwords.txt │ ├── index.md │ ├── mediacrawlerpro订阅.md │ ├── 代理使用.md │ ├── 作者介绍.md │ ├── 原生环境管理文档.md │ ├── 常见问题.md │ ├── 开发者咨询.md │ ├── 微信交流群.md │ ├── 快代理使用文档.md │ ├── 手机号登录说明.md │ ├── 捐赠名单.md │ ├── 知识付费介绍.md │ ├── 词云图使用配置.md │ ├── 豌豆HTTP使用文档.md │ ├── 项目代码结构.md │ └── 项目架构文档.md ├── libs/ │ ├── douyin.js │ └── zhihu.js ├── main.py ├── media_platform/ │ ├── __init__.py │ ├── bilibili/ │ │ ├── __init__.py │ │ ├── client.py │ │ ├── core.py │ │ ├── exception.py │ │ ├── field.py │ │ ├── help.py │ │ └── login.py │ ├── douyin/ │ │ ├── __init__.py │ │ ├── client.py │ │ ├── core.py │ │ ├── exception.py │ │ ├── field.py │ │ ├── help.py │ │ └── login.py │ ├── kuaishou/ │ │ ├── __init__.py │ │ ├── client.py │ │ ├── core.py │ │ ├── exception.py │ │ ├── field.py │ │ ├── graphql/ │ │ │ ├── comment_list.graphql │ │ │ ├── search_query.graphql │ │ │ ├── video_detail.graphql │ │ │ ├── vision_profile.graphql │ │ │ ├── vision_profile_photo_list.graphql │ │ │ ├── vision_profile_user_list.graphql │ │ │ └── vision_sub_comment_list.graphql │ │ ├── graphql.py │ │ ├── help.py │ │ └── login.py │ ├── tieba/ │ │ ├── __init__.py │ │ ├── client.py │ │ ├── core.py │ │ ├── field.py │ │ ├── help.py │ │ ├── login.py │ │ └── test_data/ │ │ ├── note_comments.html │ │ ├── note_detail.html │ │ ├── note_sub_comments.html │ │ ├── search_keyword_notes.html │ │ └── tieba_note_list.html │ ├── weibo/ │ │ ├── __init__.py │ │ ├── client.py │ │ ├── core.py │ │ ├── exception.py │ │ ├── field.py │ │ ├── help.py │ │ └── login.py │ ├── xhs/ │ │ ├── __init__.py │ │ ├── client.py │ │ ├── core.py │ │ ├── exception.py │ │ ├── extractor.py │ │ ├── field.py │ │ ├── help.py │ │ ├── login.py │ │ ├── playwright_sign.py │ │ └── xhs_sign.py │ └── zhihu/ │ ├── __init__.py │ ├── client.py │ ├── core.py │ ├── exception.py │ ├── field.py │ ├── help.py │ └── login.py ├── model/ │ ├── __init__.py │ ├── m_baidu_tieba.py │ ├── m_bilibili.py │ ├── m_douyin.py │ ├── m_kuaishou.py │ ├── m_weibo.py │ ├── m_xiaohongshu.py │ └── m_zhihu.py ├── mypy.ini ├── package.json ├── proxy/ │ ├── __init__.py │ ├── base_proxy.py │ ├── providers/ │ │ ├── __init__.py │ │ ├── jishu_http_proxy.py │ │ ├── kuaidl_proxy.py │ │ └── wandou_http_proxy.py │ ├── proxy_ip_pool.py │ ├── proxy_mixin.py │ └── types.py ├── pyproject.toml ├── recv_sms.py ├── requirements.txt ├── store/ │ ├── __init__.py │ ├── bilibili/ │ │ ├── __init__.py │ │ ├── _store_impl.py │ │ └── bilibilli_store_media.py │ ├── douyin/ │ │ ├── __init__.py │ │ ├── _store_impl.py │ │ └── douyin_store_media.py │ ├── excel_store_base.py │ ├── kuaishou/ │ │ ├── __init__.py │ │ └── _store_impl.py │ ├── tieba/ │ │ ├── __init__.py │ │ └── _store_impl.py │ ├── weibo/ │ │ ├── __init__.py │ │ ├── _store_impl.py │ │ └── weibo_store_media.py │ ├── xhs/ │ │ ├── __init__.py │ │ ├── _store_impl.py │ │ └── xhs_store_media.py │ └── zhihu/ │ ├── __init__.py │ └── _store_impl.py ├── test/ │ ├── __init__.py │ ├── test_db_sync.py │ ├── test_expiring_local_cache.py │ ├── test_mongodb_integration.py │ ├── test_proxy_ip_pool.py │ ├── test_redis_cache.py │ └── test_utils.py ├── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── test_excel_store.py │ └── test_store_factory.py ├── tools/ │ ├── __init__.py │ ├── app_runner.py │ ├── async_file_writer.py │ ├── browser_launcher.py │ ├── cdp_browser.py │ ├── crawler_util.py │ ├── easing.py │ ├── file_header_manager.py │ ├── httpx_util.py │ ├── slider_util.py │ ├── time_util.py │ ├── utils.py │ └── words.py └── var.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ *.js linguist-language=python *.css linguist-language=python *.html linguist-language=python ================================================ FILE: .github/CODEOWNERS ================================================ # 默认:仓库所有文件都需要 @NanmiCoder 审核 * @NanmiCoder .github/workflows/** @NanmiCoder requirements.txt @NanmiCoder pyproject.toml @NanmiCoder Pipfile @NanmiCoder package.json @NanmiCoder package-lock.json @NanmiCoder pnpm-lock.yaml @NanmiCoder Dockerfile @NanmiCoder docker/** @NanmiCoder scripts/deploy/** @NanmiCoder ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: MediaCrawler Bug反馈 about: 创建一个问题Bug以帮助MediaCrawler开源项目改进 title: '[BUG] ' labels: bug assignees: '' --- ## 🔍 问题检查清单 - [ ] 我已经仔细阅读了项目使用过程中的[常见问题汇总](https://nanmicoder.github.io/MediaCrawler/%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98.html) - [ ] 我已经搜索并查看了[已关闭的issues](https://github.com/NanmiCoder/MediaCrawler/issues?q=is%3Aissue+is%3Aclosed) - [ ] 我确认这不是由于滑块验证码、Cookie过期、Cookie提取错误、平台风控等常见原因导致的问题 ## 🐛 问题描述 ## 📝 复现步骤 1. 2. 3. ## 💻 运行环境 - 操作系统: - Python版本: - 是否使用IP代理: - 是否使用VPN翻墙软件: - 目标平台(抖音/小红书/微博等): ## 📋 错误日志 ```shell 在此粘贴错误日志 ``` ## 📷 错误截图 ================================================ FILE: .github/ISSUE_TEMPLATE/quesiton.md ================================================ --- name: MediaCrawler使用问题咨询 about: 提交使用过程中遇到的问题 title: '[问题] ' labels: question assignees: '' --- ## ⚠️ 提交前确认 - [ ] 我已经仔细阅读了项目使用过程中的[常见问题汇总](https://nanmicoder.github.io/MediaCrawler/%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98.html) - [ ] 我已经搜索并查看了[已关闭的issues](https://github.com/NanmiCoder/MediaCrawler/issues?q=is%3Aissue+is%3Aclosed) - [ ] 我确认这不是由于滑块验证码、Cookie过期、Cookie提取错误、平台风控等常见原因导致的问题 ## ❓ 问题描述 ## 🔍 使用场景 - 目标平台: (如:小红书/抖音/微博等) - 使用功能: (如:关键词搜索/用户主页爬取等) ## 💻 环境信息 - 操作系统: - Python版本: - 是否使用IP代理: - 是否使用VPN翻墙软件: - 目标平台(抖音/小红书/微博等): ## 📋 错误日志 ```shell 在此粘贴完整的错误日志 ``` ## 📷 错误截图 ================================================ FILE: .github/workflows/deploy.yml ================================================ # 构建 VitePress 站点并将其部署到 GitHub Pages 的示例工作流程 # name: Deploy VitePress site to Pages on: # 在针对 `main` 分支的推送上运行。如果你 # 使用 `master` 分支作为默认分支,请将其更改为 `master` push: branches: [main] # 允许你从 Actions 选项卡手动运行此工作流程 workflow_dispatch: # 设置 GITHUB_TOKEN 的权限,以允许部署到 GitHub Pages permissions: contents: read pages: write id-token: write # 只允许同时进行一次部署,跳过正在运行和最新队列之间的运行队列 # 但是,不要取消正在进行的运行,因为我们希望允许这些生产部署完成 concurrency: group: pages cancel-in-progress: false jobs: # 构建工作 build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 # 如果未启用 lastUpdated,则不需要 # - uses: pnpm/action-setup@v3 # 如果使用 pnpm,请取消注释 # - uses: oven-sh/setup-bun@v1 # 如果使用 Bun,请取消注释 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 cache: npm # 或 pnpm / yarn - name: Setup Pages uses: actions/configure-pages@v4 - name: Install dependencies run: npm ci # 或 pnpm install / yarn install / bun install - name: Build with VitePress run: npm run docs:build # 或 pnpm docs:build / yarn docs:build / bun run docs:build - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: docs/.vitepress/dist # 部署工作 deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} needs: build runs-on: ubuntu-latest name: Deploy steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ *.xml *.iml .idea /temp_image/ /browser_data/ /data/ */.DS_Store .vscode /node_modules docs/.vitepress/cache # other gitignore .venv .refer agent_zone debug_tools database/*.db ================================================ FILE: .pre-commit-config.yaml ================================================ # Pre-commit hooks configuration for MediaCrawler project # See https://pre-commit.com for more information repos: # Local hooks - repo: local hooks: # Python file header copyright check - id: check-file-headers name: Check Python file headers entry: python tools/file_header_manager.py --check language: system types: [python] pass_filenames: true stages: [pre-commit] # Auto-fix Python file headers - id: add-file-headers name: Add copyright headers to Python files entry: python tools/file_header_manager.py language: system types: [python] pass_filenames: true stages: [pre-commit] # Standard pre-commit hooks (optional, can be enabled later) - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - id: trailing-whitespace exclude: ^(.*\.md|.*\.txt)$ - id: end-of-file-fixer exclude: ^(.*\.md|.*\.txt)$ - id: check-yaml - id: check-added-large-files args: ['--maxkb=10240'] # 10MB limit - id: check-merge-conflict - id: check-case-conflict - id: mixed-line-ending # Global configuration default_language_version: python: python3 # Run hooks on all files during manual run # Usage: pre-commit run --all-files ================================================ FILE: .python-version ================================================ 3.11 ================================================ FILE: LICENSE ================================================ NON-COMMERCIAL LEARNING LICENSE 1.1 Copyright (c) [2024] [relakkes@gmail.com] WHEREAS: 1. The copyright owner owns and controls the copyright of this software and related documentation files (hereinafter referred to as the "Software"); 2. The user wishes to use the Software for learning purposes; 3. The copyright owner is willing to authorize the user to use the Software under the conditions stated in this license; NOW, THEREFORE, the parties, in compliance with relevant laws and regulations, agree to the following terms: SCOPE OF AUTHORIZATION: 1. The copyright owner hereby grants any natural person or legal entity (hereinafter referred to as the "User") accepting this license a free, non-exclusive, non-transferable right to use, copy, modify, and merge the Software for non-commercial learning purposes, subject to the following conditions. CONDITIONS: 1. The User must include the above copyright notice and this license statement in all reasonably prominent locations of the Software and its copies. 2. The Software is limited to learning and research purposes only, and may not be used for large-scale crawling or activities that disrupt platform operations. 3. Without the written consent of the copyright owner, the Software may not be used for any commercial purposes or to cause improper influence on third parties. DISCLAIMER: 1. The Software is provided "AS IS," without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. 2. In no event shall the copyright owner be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this Software, even if advised of the possibility of such damage. APPLICABLE LAW: 1. The interpretation and enforcement of this license shall comply with local laws and regulations. 2. Any disputes arising from or related to this license shall be resolved through friendly negotiation between the parties; if negotiation fails, either party may submit the dispute to the people's court where the copyright owner is located for resolution. This license constitutes the entire agreement between the parties regarding the Software, superseding and merging all prior discussions, communications, and agreements, whether oral or written. 非商业学习使用许可证 1.1 版权所有 (c) [2024] [relakkes@gmail.com] 鉴于: 1. 版权所有者拥有和控制本软件和相关文档文件(以下简称“软件”)的版权; 2. 使用者希望使用该软件进行学习; 3. 版权所有者愿意在本许可证所述的条件下授权使用者使用该软件; 现因此,双方遵循相关法律法规,同意如下条款: 授权范围: 1. 版权所有者特此免费授予接受本许可证的任何自然人或法人(以下简称“使用者”)非独占的、不可转让的权利,在非商业学习目的下使用、复制、修改、合并本软件,前提是遵守以下条件。 条件: 1. 使用者必须在软件及其副本的所有合理显著位置包含上述版权声明和本许可证声明。 2. 本软件仅限用于学习和研究目的,不得用于大规模爬虫或对平台造成运营干扰的行为。 3. 未经版权所有者书面同意,不得将本软件用于任何商业用途或对第三方造成不当影响。 免责声明: 1. 本软件按“现状”提供,不提供任何形式的明示或暗示保证,包括但不限于对适销性、特定用途的适用性和非侵权的保证。 2. 在任何情况下,版权所有者均不对因使用本软件而产生的,或在任何方式上与本软件有关的任何直接、间接、偶然、特殊、示例性或后果性损害负责(包括但不限于采购替代品或服务;使用、数据或利润的损失;或业务中断),无论这些损害是如何引起的,以及无论是通过合同、严格责任还是侵权行为(包括疏忽或其他方式)产生的,即使已被告知此类损害的可能性。 适用法律: 1. 本许可证的解释和执行应遵循当地法律法规。 2. 因本许可证引起的或与之相关的任何争议,双方应友好协商解决;协商不成时,任何一方可将争议提交至版权所有者所在地的人民法院诉讼解决。 本许可证构成双方之间关于本软件的完整协议,取代并合并以前的讨论、交流和协议,无论是口头还是书面的。 ================================================ FILE: README.md ================================================ # 🔥 MediaCrawler - 自媒体平台爬虫 🕷️
NanmiCoder%2FMediaCrawler | Trendshift [![GitHub Stars](https://img.shields.io/github/stars/NanmiCoder/MediaCrawler?style=social)](https://github.com/NanmiCoder/MediaCrawler/stargazers) [![GitHub Forks](https://img.shields.io/github/forks/NanmiCoder/MediaCrawler?style=social)](https://github.com/NanmiCoder/MediaCrawler/network/members) [![GitHub Issues](https://img.shields.io/github/issues/NanmiCoder/MediaCrawler)](https://github.com/NanmiCoder/MediaCrawler/issues) [![GitHub Pull Requests](https://img.shields.io/github/issues-pr/NanmiCoder/MediaCrawler)](https://github.com/NanmiCoder/MediaCrawler/pulls) [![License](https://img.shields.io/github/license/NanmiCoder/MediaCrawler)](https://github.com/NanmiCoder/MediaCrawler/blob/main/LICENSE) [![中文](https://img.shields.io/badge/🇨🇳_中文-当前-blue)](README.md) [![English](https://img.shields.io/badge/🇺🇸_English-Available-green)](README_en.md) [![Español](https://img.shields.io/badge/🇪🇸_Español-Available-green)](README_es.md)
> **免责声明:** > > 大家请以学习为目的使用本仓库⚠️⚠️⚠️⚠️,[爬虫违法违规的案件](https://github.com/HiddenStrawberry/Crawler_Illegal_Cases_In_China)
> >本仓库的所有内容仅供学习和参考之用,禁止用于商业用途。任何人或组织不得将本仓库的内容用于非法用途或侵犯他人合法权益。本仓库所涉及的爬虫技术仅用于学习和研究,不得用于对其他平台进行大规模爬虫或其他非法行为。对于因使用本仓库内容而引起的任何法律责任,本仓库不承担任何责任。使用本仓库的内容即表示您同意本免责声明的所有条款和条件。 > > 点击查看更为详细的免责声明。[点击跳转](#disclaimer) ## 📖 项目简介 一个功能强大的**多平台自媒体数据采集工具**,支持小红书、抖音、快手、B站、微博、贴吧、知乎等主流平台的公开信息抓取。 ### 🔧 技术原理 - **核心技术**:基于 [Playwright](https://playwright.dev/) 浏览器自动化框架登录保存登录态 - **无需JS逆向**:利用保留登录态的浏览器上下文环境,通过 JS 表达式获取签名参数 - **优势特点**:无需逆向复杂的加密算法,大幅降低技术门槛 ## ✨ 功能特性 | 平台 | 关键词搜索 | 指定帖子ID爬取 | 二级评论 | 指定创作者主页 | 登录态缓存 | IP代理池 | 生成评论词云图 | | ------ | ---------- | -------------- | -------- | -------------- | ---------- | -------- | -------------- | | 小红书 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | 抖音 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | 快手 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | B 站 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | 微博 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | 贴吧 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | 知乎 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | MediaCrawlerPro 重磅发布!开源不易,欢迎订阅支持 > 专注于学习成熟项目的架构设计,不仅仅是爬虫技术,Pro 版本的代码设计思路同样值得深入学习! [MediaCrawlerPro](https://github.com/MediaCrawlerPro) 相较于开源版本的核心优势: #### 🎯 核心功能升级 - ✅ **自媒体内容拆解Agent**(新增功能) - ✅ **断点续爬功能**(重点特性) - ✅ **多账号 + IP代理池支持**(重点特性) - ✅ **去除 Playwright 依赖**,使用更简单 - ✅ **完整 Linux 环境支持** #### 🏗️ 架构设计优化 - ✅ **代码重构优化**,更易读易维护(解耦 JS 签名逻辑) - ✅ **企业级代码质量**,适合构建大型爬虫项目 - ✅ **完美架构设计**,高扩展性,源码学习价值更大 #### 🎁 额外功能 - ✅ **自媒体视频下载器桌面端**(适合学习全栈开发) - ✅ **多平台首页信息流推荐**(HomeFeed) - ✅ **AI Agent Skill 支持**([OpenClaw](https://openclaw.ai/) 🦞 / Claude Code / Cursor 一键安装,让 Agent 自动爬取数据) - [ ] **基于评论分析AI Agent正在开发中 🚀🚀** 点击查看:[MediaCrawlerPro 项目主页](https://github.com/MediaCrawlerPro) 更多介绍 ## 🚀 快速开始 > 💡 **如果这个项目对您有帮助,请给个 ⭐ Star 支持一下!** ## 📋 前置依赖 ### 🚀 uv 安装(推荐) 在进行下一步操作之前,请确保电脑上已经安装了 uv: - **安装地址**:[uv 官方安装指南](https://docs.astral.sh/uv/getting-started/installation) - **验证安装**:终端输入命令 `uv --version`,如果正常显示版本号,证明已经安装成功 - **推荐理由**:uv 是目前最强的 Python 包管理工具,速度快、依赖解析准确 ### 🟢 Node.js 安装 项目依赖 Node.js,请前往官网下载安装: - **下载地址**:https://nodejs.org/en/download/ - **版本要求**:>= 16.0.0 ### 📦 Python 包安装 ```shell # 进入项目目录 cd MediaCrawler # 使用 uv sync 命令来保证 python 版本和相关依赖包的一致性 uv sync ``` ### 🌐 浏览器驱动安装 ```shell # 安装浏览器驱动 uv run playwright install ``` ## 🚀 运行爬虫程序 ```shell # 在 config/base_config.py 查看配置项目功能,写的有中文注释 # 从配置文件中读取关键词搜索相关的帖子并爬取帖子信息与评论 uv run main.py --platform xhs --lt qrcode --type search # 从配置文件中读取指定的帖子ID列表获取指定帖子的信息与评论信息 uv run main.py --platform xhs --lt qrcode --type detail # 打开对应APP扫二维码登录 # 其他平台爬虫使用示例,执行下面的命令查看 uv run main.py --help ```
🖥️ WebUI 可视化操作界面 MediaCrawler 提供了基于 Web 的可视化操作界面,无需命令行也能轻松使用爬虫功能。 #### 启动 WebUI 服务 ```shell # 启动 API 服务器(默认端口 8080) uv run uvicorn api.main:app --port 8080 --reload # 或者使用模块方式启动 uv run python -m api.main ``` 启动成功后,访问 `http://localhost:8080` 即可打开 WebUI 界面。 #### WebUI 功能特性 - 可视化配置爬虫参数(平台、登录方式、爬取类型等) - 实时查看爬虫运行状态和日志 - 数据预览和导出 #### 界面预览 WebUI 界面预览
🔗 使用 Python 原生 venv 管理环境(不推荐) #### 创建并激活 Python 虚拟环境 > 如果是爬取抖音和知乎,需要提前安装 nodejs 环境,版本大于等于:`16` 即可 ```shell # 进入项目根目录 cd MediaCrawler # 创建虚拟环境 # 我的 python 版本是:3.11 requirements.txt 中的库是基于这个版本的 # 如果是其他 python 版本,可能 requirements.txt 中的库不兼容,需自行解决 python -m venv venv # macOS & Linux 激活虚拟环境 source venv/bin/activate # Windows 激活虚拟环境 venv\Scripts\activate ``` #### 安装依赖库 ```shell pip install -r requirements.txt ``` #### 安装 playwright 浏览器驱动 ```shell playwright install ``` #### 运行爬虫程序(原生环境) ```shell # 项目默认是没有开启评论爬取模式,如需评论请在 config/base_config.py 中的 ENABLE_GET_COMMENTS 变量修改 # 一些其他支持项,也可以在 config/base_config.py 查看功能,写的有中文注释 # 从配置文件中读取关键词搜索相关的帖子并爬取帖子信息与评论 python main.py --platform xhs --lt qrcode --type search # 从配置文件中读取指定的帖子ID列表获取指定帖子的信息与评论信息 python main.py --platform xhs --lt qrcode --type detail # 打开对应APP扫二维码登录 # 其他平台爬虫使用示例,执行下面的命令查看 python main.py --help ```
## 💾 数据保存 MediaCrawler 支持多种数据存储方式,包括 CSV、JSON、JSONL、Excel、SQLite 和 MySQL 数据库。 📖 **详细使用说明请查看:[数据存储指南](docs/data_storage_guide.md)** [🚀 MediaCrawlerPro 重磅发布 🚀!更多的功能,更好的架构设计!开源不易,欢迎订阅支持!](https://github.com/MediaCrawlerPro) ## 💬 交流群组 - **微信交流群**:[点击加入](https://nanmicoder.github.io/MediaCrawler/%E5%BE%AE%E4%BF%A1%E4%BA%A4%E6%B5%81%E7%BE%A4.html) - **B站账号**:[关注我](https://space.bilibili.com/434377496),分享AI与爬虫技术知识 ## 💰 赞助商展示
TikHub.io 提供 900+ 高稳定性数据接口,覆盖 TK、DY、XHS、Y2B、Ins、X 等 14+ 海内外主流平台,支持用户、内容、商品、评论等多维度公开数据 API,并配套 4000 万+ 已清洗结构化数据集,使用邀请码 cfzyejV9 注册并充值,即可额外获得 $2 赠送额度。
--- ## 🤝 成为赞助者 成为赞助者,可以将您的产品展示在这里,每天获得大量曝光! **联系方式**: - 微信:`relakkes` - 邮箱:`relakkes@gmail.com` --- ## 📚 其他 - **常见问题**:[MediaCrawler 完整文档](https://nanmicoder.github.io/MediaCrawler/) - **爬虫入门教程**:[CrawlerTutorial 免费教程](https://github.com/NanmiCoder/CrawlerTutorial) - **新闻爬虫开源项目**:[NewsCrawlerCollection](https://github.com/NanmiCoder/NewsCrawlerCollection) ## ⭐ Star 趋势图 如果这个项目对您有帮助,请给个 ⭐ Star 支持一下,让更多的人看到 MediaCrawler! [![Star History Chart](https://api.star-history.com/svg?repos=NanmiCoder/MediaCrawler&type=Date)](https://star-history.com/#NanmiCoder/MediaCrawler&Date) ## 📚 参考 - **小红书签名仓库**:[Cloxl 的 xhs 签名仓库](https://github.com/Cloxl/xhshow) - **小红书客户端**:[ReaJason 的 xhs 仓库](https://github.com/ReaJason/xhs) - **短信转发**:[SmsForwarder 参考仓库](https://github.com/pppscn/SmsForwarder) - **内网穿透工具**:[ngrok 官方文档](https://ngrok.com/docs/) # 免责声明
## 1. 项目目的与性质 本项目(以下简称“本项目”)是作为一个技术研究与学习工具而创建的,旨在探索和学习网络数据采集技术。本项目专注于自媒体平台的数据爬取技术研究,旨在提供给学习者和研究者作为技术交流之用。 ## 2. 法律合规性声明 本项目开发者(以下简称“开发者”)郑重提醒用户在下载、安装和使用本项目时,严格遵守中华人民共和国相关法律法规,包括但不限于《中华人民共和国网络安全法》、《中华人民共和国反间谍法》等所有适用的国家法律和政策。用户应自行承担一切因使用本项目而可能引起的法律责任。 ## 3. 使用目的限制 本项目严禁用于任何非法目的或非学习、非研究的商业行为。本项目不得用于任何形式的非法侵入他人计算机系统,不得用于任何侵犯他人知识产权或其他合法权益的行为。用户应保证其使用本项目的目的纯属个人学习和技术研究,不得用于任何形式的非法活动。 ## 4. 免责声明 开发者已尽最大努力确保本项目的正当性及安全性,但不对用户使用本项目可能引起的任何形式的直接或间接损失承担责任。包括但不限于由于使用本项目而导致的任何数据丢失、设备损坏、法律诉讼等。 ## 5. 知识产权声明 本项目的知识产权归开发者所有。本项目受到著作权法和国际著作权条约以及其他知识产权法律和条约的保护。用户在遵守本声明及相关法律法规的前提下,可以下载和使用本项目。 ## 6. 最终解释权 关于本项目的最终解释权归开发者所有。开发者保留随时更改或更新本免责声明的权利,恕不另行通知。
================================================ FILE: README_en.md ================================================ # 🔥 MediaCrawler - Social Media Platform Crawler 🕷️
NanmiCoder%2FMediaCrawler | Trendshift [![GitHub Stars](https://img.shields.io/github/stars/NanmiCoder/MediaCrawler?style=social)](https://github.com/NanmiCoder/MediaCrawler/stargazers) [![GitHub Forks](https://img.shields.io/github/forks/NanmiCoder/MediaCrawler?style=social)](https://github.com/NanmiCoder/MediaCrawler/network/members) [![GitHub Issues](https://img.shields.io/github/issues/NanmiCoder/MediaCrawler)](https://github.com/NanmiCoder/MediaCrawler/issues) [![GitHub Pull Requests](https://img.shields.io/github/issues-pr/NanmiCoder/MediaCrawler)](https://github.com/NanmiCoder/MediaCrawler/pulls) [![License](https://img.shields.io/github/license/NanmiCoder/MediaCrawler)](https://github.com/NanmiCoder/MediaCrawler/blob/main/LICENSE) [![中文](https://img.shields.io/badge/🇨🇳_中文-Available-blue)](README.md) [![English](https://img.shields.io/badge/🇺🇸_English-Current-green)](README_en.md) [![Español](https://img.shields.io/badge/🇪🇸_Español-Available-green)](README_es.md)
> **Disclaimer:** > > Please use this repository for learning purposes only ⚠️⚠️⚠️⚠️, [Web scraping illegal cases](https://github.com/HiddenStrawberry/Crawler_Illegal_Cases_In_China)
> >All content in this repository is for learning and reference purposes only, and commercial use is prohibited. No person or organization may use the content of this repository for illegal purposes or infringe upon the legitimate rights and interests of others. The web scraping technology involved in this repository is only for learning and research, and may not be used for large-scale crawling of other platforms or other illegal activities. This repository assumes no legal responsibility for any legal liability arising from the use of the content of this repository. By using the content of this repository, you agree to all terms and conditions of this disclaimer. > > Click to view a more detailed disclaimer. [Click to jump](#disclaimer) ## 📖 Project Introduction A powerful **multi-platform social media data collection tool** that supports crawling public information from mainstream platforms including Xiaohongshu, Douyin, Kuaishou, Bilibili, Weibo, Tieba, Zhihu, and more. ### 🔧 Technical Principles - **Core Technology**: Based on [Playwright](https://playwright.dev/) browser automation framework for login and maintaining login state - **No JS Reverse Engineering Required**: Uses browser context environment with preserved login state to obtain signature parameters through JS expressions - **Advantages**: No need to reverse complex encryption algorithms, significantly lowering the technical barrier ## ✨ Features | Platform | Keyword Search | Specific Post ID Crawling | Secondary Comments | Specific Creator Homepage | Login State Cache | IP Proxy Pool | Generate Comment Word Cloud | | ------ | ---------- | -------------- | -------- | -------------- | ---------- | -------- | -------------- | | Xiaohongshu | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Douyin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Kuaishou | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Bilibili | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Weibo | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Tieba | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Zhihu | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | MediaCrawlerPro Major Release! Open source is not easy, welcome to subscribe and support! > Focus on learning mature project architectural design, not just crawling technology. The code design philosophy of the Pro version is equally worth in-depth study! [MediaCrawlerPro](https://github.com/MediaCrawlerPro) core advantages over the open-source version: #### 🎯 Core Feature Upgrades - ✅ **Content Deconstruction Agent** (New feature) - ✅ **Resume crawling functionality** (Key feature) - ✅ **Multi-account + IP proxy pool support** (Key feature) - ✅ **Remove Playwright dependency**, easier to use - ✅ **Complete Linux environment support** #### 🏗️ Architectural Design Optimization - ✅ **Code refactoring optimization**, more readable and maintainable (decoupled JS signature logic) - ✅ **Enterprise-level code quality**, suitable for building large-scale crawler projects - ✅ **Perfect architectural design**, high scalability, greater source code learning value #### 🎁 Additional Features - ✅ **Social media video downloader desktop app** (suitable for learning full-stack development) - ✅ **Multi-platform homepage feed recommendations** (HomeFeed) - [ ] **AI Agent based on comment analysis is under development 🚀🚀** Click to view: [MediaCrawlerPro Project Homepage](https://github.com/MediaCrawlerPro) for more information ## 🚀 Quick Start > 💡 **Open source is not easy, if this project helps you, please give a ⭐ Star to support!** ## 📋 Prerequisites ### 🚀 uv Installation (Recommended) Before proceeding with the next steps, please ensure that uv is installed on your computer: - **Installation Guide**: [uv Official Installation Guide](https://docs.astral.sh/uv/getting-started/installation) - **Verify Installation**: Enter the command `uv --version` in the terminal. If the version number is displayed normally, the installation was successful - **Recommendation Reason**: uv is currently the most powerful Python package management tool, with fast speed and accurate dependency resolution ### 🟢 Node.js Installation The project depends on Node.js, please download and install from the official website: - **Download Link**: https://nodejs.org/en/download/ - **Version Requirement**: >= 16.0.0 ### 📦 Python Package Installation ```shell # Enter project directory cd MediaCrawler # Use uv sync command to ensure consistency of python version and related dependency packages uv sync ``` ### 🌐 Browser Driver Installation ```shell # Install browser driver uv run playwright install ``` > **💡 Tip**: MediaCrawler now supports using playwright to connect to your local Chrome browser, solving some issues caused by Webdriver. > > Currently, `xhs` and `dy` are available using CDP mode to connect to local browsers. If needed, check the configuration items in `config/base_config.py`. ## 🚀 Run Crawler Program ```shell # The project does not enable comment crawling mode by default. If you need comments, please modify the ENABLE_GET_COMMENTS variable in config/base_config.py # Other supported options can also be viewed in config/base_config.py with Chinese comments # Read keywords from configuration file to search related posts and crawl post information and comments uv run main.py --platform xhs --lt qrcode --type search # Read specified post ID list from configuration file to get information and comment information of specified posts uv run main.py --platform xhs --lt qrcode --type detail # Open corresponding APP to scan QR code for login # For other platform crawler usage examples, execute the following command to view uv run main.py --help ``` ## WebUI Support
🖥️ WebUI Visual Operation Interface MediaCrawler provides a web-based visual operation interface, allowing you to easily use crawler features without command line. #### Start WebUI Service ```shell # Start API server (default port 8080) uv run uvicorn api.main:app --port 8080 --reload # Or start using module method uv run python -m api.main ``` After successful startup, visit `http://localhost:8080` to open the WebUI interface. #### WebUI Features - Visualize crawler parameter configuration (platform, login method, crawling type, etc.) - Real-time view of crawler running status and logs - Data preview and export #### Interface Preview WebUI Interface Preview
🔗 Using Python native venv environment management (Not recommended) #### Create and activate Python virtual environment > If crawling Douyin and Zhihu, you need to install nodejs environment in advance, version greater than or equal to: `16` ```shell # Enter project root directory cd MediaCrawler # Create virtual environment # My python version is: 3.9.6, the libraries in requirements.txt are based on this version # If using other python versions, the libraries in requirements.txt may not be compatible, please resolve on your own python -m venv venv # macOS & Linux activate virtual environment source venv/bin/activate # Windows activate virtual environment venv\Scripts\activate ``` #### Install dependency libraries ```shell pip install -r requirements.txt ``` #### Install playwright browser driver ```shell playwright install ``` #### Run crawler program (native environment) ```shell # The project does not enable comment crawling mode by default. If you need comments, please modify the ENABLE_GET_COMMENTS variable in config/base_config.py # Other supported options can also be viewed in config/base_config.py with Chinese comments # Read keywords from configuration file to search related posts and crawl post information and comments python main.py --platform xhs --lt qrcode --type search # Read specified post ID list from configuration file to get information and comment information of specified posts python main.py --platform xhs --lt qrcode --type detail # Open corresponding APP to scan QR code for login # For other platform crawler usage examples, execute the following command to view python main.py --help ```
## 💾 Data Storage MediaCrawler supports multiple data storage methods, including CSV, JSON, JSONL, Excel, SQLite, and MySQL databases. 📖 **For detailed usage instructions, please see: [Data Storage Guide](docs/data_storage_guide.md)** --- [🚀 MediaCrawlerPro Major Release 🚀! More features, better architectural design!](https://github.com/MediaCrawlerPro) ### 💬 Discussion Groups - **WeChat Discussion Group**: [Click to join](https://nanmicoder.github.io/MediaCrawler/%E5%BE%AE%E4%BF%A1%E4%BA%A4%E6%B5%81%E7%BE%A4.html) - **Bilibili Account**: [Follow me](https://space.bilibili.com/434377496), sharing AI and crawler technology knowledge ### 💰 Sponsor Display
TikHub.io provides 900+ highly stable data interfaces, covering 14+ mainstream domestic and international platforms including TK, DY, XHS, Y2B, Ins, X, etc. Supports multi-dimensional public data APIs for users, content, products, comments, etc., with 40M+ cleaned structured datasets. Use invitation code cfzyejV9 to register and recharge, and get an additional $2 bonus.
--- ### 🤝 Become a Sponsor Become a sponsor and showcase your product here, getting massive exposure daily! **Contact Information**: - WeChat: `relakkes` - Email: `relakkes@gmail.com` --- ### 📚 Other - **FAQ**: [MediaCrawler Complete Documentation](https://nanmicoder.github.io/MediaCrawler/) - **Crawler Beginner Tutorial**: [CrawlerTutorial Free Tutorial](https://github.com/NanmiCoder/CrawlerTutorial) - **News Crawler Open Source Project**: [NewsCrawlerCollection](https://github.com/NanmiCoder/NewsCrawlerCollection) ## ⭐ Star Trend Chart If this project helps you, please give a ⭐ Star to support and let more people see MediaCrawler! [![Star History Chart](https://api.star-history.com/svg?repos=NanmiCoder/MediaCrawler&type=Date)](https://star-history.com/#NanmiCoder/MediaCrawler&Date) ## 📚 References - **Xiaohongshu Signature Repository**: [Cloxl's xhs signature repository](https://github.com/Cloxl/xhshow) - **Xiaohongshu Client**: [ReaJason's xhs repository](https://github.com/ReaJason/xhs) - **SMS Forwarding**: [SmsForwarder reference repository](https://github.com/pppscn/SmsForwarder) - **Intranet Penetration Tool**: [ngrok official documentation](https://ngrok.com/docs/) # Disclaimer
## 1. Project Purpose and Nature This project (hereinafter referred to as "this project") was created as a technical research and learning tool, aimed at exploring and learning network data collection technologies. This project focuses on research of data crawling technologies for social media platforms, intended to provide learners and researchers with technical exchange purposes. ## 2. Legal Compliance Statement The project developer (hereinafter referred to as "developer") solemnly reminds users to strictly comply with relevant laws and regulations of the People's Republic of China when downloading, installing and using this project, including but not limited to the "Cybersecurity Law of the People's Republic of China", "Counter-Espionage Law of the People's Republic of China" and all applicable national laws and policies. Users shall bear all legal responsibilities that may arise from using this project. ## 3. Usage Purpose Restrictions This project is strictly prohibited from being used for any illegal purposes or non-learning, non-research commercial activities. This project may not be used for any form of illegal intrusion into other people's computer systems, nor may it be used for any activities that infringe upon others' intellectual property rights or other legitimate rights and interests. Users should ensure that their use of this project is purely for personal learning and technical research, and may not be used for any form of illegal activities. ## 4. Disclaimer The developer has made every effort to ensure the legitimacy and security of this project, but assumes no responsibility for any form of direct or indirect losses that may arise from users' use of this project. Including but not limited to any data loss, equipment damage, legal litigation, etc. caused by using this project. ## 5. Intellectual Property Statement The intellectual property rights of this project belong to the developer. This project is protected by copyright law and international copyright treaties as well as other intellectual property laws and treaties. Users may download and use this project under the premise of complying with this statement and relevant laws and regulations. ## 6. Final Interpretation Rights The developer has the final interpretation rights regarding this project. The developer reserves the right to change or update this disclaimer at any time without further notice.
## 🙏 Acknowledgments ### JetBrains Open Source License Support Thanks to JetBrains for providing free open source license support for this project! JetBrains ================================================ FILE: README_es.md ================================================ # 🔥 MediaCrawler - Rastreador de Plataformas de Redes Sociales 🕷️
NanmiCoder%2FMediaCrawler | Trendshift [![GitHub Stars](https://img.shields.io/github/stars/NanmiCoder/MediaCrawler?style=social)](https://github.com/NanmiCoder/MediaCrawler/stargazers) [![GitHub Forks](https://img.shields.io/github/forks/NanmiCoder/MediaCrawler?style=social)](https://github.com/NanmiCoder/MediaCrawler/network/members) [![GitHub Issues](https://img.shields.io/github/issues/NanmiCoder/MediaCrawler)](https://github.com/NanmiCoder/MediaCrawler/issues) [![GitHub Pull Requests](https://img.shields.io/github/issues-pr/NanmiCoder/MediaCrawler)](https://github.com/NanmiCoder/MediaCrawler/pulls) [![License](https://img.shields.io/github/license/NanmiCoder/MediaCrawler)](https://github.com/NanmiCoder/MediaCrawler/blob/main/LICENSE) [![中文](https://img.shields.io/badge/🇨🇳_中文-Available-blue)](README.md) [![English](https://img.shields.io/badge/🇺🇸_English-Available-green)](README_en.md) [![Español](https://img.shields.io/badge/🇪🇸_Español-Current-green)](README_es.md)
> **Descargo de responsabilidad:** > > Por favor, utilice este repositorio únicamente con fines de aprendizaje ⚠️⚠️⚠️⚠️, [Casos ilegales de web scraping](https://github.com/HiddenStrawberry/Crawler_Illegal_Cases_In_China)
> >Todo el contenido de este repositorio es únicamente para fines de aprendizaje y referencia, y está prohibido el uso comercial. Ninguna persona u organización puede usar el contenido de este repositorio para propósitos ilegales o infringir los derechos e intereses legítimos de otros. La tecnología de web scraping involucrada en este repositorio es solo para aprendizaje e investigación, y no puede ser utilizada para rastreo a gran escala de otras plataformas u otras actividades ilegales. Este repositorio no asume ninguna responsabilidad legal por cualquier responsabilidad legal que surja del uso del contenido de este repositorio. Al usar el contenido de este repositorio, usted acepta todos los términos y condiciones de este descargo de responsabilidad. > > Haga clic para ver un descargo de responsabilidad más detallado. [Haga clic para saltar](#disclaimer) ## 📖 Introducción del Proyecto Una poderosa **herramienta de recolección de datos de redes sociales multiplataforma** que soporta el rastreo de información pública de plataformas principales incluyendo Xiaohongshu, Douyin, Kuaishou, Bilibili, Weibo, Tieba, Zhihu, y más. ### 🔧 Principios Técnicos - **Tecnología Central**: Basado en el framework de automatización de navegador [Playwright](https://playwright.dev/) para login y mantenimiento del estado de login - **No Requiere Ingeniería Inversa de JS**: Utiliza el entorno de contexto del navegador con estado de login preservado para obtener parámetros de firma a través de expresiones JS - **Ventajas**: No necesita hacer ingeniería inversa de algoritmos de encriptación complejos, reduciendo significativamente la barrera técnica ## ✨ Características | Plataforma | Búsqueda por Palabras Clave | Rastreo de ID de Publicación Específica | Comentarios Secundarios | Página de Inicio de Creador Específico | Caché de Estado de Login | Pool de Proxy IP | Generar Nube de Palabras de Comentarios | | ------ | ---------- | -------------- | -------- | -------------- | ---------- | -------- | -------------- | | Xiaohongshu | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Douyin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Kuaishou | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Bilibili | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Weibo | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Tieba | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Zhihu | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ¡Lanzamiento Mayor de MediaCrawlerPro! ¡El código abierto no es fácil, bienvenido a suscribirse y apoyar! > Enfócate en aprender el diseño arquitectónico de proyectos maduros, no solo tecnología de rastreo. ¡La filosofía de diseño de código de la versión Pro también vale la pena estudiar en profundidad! [MediaCrawlerPro](https://github.com/MediaCrawlerPro) ventajas principales sobre la versión de código abierto: #### 🎯 Actualizaciones de Características Principales - ✅ **Agente de Deconstrucción de Contenido** (Nueva función) - ✅ **Funcionalidad de reanudación de rastreo** (Característica clave) - ✅ **Soporte de múltiples cuentas + pool de proxy IP** (Característica clave) - ✅ **Eliminar dependencia de Playwright**, más fácil de usar - ✅ **Soporte completo de entorno Linux** #### 🏗️ Optimización de Diseño Arquitectónico - ✅ **Optimización de refactorización de código**, más legible y mantenible (lógica de firma JS desacoplada) - ✅ **Calidad de código de nivel empresarial**, adecuado para construir proyectos de rastreo a gran escala - ✅ **Diseño arquitectónico perfecto**, alta escalabilidad, mayor valor de aprendizaje del código fuente #### 🎁 Características Adicionales - ✅ **Aplicación de escritorio descargadora de videos de redes sociales** (adecuada para aprender desarrollo full-stack) - ✅ **Recomendaciones de feed de página de inicio multiplataforma** (HomeFeed) - [ ] **Agente AI basado en análisis de comentarios está en desarrollo 🚀🚀** Haga clic para ver: [Página de Inicio del Proyecto MediaCrawlerPro](https://github.com/MediaCrawlerPro) para más información ## 🚀 Inicio Rápido > 💡 **¡El código abierto no es fácil, si este proyecto te ayuda, por favor da una ⭐ Estrella para apoyar!** ## 📋 Prerrequisitos ### 🚀 Instalación de uv (Recomendado) Antes de proceder con los siguientes pasos, por favor asegúrese de que uv esté instalado en su computadora: - **Guía de Instalación**: [Guía Oficial de Instalación de uv](https://docs.astral.sh/uv/getting-started/installation) - **Verificar Instalación**: Ingrese el comando `uv --version` en la terminal. Si el número de versión se muestra normalmente, la instalación fue exitosa - **Razón de Recomendación**: uv es actualmente la herramienta de gestión de paquetes Python más poderosa, con velocidad rápida y resolución de dependencias precisa ### 🟢 Instalación de Node.js El proyecto depende de Node.js, por favor descargue e instale desde el sitio web oficial: - **Enlace de Descarga**: https://nodejs.org/en/download/ - **Requisito de Versión**: >= 16.0.0 ### 📦 Instalación de Paquetes Python ```shell # Entrar al directorio del proyecto cd MediaCrawler # Usar el comando uv sync para asegurar la consistencia de la versión de python y paquetes de dependencias relacionados uv sync ``` ### 🌐 Instalación de Controlador de Navegador ```shell # Instalar controlador de navegador uv run playwright install ``` > **💡 Consejo**: MediaCrawler ahora soporta usar playwright para conectarse a su navegador Chrome local, resolviendo algunos problemas causados por Webdriver. > > Actualmente, `xhs` y `dy` están disponibles usando el modo CDP para conectarse a navegadores locales. Si es necesario, verifique los elementos de configuración en `config/base_config.py`. ## 🚀 Ejecutar Programa Rastreador ```shell # El proyecto no habilita el modo de rastreo de comentarios por defecto. Si necesita comentarios, por favor modifique la variable ENABLE_GET_COMMENTS en config/base_config.py # Otras opciones soportadas también pueden verse en config/base_config.py con comentarios en chino # Leer palabras clave del archivo de configuración para buscar publicaciones relacionadas y rastrear información de publicaciones y comentarios uv run main.py --platform xhs --lt qrcode --type search # Leer lista de ID de publicaciones específicas del archivo de configuración para obtener información e información de comentarios de publicaciones específicas uv run main.py --platform xhs --lt qrcode --type detail # Abrir la APP correspondiente para escanear código QR para login # Para ejemplos de uso de rastreador de otras plataformas, ejecute el siguiente comando para ver uv run main.py --help ``` ## Soporte WebUI
🖥️ Interfaz de Operación Visual WebUI MediaCrawler proporciona una interfaz de operación visual basada en web, permitiéndole usar fácilmente las funciones del rastreador sin línea de comandos. #### Iniciar Servicio WebUI ```shell # Iniciar servidor API (puerto predeterminado 8080) uv run uvicorn api.main:app --port 8080 --reload # O iniciar usando método de módulo uv run python -m api.main ``` Después de iniciar exitosamente, visite `http://localhost:8080` para abrir la interfaz WebUI. #### Características de WebUI - Configuración visual de parámetros del rastreador (plataforma, método de login, tipo de rastreo, etc.) - Vista en tiempo real del estado de ejecución del rastreador y logs - Vista previa y exportación de datos #### Vista Previa de la Interfaz Vista Previa de Interfaz WebUI
🔗 Usando gestión de entorno venv nativo de Python (No recomendado) #### Crear y activar entorno virtual de Python > Si rastrea Douyin y Zhihu, necesita instalar el entorno nodejs con anticipación, versión mayor o igual a: `16` ```shell # Entrar al directorio raíz del proyecto cd MediaCrawler # Crear entorno virtual # Mi versión de python es: 3.9.6, las librerías en requirements.txt están basadas en esta versión # Si usa otras versiones de python, las librerías en requirements.txt pueden no ser compatibles, por favor resuelva por su cuenta python -m venv venv # macOS & Linux activar entorno virtual source venv/bin/activate # Windows activar entorno virtual venv\Scripts\activate ``` #### Instalar librerías de dependencias ```shell pip install -r requirements.txt ``` #### Instalar controlador de navegador playwright ```shell playwright install ``` #### Ejecutar programa rastreador (entorno nativo) ```shell # El proyecto no habilita el modo de rastreo de comentarios por defecto. Si necesita comentarios, por favor modifique la variable ENABLE_GET_COMMENTS en config/base_config.py # Otras opciones soportadas también pueden verse en config/base_config.py con comentarios en chino # Leer palabras clave del archivo de configuración para buscar publicaciones relacionadas y rastrear información de publicaciones y comentarios python main.py --platform xhs --lt qrcode --type search # Leer lista de ID de publicaciones específicas del archivo de configuración para obtener información e información de comentarios de publicaciones específicas python main.py --platform xhs --lt qrcode --type detail # Abrir la APP correspondiente para escanear código QR para login # Para ejemplos de uso de rastreador de otras plataformas, ejecute el siguiente comando para ver python main.py --help ```
## 💾 Almacenamiento de Datos MediaCrawler soporta múltiples métodos de almacenamiento de datos, incluyendo CSV, JSON, JSONL, Excel, SQLite y bases de datos MySQL. 📖 **Para instrucciones de uso detalladas, por favor vea: [Guía de Almacenamiento de Datos](docs/data_storage_guide.md)** [🚀 ¡Lanzamiento Mayor de MediaCrawlerPro 🚀! ¡Más características, mejor diseño arquitectónico!](https://github.com/MediaCrawlerPro) ### 💬 Grupos de Discusión - **Grupo de Discusión WeChat**: [Haga clic para unirse](https://nanmicoder.github.io/MediaCrawler/%E5%BE%AE%E4%BF%A1%E4%BA%A4%E6%B5%81%E7%BE%A4.html) - **Cuenta de Bilibili**: [Sígueme](https://space.bilibili.com/434377496), compartiendo conocimientos de tecnología de IA y rastreo ### 💰 Exhibición de Patrocinadores
TikHub.io proporciona 900+ interfaces de datos altamente estables, cubriendo 14+ plataformas principales nacionales e internacionales incluyendo TK, DY, XHS, Y2B, Ins, X, etc. Soporta APIs de datos públicos multidimensionales para usuarios, contenido, productos, comentarios, etc., con 40M+ conjuntos de datos estructurados limpios. Use el código de invitación cfzyejV9 para registrarse y recargar, y obtenga $2 adicionales de bonificación.
--- ### 🤝 Conviértase en Patrocinador ¡Conviértase en patrocinador y muestre su producto aquí, obteniendo exposición masiva diariamente! **Información de Contacto**: - WeChat: `relakkes` - Email: `relakkes@gmail.com` --- ### 📚 Otros - **Preguntas Frecuentes**: [Documentación Completa de MediaCrawler](https://nanmicoder.github.io/MediaCrawler/) - **Tutorial de Rastreador para Principiantes**: [Tutorial Gratuito CrawlerTutorial](https://github.com/NanmiCoder/CrawlerTutorial) - **Proyecto de Código Abierto de Rastreador de Noticias**: [NewsCrawlerCollection](https://github.com/NanmiCoder/NewsCrawlerCollection) ## ⭐ Gráfico de Tendencia de Estrellas ¡Si este proyecto te ayuda, por favor da una ⭐ Estrella para apoyar y que más personas vean MediaCrawler! [![Star History Chart](https://api.star-history.com/svg?repos=NanmiCoder/MediaCrawler&type=Date)](https://star-history.com/#NanmiCoder/MediaCrawler&Date) ## 📚 Referencias - **Repositorio de Firma Xiaohongshu**: [Repositorio de firma xhs de Cloxl](https://github.com/Cloxl/xhshow) - **Cliente Xiaohongshu**: [Repositorio xhs de ReaJason](https://github.com/ReaJason/xhs) - **Reenvío de SMS**: [Repositorio de referencia SmsForwarder](https://github.com/pppscn/SmsForwarder) - **Herramienta de Penetración de Intranet**: [Documentación oficial de ngrok](https://ngrok.com/docs/) # Descargo de Responsabilidad
## 1. Propósito y Naturaleza del Proyecto Este proyecto (en adelante denominado "este proyecto") fue creado como una herramienta de investigación técnica y aprendizaje, con el objetivo de explorar y aprender tecnologías de recolección de datos de red. Este proyecto se enfoca en la investigación de tecnologías de rastreo de datos para plataformas de redes sociales, destinado a proporcionar a estudiantes e investigadores propósitos de intercambio técnico. ## 2. Declaración de Cumplimiento Legal El desarrollador del proyecto (en adelante denominado "desarrollador") recuerda solemnemente a los usuarios que cumplan estrictamente con las leyes y regulaciones relevantes de la República Popular China al descargar, instalar y usar este proyecto, incluyendo pero no limitado a la "Ley de Ciberseguridad de la República Popular China", "Ley de Contraespionaje de la República Popular China" y todas las leyes y políticas nacionales aplicables. Los usuarios deberán asumir todas las responsabilidades legales que puedan surgir del uso de este proyecto. ## 3. Restricciones de Propósito de Uso Este proyecto está estrictamente prohibido de ser utilizado para cualquier propósito ilegal o actividades comerciales que no sean de aprendizaje o investigación. Este proyecto no puede ser utilizado para ninguna forma de intrusión ilegal en sistemas informáticos de otras personas, ni puede ser utilizado para cualquier actividad que infrinja los derechos de propiedad intelectual de otros u otros derechos e intereses legítimos. Los usuarios deben asegurar que su uso de este proyecto sea puramente para aprendizaje personal e investigación técnica, y no puede ser utilizado para ninguna forma de actividades ilegales. ## 4. Descargo de Responsabilidad El desarrollador ha hecho todos los esfuerzos para asegurar la legitimidad y seguridad de este proyecto, pero no asume responsabilidad por ninguna forma de pérdidas directas o indirectas que puedan surgir del uso de este proyecto por parte de los usuarios. Incluyendo pero no limitado a cualquier pérdida de datos, daño de equipos, litigios legales, etc. causados por el uso de este proyecto. ## 5. Declaración de Propiedad Intelectual Los derechos de propiedad intelectual de este proyecto pertenecen al desarrollador. Este proyecto está protegido por la ley de derechos de autor y tratados internacionales de derechos de autor, así como otras leyes y tratados de propiedad intelectual. Los usuarios pueden descargar y usar este proyecto bajo la premisa de cumplir con esta declaración y las leyes y regulaciones relevantes. ## 6. Derechos de Interpretación Final El desarrollador tiene los derechos de interpretación final con respecto a este proyecto. El desarrollador se reserva el derecho de cambiar o actualizar este descargo de responsabilidad en cualquier momento sin previo aviso.
## 🙏 Agradecimientos ### Soporte de Licencia de Código Abierto de JetBrains ¡Gracias a JetBrains por proporcionar soporte de licencia de código abierto gratuito para este proyecto! JetBrains ================================================ FILE: api/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # WebUI API Module for MediaCrawler ================================================ FILE: api/main.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/main.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 """ MediaCrawler WebUI API Server Start command: uvicorn api.main:app --port 8080 --reload Or: python -m api.main """ import asyncio import os import subprocess import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse from .routers import crawler_router, data_router, websocket_router app = FastAPI( title="MediaCrawler WebUI API", description="API for controlling MediaCrawler from WebUI", version="1.0.0" ) # Get webui static files directory WEBUI_DIR = os.path.join(os.path.dirname(__file__), "webui") # CORS configuration - allow frontend dev server access app.add_middleware( CORSMiddleware, allow_origins=[ "http://localhost:5173", # Vite dev server "http://localhost:3000", # Backup port "http://127.0.0.1:5173", "http://127.0.0.1:3000", ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Register routers app.include_router(crawler_router, prefix="/api") app.include_router(data_router, prefix="/api") app.include_router(websocket_router, prefix="/api") @app.get("/") async def serve_frontend(): """Return frontend page""" index_path = os.path.join(WEBUI_DIR, "index.html") if os.path.exists(index_path): return FileResponse(index_path) return { "message": "MediaCrawler WebUI API", "version": "1.0.0", "docs": "/docs", "note": "WebUI not found, please build it first: cd webui && npm run build" } @app.get("/api/health") async def health_check(): return {"status": "ok"} @app.get("/api/env/check") async def check_environment(): """Check if MediaCrawler environment is configured correctly""" try: # Run uv run main.py --help command to check environment process = await asyncio.create_subprocess_exec( "uv", "run", "main.py", "--help", stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd="." # Project root directory ) stdout, stderr = await asyncio.wait_for( process.communicate(), timeout=30.0 # 30 seconds timeout ) if process.returncode == 0: return { "success": True, "message": "MediaCrawler environment configured correctly", "output": stdout.decode("utf-8", errors="ignore")[:500] # Truncate to first 500 characters } else: error_msg = stderr.decode("utf-8", errors="ignore") or stdout.decode("utf-8", errors="ignore") return { "success": False, "message": "Environment check failed", "error": error_msg[:500] } except asyncio.TimeoutError: return { "success": False, "message": "Environment check timeout", "error": "Command execution exceeded 30 seconds" } except FileNotFoundError: return { "success": False, "message": "uv command not found", "error": "Please ensure uv is installed and configured in system PATH" } except Exception as e: return { "success": False, "message": "Environment check error", "error": str(e) } @app.get("/api/config/platforms") async def get_platforms(): """Get list of supported platforms""" return { "platforms": [ {"value": "xhs", "label": "Xiaohongshu", "icon": "book-open"}, {"value": "dy", "label": "Douyin", "icon": "music"}, {"value": "ks", "label": "Kuaishou", "icon": "video"}, {"value": "bili", "label": "Bilibili", "icon": "tv"}, {"value": "wb", "label": "Weibo", "icon": "message-circle"}, {"value": "tieba", "label": "Baidu Tieba", "icon": "messages-square"}, {"value": "zhihu", "label": "Zhihu", "icon": "help-circle"}, ] } @app.get("/api/config/options") async def get_config_options(): """Get all configuration options""" return { "login_types": [ {"value": "qrcode", "label": "QR Code Login"}, {"value": "cookie", "label": "Cookie Login"}, ], "crawler_types": [ {"value": "search", "label": "Search Mode"}, {"value": "detail", "label": "Detail Mode"}, {"value": "creator", "label": "Creator Mode"}, ], "save_options": [ {"value": "jsonl", "label": "JSONL File"}, {"value": "json", "label": "JSON File"}, {"value": "csv", "label": "CSV File"}, {"value": "excel", "label": "Excel File"}, {"value": "sqlite", "label": "SQLite Database"}, {"value": "db", "label": "MySQL Database"}, {"value": "mongodb", "label": "MongoDB Database"}, ], } # Mount static resources - must be placed after all routes if os.path.exists(WEBUI_DIR): assets_dir = os.path.join(WEBUI_DIR, "assets") if os.path.exists(assets_dir): app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") # Mount logos directory logos_dir = os.path.join(WEBUI_DIR, "logos") if os.path.exists(logos_dir): app.mount("/logos", StaticFiles(directory=logos_dir), name="logos") # Mount other static files (e.g., vite.svg) app.mount("/static", StaticFiles(directory=WEBUI_DIR), name="webui-static") if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8080) ================================================ FILE: api/routers/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/routers/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from .crawler import router as crawler_router from .data import router as data_router from .websocket import router as websocket_router __all__ = ["crawler_router", "data_router", "websocket_router"] ================================================ FILE: api/routers/crawler.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/routers/crawler.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from fastapi import APIRouter, HTTPException from ..schemas import CrawlerStartRequest, CrawlerStatusResponse from ..services import crawler_manager router = APIRouter(prefix="/crawler", tags=["crawler"]) @router.post("/start") async def start_crawler(request: CrawlerStartRequest): """Start crawler task""" success = await crawler_manager.start(request) if not success: # Handle concurrent/duplicate requests: if process is already running, return 400 instead of 500 if crawler_manager.process and crawler_manager.process.poll() is None: raise HTTPException(status_code=400, detail="Crawler is already running") raise HTTPException(status_code=500, detail="Failed to start crawler") return {"status": "ok", "message": "Crawler started successfully"} @router.post("/stop") async def stop_crawler(): """Stop crawler task""" success = await crawler_manager.stop() if not success: # Handle concurrent/duplicate requests: if process already exited/doesn't exist, return 400 instead of 500 if not crawler_manager.process or crawler_manager.process.poll() is not None: raise HTTPException(status_code=400, detail="No crawler is running") raise HTTPException(status_code=500, detail="Failed to stop crawler") return {"status": "ok", "message": "Crawler stopped successfully"} @router.get("/status", response_model=CrawlerStatusResponse) async def get_crawler_status(): """Get crawler status""" return crawler_manager.get_status() @router.get("/logs") async def get_logs(limit: int = 100): """Get recent logs""" logs = crawler_manager.logs[-limit:] if limit > 0 else crawler_manager.logs return {"logs": [log.model_dump() for log in logs]} ================================================ FILE: api/routers/data.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/routers/data.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import os import json from pathlib import Path from typing import Optional from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse router = APIRouter(prefix="/data", tags=["data"]) # Data directory DATA_DIR = Path(__file__).parent.parent.parent / "data" def get_file_info(file_path: Path) -> dict: """Get file information""" stat = file_path.stat() record_count = None # Try to get record count try: if file_path.suffix == ".json": with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, list): record_count = len(data) elif file_path.suffix == ".csv": with open(file_path, "r", encoding="utf-8") as f: record_count = sum(1 for _ in f) - 1 # Subtract header row except Exception: pass return { "name": file_path.name, "path": str(file_path.relative_to(DATA_DIR)), "size": stat.st_size, "modified_at": stat.st_mtime, "record_count": record_count, "type": file_path.suffix[1:] if file_path.suffix else "unknown" } @router.get("/files") async def list_data_files(platform: Optional[str] = None, file_type: Optional[str] = None): """Get data file list""" if not DATA_DIR.exists(): return {"files": []} files = [] supported_extensions = {".json", ".csv", ".xlsx", ".xls"} for root, dirs, filenames in os.walk(DATA_DIR): root_path = Path(root) for filename in filenames: file_path = root_path / filename if file_path.suffix.lower() not in supported_extensions: continue # Platform filter if platform: rel_path = str(file_path.relative_to(DATA_DIR)) if platform.lower() not in rel_path.lower(): continue # Type filter if file_type and file_path.suffix[1:].lower() != file_type.lower(): continue try: files.append(get_file_info(file_path)) except Exception: continue # Sort by modification time (newest first) files.sort(key=lambda x: x["modified_at"], reverse=True) return {"files": files} @router.get("/files/{file_path:path}") async def get_file_content(file_path: str, preview: bool = True, limit: int = 100): """Get file content or preview""" full_path = DATA_DIR / file_path if not full_path.exists(): raise HTTPException(status_code=404, detail="File not found") if not full_path.is_file(): raise HTTPException(status_code=400, detail="Not a file") # Security check: ensure within DATA_DIR try: full_path.resolve().relative_to(DATA_DIR.resolve()) except ValueError: raise HTTPException(status_code=403, detail="Access denied") if preview: # Return preview data try: if full_path.suffix == ".json": with open(full_path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, list): return {"data": data[:limit], "total": len(data)} return {"data": data, "total": 1} elif full_path.suffix == ".csv": import csv with open(full_path, "r", encoding="utf-8") as f: reader = csv.DictReader(f) rows = [] for i, row in enumerate(reader): if i >= limit: break rows.append(row) # Re-read to get total count f.seek(0) total = sum(1 for _ in f) - 1 return {"data": rows, "total": total} elif full_path.suffix.lower() in (".xlsx", ".xls"): import pandas as pd # Read first limit rows df = pd.read_excel(full_path, nrows=limit) # Get total row count (only read first column to save memory) df_count = pd.read_excel(full_path, usecols=[0]) total = len(df_count) # Convert to list of dictionaries, handle NaN values rows = df.where(pd.notnull(df), None).to_dict(orient='records') return { "data": rows, "total": total, "columns": list(df.columns) } else: raise HTTPException(status_code=400, detail="Unsupported file type for preview") except json.JSONDecodeError: raise HTTPException(status_code=400, detail="Invalid JSON file") except Exception as e: raise HTTPException(status_code=500, detail=str(e)) else: # Return file download return FileResponse( path=full_path, filename=full_path.name, media_type="application/octet-stream" ) @router.get("/download/{file_path:path}") async def download_file(file_path: str): """Download file""" full_path = DATA_DIR / file_path if not full_path.exists(): raise HTTPException(status_code=404, detail="File not found") if not full_path.is_file(): raise HTTPException(status_code=400, detail="Not a file") # Security check try: full_path.resolve().relative_to(DATA_DIR.resolve()) except ValueError: raise HTTPException(status_code=403, detail="Access denied") return FileResponse( path=full_path, filename=full_path.name, media_type="application/octet-stream" ) @router.get("/stats") async def get_data_stats(): """Get data statistics""" if not DATA_DIR.exists(): return {"total_files": 0, "total_size": 0, "by_platform": {}, "by_type": {}} stats = { "total_files": 0, "total_size": 0, "by_platform": {}, "by_type": {} } supported_extensions = {".json", ".csv", ".xlsx", ".xls"} for root, dirs, filenames in os.walk(DATA_DIR): root_path = Path(root) for filename in filenames: file_path = root_path / filename if file_path.suffix.lower() not in supported_extensions: continue try: stat = file_path.stat() stats["total_files"] += 1 stats["total_size"] += stat.st_size # Statistics by type file_type = file_path.suffix[1:].lower() stats["by_type"][file_type] = stats["by_type"].get(file_type, 0) + 1 # Statistics by platform (inferred from path) rel_path = str(file_path.relative_to(DATA_DIR)) for platform in ["xhs", "dy", "ks", "bili", "wb", "tieba", "zhihu"]: if platform in rel_path.lower(): stats["by_platform"][platform] = stats["by_platform"].get(platform, 0) + 1 break except Exception: continue return stats ================================================ FILE: api/routers/websocket.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/routers/websocket.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import asyncio from typing import Set, Optional from fastapi import APIRouter, WebSocket, WebSocketDisconnect from ..services import crawler_manager router = APIRouter(tags=["websocket"]) class ConnectionManager: """WebSocket connection manager""" def __init__(self): self.active_connections: Set[WebSocket] = set() async def connect(self, websocket: WebSocket): await websocket.accept() self.active_connections.add(websocket) def disconnect(self, websocket: WebSocket): self.active_connections.discard(websocket) async def broadcast(self, message: dict): """Broadcast message to all connections""" if not self.active_connections: return disconnected = [] for connection in list(self.active_connections): try: await connection.send_json(message) except Exception: disconnected.append(connection) # Clean up disconnected connections for conn in disconnected: self.disconnect(conn) manager = ConnectionManager() async def log_broadcaster(): """Background task: read logs from queue and broadcast""" queue = crawler_manager.get_log_queue() while True: try: # Get log entry from queue entry = await queue.get() # Broadcast to all WebSocket connections await manager.broadcast(entry.model_dump()) except asyncio.CancelledError: break except Exception as e: print(f"Log broadcaster error: {e}") await asyncio.sleep(0.1) # Global broadcast task _broadcaster_task: Optional[asyncio.Task] = None def start_broadcaster(): """Start broadcast task""" global _broadcaster_task if _broadcaster_task is None or _broadcaster_task.done(): _broadcaster_task = asyncio.create_task(log_broadcaster()) @router.websocket("/ws/logs") async def websocket_logs(websocket: WebSocket): """WebSocket log stream""" print("[WS] New connection attempt") try: # Ensure broadcast task is running start_broadcaster() await manager.connect(websocket) print(f"[WS] Connected, active connections: {len(manager.active_connections)}") # Send existing logs for log in crawler_manager.logs: try: await websocket.send_json(log.model_dump()) except Exception as e: print(f"[WS] Error sending existing log: {e}") break print(f"[WS] Sent {len(crawler_manager.logs)} existing logs, entering main loop") while True: # Keep connection alive, receive heartbeat or any message try: data = await asyncio.wait_for( websocket.receive_text(), timeout=30.0 ) if data == "ping": await websocket.send_text("pong") except asyncio.TimeoutError: # Send ping to keep connection alive try: await websocket.send_text("ping") except Exception as e: print(f"[WS] Error sending ping: {e}") break except WebSocketDisconnect: print("[WS] Client disconnected") except Exception as e: print(f"[WS] Error: {type(e).__name__}: {e}") finally: manager.disconnect(websocket) print(f"[WS] Cleanup done, active connections: {len(manager.active_connections)}") @router.websocket("/ws/status") async def websocket_status(websocket: WebSocket): """WebSocket status stream""" await websocket.accept() try: while True: # Send status every second status = crawler_manager.get_status() await websocket.send_json(status) await asyncio.sleep(1) except WebSocketDisconnect: pass except Exception: pass ================================================ FILE: api/schemas/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/schemas/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from .crawler import ( PlatformEnum, LoginTypeEnum, CrawlerTypeEnum, SaveDataOptionEnum, CrawlerStartRequest, CrawlerStatusResponse, LogEntry, ) __all__ = [ "PlatformEnum", "LoginTypeEnum", "CrawlerTypeEnum", "SaveDataOptionEnum", "CrawlerStartRequest", "CrawlerStatusResponse", "LogEntry", ] ================================================ FILE: api/schemas/crawler.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/schemas/crawler.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from enum import Enum from typing import Optional, Literal from pydantic import BaseModel class PlatformEnum(str, Enum): """Supported media platforms""" XHS = "xhs" DOUYIN = "dy" KUAISHOU = "ks" BILIBILI = "bili" WEIBO = "wb" TIEBA = "tieba" ZHIHU = "zhihu" class LoginTypeEnum(str, Enum): """Login method""" QRCODE = "qrcode" PHONE = "phone" COOKIE = "cookie" class CrawlerTypeEnum(str, Enum): """Crawler type""" SEARCH = "search" DETAIL = "detail" CREATOR = "creator" class SaveDataOptionEnum(str, Enum): """Data save option""" CSV = "csv" DB = "db" JSON = "json" JSONL = "jsonl" SQLITE = "sqlite" MONGODB = "mongodb" EXCEL = "excel" class CrawlerStartRequest(BaseModel): """Crawler start request""" platform: PlatformEnum login_type: LoginTypeEnum = LoginTypeEnum.QRCODE crawler_type: CrawlerTypeEnum = CrawlerTypeEnum.SEARCH keywords: str = "" # Keywords for search mode specified_ids: str = "" # Post/video ID list for detail mode, comma-separated creator_ids: str = "" # Creator ID list for creator mode, comma-separated start_page: int = 1 enable_comments: bool = True enable_sub_comments: bool = False save_option: SaveDataOptionEnum = SaveDataOptionEnum.JSONL cookies: str = "" headless: bool = False class CrawlerStatusResponse(BaseModel): """Crawler status response""" status: Literal["idle", "running", "stopping", "error"] platform: Optional[str] = None crawler_type: Optional[str] = None started_at: Optional[str] = None error_message: Optional[str] = None class LogEntry(BaseModel): """Log entry""" id: int timestamp: str level: Literal["info", "warning", "error", "success", "debug"] message: str class DataFileInfo(BaseModel): """Data file information""" name: str path: str size: int modified_at: str record_count: Optional[int] = None ================================================ FILE: api/services/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/services/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from .crawler_manager import CrawlerManager, crawler_manager __all__ = ["CrawlerManager", "crawler_manager"] ================================================ FILE: api/services/crawler_manager.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/services/crawler_manager.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import asyncio import subprocess import signal import os from typing import Optional, List from datetime import datetime from pathlib import Path from ..schemas import CrawlerStartRequest, LogEntry class CrawlerManager: """Crawler process manager""" def __init__(self): self._lock = asyncio.Lock() self.process: Optional[subprocess.Popen] = None self.status = "idle" self.started_at: Optional[datetime] = None self.current_config: Optional[CrawlerStartRequest] = None self._log_id = 0 self._logs: List[LogEntry] = [] self._read_task: Optional[asyncio.Task] = None # Project root directory self._project_root = Path(__file__).parent.parent.parent # Log queue - for pushing to WebSocket self._log_queue: Optional[asyncio.Queue] = None @property def logs(self) -> List[LogEntry]: return self._logs def get_log_queue(self) -> asyncio.Queue: """Get or create log queue""" if self._log_queue is None: self._log_queue = asyncio.Queue() return self._log_queue def _create_log_entry(self, message: str, level: str = "info") -> LogEntry: """Create log entry""" self._log_id += 1 entry = LogEntry( id=self._log_id, timestamp=datetime.now().strftime("%H:%M:%S"), level=level, message=message ) self._logs.append(entry) # Keep last 500 logs if len(self._logs) > 500: self._logs = self._logs[-500:] return entry async def _push_log(self, entry: LogEntry): """Push log to queue""" if self._log_queue is not None: try: self._log_queue.put_nowait(entry) except asyncio.QueueFull: pass def _parse_log_level(self, line: str) -> str: """Parse log level""" line_upper = line.upper() if "ERROR" in line_upper or "FAILED" in line_upper: return "error" elif "WARNING" in line_upper or "WARN" in line_upper: return "warning" elif "SUCCESS" in line_upper or "完成" in line or "成功" in line: return "success" elif "DEBUG" in line_upper: return "debug" return "info" async def start(self, config: CrawlerStartRequest) -> bool: """Start crawler process""" async with self._lock: if self.process and self.process.poll() is None: return False # Clear old logs self._logs = [] self._log_id = 0 # Clear pending queue (don't replace object to avoid WebSocket broadcast coroutine holding old queue reference) if self._log_queue is None: self._log_queue = asyncio.Queue() else: try: while True: self._log_queue.get_nowait() except asyncio.QueueEmpty: pass # Build command line arguments cmd = self._build_command(config) # Log start information entry = self._create_log_entry(f"Starting crawler: {' '.join(cmd)}", "info") await self._push_log(entry) try: # Start subprocess self.process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', bufsize=1, cwd=str(self._project_root), env={**os.environ, "PYTHONUNBUFFERED": "1"} ) self.status = "running" self.started_at = datetime.now() self.current_config = config entry = self._create_log_entry( f"Crawler started on platform: {config.platform.value}, type: {config.crawler_type.value}", "success" ) await self._push_log(entry) # Start log reading task self._read_task = asyncio.create_task(self._read_output()) return True except Exception as e: self.status = "error" entry = self._create_log_entry(f"Failed to start crawler: {str(e)}", "error") await self._push_log(entry) return False async def stop(self) -> bool: """Stop crawler process""" async with self._lock: if not self.process or self.process.poll() is not None: return False self.status = "stopping" entry = self._create_log_entry("Sending SIGTERM to crawler process...", "warning") await self._push_log(entry) try: self.process.send_signal(signal.SIGTERM) # Wait for graceful exit (up to 15 seconds) for _ in range(30): if self.process.poll() is not None: break await asyncio.sleep(0.5) # If still not exited, force kill if self.process.poll() is None: entry = self._create_log_entry("Process not responding, sending SIGKILL...", "warning") await self._push_log(entry) self.process.kill() entry = self._create_log_entry("Crawler process terminated", "info") await self._push_log(entry) except Exception as e: entry = self._create_log_entry(f"Error stopping crawler: {str(e)}", "error") await self._push_log(entry) self.status = "idle" self.current_config = None # Cancel log reading task if self._read_task: self._read_task.cancel() self._read_task = None return True def get_status(self) -> dict: """Get current status""" return { "status": self.status, "platform": self.current_config.platform.value if self.current_config else None, "crawler_type": self.current_config.crawler_type.value if self.current_config else None, "started_at": self.started_at.isoformat() if self.started_at else None, "error_message": None } def _build_command(self, config: CrawlerStartRequest) -> list: """Build main.py command line arguments""" cmd = ["uv", "run", "python", "main.py"] cmd.extend(["--platform", config.platform.value]) cmd.extend(["--lt", config.login_type.value]) cmd.extend(["--type", config.crawler_type.value]) cmd.extend(["--save_data_option", config.save_option.value]) # Pass different arguments based on crawler type if config.crawler_type.value == "search" and config.keywords: cmd.extend(["--keywords", config.keywords]) elif config.crawler_type.value == "detail" and config.specified_ids: cmd.extend(["--specified_id", config.specified_ids]) elif config.crawler_type.value == "creator" and config.creator_ids: cmd.extend(["--creator_id", config.creator_ids]) if config.start_page != 1: cmd.extend(["--start", str(config.start_page)]) cmd.extend(["--get_comment", "true" if config.enable_comments else "false"]) cmd.extend(["--get_sub_comment", "true" if config.enable_sub_comments else "false"]) if config.cookies: cmd.extend(["--cookies", config.cookies]) cmd.extend(["--headless", "true" if config.headless else "false"]) return cmd async def _read_output(self): """Asynchronously read process output""" loop = asyncio.get_event_loop() try: while self.process and self.process.poll() is None: # Read a line in thread pool line = await loop.run_in_executor( None, self.process.stdout.readline ) if line: line = line.strip() if line: level = self._parse_log_level(line) entry = self._create_log_entry(line, level) await self._push_log(entry) # Read remaining output if self.process and self.process.stdout: remaining = await loop.run_in_executor( None, self.process.stdout.read ) if remaining: for line in remaining.strip().split('\n'): if line.strip(): level = self._parse_log_level(line) entry = self._create_log_entry(line.strip(), level) await self._push_log(entry) # Process ended if self.status == "running": exit_code = self.process.returncode if self.process else -1 if exit_code == 0: entry = self._create_log_entry("Crawler completed successfully", "success") else: entry = self._create_log_entry(f"Crawler exited with code: {exit_code}", "warning") await self._push_log(entry) self.status = "idle" except asyncio.CancelledError: pass except Exception as e: entry = self._create_log_entry(f"Error reading output: {str(e)}", "error") await self._push_log(entry) # Global singleton crawler_manager = CrawlerManager() ================================================ FILE: api/webui/assets/index-DvClRayq.js ================================================ var $m=t=>{throw TypeError(t)};var ld=(t,e,r)=>e.has(t)||$m("Cannot "+r);var R=(t,e,r)=>(ld(t,e,"read from private field"),r?r.call(t):e.get(t)),ve=(t,e,r)=>e.has(t)?$m("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(t):e.set(t,r),ce=(t,e,r,s)=>(ld(t,e,"write to private field"),s?s.call(t,r):e.set(t,r),r),Te=(t,e,r)=>(ld(t,e,"access private method"),r);var Sl=(t,e,r,s)=>({set _(i){ce(t,e,i,r)},get _(){return R(t,e,s)}});function t1(t,e){for(var r=0;rs[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))s(i);new MutationObserver(i=>{for(const l of i)if(l.type==="childList")for(const u of l.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&s(u)}).observe(document,{childList:!0,subtree:!0});function r(i){const l={};return i.integrity&&(l.integrity=i.integrity),i.referrerPolicy&&(l.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?l.credentials="include":i.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function s(i){if(i.ep)return;i.ep=!0;const l=r(i);fetch(i.href,l)}})();function bf(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var cd={exports:{}},Di={},ud={exports:{}},je={};/** * @license React * react.production.min.js * * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */var Um;function n1(){if(Um)return je;Um=1;var t=Symbol.for("react.element"),e=Symbol.for("react.portal"),r=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),l=Symbol.for("react.provider"),u=Symbol.for("react.context"),d=Symbol.for("react.forward_ref"),h=Symbol.for("react.suspense"),p=Symbol.for("react.memo"),y=Symbol.for("react.lazy"),v=Symbol.iterator;function C(P){return P===null||typeof P!="object"?null:(P=v&&P[v]||P["@@iterator"],typeof P=="function"?P:null)}var w={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},E=Object.assign,b={};function k(P,M,ie){this.props=P,this.context=M,this.refs=b,this.updater=ie||w}k.prototype.isReactComponent={},k.prototype.setState=function(P,M){if(typeof P!="object"&&typeof P!="function"&&P!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,P,M,"setState")},k.prototype.forceUpdate=function(P){this.updater.enqueueForceUpdate(this,P,"forceUpdate")};function T(){}T.prototype=k.prototype;function j(P,M,ie){this.props=P,this.context=M,this.refs=b,this.updater=ie||w}var _=j.prototype=new T;_.constructor=j,E(_,k.prototype),_.isPureReactComponent=!0;var A=Array.isArray,F=Object.prototype.hasOwnProperty,V={current:null},B={key:!0,ref:!0,__self:!0,__source:!0};function te(P,M,ie){var ae,me={},be=null,ee=null;if(M!=null)for(ae in M.ref!==void 0&&(ee=M.ref),M.key!==void 0&&(be=""+M.key),M)F.call(M,ae)&&!B.hasOwnProperty(ae)&&(me[ae]=M[ae]);var ye=arguments.length-2;if(ye===1)me.children=ie;else if(1>>1,M=$[P];if(0>>1;Pi(me,Q))bei(ee,me)?($[P]=ee,$[be]=Q,P=be):($[P]=me,$[ae]=Q,P=ae);else if(bei(ee,Q))$[P]=ee,$[be]=Q,P=be;else break e}}return H}function i($,H){var Q=$.sortIndex-H.sortIndex;return Q!==0?Q:$.id-H.id}if(typeof performance=="object"&&typeof performance.now=="function"){var l=performance;t.unstable_now=function(){return l.now()}}else{var u=Date,d=u.now();t.unstable_now=function(){return u.now()-d}}var h=[],p=[],y=1,v=null,C=3,w=!1,E=!1,b=!1,k=typeof setTimeout=="function"?setTimeout:null,T=typeof clearTimeout=="function"?clearTimeout:null,j=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function _($){for(var H=r(p);H!==null;){if(H.callback===null)s(p);else if(H.startTime<=$)s(p),H.sortIndex=H.expirationTime,e(h,H);else break;H=r(p)}}function A($){if(b=!1,_($),!E)if(r(h)!==null)E=!0,ne(F);else{var H=r(p);H!==null&&se(A,H.startTime-$)}}function F($,H){E=!1,b&&(b=!1,T(te),te=-1),w=!0;var Q=C;try{for(_(H),v=r(h);v!==null&&(!(v.expirationTime>H)||$&&!le());){var P=v.callback;if(typeof P=="function"){v.callback=null,C=v.priorityLevel;var M=P(v.expirationTime<=H);H=t.unstable_now(),typeof M=="function"?v.callback=M:v===r(h)&&s(h),_(H)}else s(h);v=r(h)}if(v!==null)var ie=!0;else{var ae=r(p);ae!==null&&se(A,ae.startTime-H),ie=!1}return ie}finally{v=null,C=Q,w=!1}}var V=!1,B=null,te=-1,G=5,W=-1;function le(){return!(t.unstable_now()-W$||125<$?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):G=0<$?Math.floor(1e3/$):5},t.unstable_getCurrentPriorityLevel=function(){return C},t.unstable_getFirstCallbackNode=function(){return r(h)},t.unstable_next=function($){switch(C){case 1:case 2:case 3:var H=3;break;default:H=C}var Q=C;C=H;try{return $()}finally{C=Q}},t.unstable_pauseExecution=function(){},t.unstable_requestPaint=function(){},t.unstable_runWithPriority=function($,H){switch($){case 1:case 2:case 3:case 4:case 5:break;default:$=3}var Q=C;C=$;try{return H()}finally{C=Q}},t.unstable_scheduleCallback=function($,H,Q){var P=t.unstable_now();switch(typeof Q=="object"&&Q!==null?(Q=Q.delay,Q=typeof Q=="number"&&0P?($.sortIndex=Q,e(p,$),r(h)===null&&$===r(p)&&(b?(T(te),te=-1):b=!0,se(A,Q-P))):($.sortIndex=M,e(h,$),E||w||(E=!0,ne(F))),$},t.unstable_shouldYield=le,t.unstable_wrapCallback=function($){var H=C;return function(){var Q=C;C=H;try{return $.apply(this,arguments)}finally{C=Q}}}})(hd)),hd}var Km;function i1(){return Km||(Km=1,fd.exports=s1()),fd.exports}/** * @license React * react-dom.production.min.js * * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */var qm;function a1(){if(qm)return $t;qm=1;var t=ac(),e=i1();function r(n){for(var o="https://reactjs.org/docs/error-decoder.html?invariant="+n,a=1;a"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),h=Object.prototype.hasOwnProperty,p=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,y={},v={};function C(n){return h.call(v,n)?!0:h.call(y,n)?!1:p.test(n)?v[n]=!0:(y[n]=!0,!1)}function w(n,o,a,c){if(a!==null&&a.type===0)return!1;switch(typeof o){case"function":case"symbol":return!0;case"boolean":return c?!1:a!==null?!a.acceptsBooleans:(n=n.toLowerCase().slice(0,5),n!=="data-"&&n!=="aria-");default:return!1}}function E(n,o,a,c){if(o===null||typeof o>"u"||w(n,o,a,c))return!0;if(c)return!1;if(a!==null)switch(a.type){case 3:return!o;case 4:return o===!1;case 5:return isNaN(o);case 6:return isNaN(o)||1>o}return!1}function b(n,o,a,c,f,m,S){this.acceptsBooleans=o===2||o===3||o===4,this.attributeName=c,this.attributeNamespace=f,this.mustUseProperty=a,this.propertyName=n,this.type=o,this.sanitizeURL=m,this.removeEmptyString=S}var k={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(n){k[n]=new b(n,0,!1,n,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(n){var o=n[0];k[o]=new b(o,1,!1,n[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(n){k[n]=new b(n,2,!1,n.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(n){k[n]=new b(n,2,!1,n,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(n){k[n]=new b(n,3,!1,n.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(n){k[n]=new b(n,3,!0,n,null,!1,!1)}),["capture","download"].forEach(function(n){k[n]=new b(n,4,!1,n,null,!1,!1)}),["cols","rows","size","span"].forEach(function(n){k[n]=new b(n,6,!1,n,null,!1,!1)}),["rowSpan","start"].forEach(function(n){k[n]=new b(n,5,!1,n.toLowerCase(),null,!1,!1)});var T=/[\-:]([a-z])/g;function j(n){return n[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(n){var o=n.replace(T,j);k[o]=new b(o,1,!1,n,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(n){var o=n.replace(T,j);k[o]=new b(o,1,!1,n,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(n){var o=n.replace(T,j);k[o]=new b(o,1,!1,n,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(n){k[n]=new b(n,1,!1,n.toLowerCase(),null,!1,!1)}),k.xlinkHref=new b("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(n){k[n]=new b(n,1,!1,n.toLowerCase(),null,!0,!0)});function _(n,o,a,c){var f=k.hasOwnProperty(o)?k[o]:null;(f!==null?f.type!==0:c||!(2N||f[S]!==m[N]){var O=` `+f[S].replace(" at new "," at ");return n.displayName&&O.includes("")&&(O=O.replace("",n.displayName)),O}while(1<=S&&0<=N);break}}}finally{ie=!1,Error.prepareStackTrace=a}return(n=n?n.displayName||n.name:"")?M(n):""}function me(n){switch(n.tag){case 5:return M(n.type);case 16:return M("Lazy");case 13:return M("Suspense");case 19:return M("SuspenseList");case 0:case 2:case 15:return n=ae(n.type,!1),n;case 11:return n=ae(n.type.render,!1),n;case 1:return n=ae(n.type,!0),n;default:return""}}function be(n){if(n==null)return null;if(typeof n=="function")return n.displayName||n.name||null;if(typeof n=="string")return n;switch(n){case B:return"Fragment";case V:return"Portal";case G:return"Profiler";case te:return"StrictMode";case Z:return"Suspense";case J:return"SuspenseList"}if(typeof n=="object")switch(n.$$typeof){case le:return(n.displayName||"Context")+".Consumer";case W:return(n._context.displayName||"Context")+".Provider";case K:var o=n.render;return n=n.displayName,n||(n=o.displayName||o.name||"",n=n!==""?"ForwardRef("+n+")":"ForwardRef"),n;case de:return o=n.displayName||null,o!==null?o:be(n.type)||"Memo";case ne:o=n._payload,n=n._init;try{return be(n(o))}catch{}}return null}function ee(n){var o=n.type;switch(n.tag){case 24:return"Cache";case 9:return(o.displayName||"Context")+".Consumer";case 10:return(o._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return n=o.render,n=n.displayName||n.name||"",o.displayName||(n!==""?"ForwardRef("+n+")":"ForwardRef");case 7:return"Fragment";case 5:return o;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return be(o);case 8:return o===te?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof o=="function")return o.displayName||o.name||null;if(typeof o=="string")return o}return null}function ye(n){switch(typeof n){case"boolean":case"number":case"string":case"undefined":return n;case"object":return n;default:return""}}function Se(n){var o=n.type;return(n=n.nodeName)&&n.toLowerCase()==="input"&&(o==="checkbox"||o==="radio")}function Ne(n){var o=Se(n)?"checked":"value",a=Object.getOwnPropertyDescriptor(n.constructor.prototype,o),c=""+n[o];if(!n.hasOwnProperty(o)&&typeof a<"u"&&typeof a.get=="function"&&typeof a.set=="function"){var f=a.get,m=a.set;return Object.defineProperty(n,o,{configurable:!0,get:function(){return f.call(this)},set:function(S){c=""+S,m.call(this,S)}}),Object.defineProperty(n,o,{enumerable:a.enumerable}),{getValue:function(){return c},setValue:function(S){c=""+S},stopTracking:function(){n._valueTracker=null,delete n[o]}}}}function Oe(n){n._valueTracker||(n._valueTracker=Ne(n))}function _e(n){if(!n)return!1;var o=n._valueTracker;if(!o)return!0;var a=o.getValue(),c="";return n&&(c=Se(n)?n.checked?"true":"false":n.value),n=c,n!==a?(o.setValue(n),!0):!1}function et(n){if(n=n||(typeof document<"u"?document:void 0),typeof n>"u")return null;try{return n.activeElement||n.body}catch{return n.body}}function gt(n,o){var a=o.checked;return Q({},o,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:a??n._wrapperState.initialChecked})}function On(n,o){var a=o.defaultValue==null?"":o.defaultValue,c=o.checked!=null?o.checked:o.defaultChecked;a=ye(o.value!=null?o.value:a),n._wrapperState={initialChecked:c,initialValue:a,controlled:o.type==="checkbox"||o.type==="radio"?o.checked!=null:o.value!=null}}function dn(n,o){o=o.checked,o!=null&&_(n,"checked",o,!1)}function fn(n,o){dn(n,o);var a=ye(o.value),c=o.type;if(a!=null)c==="number"?(a===0&&n.value===""||n.value!=a)&&(n.value=""+a):n.value!==""+a&&(n.value=""+a);else if(c==="submit"||c==="reset"){n.removeAttribute("value");return}o.hasOwnProperty("value")?jn(n,o.type,a):o.hasOwnProperty("defaultValue")&&jn(n,o.type,ye(o.defaultValue)),o.checked==null&&o.defaultChecked!=null&&(n.defaultChecked=!!o.defaultChecked)}function wr(n,o,a){if(o.hasOwnProperty("value")||o.hasOwnProperty("defaultValue")){var c=o.type;if(!(c!=="submit"&&c!=="reset"||o.value!==void 0&&o.value!==null))return;o=""+n._wrapperState.initialValue,a||o===n.value||(n.value=o),n.defaultValue=o}a=n.name,a!==""&&(n.name=""),n.defaultChecked=!!n._wrapperState.initialChecked,a!==""&&(n.name=a)}function jn(n,o,a){(o!=="number"||et(n.ownerDocument)!==n)&&(a==null?n.defaultValue=""+n._wrapperState.initialValue:n.defaultValue!==""+a&&(n.defaultValue=""+a))}var br=Array.isArray;function en(n,o,a,c){if(n=n.options,o){o={};for(var f=0;f"+o.valueOf().toString()+"",o=qo.firstChild;n.firstChild;)n.removeChild(n.firstChild);for(;o.firstChild;)n.appendChild(o.firstChild)}});function Ln(n,o){if(o){var a=n.firstChild;if(a&&a===n.lastChild&&a.nodeType===3){a.nodeValue=o;return}}n.textContent=o}var ao={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},fa=["Webkit","ms","Moz","O"];Object.keys(ao).forEach(function(n){fa.forEach(function(o){o=o+n.charAt(0).toUpperCase()+n.substring(1),ao[o]=ao[n]})});function Qo(n,o,a){return o==null||typeof o=="boolean"||o===""?"":a||typeof o!="number"||o===0||ao.hasOwnProperty(n)&&ao[n]?(""+o).trim():o+"px"}function tr(n,o){n=n.style;for(var a in o)if(o.hasOwnProperty(a)){var c=a.indexOf("--")===0,f=Qo(a,o[a],c);a==="float"&&(a="cssFloat"),c?n.setProperty(a,f):n[a]=f}}var ha=Q({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function In(n,o){if(o){if(ha[n]&&(o.children!=null||o.dangerouslySetInnerHTML!=null))throw Error(r(137,n));if(o.dangerouslySetInnerHTML!=null){if(o.children!=null)throw Error(r(60));if(typeof o.dangerouslySetInnerHTML!="object"||!("__html"in o.dangerouslySetInnerHTML))throw Error(r(61))}if(o.style!=null&&typeof o.style!="object")throw Error(r(62))}}function ei(n,o){if(n.indexOf("-")===-1)return typeof o.is=="string";switch(n){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var ti=null;function Yo(n){return n=n.target||n.srcElement||window,n.correspondingUseElement&&(n=n.correspondingUseElement),n.nodeType===3?n.parentNode:n}var Go=null,Sr=null,Dn=null;function hn(n){if(n=bi(n)){if(typeof Go!="function")throw Error(r(280));var o=n.stateNode;o&&(o=Ma(o),Go(n.stateNode,n.type,o))}}function pa(n){Sr?Dn?Dn.push(n):Dn=[n]:Sr=n}function Ee(){if(Sr){var n=Sr,o=Dn;if(Dn=Sr=null,hn(n),o)for(n=0;n>>=0,n===0?32:31-(yw(n)/vw|0)|0}var xa=64,wa=4194304;function ri(n){switch(n&-n){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return n&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return n&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return n}}function ba(n,o){var a=n.pendingLanes;if(a===0)return 0;var c=0,f=n.suspendedLanes,m=n.pingedLanes,S=a&268435455;if(S!==0){var N=S&~f;N!==0?c=ri(N):(m&=S,m!==0&&(c=ri(m)))}else S=a&~f,S!==0?c=ri(S):m!==0&&(c=ri(m));if(c===0)return 0;if(o!==0&&o!==c&&(o&f)===0&&(f=c&-c,m=o&-o,f>=m||f===16&&(m&4194240)!==0))return o;if((c&4)!==0&&(c|=a&16),o=n.entangledLanes,o!==0)for(n=n.entanglements,o&=c;0a;a++)o.push(n);return o}function oi(n,o,a){n.pendingLanes|=o,o!==536870912&&(n.suspendedLanes=0,n.pingedLanes=0),n=n.eventTimes,o=31-mn(o),n[o]=a}function Sw(n,o){var a=n.pendingLanes&~o;n.pendingLanes=o,n.suspendedLanes=0,n.pingedLanes=0,n.expiredLanes&=o,n.mutableReadLanes&=o,n.entangledLanes&=o,o=n.entanglements;var c=n.eventTimes;for(n=n.expirationTimes;0=fi),Ah=" ",Lh=!1;function Ih(n,o){switch(n){case"keyup":return Gw.indexOf(o.keyCode)!==-1;case"keydown":return o.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Dh(n){return n=n.detail,typeof n=="object"&&"data"in n?n.data:null}var Zo=!1;function Jw(n,o){switch(n){case"compositionend":return Dh(o);case"keypress":return o.which!==32?null:(Lh=!0,Ah);case"textInput":return n=o.data,n===Ah&&Lh?null:n;default:return null}}function Zw(n,o){if(Zo)return n==="compositionend"||!Bc&&Ih(n,o)?(n=Rh(),Na=Dc=Rr=null,Zo=!1,n):null;switch(n){case"paste":return null;case"keypress":if(!(o.ctrlKey||o.altKey||o.metaKey)||o.ctrlKey&&o.altKey){if(o.char&&1=o)return{node:a,offset:o-n};n=c}e:{for(;a;){if(a.nextSibling){a=a.nextSibling;break e}a=a.parentNode}a=void 0}a=Hh(a)}}function Wh(n,o){return n&&o?n===o?!0:n&&n.nodeType===3?!1:o&&o.nodeType===3?Wh(n,o.parentNode):"contains"in n?n.contains(o):n.compareDocumentPosition?!!(n.compareDocumentPosition(o)&16):!1:!1}function Kh(){for(var n=window,o=et();o instanceof n.HTMLIFrameElement;){try{var a=typeof o.contentWindow.location.href=="string"}catch{a=!1}if(a)n=o.contentWindow;else break;o=et(n.document)}return o}function Wc(n){var o=n&&n.nodeName&&n.nodeName.toLowerCase();return o&&(o==="input"&&(n.type==="text"||n.type==="search"||n.type==="tel"||n.type==="url"||n.type==="password")||o==="textarea"||n.contentEditable==="true")}function lb(n){var o=Kh(),a=n.focusedElem,c=n.selectionRange;if(o!==a&&a&&a.ownerDocument&&Wh(a.ownerDocument.documentElement,a)){if(c!==null&&Wc(a)){if(o=c.start,n=c.end,n===void 0&&(n=o),"selectionStart"in a)a.selectionStart=o,a.selectionEnd=Math.min(n,a.value.length);else if(n=(o=a.ownerDocument||document)&&o.defaultView||window,n.getSelection){n=n.getSelection();var f=a.textContent.length,m=Math.min(c.start,f);c=c.end===void 0?m:Math.min(c.end,f),!n.extend&&m>c&&(f=c,c=m,m=f),f=Vh(a,m);var S=Vh(a,c);f&&S&&(n.rangeCount!==1||n.anchorNode!==f.node||n.anchorOffset!==f.offset||n.focusNode!==S.node||n.focusOffset!==S.offset)&&(o=o.createRange(),o.setStart(f.node,f.offset),n.removeAllRanges(),m>c?(n.addRange(o),n.extend(S.node,S.offset)):(o.setEnd(S.node,S.offset),n.addRange(o)))}}for(o=[],n=a;n=n.parentNode;)n.nodeType===1&&o.push({element:n,left:n.scrollLeft,top:n.scrollTop});for(typeof a.focus=="function"&&a.focus(),a=0;a=document.documentMode,es=null,Kc=null,gi=null,qc=!1;function qh(n,o,a){var c=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;qc||es==null||es!==et(c)||(c=es,"selectionStart"in c&&Wc(c)?c={start:c.selectionStart,end:c.selectionEnd}:(c=(c.ownerDocument&&c.ownerDocument.defaultView||window).getSelection(),c={anchorNode:c.anchorNode,anchorOffset:c.anchorOffset,focusNode:c.focusNode,focusOffset:c.focusOffset}),gi&&mi(gi,c)||(gi=c,c=La(Kc,"onSelect"),0ss||(n.current=su[ss],su[ss]=null,ss--)}function We(n,o){ss++,su[ss]=n.current,n.current=o}var jr={},yt=Or(jr),It=Or(!1),uo=jr;function is(n,o){var a=n.type.contextTypes;if(!a)return jr;var c=n.stateNode;if(c&&c.__reactInternalMemoizedUnmaskedChildContext===o)return c.__reactInternalMemoizedMaskedChildContext;var f={},m;for(m in a)f[m]=o[m];return c&&(n=n.stateNode,n.__reactInternalMemoizedUnmaskedChildContext=o,n.__reactInternalMemoizedMaskedChildContext=f),f}function Dt(n){return n=n.childContextTypes,n!=null}function Fa(){qe(It),qe(yt)}function lp(n,o,a){if(yt.current!==jr)throw Error(r(168));We(yt,o),We(It,a)}function cp(n,o,a){var c=n.stateNode;if(o=o.childContextTypes,typeof c.getChildContext!="function")return a;c=c.getChildContext();for(var f in c)if(!(f in o))throw Error(r(108,ee(n)||"Unknown",f));return Q({},a,c)}function za(n){return n=(n=n.stateNode)&&n.__reactInternalMemoizedMergedChildContext||jr,uo=yt.current,We(yt,n),We(It,It.current),!0}function up(n,o,a){var c=n.stateNode;if(!c)throw Error(r(169));a?(n=cp(n,o,uo),c.__reactInternalMemoizedMergedChildContext=n,qe(It),qe(yt),We(yt,n)):qe(It),We(It,a)}var rr=null,$a=!1,iu=!1;function dp(n){rr===null?rr=[n]:rr.push(n)}function wb(n){$a=!0,dp(n)}function _r(){if(!iu&&rr!==null){iu=!0;var n=0,o=Ue;try{var a=rr;for(Ue=1;n>=S,f-=S,or=1<<32-mn(o)+f|a<Ce?(ut=we,we=null):ut=we.sibling;var De=q(I,we,D[Ce],re);if(De===null){we===null&&(we=ut);break}n&&we&&De.alternate===null&&o(I,we),L=m(De,L,Ce),xe===null?ge=De:xe.sibling=De,xe=De,we=ut}if(Ce===D.length)return a(I,we),Ye&&ho(I,Ce),ge;if(we===null){for(;CeCe?(ut=we,we=null):ut=we.sibling;var Ur=q(I,we,De.value,re);if(Ur===null){we===null&&(we=ut);break}n&&we&&Ur.alternate===null&&o(I,we),L=m(Ur,L,Ce),xe===null?ge=Ur:xe.sibling=Ur,xe=Ur,we=ut}if(De.done)return a(I,we),Ye&&ho(I,Ce),ge;if(we===null){for(;!De.done;Ce++,De=D.next())De=X(I,De.value,re),De!==null&&(L=m(De,L,Ce),xe===null?ge=De:xe.sibling=De,xe=De);return Ye&&ho(I,Ce),ge}for(we=c(I,we);!De.done;Ce++,De=D.next())De=ue(we,I,Ce,De.value,re),De!==null&&(n&&De.alternate!==null&&we.delete(De.key===null?Ce:De.key),L=m(De,L,Ce),xe===null?ge=De:xe.sibling=De,xe=De);return n&&we.forEach(function(e1){return o(I,e1)}),Ye&&ho(I,Ce),ge}function nt(I,L,D,re){if(typeof D=="object"&&D!==null&&D.type===B&&D.key===null&&(D=D.props.children),typeof D=="object"&&D!==null){switch(D.$$typeof){case F:e:{for(var ge=D.key,xe=L;xe!==null;){if(xe.key===ge){if(ge=D.type,ge===B){if(xe.tag===7){a(I,xe.sibling),L=f(xe,D.props.children),L.return=I,I=L;break e}}else if(xe.elementType===ge||typeof ge=="object"&&ge!==null&&ge.$$typeof===ne&&yp(ge)===xe.type){a(I,xe.sibling),L=f(xe,D.props),L.ref=Si(I,xe,D),L.return=I,I=L;break e}a(I,xe);break}else o(I,xe);xe=xe.sibling}D.type===B?(L=bo(D.props.children,I.mode,re,D.key),L.return=I,I=L):(re=pl(D.type,D.key,D.props,null,I.mode,re),re.ref=Si(I,L,D),re.return=I,I=re)}return S(I);case V:e:{for(xe=D.key;L!==null;){if(L.key===xe)if(L.tag===4&&L.stateNode.containerInfo===D.containerInfo&&L.stateNode.implementation===D.implementation){a(I,L.sibling),L=f(L,D.children||[]),L.return=I,I=L;break e}else{a(I,L);break}else o(I,L);L=L.sibling}L=rd(D,I.mode,re),L.return=I,I=L}return S(I);case ne:return xe=D._init,nt(I,L,xe(D._payload),re)}if(br(D))return he(I,L,D,re);if(H(D))return pe(I,L,D,re);Va(I,D)}return typeof D=="string"&&D!==""||typeof D=="number"?(D=""+D,L!==null&&L.tag===6?(a(I,L.sibling),L=f(L,D),L.return=I,I=L):(a(I,L),L=nd(D,I.mode,re),L.return=I,I=L),S(I)):a(I,L)}return nt}var us=vp(!0),xp=vp(!1),Wa=Or(null),Ka=null,ds=null,fu=null;function hu(){fu=ds=Ka=null}function pu(n){var o=Wa.current;qe(Wa),n._currentValue=o}function mu(n,o,a){for(;n!==null;){var c=n.alternate;if((n.childLanes&o)!==o?(n.childLanes|=o,c!==null&&(c.childLanes|=o)):c!==null&&(c.childLanes&o)!==o&&(c.childLanes|=o),n===a)break;n=n.return}}function fs(n,o){Ka=n,fu=ds=null,n=n.dependencies,n!==null&&n.firstContext!==null&&((n.lanes&o)!==0&&(Mt=!0),n.firstContext=null)}function rn(n){var o=n._currentValue;if(fu!==n)if(n={context:n,memoizedValue:o,next:null},ds===null){if(Ka===null)throw Error(r(308));ds=n,Ka.dependencies={lanes:0,firstContext:n}}else ds=ds.next=n;return o}var po=null;function gu(n){po===null?po=[n]:po.push(n)}function wp(n,o,a,c){var f=o.interleaved;return f===null?(a.next=a,gu(o)):(a.next=f.next,f.next=a),o.interleaved=a,ir(n,c)}function ir(n,o){n.lanes|=o;var a=n.alternate;for(a!==null&&(a.lanes|=o),a=n,n=n.return;n!==null;)n.childLanes|=o,a=n.alternate,a!==null&&(a.childLanes|=o),a=n,n=n.return;return a.tag===3?a.stateNode:null}var Ar=!1;function yu(n){n.updateQueue={baseState:n.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function bp(n,o){n=n.updateQueue,o.updateQueue===n&&(o.updateQueue={baseState:n.baseState,firstBaseUpdate:n.firstBaseUpdate,lastBaseUpdate:n.lastBaseUpdate,shared:n.shared,effects:n.effects})}function ar(n,o){return{eventTime:n,lane:o,tag:0,payload:null,callback:null,next:null}}function Lr(n,o,a){var c=n.updateQueue;if(c===null)return null;if(c=c.shared,(Le&2)!==0){var f=c.pending;return f===null?o.next=o:(o.next=f.next,f.next=o),c.pending=o,ir(n,a)}return f=c.interleaved,f===null?(o.next=o,gu(c)):(o.next=f.next,f.next=o),c.interleaved=o,ir(n,a)}function qa(n,o,a){if(o=o.updateQueue,o!==null&&(o=o.shared,(a&4194240)!==0)){var c=o.lanes;c&=n.pendingLanes,a|=c,o.lanes=a,jc(n,a)}}function Sp(n,o){var a=n.updateQueue,c=n.alternate;if(c!==null&&(c=c.updateQueue,a===c)){var f=null,m=null;if(a=a.firstBaseUpdate,a!==null){do{var S={eventTime:a.eventTime,lane:a.lane,tag:a.tag,payload:a.payload,callback:a.callback,next:null};m===null?f=m=S:m=m.next=S,a=a.next}while(a!==null);m===null?f=m=o:m=m.next=o}else f=m=o;a={baseState:c.baseState,firstBaseUpdate:f,lastBaseUpdate:m,shared:c.shared,effects:c.effects},n.updateQueue=a;return}n=a.lastBaseUpdate,n===null?a.firstBaseUpdate=o:n.next=o,a.lastBaseUpdate=o}function Qa(n,o,a,c){var f=n.updateQueue;Ar=!1;var m=f.firstBaseUpdate,S=f.lastBaseUpdate,N=f.shared.pending;if(N!==null){f.shared.pending=null;var O=N,z=O.next;O.next=null,S===null?m=z:S.next=z,S=O;var Y=n.alternate;Y!==null&&(Y=Y.updateQueue,N=Y.lastBaseUpdate,N!==S&&(N===null?Y.firstBaseUpdate=z:N.next=z,Y.lastBaseUpdate=O))}if(m!==null){var X=f.baseState;S=0,Y=z=O=null,N=m;do{var q=N.lane,ue=N.eventTime;if((c&q)===q){Y!==null&&(Y=Y.next={eventTime:ue,lane:0,tag:N.tag,payload:N.payload,callback:N.callback,next:null});e:{var he=n,pe=N;switch(q=o,ue=a,pe.tag){case 1:if(he=pe.payload,typeof he=="function"){X=he.call(ue,X,q);break e}X=he;break e;case 3:he.flags=he.flags&-65537|128;case 0:if(he=pe.payload,q=typeof he=="function"?he.call(ue,X,q):he,q==null)break e;X=Q({},X,q);break e;case 2:Ar=!0}}N.callback!==null&&N.lane!==0&&(n.flags|=64,q=f.effects,q===null?f.effects=[N]:q.push(N))}else ue={eventTime:ue,lane:q,tag:N.tag,payload:N.payload,callback:N.callback,next:null},Y===null?(z=Y=ue,O=X):Y=Y.next=ue,S|=q;if(N=N.next,N===null){if(N=f.shared.pending,N===null)break;q=N,N=q.next,q.next=null,f.lastBaseUpdate=q,f.shared.pending=null}}while(!0);if(Y===null&&(O=X),f.baseState=O,f.firstBaseUpdate=z,f.lastBaseUpdate=Y,o=f.shared.interleaved,o!==null){f=o;do S|=f.lane,f=f.next;while(f!==o)}else m===null&&(f.shared.lanes=0);yo|=S,n.lanes=S,n.memoizedState=X}}function Cp(n,o,a){if(n=o.effects,o.effects=null,n!==null)for(o=0;oa?a:4,n(!0);var c=Su.transition;Su.transition={};try{n(!1),o()}finally{Ue=a,Su.transition=c}}function Bp(){return on().memoizedState}function Eb(n,o,a){var c=Fr(n);if(a={lane:c,action:a,hasEagerState:!1,eagerState:null,next:null},Hp(n))Vp(o,a);else if(a=wp(n,o,a,c),a!==null){var f=Rt();bn(a,n,c,f),Wp(a,o,c)}}function kb(n,o,a){var c=Fr(n),f={lane:c,action:a,hasEagerState:!1,eagerState:null,next:null};if(Hp(n))Vp(o,f);else{var m=n.alternate;if(n.lanes===0&&(m===null||m.lanes===0)&&(m=o.lastRenderedReducer,m!==null))try{var S=o.lastRenderedState,N=m(S,a);if(f.hasEagerState=!0,f.eagerState=N,gn(N,S)){var O=o.interleaved;O===null?(f.next=f,gu(o)):(f.next=O.next,O.next=f),o.interleaved=f;return}}catch{}finally{}a=wp(n,o,f,c),a!==null&&(f=Rt(),bn(a,n,c,f),Wp(a,o,c))}}function Hp(n){var o=n.alternate;return n===Xe||o!==null&&o===Xe}function Vp(n,o){Ni=Xa=!0;var a=n.pending;a===null?o.next=o:(o.next=a.next,a.next=o),n.pending=o}function Wp(n,o,a){if((a&4194240)!==0){var c=o.lanes;c&=n.pendingLanes,a|=c,o.lanes=a,jc(n,a)}}var el={readContext:rn,useCallback:vt,useContext:vt,useEffect:vt,useImperativeHandle:vt,useInsertionEffect:vt,useLayoutEffect:vt,useMemo:vt,useReducer:vt,useRef:vt,useState:vt,useDebugValue:vt,useDeferredValue:vt,useTransition:vt,useMutableSource:vt,useSyncExternalStore:vt,useId:vt,unstable_isNewReconciler:!1},Nb={readContext:rn,useCallback:function(n,o){return Un().memoizedState=[n,o===void 0?null:o],n},useContext:rn,useEffect:Lp,useImperativeHandle:function(n,o,a){return a=a!=null?a.concat([n]):null,Ja(4194308,4,Mp.bind(null,o,n),a)},useLayoutEffect:function(n,o){return Ja(4194308,4,n,o)},useInsertionEffect:function(n,o){return Ja(4,2,n,o)},useMemo:function(n,o){var a=Un();return o=o===void 0?null:o,n=n(),a.memoizedState=[n,o],n},useReducer:function(n,o,a){var c=Un();return o=a!==void 0?a(o):o,c.memoizedState=c.baseState=o,n={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:n,lastRenderedState:o},c.queue=n,n=n.dispatch=Eb.bind(null,Xe,n),[c.memoizedState,n]},useRef:function(n){var o=Un();return n={current:n},o.memoizedState=n},useState:_p,useDebugValue:Tu,useDeferredValue:function(n){return Un().memoizedState=n},useTransition:function(){var n=_p(!1),o=n[0];return n=Cb.bind(null,n[1]),Un().memoizedState=n,[o,n]},useMutableSource:function(){},useSyncExternalStore:function(n,o,a){var c=Xe,f=Un();if(Ye){if(a===void 0)throw Error(r(407));a=a()}else{if(a=o(),ct===null)throw Error(r(349));(go&30)!==0||Rp(c,o,a)}f.memoizedState=a;var m={value:a,getSnapshot:o};return f.queue=m,Lp(Tp.bind(null,c,m,n),[n]),c.flags|=2048,Ti(9,Pp.bind(null,c,m,a,o),void 0,null),a},useId:function(){var n=Un(),o=ct.identifierPrefix;if(Ye){var a=sr,c=or;a=(c&~(1<<32-mn(c)-1)).toString(32)+a,o=":"+o+"R"+a,a=Ri++,0<\/script>",n=n.removeChild(n.firstChild)):typeof c.is=="string"?n=S.createElement(a,{is:c.is}):(n=S.createElement(a),a==="select"&&(S=n,c.multiple?S.multiple=!0:c.size&&(S.size=c.size))):n=S.createElementNS(n,a),n[zn]=o,n[wi]=c,dm(n,o,!1,!1),o.stateNode=n;e:{switch(S=ei(a,c),a){case"dialog":Ke("cancel",n),Ke("close",n),f=c;break;case"iframe":case"object":case"embed":Ke("load",n),f=c;break;case"video":case"audio":for(f=0;fys&&(o.flags|=128,c=!0,Oi(m,!1),o.lanes=4194304)}else{if(!c)if(n=Ya(S),n!==null){if(o.flags|=128,c=!0,a=n.updateQueue,a!==null&&(o.updateQueue=a,o.flags|=4),Oi(m,!0),m.tail===null&&m.tailMode==="hidden"&&!S.alternate&&!Ye)return xt(o),null}else 2*tt()-m.renderingStartTime>ys&&a!==1073741824&&(o.flags|=128,c=!0,Oi(m,!1),o.lanes=4194304);m.isBackwards?(S.sibling=o.child,o.child=S):(a=m.last,a!==null?a.sibling=S:o.child=S,m.last=S)}return m.tail!==null?(o=m.tail,m.rendering=o,m.tail=o.sibling,m.renderingStartTime=tt(),o.sibling=null,a=Ge.current,We(Ge,c?a&1|2:a&1),o):(xt(o),null);case 22:case 23:return Zu(),c=o.memoizedState!==null,n!==null&&n.memoizedState!==null!==c&&(o.flags|=8192),c&&(o.mode&1)!==0?(Gt&1073741824)!==0&&(xt(o),o.subtreeFlags&6&&(o.flags|=8192)):xt(o),null;case 24:return null;case 25:return null}throw Error(r(156,o.tag))}function Lb(n,o){switch(lu(o),o.tag){case 1:return Dt(o.type)&&Fa(),n=o.flags,n&65536?(o.flags=n&-65537|128,o):null;case 3:return hs(),qe(It),qe(yt),bu(),n=o.flags,(n&65536)!==0&&(n&128)===0?(o.flags=n&-65537|128,o):null;case 5:return xu(o),null;case 13:if(qe(Ge),n=o.memoizedState,n!==null&&n.dehydrated!==null){if(o.alternate===null)throw Error(r(340));cs()}return n=o.flags,n&65536?(o.flags=n&-65537|128,o):null;case 19:return qe(Ge),null;case 4:return hs(),null;case 10:return pu(o.type._context),null;case 22:case 23:return Zu(),null;case 24:return null;default:return null}}var ol=!1,wt=!1,Ib=typeof WeakSet=="function"?WeakSet:Set,fe=null;function ms(n,o){var a=n.ref;if(a!==null)if(typeof a=="function")try{a(null)}catch(c){Je(n,o,c)}else a.current=null}function Uu(n,o,a){try{a()}catch(c){Je(n,o,c)}}var pm=!1;function Db(n,o){if(Zc=Ea,n=Kh(),Wc(n)){if("selectionStart"in n)var a={start:n.selectionStart,end:n.selectionEnd};else e:{a=(a=n.ownerDocument)&&a.defaultView||window;var c=a.getSelection&&a.getSelection();if(c&&c.rangeCount!==0){a=c.anchorNode;var f=c.anchorOffset,m=c.focusNode;c=c.focusOffset;try{a.nodeType,m.nodeType}catch{a=null;break e}var S=0,N=-1,O=-1,z=0,Y=0,X=n,q=null;t:for(;;){for(var ue;X!==a||f!==0&&X.nodeType!==3||(N=S+f),X!==m||c!==0&&X.nodeType!==3||(O=S+c),X.nodeType===3&&(S+=X.nodeValue.length),(ue=X.firstChild)!==null;)q=X,X=ue;for(;;){if(X===n)break t;if(q===a&&++z===f&&(N=S),q===m&&++Y===c&&(O=S),(ue=X.nextSibling)!==null)break;X=q,q=X.parentNode}X=ue}a=N===-1||O===-1?null:{start:N,end:O}}else a=null}a=a||{start:0,end:0}}else a=null;for(eu={focusedElem:n,selectionRange:a},Ea=!1,fe=o;fe!==null;)if(o=fe,n=o.child,(o.subtreeFlags&1028)!==0&&n!==null)n.return=o,fe=n;else for(;fe!==null;){o=fe;try{var he=o.alternate;if((o.flags&1024)!==0)switch(o.tag){case 0:case 11:case 15:break;case 1:if(he!==null){var pe=he.memoizedProps,nt=he.memoizedState,I=o.stateNode,L=I.getSnapshotBeforeUpdate(o.elementType===o.type?pe:vn(o.type,pe),nt);I.__reactInternalSnapshotBeforeUpdate=L}break;case 3:var D=o.stateNode.containerInfo;D.nodeType===1?D.textContent="":D.nodeType===9&&D.documentElement&&D.removeChild(D.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(r(163))}}catch(re){Je(o,o.return,re)}if(n=o.sibling,n!==null){n.return=o.return,fe=n;break}fe=o.return}return he=pm,pm=!1,he}function ji(n,o,a){var c=o.updateQueue;if(c=c!==null?c.lastEffect:null,c!==null){var f=c=c.next;do{if((f.tag&n)===n){var m=f.destroy;f.destroy=void 0,m!==void 0&&Uu(o,a,m)}f=f.next}while(f!==c)}}function sl(n,o){if(o=o.updateQueue,o=o!==null?o.lastEffect:null,o!==null){var a=o=o.next;do{if((a.tag&n)===n){var c=a.create;a.destroy=c()}a=a.next}while(a!==o)}}function Bu(n){var o=n.ref;if(o!==null){var a=n.stateNode;switch(n.tag){case 5:n=a;break;default:n=a}typeof o=="function"?o(n):o.current=n}}function mm(n){var o=n.alternate;o!==null&&(n.alternate=null,mm(o)),n.child=null,n.deletions=null,n.sibling=null,n.tag===5&&(o=n.stateNode,o!==null&&(delete o[zn],delete o[wi],delete o[ou],delete o[vb],delete o[xb])),n.stateNode=null,n.return=null,n.dependencies=null,n.memoizedProps=null,n.memoizedState=null,n.pendingProps=null,n.stateNode=null,n.updateQueue=null}function gm(n){return n.tag===5||n.tag===3||n.tag===4}function ym(n){e:for(;;){for(;n.sibling===null;){if(n.return===null||gm(n.return))return null;n=n.return}for(n.sibling.return=n.return,n=n.sibling;n.tag!==5&&n.tag!==6&&n.tag!==18;){if(n.flags&2||n.child===null||n.tag===4)continue e;n.child.return=n,n=n.child}if(!(n.flags&2))return n.stateNode}}function Hu(n,o,a){var c=n.tag;if(c===5||c===6)n=n.stateNode,o?a.nodeType===8?a.parentNode.insertBefore(n,o):a.insertBefore(n,o):(a.nodeType===8?(o=a.parentNode,o.insertBefore(n,a)):(o=a,o.appendChild(n)),a=a._reactRootContainer,a!=null||o.onclick!==null||(o.onclick=Da));else if(c!==4&&(n=n.child,n!==null))for(Hu(n,o,a),n=n.sibling;n!==null;)Hu(n,o,a),n=n.sibling}function Vu(n,o,a){var c=n.tag;if(c===5||c===6)n=n.stateNode,o?a.insertBefore(n,o):a.appendChild(n);else if(c!==4&&(n=n.child,n!==null))for(Vu(n,o,a),n=n.sibling;n!==null;)Vu(n,o,a),n=n.sibling}var ht=null,xn=!1;function Ir(n,o,a){for(a=a.child;a!==null;)vm(n,o,a),a=a.sibling}function vm(n,o,a){if(Fn&&typeof Fn.onCommitFiberUnmount=="function")try{Fn.onCommitFiberUnmount(va,a)}catch{}switch(a.tag){case 5:wt||ms(a,o);case 6:var c=ht,f=xn;ht=null,Ir(n,o,a),ht=c,xn=f,ht!==null&&(xn?(n=ht,a=a.stateNode,n.nodeType===8?n.parentNode.removeChild(a):n.removeChild(a)):ht.removeChild(a.stateNode));break;case 18:ht!==null&&(xn?(n=ht,a=a.stateNode,n.nodeType===8?ru(n.parentNode,a):n.nodeType===1&&ru(n,a),ci(n)):ru(ht,a.stateNode));break;case 4:c=ht,f=xn,ht=a.stateNode.containerInfo,xn=!0,Ir(n,o,a),ht=c,xn=f;break;case 0:case 11:case 14:case 15:if(!wt&&(c=a.updateQueue,c!==null&&(c=c.lastEffect,c!==null))){f=c=c.next;do{var m=f,S=m.destroy;m=m.tag,S!==void 0&&((m&2)!==0||(m&4)!==0)&&Uu(a,o,S),f=f.next}while(f!==c)}Ir(n,o,a);break;case 1:if(!wt&&(ms(a,o),c=a.stateNode,typeof c.componentWillUnmount=="function"))try{c.props=a.memoizedProps,c.state=a.memoizedState,c.componentWillUnmount()}catch(N){Je(a,o,N)}Ir(n,o,a);break;case 21:Ir(n,o,a);break;case 22:a.mode&1?(wt=(c=wt)||a.memoizedState!==null,Ir(n,o,a),wt=c):Ir(n,o,a);break;default:Ir(n,o,a)}}function xm(n){var o=n.updateQueue;if(o!==null){n.updateQueue=null;var a=n.stateNode;a===null&&(a=n.stateNode=new Ib),o.forEach(function(c){var f=Wb.bind(null,n,c);a.has(c)||(a.add(c),c.then(f,f))})}}function wn(n,o){var a=o.deletions;if(a!==null)for(var c=0;cf&&(f=S),c&=~m}if(c=f,c=tt()-c,c=(120>c?120:480>c?480:1080>c?1080:1920>c?1920:3e3>c?3e3:4320>c?4320:1960*Fb(c/1960))-c,10n?16:n,Mr===null)var c=!1;else{if(n=Mr,Mr=null,ul=0,(Le&6)!==0)throw Error(r(331));var f=Le;for(Le|=4,fe=n.current;fe!==null;){var m=fe,S=m.child;if((fe.flags&16)!==0){var N=m.deletions;if(N!==null){for(var O=0;Ott()-qu?xo(n,0):Ku|=a),zt(n,o)}function _m(n,o){o===0&&((n.mode&1)===0?o=1:(o=wa,wa<<=1,(wa&130023424)===0&&(wa=4194304)));var a=Rt();n=ir(n,o),n!==null&&(oi(n,o,a),zt(n,a))}function Vb(n){var o=n.memoizedState,a=0;o!==null&&(a=o.retryLane),_m(n,a)}function Wb(n,o){var a=0;switch(n.tag){case 13:var c=n.stateNode,f=n.memoizedState;f!==null&&(a=f.retryLane);break;case 19:c=n.stateNode;break;default:throw Error(r(314))}c!==null&&c.delete(o),_m(n,a)}var Am;Am=function(n,o,a){if(n!==null)if(n.memoizedProps!==o.pendingProps||It.current)Mt=!0;else{if((n.lanes&a)===0&&(o.flags&128)===0)return Mt=!1,_b(n,o,a);Mt=(n.flags&131072)!==0}else Mt=!1,Ye&&(o.flags&1048576)!==0&&fp(o,Ba,o.index);switch(o.lanes=0,o.tag){case 2:var c=o.type;rl(n,o),n=o.pendingProps;var f=is(o,yt.current);fs(o,a),f=Eu(null,o,c,n,f,a);var m=ku();return o.flags|=1,typeof f=="object"&&f!==null&&typeof f.render=="function"&&f.$$typeof===void 0?(o.tag=1,o.memoizedState=null,o.updateQueue=null,Dt(c)?(m=!0,za(o)):m=!1,o.memoizedState=f.state!==null&&f.state!==void 0?f.state:null,yu(o),f.updater=tl,o.stateNode=f,f._reactInternals=o,ju(o,c,n,a),o=Iu(null,o,c,!0,m,a)):(o.tag=0,Ye&&m&&au(o),Nt(null,o,f,a),o=o.child),o;case 16:c=o.elementType;e:{switch(rl(n,o),n=o.pendingProps,f=c._init,c=f(c._payload),o.type=c,f=o.tag=qb(c),n=vn(c,n),f){case 0:o=Lu(null,o,c,n,a);break e;case 1:o=sm(null,o,c,n,a);break e;case 11:o=em(null,o,c,n,a);break e;case 14:o=tm(null,o,c,vn(c.type,n),a);break e}throw Error(r(306,c,""))}return o;case 0:return c=o.type,f=o.pendingProps,f=o.elementType===c?f:vn(c,f),Lu(n,o,c,f,a);case 1:return c=o.type,f=o.pendingProps,f=o.elementType===c?f:vn(c,f),sm(n,o,c,f,a);case 3:e:{if(im(o),n===null)throw Error(r(387));c=o.pendingProps,m=o.memoizedState,f=m.element,bp(n,o),Qa(o,c,null,a);var S=o.memoizedState;if(c=S.element,m.isDehydrated)if(m={element:c,isDehydrated:!1,cache:S.cache,pendingSuspenseBoundaries:S.pendingSuspenseBoundaries,transitions:S.transitions},o.updateQueue.baseState=m,o.memoizedState=m,o.flags&256){f=ps(Error(r(423)),o),o=am(n,o,c,a,f);break e}else if(c!==f){f=ps(Error(r(424)),o),o=am(n,o,c,a,f);break e}else for(Yt=Tr(o.stateNode.containerInfo.firstChild),Qt=o,Ye=!0,yn=null,a=xp(o,null,c,a),o.child=a;a;)a.flags=a.flags&-3|4096,a=a.sibling;else{if(cs(),c===f){o=lr(n,o,a);break e}Nt(n,o,c,a)}o=o.child}return o;case 5:return Ep(o),n===null&&uu(o),c=o.type,f=o.pendingProps,m=n!==null?n.memoizedProps:null,S=f.children,tu(c,f)?S=null:m!==null&&tu(c,m)&&(o.flags|=32),om(n,o),Nt(n,o,S,a),o.child;case 6:return n===null&&uu(o),null;case 13:return lm(n,o,a);case 4:return vu(o,o.stateNode.containerInfo),c=o.pendingProps,n===null?o.child=us(o,null,c,a):Nt(n,o,c,a),o.child;case 11:return c=o.type,f=o.pendingProps,f=o.elementType===c?f:vn(c,f),em(n,o,c,f,a);case 7:return Nt(n,o,o.pendingProps,a),o.child;case 8:return Nt(n,o,o.pendingProps.children,a),o.child;case 12:return Nt(n,o,o.pendingProps.children,a),o.child;case 10:e:{if(c=o.type._context,f=o.pendingProps,m=o.memoizedProps,S=f.value,We(Wa,c._currentValue),c._currentValue=S,m!==null)if(gn(m.value,S)){if(m.children===f.children&&!It.current){o=lr(n,o,a);break e}}else for(m=o.child,m!==null&&(m.return=o);m!==null;){var N=m.dependencies;if(N!==null){S=m.child;for(var O=N.firstContext;O!==null;){if(O.context===c){if(m.tag===1){O=ar(-1,a&-a),O.tag=2;var z=m.updateQueue;if(z!==null){z=z.shared;var Y=z.pending;Y===null?O.next=O:(O.next=Y.next,Y.next=O),z.pending=O}}m.lanes|=a,O=m.alternate,O!==null&&(O.lanes|=a),mu(m.return,a,o),N.lanes|=a;break}O=O.next}}else if(m.tag===10)S=m.type===o.type?null:m.child;else if(m.tag===18){if(S=m.return,S===null)throw Error(r(341));S.lanes|=a,N=S.alternate,N!==null&&(N.lanes|=a),mu(S,a,o),S=m.sibling}else S=m.child;if(S!==null)S.return=m;else for(S=m;S!==null;){if(S===o){S=null;break}if(m=S.sibling,m!==null){m.return=S.return,S=m;break}S=S.return}m=S}Nt(n,o,f.children,a),o=o.child}return o;case 9:return f=o.type,c=o.pendingProps.children,fs(o,a),f=rn(f),c=c(f),o.flags|=1,Nt(n,o,c,a),o.child;case 14:return c=o.type,f=vn(c,o.pendingProps),f=vn(c.type,f),tm(n,o,c,f,a);case 15:return nm(n,o,o.type,o.pendingProps,a);case 17:return c=o.type,f=o.pendingProps,f=o.elementType===c?f:vn(c,f),rl(n,o),o.tag=1,Dt(c)?(n=!0,za(o)):n=!1,fs(o,a),qp(o,c,f),ju(o,c,f,a),Iu(null,o,c,!0,n,a);case 19:return um(n,o,a);case 22:return rm(n,o,a)}throw Error(r(156,o.tag))};function Lm(n,o){return hh(n,o)}function Kb(n,o,a,c){this.tag=n,this.key=a,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=o,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=c,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function an(n,o,a,c){return new Kb(n,o,a,c)}function td(n){return n=n.prototype,!(!n||!n.isReactComponent)}function qb(n){if(typeof n=="function")return td(n)?1:0;if(n!=null){if(n=n.$$typeof,n===K)return 11;if(n===de)return 14}return 2}function $r(n,o){var a=n.alternate;return a===null?(a=an(n.tag,o,n.key,n.mode),a.elementType=n.elementType,a.type=n.type,a.stateNode=n.stateNode,a.alternate=n,n.alternate=a):(a.pendingProps=o,a.type=n.type,a.flags=0,a.subtreeFlags=0,a.deletions=null),a.flags=n.flags&14680064,a.childLanes=n.childLanes,a.lanes=n.lanes,a.child=n.child,a.memoizedProps=n.memoizedProps,a.memoizedState=n.memoizedState,a.updateQueue=n.updateQueue,o=n.dependencies,a.dependencies=o===null?null:{lanes:o.lanes,firstContext:o.firstContext},a.sibling=n.sibling,a.index=n.index,a.ref=n.ref,a}function pl(n,o,a,c,f,m){var S=2;if(c=n,typeof n=="function")td(n)&&(S=1);else if(typeof n=="string")S=5;else e:switch(n){case B:return bo(a.children,f,m,o);case te:S=8,f|=8;break;case G:return n=an(12,a,o,f|2),n.elementType=G,n.lanes=m,n;case Z:return n=an(13,a,o,f),n.elementType=Z,n.lanes=m,n;case J:return n=an(19,a,o,f),n.elementType=J,n.lanes=m,n;case se:return ml(a,f,m,o);default:if(typeof n=="object"&&n!==null)switch(n.$$typeof){case W:S=10;break e;case le:S=9;break e;case K:S=11;break e;case de:S=14;break e;case ne:S=16,c=null;break e}throw Error(r(130,n==null?n:typeof n,""))}return o=an(S,a,o,f),o.elementType=n,o.type=c,o.lanes=m,o}function bo(n,o,a,c){return n=an(7,n,c,o),n.lanes=a,n}function ml(n,o,a,c){return n=an(22,n,c,o),n.elementType=se,n.lanes=a,n.stateNode={isHidden:!1},n}function nd(n,o,a){return n=an(6,n,null,o),n.lanes=a,n}function rd(n,o,a){return o=an(4,n.children!==null?n.children:[],n.key,o),o.lanes=a,o.stateNode={containerInfo:n.containerInfo,pendingChildren:null,implementation:n.implementation},o}function Qb(n,o,a,c,f){this.tag=o,this.containerInfo=n,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Oc(0),this.expirationTimes=Oc(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Oc(0),this.identifierPrefix=c,this.onRecoverableError=f,this.mutableSourceEagerHydrationData=null}function od(n,o,a,c,f,m,S,N,O){return n=new Qb(n,o,a,N,O),o===1?(o=1,m===!0&&(o|=8)):o=0,m=an(3,null,null,o),n.current=m,m.stateNode=n,m.memoizedState={element:c,isDehydrated:a,cache:null,transitions:null,pendingSuspenseBoundaries:null},yu(m),n}function Yb(n,o,a){var c=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),dd.exports=a1(),dd.exports}var Ym;function l1(){if(Ym)return Cl;Ym=1;var t=Fy();return Cl.createRoot=t.createRoot,Cl.hydrateRoot=t.hydrateRoot,Cl}var c1=l1();const u1=bf(c1);var Ks=class{constructor(){this.listeners=new Set,this.subscribe=this.subscribe.bind(this)}subscribe(t){return this.listeners.add(t),this.onSubscribe(),()=>{this.listeners.delete(t),this.onUnsubscribe()}}hasListeners(){return this.listeners.size>0}onSubscribe(){}onUnsubscribe(){}},d1={setTimeout:(t,e)=>setTimeout(t,e),clearTimeout:t=>clearTimeout(t),setInterval:(t,e)=>setInterval(t,e),clearInterval:t=>clearInterval(t)},Kr,wf,Ry,f1=(Ry=class{constructor(){ve(this,Kr,d1);ve(this,wf,!1)}setTimeoutProvider(t){ce(this,Kr,t)}setTimeout(t,e){return R(this,Kr).setTimeout(t,e)}clearTimeout(t){R(this,Kr).clearTimeout(t)}setInterval(t,e){return R(this,Kr).setInterval(t,e)}clearInterval(t){R(this,Kr).clearInterval(t)}},Kr=new WeakMap,wf=new WeakMap,Ry),Co=new f1;function h1(t){setTimeout(t,0)}var Fo=typeof window>"u"||"Deno"in globalThis;function Ot(){}function p1(t,e){return typeof t=="function"?t(e):t}function Id(t){return typeof t=="number"&&t>=0&&t!==1/0}function zy(t,e){return Math.max(t+(e||0)-Date.now(),0)}function to(t,e){return typeof t=="function"?t(e):t}function cn(t,e){return typeof t=="function"?t(e):t}function Gm(t,e){const{type:r="all",exact:s,fetchStatus:i,predicate:l,queryKey:u,stale:d}=t;if(u){if(s){if(e.queryHash!==Cf(u,e.options))return!1}else if(!Wi(e.queryKey,u))return!1}if(r!=="all"){const h=e.isActive();if(r==="active"&&!h||r==="inactive"&&h)return!1}return!(typeof d=="boolean"&&e.isStale()!==d||i&&i!==e.state.fetchStatus||l&&!l(e))}function Xm(t,e){const{exact:r,status:s,predicate:i,mutationKey:l}=t;if(l){if(!e.options.mutationKey)return!1;if(r){if(zo(e.options.mutationKey)!==zo(l))return!1}else if(!Wi(e.options.mutationKey,l))return!1}return!(s&&e.state.status!==s||i&&!i(e))}function Cf(t,e){return((e==null?void 0:e.queryKeyHashFn)||zo)(t)}function zo(t){return JSON.stringify(t,(e,r)=>Dd(r)?Object.keys(r).sort().reduce((s,i)=>(s[i]=r[i],s),{}):r)}function Wi(t,e){return t===e?!0:typeof t!=typeof e?!1:t&&e&&typeof t=="object"&&typeof e=="object"?Object.keys(e).every(r=>Wi(t[r],e[r])):!1}var m1=Object.prototype.hasOwnProperty;function $y(t,e){if(t===e)return t;const r=Jm(t)&&Jm(e);if(!r&&!(Dd(t)&&Dd(e)))return e;const i=(r?t:Object.keys(t)).length,l=r?e:Object.keys(e),u=l.length,d=r?new Array(u):{};let h=0;for(let p=0;p{Co.setTimeout(e,t)})}function Md(t,e,r){return typeof r.structuralSharing=="function"?r.structuralSharing(t,e):r.structuralSharing!==!1?$y(t,e):e}function y1(t,e,r=0){const s=[...t,e];return r&&s.length>r?s.slice(1):s}function v1(t,e,r=0){const s=[e,...t];return r&&s.length>r?s.slice(0,-1):s}var Ef=Symbol();function Uy(t,e){return!t.queryFn&&(e!=null&&e.initialPromise)?()=>e.initialPromise:!t.queryFn||t.queryFn===Ef?()=>Promise.reject(new Error(`Missing queryFn: '${t.queryHash}'`)):t.queryFn}function By(t,e){return typeof t=="function"?t(...e):!!t}var Po,qr,js,Py,x1=(Py=class extends Ks{constructor(){super();ve(this,Po);ve(this,qr);ve(this,js);ce(this,js,e=>{if(!Fo&&window.addEventListener){const r=()=>e();return window.addEventListener("visibilitychange",r,!1),()=>{window.removeEventListener("visibilitychange",r)}}})}onSubscribe(){R(this,qr)||this.setEventListener(R(this,js))}onUnsubscribe(){var e;this.hasListeners()||((e=R(this,qr))==null||e.call(this),ce(this,qr,void 0))}setEventListener(e){var r;ce(this,js,e),(r=R(this,qr))==null||r.call(this),ce(this,qr,e(s=>{typeof s=="boolean"?this.setFocused(s):this.onFocus()}))}setFocused(e){R(this,Po)!==e&&(ce(this,Po,e),this.onFocus())}onFocus(){const e=this.isFocused();this.listeners.forEach(r=>{r(e)})}isFocused(){var e;return typeof R(this,Po)=="boolean"?R(this,Po):((e=globalThis.document)==null?void 0:e.visibilityState)!=="hidden"}},Po=new WeakMap,qr=new WeakMap,js=new WeakMap,Py),kf=new x1;function Fd(){let t,e;const r=new Promise((i,l)=>{t=i,e=l});r.status="pending",r.catch(()=>{});function s(i){Object.assign(r,i),delete r.resolve,delete r.reject}return r.resolve=i=>{s({status:"fulfilled",value:i}),t(i)},r.reject=i=>{s({status:"rejected",reason:i}),e(i)},r}var w1=h1;function b1(){let t=[],e=0,r=d=>{d()},s=d=>{d()},i=w1;const l=d=>{e?t.push(d):i(()=>{r(d)})},u=()=>{const d=t;t=[],d.length&&i(()=>{s(()=>{d.forEach(h=>{r(h)})})})};return{batch:d=>{let h;e++;try{h=d()}finally{e--,e||u()}return h},batchCalls:d=>(...h)=>{l(()=>{d(...h)})},schedule:l,setNotifyFunction:d=>{r=d},setBatchNotifyFunction:d=>{s=d},setScheduler:d=>{i=d}}}var at=b1(),_s,Qr,As,Ty,S1=(Ty=class extends Ks{constructor(){super();ve(this,_s,!0);ve(this,Qr);ve(this,As);ce(this,As,e=>{if(!Fo&&window.addEventListener){const r=()=>e(!0),s=()=>e(!1);return window.addEventListener("online",r,!1),window.addEventListener("offline",s,!1),()=>{window.removeEventListener("online",r),window.removeEventListener("offline",s)}}})}onSubscribe(){R(this,Qr)||this.setEventListener(R(this,As))}onUnsubscribe(){var e;this.hasListeners()||((e=R(this,Qr))==null||e.call(this),ce(this,Qr,void 0))}setEventListener(e){var r;ce(this,As,e),(r=R(this,Qr))==null||r.call(this),ce(this,Qr,e(this.setOnline.bind(this)))}setOnline(e){R(this,_s)!==e&&(ce(this,_s,e),this.listeners.forEach(s=>{s(e)}))}isOnline(){return R(this,_s)}},_s=new WeakMap,Qr=new WeakMap,As=new WeakMap,Ty),Wl=new S1;function C1(t){return Math.min(1e3*2**t,3e4)}function Hy(t){return(t??"online")==="online"?Wl.isOnline():!0}var zd=class extends Error{constructor(t){super("CancelledError"),this.revert=t==null?void 0:t.revert,this.silent=t==null?void 0:t.silent}};function Vy(t){let e=!1,r=0,s;const i=Fd(),l=()=>i.status!=="pending",u=b=>{var k;if(!l()){const T=new zd(b);C(T),(k=t.onCancel)==null||k.call(t,T)}},d=()=>{e=!0},h=()=>{e=!1},p=()=>kf.isFocused()&&(t.networkMode==="always"||Wl.isOnline())&&t.canRun(),y=()=>Hy(t.networkMode)&&t.canRun(),v=b=>{l()||(s==null||s(),i.resolve(b))},C=b=>{l()||(s==null||s(),i.reject(b))},w=()=>new Promise(b=>{var k;s=T=>{(l()||p())&&b(T)},(k=t.onPause)==null||k.call(t)}).then(()=>{var b;s=void 0,l()||(b=t.onContinue)==null||b.call(t)}),E=()=>{if(l())return;let b;const k=r===0?t.initialPromise:void 0;try{b=k??t.fn()}catch(T){b=Promise.reject(T)}Promise.resolve(b).then(v).catch(T=>{var V;if(l())return;const j=t.retry??(Fo?0:3),_=t.retryDelay??C1,A=typeof _=="function"?_(r,T):_,F=j===!0||typeof j=="number"&&rp()?void 0:w()).then(()=>{e?C(T):E()})})};return{promise:i,status:()=>i.status,cancel:u,continue:()=>(s==null||s(),i),cancelRetry:d,continueRetry:h,canStart:y,start:()=>(y()?E():w().then(E),i)}}var To,Oy,Wy=(Oy=class{constructor(){ve(this,To)}destroy(){this.clearGcTimeout()}scheduleGc(){this.clearGcTimeout(),Id(this.gcTime)&&ce(this,To,Co.setTimeout(()=>{this.optionalRemove()},this.gcTime))}updateGcTime(t){this.gcTime=Math.max(this.gcTime||0,t??(Fo?1/0:300*1e3))}clearGcTimeout(){R(this,To)&&(Co.clearTimeout(R(this,To)),ce(this,To,void 0))}},To=new WeakMap,Oy),Oo,Ls,ln,jo,dt,Gi,_o,En,fr,jy,E1=(jy=class extends Wy{constructor(e){super();ve(this,En);ve(this,Oo);ve(this,Ls);ve(this,ln);ve(this,jo);ve(this,dt);ve(this,Gi);ve(this,_o);ce(this,_o,!1),ce(this,Gi,e.defaultOptions),this.setOptions(e.options),this.observers=[],ce(this,jo,e.client),ce(this,ln,R(this,jo).getQueryCache()),this.queryKey=e.queryKey,this.queryHash=e.queryHash,ce(this,Oo,tg(this.options)),this.state=e.state??R(this,Oo),this.scheduleGc()}get meta(){return this.options.meta}get promise(){var e;return(e=R(this,dt))==null?void 0:e.promise}setOptions(e){if(this.options={...R(this,Gi),...e},this.updateGcTime(this.options.gcTime),this.state&&this.state.data===void 0){const r=tg(this.options);r.data!==void 0&&(this.setState(eg(r.data,r.dataUpdatedAt)),ce(this,Oo,r))}}optionalRemove(){!this.observers.length&&this.state.fetchStatus==="idle"&&R(this,ln).remove(this)}setData(e,r){const s=Md(this.state.data,e,this.options);return Te(this,En,fr).call(this,{data:s,type:"success",dataUpdatedAt:r==null?void 0:r.updatedAt,manual:r==null?void 0:r.manual}),s}setState(e,r){Te(this,En,fr).call(this,{type:"setState",state:e,setStateOptions:r})}cancel(e){var s,i;const r=(s=R(this,dt))==null?void 0:s.promise;return(i=R(this,dt))==null||i.cancel(e),r?r.then(Ot).catch(Ot):Promise.resolve()}destroy(){super.destroy(),this.cancel({silent:!0})}reset(){this.destroy(),this.setState(R(this,Oo))}isActive(){return this.observers.some(e=>cn(e.options.enabled,this)!==!1)}isDisabled(){return this.getObserversCount()>0?!this.isActive():this.options.queryFn===Ef||this.state.dataUpdateCount+this.state.errorUpdateCount===0}isStatic(){return this.getObserversCount()>0?this.observers.some(e=>to(e.options.staleTime,this)==="static"):!1}isStale(){return this.getObserversCount()>0?this.observers.some(e=>e.getCurrentResult().isStale):this.state.data===void 0||this.state.isInvalidated}isStaleByTime(e=0){return this.state.data===void 0?!0:e==="static"?!1:this.state.isInvalidated?!0:!zy(this.state.dataUpdatedAt,e)}onFocus(){var r;const e=this.observers.find(s=>s.shouldFetchOnWindowFocus());e==null||e.refetch({cancelRefetch:!1}),(r=R(this,dt))==null||r.continue()}onOnline(){var r;const e=this.observers.find(s=>s.shouldFetchOnReconnect());e==null||e.refetch({cancelRefetch:!1}),(r=R(this,dt))==null||r.continue()}addObserver(e){this.observers.includes(e)||(this.observers.push(e),this.clearGcTimeout(),R(this,ln).notify({type:"observerAdded",query:this,observer:e}))}removeObserver(e){this.observers.includes(e)&&(this.observers=this.observers.filter(r=>r!==e),this.observers.length||(R(this,dt)&&(R(this,_o)?R(this,dt).cancel({revert:!0}):R(this,dt).cancelRetry()),this.scheduleGc()),R(this,ln).notify({type:"observerRemoved",query:this,observer:e}))}getObserversCount(){return this.observers.length}invalidate(){this.state.isInvalidated||Te(this,En,fr).call(this,{type:"invalidate"})}async fetch(e,r){var h,p,y,v,C,w,E,b,k,T,j,_;if(this.state.fetchStatus!=="idle"&&((h=R(this,dt))==null?void 0:h.status())!=="rejected"){if(this.state.data!==void 0&&(r!=null&&r.cancelRefetch))this.cancel({silent:!0});else if(R(this,dt))return R(this,dt).continueRetry(),R(this,dt).promise}if(e&&this.setOptions(e),!this.options.queryFn){const A=this.observers.find(F=>F.options.queryFn);A&&this.setOptions(A.options)}const s=new AbortController,i=A=>{Object.defineProperty(A,"signal",{enumerable:!0,get:()=>(ce(this,_o,!0),s.signal)})},l=()=>{const A=Uy(this.options,r),V=(()=>{const B={client:R(this,jo),queryKey:this.queryKey,meta:this.meta};return i(B),B})();return ce(this,_o,!1),this.options.persister?this.options.persister(A,V,this):A(V)},d=(()=>{const A={fetchOptions:r,options:this.options,queryKey:this.queryKey,client:R(this,jo),state:this.state,fetchFn:l};return i(A),A})();(p=this.options.behavior)==null||p.onFetch(d,this),ce(this,Ls,this.state),(this.state.fetchStatus==="idle"||this.state.fetchMeta!==((y=d.fetchOptions)==null?void 0:y.meta))&&Te(this,En,fr).call(this,{type:"fetch",meta:(v=d.fetchOptions)==null?void 0:v.meta}),ce(this,dt,Vy({initialPromise:r==null?void 0:r.initialPromise,fn:d.fetchFn,onCancel:A=>{A instanceof zd&&A.revert&&this.setState({...R(this,Ls),fetchStatus:"idle"}),s.abort()},onFail:(A,F)=>{Te(this,En,fr).call(this,{type:"failed",failureCount:A,error:F})},onPause:()=>{Te(this,En,fr).call(this,{type:"pause"})},onContinue:()=>{Te(this,En,fr).call(this,{type:"continue"})},retry:d.options.retry,retryDelay:d.options.retryDelay,networkMode:d.options.networkMode,canRun:()=>!0}));try{const A=await R(this,dt).start();if(A===void 0)throw new Error(`${this.queryHash} data is undefined`);return this.setData(A),(w=(C=R(this,ln).config).onSuccess)==null||w.call(C,A,this),(b=(E=R(this,ln).config).onSettled)==null||b.call(E,A,this.state.error,this),A}catch(A){if(A instanceof zd){if(A.silent)return R(this,dt).promise;if(A.revert){if(this.state.data===void 0)throw A;return this.state.data}}throw Te(this,En,fr).call(this,{type:"error",error:A}),(T=(k=R(this,ln).config).onError)==null||T.call(k,A,this),(_=(j=R(this,ln).config).onSettled)==null||_.call(j,this.state.data,A,this),A}finally{this.scheduleGc()}}},Oo=new WeakMap,Ls=new WeakMap,ln=new WeakMap,jo=new WeakMap,dt=new WeakMap,Gi=new WeakMap,_o=new WeakMap,En=new WeakSet,fr=function(e){const r=s=>{switch(e.type){case"failed":return{...s,fetchFailureCount:e.failureCount,fetchFailureReason:e.error};case"pause":return{...s,fetchStatus:"paused"};case"continue":return{...s,fetchStatus:"fetching"};case"fetch":return{...s,...Ky(s.data,this.options),fetchMeta:e.meta??null};case"success":const i={...s,...eg(e.data,e.dataUpdatedAt),dataUpdateCount:s.dataUpdateCount+1,...!e.manual&&{fetchStatus:"idle",fetchFailureCount:0,fetchFailureReason:null}};return ce(this,Ls,e.manual?i:void 0),i;case"error":const l=e.error;return{...s,error:l,errorUpdateCount:s.errorUpdateCount+1,errorUpdatedAt:Date.now(),fetchFailureCount:s.fetchFailureCount+1,fetchFailureReason:l,fetchStatus:"idle",status:"error"};case"invalidate":return{...s,isInvalidated:!0};case"setState":return{...s,...e.state}}};this.state=r(this.state),at.batch(()=>{this.observers.forEach(s=>{s.onQueryUpdate()}),R(this,ln).notify({query:this,type:"updated",action:e})})},jy);function Ky(t,e){return{fetchFailureCount:0,fetchFailureReason:null,fetchStatus:Hy(e.networkMode)?"fetching":"paused",...t===void 0&&{error:null,status:"pending"}}}function eg(t,e){return{data:t,dataUpdatedAt:e??Date.now(),error:null,isInvalidated:!1,status:"success"}}function tg(t){const e=typeof t.initialData=="function"?t.initialData():t.initialData,r=e!==void 0,s=r?typeof t.initialDataUpdatedAt=="function"?t.initialDataUpdatedAt():t.initialDataUpdatedAt:0;return{data:e,dataUpdateCount:0,dataUpdatedAt:r?s??Date.now():0,error:null,errorUpdateCount:0,errorUpdatedAt:0,fetchFailureCount:0,fetchFailureReason:null,fetchMeta:null,isInvalidated:!1,status:r?"success":"pending",fetchStatus:"idle"}}var Ut,Ae,Xi,Pt,Ao,Is,hr,Yr,Ji,Ds,Ms,Lo,Io,Gr,Fs,$e,Ui,$d,Ud,Bd,Hd,Vd,Wd,Kd,qy,_y,k1=(_y=class extends Ks{constructor(e,r){super();ve(this,$e);ve(this,Ut);ve(this,Ae);ve(this,Xi);ve(this,Pt);ve(this,Ao);ve(this,Is);ve(this,hr);ve(this,Yr);ve(this,Ji);ve(this,Ds);ve(this,Ms);ve(this,Lo);ve(this,Io);ve(this,Gr);ve(this,Fs,new Set);this.options=r,ce(this,Ut,e),ce(this,Yr,null),ce(this,hr,Fd()),this.bindMethods(),this.setOptions(r)}bindMethods(){this.refetch=this.refetch.bind(this)}onSubscribe(){this.listeners.size===1&&(R(this,Ae).addObserver(this),ng(R(this,Ae),this.options)?Te(this,$e,Ui).call(this):this.updateResult(),Te(this,$e,Hd).call(this))}onUnsubscribe(){this.hasListeners()||this.destroy()}shouldFetchOnReconnect(){return qd(R(this,Ae),this.options,this.options.refetchOnReconnect)}shouldFetchOnWindowFocus(){return qd(R(this,Ae),this.options,this.options.refetchOnWindowFocus)}destroy(){this.listeners=new Set,Te(this,$e,Vd).call(this),Te(this,$e,Wd).call(this),R(this,Ae).removeObserver(this)}setOptions(e){const r=this.options,s=R(this,Ae);if(this.options=R(this,Ut).defaultQueryOptions(e),this.options.enabled!==void 0&&typeof this.options.enabled!="boolean"&&typeof this.options.enabled!="function"&&typeof cn(this.options.enabled,R(this,Ae))!="boolean")throw new Error("Expected enabled to be a boolean or a callback that returns a boolean");Te(this,$e,Kd).call(this),R(this,Ae).setOptions(this.options),r._defaulted&&!Vl(this.options,r)&&R(this,Ut).getQueryCache().notify({type:"observerOptionsUpdated",query:R(this,Ae),observer:this});const i=this.hasListeners();i&&rg(R(this,Ae),s,this.options,r)&&Te(this,$e,Ui).call(this),this.updateResult(),i&&(R(this,Ae)!==s||cn(this.options.enabled,R(this,Ae))!==cn(r.enabled,R(this,Ae))||to(this.options.staleTime,R(this,Ae))!==to(r.staleTime,R(this,Ae)))&&Te(this,$e,$d).call(this);const l=Te(this,$e,Ud).call(this);i&&(R(this,Ae)!==s||cn(this.options.enabled,R(this,Ae))!==cn(r.enabled,R(this,Ae))||l!==R(this,Gr))&&Te(this,$e,Bd).call(this,l)}getOptimisticResult(e){const r=R(this,Ut).getQueryCache().build(R(this,Ut),e),s=this.createResult(r,e);return R1(this,s)&&(ce(this,Pt,s),ce(this,Is,this.options),ce(this,Ao,R(this,Ae).state)),s}getCurrentResult(){return R(this,Pt)}trackResult(e,r){return new Proxy(e,{get:(s,i)=>(this.trackProp(i),r==null||r(i),i==="promise"&&(this.trackProp("data"),!this.options.experimental_prefetchInRender&&R(this,hr).status==="pending"&&R(this,hr).reject(new Error("experimental_prefetchInRender feature flag is not enabled"))),Reflect.get(s,i))})}trackProp(e){R(this,Fs).add(e)}getCurrentQuery(){return R(this,Ae)}refetch({...e}={}){return this.fetch({...e})}fetchOptimistic(e){const r=R(this,Ut).defaultQueryOptions(e),s=R(this,Ut).getQueryCache().build(R(this,Ut),r);return s.fetch().then(()=>this.createResult(s,r))}fetch(e){return Te(this,$e,Ui).call(this,{...e,cancelRefetch:e.cancelRefetch??!0}).then(()=>(this.updateResult(),R(this,Pt)))}createResult(e,r){var G;const s=R(this,Ae),i=this.options,l=R(this,Pt),u=R(this,Ao),d=R(this,Is),p=e!==s?e.state:R(this,Xi),{state:y}=e;let v={...y},C=!1,w;if(r._optimisticResults){const W=this.hasListeners(),le=!W&&ng(e,r),K=W&&rg(e,s,r,i);(le||K)&&(v={...v,...Ky(y.data,e.options)}),r._optimisticResults==="isRestoring"&&(v.fetchStatus="idle")}let{error:E,errorUpdatedAt:b,status:k}=v;w=v.data;let T=!1;if(r.placeholderData!==void 0&&w===void 0&&k==="pending"){let W;l!=null&&l.isPlaceholderData&&r.placeholderData===(d==null?void 0:d.placeholderData)?(W=l.data,T=!0):W=typeof r.placeholderData=="function"?r.placeholderData((G=R(this,Ms))==null?void 0:G.state.data,R(this,Ms)):r.placeholderData,W!==void 0&&(k="success",w=Md(l==null?void 0:l.data,W,r),C=!0)}if(r.select&&w!==void 0&&!T)if(l&&w===(u==null?void 0:u.data)&&r.select===R(this,Ji))w=R(this,Ds);else try{ce(this,Ji,r.select),w=r.select(w),w=Md(l==null?void 0:l.data,w,r),ce(this,Ds,w),ce(this,Yr,null)}catch(W){ce(this,Yr,W)}R(this,Yr)&&(E=R(this,Yr),w=R(this,Ds),b=Date.now(),k="error");const j=v.fetchStatus==="fetching",_=k==="pending",A=k==="error",F=_&&j,V=w!==void 0,te={status:k,fetchStatus:v.fetchStatus,isPending:_,isSuccess:k==="success",isError:A,isInitialLoading:F,isLoading:F,data:w,dataUpdatedAt:v.dataUpdatedAt,error:E,errorUpdatedAt:b,failureCount:v.fetchFailureCount,failureReason:v.fetchFailureReason,errorUpdateCount:v.errorUpdateCount,isFetched:v.dataUpdateCount>0||v.errorUpdateCount>0,isFetchedAfterMount:v.dataUpdateCount>p.dataUpdateCount||v.errorUpdateCount>p.errorUpdateCount,isFetching:j,isRefetching:j&&!_,isLoadingError:A&&!V,isPaused:v.fetchStatus==="paused",isPlaceholderData:C,isRefetchError:A&&V,isStale:Nf(e,r),refetch:this.refetch,promise:R(this,hr),isEnabled:cn(r.enabled,e)!==!1};if(this.options.experimental_prefetchInRender){const W=Z=>{te.status==="error"?Z.reject(te.error):te.data!==void 0&&Z.resolve(te.data)},le=()=>{const Z=ce(this,hr,te.promise=Fd());W(Z)},K=R(this,hr);switch(K.status){case"pending":e.queryHash===s.queryHash&&W(K);break;case"fulfilled":(te.status==="error"||te.data!==K.value)&&le();break;case"rejected":(te.status!=="error"||te.error!==K.reason)&&le();break}}return te}updateResult(){const e=R(this,Pt),r=this.createResult(R(this,Ae),this.options);if(ce(this,Ao,R(this,Ae).state),ce(this,Is,this.options),R(this,Ao).data!==void 0&&ce(this,Ms,R(this,Ae)),Vl(r,e))return;ce(this,Pt,r);const s=()=>{if(!e)return!0;const{notifyOnChangeProps:i}=this.options,l=typeof i=="function"?i():i;if(l==="all"||!l&&!R(this,Fs).size)return!0;const u=new Set(l??R(this,Fs));return this.options.throwOnError&&u.add("error"),Object.keys(R(this,Pt)).some(d=>{const h=d;return R(this,Pt)[h]!==e[h]&&u.has(h)})};Te(this,$e,qy).call(this,{listeners:s()})}onQueryUpdate(){this.updateResult(),this.hasListeners()&&Te(this,$e,Hd).call(this)}},Ut=new WeakMap,Ae=new WeakMap,Xi=new WeakMap,Pt=new WeakMap,Ao=new WeakMap,Is=new WeakMap,hr=new WeakMap,Yr=new WeakMap,Ji=new WeakMap,Ds=new WeakMap,Ms=new WeakMap,Lo=new WeakMap,Io=new WeakMap,Gr=new WeakMap,Fs=new WeakMap,$e=new WeakSet,Ui=function(e){Te(this,$e,Kd).call(this);let r=R(this,Ae).fetch(this.options,e);return e!=null&&e.throwOnError||(r=r.catch(Ot)),r},$d=function(){Te(this,$e,Vd).call(this);const e=to(this.options.staleTime,R(this,Ae));if(Fo||R(this,Pt).isStale||!Id(e))return;const s=zy(R(this,Pt).dataUpdatedAt,e)+1;ce(this,Lo,Co.setTimeout(()=>{R(this,Pt).isStale||this.updateResult()},s))},Ud=function(){return(typeof this.options.refetchInterval=="function"?this.options.refetchInterval(R(this,Ae)):this.options.refetchInterval)??!1},Bd=function(e){Te(this,$e,Wd).call(this),ce(this,Gr,e),!(Fo||cn(this.options.enabled,R(this,Ae))===!1||!Id(R(this,Gr))||R(this,Gr)===0)&&ce(this,Io,Co.setInterval(()=>{(this.options.refetchIntervalInBackground||kf.isFocused())&&Te(this,$e,Ui).call(this)},R(this,Gr)))},Hd=function(){Te(this,$e,$d).call(this),Te(this,$e,Bd).call(this,Te(this,$e,Ud).call(this))},Vd=function(){R(this,Lo)&&(Co.clearTimeout(R(this,Lo)),ce(this,Lo,void 0))},Wd=function(){R(this,Io)&&(Co.clearInterval(R(this,Io)),ce(this,Io,void 0))},Kd=function(){const e=R(this,Ut).getQueryCache().build(R(this,Ut),this.options);if(e===R(this,Ae))return;const r=R(this,Ae);ce(this,Ae,e),ce(this,Xi,e.state),this.hasListeners()&&(r==null||r.removeObserver(this),e.addObserver(this))},qy=function(e){at.batch(()=>{e.listeners&&this.listeners.forEach(r=>{r(R(this,Pt))}),R(this,Ut).getQueryCache().notify({query:R(this,Ae),type:"observerResultsUpdated"})})},_y);function N1(t,e){return cn(e.enabled,t)!==!1&&t.state.data===void 0&&!(t.state.status==="error"&&e.retryOnMount===!1)}function ng(t,e){return N1(t,e)||t.state.data!==void 0&&qd(t,e,e.refetchOnMount)}function qd(t,e,r){if(cn(e.enabled,t)!==!1&&to(e.staleTime,t)!=="static"){const s=typeof r=="function"?r(t):r;return s==="always"||s!==!1&&Nf(t,e)}return!1}function rg(t,e,r,s){return(t!==e||cn(s.enabled,t)===!1)&&(!r.suspense||t.state.status!=="error")&&Nf(t,r)}function Nf(t,e){return cn(e.enabled,t)!==!1&&t.isStaleByTime(to(e.staleTime,t))}function R1(t,e){return!Vl(t.getCurrentResult(),e)}function og(t){return{onFetch:(e,r)=>{var y,v,C,w,E;const s=e.options,i=(C=(v=(y=e.fetchOptions)==null?void 0:y.meta)==null?void 0:v.fetchMore)==null?void 0:C.direction,l=((w=e.state.data)==null?void 0:w.pages)||[],u=((E=e.state.data)==null?void 0:E.pageParams)||[];let d={pages:[],pageParams:[]},h=0;const p=async()=>{let b=!1;const k=_=>{Object.defineProperty(_,"signal",{enumerable:!0,get:()=>(e.signal.aborted?b=!0:e.signal.addEventListener("abort",()=>{b=!0}),e.signal)})},T=Uy(e.options,e.fetchOptions),j=async(_,A,F)=>{if(b)return Promise.reject();if(A==null&&_.pages.length)return Promise.resolve(_);const B=(()=>{const le={client:e.client,queryKey:e.queryKey,pageParam:A,direction:F?"backward":"forward",meta:e.options.meta};return k(le),le})(),te=await T(B),{maxPages:G}=e.options,W=F?v1:y1;return{pages:W(_.pages,te,G),pageParams:W(_.pageParams,A,G)}};if(i&&l.length){const _=i==="backward",A=_?P1:sg,F={pages:l,pageParams:u},V=A(s,F);d=await j(F,V,_)}else{const _=t??l.length;do{const A=h===0?u[0]??s.initialPageParam:sg(s,d);if(h>0&&A==null)break;d=await j(d,A),h++}while(h<_)}return d};e.options.persister?e.fetchFn=()=>{var b,k;return(k=(b=e.options).persister)==null?void 0:k.call(b,p,{client:e.client,queryKey:e.queryKey,meta:e.options.meta,signal:e.signal},r)}:e.fetchFn=p}}}function sg(t,{pages:e,pageParams:r}){const s=e.length-1;return e.length>0?t.getNextPageParam(e[s],e,r[s],r):void 0}function P1(t,{pages:e,pageParams:r}){var s;return e.length>0?(s=t.getPreviousPageParam)==null?void 0:s.call(t,e[0],e,r[0],r):void 0}var Zi,Vn,Tt,Do,Wn,Vr,Ay,T1=(Ay=class extends Wy{constructor(e){super();ve(this,Wn);ve(this,Zi);ve(this,Vn);ve(this,Tt);ve(this,Do);ce(this,Zi,e.client),this.mutationId=e.mutationId,ce(this,Tt,e.mutationCache),ce(this,Vn,[]),this.state=e.state||Qy(),this.setOptions(e.options),this.scheduleGc()}setOptions(e){this.options=e,this.updateGcTime(this.options.gcTime)}get meta(){return this.options.meta}addObserver(e){R(this,Vn).includes(e)||(R(this,Vn).push(e),this.clearGcTimeout(),R(this,Tt).notify({type:"observerAdded",mutation:this,observer:e}))}removeObserver(e){ce(this,Vn,R(this,Vn).filter(r=>r!==e)),this.scheduleGc(),R(this,Tt).notify({type:"observerRemoved",mutation:this,observer:e})}optionalRemove(){R(this,Vn).length||(this.state.status==="pending"?this.scheduleGc():R(this,Tt).remove(this))}continue(){var e;return((e=R(this,Do))==null?void 0:e.continue())??this.execute(this.state.variables)}async execute(e){var u,d,h,p,y,v,C,w,E,b,k,T,j,_,A,F,V,B,te,G;const r=()=>{Te(this,Wn,Vr).call(this,{type:"continue"})},s={client:R(this,Zi),meta:this.options.meta,mutationKey:this.options.mutationKey};ce(this,Do,Vy({fn:()=>this.options.mutationFn?this.options.mutationFn(e,s):Promise.reject(new Error("No mutationFn found")),onFail:(W,le)=>{Te(this,Wn,Vr).call(this,{type:"failed",failureCount:W,error:le})},onPause:()=>{Te(this,Wn,Vr).call(this,{type:"pause"})},onContinue:r,retry:this.options.retry??0,retryDelay:this.options.retryDelay,networkMode:this.options.networkMode,canRun:()=>R(this,Tt).canRun(this)}));const i=this.state.status==="pending",l=!R(this,Do).canStart();try{if(i)r();else{Te(this,Wn,Vr).call(this,{type:"pending",variables:e,isPaused:l}),await((d=(u=R(this,Tt).config).onMutate)==null?void 0:d.call(u,e,this,s));const le=await((p=(h=this.options).onMutate)==null?void 0:p.call(h,e,s));le!==this.state.context&&Te(this,Wn,Vr).call(this,{type:"pending",context:le,variables:e,isPaused:l})}const W=await R(this,Do).start();return await((v=(y=R(this,Tt).config).onSuccess)==null?void 0:v.call(y,W,e,this.state.context,this,s)),await((w=(C=this.options).onSuccess)==null?void 0:w.call(C,W,e,this.state.context,s)),await((b=(E=R(this,Tt).config).onSettled)==null?void 0:b.call(E,W,null,this.state.variables,this.state.context,this,s)),await((T=(k=this.options).onSettled)==null?void 0:T.call(k,W,null,e,this.state.context,s)),Te(this,Wn,Vr).call(this,{type:"success",data:W}),W}catch(W){try{throw await((_=(j=R(this,Tt).config).onError)==null?void 0:_.call(j,W,e,this.state.context,this,s)),await((F=(A=this.options).onError)==null?void 0:F.call(A,W,e,this.state.context,s)),await((B=(V=R(this,Tt).config).onSettled)==null?void 0:B.call(V,void 0,W,this.state.variables,this.state.context,this,s)),await((G=(te=this.options).onSettled)==null?void 0:G.call(te,void 0,W,e,this.state.context,s)),W}finally{Te(this,Wn,Vr).call(this,{type:"error",error:W})}}finally{R(this,Tt).runNext(this)}}},Zi=new WeakMap,Vn=new WeakMap,Tt=new WeakMap,Do=new WeakMap,Wn=new WeakSet,Vr=function(e){const r=s=>{switch(e.type){case"failed":return{...s,failureCount:e.failureCount,failureReason:e.error};case"pause":return{...s,isPaused:!0};case"continue":return{...s,isPaused:!1};case"pending":return{...s,context:e.context,data:void 0,failureCount:0,failureReason:null,error:null,isPaused:e.isPaused,status:"pending",variables:e.variables,submittedAt:Date.now()};case"success":return{...s,data:e.data,failureCount:0,failureReason:null,error:null,status:"success",isPaused:!1};case"error":return{...s,data:void 0,error:e.error,failureCount:s.failureCount+1,failureReason:e.error,isPaused:!1,status:"error"}}};this.state=r(this.state),at.batch(()=>{R(this,Vn).forEach(s=>{s.onMutationUpdate(e)}),R(this,Tt).notify({mutation:this,type:"updated",action:e})})},Ay);function Qy(){return{context:void 0,data:void 0,error:null,failureCount:0,failureReason:null,isPaused:!1,status:"idle",variables:void 0,submittedAt:0}}var pr,kn,ea,Ly,O1=(Ly=class extends Ks{constructor(e={}){super();ve(this,pr);ve(this,kn);ve(this,ea);this.config=e,ce(this,pr,new Set),ce(this,kn,new Map),ce(this,ea,0)}build(e,r,s){const i=new T1({client:e,mutationCache:this,mutationId:++Sl(this,ea)._,options:e.defaultMutationOptions(r),state:s});return this.add(i),i}add(e){R(this,pr).add(e);const r=El(e);if(typeof r=="string"){const s=R(this,kn).get(r);s?s.push(e):R(this,kn).set(r,[e])}this.notify({type:"added",mutation:e})}remove(e){if(R(this,pr).delete(e)){const r=El(e);if(typeof r=="string"){const s=R(this,kn).get(r);if(s)if(s.length>1){const i=s.indexOf(e);i!==-1&&s.splice(i,1)}else s[0]===e&&R(this,kn).delete(r)}}this.notify({type:"removed",mutation:e})}canRun(e){const r=El(e);if(typeof r=="string"){const s=R(this,kn).get(r),i=s==null?void 0:s.find(l=>l.state.status==="pending");return!i||i===e}else return!0}runNext(e){var s;const r=El(e);if(typeof r=="string"){const i=(s=R(this,kn).get(r))==null?void 0:s.find(l=>l!==e&&l.state.isPaused);return(i==null?void 0:i.continue())??Promise.resolve()}else return Promise.resolve()}clear(){at.batch(()=>{R(this,pr).forEach(e=>{this.notify({type:"removed",mutation:e})}),R(this,pr).clear(),R(this,kn).clear()})}getAll(){return Array.from(R(this,pr))}find(e){const r={exact:!0,...e};return this.getAll().find(s=>Xm(r,s))}findAll(e={}){return this.getAll().filter(r=>Xm(e,r))}notify(e){at.batch(()=>{this.listeners.forEach(r=>{r(e)})})}resumePausedMutations(){const e=this.getAll().filter(r=>r.state.isPaused);return at.batch(()=>Promise.all(e.map(r=>r.continue().catch(Ot))))}},pr=new WeakMap,kn=new WeakMap,ea=new WeakMap,Ly);function El(t){var e;return(e=t.options.scope)==null?void 0:e.id}var mr,Xr,Bt,gr,yr,Dl,Qd,Iy,j1=(Iy=class extends Ks{constructor(r,s){super();ve(this,yr);ve(this,mr);ve(this,Xr);ve(this,Bt);ve(this,gr);ce(this,mr,r),this.setOptions(s),this.bindMethods(),Te(this,yr,Dl).call(this)}bindMethods(){this.mutate=this.mutate.bind(this),this.reset=this.reset.bind(this)}setOptions(r){var i;const s=this.options;this.options=R(this,mr).defaultMutationOptions(r),Vl(this.options,s)||R(this,mr).getMutationCache().notify({type:"observerOptionsUpdated",mutation:R(this,Bt),observer:this}),s!=null&&s.mutationKey&&this.options.mutationKey&&zo(s.mutationKey)!==zo(this.options.mutationKey)?this.reset():((i=R(this,Bt))==null?void 0:i.state.status)==="pending"&&R(this,Bt).setOptions(this.options)}onUnsubscribe(){var r;this.hasListeners()||(r=R(this,Bt))==null||r.removeObserver(this)}onMutationUpdate(r){Te(this,yr,Dl).call(this),Te(this,yr,Qd).call(this,r)}getCurrentResult(){return R(this,Xr)}reset(){var r;(r=R(this,Bt))==null||r.removeObserver(this),ce(this,Bt,void 0),Te(this,yr,Dl).call(this),Te(this,yr,Qd).call(this)}mutate(r,s){var i;return ce(this,gr,s),(i=R(this,Bt))==null||i.removeObserver(this),ce(this,Bt,R(this,mr).getMutationCache().build(R(this,mr),this.options)),R(this,Bt).addObserver(this),R(this,Bt).execute(r)}},mr=new WeakMap,Xr=new WeakMap,Bt=new WeakMap,gr=new WeakMap,yr=new WeakSet,Dl=function(){var s;const r=((s=R(this,Bt))==null?void 0:s.state)??Qy();ce(this,Xr,{...r,isPending:r.status==="pending",isSuccess:r.status==="success",isError:r.status==="error",isIdle:r.status==="idle",mutate:this.mutate,reset:this.reset})},Qd=function(r){at.batch(()=>{var s,i,l,u,d,h,p,y;if(R(this,gr)&&this.hasListeners()){const v=R(this,Xr).variables,C=R(this,Xr).context,w={client:R(this,mr),meta:this.options.meta,mutationKey:this.options.mutationKey};(r==null?void 0:r.type)==="success"?((i=(s=R(this,gr)).onSuccess)==null||i.call(s,r.data,v,C,w),(u=(l=R(this,gr)).onSettled)==null||u.call(l,r.data,null,v,C,w)):(r==null?void 0:r.type)==="error"&&((h=(d=R(this,gr)).onError)==null||h.call(d,r.error,v,C,w),(y=(p=R(this,gr)).onSettled)==null||y.call(p,void 0,r.error,v,C,w))}this.listeners.forEach(v=>{v(R(this,Xr))})})},Iy),Kn,Dy,_1=(Dy=class extends Ks{constructor(e={}){super();ve(this,Kn);this.config=e,ce(this,Kn,new Map)}build(e,r,s){const i=r.queryKey,l=r.queryHash??Cf(i,r);let u=this.get(l);return u||(u=new E1({client:e,queryKey:i,queryHash:l,options:e.defaultQueryOptions(r),state:s,defaultOptions:e.getQueryDefaults(i)}),this.add(u)),u}add(e){R(this,Kn).has(e.queryHash)||(R(this,Kn).set(e.queryHash,e),this.notify({type:"added",query:e}))}remove(e){const r=R(this,Kn).get(e.queryHash);r&&(e.destroy(),r===e&&R(this,Kn).delete(e.queryHash),this.notify({type:"removed",query:e}))}clear(){at.batch(()=>{this.getAll().forEach(e=>{this.remove(e)})})}get(e){return R(this,Kn).get(e)}getAll(){return[...R(this,Kn).values()]}find(e){const r={exact:!0,...e};return this.getAll().find(s=>Gm(r,s))}findAll(e={}){const r=this.getAll();return Object.keys(e).length>0?r.filter(s=>Gm(e,s)):r}notify(e){at.batch(()=>{this.listeners.forEach(r=>{r(e)})})}onFocus(){at.batch(()=>{this.getAll().forEach(e=>{e.onFocus()})})}onOnline(){at.batch(()=>{this.getAll().forEach(e=>{e.onOnline()})})}},Kn=new WeakMap,Dy),Ze,Jr,Zr,zs,$s,eo,Us,Bs,My,A1=(My=class{constructor(t={}){ve(this,Ze);ve(this,Jr);ve(this,Zr);ve(this,zs);ve(this,$s);ve(this,eo);ve(this,Us);ve(this,Bs);ce(this,Ze,t.queryCache||new _1),ce(this,Jr,t.mutationCache||new O1),ce(this,Zr,t.defaultOptions||{}),ce(this,zs,new Map),ce(this,$s,new Map),ce(this,eo,0)}mount(){Sl(this,eo)._++,R(this,eo)===1&&(ce(this,Us,kf.subscribe(async t=>{t&&(await this.resumePausedMutations(),R(this,Ze).onFocus())})),ce(this,Bs,Wl.subscribe(async t=>{t&&(await this.resumePausedMutations(),R(this,Ze).onOnline())})))}unmount(){var t,e;Sl(this,eo)._--,R(this,eo)===0&&((t=R(this,Us))==null||t.call(this),ce(this,Us,void 0),(e=R(this,Bs))==null||e.call(this),ce(this,Bs,void 0))}isFetching(t){return R(this,Ze).findAll({...t,fetchStatus:"fetching"}).length}isMutating(t){return R(this,Jr).findAll({...t,status:"pending"}).length}getQueryData(t){var r;const e=this.defaultQueryOptions({queryKey:t});return(r=R(this,Ze).get(e.queryHash))==null?void 0:r.state.data}ensureQueryData(t){const e=this.defaultQueryOptions(t),r=R(this,Ze).build(this,e),s=r.state.data;return s===void 0?this.fetchQuery(t):(t.revalidateIfStale&&r.isStaleByTime(to(e.staleTime,r))&&this.prefetchQuery(e),Promise.resolve(s))}getQueriesData(t){return R(this,Ze).findAll(t).map(({queryKey:e,state:r})=>{const s=r.data;return[e,s]})}setQueryData(t,e,r){const s=this.defaultQueryOptions({queryKey:t}),i=R(this,Ze).get(s.queryHash),l=i==null?void 0:i.state.data,u=p1(e,l);if(u!==void 0)return R(this,Ze).build(this,s).setData(u,{...r,manual:!0})}setQueriesData(t,e,r){return at.batch(()=>R(this,Ze).findAll(t).map(({queryKey:s})=>[s,this.setQueryData(s,e,r)]))}getQueryState(t){var r;const e=this.defaultQueryOptions({queryKey:t});return(r=R(this,Ze).get(e.queryHash))==null?void 0:r.state}removeQueries(t){const e=R(this,Ze);at.batch(()=>{e.findAll(t).forEach(r=>{e.remove(r)})})}resetQueries(t,e){const r=R(this,Ze);return at.batch(()=>(r.findAll(t).forEach(s=>{s.reset()}),this.refetchQueries({type:"active",...t},e)))}cancelQueries(t,e={}){const r={revert:!0,...e},s=at.batch(()=>R(this,Ze).findAll(t).map(i=>i.cancel(r)));return Promise.all(s).then(Ot).catch(Ot)}invalidateQueries(t,e={}){return at.batch(()=>(R(this,Ze).findAll(t).forEach(r=>{r.invalidate()}),(t==null?void 0:t.refetchType)==="none"?Promise.resolve():this.refetchQueries({...t,type:(t==null?void 0:t.refetchType)??(t==null?void 0:t.type)??"active"},e)))}refetchQueries(t,e={}){const r={...e,cancelRefetch:e.cancelRefetch??!0},s=at.batch(()=>R(this,Ze).findAll(t).filter(i=>!i.isDisabled()&&!i.isStatic()).map(i=>{let l=i.fetch(void 0,r);return r.throwOnError||(l=l.catch(Ot)),i.state.fetchStatus==="paused"?Promise.resolve():l}));return Promise.all(s).then(Ot)}fetchQuery(t){const e=this.defaultQueryOptions(t);e.retry===void 0&&(e.retry=!1);const r=R(this,Ze).build(this,e);return r.isStaleByTime(to(e.staleTime,r))?r.fetch(e):Promise.resolve(r.state.data)}prefetchQuery(t){return this.fetchQuery(t).then(Ot).catch(Ot)}fetchInfiniteQuery(t){return t.behavior=og(t.pages),this.fetchQuery(t)}prefetchInfiniteQuery(t){return this.fetchInfiniteQuery(t).then(Ot).catch(Ot)}ensureInfiniteQueryData(t){return t.behavior=og(t.pages),this.ensureQueryData(t)}resumePausedMutations(){return Wl.isOnline()?R(this,Jr).resumePausedMutations():Promise.resolve()}getQueryCache(){return R(this,Ze)}getMutationCache(){return R(this,Jr)}getDefaultOptions(){return R(this,Zr)}setDefaultOptions(t){ce(this,Zr,t)}setQueryDefaults(t,e){R(this,zs).set(zo(t),{queryKey:t,defaultOptions:e})}getQueryDefaults(t){const e=[...R(this,zs).values()],r={};return e.forEach(s=>{Wi(t,s.queryKey)&&Object.assign(r,s.defaultOptions)}),r}setMutationDefaults(t,e){R(this,$s).set(zo(t),{mutationKey:t,defaultOptions:e})}getMutationDefaults(t){const e=[...R(this,$s).values()],r={};return e.forEach(s=>{Wi(t,s.mutationKey)&&Object.assign(r,s.defaultOptions)}),r}defaultQueryOptions(t){if(t._defaulted)return t;const e={...R(this,Zr).queries,...this.getQueryDefaults(t.queryKey),...t,_defaulted:!0};return e.queryHash||(e.queryHash=Cf(e.queryKey,e)),e.refetchOnReconnect===void 0&&(e.refetchOnReconnect=e.networkMode!=="always"),e.throwOnError===void 0&&(e.throwOnError=!!e.suspense),!e.networkMode&&e.persister&&(e.networkMode="offlineFirst"),e.queryFn===Ef&&(e.enabled=!1),e}defaultMutationOptions(t){return t!=null&&t._defaulted?t:{...R(this,Zr).mutations,...(t==null?void 0:t.mutationKey)&&this.getMutationDefaults(t.mutationKey),...t,_defaulted:!0}}clear(){R(this,Ze).clear(),R(this,Jr).clear()}},Ze=new WeakMap,Jr=new WeakMap,Zr=new WeakMap,zs=new WeakMap,$s=new WeakMap,eo=new WeakMap,Us=new WeakMap,Bs=new WeakMap,My),Yy=x.createContext(void 0),lc=t=>{const e=x.useContext(Yy);if(!e)throw new Error("No QueryClient set, use QueryClientProvider to set one");return e},L1=({client:t,children:e})=>(x.useEffect(()=>(t.mount(),()=>{t.unmount()}),[t]),g.jsx(Yy.Provider,{value:t,children:e})),Gy=x.createContext(!1),I1=()=>x.useContext(Gy);Gy.Provider;function D1(){let t=!1;return{clearReset:()=>{t=!1},reset:()=>{t=!0},isReset:()=>t}}var M1=x.createContext(D1()),F1=()=>x.useContext(M1),z1=(t,e)=>{(t.suspense||t.throwOnError||t.experimental_prefetchInRender)&&(e.isReset()||(t.retryOnMount=!1))},$1=t=>{x.useEffect(()=>{t.clearReset()},[t])},U1=({result:t,errorResetBoundary:e,throwOnError:r,query:s,suspense:i})=>t.isError&&!e.isReset()&&!t.isFetching&&s&&(i&&t.data===void 0||By(r,[t.error,s])),B1=t=>{if(t.suspense){const r=i=>i==="static"?i:Math.max(i??1e3,1e3),s=t.staleTime;t.staleTime=typeof s=="function"?(...i)=>r(s(...i)):r(s),typeof t.gcTime=="number"&&(t.gcTime=Math.max(t.gcTime,1e3))}},H1=(t,e)=>t.isLoading&&t.isFetching&&!e,V1=(t,e)=>(t==null?void 0:t.suspense)&&e.isPending,ig=(t,e,r)=>e.fetchOptimistic(t).catch(()=>{r.clearReset()});function W1(t,e,r){var v,C,w,E,b;const s=I1(),i=F1(),l=lc(),u=l.defaultQueryOptions(t);(C=(v=l.getDefaultOptions().queries)==null?void 0:v._experimental_beforeQuery)==null||C.call(v,u),u._optimisticResults=s?"isRestoring":"optimistic",B1(u),z1(u,i),$1(i);const d=!l.getQueryCache().get(u.queryHash),[h]=x.useState(()=>new e(l,u)),p=h.getOptimisticResult(u),y=!s&&t.subscribed!==!1;if(x.useSyncExternalStore(x.useCallback(k=>{const T=y?h.subscribe(at.batchCalls(k)):Ot;return h.updateResult(),T},[h,y]),()=>h.getCurrentResult(),()=>h.getCurrentResult()),x.useEffect(()=>{h.setOptions(u)},[u,h]),V1(u,p))throw ig(u,h,i);if(U1({result:p,errorResetBoundary:i,throwOnError:u.throwOnError,query:l.getQueryCache().get(u.queryHash),suspense:u.suspense}))throw p.error;if((E=(w=l.getDefaultOptions().queries)==null?void 0:w._experimental_afterQuery)==null||E.call(w,u,p),u.experimental_prefetchInRender&&!Fo&&H1(p,s)){const k=d?ig(u,h,i):(b=l.getQueryCache().get(u.queryHash))==null?void 0:b.promise;k==null||k.catch(Ot).finally(()=>{h.updateResult()})}return u.notifyOnChangeProps?p:h.trackResult(p)}function ta(t,e){return W1(t,k1)}function Xy(t,e){const r=lc(),[s]=x.useState(()=>new j1(r,t));x.useEffect(()=>{s.setOptions(t)},[s,t]);const i=x.useSyncExternalStore(x.useCallback(u=>s.subscribe(at.batchCalls(u)),[s]),()=>s.getCurrentResult(),()=>s.getCurrentResult()),l=x.useCallback((u,d)=>{s.mutate(u,d).catch(Ot)},[s]);if(i.error&&By(s.options.throwOnError,[i.error]))throw i.error;return{...i,mutate:l,mutateAsync:i.mutate}}var na=Fy();const Jy=bf(na);var K1=t=>{switch(t){case"success":return Y1;case"info":return X1;case"warning":return G1;case"error":return J1;default:return null}},q1=Array(12).fill(0),Q1=({visible:t,className:e})=>oe.createElement("div",{className:["sonner-loading-wrapper",e].filter(Boolean).join(" "),"data-visible":t},oe.createElement("div",{className:"sonner-spinner"},q1.map((r,s)=>oe.createElement("div",{className:"sonner-loading-bar",key:`spinner-bar-${s}`})))),Y1=oe.createElement("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 20 20",fill:"currentColor",height:"20",width:"20"},oe.createElement("path",{fillRule:"evenodd",d:"M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z",clipRule:"evenodd"})),G1=oe.createElement("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",height:"20",width:"20"},oe.createElement("path",{fillRule:"evenodd",d:"M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z",clipRule:"evenodd"})),X1=oe.createElement("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 20 20",fill:"currentColor",height:"20",width:"20"},oe.createElement("path",{fillRule:"evenodd",d:"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z",clipRule:"evenodd"})),J1=oe.createElement("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 20 20",fill:"currentColor",height:"20",width:"20"},oe.createElement("path",{fillRule:"evenodd",d:"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z",clipRule:"evenodd"})),Z1=oe.createElement("svg",{xmlns:"http://www.w3.org/2000/svg",width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"1.5",strokeLinecap:"round",strokeLinejoin:"round"},oe.createElement("line",{x1:"18",y1:"6",x2:"6",y2:"18"}),oe.createElement("line",{x1:"6",y1:"6",x2:"18",y2:"18"})),eS=()=>{let[t,e]=oe.useState(document.hidden);return oe.useEffect(()=>{let r=()=>{e(document.hidden)};return document.addEventListener("visibilitychange",r),()=>window.removeEventListener("visibilitychange",r)},[]),t},Yd=1,tS=class{constructor(){this.subscribe=t=>(this.subscribers.push(t),()=>{let e=this.subscribers.indexOf(t);this.subscribers.splice(e,1)}),this.publish=t=>{this.subscribers.forEach(e=>e(t))},this.addToast=t=>{this.publish(t),this.toasts=[...this.toasts,t]},this.create=t=>{var e;let{message:r,...s}=t,i=typeof(t==null?void 0:t.id)=="number"||((e=t.id)==null?void 0:e.length)>0?t.id:Yd++,l=this.toasts.find(d=>d.id===i),u=t.dismissible===void 0?!0:t.dismissible;return this.dismissedToasts.has(i)&&this.dismissedToasts.delete(i),l?this.toasts=this.toasts.map(d=>d.id===i?(this.publish({...d,...t,id:i,title:r}),{...d,...t,id:i,dismissible:u,title:r}):d):this.addToast({title:r,...s,dismissible:u,id:i}),i},this.dismiss=t=>(this.dismissedToasts.add(t),t||this.toasts.forEach(e=>{this.subscribers.forEach(r=>r({id:e.id,dismiss:!0}))}),this.subscribers.forEach(e=>e({id:t,dismiss:!0})),t),this.message=(t,e)=>this.create({...e,message:t}),this.error=(t,e)=>this.create({...e,message:t,type:"error"}),this.success=(t,e)=>this.create({...e,type:"success",message:t}),this.info=(t,e)=>this.create({...e,type:"info",message:t}),this.warning=(t,e)=>this.create({...e,type:"warning",message:t}),this.loading=(t,e)=>this.create({...e,type:"loading",message:t}),this.promise=(t,e)=>{if(!e)return;let r;e.loading!==void 0&&(r=this.create({...e,promise:t,type:"loading",message:e.loading,description:typeof e.description!="function"?e.description:void 0}));let s=t instanceof Promise?t:t(),i=r!==void 0,l,u=s.then(async h=>{if(l=["resolve",h],oe.isValidElement(h))i=!1,this.create({id:r,type:"default",message:h});else if(rS(h)&&!h.ok){i=!1;let p=typeof e.error=="function"?await e.error(`HTTP error! status: ${h.status}`):e.error,y=typeof e.description=="function"?await e.description(`HTTP error! status: ${h.status}`):e.description;this.create({id:r,type:"error",message:p,description:y})}else if(e.success!==void 0){i=!1;let p=typeof e.success=="function"?await e.success(h):e.success,y=typeof e.description=="function"?await e.description(h):e.description;this.create({id:r,type:"success",message:p,description:y})}}).catch(async h=>{if(l=["reject",h],e.error!==void 0){i=!1;let p=typeof e.error=="function"?await e.error(h):e.error,y=typeof e.description=="function"?await e.description(h):e.description;this.create({id:r,type:"error",message:p,description:y})}}).finally(()=>{var h;i&&(this.dismiss(r),r=void 0),(h=e.finally)==null||h.call(e)}),d=()=>new Promise((h,p)=>u.then(()=>l[0]==="reject"?p(l[1]):h(l[1])).catch(p));return typeof r!="string"&&typeof r!="number"?{unwrap:d}:Object.assign(r,{unwrap:d})},this.custom=(t,e)=>{let r=(e==null?void 0:e.id)||Yd++;return this.create({jsx:t(r),id:r,...e}),r},this.getActiveToasts=()=>this.toasts.filter(t=>!this.dismissedToasts.has(t.id)),this.subscribers=[],this.toasts=[],this.dismissedToasts=new Set}},Ht=new tS,nS=(t,e)=>{let r=(e==null?void 0:e.id)||Yd++;return Ht.addToast({title:t,...e,id:r}),r},rS=t=>t&&typeof t=="object"&&"ok"in t&&typeof t.ok=="boolean"&&"status"in t&&typeof t.status=="number",oS=nS,sS=()=>Ht.toasts,iS=()=>Ht.getActiveToasts(),Kl=Object.assign(oS,{success:Ht.success,info:Ht.info,warning:Ht.warning,error:Ht.error,custom:Ht.custom,message:Ht.message,promise:Ht.promise,dismiss:Ht.dismiss,loading:Ht.loading},{getHistory:sS,getToasts:iS});function aS(t,{insertAt:e}={}){if(typeof document>"u")return;let r=document.head||document.getElementsByTagName("head")[0],s=document.createElement("style");s.type="text/css",e==="top"&&r.firstChild?r.insertBefore(s,r.firstChild):r.appendChild(s),s.styleSheet?s.styleSheet.cssText=t:s.appendChild(document.createTextNode(t))}aS(`:where(html[dir="ltr"]),:where([data-sonner-toaster][dir="ltr"]){--toast-icon-margin-start: -3px;--toast-icon-margin-end: 4px;--toast-svg-margin-start: -1px;--toast-svg-margin-end: 0px;--toast-button-margin-start: auto;--toast-button-margin-end: 0;--toast-close-button-start: 0;--toast-close-button-end: unset;--toast-close-button-transform: translate(-35%, -35%)}:where(html[dir="rtl"]),:where([data-sonner-toaster][dir="rtl"]){--toast-icon-margin-start: 4px;--toast-icon-margin-end: -3px;--toast-svg-margin-start: 0px;--toast-svg-margin-end: -1px;--toast-button-margin-start: 0;--toast-button-margin-end: auto;--toast-close-button-start: unset;--toast-close-button-end: 0;--toast-close-button-transform: translate(35%, -35%)}:where([data-sonner-toaster]){position:fixed;width:var(--width);font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;--gray1: hsl(0, 0%, 99%);--gray2: hsl(0, 0%, 97.3%);--gray3: hsl(0, 0%, 95.1%);--gray4: hsl(0, 0%, 93%);--gray5: hsl(0, 0%, 90.9%);--gray6: hsl(0, 0%, 88.7%);--gray7: hsl(0, 0%, 85.8%);--gray8: hsl(0, 0%, 78%);--gray9: hsl(0, 0%, 56.1%);--gray10: hsl(0, 0%, 52.3%);--gray11: hsl(0, 0%, 43.5%);--gray12: hsl(0, 0%, 9%);--border-radius: 8px;box-sizing:border-box;padding:0;margin:0;list-style:none;outline:none;z-index:999999999;transition:transform .4s ease}:where([data-sonner-toaster][data-lifted="true"]){transform:translateY(-10px)}@media (hover: none) and (pointer: coarse){:where([data-sonner-toaster][data-lifted="true"]){transform:none}}:where([data-sonner-toaster][data-x-position="right"]){right:var(--offset-right)}:where([data-sonner-toaster][data-x-position="left"]){left:var(--offset-left)}:where([data-sonner-toaster][data-x-position="center"]){left:50%;transform:translate(-50%)}:where([data-sonner-toaster][data-y-position="top"]){top:var(--offset-top)}:where([data-sonner-toaster][data-y-position="bottom"]){bottom:var(--offset-bottom)}:where([data-sonner-toast]){--y: translateY(100%);--lift-amount: calc(var(--lift) * var(--gap));z-index:var(--z-index);position:absolute;opacity:0;transform:var(--y);filter:blur(0);touch-action:none;transition:transform .4s,opacity .4s,height .4s,box-shadow .2s;box-sizing:border-box;outline:none;overflow-wrap:anywhere}:where([data-sonner-toast][data-styled="true"]){padding:16px;background:var(--normal-bg);border:1px solid var(--normal-border);color:var(--normal-text);border-radius:var(--border-radius);box-shadow:0 4px 12px #0000001a;width:var(--width);font-size:13px;display:flex;align-items:center;gap:6px}:where([data-sonner-toast]:focus-visible){box-shadow:0 4px 12px #0000001a,0 0 0 2px #0003}:where([data-sonner-toast][data-y-position="top"]){top:0;--y: translateY(-100%);--lift: 1;--lift-amount: calc(1 * var(--gap))}:where([data-sonner-toast][data-y-position="bottom"]){bottom:0;--y: translateY(100%);--lift: -1;--lift-amount: calc(var(--lift) * var(--gap))}:where([data-sonner-toast]) :where([data-description]){font-weight:400;line-height:1.4;color:inherit}:where([data-sonner-toast]) :where([data-title]){font-weight:500;line-height:1.5;color:inherit}:where([data-sonner-toast]) :where([data-icon]){display:flex;height:16px;width:16px;position:relative;justify-content:flex-start;align-items:center;flex-shrink:0;margin-left:var(--toast-icon-margin-start);margin-right:var(--toast-icon-margin-end)}:where([data-sonner-toast][data-promise="true"]) :where([data-icon])>svg{opacity:0;transform:scale(.8);transform-origin:center;animation:sonner-fade-in .3s ease forwards}:where([data-sonner-toast]) :where([data-icon])>*{flex-shrink:0}:where([data-sonner-toast]) :where([data-icon]) svg{margin-left:var(--toast-svg-margin-start);margin-right:var(--toast-svg-margin-end)}:where([data-sonner-toast]) :where([data-content]){display:flex;flex-direction:column;gap:2px}[data-sonner-toast][data-styled=true] [data-button]{border-radius:4px;padding-left:8px;padding-right:8px;height:24px;font-size:12px;color:var(--normal-bg);background:var(--normal-text);margin-left:var(--toast-button-margin-start);margin-right:var(--toast-button-margin-end);border:none;cursor:pointer;outline:none;display:flex;align-items:center;flex-shrink:0;transition:opacity .4s,box-shadow .2s}:where([data-sonner-toast]) :where([data-button]):focus-visible{box-shadow:0 0 0 2px #0006}:where([data-sonner-toast]) :where([data-button]):first-of-type{margin-left:var(--toast-button-margin-start);margin-right:var(--toast-button-margin-end)}:where([data-sonner-toast]) :where([data-cancel]){color:var(--normal-text);background:rgba(0,0,0,.08)}:where([data-sonner-toast][data-theme="dark"]) :where([data-cancel]){background:rgba(255,255,255,.3)}:where([data-sonner-toast]) :where([data-close-button]){position:absolute;left:var(--toast-close-button-start);right:var(--toast-close-button-end);top:0;height:20px;width:20px;display:flex;justify-content:center;align-items:center;padding:0;color:var(--gray12);border:1px solid var(--gray4);transform:var(--toast-close-button-transform);border-radius:50%;cursor:pointer;z-index:1;transition:opacity .1s,background .2s,border-color .2s}[data-sonner-toast] [data-close-button]{background:var(--gray1)}:where([data-sonner-toast]) :where([data-close-button]):focus-visible{box-shadow:0 4px 12px #0000001a,0 0 0 2px #0003}:where([data-sonner-toast]) :where([data-disabled="true"]){cursor:not-allowed}:where([data-sonner-toast]):hover :where([data-close-button]):hover{background:var(--gray2);border-color:var(--gray5)}:where([data-sonner-toast][data-swiping="true"]):before{content:"";position:absolute;left:-50%;right:-50%;height:100%;z-index:-1}:where([data-sonner-toast][data-y-position="top"][data-swiping="true"]):before{bottom:50%;transform:scaleY(3) translateY(50%)}:where([data-sonner-toast][data-y-position="bottom"][data-swiping="true"]):before{top:50%;transform:scaleY(3) translateY(-50%)}:where([data-sonner-toast][data-swiping="false"][data-removed="true"]):before{content:"";position:absolute;inset:0;transform:scaleY(2)}:where([data-sonner-toast]):after{content:"";position:absolute;left:0;height:calc(var(--gap) + 1px);bottom:100%;width:100%}:where([data-sonner-toast][data-mounted="true"]){--y: translateY(0);opacity:1}:where([data-sonner-toast][data-expanded="false"][data-front="false"]){--scale: var(--toasts-before) * .05 + 1;--y: translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--scale)));height:var(--front-toast-height)}:where([data-sonner-toast])>*{transition:opacity .4s}:where([data-sonner-toast][data-expanded="false"][data-front="false"][data-styled="true"])>*{opacity:0}:where([data-sonner-toast][data-visible="false"]){opacity:0;pointer-events:none}:where([data-sonner-toast][data-mounted="true"][data-expanded="true"]){--y: translateY(calc(var(--lift) * var(--offset)));height:var(--initial-height)}:where([data-sonner-toast][data-removed="true"][data-front="true"][data-swipe-out="false"]){--y: translateY(calc(var(--lift) * -100%));opacity:0}:where([data-sonner-toast][data-removed="true"][data-front="false"][data-swipe-out="false"][data-expanded="true"]){--y: translateY(calc(var(--lift) * var(--offset) + var(--lift) * -100%));opacity:0}:where([data-sonner-toast][data-removed="true"][data-front="false"][data-swipe-out="false"][data-expanded="false"]){--y: translateY(40%);opacity:0;transition:transform .5s,opacity .2s}:where([data-sonner-toast][data-removed="true"][data-front="false"]):before{height:calc(var(--initial-height) + 20%)}[data-sonner-toast][data-swiping=true]{transform:var(--y) translateY(var(--swipe-amount-y, 0px)) translate(var(--swipe-amount-x, 0px));transition:none}[data-sonner-toast][data-swiped=true]{user-select:none}[data-sonner-toast][data-swipe-out=true][data-y-position=bottom],[data-sonner-toast][data-swipe-out=true][data-y-position=top]{animation-duration:.2s;animation-timing-function:ease-out;animation-fill-mode:forwards}[data-sonner-toast][data-swipe-out=true][data-swipe-direction=left]{animation-name:swipe-out-left}[data-sonner-toast][data-swipe-out=true][data-swipe-direction=right]{animation-name:swipe-out-right}[data-sonner-toast][data-swipe-out=true][data-swipe-direction=up]{animation-name:swipe-out-up}[data-sonner-toast][data-swipe-out=true][data-swipe-direction=down]{animation-name:swipe-out-down}@keyframes swipe-out-left{0%{transform:var(--y) translate(var(--swipe-amount-x));opacity:1}to{transform:var(--y) translate(calc(var(--swipe-amount-x) - 100%));opacity:0}}@keyframes swipe-out-right{0%{transform:var(--y) translate(var(--swipe-amount-x));opacity:1}to{transform:var(--y) translate(calc(var(--swipe-amount-x) + 100%));opacity:0}}@keyframes swipe-out-up{0%{transform:var(--y) translateY(var(--swipe-amount-y));opacity:1}to{transform:var(--y) translateY(calc(var(--swipe-amount-y) - 100%));opacity:0}}@keyframes swipe-out-down{0%{transform:var(--y) translateY(var(--swipe-amount-y));opacity:1}to{transform:var(--y) translateY(calc(var(--swipe-amount-y) + 100%));opacity:0}}@media (max-width: 600px){[data-sonner-toaster]{position:fixed;right:var(--mobile-offset-right);left:var(--mobile-offset-left);width:100%}[data-sonner-toaster][dir=rtl]{left:calc(var(--mobile-offset-left) * -1)}[data-sonner-toaster] [data-sonner-toast]{left:0;right:0;width:calc(100% - var(--mobile-offset-left) * 2)}[data-sonner-toaster][data-x-position=left]{left:var(--mobile-offset-left)}[data-sonner-toaster][data-y-position=bottom]{bottom:var(--mobile-offset-bottom)}[data-sonner-toaster][data-y-position=top]{top:var(--mobile-offset-top)}[data-sonner-toaster][data-x-position=center]{left:var(--mobile-offset-left);right:var(--mobile-offset-right);transform:none}}[data-sonner-toaster][data-theme=light]{--normal-bg: #fff;--normal-border: var(--gray4);--normal-text: var(--gray12);--success-bg: hsl(143, 85%, 96%);--success-border: hsl(145, 92%, 91%);--success-text: hsl(140, 100%, 27%);--info-bg: hsl(208, 100%, 97%);--info-border: hsl(221, 91%, 91%);--info-text: hsl(210, 92%, 45%);--warning-bg: hsl(49, 100%, 97%);--warning-border: hsl(49, 91%, 91%);--warning-text: hsl(31, 92%, 45%);--error-bg: hsl(359, 100%, 97%);--error-border: hsl(359, 100%, 94%);--error-text: hsl(360, 100%, 45%)}[data-sonner-toaster][data-theme=light] [data-sonner-toast][data-invert=true]{--normal-bg: #000;--normal-border: hsl(0, 0%, 20%);--normal-text: var(--gray1)}[data-sonner-toaster][data-theme=dark] [data-sonner-toast][data-invert=true]{--normal-bg: #fff;--normal-border: var(--gray3);--normal-text: var(--gray12)}[data-sonner-toaster][data-theme=dark]{--normal-bg: #000;--normal-bg-hover: hsl(0, 0%, 12%);--normal-border: hsl(0, 0%, 20%);--normal-border-hover: hsl(0, 0%, 25%);--normal-text: var(--gray1);--success-bg: hsl(150, 100%, 6%);--success-border: hsl(147, 100%, 12%);--success-text: hsl(150, 86%, 65%);--info-bg: hsl(215, 100%, 6%);--info-border: hsl(223, 100%, 12%);--info-text: hsl(216, 87%, 65%);--warning-bg: hsl(64, 100%, 6%);--warning-border: hsl(60, 100%, 12%);--warning-text: hsl(46, 87%, 65%);--error-bg: hsl(358, 76%, 10%);--error-border: hsl(357, 89%, 16%);--error-text: hsl(358, 100%, 81%)}[data-sonner-toaster][data-theme=dark] [data-sonner-toast] [data-close-button]{background:var(--normal-bg);border-color:var(--normal-border);color:var(--normal-text)}[data-sonner-toaster][data-theme=dark] [data-sonner-toast] [data-close-button]:hover{background:var(--normal-bg-hover);border-color:var(--normal-border-hover)}[data-rich-colors=true][data-sonner-toast][data-type=success],[data-rich-colors=true][data-sonner-toast][data-type=success] [data-close-button]{background:var(--success-bg);border-color:var(--success-border);color:var(--success-text)}[data-rich-colors=true][data-sonner-toast][data-type=info],[data-rich-colors=true][data-sonner-toast][data-type=info] [data-close-button]{background:var(--info-bg);border-color:var(--info-border);color:var(--info-text)}[data-rich-colors=true][data-sonner-toast][data-type=warning],[data-rich-colors=true][data-sonner-toast][data-type=warning] [data-close-button]{background:var(--warning-bg);border-color:var(--warning-border);color:var(--warning-text)}[data-rich-colors=true][data-sonner-toast][data-type=error],[data-rich-colors=true][data-sonner-toast][data-type=error] [data-close-button]{background:var(--error-bg);border-color:var(--error-border);color:var(--error-text)}.sonner-loading-wrapper{--size: 16px;height:var(--size);width:var(--size);position:absolute;inset:0;z-index:10}.sonner-loading-wrapper[data-visible=false]{transform-origin:center;animation:sonner-fade-out .2s ease forwards}.sonner-spinner{position:relative;top:50%;left:50%;height:var(--size);width:var(--size)}.sonner-loading-bar{animation:sonner-spin 1.2s linear infinite;background:var(--gray11);border-radius:6px;height:8%;left:-10%;position:absolute;top:-3.9%;width:24%}.sonner-loading-bar:nth-child(1){animation-delay:-1.2s;transform:rotate(.0001deg) translate(146%)}.sonner-loading-bar:nth-child(2){animation-delay:-1.1s;transform:rotate(30deg) translate(146%)}.sonner-loading-bar:nth-child(3){animation-delay:-1s;transform:rotate(60deg) translate(146%)}.sonner-loading-bar:nth-child(4){animation-delay:-.9s;transform:rotate(90deg) translate(146%)}.sonner-loading-bar:nth-child(5){animation-delay:-.8s;transform:rotate(120deg) translate(146%)}.sonner-loading-bar:nth-child(6){animation-delay:-.7s;transform:rotate(150deg) translate(146%)}.sonner-loading-bar:nth-child(7){animation-delay:-.6s;transform:rotate(180deg) translate(146%)}.sonner-loading-bar:nth-child(8){animation-delay:-.5s;transform:rotate(210deg) translate(146%)}.sonner-loading-bar:nth-child(9){animation-delay:-.4s;transform:rotate(240deg) translate(146%)}.sonner-loading-bar:nth-child(10){animation-delay:-.3s;transform:rotate(270deg) translate(146%)}.sonner-loading-bar:nth-child(11){animation-delay:-.2s;transform:rotate(300deg) translate(146%)}.sonner-loading-bar:nth-child(12){animation-delay:-.1s;transform:rotate(330deg) translate(146%)}@keyframes sonner-fade-in{0%{opacity:0;transform:scale(.8)}to{opacity:1;transform:scale(1)}}@keyframes sonner-fade-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.8)}}@keyframes sonner-spin{0%{opacity:1}to{opacity:.15}}@media (prefers-reduced-motion){[data-sonner-toast],[data-sonner-toast]>*,.sonner-loading-bar{transition:none!important;animation:none!important}}.sonner-loader{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);transform-origin:center;transition:opacity .2s,transform .2s}.sonner-loader[data-visible=false]{opacity:0;transform:scale(.8) translate(-50%,-50%)} `);function kl(t){return t.label!==void 0}var lS=3,cS="32px",uS="16px",ag=4e3,dS=356,fS=14,hS=20,pS=200;function Sn(...t){return t.filter(Boolean).join(" ")}function mS(t){let[e,r]=t.split("-"),s=[];return e&&s.push(e),r&&s.push(r),s}var gS=t=>{var e,r,s,i,l,u,d,h,p,y,v;let{invert:C,toast:w,unstyled:E,interacting:b,setHeights:k,visibleToasts:T,heights:j,index:_,toasts:A,expanded:F,removeToast:V,defaultRichColors:B,closeButton:te,style:G,cancelButtonStyle:W,actionButtonStyle:le,className:K="",descriptionClassName:Z="",duration:J,position:de,gap:ne,loadingIcon:se,expandByDefault:$,classNames:H,icons:Q,closeButtonAriaLabel:P="Close toast",pauseWhenPageIsHidden:M}=t,[ie,ae]=oe.useState(null),[me,be]=oe.useState(null),[ee,ye]=oe.useState(!1),[Se,Ne]=oe.useState(!1),[Oe,_e]=oe.useState(!1),[et,gt]=oe.useState(!1),[On,dn]=oe.useState(!1),[fn,wr]=oe.useState(0),[jn,br]=oe.useState(0),en=oe.useRef(w.duration||J||ag),Ko=oe.useRef(null),_n=oe.useRef(null),ca=_===0,ua=_+1<=T,Et=w.type,An=w.dismissible!==!1,qo=w.className||"",da=w.descriptionClassName||"",Ln=oe.useMemo(()=>j.findIndex(Ee=>Ee.toastId===w.id)||0,[j,w.id]),ao=oe.useMemo(()=>{var Ee;return(Ee=w.closeButton)!=null?Ee:te},[w.closeButton,te]),fa=oe.useMemo(()=>w.duration||J||ag,[w.duration,J]),Qo=oe.useRef(0),tr=oe.useRef(0),ha=oe.useRef(0),In=oe.useRef(null),[ei,ti]=de.split("-"),Yo=oe.useMemo(()=>j.reduce((Ee,Fe,He)=>He>=Ln?Ee:Ee+Fe.height,0),[j,Ln]),Go=eS(),Sr=w.invert||C,Dn=Et==="loading";tr.current=oe.useMemo(()=>Ln*ne+Yo,[Ln,Yo]),oe.useEffect(()=>{en.current=fa},[fa]),oe.useEffect(()=>{ye(!0)},[]),oe.useEffect(()=>{let Ee=_n.current;if(Ee){let Fe=Ee.getBoundingClientRect().height;return br(Fe),k(He=>[{toastId:w.id,height:Fe,position:w.position},...He]),()=>k(He=>He.filter(kt=>kt.toastId!==w.id))}},[k,w.id]),oe.useLayoutEffect(()=>{if(!ee)return;let Ee=_n.current,Fe=Ee.style.height;Ee.style.height="auto";let He=Ee.getBoundingClientRect().height;Ee.style.height=Fe,br(He),k(kt=>kt.find(At=>At.toastId===w.id)?kt.map(At=>At.toastId===w.id?{...At,height:He}:At):[{toastId:w.id,height:He,position:w.position},...kt])},[ee,w.title,w.description,k,w.id]);let hn=oe.useCallback(()=>{Ne(!0),wr(tr.current),k(Ee=>Ee.filter(Fe=>Fe.toastId!==w.id)),setTimeout(()=>{V(w)},pS)},[w,V,k,tr]);oe.useEffect(()=>{if(w.promise&&Et==="loading"||w.duration===1/0||w.type==="loading")return;let Ee;return F||b||M&&Go?(()=>{if(ha.current{var Fe;(Fe=w.onAutoClose)==null||Fe.call(w,w),hn()},en.current)),()=>clearTimeout(Ee)},[F,b,w,Et,M,Go,hn]),oe.useEffect(()=>{w.delete&&hn()},[hn,w.delete]);function pa(){var Ee,Fe,He;return Q!=null&&Q.loading?oe.createElement("div",{className:Sn(H==null?void 0:H.loader,(Ee=w==null?void 0:w.classNames)==null?void 0:Ee.loader,"sonner-loader"),"data-visible":Et==="loading"},Q.loading):se?oe.createElement("div",{className:Sn(H==null?void 0:H.loader,(Fe=w==null?void 0:w.classNames)==null?void 0:Fe.loader,"sonner-loader"),"data-visible":Et==="loading"},se):oe.createElement(Q1,{className:Sn(H==null?void 0:H.loader,(He=w==null?void 0:w.classNames)==null?void 0:He.loader),visible:Et==="loading"})}return oe.createElement("li",{tabIndex:0,ref:_n,className:Sn(K,qo,H==null?void 0:H.toast,(e=w==null?void 0:w.classNames)==null?void 0:e.toast,H==null?void 0:H.default,H==null?void 0:H[Et],(r=w==null?void 0:w.classNames)==null?void 0:r[Et]),"data-sonner-toast":"","data-rich-colors":(s=w.richColors)!=null?s:B,"data-styled":!(w.jsx||w.unstyled||E),"data-mounted":ee,"data-promise":!!w.promise,"data-swiped":On,"data-removed":Se,"data-visible":ua,"data-y-position":ei,"data-x-position":ti,"data-index":_,"data-front":ca,"data-swiping":Oe,"data-dismissible":An,"data-type":Et,"data-invert":Sr,"data-swipe-out":et,"data-swipe-direction":me,"data-expanded":!!(F||$&&ee),style:{"--index":_,"--toasts-before":_,"--z-index":A.length-_,"--offset":`${Se?fn:tr.current}px`,"--initial-height":$?"auto":`${jn}px`,...G,...w.style},onDragEnd:()=>{_e(!1),ae(null),In.current=null},onPointerDown:Ee=>{Dn||!An||(Ko.current=new Date,wr(tr.current),Ee.target.setPointerCapture(Ee.pointerId),Ee.target.tagName!=="BUTTON"&&(_e(!0),In.current={x:Ee.clientX,y:Ee.clientY}))},onPointerUp:()=>{var Ee,Fe,He,kt;if(et||!An)return;In.current=null;let At=Number(((Ee=_n.current)==null?void 0:Ee.style.getPropertyValue("--swipe-amount-x").replace("px",""))||0),Lt=Number(((Fe=_n.current)==null?void 0:Fe.style.getPropertyValue("--swipe-amount-y").replace("px",""))||0),pn=new Date().getTime()-((He=Ko.current)==null?void 0:He.getTime()),ft=ie==="x"?At:Lt,Mn=Math.abs(ft)/pn;if(Math.abs(ft)>=hS||Mn>.11){wr(tr.current),(kt=w.onDismiss)==null||kt.call(w,w),be(ie==="x"?At>0?"right":"left":Lt>0?"down":"up"),hn(),gt(!0),dn(!1);return}_e(!1),ae(null)},onPointerMove:Ee=>{var Fe,He,kt,At;if(!In.current||!An||((Fe=window.getSelection())==null?void 0:Fe.toString().length)>0)return;let Lt=Ee.clientY-In.current.y,pn=Ee.clientX-In.current.x,ft=(He=t.swipeDirections)!=null?He:mS(de);!ie&&(Math.abs(pn)>1||Math.abs(Lt)>1)&&ae(Math.abs(pn)>Math.abs(Lt)?"x":"y");let Mn={x:0,y:0};ie==="y"?(ft.includes("top")||ft.includes("bottom"))&&(ft.includes("top")&&Lt<0||ft.includes("bottom")&&Lt>0)&&(Mn.y=Lt):ie==="x"&&(ft.includes("left")||ft.includes("right"))&&(ft.includes("left")&&pn<0||ft.includes("right")&&pn>0)&&(Mn.x=pn),(Math.abs(Mn.x)>0||Math.abs(Mn.y)>0)&&dn(!0),(kt=_n.current)==null||kt.style.setProperty("--swipe-amount-x",`${Mn.x}px`),(At=_n.current)==null||At.style.setProperty("--swipe-amount-y",`${Mn.y}px`)}},ao&&!w.jsx?oe.createElement("button",{"aria-label":P,"data-disabled":Dn,"data-close-button":!0,onClick:Dn||!An?()=>{}:()=>{var Ee;hn(),(Ee=w.onDismiss)==null||Ee.call(w,w)},className:Sn(H==null?void 0:H.closeButton,(i=w==null?void 0:w.classNames)==null?void 0:i.closeButton)},(l=Q==null?void 0:Q.close)!=null?l:Z1):null,w.jsx||x.isValidElement(w.title)?w.jsx?w.jsx:typeof w.title=="function"?w.title():w.title:oe.createElement(oe.Fragment,null,Et||w.icon||w.promise?oe.createElement("div",{"data-icon":"",className:Sn(H==null?void 0:H.icon,(u=w==null?void 0:w.classNames)==null?void 0:u.icon)},w.promise||w.type==="loading"&&!w.icon?w.icon||pa():null,w.type!=="loading"?w.icon||(Q==null?void 0:Q[Et])||K1(Et):null):null,oe.createElement("div",{"data-content":"",className:Sn(H==null?void 0:H.content,(d=w==null?void 0:w.classNames)==null?void 0:d.content)},oe.createElement("div",{"data-title":"",className:Sn(H==null?void 0:H.title,(h=w==null?void 0:w.classNames)==null?void 0:h.title)},typeof w.title=="function"?w.title():w.title),w.description?oe.createElement("div",{"data-description":"",className:Sn(Z,da,H==null?void 0:H.description,(p=w==null?void 0:w.classNames)==null?void 0:p.description)},typeof w.description=="function"?w.description():w.description):null),x.isValidElement(w.cancel)?w.cancel:w.cancel&&kl(w.cancel)?oe.createElement("button",{"data-button":!0,"data-cancel":!0,style:w.cancelButtonStyle||W,onClick:Ee=>{var Fe,He;kl(w.cancel)&&An&&((He=(Fe=w.cancel).onClick)==null||He.call(Fe,Ee),hn())},className:Sn(H==null?void 0:H.cancelButton,(y=w==null?void 0:w.classNames)==null?void 0:y.cancelButton)},w.cancel.label):null,x.isValidElement(w.action)?w.action:w.action&&kl(w.action)?oe.createElement("button",{"data-button":!0,"data-action":!0,style:w.actionButtonStyle||le,onClick:Ee=>{var Fe,He;kl(w.action)&&((He=(Fe=w.action).onClick)==null||He.call(Fe,Ee),!Ee.defaultPrevented&&hn())},className:Sn(H==null?void 0:H.actionButton,(v=w==null?void 0:w.classNames)==null?void 0:v.actionButton)},w.action.label):null))};function lg(){if(typeof window>"u"||typeof document>"u")return"ltr";let t=document.documentElement.getAttribute("dir");return t==="auto"||!t?window.getComputedStyle(document.documentElement).direction:t}function yS(t,e){let r={};return[t,e].forEach((s,i)=>{let l=i===1,u=l?"--mobile-offset":"--offset",d=l?uS:cS;function h(p){["top","right","bottom","left"].forEach(y=>{r[`${u}-${y}`]=typeof p=="number"?`${p}px`:p})}typeof s=="number"||typeof s=="string"?h(s):typeof s=="object"?["top","right","bottom","left"].forEach(p=>{s[p]===void 0?r[`${u}-${p}`]=d:r[`${u}-${p}`]=typeof s[p]=="number"?`${s[p]}px`:s[p]}):h(d)}),r}var vS=x.forwardRef(function(t,e){let{invert:r,position:s="bottom-right",hotkey:i=["altKey","KeyT"],expand:l,closeButton:u,className:d,offset:h,mobileOffset:p,theme:y="light",richColors:v,duration:C,style:w,visibleToasts:E=lS,toastOptions:b,dir:k=lg(),gap:T=fS,loadingIcon:j,icons:_,containerAriaLabel:A="Notifications",pauseWhenPageIsHidden:F}=t,[V,B]=oe.useState([]),te=oe.useMemo(()=>Array.from(new Set([s].concat(V.filter(M=>M.position).map(M=>M.position)))),[V,s]),[G,W]=oe.useState([]),[le,K]=oe.useState(!1),[Z,J]=oe.useState(!1),[de,ne]=oe.useState(y!=="system"?y:typeof window<"u"&&window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"),se=oe.useRef(null),$=i.join("+").replace(/Key/g,"").replace(/Digit/g,""),H=oe.useRef(null),Q=oe.useRef(!1),P=oe.useCallback(M=>{B(ie=>{var ae;return(ae=ie.find(me=>me.id===M.id))!=null&&ae.delete||Ht.dismiss(M.id),ie.filter(({id:me})=>me!==M.id)})},[]);return oe.useEffect(()=>Ht.subscribe(M=>{if(M.dismiss){B(ie=>ie.map(ae=>ae.id===M.id?{...ae,delete:!0}:ae));return}setTimeout(()=>{Jy.flushSync(()=>{B(ie=>{let ae=ie.findIndex(me=>me.id===M.id);return ae!==-1?[...ie.slice(0,ae),{...ie[ae],...M},...ie.slice(ae+1)]:[M,...ie]})})})}),[]),oe.useEffect(()=>{if(y!=="system"){ne(y);return}if(y==="system"&&(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?ne("dark"):ne("light")),typeof window>"u")return;let M=window.matchMedia("(prefers-color-scheme: dark)");try{M.addEventListener("change",({matches:ie})=>{ne(ie?"dark":"light")})}catch{M.addListener(({matches:ae})=>{try{ne(ae?"dark":"light")}catch(me){console.error(me)}})}},[y]),oe.useEffect(()=>{V.length<=1&&K(!1)},[V]),oe.useEffect(()=>{let M=ie=>{var ae,me;i.every(be=>ie[be]||ie.code===be)&&(K(!0),(ae=se.current)==null||ae.focus()),ie.code==="Escape"&&(document.activeElement===se.current||(me=se.current)!=null&&me.contains(document.activeElement))&&K(!1)};return document.addEventListener("keydown",M),()=>document.removeEventListener("keydown",M)},[i]),oe.useEffect(()=>{if(se.current)return()=>{H.current&&(H.current.focus({preventScroll:!0}),H.current=null,Q.current=!1)}},[se.current]),oe.createElement("section",{ref:e,"aria-label":`${A} ${$}`,tabIndex:-1,"aria-live":"polite","aria-relevant":"additions text","aria-atomic":"false",suppressHydrationWarning:!0},te.map((M,ie)=>{var ae;let[me,be]=M.split("-");return V.length?oe.createElement("ol",{key:M,dir:k==="auto"?lg():k,tabIndex:-1,ref:se,className:d,"data-sonner-toaster":!0,"data-theme":de,"data-y-position":me,"data-lifted":le&&V.length>1&&!l,"data-x-position":be,style:{"--front-toast-height":`${((ae=G[0])==null?void 0:ae.height)||0}px`,"--width":`${dS}px`,"--gap":`${T}px`,...w,...yS(h,p)},onBlur:ee=>{Q.current&&!ee.currentTarget.contains(ee.relatedTarget)&&(Q.current=!1,H.current&&(H.current.focus({preventScroll:!0}),H.current=null))},onFocus:ee=>{ee.target instanceof HTMLElement&&ee.target.dataset.dismissible==="false"||Q.current||(Q.current=!0,H.current=ee.relatedTarget)},onMouseEnter:()=>K(!0),onMouseMove:()=>K(!0),onMouseLeave:()=>{Z||K(!1)},onDragEnd:()=>K(!1),onPointerDown:ee=>{ee.target instanceof HTMLElement&&ee.target.dataset.dismissible==="false"||J(!0)},onPointerUp:()=>J(!1)},V.filter(ee=>!ee.position&&ie===0||ee.position===M).map((ee,ye)=>{var Se,Ne;return oe.createElement(gS,{key:ee.id,icons:_,index:ye,toast:ee,defaultRichColors:v,duration:(Se=b==null?void 0:b.duration)!=null?Se:C,className:b==null?void 0:b.className,descriptionClassName:b==null?void 0:b.descriptionClassName,invert:r,visibleToasts:E,closeButton:(Ne=b==null?void 0:b.closeButton)!=null?Ne:u,interacting:Z,position:M,style:b==null?void 0:b.style,unstyled:b==null?void 0:b.unstyled,classNames:b==null?void 0:b.classNames,cancelButtonStyle:b==null?void 0:b.cancelButtonStyle,actionButtonStyle:b==null?void 0:b.actionButtonStyle,removeToast:P,toasts:V.filter(Oe=>Oe.position==ee.position),heights:G.filter(Oe=>Oe.position==ee.position),setHeights:W,expandByDefault:l,gap:T,loadingIcon:j,expanded:le,pauseWhenPageIsHidden:F,swipeDirections:t.swipeDirections})})):null}))});/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const xS=t=>t.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),Zy=(...t)=>t.filter((e,r,s)=>!!e&&e.trim()!==""&&s.indexOf(e)===r).join(" ").trim();/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */var wS={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const bS=x.forwardRef(({color:t="currentColor",size:e=24,strokeWidth:r=2,absoluteStrokeWidth:s,className:i="",children:l,iconNode:u,...d},h)=>x.createElement("svg",{ref:h,...wS,width:e,height:e,stroke:t,strokeWidth:s?Number(r)*24/Number(e):r,className:Zy("lucide",i),...d},[...u.map(([p,y])=>x.createElement(p,y)),...Array.isArray(l)?l:[l]]));/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const Ie=(t,e)=>{const r=x.forwardRef(({className:s,...i},l)=>x.createElement(bS,{ref:l,iconNode:e,className:Zy(`lucide-${xS(t)}`,s),...i}));return r.displayName=`${t}`,r};/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const SS=Ie("Bug",[["path",{d:"m8 2 1.88 1.88",key:"fmnt4t"}],["path",{d:"M14.12 3.88 16 2",key:"qol33r"}],["path",{d:"M9 7.13v-1a3.003 3.003 0 1 1 6 0v1",key:"d7y7pr"}],["path",{d:"M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6",key:"xs1cw7"}],["path",{d:"M12 20v-9",key:"1qisl0"}],["path",{d:"M6.53 9C4.6 8.8 3 7.1 3 5",key:"32zzws"}],["path",{d:"M6 13H2",key:"82j7cp"}],["path",{d:"M3 21c0-2.1 1.7-3.9 3.8-4",key:"4p0ekp"}],["path",{d:"M20.97 5c0 2.1-1.6 3.8-3.5 4",key:"18gb23"}],["path",{d:"M22 13h-4",key:"1jl80f"}],["path",{d:"M17.2 17c2.1.1 3.8 1.9 3.8 4",key:"k3fwyw"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const Rf=Ie("Check",[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const Pf=Ie("ChevronDown",[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const ev=Ie("ChevronUp",[["path",{d:"m18 15-6-6-6 6",key:"153udz"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const CS=Ie("CircleCheckBig",[["path",{d:"M21.801 10A10 10 0 1 1 17 3.335",key:"yps3ct"}],["path",{d:"m9 11 3 3L22 4",key:"1pflzl"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const ES=Ie("CircleX",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m15 9-6 6",key:"1uzhvr"}],["path",{d:"m9 9 6 6",key:"z0biqf"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const tv=Ie("Database",[["ellipse",{cx:"12",cy:"5",rx:"9",ry:"3",key:"msslwz"}],["path",{d:"M3 5V19A9 3 0 0 0 21 19V5",key:"1wlel7"}],["path",{d:"M3 12A9 3 0 0 0 21 12",key:"mv7ke4"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const nv=Ie("Download",[["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",key:"ih7n3h"}],["polyline",{points:"7 10 12 15 17 10",key:"2ggqvy"}],["line",{x1:"12",x2:"12",y1:"15",y2:"3",key:"1vk2je"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const kS=Ie("ExternalLink",[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const NS=Ie("Eye",[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0",key:"1nclc0"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const RS=Ie("FileJson",[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}],["path",{d:"M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1",key:"1oajmo"}],["path",{d:"M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1",key:"mpwhp6"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const pd=Ie("FileSpreadsheet",[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}],["path",{d:"M8 13h2",key:"yr2amv"}],["path",{d:"M14 13h2",key:"un5t4a"}],["path",{d:"M8 17h2",key:"2yhykz"}],["path",{d:"M14 17h2",key:"10kma7"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const PS=Ie("FileText",[["path",{d:"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z",key:"1rqfz7"}],["path",{d:"M14 2v4a2 2 0 0 0 2 2h4",key:"tnqrlb"}],["path",{d:"M10 9H8",key:"b1mrlr"}],["path",{d:"M16 13H8",key:"t4e002"}],["path",{d:"M16 17H8",key:"z1uh3a"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const TS=Ie("FolderOpen",[["path",{d:"m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2",key:"usdka0"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const OS=Ie("Github",[["path",{d:"M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4",key:"tonef"}],["path",{d:"M9 18c-4.51 2-5-2-7-2",key:"9comsn"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const rv=Ie("Globe",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",key:"13o1zl"}],["path",{d:"M2 12h20",key:"9i4pu4"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const jS=Ie("Heart",[["path",{d:"M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z",key:"c3ymky"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const _S=Ie("KeyRound",[["path",{d:"M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z",key:"1s6t7t"}],["circle",{cx:"16.5",cy:"7.5",r:".5",fill:"currentColor",key:"w0ekpg"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const AS=Ie("LoaderCircle",[["path",{d:"M21 12a9 9 0 1 1-6.219-8.56",key:"13zald"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const LS=Ie("MessageSquare",[["path",{d:"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z",key:"1lielz"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const IS=Ie("Monitor",[["rect",{width:"20",height:"14",x:"2",y:"3",rx:"2",key:"48i651"}],["line",{x1:"8",x2:"16",y1:"21",y2:"21",key:"1svkeh"}],["line",{x1:"12",x2:"12",y1:"17",y2:"21",key:"vw1qmm"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const DS=Ie("Moon",[["path",{d:"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z",key:"a7tn18"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const MS=Ie("Play",[["polygon",{points:"6 3 20 12 6 21 6 3",key:"1oa8hb"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const Tf=Ie("RefreshCw",[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const FS=Ie("Search",[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["path",{d:"m21 21-4.3-4.3",key:"1qie3q"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const zS=Ie("ShieldAlert",[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}],["path",{d:"M12 8v4",key:"1got3b"}],["path",{d:"M12 16h.01",key:"1drbdi"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const $S=Ie("Sparkles",[["path",{d:"M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z",key:"4pj2yx"}],["path",{d:"M20 3v4",key:"1olli1"}],["path",{d:"M22 5h-4",key:"1gvqau"}],["path",{d:"M4 17v2",key:"vumght"}],["path",{d:"M5 18H3",key:"zchphs"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const US=Ie("Square",[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const BS=Ie("Sun",[["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"m4.93 4.93 1.41 1.41",key:"149t6j"}],["path",{d:"m17.66 17.66 1.41 1.41",key:"ptbguv"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"m6.34 17.66-1.41 1.41",key:"1m8zz5"}],["path",{d:"m19.07 4.93-1.41 1.41",key:"1shlcs"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const HS=Ie("Trash2",[["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6",key:"4alrt4"}],["path",{d:"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2",key:"v07s0e"}],["line",{x1:"10",x2:"10",y1:"11",y2:"17",key:"1uufr5"}],["line",{x1:"14",x2:"14",y1:"11",y2:"17",key:"xtxkd"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const Of=Ie("TriangleAlert",[["path",{d:"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3",key:"wmoenq"}],["path",{d:"M12 9v4",key:"juzpu7"}],["path",{d:"M12 17h.01",key:"p32p05"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const VS=Ie("Wifi",[["path",{d:"M12 20h.01",key:"zekei9"}],["path",{d:"M2 8.82a15 15 0 0 1 20 0",key:"dnpr2z"}],["path",{d:"M5 12.859a10 10 0 0 1 14 0",key:"1x1e6c"}],["path",{d:"M8.5 16.429a5 5 0 0 1 7 0",key:"1bycff"}]]);/** * @license lucide-react v0.468.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */const jf=Ie("X",[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]]),ke=t=>typeof t=="string",Mi=()=>{let t,e;const r=new Promise((s,i)=>{t=s,e=i});return r.resolve=t,r.reject=e,r},cg=t=>t==null?"":""+t,WS=(t,e,r)=>{t.forEach(s=>{e[s]&&(r[s]=e[s])})},KS=/###/g,ug=t=>t&&t.indexOf("###")>-1?t.replace(KS,"."):t,dg=t=>!t||ke(t),Hi=(t,e,r)=>{const s=ke(e)?e.split("."):e;let i=0;for(;i{const{obj:s,k:i}=Hi(t,e,Object);if(s!==void 0||e.length===1){s[i]=r;return}let l=e[e.length-1],u=e.slice(0,e.length-1),d=Hi(t,u,Object);for(;d.obj===void 0&&u.length;)l=`${u[u.length-1]}.${l}`,u=u.slice(0,u.length-1),d=Hi(t,u,Object),d!=null&&d.obj&&typeof d.obj[`${d.k}.${l}`]<"u"&&(d.obj=void 0);d.obj[`${d.k}.${l}`]=r},qS=(t,e,r,s)=>{const{obj:i,k:l}=Hi(t,e,Object);i[l]=i[l]||[],i[l].push(r)},ql=(t,e)=>{const{obj:r,k:s}=Hi(t,e);if(r&&Object.prototype.hasOwnProperty.call(r,s))return r[s]},QS=(t,e,r)=>{const s=ql(t,r);return s!==void 0?s:ql(e,r)},ov=(t,e,r)=>{for(const s in e)s!=="__proto__"&&s!=="constructor"&&(s in t?ke(t[s])||t[s]instanceof String||ke(e[s])||e[s]instanceof String?r&&(t[s]=e[s]):ov(t[s],e[s],r):t[s]=e[s]);return t},xs=t=>t.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&");var YS={"&":"&","<":"<",">":">",'"':""","'":"'","/":"/"};const GS=t=>ke(t)?t.replace(/[&<>"'\/]/g,e=>YS[e]):t;class XS{constructor(e){this.capacity=e,this.regExpMap=new Map,this.regExpQueue=[]}getRegExp(e){const r=this.regExpMap.get(e);if(r!==void 0)return r;const s=new RegExp(e);return this.regExpQueue.length===this.capacity&&this.regExpMap.delete(this.regExpQueue.shift()),this.regExpMap.set(e,s),this.regExpQueue.push(e),s}}const JS=[" ",",","?","!",";"],ZS=new XS(20),eC=(t,e,r)=>{e=e||"",r=r||"";const s=JS.filter(u=>e.indexOf(u)<0&&r.indexOf(u)<0);if(s.length===0)return!0;const i=ZS.getRegExp(`(${s.map(u=>u==="?"?"\\?":u).join("|")})`);let l=!i.test(t);if(!l){const u=t.indexOf(r);u>0&&!i.test(t.substring(0,u))&&(l=!0)}return l},Gd=(t,e,r=".")=>{if(!t)return;if(t[e])return Object.prototype.hasOwnProperty.call(t,e)?t[e]:void 0;const s=e.split(r);let i=t;for(let l=0;l-1&&ht==null?void 0:t.replace("_","-"),tC={type:"logger",log(t){this.output("log",t)},warn(t){this.output("warn",t)},error(t){this.output("error",t)},output(t,e){var r,s;(s=(r=console==null?void 0:console[t])==null?void 0:r.apply)==null||s.call(r,console,e)}};class Ql{constructor(e,r={}){this.init(e,r)}init(e,r={}){this.prefix=r.prefix||"i18next:",this.logger=e||tC,this.options=r,this.debug=r.debug}log(...e){return this.forward(e,"log","",!0)}warn(...e){return this.forward(e,"warn","",!0)}error(...e){return this.forward(e,"error","")}deprecate(...e){return this.forward(e,"warn","WARNING DEPRECATED: ",!0)}forward(e,r,s,i){return i&&!this.debug?null:(ke(e[0])&&(e[0]=`${s}${this.prefix} ${e[0]}`),this.logger[r](e))}create(e){return new Ql(this.logger,{prefix:`${this.prefix}:${e}:`,...this.options})}clone(e){return e=e||this.options,e.prefix=e.prefix||this.prefix,new Ql(this.logger,e)}}var Qn=new Ql;class cc{constructor(){this.observers={}}on(e,r){return e.split(" ").forEach(s=>{this.observers[s]||(this.observers[s]=new Map);const i=this.observers[s].get(r)||0;this.observers[s].set(r,i+1)}),this}off(e,r){if(this.observers[e]){if(!r){delete this.observers[e];return}this.observers[e].delete(r)}}emit(e,...r){this.observers[e]&&Array.from(this.observers[e].entries()).forEach(([i,l])=>{for(let u=0;u{for(let u=0;u-1&&this.options.ns.splice(r,1)}getResource(e,r,s,i={}){var p,y;const l=i.keySeparator!==void 0?i.keySeparator:this.options.keySeparator,u=i.ignoreJSONStructure!==void 0?i.ignoreJSONStructure:this.options.ignoreJSONStructure;let d;e.indexOf(".")>-1?d=e.split("."):(d=[e,r],s&&(Array.isArray(s)?d.push(...s):ke(s)&&l?d.push(...s.split(l)):d.push(s)));const h=ql(this.data,d);return!h&&!r&&!s&&e.indexOf(".")>-1&&(e=d[0],r=d[1],s=d.slice(2).join(".")),h||!u||!ke(s)?h:Gd((y=(p=this.data)==null?void 0:p[e])==null?void 0:y[r],s,l)}addResource(e,r,s,i,l={silent:!1}){const u=l.keySeparator!==void 0?l.keySeparator:this.options.keySeparator;let d=[e,r];s&&(d=d.concat(u?s.split(u):s)),e.indexOf(".")>-1&&(d=e.split("."),i=r,r=d[1]),this.addNamespaces(r),fg(this.data,d,i),l.silent||this.emit("added",e,r,s,i)}addResources(e,r,s,i={silent:!1}){for(const l in s)(ke(s[l])||Array.isArray(s[l]))&&this.addResource(e,r,l,s[l],{silent:!0});i.silent||this.emit("added",e,r,s)}addResourceBundle(e,r,s,i,l,u={silent:!1,skipCopy:!1}){let d=[e,r];e.indexOf(".")>-1&&(d=e.split("."),i=s,s=r,r=d[1]),this.addNamespaces(r);let h=ql(this.data,d)||{};u.skipCopy||(s=JSON.parse(JSON.stringify(s))),i?ov(h,s,l):h={...h,...s},fg(this.data,d,h),u.silent||this.emit("added",e,r,s)}removeResourceBundle(e,r){this.hasResourceBundle(e,r)&&delete this.data[e][r],this.removeNamespaces(r),this.emit("removed",e,r)}hasResourceBundle(e,r){return this.getResource(e,r)!==void 0}getResourceBundle(e,r){return r||(r=this.options.defaultNS),this.getResource(e,r)}getDataByLanguage(e){return this.data[e]}hasLanguageSomeTranslations(e){const r=this.getDataByLanguage(e);return!!(r&&Object.keys(r)||[]).find(i=>r[i]&&Object.keys(r[i]).length>0)}toJSON(){return this.data}}var sv={processors:{},addPostProcessor(t){this.processors[t.name]=t},handle(t,e,r,s,i){return t.forEach(l=>{var u;e=((u=this.processors[l])==null?void 0:u.process(e,r,s,i))??e}),e}};const iv=Symbol("i18next/PATH_KEY");function nC(){const t=[],e=Object.create(null);let r;return e.get=(s,i)=>{var l;return(l=r==null?void 0:r.revoke)==null||l.call(r),i===iv?t:(t.push(i),r=Proxy.revocable(s,e),r.proxy)},Proxy.revocable(Object.create(null),e).proxy}function Xd(t,e){const{[iv]:r}=t(nC());return r.join((e==null?void 0:e.keySeparator)??".")}const pg={},md=t=>!ke(t)&&typeof t!="boolean"&&typeof t!="number";class Yl extends cc{constructor(e,r={}){super(),WS(["resourceStore","languageUtils","pluralResolver","interpolator","backendConnector","i18nFormat","utils"],e,this),this.options=r,this.options.keySeparator===void 0&&(this.options.keySeparator="."),this.logger=Qn.create("translator")}changeLanguage(e){e&&(this.language=e)}exists(e,r={interpolation:{}}){const s={...r};if(e==null)return!1;const i=this.resolve(e,s);if((i==null?void 0:i.res)===void 0)return!1;const l=md(i.res);return!(s.returnObjects===!1&&l)}extractFromKey(e,r){let s=r.nsSeparator!==void 0?r.nsSeparator:this.options.nsSeparator;s===void 0&&(s=":");const i=r.keySeparator!==void 0?r.keySeparator:this.options.keySeparator;let l=r.ns||this.options.defaultNS||[];const u=s&&e.indexOf(s)>-1,d=!this.options.userDefinedKeySeparator&&!r.keySeparator&&!this.options.userDefinedNsSeparator&&!r.nsSeparator&&!eC(e,s,i);if(u&&!d){const h=e.match(this.interpolator.nestingRegexp);if(h&&h.length>0)return{key:e,namespaces:ke(l)?[l]:l};const p=e.split(s);(s!==i||s===i&&this.options.ns.indexOf(p[0])>-1)&&(l=p.shift()),e=p.join(i)}return{key:e,namespaces:ke(l)?[l]:l}}translate(e,r,s){let i=typeof r=="object"?{...r}:r;if(typeof i!="object"&&this.options.overloadTranslationOptionHandler&&(i=this.options.overloadTranslationOptionHandler(arguments)),typeof i=="object"&&(i={...i}),i||(i={}),e==null)return"";typeof e=="function"&&(e=Xd(e,{...this.options,...i})),Array.isArray(e)||(e=[String(e)]);const l=i.returnDetails!==void 0?i.returnDetails:this.options.returnDetails,u=i.keySeparator!==void 0?i.keySeparator:this.options.keySeparator,{key:d,namespaces:h}=this.extractFromKey(e[e.length-1],i),p=h[h.length-1];let y=i.nsSeparator!==void 0?i.nsSeparator:this.options.nsSeparator;y===void 0&&(y=":");const v=i.lng||this.language,C=i.appendNamespaceToCIMode||this.options.appendNamespaceToCIMode;if((v==null?void 0:v.toLowerCase())==="cimode")return C?l?{res:`${p}${y}${d}`,usedKey:d,exactUsedKey:d,usedLng:v,usedNS:p,usedParams:this.getUsedParamsDetails(i)}:`${p}${y}${d}`:l?{res:d,usedKey:d,exactUsedKey:d,usedLng:v,usedNS:p,usedParams:this.getUsedParamsDetails(i)}:d;const w=this.resolve(e,i);let E=w==null?void 0:w.res;const b=(w==null?void 0:w.usedKey)||d,k=(w==null?void 0:w.exactUsedKey)||d,T=["[object Number]","[object Function]","[object RegExp]"],j=i.joinArrays!==void 0?i.joinArrays:this.options.joinArrays,_=!this.i18nFormat||this.i18nFormat.handleAsObject,A=i.count!==void 0&&!ke(i.count),F=Yl.hasDefaultValue(i),V=A?this.pluralResolver.getSuffix(v,i.count,i):"",B=i.ordinal&&A?this.pluralResolver.getSuffix(v,i.count,{ordinal:!1}):"",te=A&&!i.ordinal&&i.count===0,G=te&&i[`defaultValue${this.options.pluralSeparator}zero`]||i[`defaultValue${V}`]||i[`defaultValue${B}`]||i.defaultValue;let W=E;_&&!E&&F&&(W=G);const le=md(W),K=Object.prototype.toString.apply(W);if(_&&W&&le&&T.indexOf(K)<0&&!(ke(j)&&Array.isArray(W))){if(!i.returnObjects&&!this.options.returnObjects){this.options.returnedObjectHandler||this.logger.warn("accessing an object - but returnObjects options is not enabled!");const Z=this.options.returnedObjectHandler?this.options.returnedObjectHandler(b,W,{...i,ns:h}):`key '${d} (${this.language})' returned an object instead of string.`;return l?(w.res=Z,w.usedParams=this.getUsedParamsDetails(i),w):Z}if(u){const Z=Array.isArray(W),J=Z?[]:{},de=Z?k:b;for(const ne in W)if(Object.prototype.hasOwnProperty.call(W,ne)){const se=`${de}${u}${ne}`;F&&!E?J[ne]=this.translate(se,{...i,defaultValue:md(G)?G[ne]:void 0,joinArrays:!1,ns:h}):J[ne]=this.translate(se,{...i,joinArrays:!1,ns:h}),J[ne]===se&&(J[ne]=W[ne])}E=J}}else if(_&&ke(j)&&Array.isArray(E))E=E.join(j),E&&(E=this.extendTranslation(E,e,i,s));else{let Z=!1,J=!1;!this.isValidLookup(E)&&F&&(Z=!0,E=G),this.isValidLookup(E)||(J=!0,E=d);const ne=(i.missingKeyNoValueFallbackToKey||this.options.missingKeyNoValueFallbackToKey)&&J?void 0:E,se=F&&G!==E&&this.options.updateMissing;if(J||Z||se){if(this.logger.log(se?"updateKey":"missingKey",v,p,d,se?G:E),u){const P=this.resolve(d,{...i,keySeparator:!1});P&&P.res&&this.logger.warn("Seems the loaded translations were in flat JSON format instead of nested. Either set keySeparator: false on init or make sure your translations are published in nested format.")}let $=[];const H=this.languageUtils.getFallbackCodes(this.options.fallbackLng,i.lng||this.language);if(this.options.saveMissingTo==="fallback"&&H&&H[0])for(let P=0;P{var me;const ae=F&&ie!==E?ie:ne;this.options.missingKeyHandler?this.options.missingKeyHandler(P,p,M,ae,se,i):(me=this.backendConnector)!=null&&me.saveMissing&&this.backendConnector.saveMissing(P,p,M,ae,se,i),this.emit("missingKey",P,p,M,E)};this.options.saveMissing&&(this.options.saveMissingPlurals&&A?$.forEach(P=>{const M=this.pluralResolver.getSuffixes(P,i);te&&i[`defaultValue${this.options.pluralSeparator}zero`]&&M.indexOf(`${this.options.pluralSeparator}zero`)<0&&M.push(`${this.options.pluralSeparator}zero`),M.forEach(ie=>{Q([P],d+ie,i[`defaultValue${ie}`]||G)})}):Q($,d,G))}E=this.extendTranslation(E,e,i,w,s),J&&E===d&&this.options.appendNamespaceToMissingKey&&(E=`${p}${y}${d}`),(J||Z)&&this.options.parseMissingKeyHandler&&(E=this.options.parseMissingKeyHandler(this.options.appendNamespaceToMissingKey?`${p}${y}${d}`:d,Z?E:void 0,i))}return l?(w.res=E,w.usedParams=this.getUsedParamsDetails(i),w):E}extendTranslation(e,r,s,i,l){var h,p;if((h=this.i18nFormat)!=null&&h.parse)e=this.i18nFormat.parse(e,{...this.options.interpolation.defaultVariables,...s},s.lng||this.language||i.usedLng,i.usedNS,i.usedKey,{resolved:i});else if(!s.skipInterpolation){s.interpolation&&this.interpolator.init({...s,interpolation:{...this.options.interpolation,...s.interpolation}});const y=ke(e)&&(((p=s==null?void 0:s.interpolation)==null?void 0:p.skipOnVariables)!==void 0?s.interpolation.skipOnVariables:this.options.interpolation.skipOnVariables);let v;if(y){const w=e.match(this.interpolator.nestingRegexp);v=w&&w.length}let C=s.replace&&!ke(s.replace)?s.replace:s;if(this.options.interpolation.defaultVariables&&(C={...this.options.interpolation.defaultVariables,...C}),e=this.interpolator.interpolate(e,C,s.lng||this.language||i.usedLng,s),y){const w=e.match(this.interpolator.nestingRegexp),E=w&&w.length;v(l==null?void 0:l[0])===w[0]&&!s.context?(this.logger.warn(`It seems you are nesting recursively key: ${w[0]} in key: ${r[0]}`),null):this.translate(...w,r),s)),s.interpolation&&this.interpolator.reset()}const u=s.postProcess||this.options.postProcess,d=ke(u)?[u]:u;return e!=null&&(d!=null&&d.length)&&s.applyPostProcessor!==!1&&(e=sv.handle(d,e,r,this.options&&this.options.postProcessPassResolved?{i18nResolved:{...i,usedParams:this.getUsedParamsDetails(s)},...s}:s,this)),e}resolve(e,r={}){let s,i,l,u,d;return ke(e)&&(e=[e]),e.forEach(h=>{if(this.isValidLookup(s))return;const p=this.extractFromKey(h,r),y=p.key;i=y;let v=p.namespaces;this.options.fallbackNS&&(v=v.concat(this.options.fallbackNS));const C=r.count!==void 0&&!ke(r.count),w=C&&!r.ordinal&&r.count===0,E=r.context!==void 0&&(ke(r.context)||typeof r.context=="number")&&r.context!=="",b=r.lngs?r.lngs:this.languageUtils.toResolveHierarchy(r.lng||this.language,r.fallbackLng);v.forEach(k=>{var T,j;this.isValidLookup(s)||(d=k,!pg[`${b[0]}-${k}`]&&((T=this.utils)!=null&&T.hasLoadedNamespace)&&!((j=this.utils)!=null&&j.hasLoadedNamespace(d))&&(pg[`${b[0]}-${k}`]=!0,this.logger.warn(`key "${i}" for languages "${b.join(", ")}" won't get resolved as namespace "${d}" was not yet loaded`,"This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!")),b.forEach(_=>{var V;if(this.isValidLookup(s))return;u=_;const A=[y];if((V=this.i18nFormat)!=null&&V.addLookupKeys)this.i18nFormat.addLookupKeys(A,y,_,k,r);else{let B;C&&(B=this.pluralResolver.getSuffix(_,r.count,r));const te=`${this.options.pluralSeparator}zero`,G=`${this.options.pluralSeparator}ordinal${this.options.pluralSeparator}`;if(C&&(r.ordinal&&B.indexOf(G)===0&&A.push(y+B.replace(G,this.options.pluralSeparator)),A.push(y+B),w&&A.push(y+te)),E){const W=`${y}${this.options.contextSeparator||"_"}${r.context}`;A.push(W),C&&(r.ordinal&&B.indexOf(G)===0&&A.push(W+B.replace(G,this.options.pluralSeparator)),A.push(W+B),w&&A.push(W+te))}}let F;for(;F=A.pop();)this.isValidLookup(s)||(l=F,s=this.getResource(_,k,F,r))}))})}),{res:s,usedKey:i,exactUsedKey:l,usedLng:u,usedNS:d}}isValidLookup(e){return e!==void 0&&!(!this.options.returnNull&&e===null)&&!(!this.options.returnEmptyString&&e==="")}getResource(e,r,s,i={}){var l;return(l=this.i18nFormat)!=null&&l.getResource?this.i18nFormat.getResource(e,r,s,i):this.resourceStore.getResource(e,r,s,i)}getUsedParamsDetails(e={}){const r=["defaultValue","ordinal","context","replace","lng","lngs","fallbackLng","ns","keySeparator","nsSeparator","returnObjects","returnDetails","joinArrays","postProcess","interpolation"],s=e.replace&&!ke(e.replace);let i=s?e.replace:e;if(s&&typeof e.count<"u"&&(i.count=e.count),this.options.interpolation.defaultVariables&&(i={...this.options.interpolation.defaultVariables,...i}),!s){i={...i};for(const l of r)delete i[l]}return i}static hasDefaultValue(e){const r="defaultValue";for(const s in e)if(Object.prototype.hasOwnProperty.call(e,s)&&r===s.substring(0,r.length)&&e[s]!==void 0)return!0;return!1}}class mg{constructor(e){this.options=e,this.supportedLngs=this.options.supportedLngs||!1,this.logger=Qn.create("languageUtils")}getScriptPartFromCode(e){if(e=Ki(e),!e||e.indexOf("-")<0)return null;const r=e.split("-");return r.length===2||(r.pop(),r[r.length-1].toLowerCase()==="x")?null:this.formatLanguageCode(r.join("-"))}getLanguagePartFromCode(e){if(e=Ki(e),!e||e.indexOf("-")<0)return e;const r=e.split("-");return this.formatLanguageCode(r[0])}formatLanguageCode(e){if(ke(e)&&e.indexOf("-")>-1){let r;try{r=Intl.getCanonicalLocales(e)[0]}catch{}return r&&this.options.lowerCaseLng&&(r=r.toLowerCase()),r||(this.options.lowerCaseLng?e.toLowerCase():e)}return this.options.cleanCode||this.options.lowerCaseLng?e.toLowerCase():e}isSupportedCode(e){return(this.options.load==="languageOnly"||this.options.nonExplicitSupportedLngs)&&(e=this.getLanguagePartFromCode(e)),!this.supportedLngs||!this.supportedLngs.length||this.supportedLngs.indexOf(e)>-1}getBestMatchFromCodes(e){if(!e)return null;let r;return e.forEach(s=>{if(r)return;const i=this.formatLanguageCode(s);(!this.options.supportedLngs||this.isSupportedCode(i))&&(r=i)}),!r&&this.options.supportedLngs&&e.forEach(s=>{if(r)return;const i=this.getScriptPartFromCode(s);if(this.isSupportedCode(i))return r=i;const l=this.getLanguagePartFromCode(s);if(this.isSupportedCode(l))return r=l;r=this.options.supportedLngs.find(u=>{if(u===l)return u;if(!(u.indexOf("-")<0&&l.indexOf("-")<0)&&(u.indexOf("-")>0&&l.indexOf("-")<0&&u.substring(0,u.indexOf("-"))===l||u.indexOf(l)===0&&l.length>1))return u})}),r||(r=this.getFallbackCodes(this.options.fallbackLng)[0]),r}getFallbackCodes(e,r){if(!e)return[];if(typeof e=="function"&&(e=e(r)),ke(e)&&(e=[e]),Array.isArray(e))return e;if(!r)return e.default||[];let s=e[r];return s||(s=e[this.getScriptPartFromCode(r)]),s||(s=e[this.formatLanguageCode(r)]),s||(s=e[this.getLanguagePartFromCode(r)]),s||(s=e.default),s||[]}toResolveHierarchy(e,r){const s=this.getFallbackCodes((r===!1?[]:r)||this.options.fallbackLng||[],e),i=[],l=u=>{u&&(this.isSupportedCode(u)?i.push(u):this.logger.warn(`rejecting language code not found in supportedLngs: ${u}`))};return ke(e)&&(e.indexOf("-")>-1||e.indexOf("_")>-1)?(this.options.load!=="languageOnly"&&l(this.formatLanguageCode(e)),this.options.load!=="languageOnly"&&this.options.load!=="currentOnly"&&l(this.getScriptPartFromCode(e)),this.options.load!=="currentOnly"&&l(this.getLanguagePartFromCode(e))):ke(e)&&l(this.formatLanguageCode(e)),s.forEach(u=>{i.indexOf(u)<0&&l(this.formatLanguageCode(u))}),i}}const gg={zero:0,one:1,two:2,few:3,many:4,other:5},yg={select:t=>t===1?"one":"other",resolvedOptions:()=>({pluralCategories:["one","other"]})};class rC{constructor(e,r={}){this.languageUtils=e,this.options=r,this.logger=Qn.create("pluralResolver"),this.pluralRulesCache={}}addRule(e,r){this.rules[e]=r}clearCache(){this.pluralRulesCache={}}getRule(e,r={}){const s=Ki(e==="dev"?"en":e),i=r.ordinal?"ordinal":"cardinal",l=JSON.stringify({cleanedCode:s,type:i});if(l in this.pluralRulesCache)return this.pluralRulesCache[l];let u;try{u=new Intl.PluralRules(s,{type:i})}catch{if(!Intl)return this.logger.error("No Intl support, please use an Intl polyfill!"),yg;if(!e.match(/-|_/))return yg;const h=this.languageUtils.getLanguagePartFromCode(e);u=this.getRule(h,r)}return this.pluralRulesCache[l]=u,u}needsPlural(e,r={}){let s=this.getRule(e,r);return s||(s=this.getRule("dev",r)),(s==null?void 0:s.resolvedOptions().pluralCategories.length)>1}getPluralFormsOfKey(e,r,s={}){return this.getSuffixes(e,s).map(i=>`${r}${i}`)}getSuffixes(e,r={}){let s=this.getRule(e,r);return s||(s=this.getRule("dev",r)),s?s.resolvedOptions().pluralCategories.sort((i,l)=>gg[i]-gg[l]).map(i=>`${this.options.prepend}${r.ordinal?`ordinal${this.options.prepend}`:""}${i}`):[]}getSuffix(e,r,s={}){const i=this.getRule(e,s);return i?`${this.options.prepend}${s.ordinal?`ordinal${this.options.prepend}`:""}${i.select(r)}`:(this.logger.warn(`no plural rule found for: ${e}`),this.getSuffix("dev",r,s))}}const vg=(t,e,r,s=".",i=!0)=>{let l=QS(t,e,r);return!l&&i&&ke(r)&&(l=Gd(t,r,s),l===void 0&&(l=Gd(e,r,s))),l},gd=t=>t.replace(/\$/g,"$$$$");class xg{constructor(e={}){var r;this.logger=Qn.create("interpolator"),this.options=e,this.format=((r=e==null?void 0:e.interpolation)==null?void 0:r.format)||(s=>s),this.init(e)}init(e={}){e.interpolation||(e.interpolation={escapeValue:!0});const{escape:r,escapeValue:s,useRawValueToEscape:i,prefix:l,prefixEscaped:u,suffix:d,suffixEscaped:h,formatSeparator:p,unescapeSuffix:y,unescapePrefix:v,nestingPrefix:C,nestingPrefixEscaped:w,nestingSuffix:E,nestingSuffixEscaped:b,nestingOptionsSeparator:k,maxReplaces:T,alwaysFormat:j}=e.interpolation;this.escape=r!==void 0?r:GS,this.escapeValue=s!==void 0?s:!0,this.useRawValueToEscape=i!==void 0?i:!1,this.prefix=l?xs(l):u||"{{",this.suffix=d?xs(d):h||"}}",this.formatSeparator=p||",",this.unescapePrefix=y?"":v||"-",this.unescapeSuffix=this.unescapePrefix?"":y||"",this.nestingPrefix=C?xs(C):w||xs("$t("),this.nestingSuffix=E?xs(E):b||xs(")"),this.nestingOptionsSeparator=k||",",this.maxReplaces=T||1e3,this.alwaysFormat=j!==void 0?j:!1,this.resetRegExp()}reset(){this.options&&this.init(this.options)}resetRegExp(){const e=(r,s)=>(r==null?void 0:r.source)===s?(r.lastIndex=0,r):new RegExp(s,"g");this.regexp=e(this.regexp,`${this.prefix}(.+?)${this.suffix}`),this.regexpUnescape=e(this.regexpUnescape,`${this.prefix}${this.unescapePrefix}(.+?)${this.unescapeSuffix}${this.suffix}`),this.nestingRegexp=e(this.nestingRegexp,`${this.nestingPrefix}((?:[^()"']+|"[^"]*"|'[^']*'|\\((?:[^()]|"[^"]*"|'[^']*')*\\))*?)${this.nestingSuffix}`)}interpolate(e,r,s,i){var w;let l,u,d;const h=this.options&&this.options.interpolation&&this.options.interpolation.defaultVariables||{},p=E=>{if(E.indexOf(this.formatSeparator)<0){const j=vg(r,h,E,this.options.keySeparator,this.options.ignoreJSONStructure);return this.alwaysFormat?this.format(j,void 0,s,{...i,...r,interpolationkey:E}):j}const b=E.split(this.formatSeparator),k=b.shift().trim(),T=b.join(this.formatSeparator).trim();return this.format(vg(r,h,k,this.options.keySeparator,this.options.ignoreJSONStructure),T,s,{...i,...r,interpolationkey:k})};this.resetRegExp();const y=(i==null?void 0:i.missingInterpolationHandler)||this.options.missingInterpolationHandler,v=((w=i==null?void 0:i.interpolation)==null?void 0:w.skipOnVariables)!==void 0?i.interpolation.skipOnVariables:this.options.interpolation.skipOnVariables;return[{regex:this.regexpUnescape,safeValue:E=>gd(E)},{regex:this.regexp,safeValue:E=>this.escapeValue?gd(this.escape(E)):gd(E)}].forEach(E=>{for(d=0;l=E.regex.exec(e);){const b=l[1].trim();if(u=p(b),u===void 0)if(typeof y=="function"){const T=y(e,l,i);u=ke(T)?T:""}else if(i&&Object.prototype.hasOwnProperty.call(i,b))u="";else if(v){u=l[0];continue}else this.logger.warn(`missed to pass in variable ${b} for interpolating ${e}`),u="";else!ke(u)&&!this.useRawValueToEscape&&(u=cg(u));const k=E.safeValue(u);if(e=e.replace(l[0],k),v?(E.regex.lastIndex+=u.length,E.regex.lastIndex-=l[0].length):E.regex.lastIndex=0,d++,d>=this.maxReplaces)break}}),e}nest(e,r,s={}){let i,l,u;const d=(h,p)=>{const y=this.nestingOptionsSeparator;if(h.indexOf(y)<0)return h;const v=h.split(new RegExp(`${y}[ ]*{`));let C=`{${v[1]}`;h=v[0],C=this.interpolate(C,u);const w=C.match(/'/g),E=C.match(/"/g);(((w==null?void 0:w.length)??0)%2===0&&!E||E.length%2!==0)&&(C=C.replace(/'/g,'"'));try{u=JSON.parse(C),p&&(u={...p,...u})}catch(b){return this.logger.warn(`failed parsing options string in nesting for key ${h}`,b),`${h}${y}${C}`}return u.defaultValue&&u.defaultValue.indexOf(this.prefix)>-1&&delete u.defaultValue,h};for(;i=this.nestingRegexp.exec(e);){let h=[];u={...s},u=u.replace&&!ke(u.replace)?u.replace:u,u.applyPostProcessor=!1,delete u.defaultValue;const p=/{.*}/.test(i[1])?i[1].lastIndexOf("}")+1:i[1].indexOf(this.formatSeparator);if(p!==-1&&(h=i[1].slice(p).split(this.formatSeparator).map(y=>y.trim()).filter(Boolean),i[1]=i[1].slice(0,p)),l=r(d.call(this,i[1].trim(),u),u),l&&i[0]===e&&!ke(l))return l;ke(l)||(l=cg(l)),l||(this.logger.warn(`missed to resolve ${i[1]} for nesting ${e}`),l=""),h.length&&(l=h.reduce((y,v)=>this.format(y,v,s.lng,{...s,interpolationkey:i[1].trim()}),l.trim())),e=e.replace(i[0],l),this.regexp.lastIndex=0}return e}}const oC=t=>{let e=t.toLowerCase().trim();const r={};if(t.indexOf("(")>-1){const s=t.split("(");e=s[0].toLowerCase().trim();const i=s[1].substring(0,s[1].length-1);e==="currency"&&i.indexOf(":")<0?r.currency||(r.currency=i.trim()):e==="relativetime"&&i.indexOf(":")<0?r.range||(r.range=i.trim()):i.split(";").forEach(u=>{if(u){const[d,...h]=u.split(":"),p=h.join(":").trim().replace(/^'+|'+$/g,""),y=d.trim();r[y]||(r[y]=p),p==="false"&&(r[y]=!1),p==="true"&&(r[y]=!0),isNaN(p)||(r[y]=parseInt(p,10))}})}return{formatName:e,formatOptions:r}},wg=t=>{const e={};return(r,s,i)=>{let l=i;i&&i.interpolationkey&&i.formatParams&&i.formatParams[i.interpolationkey]&&i[i.interpolationkey]&&(l={...l,[i.interpolationkey]:void 0});const u=s+JSON.stringify(l);let d=e[u];return d||(d=t(Ki(s),i),e[u]=d),d(r)}},sC=t=>(e,r,s)=>t(Ki(r),s)(e);class iC{constructor(e={}){this.logger=Qn.create("formatter"),this.options=e,this.init(e)}init(e,r={interpolation:{}}){this.formatSeparator=r.interpolation.formatSeparator||",";const s=r.cacheInBuiltFormats?wg:sC;this.formats={number:s((i,l)=>{const u=new Intl.NumberFormat(i,{...l});return d=>u.format(d)}),currency:s((i,l)=>{const u=new Intl.NumberFormat(i,{...l,style:"currency"});return d=>u.format(d)}),datetime:s((i,l)=>{const u=new Intl.DateTimeFormat(i,{...l});return d=>u.format(d)}),relativetime:s((i,l)=>{const u=new Intl.RelativeTimeFormat(i,{...l});return d=>u.format(d,l.range||"day")}),list:s((i,l)=>{const u=new Intl.ListFormat(i,{...l});return d=>u.format(d)})}}add(e,r){this.formats[e.toLowerCase().trim()]=r}addCached(e,r){this.formats[e.toLowerCase().trim()]=wg(r)}format(e,r,s,i={}){const l=r.split(this.formatSeparator);if(l.length>1&&l[0].indexOf("(")>1&&l[0].indexOf(")")<0&&l.find(d=>d.indexOf(")")>-1)){const d=l.findIndex(h=>h.indexOf(")")>-1);l[0]=[l[0],...l.splice(1,d)].join(this.formatSeparator)}return l.reduce((d,h)=>{var v;const{formatName:p,formatOptions:y}=oC(h);if(this.formats[p]){let C=d;try{const w=((v=i==null?void 0:i.formatParams)==null?void 0:v[i.interpolationkey])||{},E=w.locale||w.lng||i.locale||i.lng||s;C=this.formats[p](d,E,{...y,...i,...w})}catch(w){this.logger.warn(w)}return C}else this.logger.warn(`there was no format function for ${p}`);return d},e)}}const aC=(t,e)=>{t.pending[e]!==void 0&&(delete t.pending[e],t.pendingCount--)};class lC extends cc{constructor(e,r,s,i={}){var l,u;super(),this.backend=e,this.store=r,this.services=s,this.languageUtils=s.languageUtils,this.options=i,this.logger=Qn.create("backendConnector"),this.waitingReads=[],this.maxParallelReads=i.maxParallelReads||10,this.readingCalls=0,this.maxRetries=i.maxRetries>=0?i.maxRetries:5,this.retryTimeout=i.retryTimeout>=1?i.retryTimeout:350,this.state={},this.queue=[],(u=(l=this.backend)==null?void 0:l.init)==null||u.call(l,s,i.backend,i)}queueLoad(e,r,s,i){const l={},u={},d={},h={};return e.forEach(p=>{let y=!0;r.forEach(v=>{const C=`${p}|${v}`;!s.reload&&this.store.hasResourceBundle(p,v)?this.state[C]=2:this.state[C]<0||(this.state[C]===1?u[C]===void 0&&(u[C]=!0):(this.state[C]=1,y=!1,u[C]===void 0&&(u[C]=!0),l[C]===void 0&&(l[C]=!0),h[v]===void 0&&(h[v]=!0)))}),y||(d[p]=!0)}),(Object.keys(l).length||Object.keys(u).length)&&this.queue.push({pending:u,pendingCount:Object.keys(u).length,loaded:{},errors:[],callback:i}),{toLoad:Object.keys(l),pending:Object.keys(u),toLoadLanguages:Object.keys(d),toLoadNamespaces:Object.keys(h)}}loaded(e,r,s){const i=e.split("|"),l=i[0],u=i[1];r&&this.emit("failedLoading",l,u,r),!r&&s&&this.store.addResourceBundle(l,u,s,void 0,void 0,{skipCopy:!0}),this.state[e]=r?-1:2,r&&s&&(this.state[e]=0);const d={};this.queue.forEach(h=>{qS(h.loaded,[l],u),aC(h,e),r&&h.errors.push(r),h.pendingCount===0&&!h.done&&(Object.keys(h.loaded).forEach(p=>{d[p]||(d[p]={});const y=h.loaded[p];y.length&&y.forEach(v=>{d[p][v]===void 0&&(d[p][v]=!0)})}),h.done=!0,h.errors.length?h.callback(h.errors):h.callback())}),this.emit("loaded",d),this.queue=this.queue.filter(h=>!h.done)}read(e,r,s,i=0,l=this.retryTimeout,u){if(!e.length)return u(null,{});if(this.readingCalls>=this.maxParallelReads){this.waitingReads.push({lng:e,ns:r,fcName:s,tried:i,wait:l,callback:u});return}this.readingCalls++;const d=(p,y)=>{if(this.readingCalls--,this.waitingReads.length>0){const v=this.waitingReads.shift();this.read(v.lng,v.ns,v.fcName,v.tried,v.wait,v.callback)}if(p&&y&&i{this.read.call(this,e,r,s,i+1,l*2,u)},l);return}u(p,y)},h=this.backend[s].bind(this.backend);if(h.length===2){try{const p=h(e,r);p&&typeof p.then=="function"?p.then(y=>d(null,y)).catch(d):d(null,p)}catch(p){d(p)}return}return h(e,r,d)}prepareLoading(e,r,s={},i){if(!this.backend)return this.logger.warn("No backend was added via i18next.use. Will not load resources."),i&&i();ke(e)&&(e=this.languageUtils.toResolveHierarchy(e)),ke(r)&&(r=[r]);const l=this.queueLoad(e,r,s,i);if(!l.toLoad.length)return l.pending.length||i(),null;l.toLoad.forEach(u=>{this.loadOne(u)})}load(e,r,s){this.prepareLoading(e,r,{},s)}reload(e,r,s){this.prepareLoading(e,r,{reload:!0},s)}loadOne(e,r=""){const s=e.split("|"),i=s[0],l=s[1];this.read(i,l,"read",void 0,void 0,(u,d)=>{u&&this.logger.warn(`${r}loading namespace ${l} for language ${i} failed`,u),!u&&d&&this.logger.log(`${r}loaded namespace ${l} for language ${i}`,d),this.loaded(e,u,d)})}saveMissing(e,r,s,i,l,u={},d=()=>{}){var h,p,y,v,C;if((p=(h=this.services)==null?void 0:h.utils)!=null&&p.hasLoadedNamespace&&!((v=(y=this.services)==null?void 0:y.utils)!=null&&v.hasLoadedNamespace(r))){this.logger.warn(`did not save key "${s}" as the namespace "${r}" was not yet loaded`,"This means something IS WRONG in your setup. You access the t function before i18next.init / i18next.loadNamespace / i18next.changeLanguage was done. Wait for the callback or Promise to resolve before accessing it!!!");return}if(!(s==null||s==="")){if((C=this.backend)!=null&&C.create){const w={...u,isUpdate:l},E=this.backend.create.bind(this.backend);if(E.length<6)try{let b;E.length===5?b=E(e,r,s,i,w):b=E(e,r,s,i),b&&typeof b.then=="function"?b.then(k=>d(null,k)).catch(d):d(null,b)}catch(b){d(b)}else E(e,r,s,i,d,w)}!e||!e[0]||this.store.addResource(e[0],r,s,i)}}}const bg=()=>({debug:!1,initAsync:!0,ns:["translation"],defaultNS:["translation"],fallbackLng:["dev"],fallbackNS:!1,supportedLngs:!1,nonExplicitSupportedLngs:!1,load:"all",preload:!1,simplifyPluralSuffix:!0,keySeparator:".",nsSeparator:":",pluralSeparator:"_",contextSeparator:"_",partialBundledLanguages:!1,saveMissing:!1,updateMissing:!1,saveMissingTo:"fallback",saveMissingPlurals:!0,missingKeyHandler:!1,missingInterpolationHandler:!1,postProcess:!1,postProcessPassResolved:!1,returnNull:!1,returnEmptyString:!0,returnObjects:!1,joinArrays:!1,returnedObjectHandler:!1,parseMissingKeyHandler:!1,appendNamespaceToMissingKey:!1,appendNamespaceToCIMode:!1,overloadTranslationOptionHandler:t=>{let e={};if(typeof t[1]=="object"&&(e=t[1]),ke(t[1])&&(e.defaultValue=t[1]),ke(t[2])&&(e.tDescription=t[2]),typeof t[2]=="object"||typeof t[3]=="object"){const r=t[3]||t[2];Object.keys(r).forEach(s=>{e[s]=r[s]})}return e},interpolation:{escapeValue:!0,format:t=>t,prefix:"{{",suffix:"}}",formatSeparator:",",unescapePrefix:"-",nestingPrefix:"$t(",nestingSuffix:")",nestingOptionsSeparator:",",maxReplaces:1e3,skipOnVariables:!0},cacheInBuiltFormats:!0}),Sg=t=>{var e,r;return ke(t.ns)&&(t.ns=[t.ns]),ke(t.fallbackLng)&&(t.fallbackLng=[t.fallbackLng]),ke(t.fallbackNS)&&(t.fallbackNS=[t.fallbackNS]),((r=(e=t.supportedLngs)==null?void 0:e.indexOf)==null?void 0:r.call(e,"cimode"))<0&&(t.supportedLngs=t.supportedLngs.concat(["cimode"])),typeof t.initImmediate=="boolean"&&(t.initAsync=t.initImmediate),t},Nl=()=>{},cC=t=>{Object.getOwnPropertyNames(Object.getPrototypeOf(t)).forEach(r=>{typeof t[r]=="function"&&(t[r]=t[r].bind(t))})};class Vi extends cc{constructor(e={},r){if(super(),this.options=Sg(e),this.services={},this.logger=Qn,this.modules={external:[]},cC(this),r&&!this.isInitialized&&!e.isClone){if(!this.options.initAsync)return this.init(e,r),this;setTimeout(()=>{this.init(e,r)},0)}}init(e={},r){this.isInitializing=!0,typeof e=="function"&&(r=e,e={}),e.defaultNS==null&&e.ns&&(ke(e.ns)?e.defaultNS=e.ns:e.ns.indexOf("translation")<0&&(e.defaultNS=e.ns[0]));const s=bg();this.options={...s,...this.options,...Sg(e)},this.options.interpolation={...s.interpolation,...this.options.interpolation},e.keySeparator!==void 0&&(this.options.userDefinedKeySeparator=e.keySeparator),e.nsSeparator!==void 0&&(this.options.userDefinedNsSeparator=e.nsSeparator),typeof this.options.overloadTranslationOptionHandler!="function"&&(this.options.overloadTranslationOptionHandler=s.overloadTranslationOptionHandler);const i=p=>p?typeof p=="function"?new p:p:null;if(!this.options.isClone){this.modules.logger?Qn.init(i(this.modules.logger),this.options):Qn.init(null,this.options);let p;this.modules.formatter?p=this.modules.formatter:p=iC;const y=new mg(this.options);this.store=new hg(this.options.resources,this.options);const v=this.services;v.logger=Qn,v.resourceStore=this.store,v.languageUtils=y,v.pluralResolver=new rC(y,{prepend:this.options.pluralSeparator,simplifyPluralSuffix:this.options.simplifyPluralSuffix}),this.options.interpolation.format&&this.options.interpolation.format!==s.interpolation.format&&this.logger.deprecate("init: you are still using the legacy format function, please use the new approach: https://www.i18next.com/translation-function/formatting"),p&&(!this.options.interpolation.format||this.options.interpolation.format===s.interpolation.format)&&(v.formatter=i(p),v.formatter.init&&v.formatter.init(v,this.options),this.options.interpolation.format=v.formatter.format.bind(v.formatter)),v.interpolator=new xg(this.options),v.utils={hasLoadedNamespace:this.hasLoadedNamespace.bind(this)},v.backendConnector=new lC(i(this.modules.backend),v.resourceStore,v,this.options),v.backendConnector.on("*",(w,...E)=>{this.emit(w,...E)}),this.modules.languageDetector&&(v.languageDetector=i(this.modules.languageDetector),v.languageDetector.init&&v.languageDetector.init(v,this.options.detection,this.options)),this.modules.i18nFormat&&(v.i18nFormat=i(this.modules.i18nFormat),v.i18nFormat.init&&v.i18nFormat.init(this)),this.translator=new Yl(this.services,this.options),this.translator.on("*",(w,...E)=>{this.emit(w,...E)}),this.modules.external.forEach(w=>{w.init&&w.init(this)})}if(this.format=this.options.interpolation.format,r||(r=Nl),this.options.fallbackLng&&!this.services.languageDetector&&!this.options.lng){const p=this.services.languageUtils.getFallbackCodes(this.options.fallbackLng);p.length>0&&p[0]!=="dev"&&(this.options.lng=p[0])}!this.services.languageDetector&&!this.options.lng&&this.logger.warn("init: no languageDetector is used and no lng is defined"),["getResource","hasResourceBundle","getResourceBundle","getDataByLanguage"].forEach(p=>{this[p]=(...y)=>this.store[p](...y)}),["addResource","addResources","addResourceBundle","removeResourceBundle"].forEach(p=>{this[p]=(...y)=>(this.store[p](...y),this)});const d=Mi(),h=()=>{const p=(y,v)=>{this.isInitializing=!1,this.isInitialized&&!this.initializedStoreOnce&&this.logger.warn("init: i18next is already initialized. You should call init just once!"),this.isInitialized=!0,this.options.isClone||this.logger.log("initialized",this.options),this.emit("initialized",this.options),d.resolve(v),r(y,v)};if(this.languages&&!this.isInitialized)return p(null,this.t.bind(this));this.changeLanguage(this.options.lng,p)};return this.options.resources||!this.options.initAsync?h():setTimeout(h,0),d}loadResources(e,r=Nl){var l,u;let s=r;const i=ke(e)?e:this.language;if(typeof e=="function"&&(s=e),!this.options.resources||this.options.partialBundledLanguages){if((i==null?void 0:i.toLowerCase())==="cimode"&&(!this.options.preload||this.options.preload.length===0))return s();const d=[],h=p=>{if(!p||p==="cimode")return;this.services.languageUtils.toResolveHierarchy(p).forEach(v=>{v!=="cimode"&&d.indexOf(v)<0&&d.push(v)})};i?h(i):this.services.languageUtils.getFallbackCodes(this.options.fallbackLng).forEach(y=>h(y)),(u=(l=this.options.preload)==null?void 0:l.forEach)==null||u.call(l,p=>h(p)),this.services.backendConnector.load(d,this.options.ns,p=>{!p&&!this.resolvedLanguage&&this.language&&this.setResolvedLanguage(this.language),s(p)})}else s(null)}reloadResources(e,r,s){const i=Mi();return typeof e=="function"&&(s=e,e=void 0),typeof r=="function"&&(s=r,r=void 0),e||(e=this.languages),r||(r=this.options.ns),s||(s=Nl),this.services.backendConnector.reload(e,r,l=>{i.resolve(),s(l)}),i}use(e){if(!e)throw new Error("You are passing an undefined module! Please check the object you are passing to i18next.use()");if(!e.type)throw new Error("You are passing a wrong module! Please check the object you are passing to i18next.use()");return e.type==="backend"&&(this.modules.backend=e),(e.type==="logger"||e.log&&e.warn&&e.error)&&(this.modules.logger=e),e.type==="languageDetector"&&(this.modules.languageDetector=e),e.type==="i18nFormat"&&(this.modules.i18nFormat=e),e.type==="postProcessor"&&sv.addPostProcessor(e),e.type==="formatter"&&(this.modules.formatter=e),e.type==="3rdParty"&&this.modules.external.push(e),this}setResolvedLanguage(e){if(!(!e||!this.languages)&&!(["cimode","dev"].indexOf(e)>-1)){for(let r=0;r-1)&&this.store.hasLanguageSomeTranslations(s)){this.resolvedLanguage=s;break}}!this.resolvedLanguage&&this.languages.indexOf(e)<0&&this.store.hasLanguageSomeTranslations(e)&&(this.resolvedLanguage=e,this.languages.unshift(e))}}changeLanguage(e,r){this.isLanguageChangingTo=e;const s=Mi();this.emit("languageChanging",e);const i=d=>{this.language=d,this.languages=this.services.languageUtils.toResolveHierarchy(d),this.resolvedLanguage=void 0,this.setResolvedLanguage(d)},l=(d,h)=>{h?this.isLanguageChangingTo===e&&(i(h),this.translator.changeLanguage(h),this.isLanguageChangingTo=void 0,this.emit("languageChanged",h),this.logger.log("languageChanged",h)):this.isLanguageChangingTo=void 0,s.resolve((...p)=>this.t(...p)),r&&r(d,(...p)=>this.t(...p))},u=d=>{var y,v;!e&&!d&&this.services.languageDetector&&(d=[]);const h=ke(d)?d:d&&d[0],p=this.store.hasLanguageSomeTranslations(h)?h:this.services.languageUtils.getBestMatchFromCodes(ke(d)?[d]:d);p&&(this.language||i(p),this.translator.language||this.translator.changeLanguage(p),(v=(y=this.services.languageDetector)==null?void 0:y.cacheUserLanguage)==null||v.call(y,p)),this.loadResources(p,C=>{l(C,p)})};return!e&&this.services.languageDetector&&!this.services.languageDetector.async?u(this.services.languageDetector.detect()):!e&&this.services.languageDetector&&this.services.languageDetector.async?this.services.languageDetector.detect.length===0?this.services.languageDetector.detect().then(u):this.services.languageDetector.detect(u):u(e),s}getFixedT(e,r,s){const i=(l,u,...d)=>{let h;typeof u!="object"?h=this.options.overloadTranslationOptionHandler([l,u].concat(d)):h={...u},h.lng=h.lng||i.lng,h.lngs=h.lngs||i.lngs,h.ns=h.ns||i.ns,h.keyPrefix!==""&&(h.keyPrefix=h.keyPrefix||s||i.keyPrefix);const p=this.options.keySeparator||".";let y;return h.keyPrefix&&Array.isArray(l)?y=l.map(v=>(typeof v=="function"&&(v=Xd(v,{...this.options,...u})),`${h.keyPrefix}${p}${v}`)):(typeof l=="function"&&(l=Xd(l,{...this.options,...u})),y=h.keyPrefix?`${h.keyPrefix}${p}${l}`:l),this.t(y,h)};return ke(e)?i.lng=e:i.lngs=e,i.ns=r,i.keyPrefix=s,i}t(...e){var r;return(r=this.translator)==null?void 0:r.translate(...e)}exists(...e){var r;return(r=this.translator)==null?void 0:r.exists(...e)}setDefaultNamespace(e){this.options.defaultNS=e}hasLoadedNamespace(e,r={}){if(!this.isInitialized)return this.logger.warn("hasLoadedNamespace: i18next was not initialized",this.languages),!1;if(!this.languages||!this.languages.length)return this.logger.warn("hasLoadedNamespace: i18n.languages were undefined or empty",this.languages),!1;const s=r.lng||this.resolvedLanguage||this.languages[0],i=this.options?this.options.fallbackLng:!1,l=this.languages[this.languages.length-1];if(s.toLowerCase()==="cimode")return!0;const u=(d,h)=>{const p=this.services.backendConnector.state[`${d}|${h}`];return p===-1||p===0||p===2};if(r.precheck){const d=r.precheck(this,u);if(d!==void 0)return d}return!!(this.hasResourceBundle(s,e)||!this.services.backendConnector.backend||this.options.resources&&!this.options.partialBundledLanguages||u(s,e)&&(!i||u(l,e)))}loadNamespaces(e,r){const s=Mi();return this.options.ns?(ke(e)&&(e=[e]),e.forEach(i=>{this.options.ns.indexOf(i)<0&&this.options.ns.push(i)}),this.loadResources(i=>{s.resolve(),r&&r(i)}),s):(r&&r(),Promise.resolve())}loadLanguages(e,r){const s=Mi();ke(e)&&(e=[e]);const i=this.options.preload||[],l=e.filter(u=>i.indexOf(u)<0&&this.services.languageUtils.isSupportedCode(u));return l.length?(this.options.preload=i.concat(l),this.loadResources(u=>{s.resolve(),r&&r(u)}),s):(r&&r(),Promise.resolve())}dir(e){var i,l;if(e||(e=this.resolvedLanguage||(((i=this.languages)==null?void 0:i.length)>0?this.languages[0]:this.language)),!e)return"rtl";try{const u=new Intl.Locale(e);if(u&&u.getTextInfo){const d=u.getTextInfo();if(d&&d.direction)return d.direction}}catch{}const r=["ar","shu","sqr","ssh","xaa","yhd","yud","aao","abh","abv","acm","acq","acw","acx","acy","adf","ads","aeb","aec","afb","ajp","apc","apd","arb","arq","ars","ary","arz","auz","avl","ayh","ayl","ayn","ayp","bbz","pga","he","iw","ps","pbt","pbu","pst","prp","prd","ug","ur","ydd","yds","yih","ji","yi","hbo","men","xmn","fa","jpr","peo","pes","prs","dv","sam","ckb"],s=((l=this.services)==null?void 0:l.languageUtils)||new mg(bg());return e.toLowerCase().indexOf("-latn")>1?"ltr":r.indexOf(s.getLanguagePartFromCode(e))>-1||e.toLowerCase().indexOf("-arab")>1?"rtl":"ltr"}static createInstance(e={},r){const s=new Vi(e,r);return s.createInstance=Vi.createInstance,s}cloneInstance(e={},r=Nl){const s=e.forkResourceStore;s&&delete e.forkResourceStore;const i={...this.options,...e,isClone:!0},l=new Vi(i);if((e.debug!==void 0||e.prefix!==void 0)&&(l.logger=l.logger.clone(e)),["store","services","language"].forEach(d=>{l[d]=this[d]}),l.services={...this.services},l.services.utils={hasLoadedNamespace:l.hasLoadedNamespace.bind(l)},s){const d=Object.keys(this.store.data).reduce((h,p)=>(h[p]={...this.store.data[p]},h[p]=Object.keys(h[p]).reduce((y,v)=>(y[v]={...h[p][v]},y),h[p]),h),{});l.store=new hg(d,i),l.services.resourceStore=l.store}return e.interpolation&&(l.services.interpolator=new xg(i)),l.translator=new Yl(l.services,i),l.translator.on("*",(d,...h)=>{l.emit(d,...h)}),l.init(i,r),l.translator.options=i,l.translator.backendConnector.services.utils={hasLoadedNamespace:l.hasLoadedNamespace.bind(l)},l}toJSON(){return{options:this.options,store:this.store,language:this.language,languages:this.languages,resolvedLanguage:this.resolvedLanguage}}}const _t=Vi.createInstance();_t.createInstance;_t.dir;_t.init;_t.loadResources;_t.reloadResources;_t.use;_t.changeLanguage;_t.getFixedT;_t.t;_t.exists;_t.setDefaultNamespace;_t.hasLoadedNamespace;_t.loadNamespaces;_t.loadLanguages;const uC=(t,e,r,s)=>{var l,u,d,h;const i=[r,{code:e,...s||{}}];if((u=(l=t==null?void 0:t.services)==null?void 0:l.logger)!=null&&u.forward)return t.services.logger.forward(i,"warn","react-i18next::",!0);$o(i[0])&&(i[0]=`react-i18next:: ${i[0]}`),(h=(d=t==null?void 0:t.services)==null?void 0:d.logger)!=null&&h.warn?t.services.logger.warn(...i):console!=null&&console.warn&&console.warn(...i)},Cg={},av=(t,e,r,s)=>{$o(r)&&Cg[r]||($o(r)&&(Cg[r]=new Date),uC(t,e,r,s))},lv=(t,e)=>()=>{if(t.isInitialized)e();else{const r=()=>{setTimeout(()=>{t.off("initialized",r)},0),e()};t.on("initialized",r)}},Jd=(t,e,r)=>{t.loadNamespaces(e,lv(t,r))},Eg=(t,e,r,s)=>{if($o(r)&&(r=[r]),t.options.preload&&t.options.preload.indexOf(e)>-1)return Jd(t,r,s);r.forEach(i=>{t.options.ns.indexOf(i)<0&&t.options.ns.push(i)}),t.loadLanguages(e,lv(t,s))},dC=(t,e,r={})=>!e.languages||!e.languages.length?(av(e,"NO_LANGUAGES","i18n.languages were undefined or empty",{languages:e.languages}),!0):e.hasLoadedNamespace(t,{lng:r.lng,precheck:(s,i)=>{if(r.bindI18n&&r.bindI18n.indexOf("languageChanging")>-1&&s.services.backendConnector.backend&&s.isLanguageChangingTo&&!i(s.isLanguageChangingTo,t))return!1}}),$o=t=>typeof t=="string",fC=t=>typeof t=="object"&&t!==null,hC=/&(?:amp|#38|lt|#60|gt|#62|apos|#39|quot|#34|nbsp|#160|copy|#169|reg|#174|hellip|#8230|#x2F|#47);/g,pC={"&":"&","&":"&","<":"<","<":"<",">":">",">":">","'":"'","'":"'",""":'"',""":'"'," ":" "," ":" ","©":"©","©":"©","®":"®","®":"®","…":"…","…":"…","/":"/","/":"/"},mC=t=>pC[t],gC=t=>t.replace(hC,mC);let Zd={bindI18n:"languageChanged",bindI18nStore:"",transEmptyNodeValue:"",transSupportBasicHtmlNodes:!0,transWrapTextNodes:"",transKeepBasicHtmlNodesFor:["br","strong","i","p"],useSuspense:!0,unescape:gC,transDefaultProps:void 0};const yC=(t={})=>{Zd={...Zd,...t}},vC=()=>Zd;let cv;const xC=t=>{cv=t},wC=()=>cv,bC={type:"3rdParty",init(t){yC(t.options.react),xC(t)}},SC=x.createContext();class CC{constructor(){this.usedNamespaces={}}addUsedNamespaces(e){e.forEach(r=>{this.usedNamespaces[r]||(this.usedNamespaces[r]=!0)})}getUsedNamespaces(){return Object.keys(this.usedNamespaces)}}var yd={exports:{}},vd={};/** * @license React * use-sync-external-store-shim.production.js * * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */var kg;function EC(){if(kg)return vd;kg=1;var t=ac();function e(v,C){return v===C&&(v!==0||1/v===1/C)||v!==v&&C!==C}var r=typeof Object.is=="function"?Object.is:e,s=t.useState,i=t.useEffect,l=t.useLayoutEffect,u=t.useDebugValue;function d(v,C){var w=C(),E=s({inst:{value:w,getSnapshot:C}}),b=E[0].inst,k=E[1];return l(function(){b.value=w,b.getSnapshot=C,h(b)&&k({inst:b})},[v,w,C]),i(function(){return h(b)&&k({inst:b}),v(function(){h(b)&&k({inst:b})})},[v]),u(w),w}function h(v){var C=v.getSnapshot;v=v.value;try{var w=C();return!r(v,w)}catch{return!0}}function p(v,C){return C()}var y=typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"?p:d;return vd.useSyncExternalStore=t.useSyncExternalStore!==void 0?t.useSyncExternalStore:y,vd}var Ng;function kC(){return Ng||(Ng=1,yd.exports=EC()),yd.exports}var NC=kC();const RC=(t,e)=>$o(e)?e:fC(e)&&$o(e.defaultValue)?e.defaultValue:Array.isArray(t)?t[t.length-1]:t,PC={t:RC,ready:!1},TC=()=>()=>{},Zt=(t,e={})=>{var G,W,le;const{i18n:r}=e,{i18n:s,defaultNS:i}=x.useContext(SC)||{},l=r||s||wC();l&&!l.reportNamespaces&&(l.reportNamespaces=new CC),l||av(l,"NO_I18NEXT_INSTANCE","useTranslation: You will need to pass in an i18next instance by using initReactI18next");const u=x.useMemo(()=>{var K;return{...vC(),...(K=l==null?void 0:l.options)==null?void 0:K.react,...e}},[l,e]),{useSuspense:d,keyPrefix:h}=u,p=t||i||((G=l==null?void 0:l.options)==null?void 0:G.defaultNS),y=$o(p)?[p]:p||["translation"],v=x.useMemo(()=>y,y);(le=(W=l==null?void 0:l.reportNamespaces)==null?void 0:W.addUsedNamespaces)==null||le.call(W,v);const C=x.useRef(0),w=x.useCallback(K=>{if(!l)return TC;const{bindI18n:Z,bindI18nStore:J}=u,de=()=>{C.current+=1,K()};return Z&&l.on(Z,de),J&&l.store.on(J,de),()=>{Z&&Z.split(" ").forEach(ne=>l.off(ne,de)),J&&J.split(" ").forEach(ne=>l.store.off(ne,de))}},[l,u]),E=x.useRef(),b=x.useCallback(()=>{if(!l)return PC;const K=!!(l.isInitialized||l.initializedStoreOnce)&&v.every($=>dC($,l,u)),Z=e.lng||l.language,J=C.current,de=E.current;if(de&&de.ready===K&&de.lng===Z&&de.keyPrefix===h&&de.revision===J)return de;const se={t:l.getFixedT(Z,u.nsMode==="fallback"?v:v[0],h),ready:K,lng:Z,keyPrefix:h,revision:J};return E.current=se,se},[l,v,h,u,e.lng]),[k,T]=x.useState(0),{t:j,ready:_}=NC.useSyncExternalStore(w,b,b);x.useEffect(()=>{if(l&&!_&&!d){const K=()=>T(Z=>Z+1);e.lng?Eg(l,e.lng,v,K):Jd(l,v,K)}},[l,e.lng,v,_,d,k]);const A=l||{},F=x.useRef(null),V=x.useRef(),B=K=>{const Z=Object.getOwnPropertyDescriptors(K);Z.__original&&delete Z.__original;const J=Object.create(Object.getPrototypeOf(K),Z);if(!Object.prototype.hasOwnProperty.call(J,"__original"))try{Object.defineProperty(J,"__original",{value:K,writable:!1,enumerable:!1,configurable:!1})}catch{}return J},te=x.useMemo(()=>{const K=A,Z=K==null?void 0:K.language;let J=K;K&&(F.current&&F.current.__original===K?V.current!==Z?(J=B(K),F.current=J,V.current=Z):J=F.current:(J=B(K),F.current=J,V.current=Z));const de=[j,J,_];return de.t=j,de.i18n=J,de.ready=_,de},[j,A,_,A.resolvedLanguage,A.language,A.languages]);if(l&&d&&!_)throw new Promise(K=>{const Z=()=>K();e.lng?Eg(l,e.lng,v,Z):Jd(l,v,Z)});return te};function uv(t){var e,r,s="";if(typeof t=="string"||typeof t=="number")s+=t;else if(typeof t=="object")if(Array.isArray(t)){var i=t.length;for(e=0;etypeof t=="boolean"?`${t}`:t===0?"0":t,Pg=dv,_f=(t,e)=>r=>{var s;if((e==null?void 0:e.variants)==null)return Pg(t,r==null?void 0:r.class,r==null?void 0:r.className);const{variants:i,defaultVariants:l}=e,u=Object.keys(i).map(p=>{const y=r==null?void 0:r[p],v=l==null?void 0:l[p];if(y===null)return null;const C=Rg(y)||Rg(v);return i[p][C]}),d=r&&Object.entries(r).reduce((p,y)=>{let[v,C]=y;return C===void 0||(p[v]=C),p},{}),h=e==null||(s=e.compoundVariants)===null||s===void 0?void 0:s.reduce((p,y)=>{let{class:v,className:C,...w}=y;return Object.entries(w).every(E=>{let[b,k]=E;return Array.isArray(k)?k.includes({...l,...d}[b]):{...l,...d}[b]===k})?[...p,v,C]:p},[]);return Pg(t,u,h,r==null?void 0:r.class,r==null?void 0:r.className)},Af="-",OC=t=>{const e=_C(t),{conflictingClassGroups:r,conflictingClassGroupModifiers:s}=t;return{getClassGroupId:u=>{const d=u.split(Af);return d[0]===""&&d.length!==1&&d.shift(),fv(d,e)||jC(u)},getConflictingClassGroupIds:(u,d)=>{const h=r[u]||[];return d&&s[u]?[...h,...s[u]]:h}}},fv=(t,e)=>{var u;if(t.length===0)return e.classGroupId;const r=t[0],s=e.nextPart.get(r),i=s?fv(t.slice(1),s):void 0;if(i)return i;if(e.validators.length===0)return;const l=t.join(Af);return(u=e.validators.find(({validator:d})=>d(l)))==null?void 0:u.classGroupId},Tg=/^\[(.+)\]$/,jC=t=>{if(Tg.test(t)){const e=Tg.exec(t)[1],r=e==null?void 0:e.substring(0,e.indexOf(":"));if(r)return"arbitrary.."+r}},_C=t=>{const{theme:e,prefix:r}=t,s={nextPart:new Map,validators:[]};return LC(Object.entries(t.classGroups),r).forEach(([l,u])=>{ef(u,s,l,e)}),s},ef=(t,e,r,s)=>{t.forEach(i=>{if(typeof i=="string"){const l=i===""?e:Og(e,i);l.classGroupId=r;return}if(typeof i=="function"){if(AC(i)){ef(i(s),e,r,s);return}e.validators.push({validator:i,classGroupId:r});return}Object.entries(i).forEach(([l,u])=>{ef(u,Og(e,l),r,s)})})},Og=(t,e)=>{let r=t;return e.split(Af).forEach(s=>{r.nextPart.has(s)||r.nextPart.set(s,{nextPart:new Map,validators:[]}),r=r.nextPart.get(s)}),r},AC=t=>t.isThemeGetter,LC=(t,e)=>e?t.map(([r,s])=>{const i=s.map(l=>typeof l=="string"?e+l:typeof l=="object"?Object.fromEntries(Object.entries(l).map(([u,d])=>[e+u,d])):l);return[r,i]}):t,IC=t=>{if(t<1)return{get:()=>{},set:()=>{}};let e=0,r=new Map,s=new Map;const i=(l,u)=>{r.set(l,u),e++,e>t&&(e=0,s=r,r=new Map)};return{get(l){let u=r.get(l);if(u!==void 0)return u;if((u=s.get(l))!==void 0)return i(l,u),u},set(l,u){r.has(l)?r.set(l,u):i(l,u)}}},hv="!",DC=t=>{const{separator:e,experimentalParseClassName:r}=t,s=e.length===1,i=e[0],l=e.length,u=d=>{const h=[];let p=0,y=0,v;for(let k=0;ky?v-y:void 0;return{modifiers:h,hasImportantModifier:w,baseClassName:E,maybePostfixModifierPosition:b}};return r?d=>r({className:d,parseClassName:u}):u},MC=t=>{if(t.length<=1)return t;const e=[];let r=[];return t.forEach(s=>{s[0]==="["?(e.push(...r.sort(),s),r=[]):r.push(s)}),e.push(...r.sort()),e},FC=t=>({cache:IC(t.cacheSize),parseClassName:DC(t),...OC(t)}),zC=/\s+/,$C=(t,e)=>{const{parseClassName:r,getClassGroupId:s,getConflictingClassGroupIds:i}=e,l=[],u=t.trim().split(zC);let d="";for(let h=u.length-1;h>=0;h-=1){const p=u[h],{modifiers:y,hasImportantModifier:v,baseClassName:C,maybePostfixModifierPosition:w}=r(p);let E=!!w,b=s(E?C.substring(0,w):C);if(!b){if(!E){d=p+(d.length>0?" "+d:d);continue}if(b=s(C),!b){d=p+(d.length>0?" "+d:d);continue}E=!1}const k=MC(y).join(":"),T=v?k+hv:k,j=T+b;if(l.includes(j))continue;l.push(j);const _=i(b,E);for(let A=0;A<_.length;++A){const F=_[A];l.push(T+F)}d=p+(d.length>0?" "+d:d)}return d};function UC(){let t=0,e,r,s="";for(;t{if(typeof t=="string")return t;let e,r="";for(let s=0;sv(y),t());return r=FC(p),s=r.cache.get,i=r.cache.set,l=d,d(h)}function d(h){const p=s(h);if(p)return p;const y=$C(h,r);return i(h,y),y}return function(){return l(UC.apply(null,arguments))}}const Qe=t=>{const e=r=>r[t]||[];return e.isThemeGetter=!0,e},mv=/^\[(?:([a-z-]+):)?(.+)\]$/i,HC=/^\d+\/\d+$/,VC=new Set(["px","full","screen"]),WC=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,KC=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,qC=/^(rgba?|hsla?|hwb|(ok)?(lab|lch))\(.+\)$/,QC=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,YC=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,ur=t=>Rs(t)||VC.has(t)||HC.test(t),Br=t=>qs(t,"length",rE),Rs=t=>!!t&&!Number.isNaN(Number(t)),xd=t=>qs(t,"number",Rs),Fi=t=>!!t&&Number.isInteger(Number(t)),GC=t=>t.endsWith("%")&&Rs(t.slice(0,-1)),Pe=t=>mv.test(t),Hr=t=>WC.test(t),XC=new Set(["length","size","percentage"]),JC=t=>qs(t,XC,gv),ZC=t=>qs(t,"position",gv),eE=new Set(["image","url"]),tE=t=>qs(t,eE,sE),nE=t=>qs(t,"",oE),zi=()=>!0,qs=(t,e,r)=>{const s=mv.exec(t);return s?s[1]?typeof e=="string"?s[1]===e:e.has(s[1]):r(s[2]):!1},rE=t=>KC.test(t)&&!qC.test(t),gv=()=>!1,oE=t=>QC.test(t),sE=t=>YC.test(t),iE=()=>{const t=Qe("colors"),e=Qe("spacing"),r=Qe("blur"),s=Qe("brightness"),i=Qe("borderColor"),l=Qe("borderRadius"),u=Qe("borderSpacing"),d=Qe("borderWidth"),h=Qe("contrast"),p=Qe("grayscale"),y=Qe("hueRotate"),v=Qe("invert"),C=Qe("gap"),w=Qe("gradientColorStops"),E=Qe("gradientColorStopPositions"),b=Qe("inset"),k=Qe("margin"),T=Qe("opacity"),j=Qe("padding"),_=Qe("saturate"),A=Qe("scale"),F=Qe("sepia"),V=Qe("skew"),B=Qe("space"),te=Qe("translate"),G=()=>["auto","contain","none"],W=()=>["auto","hidden","clip","visible","scroll"],le=()=>["auto",Pe,e],K=()=>[Pe,e],Z=()=>["",ur,Br],J=()=>["auto",Rs,Pe],de=()=>["bottom","center","left","left-bottom","left-top","right","right-bottom","right-top","top"],ne=()=>["solid","dashed","dotted","double","none"],se=()=>["normal","multiply","screen","overlay","darken","lighten","color-dodge","color-burn","hard-light","soft-light","difference","exclusion","hue","saturation","color","luminosity"],$=()=>["start","end","center","between","around","evenly","stretch"],H=()=>["","0",Pe],Q=()=>["auto","avoid","all","avoid-page","page","left","right","column"],P=()=>[Rs,Pe];return{cacheSize:500,separator:":",theme:{colors:[zi],spacing:[ur,Br],blur:["none","",Hr,Pe],brightness:P(),borderColor:[t],borderRadius:["none","","full",Hr,Pe],borderSpacing:K(),borderWidth:Z(),contrast:P(),grayscale:H(),hueRotate:P(),invert:H(),gap:K(),gradientColorStops:[t],gradientColorStopPositions:[GC,Br],inset:le(),margin:le(),opacity:P(),padding:K(),saturate:P(),scale:P(),sepia:H(),skew:P(),space:K(),translate:K()},classGroups:{aspect:[{aspect:["auto","square","video",Pe]}],container:["container"],columns:[{columns:[Hr]}],"break-after":[{"break-after":Q()}],"break-before":[{"break-before":Q()}],"break-inside":[{"break-inside":["auto","avoid","avoid-page","avoid-column"]}],"box-decoration":[{"box-decoration":["slice","clone"]}],box:[{box:["border","content"]}],display:["block","inline-block","inline","flex","inline-flex","table","inline-table","table-caption","table-cell","table-column","table-column-group","table-footer-group","table-header-group","table-row-group","table-row","flow-root","grid","inline-grid","contents","list-item","hidden"],float:[{float:["right","left","none","start","end"]}],clear:[{clear:["left","right","both","none","start","end"]}],isolation:["isolate","isolation-auto"],"object-fit":[{object:["contain","cover","fill","none","scale-down"]}],"object-position":[{object:[...de(),Pe]}],overflow:[{overflow:W()}],"overflow-x":[{"overflow-x":W()}],"overflow-y":[{"overflow-y":W()}],overscroll:[{overscroll:G()}],"overscroll-x":[{"overscroll-x":G()}],"overscroll-y":[{"overscroll-y":G()}],position:["static","fixed","absolute","relative","sticky"],inset:[{inset:[b]}],"inset-x":[{"inset-x":[b]}],"inset-y":[{"inset-y":[b]}],start:[{start:[b]}],end:[{end:[b]}],top:[{top:[b]}],right:[{right:[b]}],bottom:[{bottom:[b]}],left:[{left:[b]}],visibility:["visible","invisible","collapse"],z:[{z:["auto",Fi,Pe]}],basis:[{basis:le()}],"flex-direction":[{flex:["row","row-reverse","col","col-reverse"]}],"flex-wrap":[{flex:["wrap","wrap-reverse","nowrap"]}],flex:[{flex:["1","auto","initial","none",Pe]}],grow:[{grow:H()}],shrink:[{shrink:H()}],order:[{order:["first","last","none",Fi,Pe]}],"grid-cols":[{"grid-cols":[zi]}],"col-start-end":[{col:["auto",{span:["full",Fi,Pe]},Pe]}],"col-start":[{"col-start":J()}],"col-end":[{"col-end":J()}],"grid-rows":[{"grid-rows":[zi]}],"row-start-end":[{row:["auto",{span:[Fi,Pe]},Pe]}],"row-start":[{"row-start":J()}],"row-end":[{"row-end":J()}],"grid-flow":[{"grid-flow":["row","col","dense","row-dense","col-dense"]}],"auto-cols":[{"auto-cols":["auto","min","max","fr",Pe]}],"auto-rows":[{"auto-rows":["auto","min","max","fr",Pe]}],gap:[{gap:[C]}],"gap-x":[{"gap-x":[C]}],"gap-y":[{"gap-y":[C]}],"justify-content":[{justify:["normal",...$()]}],"justify-items":[{"justify-items":["start","end","center","stretch"]}],"justify-self":[{"justify-self":["auto","start","end","center","stretch"]}],"align-content":[{content:["normal",...$(),"baseline"]}],"align-items":[{items:["start","end","center","baseline","stretch"]}],"align-self":[{self:["auto","start","end","center","stretch","baseline"]}],"place-content":[{"place-content":[...$(),"baseline"]}],"place-items":[{"place-items":["start","end","center","baseline","stretch"]}],"place-self":[{"place-self":["auto","start","end","center","stretch"]}],p:[{p:[j]}],px:[{px:[j]}],py:[{py:[j]}],ps:[{ps:[j]}],pe:[{pe:[j]}],pt:[{pt:[j]}],pr:[{pr:[j]}],pb:[{pb:[j]}],pl:[{pl:[j]}],m:[{m:[k]}],mx:[{mx:[k]}],my:[{my:[k]}],ms:[{ms:[k]}],me:[{me:[k]}],mt:[{mt:[k]}],mr:[{mr:[k]}],mb:[{mb:[k]}],ml:[{ml:[k]}],"space-x":[{"space-x":[B]}],"space-x-reverse":["space-x-reverse"],"space-y":[{"space-y":[B]}],"space-y-reverse":["space-y-reverse"],w:[{w:["auto","min","max","fit","svw","lvw","dvw",Pe,e]}],"min-w":[{"min-w":[Pe,e,"min","max","fit"]}],"max-w":[{"max-w":[Pe,e,"none","full","min","max","fit","prose",{screen:[Hr]},Hr]}],h:[{h:[Pe,e,"auto","min","max","fit","svh","lvh","dvh"]}],"min-h":[{"min-h":[Pe,e,"min","max","fit","svh","lvh","dvh"]}],"max-h":[{"max-h":[Pe,e,"min","max","fit","svh","lvh","dvh"]}],size:[{size:[Pe,e,"auto","min","max","fit"]}],"font-size":[{text:["base",Hr,Br]}],"font-smoothing":["antialiased","subpixel-antialiased"],"font-style":["italic","not-italic"],"font-weight":[{font:["thin","extralight","light","normal","medium","semibold","bold","extrabold","black",xd]}],"font-family":[{font:[zi]}],"fvn-normal":["normal-nums"],"fvn-ordinal":["ordinal"],"fvn-slashed-zero":["slashed-zero"],"fvn-figure":["lining-nums","oldstyle-nums"],"fvn-spacing":["proportional-nums","tabular-nums"],"fvn-fraction":["diagonal-fractions","stacked-fractions"],tracking:[{tracking:["tighter","tight","normal","wide","wider","widest",Pe]}],"line-clamp":[{"line-clamp":["none",Rs,xd]}],leading:[{leading:["none","tight","snug","normal","relaxed","loose",ur,Pe]}],"list-image":[{"list-image":["none",Pe]}],"list-style-type":[{list:["none","disc","decimal",Pe]}],"list-style-position":[{list:["inside","outside"]}],"placeholder-color":[{placeholder:[t]}],"placeholder-opacity":[{"placeholder-opacity":[T]}],"text-alignment":[{text:["left","center","right","justify","start","end"]}],"text-color":[{text:[t]}],"text-opacity":[{"text-opacity":[T]}],"text-decoration":["underline","overline","line-through","no-underline"],"text-decoration-style":[{decoration:[...ne(),"wavy"]}],"text-decoration-thickness":[{decoration:["auto","from-font",ur,Br]}],"underline-offset":[{"underline-offset":["auto",ur,Pe]}],"text-decoration-color":[{decoration:[t]}],"text-transform":["uppercase","lowercase","capitalize","normal-case"],"text-overflow":["truncate","text-ellipsis","text-clip"],"text-wrap":[{text:["wrap","nowrap","balance","pretty"]}],indent:[{indent:K()}],"vertical-align":[{align:["baseline","top","middle","bottom","text-top","text-bottom","sub","super",Pe]}],whitespace:[{whitespace:["normal","nowrap","pre","pre-line","pre-wrap","break-spaces"]}],break:[{break:["normal","words","all","keep"]}],hyphens:[{hyphens:["none","manual","auto"]}],content:[{content:["none",Pe]}],"bg-attachment":[{bg:["fixed","local","scroll"]}],"bg-clip":[{"bg-clip":["border","padding","content","text"]}],"bg-opacity":[{"bg-opacity":[T]}],"bg-origin":[{"bg-origin":["border","padding","content"]}],"bg-position":[{bg:[...de(),ZC]}],"bg-repeat":[{bg:["no-repeat",{repeat:["","x","y","round","space"]}]}],"bg-size":[{bg:["auto","cover","contain",JC]}],"bg-image":[{bg:["none",{"gradient-to":["t","tr","r","br","b","bl","l","tl"]},tE]}],"bg-color":[{bg:[t]}],"gradient-from-pos":[{from:[E]}],"gradient-via-pos":[{via:[E]}],"gradient-to-pos":[{to:[E]}],"gradient-from":[{from:[w]}],"gradient-via":[{via:[w]}],"gradient-to":[{to:[w]}],rounded:[{rounded:[l]}],"rounded-s":[{"rounded-s":[l]}],"rounded-e":[{"rounded-e":[l]}],"rounded-t":[{"rounded-t":[l]}],"rounded-r":[{"rounded-r":[l]}],"rounded-b":[{"rounded-b":[l]}],"rounded-l":[{"rounded-l":[l]}],"rounded-ss":[{"rounded-ss":[l]}],"rounded-se":[{"rounded-se":[l]}],"rounded-ee":[{"rounded-ee":[l]}],"rounded-es":[{"rounded-es":[l]}],"rounded-tl":[{"rounded-tl":[l]}],"rounded-tr":[{"rounded-tr":[l]}],"rounded-br":[{"rounded-br":[l]}],"rounded-bl":[{"rounded-bl":[l]}],"border-w":[{border:[d]}],"border-w-x":[{"border-x":[d]}],"border-w-y":[{"border-y":[d]}],"border-w-s":[{"border-s":[d]}],"border-w-e":[{"border-e":[d]}],"border-w-t":[{"border-t":[d]}],"border-w-r":[{"border-r":[d]}],"border-w-b":[{"border-b":[d]}],"border-w-l":[{"border-l":[d]}],"border-opacity":[{"border-opacity":[T]}],"border-style":[{border:[...ne(),"hidden"]}],"divide-x":[{"divide-x":[d]}],"divide-x-reverse":["divide-x-reverse"],"divide-y":[{"divide-y":[d]}],"divide-y-reverse":["divide-y-reverse"],"divide-opacity":[{"divide-opacity":[T]}],"divide-style":[{divide:ne()}],"border-color":[{border:[i]}],"border-color-x":[{"border-x":[i]}],"border-color-y":[{"border-y":[i]}],"border-color-s":[{"border-s":[i]}],"border-color-e":[{"border-e":[i]}],"border-color-t":[{"border-t":[i]}],"border-color-r":[{"border-r":[i]}],"border-color-b":[{"border-b":[i]}],"border-color-l":[{"border-l":[i]}],"divide-color":[{divide:[i]}],"outline-style":[{outline:["",...ne()]}],"outline-offset":[{"outline-offset":[ur,Pe]}],"outline-w":[{outline:[ur,Br]}],"outline-color":[{outline:[t]}],"ring-w":[{ring:Z()}],"ring-w-inset":["ring-inset"],"ring-color":[{ring:[t]}],"ring-opacity":[{"ring-opacity":[T]}],"ring-offset-w":[{"ring-offset":[ur,Br]}],"ring-offset-color":[{"ring-offset":[t]}],shadow:[{shadow:["","inner","none",Hr,nE]}],"shadow-color":[{shadow:[zi]}],opacity:[{opacity:[T]}],"mix-blend":[{"mix-blend":[...se(),"plus-lighter","plus-darker"]}],"bg-blend":[{"bg-blend":se()}],filter:[{filter:["","none"]}],blur:[{blur:[r]}],brightness:[{brightness:[s]}],contrast:[{contrast:[h]}],"drop-shadow":[{"drop-shadow":["","none",Hr,Pe]}],grayscale:[{grayscale:[p]}],"hue-rotate":[{"hue-rotate":[y]}],invert:[{invert:[v]}],saturate:[{saturate:[_]}],sepia:[{sepia:[F]}],"backdrop-filter":[{"backdrop-filter":["","none"]}],"backdrop-blur":[{"backdrop-blur":[r]}],"backdrop-brightness":[{"backdrop-brightness":[s]}],"backdrop-contrast":[{"backdrop-contrast":[h]}],"backdrop-grayscale":[{"backdrop-grayscale":[p]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[y]}],"backdrop-invert":[{"backdrop-invert":[v]}],"backdrop-opacity":[{"backdrop-opacity":[T]}],"backdrop-saturate":[{"backdrop-saturate":[_]}],"backdrop-sepia":[{"backdrop-sepia":[F]}],"border-collapse":[{border:["collapse","separate"]}],"border-spacing":[{"border-spacing":[u]}],"border-spacing-x":[{"border-spacing-x":[u]}],"border-spacing-y":[{"border-spacing-y":[u]}],"table-layout":[{table:["auto","fixed"]}],caption:[{caption:["top","bottom"]}],transition:[{transition:["none","all","","colors","opacity","shadow","transform",Pe]}],duration:[{duration:P()}],ease:[{ease:["linear","in","out","in-out",Pe]}],delay:[{delay:P()}],animate:[{animate:["none","spin","ping","pulse","bounce",Pe]}],transform:[{transform:["","gpu","none"]}],scale:[{scale:[A]}],"scale-x":[{"scale-x":[A]}],"scale-y":[{"scale-y":[A]}],rotate:[{rotate:[Fi,Pe]}],"translate-x":[{"translate-x":[te]}],"translate-y":[{"translate-y":[te]}],"skew-x":[{"skew-x":[V]}],"skew-y":[{"skew-y":[V]}],"transform-origin":[{origin:["center","top","top-right","right","bottom-right","bottom","bottom-left","left","top-left",Pe]}],accent:[{accent:["auto",t]}],appearance:[{appearance:["none","auto"]}],cursor:[{cursor:["auto","default","pointer","wait","text","move","help","not-allowed","none","context-menu","progress","cell","crosshair","vertical-text","alias","copy","no-drop","grab","grabbing","all-scroll","col-resize","row-resize","n-resize","e-resize","s-resize","w-resize","ne-resize","nw-resize","se-resize","sw-resize","ew-resize","ns-resize","nesw-resize","nwse-resize","zoom-in","zoom-out",Pe]}],"caret-color":[{caret:[t]}],"pointer-events":[{"pointer-events":["none","auto"]}],resize:[{resize:["none","y","x",""]}],"scroll-behavior":[{scroll:["auto","smooth"]}],"scroll-m":[{"scroll-m":K()}],"scroll-mx":[{"scroll-mx":K()}],"scroll-my":[{"scroll-my":K()}],"scroll-ms":[{"scroll-ms":K()}],"scroll-me":[{"scroll-me":K()}],"scroll-mt":[{"scroll-mt":K()}],"scroll-mr":[{"scroll-mr":K()}],"scroll-mb":[{"scroll-mb":K()}],"scroll-ml":[{"scroll-ml":K()}],"scroll-p":[{"scroll-p":K()}],"scroll-px":[{"scroll-px":K()}],"scroll-py":[{"scroll-py":K()}],"scroll-ps":[{"scroll-ps":K()}],"scroll-pe":[{"scroll-pe":K()}],"scroll-pt":[{"scroll-pt":K()}],"scroll-pr":[{"scroll-pr":K()}],"scroll-pb":[{"scroll-pb":K()}],"scroll-pl":[{"scroll-pl":K()}],"snap-align":[{snap:["start","end","center","align-none"]}],"snap-stop":[{snap:["normal","always"]}],"snap-type":[{snap:["none","x","y","both"]}],"snap-strictness":[{snap:["mandatory","proximity"]}],touch:[{touch:["auto","none","manipulation"]}],"touch-x":[{"touch-pan":["x","left","right"]}],"touch-y":[{"touch-pan":["y","up","down"]}],"touch-pz":["touch-pinch-zoom"],select:[{select:["none","text","all","auto"]}],"will-change":[{"will-change":["auto","scroll","contents","transform",Pe]}],fill:[{fill:[t,"none"]}],"stroke-w":[{stroke:[ur,Br,xd]}],stroke:[{stroke:[t,"none"]}],sr:["sr-only","not-sr-only"],"forced-color-adjust":[{"forced-color-adjust":["auto","none"]}]},conflictingClassGroups:{overflow:["overflow-x","overflow-y"],overscroll:["overscroll-x","overscroll-y"],inset:["inset-x","inset-y","start","end","top","right","bottom","left"],"inset-x":["right","left"],"inset-y":["top","bottom"],flex:["basis","grow","shrink"],gap:["gap-x","gap-y"],p:["px","py","ps","pe","pt","pr","pb","pl"],px:["pr","pl"],py:["pt","pb"],m:["mx","my","ms","me","mt","mr","mb","ml"],mx:["mr","ml"],my:["mt","mb"],size:["w","h"],"font-size":["leading"],"fvn-normal":["fvn-ordinal","fvn-slashed-zero","fvn-figure","fvn-spacing","fvn-fraction"],"fvn-ordinal":["fvn-normal"],"fvn-slashed-zero":["fvn-normal"],"fvn-figure":["fvn-normal"],"fvn-spacing":["fvn-normal"],"fvn-fraction":["fvn-normal"],"line-clamp":["display","overflow"],rounded:["rounded-s","rounded-e","rounded-t","rounded-r","rounded-b","rounded-l","rounded-ss","rounded-se","rounded-ee","rounded-es","rounded-tl","rounded-tr","rounded-br","rounded-bl"],"rounded-s":["rounded-ss","rounded-es"],"rounded-e":["rounded-se","rounded-ee"],"rounded-t":["rounded-tl","rounded-tr"],"rounded-r":["rounded-tr","rounded-br"],"rounded-b":["rounded-br","rounded-bl"],"rounded-l":["rounded-tl","rounded-bl"],"border-spacing":["border-spacing-x","border-spacing-y"],"border-w":["border-w-s","border-w-e","border-w-t","border-w-r","border-w-b","border-w-l"],"border-w-x":["border-w-r","border-w-l"],"border-w-y":["border-w-t","border-w-b"],"border-color":["border-color-s","border-color-e","border-color-t","border-color-r","border-color-b","border-color-l"],"border-color-x":["border-color-r","border-color-l"],"border-color-y":["border-color-t","border-color-b"],"scroll-m":["scroll-mx","scroll-my","scroll-ms","scroll-me","scroll-mt","scroll-mr","scroll-mb","scroll-ml"],"scroll-mx":["scroll-mr","scroll-ml"],"scroll-my":["scroll-mt","scroll-mb"],"scroll-p":["scroll-px","scroll-py","scroll-ps","scroll-pe","scroll-pt","scroll-pr","scroll-pb","scroll-pl"],"scroll-px":["scroll-pr","scroll-pl"],"scroll-py":["scroll-pt","scroll-pb"],touch:["touch-x","touch-y","touch-pz"],"touch-x":["touch"],"touch-y":["touch"],"touch-pz":["touch"]},conflictingClassGroupModifiers:{"font-size":["leading"]}}},aE=BC(iE);function Be(...t){return aE(dv(t))}function lE(t){if(t===0)return"0 B";const e=1024,r=["B","KB","MB","GB","TB"],s=Math.floor(Math.log(t)/Math.log(e));return parseFloat((t/Math.pow(e,s)).toFixed(2))+" "+r[s]}function cE(t){return(typeof t=="string"?new Date(t):typeof t=="number"?new Date(t*1e3):t).toLocaleString("zh-CN",{year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit"})}const uE=_f("inline-flex items-center rounded-sm border px-2 py-0.5 text-xs font-mono transition-colors focus:outline-none",{variants:{variant:{default:"border-cyber-neon-cyan/30 bg-cyber-neon-cyan/10 text-cyber-neon-cyan",secondary:"border-cyber-border-DEFAULT bg-cyber-bg-tertiary text-cyber-text-secondary",destructive:"border-cyber-neon-pink/30 bg-cyber-neon-pink/10 text-cyber-neon-pink",outline:"border-cyber-border-DEFAULT text-cyber-text-primary",success:"border-cyber-neon-green/30 bg-cyber-neon-green/10 text-cyber-neon-green shadow-glow-green-sm",warning:"border-cyber-neon-orange/30 bg-cyber-neon-orange/10 text-cyber-neon-orange",idle:"border-cyber-border-DEFAULT bg-cyber-bg-tertiary text-cyber-text-muted",running:"border-cyber-neon-green/50 bg-cyber-neon-green/20 text-cyber-neon-green shadow-glow-green-sm animate-pulse-fast"}},defaultVariants:{variant:"default"}});function qi({className:t,variant:e,...r}){return g.jsx("div",{className:Be(uE({variant:e}),t),...r})}const jg=t=>{let e;const r=new Set,s=(p,y)=>{const v=typeof p=="function"?p(e):p;if(!Object.is(v,e)){const C=e;e=y??(typeof v!="object"||v===null)?v:Object.assign({},e,v),r.forEach(w=>w(e,C))}},i=()=>e,d={setState:s,getState:i,getInitialState:()=>h,subscribe:p=>(r.add(p),()=>r.delete(p))},h=e=t(s,i,d);return d},dE=(t=>t?jg(t):jg),fE=t=>t;function hE(t,e=fE){const r=oe.useSyncExternalStore(t.subscribe,oe.useCallback(()=>e(t.getState()),[t,e]),oe.useCallback(()=>e(t.getInitialState()),[t,e]));return oe.useDebugValue(r),r}const _g=t=>{const e=dE(t),r=s=>hE(e,s);return Object.assign(r,e),r},yv=(t=>t?_g(t):_g),tf="mediacrawler_cleared_log_id";function pE(){const t=localStorage.getItem(tf);if(t===null)return null;const e=parseInt(t,10);return isNaN(e)?null:e}function Rl(t){t===null?localStorage.removeItem(tf):localStorage.setItem(tf,t.toString())}const mE={platform:"bili",login_type:"qrcode",crawler_type:"search",keywords:"",specified_ids:"",creator_ids:"",start_page:1,enable_comments:!0,enable_sub_comments:!1,save_option:"json",cookies:"",headless:!1},jt=yv((t,e)=>({status:"idle",platform:null,crawlerType:null,startedAt:null,logs:[],clearedAfterLogId:pE(),config:mE,setStatus:r=>{t({status:r}),r==="running"&&e().clearedAfterLogId!==null&&(t({clearedAfterLogId:null}),Rl(null))},setRunningInfo:(r,s,i)=>{t({platform:r,crawlerType:s,startedAt:i}),i!==null&&e().clearedAfterLogId!==null&&(t({clearedAfterLogId:null}),Rl(null))},addLog:r=>{const{clearedAfterLogId:s,logs:i}=e();s!==null&&r.id<=s||i.length>0&&i[i.length-1].id===r.id||i.some(l=>l.id===r.id)||t(l=>({logs:[...l.logs.slice(-499),r]}))},setLogs:r=>{const{clearedAfterLogId:s}=e(),i=s!==null?r.filter(l=>l.id>s):r;t({logs:i})},clearLogs:()=>{const{logs:r}=e(),s=r.length>0?Math.max(...r.map(i=>i.id)):0;t({logs:[],clearedAfterLogId:s}),Rl(s)},restoreLogs:()=>{t({clearedAfterLogId:null}),Rl(null),window.location.reload()},updateConfig:r=>t(s=>({config:{...s.config,...r}})),reset:()=>t({status:"idle",platform:null,crawlerType:null,startedAt:null})}));function vv(t,e){return function(){return t.apply(e,arguments)}}const{toString:gE}=Object.prototype,{getPrototypeOf:Lf}=Object,{iterator:uc,toStringTag:xv}=Symbol,dc=(t=>e=>{const r=gE.call(e);return t[r]||(t[r]=r.slice(8,-1).toLowerCase())})(Object.create(null)),Pn=t=>(t=t.toLowerCase(),e=>dc(e)===t),fc=t=>e=>typeof e===t,{isArray:Qs}=Array,Hs=fc("undefined");function ra(t){return t!==null&&!Hs(t)&&t.constructor!==null&&!Hs(t.constructor)&&Wt(t.constructor.isBuffer)&&t.constructor.isBuffer(t)}const wv=Pn("ArrayBuffer");function yE(t){let e;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?e=ArrayBuffer.isView(t):e=t&&t.buffer&&wv(t.buffer),e}const vE=fc("string"),Wt=fc("function"),bv=fc("number"),oa=t=>t!==null&&typeof t=="object",xE=t=>t===!0||t===!1,Ml=t=>{if(dc(t)!=="object")return!1;const e=Lf(t);return(e===null||e===Object.prototype||Object.getPrototypeOf(e)===null)&&!(xv in t)&&!(uc in t)},wE=t=>{if(!oa(t)||ra(t))return!1;try{return Object.keys(t).length===0&&Object.getPrototypeOf(t)===Object.prototype}catch{return!1}},bE=Pn("Date"),SE=Pn("File"),CE=Pn("Blob"),EE=Pn("FileList"),kE=t=>oa(t)&&Wt(t.pipe),NE=t=>{let e;return t&&(typeof FormData=="function"&&t instanceof FormData||Wt(t.append)&&((e=dc(t))==="formdata"||e==="object"&&Wt(t.toString)&&t.toString()==="[object FormData]"))},RE=Pn("URLSearchParams"),[PE,TE,OE,jE]=["ReadableStream","Request","Response","Headers"].map(Pn),_E=t=>t.trim?t.trim():t.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function sa(t,e,{allOwnKeys:r=!1}={}){if(t===null||typeof t>"u")return;let s,i;if(typeof t!="object"&&(t=[t]),Qs(t))for(s=0,i=t.length;s0;)if(i=r[s],e===i.toLowerCase())return i;return null}const Eo=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,Cv=t=>!Hs(t)&&t!==Eo;function nf(){const{caseless:t,skipUndefined:e}=Cv(this)&&this||{},r={},s=(i,l)=>{const u=t&&Sv(r,l)||l;Ml(r[u])&&Ml(i)?r[u]=nf(r[u],i):Ml(i)?r[u]=nf({},i):Qs(i)?r[u]=i.slice():(!e||!Hs(i))&&(r[u]=i)};for(let i=0,l=arguments.length;i(sa(e,(i,l)=>{r&&Wt(i)?t[l]=vv(i,r):t[l]=i},{allOwnKeys:s}),t),LE=t=>(t.charCodeAt(0)===65279&&(t=t.slice(1)),t),IE=(t,e,r,s)=>{t.prototype=Object.create(e.prototype,s),t.prototype.constructor=t,Object.defineProperty(t,"super",{value:e.prototype}),r&&Object.assign(t.prototype,r)},DE=(t,e,r,s)=>{let i,l,u;const d={};if(e=e||{},t==null)return e;do{for(i=Object.getOwnPropertyNames(t),l=i.length;l-- >0;)u=i[l],(!s||s(u,t,e))&&!d[u]&&(e[u]=t[u],d[u]=!0);t=r!==!1&&Lf(t)}while(t&&(!r||r(t,e))&&t!==Object.prototype);return e},ME=(t,e,r)=>{t=String(t),(r===void 0||r>t.length)&&(r=t.length),r-=e.length;const s=t.indexOf(e,r);return s!==-1&&s===r},FE=t=>{if(!t)return null;if(Qs(t))return t;let e=t.length;if(!bv(e))return null;const r=new Array(e);for(;e-- >0;)r[e]=t[e];return r},zE=(t=>e=>t&&e instanceof t)(typeof Uint8Array<"u"&&Lf(Uint8Array)),$E=(t,e)=>{const s=(t&&t[uc]).call(t);let i;for(;(i=s.next())&&!i.done;){const l=i.value;e.call(t,l[0],l[1])}},UE=(t,e)=>{let r;const s=[];for(;(r=t.exec(e))!==null;)s.push(r);return s},BE=Pn("HTMLFormElement"),HE=t=>t.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(r,s,i){return s.toUpperCase()+i}),Ag=(({hasOwnProperty:t})=>(e,r)=>t.call(e,r))(Object.prototype),VE=Pn("RegExp"),Ev=(t,e)=>{const r=Object.getOwnPropertyDescriptors(t),s={};sa(r,(i,l)=>{let u;(u=e(i,l,t))!==!1&&(s[l]=u||i)}),Object.defineProperties(t,s)},WE=t=>{Ev(t,(e,r)=>{if(Wt(t)&&["arguments","caller","callee"].indexOf(r)!==-1)return!1;const s=t[r];if(Wt(s)){if(e.enumerable=!1,"writable"in e){e.writable=!1;return}e.set||(e.set=()=>{throw Error("Can not rewrite read-only method '"+r+"'")})}})},KE=(t,e)=>{const r={},s=i=>{i.forEach(l=>{r[l]=!0})};return Qs(t)?s(t):s(String(t).split(e)),r},qE=()=>{},QE=(t,e)=>t!=null&&Number.isFinite(t=+t)?t:e;function YE(t){return!!(t&&Wt(t.append)&&t[xv]==="FormData"&&t[uc])}const GE=t=>{const e=new Array(10),r=(s,i)=>{if(oa(s)){if(e.indexOf(s)>=0)return;if(ra(s))return s;if(!("toJSON"in s)){e[i]=s;const l=Qs(s)?[]:{};return sa(s,(u,d)=>{const h=r(u,i+1);!Hs(h)&&(l[d]=h)}),e[i]=void 0,l}}return s};return r(t,0)},XE=Pn("AsyncFunction"),JE=t=>t&&(oa(t)||Wt(t))&&Wt(t.then)&&Wt(t.catch),kv=((t,e)=>t?setImmediate:e?((r,s)=>(Eo.addEventListener("message",({source:i,data:l})=>{i===Eo&&l===r&&s.length&&s.shift()()},!1),i=>{s.push(i),Eo.postMessage(r,"*")}))(`axios@${Math.random()}`,[]):r=>setTimeout(r))(typeof setImmediate=="function",Wt(Eo.postMessage)),ZE=typeof queueMicrotask<"u"?queueMicrotask.bind(Eo):typeof process<"u"&&process.nextTick||kv,ek=t=>t!=null&&Wt(t[uc]),U={isArray:Qs,isArrayBuffer:wv,isBuffer:ra,isFormData:NE,isArrayBufferView:yE,isString:vE,isNumber:bv,isBoolean:xE,isObject:oa,isPlainObject:Ml,isEmptyObject:wE,isReadableStream:PE,isRequest:TE,isResponse:OE,isHeaders:jE,isUndefined:Hs,isDate:bE,isFile:SE,isBlob:CE,isRegExp:VE,isFunction:Wt,isStream:kE,isURLSearchParams:RE,isTypedArray:zE,isFileList:EE,forEach:sa,merge:nf,extend:AE,trim:_E,stripBOM:LE,inherits:IE,toFlatObject:DE,kindOf:dc,kindOfTest:Pn,endsWith:ME,toArray:FE,forEachEntry:$E,matchAll:UE,isHTMLForm:BE,hasOwnProperty:Ag,hasOwnProp:Ag,reduceDescriptors:Ev,freezeMethods:WE,toObjectSet:KE,toCamelCase:HE,noop:qE,toFiniteNumber:QE,findKey:Sv,global:Eo,isContextDefined:Cv,isSpecCompliantForm:YE,toJSONObject:GE,isAsyncFn:XE,isThenable:JE,setImmediate:kv,asap:ZE,isIterable:ek};function Re(t,e,r,s,i){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack,this.message=t,this.name="AxiosError",e&&(this.code=e),r&&(this.config=r),s&&(this.request=s),i&&(this.response=i,this.status=i.status?i.status:null)}U.inherits(Re,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:U.toJSONObject(this.config),code:this.code,status:this.status}}});const Nv=Re.prototype,Rv={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach(t=>{Rv[t]={value:t}});Object.defineProperties(Re,Rv);Object.defineProperty(Nv,"isAxiosError",{value:!0});Re.from=(t,e,r,s,i,l)=>{const u=Object.create(Nv);U.toFlatObject(t,u,function(y){return y!==Error.prototype},p=>p!=="isAxiosError");const d=t&&t.message?t.message:"Error",h=e==null&&t?t.code:e;return Re.call(u,d,h,r,s,i),t&&u.cause==null&&Object.defineProperty(u,"cause",{value:t,configurable:!0}),u.name=t&&t.name||"Error",l&&Object.assign(u,l),u};const tk=null;function rf(t){return U.isPlainObject(t)||U.isArray(t)}function Pv(t){return U.endsWith(t,"[]")?t.slice(0,-2):t}function Lg(t,e,r){return t?t.concat(e).map(function(i,l){return i=Pv(i),!r&&l?"["+i+"]":i}).join(r?".":""):e}function nk(t){return U.isArray(t)&&!t.some(rf)}const rk=U.toFlatObject(U,{},null,function(e){return/^is[A-Z]/.test(e)});function hc(t,e,r){if(!U.isObject(t))throw new TypeError("target must be an object");e=e||new FormData,r=U.toFlatObject(r,{metaTokens:!0,dots:!1,indexes:!1},!1,function(b,k){return!U.isUndefined(k[b])});const s=r.metaTokens,i=r.visitor||y,l=r.dots,u=r.indexes,h=(r.Blob||typeof Blob<"u"&&Blob)&&U.isSpecCompliantForm(e);if(!U.isFunction(i))throw new TypeError("visitor must be a function");function p(E){if(E===null)return"";if(U.isDate(E))return E.toISOString();if(U.isBoolean(E))return E.toString();if(!h&&U.isBlob(E))throw new Re("Blob is not supported. Use a Buffer instead.");return U.isArrayBuffer(E)||U.isTypedArray(E)?h&&typeof Blob=="function"?new Blob([E]):Buffer.from(E):E}function y(E,b,k){let T=E;if(E&&!k&&typeof E=="object"){if(U.endsWith(b,"{}"))b=s?b:b.slice(0,-2),E=JSON.stringify(E);else if(U.isArray(E)&&nk(E)||(U.isFileList(E)||U.endsWith(b,"[]"))&&(T=U.toArray(E)))return b=Pv(b),T.forEach(function(_,A){!(U.isUndefined(_)||_===null)&&e.append(u===!0?Lg([b],A,l):u===null?b:b+"[]",p(_))}),!1}return rf(E)?!0:(e.append(Lg(k,b,l),p(E)),!1)}const v=[],C=Object.assign(rk,{defaultVisitor:y,convertValue:p,isVisitable:rf});function w(E,b){if(!U.isUndefined(E)){if(v.indexOf(E)!==-1)throw Error("Circular reference detected in "+b.join("."));v.push(E),U.forEach(E,function(T,j){(!(U.isUndefined(T)||T===null)&&i.call(e,T,U.isString(j)?j.trim():j,b,C))===!0&&w(T,b?b.concat(j):[j])}),v.pop()}}if(!U.isObject(t))throw new TypeError("data must be an object");return w(t),e}function Ig(t){const e={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(t).replace(/[!'()~]|%20|%00/g,function(s){return e[s]})}function If(t,e){this._pairs=[],t&&hc(t,this,e)}const Tv=If.prototype;Tv.append=function(e,r){this._pairs.push([e,r])};Tv.toString=function(e){const r=e?function(s){return e.call(this,s,Ig)}:Ig;return this._pairs.map(function(i){return r(i[0])+"="+r(i[1])},"").join("&")};function ok(t){return encodeURIComponent(t).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+")}function Ov(t,e,r){if(!e)return t;const s=r&&r.encode||ok;U.isFunction(r)&&(r={serialize:r});const i=r&&r.serialize;let l;if(i?l=i(e,r):l=U.isURLSearchParams(e)?e.toString():new If(e,r).toString(s),l){const u=t.indexOf("#");u!==-1&&(t=t.slice(0,u)),t+=(t.indexOf("?")===-1?"?":"&")+l}return t}class Dg{constructor(){this.handlers=[]}use(e,r,s){return this.handlers.push({fulfilled:e,rejected:r,synchronous:s?s.synchronous:!1,runWhen:s?s.runWhen:null}),this.handlers.length-1}eject(e){this.handlers[e]&&(this.handlers[e]=null)}clear(){this.handlers&&(this.handlers=[])}forEach(e){U.forEach(this.handlers,function(s){s!==null&&e(s)})}}const jv={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},sk=typeof URLSearchParams<"u"?URLSearchParams:If,ik=typeof FormData<"u"?FormData:null,ak=typeof Blob<"u"?Blob:null,lk={isBrowser:!0,classes:{URLSearchParams:sk,FormData:ik,Blob:ak},protocols:["http","https","file","blob","url","data"]},Df=typeof window<"u"&&typeof document<"u",of=typeof navigator=="object"&&navigator||void 0,ck=Df&&(!of||["ReactNative","NativeScript","NS"].indexOf(of.product)<0),uk=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope&&typeof self.importScripts=="function",dk=Df&&window.location.href||"http://localhost",fk=Object.freeze(Object.defineProperty({__proto__:null,hasBrowserEnv:Df,hasStandardBrowserEnv:ck,hasStandardBrowserWebWorkerEnv:uk,navigator:of,origin:dk},Symbol.toStringTag,{value:"Module"})),St={...fk,...lk};function hk(t,e){return hc(t,new St.classes.URLSearchParams,{visitor:function(r,s,i,l){return St.isNode&&U.isBuffer(r)?(this.append(s,r.toString("base64")),!1):l.defaultVisitor.apply(this,arguments)},...e})}function pk(t){return U.matchAll(/\w+|\[(\w*)]/g,t).map(e=>e[0]==="[]"?"":e[1]||e[0])}function mk(t){const e={},r=Object.keys(t);let s;const i=r.length;let l;for(s=0;s=r.length;return u=!u&&U.isArray(i)?i.length:u,h?(U.hasOwnProp(i,u)?i[u]=[i[u],s]:i[u]=s,!d):((!i[u]||!U.isObject(i[u]))&&(i[u]=[]),e(r,s,i[u],l)&&U.isArray(i[u])&&(i[u]=mk(i[u])),!d)}if(U.isFormData(t)&&U.isFunction(t.entries)){const r={};return U.forEachEntry(t,(s,i)=>{e(pk(s),i,r,0)}),r}return null}function gk(t,e,r){if(U.isString(t))try{return(e||JSON.parse)(t),U.trim(t)}catch(s){if(s.name!=="SyntaxError")throw s}return(r||JSON.stringify)(t)}const ia={transitional:jv,adapter:["xhr","http","fetch"],transformRequest:[function(e,r){const s=r.getContentType()||"",i=s.indexOf("application/json")>-1,l=U.isObject(e);if(l&&U.isHTMLForm(e)&&(e=new FormData(e)),U.isFormData(e))return i?JSON.stringify(_v(e)):e;if(U.isArrayBuffer(e)||U.isBuffer(e)||U.isStream(e)||U.isFile(e)||U.isBlob(e)||U.isReadableStream(e))return e;if(U.isArrayBufferView(e))return e.buffer;if(U.isURLSearchParams(e))return r.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),e.toString();let d;if(l){if(s.indexOf("application/x-www-form-urlencoded")>-1)return hk(e,this.formSerializer).toString();if((d=U.isFileList(e))||s.indexOf("multipart/form-data")>-1){const h=this.env&&this.env.FormData;return hc(d?{"files[]":e}:e,h&&new h,this.formSerializer)}}return l||i?(r.setContentType("application/json",!1),gk(e)):e}],transformResponse:[function(e){const r=this.transitional||ia.transitional,s=r&&r.forcedJSONParsing,i=this.responseType==="json";if(U.isResponse(e)||U.isReadableStream(e))return e;if(e&&U.isString(e)&&(s&&!this.responseType||i)){const u=!(r&&r.silentJSONParsing)&&i;try{return JSON.parse(e,this.parseReviver)}catch(d){if(u)throw d.name==="SyntaxError"?Re.from(d,Re.ERR_BAD_RESPONSE,this,null,this.response):d}}return e}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:St.classes.FormData,Blob:St.classes.Blob},validateStatus:function(e){return e>=200&&e<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};U.forEach(["delete","get","head","post","put","patch"],t=>{ia.headers[t]={}});const yk=U.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),vk=t=>{const e={};let r,s,i;return t&&t.split(` `).forEach(function(u){i=u.indexOf(":"),r=u.substring(0,i).trim().toLowerCase(),s=u.substring(i+1).trim(),!(!r||e[r]&&yk[r])&&(r==="set-cookie"?e[r]?e[r].push(s):e[r]=[s]:e[r]=e[r]?e[r]+", "+s:s)}),e},Mg=Symbol("internals");function $i(t){return t&&String(t).trim().toLowerCase()}function Fl(t){return t===!1||t==null?t:U.isArray(t)?t.map(Fl):String(t)}function xk(t){const e=Object.create(null),r=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let s;for(;s=r.exec(t);)e[s[1]]=s[2];return e}const wk=t=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(t.trim());function wd(t,e,r,s,i){if(U.isFunction(s))return s.call(this,e,r);if(i&&(e=r),!!U.isString(e)){if(U.isString(s))return e.indexOf(s)!==-1;if(U.isRegExp(s))return s.test(e)}}function bk(t){return t.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(e,r,s)=>r.toUpperCase()+s)}function Sk(t,e){const r=U.toCamelCase(" "+e);["get","set","has"].forEach(s=>{Object.defineProperty(t,s+r,{value:function(i,l,u){return this[s].call(this,e,i,l,u)},configurable:!0})})}let Kt=class{constructor(e){e&&this.set(e)}set(e,r,s){const i=this;function l(d,h,p){const y=$i(h);if(!y)throw new Error("header name must be a non-empty string");const v=U.findKey(i,y);(!v||i[v]===void 0||p===!0||p===void 0&&i[v]!==!1)&&(i[v||h]=Fl(d))}const u=(d,h)=>U.forEach(d,(p,y)=>l(p,y,h));if(U.isPlainObject(e)||e instanceof this.constructor)u(e,r);else if(U.isString(e)&&(e=e.trim())&&!wk(e))u(vk(e),r);else if(U.isObject(e)&&U.isIterable(e)){let d={},h,p;for(const y of e){if(!U.isArray(y))throw TypeError("Object iterator must return a key-value pair");d[p=y[0]]=(h=d[p])?U.isArray(h)?[...h,y[1]]:[h,y[1]]:y[1]}u(d,r)}else e!=null&&l(r,e,s);return this}get(e,r){if(e=$i(e),e){const s=U.findKey(this,e);if(s){const i=this[s];if(!r)return i;if(r===!0)return xk(i);if(U.isFunction(r))return r.call(this,i,s);if(U.isRegExp(r))return r.exec(i);throw new TypeError("parser must be boolean|regexp|function")}}}has(e,r){if(e=$i(e),e){const s=U.findKey(this,e);return!!(s&&this[s]!==void 0&&(!r||wd(this,this[s],s,r)))}return!1}delete(e,r){const s=this;let i=!1;function l(u){if(u=$i(u),u){const d=U.findKey(s,u);d&&(!r||wd(s,s[d],d,r))&&(delete s[d],i=!0)}}return U.isArray(e)?e.forEach(l):l(e),i}clear(e){const r=Object.keys(this);let s=r.length,i=!1;for(;s--;){const l=r[s];(!e||wd(this,this[l],l,e,!0))&&(delete this[l],i=!0)}return i}normalize(e){const r=this,s={};return U.forEach(this,(i,l)=>{const u=U.findKey(s,l);if(u){r[u]=Fl(i),delete r[l];return}const d=e?bk(l):String(l).trim();d!==l&&delete r[l],r[d]=Fl(i),s[d]=!0}),this}concat(...e){return this.constructor.concat(this,...e)}toJSON(e){const r=Object.create(null);return U.forEach(this,(s,i)=>{s!=null&&s!==!1&&(r[i]=e&&U.isArray(s)?s.join(", "):s)}),r}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([e,r])=>e+": "+r).join(` `)}getSetCookie(){return this.get("set-cookie")||[]}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(e){return e instanceof this?e:new this(e)}static concat(e,...r){const s=new this(e);return r.forEach(i=>s.set(i)),s}static accessor(e){const s=(this[Mg]=this[Mg]={accessors:{}}).accessors,i=this.prototype;function l(u){const d=$i(u);s[d]||(Sk(i,u),s[d]=!0)}return U.isArray(e)?e.forEach(l):l(e),this}};Kt.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);U.reduceDescriptors(Kt.prototype,({value:t},e)=>{let r=e[0].toUpperCase()+e.slice(1);return{get:()=>t,set(s){this[r]=s}}});U.freezeMethods(Kt);function bd(t,e){const r=this||ia,s=e||r,i=Kt.from(s.headers);let l=s.data;return U.forEach(t,function(d){l=d.call(r,l,i.normalize(),e?e.status:void 0)}),i.normalize(),l}function Av(t){return!!(t&&t.__CANCEL__)}function Ys(t,e,r){Re.call(this,t??"canceled",Re.ERR_CANCELED,e,r),this.name="CanceledError"}U.inherits(Ys,Re,{__CANCEL__:!0});function Lv(t,e,r){const s=r.config.validateStatus;!r.status||!s||s(r.status)?t(r):e(new Re("Request failed with status code "+r.status,[Re.ERR_BAD_REQUEST,Re.ERR_BAD_RESPONSE][Math.floor(r.status/100)-4],r.config,r.request,r))}function Ck(t){const e=/^([-+\w]{1,25})(:?\/\/|:)/.exec(t);return e&&e[1]||""}function Ek(t,e){t=t||10;const r=new Array(t),s=new Array(t);let i=0,l=0,u;return e=e!==void 0?e:1e3,function(h){const p=Date.now(),y=s[l];u||(u=p),r[i]=h,s[i]=p;let v=l,C=0;for(;v!==i;)C+=r[v++],v=v%t;if(i=(i+1)%t,i===l&&(l=(l+1)%t),p-u{r=y,i=null,l&&(clearTimeout(l),l=null),t(...p)};return[(...p)=>{const y=Date.now(),v=y-r;v>=s?u(p,y):(i=p,l||(l=setTimeout(()=>{l=null,u(i)},s-v)))},()=>i&&u(i)]}const Gl=(t,e,r=3)=>{let s=0;const i=Ek(50,250);return kk(l=>{const u=l.loaded,d=l.lengthComputable?l.total:void 0,h=u-s,p=i(h),y=u<=d;s=u;const v={loaded:u,total:d,progress:d?u/d:void 0,bytes:h,rate:p||void 0,estimated:p&&d&&y?(d-u)/p:void 0,event:l,lengthComputable:d!=null,[e?"download":"upload"]:!0};t(v)},r)},Fg=(t,e)=>{const r=t!=null;return[s=>e[0]({lengthComputable:r,total:t,loaded:s}),e[1]]},zg=t=>(...e)=>U.asap(()=>t(...e)),Nk=St.hasStandardBrowserEnv?((t,e)=>r=>(r=new URL(r,St.origin),t.protocol===r.protocol&&t.host===r.host&&(e||t.port===r.port)))(new URL(St.origin),St.navigator&&/(msie|trident)/i.test(St.navigator.userAgent)):()=>!0,Rk=St.hasStandardBrowserEnv?{write(t,e,r,s,i,l,u){if(typeof document>"u")return;const d=[`${t}=${encodeURIComponent(e)}`];U.isNumber(r)&&d.push(`expires=${new Date(r).toUTCString()}`),U.isString(s)&&d.push(`path=${s}`),U.isString(i)&&d.push(`domain=${i}`),l===!0&&d.push("secure"),U.isString(u)&&d.push(`SameSite=${u}`),document.cookie=d.join("; ")},read(t){if(typeof document>"u")return null;const e=document.cookie.match(new RegExp("(?:^|; )"+t+"=([^;]*)"));return e?decodeURIComponent(e[1]):null},remove(t){this.write(t,"",Date.now()-864e5,"/")}}:{write(){},read(){return null},remove(){}};function Pk(t){return/^([a-z][a-z\d+\-.]*:)?\/\//i.test(t)}function Tk(t,e){return e?t.replace(/\/?\/$/,"")+"/"+e.replace(/^\/+/,""):t}function Iv(t,e,r){let s=!Pk(e);return t&&(s||r==!1)?Tk(t,e):e}const $g=t=>t instanceof Kt?{...t}:t;function Uo(t,e){e=e||{};const r={};function s(p,y,v,C){return U.isPlainObject(p)&&U.isPlainObject(y)?U.merge.call({caseless:C},p,y):U.isPlainObject(y)?U.merge({},y):U.isArray(y)?y.slice():y}function i(p,y,v,C){if(U.isUndefined(y)){if(!U.isUndefined(p))return s(void 0,p,v,C)}else return s(p,y,v,C)}function l(p,y){if(!U.isUndefined(y))return s(void 0,y)}function u(p,y){if(U.isUndefined(y)){if(!U.isUndefined(p))return s(void 0,p)}else return s(void 0,y)}function d(p,y,v){if(v in e)return s(p,y);if(v in t)return s(void 0,p)}const h={url:l,method:l,data:l,baseURL:u,transformRequest:u,transformResponse:u,paramsSerializer:u,timeout:u,timeoutMessage:u,withCredentials:u,withXSRFToken:u,adapter:u,responseType:u,xsrfCookieName:u,xsrfHeaderName:u,onUploadProgress:u,onDownloadProgress:u,decompress:u,maxContentLength:u,maxBodyLength:u,beforeRedirect:u,transport:u,httpAgent:u,httpsAgent:u,cancelToken:u,socketPath:u,responseEncoding:u,validateStatus:d,headers:(p,y,v)=>i($g(p),$g(y),v,!0)};return U.forEach(Object.keys({...t,...e}),function(y){const v=h[y]||i,C=v(t[y],e[y],y);U.isUndefined(C)&&v!==d||(r[y]=C)}),r}const Dv=t=>{const e=Uo({},t);let{data:r,withXSRFToken:s,xsrfHeaderName:i,xsrfCookieName:l,headers:u,auth:d}=e;if(e.headers=u=Kt.from(u),e.url=Ov(Iv(e.baseURL,e.url,e.allowAbsoluteUrls),t.params,t.paramsSerializer),d&&u.set("Authorization","Basic "+btoa((d.username||"")+":"+(d.password?unescape(encodeURIComponent(d.password)):""))),U.isFormData(r)){if(St.hasStandardBrowserEnv||St.hasStandardBrowserWebWorkerEnv)u.setContentType(void 0);else if(U.isFunction(r.getHeaders)){const h=r.getHeaders(),p=["content-type","content-length"];Object.entries(h).forEach(([y,v])=>{p.includes(y.toLowerCase())&&u.set(y,v)})}}if(St.hasStandardBrowserEnv&&(s&&U.isFunction(s)&&(s=s(e)),s||s!==!1&&Nk(e.url))){const h=i&&l&&Rk.read(l);h&&u.set(i,h)}return e},Ok=typeof XMLHttpRequest<"u",jk=Ok&&function(t){return new Promise(function(r,s){const i=Dv(t);let l=i.data;const u=Kt.from(i.headers).normalize();let{responseType:d,onUploadProgress:h,onDownloadProgress:p}=i,y,v,C,w,E;function b(){w&&w(),E&&E(),i.cancelToken&&i.cancelToken.unsubscribe(y),i.signal&&i.signal.removeEventListener("abort",y)}let k=new XMLHttpRequest;k.open(i.method.toUpperCase(),i.url,!0),k.timeout=i.timeout;function T(){if(!k)return;const _=Kt.from("getAllResponseHeaders"in k&&k.getAllResponseHeaders()),F={data:!d||d==="text"||d==="json"?k.responseText:k.response,status:k.status,statusText:k.statusText,headers:_,config:t,request:k};Lv(function(B){r(B),b()},function(B){s(B),b()},F),k=null}"onloadend"in k?k.onloadend=T:k.onreadystatechange=function(){!k||k.readyState!==4||k.status===0&&!(k.responseURL&&k.responseURL.indexOf("file:")===0)||setTimeout(T)},k.onabort=function(){k&&(s(new Re("Request aborted",Re.ECONNABORTED,t,k)),k=null)},k.onerror=function(A){const F=A&&A.message?A.message:"Network Error",V=new Re(F,Re.ERR_NETWORK,t,k);V.event=A||null,s(V),k=null},k.ontimeout=function(){let A=i.timeout?"timeout of "+i.timeout+"ms exceeded":"timeout exceeded";const F=i.transitional||jv;i.timeoutErrorMessage&&(A=i.timeoutErrorMessage),s(new Re(A,F.clarifyTimeoutError?Re.ETIMEDOUT:Re.ECONNABORTED,t,k)),k=null},l===void 0&&u.setContentType(null),"setRequestHeader"in k&&U.forEach(u.toJSON(),function(A,F){k.setRequestHeader(F,A)}),U.isUndefined(i.withCredentials)||(k.withCredentials=!!i.withCredentials),d&&d!=="json"&&(k.responseType=i.responseType),p&&([C,E]=Gl(p,!0),k.addEventListener("progress",C)),h&&k.upload&&([v,w]=Gl(h),k.upload.addEventListener("progress",v),k.upload.addEventListener("loadend",w)),(i.cancelToken||i.signal)&&(y=_=>{k&&(s(!_||_.type?new Ys(null,t,k):_),k.abort(),k=null)},i.cancelToken&&i.cancelToken.subscribe(y),i.signal&&(i.signal.aborted?y():i.signal.addEventListener("abort",y)));const j=Ck(i.url);if(j&&St.protocols.indexOf(j)===-1){s(new Re("Unsupported protocol "+j+":",Re.ERR_BAD_REQUEST,t));return}k.send(l||null)})},_k=(t,e)=>{const{length:r}=t=t?t.filter(Boolean):[];if(e||r){let s=new AbortController,i;const l=function(p){if(!i){i=!0,d();const y=p instanceof Error?p:this.reason;s.abort(y instanceof Re?y:new Ys(y instanceof Error?y.message:y))}};let u=e&&setTimeout(()=>{u=null,l(new Re(`timeout ${e} of ms exceeded`,Re.ETIMEDOUT))},e);const d=()=>{t&&(u&&clearTimeout(u),u=null,t.forEach(p=>{p.unsubscribe?p.unsubscribe(l):p.removeEventListener("abort",l)}),t=null)};t.forEach(p=>p.addEventListener("abort",l));const{signal:h}=s;return h.unsubscribe=()=>U.asap(d),h}},Ak=function*(t,e){let r=t.byteLength;if(r{const i=Lk(t,e);let l=0,u,d=h=>{u||(u=!0,s&&s(h))};return new ReadableStream({async pull(h){try{const{done:p,value:y}=await i.next();if(p){d(),h.close();return}let v=y.byteLength;if(r){let C=l+=v;r(C)}h.enqueue(new Uint8Array(y))}catch(p){throw d(p),p}},cancel(h){return d(h),i.return()}},{highWaterMark:2})},Bg=64*1024,{isFunction:Pl}=U,Dk=(({Request:t,Response:e})=>({Request:t,Response:e}))(U.global),{ReadableStream:Hg,TextEncoder:Vg}=U.global,Wg=(t,...e)=>{try{return!!t(...e)}catch{return!1}},Mk=t=>{t=U.merge.call({skipUndefined:!0},Dk,t);const{fetch:e,Request:r,Response:s}=t,i=e?Pl(e):typeof fetch=="function",l=Pl(r),u=Pl(s);if(!i)return!1;const d=i&&Pl(Hg),h=i&&(typeof Vg=="function"?(E=>b=>E.encode(b))(new Vg):async E=>new Uint8Array(await new r(E).arrayBuffer())),p=l&&d&&Wg(()=>{let E=!1;const b=new r(St.origin,{body:new Hg,method:"POST",get duplex(){return E=!0,"half"}}).headers.has("Content-Type");return E&&!b}),y=u&&d&&Wg(()=>U.isReadableStream(new s("").body)),v={stream:y&&(E=>E.body)};i&&["text","arrayBuffer","blob","formData","stream"].forEach(E=>{!v[E]&&(v[E]=(b,k)=>{let T=b&&b[E];if(T)return T.call(b);throw new Re(`Response type '${E}' is not supported`,Re.ERR_NOT_SUPPORT,k)})});const C=async E=>{if(E==null)return 0;if(U.isBlob(E))return E.size;if(U.isSpecCompliantForm(E))return(await new r(St.origin,{method:"POST",body:E}).arrayBuffer()).byteLength;if(U.isArrayBufferView(E)||U.isArrayBuffer(E))return E.byteLength;if(U.isURLSearchParams(E)&&(E=E+""),U.isString(E))return(await h(E)).byteLength},w=async(E,b)=>{const k=U.toFiniteNumber(E.getContentLength());return k??C(b)};return async E=>{let{url:b,method:k,data:T,signal:j,cancelToken:_,timeout:A,onDownloadProgress:F,onUploadProgress:V,responseType:B,headers:te,withCredentials:G="same-origin",fetchOptions:W}=Dv(E),le=e||fetch;B=B?(B+"").toLowerCase():"text";let K=_k([j,_&&_.toAbortSignal()],A),Z=null;const J=K&&K.unsubscribe&&(()=>{K.unsubscribe()});let de;try{if(V&&p&&k!=="get"&&k!=="head"&&(de=await w(te,T))!==0){let P=new r(b,{method:"POST",body:T,duplex:"half"}),M;if(U.isFormData(T)&&(M=P.headers.get("content-type"))&&te.setContentType(M),P.body){const[ie,ae]=Fg(de,Gl(zg(V)));T=Ug(P.body,Bg,ie,ae)}}U.isString(G)||(G=G?"include":"omit");const ne=l&&"credentials"in r.prototype,se={...W,signal:K,method:k.toUpperCase(),headers:te.normalize().toJSON(),body:T,duplex:"half",credentials:ne?G:void 0};Z=l&&new r(b,se);let $=await(l?le(Z,W):le(b,se));const H=y&&(B==="stream"||B==="response");if(y&&(F||H&&J)){const P={};["status","statusText","headers"].forEach(me=>{P[me]=$[me]});const M=U.toFiniteNumber($.headers.get("content-length")),[ie,ae]=F&&Fg(M,Gl(zg(F),!0))||[];$=new s(Ug($.body,Bg,ie,()=>{ae&&ae(),J&&J()}),P)}B=B||"text";let Q=await v[U.findKey(v,B)||"text"]($,E);return!H&&J&&J(),await new Promise((P,M)=>{Lv(P,M,{data:Q,headers:Kt.from($.headers),status:$.status,statusText:$.statusText,config:E,request:Z})})}catch(ne){throw J&&J(),ne&&ne.name==="TypeError"&&/Load failed|fetch/i.test(ne.message)?Object.assign(new Re("Network Error",Re.ERR_NETWORK,E,Z),{cause:ne.cause||ne}):Re.from(ne,ne&&ne.code,E,Z)}}},Fk=new Map,Mv=t=>{let e=t&&t.env||{};const{fetch:r,Request:s,Response:i}=e,l=[s,i,r];let u=l.length,d=u,h,p,y=Fk;for(;d--;)h=l[d],p=y.get(h),p===void 0&&y.set(h,p=d?new Map:Mk(e)),y=p;return p};Mv();const Mf={http:tk,xhr:jk,fetch:{get:Mv}};U.forEach(Mf,(t,e)=>{if(t){try{Object.defineProperty(t,"name",{value:e})}catch{}Object.defineProperty(t,"adapterName",{value:e})}});const Kg=t=>`- ${t}`,zk=t=>U.isFunction(t)||t===null||t===!1;function $k(t,e){t=U.isArray(t)?t:[t];const{length:r}=t;let s,i;const l={};for(let u=0;u`adapter ${h} `+(p===!1?"is not supported by the environment":"is not available in the build"));let d=r?u.length>1?`since : `+u.map(Kg).join(` `):" "+Kg(u[0]):"as no adapter specified";throw new Re("There is no suitable adapter to dispatch the request "+d,"ERR_NOT_SUPPORT")}return i}const Fv={getAdapter:$k,adapters:Mf};function Sd(t){if(t.cancelToken&&t.cancelToken.throwIfRequested(),t.signal&&t.signal.aborted)throw new Ys(null,t)}function qg(t){return Sd(t),t.headers=Kt.from(t.headers),t.data=bd.call(t,t.transformRequest),["post","put","patch"].indexOf(t.method)!==-1&&t.headers.setContentType("application/x-www-form-urlencoded",!1),Fv.getAdapter(t.adapter||ia.adapter,t)(t).then(function(s){return Sd(t),s.data=bd.call(t,t.transformResponse,s),s.headers=Kt.from(s.headers),s},function(s){return Av(s)||(Sd(t),s&&s.response&&(s.response.data=bd.call(t,t.transformResponse,s.response),s.response.headers=Kt.from(s.response.headers))),Promise.reject(s)})}const zv="1.13.2",pc={};["object","boolean","number","function","string","symbol"].forEach((t,e)=>{pc[t]=function(s){return typeof s===t||"a"+(e<1?"n ":" ")+t}});const Qg={};pc.transitional=function(e,r,s){function i(l,u){return"[Axios v"+zv+"] Transitional option '"+l+"'"+u+(s?". "+s:"")}return(l,u,d)=>{if(e===!1)throw new Re(i(u," has been removed"+(r?" in "+r:"")),Re.ERR_DEPRECATED);return r&&!Qg[u]&&(Qg[u]=!0,console.warn(i(u," has been deprecated since v"+r+" and will be removed in the near future"))),e?e(l,u,d):!0}};pc.spelling=function(e){return(r,s)=>(console.warn(`${s} is likely a misspelling of ${e}`),!0)};function Uk(t,e,r){if(typeof t!="object")throw new Re("options must be an object",Re.ERR_BAD_OPTION_VALUE);const s=Object.keys(t);let i=s.length;for(;i-- >0;){const l=s[i],u=e[l];if(u){const d=t[l],h=d===void 0||u(d,l,t);if(h!==!0)throw new Re("option "+l+" must be "+h,Re.ERR_BAD_OPTION_VALUE);continue}if(r!==!0)throw new Re("Unknown option "+l,Re.ERR_BAD_OPTION)}}const zl={assertOptions:Uk,validators:pc},Hn=zl.validators;let Mo=class{constructor(e){this.defaults=e||{},this.interceptors={request:new Dg,response:new Dg}}async request(e,r){try{return await this._request(e,r)}catch(s){if(s instanceof Error){let i={};Error.captureStackTrace?Error.captureStackTrace(i):i=new Error;const l=i.stack?i.stack.replace(/^.+\n/,""):"";try{s.stack?l&&!String(s.stack).endsWith(l.replace(/^.+\n.+\n/,""))&&(s.stack+=` `+l):s.stack=l}catch{}}throw s}}_request(e,r){typeof e=="string"?(r=r||{},r.url=e):r=e||{},r=Uo(this.defaults,r);const{transitional:s,paramsSerializer:i,headers:l}=r;s!==void 0&&zl.assertOptions(s,{silentJSONParsing:Hn.transitional(Hn.boolean),forcedJSONParsing:Hn.transitional(Hn.boolean),clarifyTimeoutError:Hn.transitional(Hn.boolean)},!1),i!=null&&(U.isFunction(i)?r.paramsSerializer={serialize:i}:zl.assertOptions(i,{encode:Hn.function,serialize:Hn.function},!0)),r.allowAbsoluteUrls!==void 0||(this.defaults.allowAbsoluteUrls!==void 0?r.allowAbsoluteUrls=this.defaults.allowAbsoluteUrls:r.allowAbsoluteUrls=!0),zl.assertOptions(r,{baseUrl:Hn.spelling("baseURL"),withXsrfToken:Hn.spelling("withXSRFToken")},!0),r.method=(r.method||this.defaults.method||"get").toLowerCase();let u=l&&U.merge(l.common,l[r.method]);l&&U.forEach(["delete","get","head","post","put","patch","common"],E=>{delete l[E]}),r.headers=Kt.concat(u,l);const d=[];let h=!0;this.interceptors.request.forEach(function(b){typeof b.runWhen=="function"&&b.runWhen(r)===!1||(h=h&&b.synchronous,d.unshift(b.fulfilled,b.rejected))});const p=[];this.interceptors.response.forEach(function(b){p.push(b.fulfilled,b.rejected)});let y,v=0,C;if(!h){const E=[qg.bind(this),void 0];for(E.unshift(...d),E.push(...p),C=E.length,y=Promise.resolve(r);v{if(!s._listeners)return;let l=s._listeners.length;for(;l-- >0;)s._listeners[l](i);s._listeners=null}),this.promise.then=i=>{let l;const u=new Promise(d=>{s.subscribe(d),l=d}).then(i);return u.cancel=function(){s.unsubscribe(l)},u},e(function(l,u,d){s.reason||(s.reason=new Ys(l,u,d),r(s.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(e){if(this.reason){e(this.reason);return}this._listeners?this._listeners.push(e):this._listeners=[e]}unsubscribe(e){if(!this._listeners)return;const r=this._listeners.indexOf(e);r!==-1&&this._listeners.splice(r,1)}toAbortSignal(){const e=new AbortController,r=s=>{e.abort(s)};return this.subscribe(r),e.signal.unsubscribe=()=>this.unsubscribe(r),e.signal}static source(){let e;return{token:new $v(function(i){e=i}),cancel:e}}};function Hk(t){return function(r){return t.apply(null,r)}}function Vk(t){return U.isObject(t)&&t.isAxiosError===!0}const sf={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511,WebServerIsDown:521,ConnectionTimedOut:522,OriginIsUnreachable:523,TimeoutOccurred:524,SslHandshakeFailed:525,InvalidSslCertificate:526};Object.entries(sf).forEach(([t,e])=>{sf[e]=t});function Uv(t){const e=new Mo(t),r=vv(Mo.prototype.request,e);return U.extend(r,Mo.prototype,e,{allOwnKeys:!0}),U.extend(r,e,null,{allOwnKeys:!0}),r.create=function(i){return Uv(Uo(t,i))},r}const rt=Uv(ia);rt.Axios=Mo;rt.CanceledError=Ys;rt.CancelToken=Bk;rt.isCancel=Av;rt.VERSION=zv;rt.toFormData=hc;rt.AxiosError=Re;rt.Cancel=rt.CanceledError;rt.all=function(e){return Promise.all(e)};rt.spread=Hk;rt.isAxiosError=Vk;rt.mergeConfig=Uo;rt.AxiosHeaders=Kt;rt.formToJSON=t=>_v(U.isHTMLForm(t)?new FormData(t):t);rt.getAdapter=Fv.getAdapter;rt.HttpStatusCode=sf;rt.default=rt;const{Axios:t_,AxiosError:n_,CanceledError:r_,isCancel:o_,CancelToken:s_,VERSION:i_,all:a_,Cancel:l_,isAxiosError:c_,spread:u_,toFormData:d_,AxiosHeaders:f_,HttpStatusCode:h_,formToJSON:p_,getAdapter:m_,mergeConfig:g_}=rt,Yn=rt.create({baseURL:"/api",timeout:3e4,headers:{"Content-Type":"application/json"}}),Ff={start:t=>Yn.post("/crawler/start",t),stop:()=>Yn.post("/crawler/stop"),getStatus:()=>Yn.get("/crawler/status"),getLogs:(t=100)=>Yn.get("/crawler/logs",{params:{limit:t}})},Xl={getFiles:(t,e)=>Yn.get("/data/files",{params:{platform:t,file_type:e}}),getFileContent:(t,e=100)=>Yn.get("/data/files/"+t,{params:{preview:!0,limit:e}}),getStats:()=>Yn.get("/data/stats"),getDownloadUrl:t=>`/api/data/download/${t}`},Bv={getPlatforms:()=>Yn.get("/config/platforms"),getOptions:()=>Yn.get("/config/options")},Wk={check:()=>Yn.get("/env/check")};function Kk(){const t=jt(r=>r.setStatus),e=jt(r=>r.setRunningInfo);return ta({queryKey:["crawlerStatus"],queryFn:async()=>{const{data:r}=await Ff.getStatus();return t(r.status),e(r.platform,r.crawler_type,r.started_at),r},refetchInterval:2e3})}function qk(){const t=lc(),e=jt(s=>s.setStatus),r=jt(s=>s.clearLogs);return Xy({mutationFn:s=>Ff.start(s),onMutate:()=>{r(),e("running")},onSuccess:()=>{Kl.success("Crawler started successfully"),t.invalidateQueries({queryKey:["crawlerStatus"]})},onError:s=>{e("idle"),Kl.error(`Failed to start crawler: ${s.message}`)}})}function Qk(){const t=lc(),e=jt(r=>r.setStatus);return Xy({mutationFn:()=>Ff.stop(),onMutate:()=>{e("stopping")},onSuccess:()=>{Kl.success("Crawler stopped"),e("idle"),t.invalidateQueries({queryKey:["crawlerStatus"]})},onError:r=>{Kl.error(`Failed to stop crawler: ${r.message}`)}})}function Yk(){return ta({queryKey:["platforms"],queryFn:async()=>{const{data:t}=await Bv.getPlatforms();return t.platforms},staleTime:1/0})}function Gk(){return ta({queryKey:["configOptions"],queryFn:async()=>{const{data:t}=await Bv.getOptions();return t},staleTime:1/0})}function af(t,[e,r]){return Math.min(r,Math.max(e,t))}function Me(t,e,{checkForDefaultPrevented:r=!0}={}){return function(i){if(t==null||t(i),r===!1||!i.defaultPrevented)return e==null?void 0:e(i)}}function Xk(t,e){const r=x.createContext(e),s=l=>{const{children:u,...d}=l,h=x.useMemo(()=>d,Object.values(d));return g.jsx(r.Provider,{value:h,children:u})};s.displayName=t+"Provider";function i(l){const u=x.useContext(r);if(u)return u;if(e!==void 0)return e;throw new Error(`\`${l}\` must be used within \`${t}\``)}return[s,i]}function aa(t,e=[]){let r=[];function s(l,u){const d=x.createContext(u),h=r.length;r=[...r,u];const p=v=>{var T;const{scope:C,children:w,...E}=v,b=((T=C==null?void 0:C[t])==null?void 0:T[h])||d,k=x.useMemo(()=>E,Object.values(E));return g.jsx(b.Provider,{value:k,children:w})};p.displayName=l+"Provider";function y(v,C){var b;const w=((b=C==null?void 0:C[t])==null?void 0:b[h])||d,E=x.useContext(w);if(E)return E;if(u!==void 0)return u;throw new Error(`\`${v}\` must be used within \`${l}\``)}return[p,y]}const i=()=>{const l=r.map(u=>x.createContext(u));return function(d){const h=(d==null?void 0:d[t])||l;return x.useMemo(()=>({[`__scope${t}`]:{...d,[t]:h}}),[d,h])}};return i.scopeName=t,[s,Jk(i,...e)]}function Jk(...t){const e=t[0];if(t.length===1)return e;const r=()=>{const s=t.map(i=>({useScope:i(),scopeName:i.scopeName}));return function(l){const u=s.reduce((d,{useScope:h,scopeName:p})=>{const v=h(l)[`__scope${p}`];return{...d,...v}},{});return x.useMemo(()=>({[`__scope${e.scopeName}`]:u}),[u])}};return r.scopeName=e.scopeName,r}function Yg(t,e){if(typeof t=="function")return t(e);t!=null&&(t.current=e)}function Gs(...t){return e=>{let r=!1;const s=t.map(i=>{const l=Yg(i,e);return!r&&typeof l=="function"&&(r=!0),l});if(r)return()=>{for(let i=0;i{const{children:l,...u}=s,d=x.Children.toArray(l),h=d.find(tN);if(h){const p=h.props.children,y=d.map(v=>v===h?x.Children.count(p)>1?x.Children.only(null):x.isValidElement(p)?p.props.children:null:v);return g.jsx(e,{...u,ref:i,children:x.isValidElement(p)?x.cloneElement(p,void 0,y):null})}return g.jsx(e,{...u,ref:i,children:l})});return r.displayName=`${t}.Slot`,r}function Zk(t){const e=x.forwardRef((r,s)=>{const{children:i,...l}=r;if(x.isValidElement(i)){const u=rN(i),d=nN(l,i.props);return i.type!==x.Fragment&&(d.ref=s?Gs(s,u):u),x.cloneElement(i,d)}return x.Children.count(i)>1?x.Children.only(null):null});return e.displayName=`${t}.SlotClone`,e}var eN=Symbol("radix.slottable");function tN(t){return x.isValidElement(t)&&typeof t.type=="function"&&"__radixId"in t.type&&t.type.__radixId===eN}function nN(t,e){const r={...e};for(const s in e){const i=t[s],l=e[s];/^on[A-Z]/.test(s)?i&&l?r[s]=(...d)=>{const h=l(...d);return i(...d),h}:i&&(r[s]=i):s==="style"?r[s]={...i,...l}:s==="className"&&(r[s]=[i,l].filter(Boolean).join(" "))}return{...t,...r}}function rN(t){var s,i;let e=(s=Object.getOwnPropertyDescriptor(t.props,"ref"))==null?void 0:s.get,r=e&&"isReactWarning"in e&&e.isReactWarning;return r?t.ref:(e=(i=Object.getOwnPropertyDescriptor(t,"ref"))==null?void 0:i.get,r=e&&"isReactWarning"in e&&e.isReactWarning,r?t.props.ref:t.props.ref||t.ref)}function oN(t){const e=t+"CollectionProvider",[r,s]=aa(e),[i,l]=r(e,{collectionRef:{current:null},itemMap:new Map}),u=b=>{const{scope:k,children:T}=b,j=oe.useRef(null),_=oe.useRef(new Map).current;return g.jsx(i,{scope:k,itemMap:_,collectionRef:j,children:T})};u.displayName=e;const d=t+"CollectionSlot",h=Gg(d),p=oe.forwardRef((b,k)=>{const{scope:T,children:j}=b,_=l(d,T),A=Ve(k,_.collectionRef);return g.jsx(h,{ref:A,children:j})});p.displayName=d;const y=t+"CollectionItemSlot",v="data-radix-collection-item",C=Gg(y),w=oe.forwardRef((b,k)=>{const{scope:T,children:j,..._}=b,A=oe.useRef(null),F=Ve(k,A),V=l(y,T);return oe.useEffect(()=>(V.itemMap.set(A,{ref:A,..._}),()=>void V.itemMap.delete(A))),g.jsx(C,{[v]:"",ref:F,children:j})});w.displayName=y;function E(b){const k=l(t+"CollectionConsumer",b);return oe.useCallback(()=>{const j=k.collectionRef.current;if(!j)return[];const _=Array.from(j.querySelectorAll(`[${v}]`));return Array.from(k.itemMap.values()).sort((V,B)=>_.indexOf(V.ref.current)-_.indexOf(B.ref.current))},[k.collectionRef,k.itemMap])}return[{Provider:u,Slot:p,ItemSlot:w},E,s]}var sN=x.createContext(void 0);function Hv(t){const e=x.useContext(sN);return t||e||"ltr"}function iN(t){const e=aN(t),r=x.forwardRef((s,i)=>{const{children:l,...u}=s,d=x.Children.toArray(l),h=d.find(cN);if(h){const p=h.props.children,y=d.map(v=>v===h?x.Children.count(p)>1?x.Children.only(null):x.isValidElement(p)?p.props.children:null:v);return g.jsx(e,{...u,ref:i,children:x.isValidElement(p)?x.cloneElement(p,void 0,y):null})}return g.jsx(e,{...u,ref:i,children:l})});return r.displayName=`${t}.Slot`,r}function aN(t){const e=x.forwardRef((r,s)=>{const{children:i,...l}=r;if(x.isValidElement(i)){const u=dN(i),d=uN(l,i.props);return i.type!==x.Fragment&&(d.ref=s?Gs(s,u):u),x.cloneElement(i,d)}return x.Children.count(i)>1?x.Children.only(null):null});return e.displayName=`${t}.SlotClone`,e}var lN=Symbol("radix.slottable");function cN(t){return x.isValidElement(t)&&typeof t.type=="function"&&"__radixId"in t.type&&t.type.__radixId===lN}function uN(t,e){const r={...e};for(const s in e){const i=t[s],l=e[s];/^on[A-Z]/.test(s)?i&&l?r[s]=(...d)=>{const h=l(...d);return i(...d),h}:i&&(r[s]=i):s==="style"?r[s]={...i,...l}:s==="className"&&(r[s]=[i,l].filter(Boolean).join(" "))}return{...t,...r}}function dN(t){var s,i;let e=(s=Object.getOwnPropertyDescriptor(t.props,"ref"))==null?void 0:s.get,r=e&&"isReactWarning"in e&&e.isReactWarning;return r?t.ref:(e=(i=Object.getOwnPropertyDescriptor(t,"ref"))==null?void 0:i.get,r=e&&"isReactWarning"in e&&e.isReactWarning,r?t.props.ref:t.props.ref||t.ref)}var fN=["a","button","div","form","h2","h3","img","input","label","li","nav","ol","p","select","span","svg","ul"],ze=fN.reduce((t,e)=>{const r=iN(`Primitive.${e}`),s=x.forwardRef((i,l)=>{const{asChild:u,...d}=i,h=u?r:e;return typeof window<"u"&&(window[Symbol.for("radix-ui")]=!0),g.jsx(h,{...d,ref:l})});return s.displayName=`Primitive.${e}`,{...t,[e]:s}},{});function hN(t,e){t&&na.flushSync(()=>t.dispatchEvent(e))}function Vt(t){const e=x.useRef(t);return x.useEffect(()=>{e.current=t}),x.useMemo(()=>(...r)=>{var s;return(s=e.current)==null?void 0:s.call(e,...r)},[])}function pN(t,e=globalThis==null?void 0:globalThis.document){const r=Vt(t);x.useEffect(()=>{const s=i=>{i.key==="Escape"&&r(i)};return e.addEventListener("keydown",s,{capture:!0}),()=>e.removeEventListener("keydown",s,{capture:!0})},[r,e])}var mN="DismissableLayer",lf="dismissableLayer.update",gN="dismissableLayer.pointerDownOutside",yN="dismissableLayer.focusOutside",Xg,Vv=x.createContext({layers:new Set,layersWithOutsidePointerEventsDisabled:new Set,branches:new Set}),zf=x.forwardRef((t,e)=>{const{disableOutsidePointerEvents:r=!1,onEscapeKeyDown:s,onPointerDownOutside:i,onFocusOutside:l,onInteractOutside:u,onDismiss:d,...h}=t,p=x.useContext(Vv),[y,v]=x.useState(null),C=(y==null?void 0:y.ownerDocument)??(globalThis==null?void 0:globalThis.document),[,w]=x.useState({}),E=Ve(e,B=>v(B)),b=Array.from(p.layers),[k]=[...p.layersWithOutsidePointerEventsDisabled].slice(-1),T=b.indexOf(k),j=y?b.indexOf(y):-1,_=p.layersWithOutsidePointerEventsDisabled.size>0,A=j>=T,F=wN(B=>{const te=B.target,G=[...p.branches].some(W=>W.contains(te));!A||G||(i==null||i(B),u==null||u(B),B.defaultPrevented||d==null||d())},C),V=bN(B=>{const te=B.target;[...p.branches].some(W=>W.contains(te))||(l==null||l(B),u==null||u(B),B.defaultPrevented||d==null||d())},C);return pN(B=>{j===p.layers.size-1&&(s==null||s(B),!B.defaultPrevented&&d&&(B.preventDefault(),d()))},C),x.useEffect(()=>{if(y)return r&&(p.layersWithOutsidePointerEventsDisabled.size===0&&(Xg=C.body.style.pointerEvents,C.body.style.pointerEvents="none"),p.layersWithOutsidePointerEventsDisabled.add(y)),p.layers.add(y),Jg(),()=>{r&&p.layersWithOutsidePointerEventsDisabled.size===1&&(C.body.style.pointerEvents=Xg)}},[y,C,r,p]),x.useEffect(()=>()=>{y&&(p.layers.delete(y),p.layersWithOutsidePointerEventsDisabled.delete(y),Jg())},[y,p]),x.useEffect(()=>{const B=()=>w({});return document.addEventListener(lf,B),()=>document.removeEventListener(lf,B)},[]),g.jsx(ze.div,{...h,ref:E,style:{pointerEvents:_?A?"auto":"none":void 0,...t.style},onFocusCapture:Me(t.onFocusCapture,V.onFocusCapture),onBlurCapture:Me(t.onBlurCapture,V.onBlurCapture),onPointerDownCapture:Me(t.onPointerDownCapture,F.onPointerDownCapture)})});zf.displayName=mN;var vN="DismissableLayerBranch",xN=x.forwardRef((t,e)=>{const r=x.useContext(Vv),s=x.useRef(null),i=Ve(e,s);return x.useEffect(()=>{const l=s.current;if(l)return r.branches.add(l),()=>{r.branches.delete(l)}},[r.branches]),g.jsx(ze.div,{...t,ref:i})});xN.displayName=vN;function wN(t,e=globalThis==null?void 0:globalThis.document){const r=Vt(t),s=x.useRef(!1),i=x.useRef(()=>{});return x.useEffect(()=>{const l=d=>{if(d.target&&!s.current){let h=function(){Wv(gN,r,p,{discrete:!0})};const p={originalEvent:d};d.pointerType==="touch"?(e.removeEventListener("click",i.current),i.current=h,e.addEventListener("click",i.current,{once:!0})):h()}else e.removeEventListener("click",i.current);s.current=!1},u=window.setTimeout(()=>{e.addEventListener("pointerdown",l)},0);return()=>{window.clearTimeout(u),e.removeEventListener("pointerdown",l),e.removeEventListener("click",i.current)}},[e,r]),{onPointerDownCapture:()=>s.current=!0}}function bN(t,e=globalThis==null?void 0:globalThis.document){const r=Vt(t),s=x.useRef(!1);return x.useEffect(()=>{const i=l=>{l.target&&!s.current&&Wv(yN,r,{originalEvent:l},{discrete:!1})};return e.addEventListener("focusin",i),()=>e.removeEventListener("focusin",i)},[e,r]),{onFocusCapture:()=>s.current=!0,onBlurCapture:()=>s.current=!1}}function Jg(){const t=new CustomEvent(lf);document.dispatchEvent(t)}function Wv(t,e,r,{discrete:s}){const i=r.originalEvent.target,l=new CustomEvent(t,{bubbles:!1,cancelable:!0,detail:r});e&&i.addEventListener(t,e,{once:!0}),s?hN(i,l):i.dispatchEvent(l)}var Cd=0;function Kv(){x.useEffect(()=>{const t=document.querySelectorAll("[data-radix-focus-guard]");return document.body.insertAdjacentElement("afterbegin",t[0]??Zg()),document.body.insertAdjacentElement("beforeend",t[1]??Zg()),Cd++,()=>{Cd===1&&document.querySelectorAll("[data-radix-focus-guard]").forEach(e=>e.remove()),Cd--}},[])}function Zg(){const t=document.createElement("span");return t.setAttribute("data-radix-focus-guard",""),t.tabIndex=0,t.style.outline="none",t.style.opacity="0",t.style.position="fixed",t.style.pointerEvents="none",t}var Ed="focusScope.autoFocusOnMount",kd="focusScope.autoFocusOnUnmount",ey={bubbles:!1,cancelable:!0},SN="FocusScope",$f=x.forwardRef((t,e)=>{const{loop:r=!1,trapped:s=!1,onMountAutoFocus:i,onUnmountAutoFocus:l,...u}=t,[d,h]=x.useState(null),p=Vt(i),y=Vt(l),v=x.useRef(null),C=Ve(e,b=>h(b)),w=x.useRef({paused:!1,pause(){this.paused=!0},resume(){this.paused=!1}}).current;x.useEffect(()=>{if(s){let b=function(_){if(w.paused||!d)return;const A=_.target;d.contains(A)?v.current=A:Wr(v.current,{select:!0})},k=function(_){if(w.paused||!d)return;const A=_.relatedTarget;A!==null&&(d.contains(A)||Wr(v.current,{select:!0}))},T=function(_){if(document.activeElement===document.body)for(const F of _)F.removedNodes.length>0&&Wr(d)};document.addEventListener("focusin",b),document.addEventListener("focusout",k);const j=new MutationObserver(T);return d&&j.observe(d,{childList:!0,subtree:!0}),()=>{document.removeEventListener("focusin",b),document.removeEventListener("focusout",k),j.disconnect()}}},[s,d,w.paused]),x.useEffect(()=>{if(d){ny.add(w);const b=document.activeElement;if(!d.contains(b)){const T=new CustomEvent(Ed,ey);d.addEventListener(Ed,p),d.dispatchEvent(T),T.defaultPrevented||(CN(PN(qv(d)),{select:!0}),document.activeElement===b&&Wr(d))}return()=>{d.removeEventListener(Ed,p),setTimeout(()=>{const T=new CustomEvent(kd,ey);d.addEventListener(kd,y),d.dispatchEvent(T),T.defaultPrevented||Wr(b??document.body,{select:!0}),d.removeEventListener(kd,y),ny.remove(w)},0)}}},[d,p,y,w]);const E=x.useCallback(b=>{if(!r&&!s||w.paused)return;const k=b.key==="Tab"&&!b.altKey&&!b.ctrlKey&&!b.metaKey,T=document.activeElement;if(k&&T){const j=b.currentTarget,[_,A]=EN(j);_&&A?!b.shiftKey&&T===A?(b.preventDefault(),r&&Wr(_,{select:!0})):b.shiftKey&&T===_&&(b.preventDefault(),r&&Wr(A,{select:!0})):T===j&&b.preventDefault()}},[r,s,w.paused]);return g.jsx(ze.div,{tabIndex:-1,...u,ref:C,onKeyDown:E})});$f.displayName=SN;function CN(t,{select:e=!1}={}){const r=document.activeElement;for(const s of t)if(Wr(s,{select:e}),document.activeElement!==r)return}function EN(t){const e=qv(t),r=ty(e,t),s=ty(e.reverse(),t);return[r,s]}function qv(t){const e=[],r=document.createTreeWalker(t,NodeFilter.SHOW_ELEMENT,{acceptNode:s=>{const i=s.tagName==="INPUT"&&s.type==="hidden";return s.disabled||s.hidden||i?NodeFilter.FILTER_SKIP:s.tabIndex>=0?NodeFilter.FILTER_ACCEPT:NodeFilter.FILTER_SKIP}});for(;r.nextNode();)e.push(r.currentNode);return e}function ty(t,e){for(const r of t)if(!kN(r,{upTo:e}))return r}function kN(t,{upTo:e}){if(getComputedStyle(t).visibility==="hidden")return!0;for(;t;){if(e!==void 0&&t===e)return!1;if(getComputedStyle(t).display==="none")return!0;t=t.parentElement}return!1}function NN(t){return t instanceof HTMLInputElement&&"select"in t}function Wr(t,{select:e=!1}={}){if(t&&t.focus){const r=document.activeElement;t.focus({preventScroll:!0}),t!==r&&NN(t)&&e&&t.select()}}var ny=RN();function RN(){let t=[];return{add(e){const r=t[0];e!==r&&(r==null||r.pause()),t=ry(t,e),t.unshift(e)},remove(e){var r;t=ry(t,e),(r=t[0])==null||r.resume()}}}function ry(t,e){const r=[...t],s=r.indexOf(e);return s!==-1&&r.splice(s,1),r}function PN(t){return t.filter(e=>e.tagName!=="A")}var mt=globalThis!=null&&globalThis.document?x.useLayoutEffect:()=>{},TN=Sf[" useId ".trim().toString()]||(()=>{}),ON=0;function Ps(t){const[e,r]=x.useState(TN());return mt(()=>{r(s=>s??String(ON++))},[t]),e?`radix-${e}`:""}const jN=["top","right","bottom","left"],no=Math.min,Xt=Math.max,Jl=Math.round,Tl=Math.floor,Xn=t=>({x:t,y:t}),_N={left:"right",right:"left",bottom:"top",top:"bottom"},AN={start:"end",end:"start"};function cf(t,e,r){return Xt(t,no(e,r))}function vr(t,e){return typeof t=="function"?t(e):t}function xr(t){return t.split("-")[0]}function Xs(t){return t.split("-")[1]}function Uf(t){return t==="x"?"y":"x"}function Bf(t){return t==="y"?"height":"width"}const LN=new Set(["top","bottom"]);function Gn(t){return LN.has(xr(t))?"y":"x"}function Hf(t){return Uf(Gn(t))}function IN(t,e,r){r===void 0&&(r=!1);const s=Xs(t),i=Hf(t),l=Bf(i);let u=i==="x"?s===(r?"end":"start")?"right":"left":s==="start"?"bottom":"top";return e.reference[l]>e.floating[l]&&(u=Zl(u)),[u,Zl(u)]}function DN(t){const e=Zl(t);return[uf(t),e,uf(e)]}function uf(t){return t.replace(/start|end/g,e=>AN[e])}const oy=["left","right"],sy=["right","left"],MN=["top","bottom"],FN=["bottom","top"];function zN(t,e,r){switch(t){case"top":case"bottom":return r?e?sy:oy:e?oy:sy;case"left":case"right":return e?MN:FN;default:return[]}}function $N(t,e,r,s){const i=Xs(t);let l=zN(xr(t),r==="start",s);return i&&(l=l.map(u=>u+"-"+i),e&&(l=l.concat(l.map(uf)))),l}function Zl(t){return t.replace(/left|right|bottom|top/g,e=>_N[e])}function UN(t){return{top:0,right:0,bottom:0,left:0,...t}}function Qv(t){return typeof t!="number"?UN(t):{top:t,right:t,bottom:t,left:t}}function ec(t){const{x:e,y:r,width:s,height:i}=t;return{width:s,height:i,top:r,left:e,right:e+s,bottom:r+i,x:e,y:r}}function iy(t,e,r){let{reference:s,floating:i}=t;const l=Gn(e),u=Hf(e),d=Bf(u),h=xr(e),p=l==="y",y=s.x+s.width/2-i.width/2,v=s.y+s.height/2-i.height/2,C=s[d]/2-i[d]/2;let w;switch(h){case"top":w={x:y,y:s.y-i.height};break;case"bottom":w={x:y,y:s.y+s.height};break;case"right":w={x:s.x+s.width,y:v};break;case"left":w={x:s.x-i.width,y:v};break;default:w={x:s.x,y:s.y}}switch(Xs(e)){case"start":w[u]-=C*(r&&p?-1:1);break;case"end":w[u]+=C*(r&&p?-1:1);break}return w}const BN=async(t,e,r)=>{const{placement:s="bottom",strategy:i="absolute",middleware:l=[],platform:u}=r,d=l.filter(Boolean),h=await(u.isRTL==null?void 0:u.isRTL(e));let p=await u.getElementRects({reference:t,floating:e,strategy:i}),{x:y,y:v}=iy(p,s,h),C=s,w={},E=0;for(let b=0;b({name:"arrow",options:t,async fn(e){const{x:r,y:s,placement:i,rects:l,platform:u,elements:d,middlewareData:h}=e,{element:p,padding:y=0}=vr(t,e)||{};if(p==null)return{};const v=Qv(y),C={x:r,y:s},w=Hf(i),E=Bf(w),b=await u.getDimensions(p),k=w==="y",T=k?"top":"left",j=k?"bottom":"right",_=k?"clientHeight":"clientWidth",A=l.reference[E]+l.reference[w]-C[w]-l.floating[E],F=C[w]-l.reference[w],V=await(u.getOffsetParent==null?void 0:u.getOffsetParent(p));let B=V?V[_]:0;(!B||!await(u.isElement==null?void 0:u.isElement(V)))&&(B=d.floating[_]||l.floating[E]);const te=A/2-F/2,G=B/2-b[E]/2-1,W=no(v[T],G),le=no(v[j],G),K=W,Z=B-b[E]-le,J=B/2-b[E]/2+te,de=cf(K,J,Z),ne=!h.arrow&&Xs(i)!=null&&J!==de&&l.reference[E]/2-(JJ<=0)){var le,K;const J=(((le=l.flip)==null?void 0:le.index)||0)+1,de=B[J];if(de&&(!(v==="alignment"?j!==Gn(de):!1)||W.every($=>Gn($.placement)===j?$.overflows[0]>0:!0)))return{data:{index:J,overflows:W},reset:{placement:de}};let ne=(K=W.filter(se=>se.overflows[0]<=0).sort((se,$)=>se.overflows[1]-$.overflows[1])[0])==null?void 0:K.placement;if(!ne)switch(w){case"bestFit":{var Z;const se=(Z=W.filter($=>{if(V){const H=Gn($.placement);return H===j||H==="y"}return!0}).map($=>[$.placement,$.overflows.filter(H=>H>0).reduce((H,Q)=>H+Q,0)]).sort(($,H)=>$[1]-H[1])[0])==null?void 0:Z[0];se&&(ne=se);break}case"initialPlacement":ne=d;break}if(i!==ne)return{reset:{placement:ne}}}return{}}}};function ay(t,e){return{top:t.top-e.height,right:t.right-e.width,bottom:t.bottom-e.height,left:t.left-e.width}}function ly(t){return jN.some(e=>t[e]>=0)}const WN=function(t){return t===void 0&&(t={}),{name:"hide",options:t,async fn(e){const{rects:r}=e,{strategy:s="referenceHidden",...i}=vr(t,e);switch(s){case"referenceHidden":{const l=await Qi(e,{...i,elementContext:"reference"}),u=ay(l,r.reference);return{data:{referenceHiddenOffsets:u,referenceHidden:ly(u)}}}case"escaped":{const l=await Qi(e,{...i,altBoundary:!0}),u=ay(l,r.floating);return{data:{escapedOffsets:u,escaped:ly(u)}}}default:return{}}}}},Yv=new Set(["left","top"]);async function KN(t,e){const{placement:r,platform:s,elements:i}=t,l=await(s.isRTL==null?void 0:s.isRTL(i.floating)),u=xr(r),d=Xs(r),h=Gn(r)==="y",p=Yv.has(u)?-1:1,y=l&&h?-1:1,v=vr(e,t);let{mainAxis:C,crossAxis:w,alignmentAxis:E}=typeof v=="number"?{mainAxis:v,crossAxis:0,alignmentAxis:null}:{mainAxis:v.mainAxis||0,crossAxis:v.crossAxis||0,alignmentAxis:v.alignmentAxis};return d&&typeof E=="number"&&(w=d==="end"?E*-1:E),h?{x:w*y,y:C*p}:{x:C*p,y:w*y}}const qN=function(t){return t===void 0&&(t=0),{name:"offset",options:t,async fn(e){var r,s;const{x:i,y:l,placement:u,middlewareData:d}=e,h=await KN(e,t);return u===((r=d.offset)==null?void 0:r.placement)&&(s=d.arrow)!=null&&s.alignmentOffset?{}:{x:i+h.x,y:l+h.y,data:{...h,placement:u}}}}},QN=function(t){return t===void 0&&(t={}),{name:"shift",options:t,async fn(e){const{x:r,y:s,placement:i}=e,{mainAxis:l=!0,crossAxis:u=!1,limiter:d={fn:k=>{let{x:T,y:j}=k;return{x:T,y:j}}},...h}=vr(t,e),p={x:r,y:s},y=await Qi(e,h),v=Gn(xr(i)),C=Uf(v);let w=p[C],E=p[v];if(l){const k=C==="y"?"top":"left",T=C==="y"?"bottom":"right",j=w+y[k],_=w-y[T];w=cf(j,w,_)}if(u){const k=v==="y"?"top":"left",T=v==="y"?"bottom":"right",j=E+y[k],_=E-y[T];E=cf(j,E,_)}const b=d.fn({...e,[C]:w,[v]:E});return{...b,data:{x:b.x-r,y:b.y-s,enabled:{[C]:l,[v]:u}}}}}},YN=function(t){return t===void 0&&(t={}),{options:t,fn(e){const{x:r,y:s,placement:i,rects:l,middlewareData:u}=e,{offset:d=0,mainAxis:h=!0,crossAxis:p=!0}=vr(t,e),y={x:r,y:s},v=Gn(i),C=Uf(v);let w=y[C],E=y[v];const b=vr(d,e),k=typeof b=="number"?{mainAxis:b,crossAxis:0}:{mainAxis:0,crossAxis:0,...b};if(h){const _=C==="y"?"height":"width",A=l.reference[C]-l.floating[_]+k.mainAxis,F=l.reference[C]+l.reference[_]-k.mainAxis;wF&&(w=F)}if(p){var T,j;const _=C==="y"?"width":"height",A=Yv.has(xr(i)),F=l.reference[v]-l.floating[_]+(A&&((T=u.offset)==null?void 0:T[v])||0)+(A?0:k.crossAxis),V=l.reference[v]+l.reference[_]+(A?0:((j=u.offset)==null?void 0:j[v])||0)-(A?k.crossAxis:0);EV&&(E=V)}return{[C]:w,[v]:E}}}},GN=function(t){return t===void 0&&(t={}),{name:"size",options:t,async fn(e){var r,s;const{placement:i,rects:l,platform:u,elements:d}=e,{apply:h=()=>{},...p}=vr(t,e),y=await Qi(e,p),v=xr(i),C=Xs(i),w=Gn(i)==="y",{width:E,height:b}=l.floating;let k,T;v==="top"||v==="bottom"?(k=v,T=C===(await(u.isRTL==null?void 0:u.isRTL(d.floating))?"start":"end")?"left":"right"):(T=v,k=C==="end"?"top":"bottom");const j=b-y.top-y.bottom,_=E-y.left-y.right,A=no(b-y[k],j),F=no(E-y[T],_),V=!e.middlewareData.shift;let B=A,te=F;if((r=e.middlewareData.shift)!=null&&r.enabled.x&&(te=_),(s=e.middlewareData.shift)!=null&&s.enabled.y&&(B=j),V&&!C){const W=Xt(y.left,0),le=Xt(y.right,0),K=Xt(y.top,0),Z=Xt(y.bottom,0);w?te=E-2*(W!==0||le!==0?W+le:Xt(y.left,y.right)):B=b-2*(K!==0||Z!==0?K+Z:Xt(y.top,y.bottom))}await h({...e,availableWidth:te,availableHeight:B});const G=await u.getDimensions(d.floating);return E!==G.width||b!==G.height?{reset:{rects:!0}}:{}}}};function mc(){return typeof window<"u"}function Js(t){return Gv(t)?(t.nodeName||"").toLowerCase():"#document"}function Jt(t){var e;return(t==null||(e=t.ownerDocument)==null?void 0:e.defaultView)||window}function Zn(t){var e;return(e=(Gv(t)?t.ownerDocument:t.document)||window.document)==null?void 0:e.documentElement}function Gv(t){return mc()?t instanceof Node||t instanceof Jt(t).Node:!1}function Nn(t){return mc()?t instanceof Element||t instanceof Jt(t).Element:!1}function Jn(t){return mc()?t instanceof HTMLElement||t instanceof Jt(t).HTMLElement:!1}function cy(t){return!mc()||typeof ShadowRoot>"u"?!1:t instanceof ShadowRoot||t instanceof Jt(t).ShadowRoot}const XN=new Set(["inline","contents"]);function la(t){const{overflow:e,overflowX:r,overflowY:s,display:i}=Rn(t);return/auto|scroll|overlay|hidden|clip/.test(e+s+r)&&!XN.has(i)}const JN=new Set(["table","td","th"]);function ZN(t){return JN.has(Js(t))}const e2=[":popover-open",":modal"];function gc(t){return e2.some(e=>{try{return t.matches(e)}catch{return!1}})}const t2=["transform","translate","scale","rotate","perspective"],n2=["transform","translate","scale","rotate","perspective","filter"],r2=["paint","layout","strict","content"];function Vf(t){const e=Wf(),r=Nn(t)?Rn(t):t;return t2.some(s=>r[s]?r[s]!=="none":!1)||(r.containerType?r.containerType!=="normal":!1)||!e&&(r.backdropFilter?r.backdropFilter!=="none":!1)||!e&&(r.filter?r.filter!=="none":!1)||n2.some(s=>(r.willChange||"").includes(s))||r2.some(s=>(r.contain||"").includes(s))}function o2(t){let e=ro(t);for(;Jn(e)&&!Vs(e);){if(Vf(e))return e;if(gc(e))return null;e=ro(e)}return null}function Wf(){return typeof CSS>"u"||!CSS.supports?!1:CSS.supports("-webkit-backdrop-filter","none")}const s2=new Set(["html","body","#document"]);function Vs(t){return s2.has(Js(t))}function Rn(t){return Jt(t).getComputedStyle(t)}function yc(t){return Nn(t)?{scrollLeft:t.scrollLeft,scrollTop:t.scrollTop}:{scrollLeft:t.scrollX,scrollTop:t.scrollY}}function ro(t){if(Js(t)==="html")return t;const e=t.assignedSlot||t.parentNode||cy(t)&&t.host||Zn(t);return cy(e)?e.host:e}function Xv(t){const e=ro(t);return Vs(e)?t.ownerDocument?t.ownerDocument.body:t.body:Jn(e)&&la(e)?e:Xv(e)}function Yi(t,e,r){var s;e===void 0&&(e=[]),r===void 0&&(r=!0);const i=Xv(t),l=i===((s=t.ownerDocument)==null?void 0:s.body),u=Jt(i);if(l){const d=df(u);return e.concat(u,u.visualViewport||[],la(i)?i:[],d&&r?Yi(d):[])}return e.concat(i,Yi(i,[],r))}function df(t){return t.parent&&Object.getPrototypeOf(t.parent)?t.frameElement:null}function Jv(t){const e=Rn(t);let r=parseFloat(e.width)||0,s=parseFloat(e.height)||0;const i=Jn(t),l=i?t.offsetWidth:r,u=i?t.offsetHeight:s,d=Jl(r)!==l||Jl(s)!==u;return d&&(r=l,s=u),{width:r,height:s,$:d}}function Kf(t){return Nn(t)?t:t.contextElement}function Ts(t){const e=Kf(t);if(!Jn(e))return Xn(1);const r=e.getBoundingClientRect(),{width:s,height:i,$:l}=Jv(e);let u=(l?Jl(r.width):r.width)/s,d=(l?Jl(r.height):r.height)/i;return(!u||!Number.isFinite(u))&&(u=1),(!d||!Number.isFinite(d))&&(d=1),{x:u,y:d}}const i2=Xn(0);function Zv(t){const e=Jt(t);return!Wf()||!e.visualViewport?i2:{x:e.visualViewport.offsetLeft,y:e.visualViewport.offsetTop}}function a2(t,e,r){return e===void 0&&(e=!1),!r||e&&r!==Jt(t)?!1:e}function Bo(t,e,r,s){e===void 0&&(e=!1),r===void 0&&(r=!1);const i=t.getBoundingClientRect(),l=Kf(t);let u=Xn(1);e&&(s?Nn(s)&&(u=Ts(s)):u=Ts(t));const d=a2(l,r,s)?Zv(l):Xn(0);let h=(i.left+d.x)/u.x,p=(i.top+d.y)/u.y,y=i.width/u.x,v=i.height/u.y;if(l){const C=Jt(l),w=s&&Nn(s)?Jt(s):s;let E=C,b=df(E);for(;b&&s&&w!==E;){const k=Ts(b),T=b.getBoundingClientRect(),j=Rn(b),_=T.left+(b.clientLeft+parseFloat(j.paddingLeft))*k.x,A=T.top+(b.clientTop+parseFloat(j.paddingTop))*k.y;h*=k.x,p*=k.y,y*=k.x,v*=k.y,h+=_,p+=A,E=Jt(b),b=df(E)}}return ec({width:y,height:v,x:h,y:p})}function vc(t,e){const r=yc(t).scrollLeft;return e?e.left+r:Bo(Zn(t)).left+r}function ex(t,e){const r=t.getBoundingClientRect(),s=r.left+e.scrollLeft-vc(t,r),i=r.top+e.scrollTop;return{x:s,y:i}}function l2(t){let{elements:e,rect:r,offsetParent:s,strategy:i}=t;const l=i==="fixed",u=Zn(s),d=e?gc(e.floating):!1;if(s===u||d&&l)return r;let h={scrollLeft:0,scrollTop:0},p=Xn(1);const y=Xn(0),v=Jn(s);if((v||!v&&!l)&&((Js(s)!=="body"||la(u))&&(h=yc(s)),Jn(s))){const w=Bo(s);p=Ts(s),y.x=w.x+s.clientLeft,y.y=w.y+s.clientTop}const C=u&&!v&&!l?ex(u,h):Xn(0);return{width:r.width*p.x,height:r.height*p.y,x:r.x*p.x-h.scrollLeft*p.x+y.x+C.x,y:r.y*p.y-h.scrollTop*p.y+y.y+C.y}}function c2(t){return Array.from(t.getClientRects())}function u2(t){const e=Zn(t),r=yc(t),s=t.ownerDocument.body,i=Xt(e.scrollWidth,e.clientWidth,s.scrollWidth,s.clientWidth),l=Xt(e.scrollHeight,e.clientHeight,s.scrollHeight,s.clientHeight);let u=-r.scrollLeft+vc(t);const d=-r.scrollTop;return Rn(s).direction==="rtl"&&(u+=Xt(e.clientWidth,s.clientWidth)-i),{width:i,height:l,x:u,y:d}}const uy=25;function d2(t,e){const r=Jt(t),s=Zn(t),i=r.visualViewport;let l=s.clientWidth,u=s.clientHeight,d=0,h=0;if(i){l=i.width,u=i.height;const y=Wf();(!y||y&&e==="fixed")&&(d=i.offsetLeft,h=i.offsetTop)}const p=vc(s);if(p<=0){const y=s.ownerDocument,v=y.body,C=getComputedStyle(v),w=y.compatMode==="CSS1Compat"&&parseFloat(C.marginLeft)+parseFloat(C.marginRight)||0,E=Math.abs(s.clientWidth-v.clientWidth-w);E<=uy&&(l-=E)}else p<=uy&&(l+=p);return{width:l,height:u,x:d,y:h}}const f2=new Set(["absolute","fixed"]);function h2(t,e){const r=Bo(t,!0,e==="fixed"),s=r.top+t.clientTop,i=r.left+t.clientLeft,l=Jn(t)?Ts(t):Xn(1),u=t.clientWidth*l.x,d=t.clientHeight*l.y,h=i*l.x,p=s*l.y;return{width:u,height:d,x:h,y:p}}function dy(t,e,r){let s;if(e==="viewport")s=d2(t,r);else if(e==="document")s=u2(Zn(t));else if(Nn(e))s=h2(e,r);else{const i=Zv(t);s={x:e.x-i.x,y:e.y-i.y,width:e.width,height:e.height}}return ec(s)}function tx(t,e){const r=ro(t);return r===e||!Nn(r)||Vs(r)?!1:Rn(r).position==="fixed"||tx(r,e)}function p2(t,e){const r=e.get(t);if(r)return r;let s=Yi(t,[],!1).filter(d=>Nn(d)&&Js(d)!=="body"),i=null;const l=Rn(t).position==="fixed";let u=l?ro(t):t;for(;Nn(u)&&!Vs(u);){const d=Rn(u),h=Vf(u);!h&&d.position==="fixed"&&(i=null),(l?!h&&!i:!h&&d.position==="static"&&!!i&&f2.has(i.position)||la(u)&&!h&&tx(t,u))?s=s.filter(y=>y!==u):i=d,u=ro(u)}return e.set(t,s),s}function m2(t){let{element:e,boundary:r,rootBoundary:s,strategy:i}=t;const u=[...r==="clippingAncestors"?gc(e)?[]:p2(e,this._c):[].concat(r),s],d=u[0],h=u.reduce((p,y)=>{const v=dy(e,y,i);return p.top=Xt(v.top,p.top),p.right=no(v.right,p.right),p.bottom=no(v.bottom,p.bottom),p.left=Xt(v.left,p.left),p},dy(e,d,i));return{width:h.right-h.left,height:h.bottom-h.top,x:h.left,y:h.top}}function g2(t){const{width:e,height:r}=Jv(t);return{width:e,height:r}}function y2(t,e,r){const s=Jn(e),i=Zn(e),l=r==="fixed",u=Bo(t,!0,l,e);let d={scrollLeft:0,scrollTop:0};const h=Xn(0);function p(){h.x=vc(i)}if(s||!s&&!l)if((Js(e)!=="body"||la(i))&&(d=yc(e)),s){const w=Bo(e,!0,l,e);h.x=w.x+e.clientLeft,h.y=w.y+e.clientTop}else i&&p();l&&!s&&i&&p();const y=i&&!s&&!l?ex(i,d):Xn(0),v=u.left+d.scrollLeft-h.x-y.x,C=u.top+d.scrollTop-h.y-y.y;return{x:v,y:C,width:u.width,height:u.height}}function Nd(t){return Rn(t).position==="static"}function fy(t,e){if(!Jn(t)||Rn(t).position==="fixed")return null;if(e)return e(t);let r=t.offsetParent;return Zn(t)===r&&(r=r.ownerDocument.body),r}function nx(t,e){const r=Jt(t);if(gc(t))return r;if(!Jn(t)){let i=ro(t);for(;i&&!Vs(i);){if(Nn(i)&&!Nd(i))return i;i=ro(i)}return r}let s=fy(t,e);for(;s&&ZN(s)&&Nd(s);)s=fy(s,e);return s&&Vs(s)&&Nd(s)&&!Vf(s)?r:s||o2(t)||r}const v2=async function(t){const e=this.getOffsetParent||nx,r=this.getDimensions,s=await r(t.floating);return{reference:y2(t.reference,await e(t.floating),t.strategy),floating:{x:0,y:0,width:s.width,height:s.height}}};function x2(t){return Rn(t).direction==="rtl"}const w2={convertOffsetParentRelativeRectToViewportRelativeRect:l2,getDocumentElement:Zn,getClippingRect:m2,getOffsetParent:nx,getElementRects:v2,getClientRects:c2,getDimensions:g2,getScale:Ts,isElement:Nn,isRTL:x2};function rx(t,e){return t.x===e.x&&t.y===e.y&&t.width===e.width&&t.height===e.height}function b2(t,e){let r=null,s;const i=Zn(t);function l(){var d;clearTimeout(s),(d=r)==null||d.disconnect(),r=null}function u(d,h){d===void 0&&(d=!1),h===void 0&&(h=1),l();const p=t.getBoundingClientRect(),{left:y,top:v,width:C,height:w}=p;if(d||e(),!C||!w)return;const E=Tl(v),b=Tl(i.clientWidth-(y+C)),k=Tl(i.clientHeight-(v+w)),T=Tl(y),_={rootMargin:-E+"px "+-b+"px "+-k+"px "+-T+"px",threshold:Xt(0,no(1,h))||1};let A=!0;function F(V){const B=V[0].intersectionRatio;if(B!==h){if(!A)return u();B?u(!1,B):s=setTimeout(()=>{u(!1,1e-7)},1e3)}B===1&&!rx(p,t.getBoundingClientRect())&&u(),A=!1}try{r=new IntersectionObserver(F,{..._,root:i.ownerDocument})}catch{r=new IntersectionObserver(F,_)}r.observe(t)}return u(!0),l}function S2(t,e,r,s){s===void 0&&(s={});const{ancestorScroll:i=!0,ancestorResize:l=!0,elementResize:u=typeof ResizeObserver=="function",layoutShift:d=typeof IntersectionObserver=="function",animationFrame:h=!1}=s,p=Kf(t),y=i||l?[...p?Yi(p):[],...Yi(e)]:[];y.forEach(T=>{i&&T.addEventListener("scroll",r,{passive:!0}),l&&T.addEventListener("resize",r)});const v=p&&d?b2(p,r):null;let C=-1,w=null;u&&(w=new ResizeObserver(T=>{let[j]=T;j&&j.target===p&&w&&(w.unobserve(e),cancelAnimationFrame(C),C=requestAnimationFrame(()=>{var _;(_=w)==null||_.observe(e)})),r()}),p&&!h&&w.observe(p),w.observe(e));let E,b=h?Bo(t):null;h&&k();function k(){const T=Bo(t);b&&!rx(b,T)&&r(),b=T,E=requestAnimationFrame(k)}return r(),()=>{var T;y.forEach(j=>{i&&j.removeEventListener("scroll",r),l&&j.removeEventListener("resize",r)}),v==null||v(),(T=w)==null||T.disconnect(),w=null,h&&cancelAnimationFrame(E)}}const C2=qN,E2=QN,k2=VN,N2=GN,R2=WN,hy=HN,P2=YN,T2=(t,e,r)=>{const s=new Map,i={platform:w2,...r},l={...i.platform,_c:s};return BN(t,e,{...i,platform:l})};var O2=typeof document<"u",j2=function(){},$l=O2?x.useLayoutEffect:j2;function tc(t,e){if(t===e)return!0;if(typeof t!=typeof e)return!1;if(typeof t=="function"&&t.toString()===e.toString())return!0;let r,s,i;if(t&&e&&typeof t=="object"){if(Array.isArray(t)){if(r=t.length,r!==e.length)return!1;for(s=r;s--!==0;)if(!tc(t[s],e[s]))return!1;return!0}if(i=Object.keys(t),r=i.length,r!==Object.keys(e).length)return!1;for(s=r;s--!==0;)if(!{}.hasOwnProperty.call(e,i[s]))return!1;for(s=r;s--!==0;){const l=i[s];if(!(l==="_owner"&&t.$$typeof)&&!tc(t[l],e[l]))return!1}return!0}return t!==t&&e!==e}function ox(t){return typeof window>"u"?1:(t.ownerDocument.defaultView||window).devicePixelRatio||1}function py(t,e){const r=ox(t);return Math.round(e*r)/r}function Rd(t){const e=x.useRef(t);return $l(()=>{e.current=t}),e}function _2(t){t===void 0&&(t={});const{placement:e="bottom",strategy:r="absolute",middleware:s=[],platform:i,elements:{reference:l,floating:u}={},transform:d=!0,whileElementsMounted:h,open:p}=t,[y,v]=x.useState({x:0,y:0,strategy:r,placement:e,middlewareData:{},isPositioned:!1}),[C,w]=x.useState(s);tc(C,s)||w(s);const[E,b]=x.useState(null),[k,T]=x.useState(null),j=x.useCallback($=>{$!==V.current&&(V.current=$,b($))},[]),_=x.useCallback($=>{$!==B.current&&(B.current=$,T($))},[]),A=l||E,F=u||k,V=x.useRef(null),B=x.useRef(null),te=x.useRef(y),G=h!=null,W=Rd(h),le=Rd(i),K=Rd(p),Z=x.useCallback(()=>{if(!V.current||!B.current)return;const $={placement:e,strategy:r,middleware:C};le.current&&($.platform=le.current),T2(V.current,B.current,$).then(H=>{const Q={...H,isPositioned:K.current!==!1};J.current&&!tc(te.current,Q)&&(te.current=Q,na.flushSync(()=>{v(Q)}))})},[C,e,r,le,K]);$l(()=>{p===!1&&te.current.isPositioned&&(te.current.isPositioned=!1,v($=>({...$,isPositioned:!1})))},[p]);const J=x.useRef(!1);$l(()=>(J.current=!0,()=>{J.current=!1}),[]),$l(()=>{if(A&&(V.current=A),F&&(B.current=F),A&&F){if(W.current)return W.current(A,F,Z);Z()}},[A,F,Z,W,G]);const de=x.useMemo(()=>({reference:V,floating:B,setReference:j,setFloating:_}),[j,_]),ne=x.useMemo(()=>({reference:A,floating:F}),[A,F]),se=x.useMemo(()=>{const $={position:r,left:0,top:0};if(!ne.floating)return $;const H=py(ne.floating,y.x),Q=py(ne.floating,y.y);return d?{...$,transform:"translate("+H+"px, "+Q+"px)",...ox(ne.floating)>=1.5&&{willChange:"transform"}}:{position:r,left:H,top:Q}},[r,d,ne.floating,y.x,y.y]);return x.useMemo(()=>({...y,update:Z,refs:de,elements:ne,floatingStyles:se}),[y,Z,de,ne,se])}const A2=t=>{function e(r){return{}.hasOwnProperty.call(r,"current")}return{name:"arrow",options:t,fn(r){const{element:s,padding:i}=typeof t=="function"?t(r):t;return s&&e(s)?s.current!=null?hy({element:s.current,padding:i}).fn(r):{}:s?hy({element:s,padding:i}).fn(r):{}}}},L2=(t,e)=>({...C2(t),options:[t,e]}),I2=(t,e)=>({...E2(t),options:[t,e]}),D2=(t,e)=>({...P2(t),options:[t,e]}),M2=(t,e)=>({...k2(t),options:[t,e]}),F2=(t,e)=>({...N2(t),options:[t,e]}),z2=(t,e)=>({...R2(t),options:[t,e]}),$2=(t,e)=>({...A2(t),options:[t,e]});var U2="Arrow",sx=x.forwardRef((t,e)=>{const{children:r,width:s=10,height:i=5,...l}=t;return g.jsx(ze.svg,{...l,ref:e,width:s,height:i,viewBox:"0 0 30 10",preserveAspectRatio:"none",children:t.asChild?r:g.jsx("polygon",{points:"0,0 30,0 15,10"})})});sx.displayName=U2;var B2=sx;function H2(t){const[e,r]=x.useState(void 0);return mt(()=>{if(t){r({width:t.offsetWidth,height:t.offsetHeight});const s=new ResizeObserver(i=>{if(!Array.isArray(i)||!i.length)return;const l=i[0];let u,d;if("borderBoxSize"in l){const h=l.borderBoxSize,p=Array.isArray(h)?h[0]:h;u=p.inlineSize,d=p.blockSize}else u=t.offsetWidth,d=t.offsetHeight;r({width:u,height:d})});return s.observe(t,{box:"border-box"}),()=>s.unobserve(t)}else r(void 0)},[t]),e}var qf="Popper",[ix,ax]=aa(qf),[V2,lx]=ix(qf),cx=t=>{const{__scopePopper:e,children:r}=t,[s,i]=x.useState(null);return g.jsx(V2,{scope:e,anchor:s,onAnchorChange:i,children:r})};cx.displayName=qf;var ux="PopperAnchor",dx=x.forwardRef((t,e)=>{const{__scopePopper:r,virtualRef:s,...i}=t,l=lx(ux,r),u=x.useRef(null),d=Ve(e,u),h=x.useRef(null);return x.useEffect(()=>{const p=h.current;h.current=(s==null?void 0:s.current)||u.current,p!==h.current&&l.onAnchorChange(h.current)}),s?null:g.jsx(ze.div,{...i,ref:d})});dx.displayName=ux;var Qf="PopperContent",[W2,K2]=ix(Qf),fx=x.forwardRef((t,e)=>{var ee,ye,Se,Ne,Oe,_e;const{__scopePopper:r,side:s="bottom",sideOffset:i=0,align:l="center",alignOffset:u=0,arrowPadding:d=0,avoidCollisions:h=!0,collisionBoundary:p=[],collisionPadding:y=0,sticky:v="partial",hideWhenDetached:C=!1,updatePositionStrategy:w="optimized",onPlaced:E,...b}=t,k=lx(Qf,r),[T,j]=x.useState(null),_=Ve(e,et=>j(et)),[A,F]=x.useState(null),V=H2(A),B=(V==null?void 0:V.width)??0,te=(V==null?void 0:V.height)??0,G=s+(l!=="center"?"-"+l:""),W=typeof y=="number"?y:{top:0,right:0,bottom:0,left:0,...y},le=Array.isArray(p)?p:[p],K=le.length>0,Z={padding:W,boundary:le.filter(Q2),altBoundary:K},{refs:J,floatingStyles:de,placement:ne,isPositioned:se,middlewareData:$}=_2({strategy:"fixed",placement:G,whileElementsMounted:(...et)=>S2(...et,{animationFrame:w==="always"}),elements:{reference:k.anchor},middleware:[L2({mainAxis:i+te,alignmentAxis:u}),h&&I2({mainAxis:!0,crossAxis:!1,limiter:v==="partial"?D2():void 0,...Z}),h&&M2({...Z}),F2({...Z,apply:({elements:et,rects:gt,availableWidth:On,availableHeight:dn})=>{const{width:fn,height:wr}=gt.reference,jn=et.floating.style;jn.setProperty("--radix-popper-available-width",`${On}px`),jn.setProperty("--radix-popper-available-height",`${dn}px`),jn.setProperty("--radix-popper-anchor-width",`${fn}px`),jn.setProperty("--radix-popper-anchor-height",`${wr}px`)}}),A&&$2({element:A,padding:d}),Y2({arrowWidth:B,arrowHeight:te}),C&&z2({strategy:"referenceHidden",...Z})]}),[H,Q]=mx(ne),P=Vt(E);mt(()=>{se&&(P==null||P())},[se,P]);const M=(ee=$.arrow)==null?void 0:ee.x,ie=(ye=$.arrow)==null?void 0:ye.y,ae=((Se=$.arrow)==null?void 0:Se.centerOffset)!==0,[me,be]=x.useState();return mt(()=>{T&&be(window.getComputedStyle(T).zIndex)},[T]),g.jsx("div",{ref:J.setFloating,"data-radix-popper-content-wrapper":"",style:{...de,transform:se?de.transform:"translate(0, -200%)",minWidth:"max-content",zIndex:me,"--radix-popper-transform-origin":[(Ne=$.transformOrigin)==null?void 0:Ne.x,(Oe=$.transformOrigin)==null?void 0:Oe.y].join(" "),...((_e=$.hide)==null?void 0:_e.referenceHidden)&&{visibility:"hidden",pointerEvents:"none"}},dir:t.dir,children:g.jsx(W2,{scope:r,placedSide:H,onArrowChange:F,arrowX:M,arrowY:ie,shouldHideArrow:ae,children:g.jsx(ze.div,{"data-side":H,"data-align":Q,...b,ref:_,style:{...b.style,animation:se?void 0:"none"}})})})});fx.displayName=Qf;var hx="PopperArrow",q2={top:"bottom",right:"left",bottom:"top",left:"right"},px=x.forwardRef(function(e,r){const{__scopePopper:s,...i}=e,l=K2(hx,s),u=q2[l.placedSide];return g.jsx("span",{ref:l.onArrowChange,style:{position:"absolute",left:l.arrowX,top:l.arrowY,[u]:0,transformOrigin:{top:"",right:"0 0",bottom:"center 0",left:"100% 0"}[l.placedSide],transform:{top:"translateY(100%)",right:"translateY(50%) rotate(90deg) translateX(-50%)",bottom:"rotate(180deg)",left:"translateY(50%) rotate(-90deg) translateX(50%)"}[l.placedSide],visibility:l.shouldHideArrow?"hidden":void 0},children:g.jsx(B2,{...i,ref:r,style:{...i.style,display:"block"}})})});px.displayName=hx;function Q2(t){return t!==null}var Y2=t=>({name:"transformOrigin",options:t,fn(e){var k,T,j;const{placement:r,rects:s,middlewareData:i}=e,u=((k=i.arrow)==null?void 0:k.centerOffset)!==0,d=u?0:t.arrowWidth,h=u?0:t.arrowHeight,[p,y]=mx(r),v={start:"0%",center:"50%",end:"100%"}[y],C=(((T=i.arrow)==null?void 0:T.x)??0)+d/2,w=(((j=i.arrow)==null?void 0:j.y)??0)+h/2;let E="",b="";return p==="bottom"?(E=u?v:`${C}px`,b=`${-h}px`):p==="top"?(E=u?v:`${C}px`,b=`${s.floating.height+h}px`):p==="right"?(E=`${-h}px`,b=u?v:`${w}px`):p==="left"&&(E=`${s.floating.width+h}px`,b=u?v:`${w}px`),{data:{x:E,y:b}}}});function mx(t){const[e,r="center"]=t.split("-");return[e,r]}var G2=cx,X2=dx,J2=fx,Z2=px,eR="Portal",Yf=x.forwardRef((t,e)=>{var d;const{container:r,...s}=t,[i,l]=x.useState(!1);mt(()=>l(!0),[]);const u=r||i&&((d=globalThis==null?void 0:globalThis.document)==null?void 0:d.body);return u?Jy.createPortal(g.jsx(ze.div,{...s,ref:e}),u):null});Yf.displayName=eR;function tR(t){const e=nR(t),r=x.forwardRef((s,i)=>{const{children:l,...u}=s,d=x.Children.toArray(l),h=d.find(oR);if(h){const p=h.props.children,y=d.map(v=>v===h?x.Children.count(p)>1?x.Children.only(null):x.isValidElement(p)?p.props.children:null:v);return g.jsx(e,{...u,ref:i,children:x.isValidElement(p)?x.cloneElement(p,void 0,y):null})}return g.jsx(e,{...u,ref:i,children:l})});return r.displayName=`${t}.Slot`,r}function nR(t){const e=x.forwardRef((r,s)=>{const{children:i,...l}=r;if(x.isValidElement(i)){const u=iR(i),d=sR(l,i.props);return i.type!==x.Fragment&&(d.ref=s?Gs(s,u):u),x.cloneElement(i,d)}return x.Children.count(i)>1?x.Children.only(null):null});return e.displayName=`${t}.SlotClone`,e}var rR=Symbol("radix.slottable");function oR(t){return x.isValidElement(t)&&typeof t.type=="function"&&"__radixId"in t.type&&t.type.__radixId===rR}function sR(t,e){const r={...e};for(const s in e){const i=t[s],l=e[s];/^on[A-Z]/.test(s)?i&&l?r[s]=(...d)=>{const h=l(...d);return i(...d),h}:i&&(r[s]=i):s==="style"?r[s]={...i,...l}:s==="className"&&(r[s]=[i,l].filter(Boolean).join(" "))}return{...t,...r}}function iR(t){var s,i;let e=(s=Object.getOwnPropertyDescriptor(t.props,"ref"))==null?void 0:s.get,r=e&&"isReactWarning"in e&&e.isReactWarning;return r?t.ref:(e=(i=Object.getOwnPropertyDescriptor(t,"ref"))==null?void 0:i.get,r=e&&"isReactWarning"in e&&e.isReactWarning,r?t.props.ref:t.props.ref||t.ref)}var aR=Sf[" useInsertionEffect ".trim().toString()]||mt;function ff({prop:t,defaultProp:e,onChange:r=()=>{},caller:s}){const[i,l,u]=lR({defaultProp:e,onChange:r}),d=t!==void 0,h=d?t:i;{const y=x.useRef(t!==void 0);x.useEffect(()=>{const v=y.current;v!==d&&console.warn(`${s} is changing from ${v?"controlled":"uncontrolled"} to ${d?"controlled":"uncontrolled"}. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.`),y.current=d},[d,s])}const p=x.useCallback(y=>{var v;if(d){const C=cR(y)?y(t):y;C!==t&&((v=u.current)==null||v.call(u,C))}else l(y)},[d,t,l,u]);return[h,p]}function lR({defaultProp:t,onChange:e}){const[r,s]=x.useState(t),i=x.useRef(r),l=x.useRef(e);return aR(()=>{l.current=e},[e]),x.useEffect(()=>{var u;i.current!==r&&((u=l.current)==null||u.call(l,r),i.current=r)},[r,i]),[r,s,l]}function cR(t){return typeof t=="function"}function uR(t){const e=x.useRef({value:t,previous:t});return x.useMemo(()=>(e.current.value!==t&&(e.current.previous=e.current.value,e.current.value=t),e.current.previous),[t])}var gx=Object.freeze({position:"absolute",border:0,width:1,height:1,padding:0,margin:-1,overflow:"hidden",clip:"rect(0, 0, 0, 0)",whiteSpace:"nowrap",wordWrap:"normal"}),dR="VisuallyHidden",fR=x.forwardRef((t,e)=>g.jsx(ze.span,{...t,ref:e,style:{...gx,...t.style}}));fR.displayName=dR;var hR=function(t){if(typeof document>"u")return null;var e=Array.isArray(t)?t[0]:t;return e.ownerDocument.body},ws=new WeakMap,Ol=new WeakMap,jl={},Pd=0,yx=function(t){return t&&(t.host||yx(t.parentNode))},pR=function(t,e){return e.map(function(r){if(t.contains(r))return r;var s=yx(r);return s&&t.contains(s)?s:(console.error("aria-hidden",r,"in not contained inside",t,". Doing nothing"),null)}).filter(function(r){return!!r})},mR=function(t,e,r,s){var i=pR(e,Array.isArray(t)?t:[t]);jl[r]||(jl[r]=new WeakMap);var l=jl[r],u=[],d=new Set,h=new Set(i),p=function(v){!v||d.has(v)||(d.add(v),p(v.parentNode))};i.forEach(p);var y=function(v){!v||h.has(v)||Array.prototype.forEach.call(v.children,function(C){if(d.has(C))y(C);else try{var w=C.getAttribute(s),E=w!==null&&w!=="false",b=(ws.get(C)||0)+1,k=(l.get(C)||0)+1;ws.set(C,b),l.set(C,k),u.push(C),b===1&&E&&Ol.set(C,!0),k===1&&C.setAttribute(r,"true"),E||C.setAttribute(s,"true")}catch(T){console.error("aria-hidden: cannot operate on ",C,T)}})};return y(e),d.clear(),Pd++,function(){u.forEach(function(v){var C=ws.get(v)-1,w=l.get(v)-1;ws.set(v,C),l.set(v,w),C||(Ol.has(v)||v.removeAttribute(s),Ol.delete(v)),w||v.removeAttribute(r)}),Pd--,Pd||(ws=new WeakMap,ws=new WeakMap,Ol=new WeakMap,jl={})}},vx=function(t,e,r){r===void 0&&(r="data-aria-hidden");var s=Array.from(Array.isArray(t)?t:[t]),i=hR(t);return i?(s.push.apply(s,Array.from(i.querySelectorAll("[aria-live], script"))),mR(s,i,r,"aria-hidden")):function(){return null}},qn=function(){return qn=Object.assign||function(e){for(var r,s=1,i=arguments.length;s"u")return _R;var e=AR(t),r=document.documentElement.clientWidth,s=window.innerWidth;return{left:e[0],top:e[1],right:e[2],gap:Math.max(0,s-r+e[2]-e[0])}},IR=Sx(),Os="data-scroll-locked",DR=function(t,e,r,s){var i=t.left,l=t.top,u=t.right,d=t.gap;return r===void 0&&(r="margin"),` .`.concat(yR,` { overflow: hidden `).concat(s,`; padding-right: `).concat(d,"px ").concat(s,`; } body[`).concat(Os,`] { overflow: hidden `).concat(s,`; overscroll-behavior: contain; `).concat([e&&"position: relative ".concat(s,";"),r==="margin"&&` padding-left: `.concat(i,`px; padding-top: `).concat(l,`px; padding-right: `).concat(u,`px; margin-left:0; margin-top:0; margin-right: `).concat(d,"px ").concat(s,`; `),r==="padding"&&"padding-right: ".concat(d,"px ").concat(s,";")].filter(Boolean).join(""),` } .`).concat(Ul,` { right: `).concat(d,"px ").concat(s,`; } .`).concat(Bl,` { margin-right: `).concat(d,"px ").concat(s,`; } .`).concat(Ul," .").concat(Ul,` { right: 0 `).concat(s,`; } .`).concat(Bl," .").concat(Bl,` { margin-right: 0 `).concat(s,`; } body[`).concat(Os,`] { `).concat(vR,": ").concat(d,`px; } `)},gy=function(){var t=parseInt(document.body.getAttribute(Os)||"0",10);return isFinite(t)?t:0},MR=function(){x.useEffect(function(){return document.body.setAttribute(Os,(gy()+1).toString()),function(){var t=gy()-1;t<=0?document.body.removeAttribute(Os):document.body.setAttribute(Os,t.toString())}},[])},FR=function(t){var e=t.noRelative,r=t.noImportant,s=t.gapMode,i=s===void 0?"margin":s;MR();var l=x.useMemo(function(){return LR(i)},[i]);return x.createElement(IR,{styles:DR(l,!e,i,r?"":"!important")})},hf=!1;if(typeof window<"u")try{var _l=Object.defineProperty({},"passive",{get:function(){return hf=!0,!0}});window.addEventListener("test",_l,_l),window.removeEventListener("test",_l,_l)}catch{hf=!1}var bs=hf?{passive:!1}:!1,zR=function(t){return t.tagName==="TEXTAREA"},Cx=function(t,e){if(!(t instanceof Element))return!1;var r=window.getComputedStyle(t);return r[e]!=="hidden"&&!(r.overflowY===r.overflowX&&!zR(t)&&r[e]==="visible")},$R=function(t){return Cx(t,"overflowY")},UR=function(t){return Cx(t,"overflowX")},yy=function(t,e){var r=e.ownerDocument,s=e;do{typeof ShadowRoot<"u"&&s instanceof ShadowRoot&&(s=s.host);var i=Ex(t,s);if(i){var l=kx(t,s),u=l[1],d=l[2];if(u>d)return!0}s=s.parentNode}while(s&&s!==r.body);return!1},BR=function(t){var e=t.scrollTop,r=t.scrollHeight,s=t.clientHeight;return[e,r,s]},HR=function(t){var e=t.scrollLeft,r=t.scrollWidth,s=t.clientWidth;return[e,r,s]},Ex=function(t,e){return t==="v"?$R(e):UR(e)},kx=function(t,e){return t==="v"?BR(e):HR(e)},VR=function(t,e){return t==="h"&&e==="rtl"?-1:1},WR=function(t,e,r,s,i){var l=VR(t,window.getComputedStyle(e).direction),u=l*s,d=r.target,h=e.contains(d),p=!1,y=u>0,v=0,C=0;do{if(!d)break;var w=kx(t,d),E=w[0],b=w[1],k=w[2],T=b-k-l*E;(E||T)&&Ex(t,d)&&(v+=T,C+=E);var j=d.parentNode;d=j&&j.nodeType===Node.DOCUMENT_FRAGMENT_NODE?j.host:j}while(!h&&d!==document.body||h&&(e.contains(d)||e===d));return(y&&Math.abs(v)<1||!y&&Math.abs(C)<1)&&(p=!0),p},Al=function(t){return"changedTouches"in t?[t.changedTouches[0].clientX,t.changedTouches[0].clientY]:[0,0]},vy=function(t){return[t.deltaX,t.deltaY]},xy=function(t){return t&&"current"in t?t.current:t},KR=function(t,e){return t[0]===e[0]&&t[1]===e[1]},qR=function(t){return` .block-interactivity-`.concat(t,` {pointer-events: none;} .allow-interactivity-`).concat(t,` {pointer-events: all;} `)},QR=0,Ss=[];function YR(t){var e=x.useRef([]),r=x.useRef([0,0]),s=x.useRef(),i=x.useState(QR++)[0],l=x.useState(Sx)[0],u=x.useRef(t);x.useEffect(function(){u.current=t},[t]),x.useEffect(function(){if(t.inert){document.body.classList.add("block-interactivity-".concat(i));var b=gR([t.lockRef.current],(t.shards||[]).map(xy),!0).filter(Boolean);return b.forEach(function(k){return k.classList.add("allow-interactivity-".concat(i))}),function(){document.body.classList.remove("block-interactivity-".concat(i)),b.forEach(function(k){return k.classList.remove("allow-interactivity-".concat(i))})}}},[t.inert,t.lockRef.current,t.shards]);var d=x.useCallback(function(b,k){if("touches"in b&&b.touches.length===2||b.type==="wheel"&&b.ctrlKey)return!u.current.allowPinchZoom;var T=Al(b),j=r.current,_="deltaX"in b?b.deltaX:j[0]-T[0],A="deltaY"in b?b.deltaY:j[1]-T[1],F,V=b.target,B=Math.abs(_)>Math.abs(A)?"h":"v";if("touches"in b&&B==="h"&&V.type==="range")return!1;var te=window.getSelection(),G=te&&te.anchorNode,W=G?G===V||G.contains(V):!1;if(W)return!1;var le=yy(B,V);if(!le)return!0;if(le?F=B:(F=B==="v"?"h":"v",le=yy(B,V)),!le)return!1;if(!s.current&&"changedTouches"in b&&(_||A)&&(s.current=F),!F)return!0;var K=s.current||F;return WR(K,k,b,K==="h"?_:A)},[]),h=x.useCallback(function(b){var k=b;if(!(!Ss.length||Ss[Ss.length-1]!==l)){var T="deltaY"in k?vy(k):Al(k),j=e.current.filter(function(F){return F.name===k.type&&(F.target===k.target||k.target===F.shadowParent)&&KR(F.delta,T)})[0];if(j&&j.should){k.cancelable&&k.preventDefault();return}if(!j){var _=(u.current.shards||[]).map(xy).filter(Boolean).filter(function(F){return F.contains(k.target)}),A=_.length>0?d(k,_[0]):!u.current.noIsolation;A&&k.cancelable&&k.preventDefault()}}},[]),p=x.useCallback(function(b,k,T,j){var _={name:b,delta:k,target:T,should:j,shadowParent:GR(T)};e.current.push(_),setTimeout(function(){e.current=e.current.filter(function(A){return A!==_})},1)},[]),y=x.useCallback(function(b){r.current=Al(b),s.current=void 0},[]),v=x.useCallback(function(b){p(b.type,vy(b),b.target,d(b,t.lockRef.current))},[]),C=x.useCallback(function(b){p(b.type,Al(b),b.target,d(b,t.lockRef.current))},[]);x.useEffect(function(){return Ss.push(l),t.setCallbacks({onScrollCapture:v,onWheelCapture:v,onTouchMoveCapture:C}),document.addEventListener("wheel",h,bs),document.addEventListener("touchmove",h,bs),document.addEventListener("touchstart",y,bs),function(){Ss=Ss.filter(function(b){return b!==l}),document.removeEventListener("wheel",h,bs),document.removeEventListener("touchmove",h,bs),document.removeEventListener("touchstart",y,bs)}},[]);var w=t.removeScrollBar,E=t.inert;return x.createElement(x.Fragment,null,E?x.createElement(l,{styles:qR(i)}):null,w?x.createElement(FR,{noRelative:t.noRelative,gapMode:t.gapMode}):null)}function GR(t){for(var e=null;t!==null;)t instanceof ShadowRoot&&(e=t.host,t=t.host),t=t.parentNode;return e}const XR=kR(bx,YR);var Gf=x.forwardRef(function(t,e){return x.createElement(xc,qn({},t,{ref:e,sideCar:XR}))});Gf.classNames=xc.classNames;var JR=[" ","Enter","ArrowUp","ArrowDown"],ZR=[" ","Enter"],Ho="Select",[wc,bc,eP]=oN(Ho),[Zs]=aa(Ho,[eP,ax]),Sc=ax(),[tP,oo]=Zs(Ho),[nP,rP]=Zs(Ho),Nx=t=>{const{__scopeSelect:e,children:r,open:s,defaultOpen:i,onOpenChange:l,value:u,defaultValue:d,onValueChange:h,dir:p,name:y,autoComplete:v,disabled:C,required:w,form:E}=t,b=Sc(e),[k,T]=x.useState(null),[j,_]=x.useState(null),[A,F]=x.useState(!1),V=Hv(p),[B,te]=ff({prop:s,defaultProp:i??!1,onChange:l,caller:Ho}),[G,W]=ff({prop:u,defaultProp:d,onChange:h,caller:Ho}),le=x.useRef(null),K=k?E||!!k.closest("form"):!0,[Z,J]=x.useState(new Set),de=Array.from(Z).map(ne=>ne.props.value).join(";");return g.jsx(G2,{...b,children:g.jsxs(tP,{required:w,scope:e,trigger:k,onTriggerChange:T,valueNode:j,onValueNodeChange:_,valueNodeHasChildren:A,onValueNodeHasChildrenChange:F,contentId:Ps(),value:G,onValueChange:W,open:B,onOpenChange:te,dir:V,triggerPointerDownPosRef:le,disabled:C,children:[g.jsx(wc.Provider,{scope:e,children:g.jsx(nP,{scope:t.__scopeSelect,onNativeOptionAdd:x.useCallback(ne=>{J(se=>new Set(se).add(ne))},[]),onNativeOptionRemove:x.useCallback(ne=>{J(se=>{const $=new Set(se);return $.delete(ne),$})},[]),children:r})}),K?g.jsxs(Gx,{"aria-hidden":!0,required:w,tabIndex:-1,name:y,autoComplete:v,value:G,onChange:ne=>W(ne.target.value),disabled:C,form:E,children:[G===void 0?g.jsx("option",{value:""}):null,Array.from(Z)]},de):null]})})};Nx.displayName=Ho;var Rx="SelectTrigger",Px=x.forwardRef((t,e)=>{const{__scopeSelect:r,disabled:s=!1,...i}=t,l=Sc(r),u=oo(Rx,r),d=u.disabled||s,h=Ve(e,u.onTriggerChange),p=bc(r),y=x.useRef("touch"),[v,C,w]=Jx(b=>{const k=p().filter(_=>!_.disabled),T=k.find(_=>_.value===u.value),j=Zx(k,b,T);j!==void 0&&u.onValueChange(j.value)}),E=b=>{d||(u.onOpenChange(!0),w()),b&&(u.triggerPointerDownPosRef.current={x:Math.round(b.pageX),y:Math.round(b.pageY)})};return g.jsx(X2,{asChild:!0,...l,children:g.jsx(ze.button,{type:"button",role:"combobox","aria-controls":u.contentId,"aria-expanded":u.open,"aria-required":u.required,"aria-autocomplete":"none",dir:u.dir,"data-state":u.open?"open":"closed",disabled:d,"data-disabled":d?"":void 0,"data-placeholder":Xx(u.value)?"":void 0,...i,ref:h,onClick:Me(i.onClick,b=>{b.currentTarget.focus(),y.current!=="mouse"&&E(b)}),onPointerDown:Me(i.onPointerDown,b=>{y.current=b.pointerType;const k=b.target;k.hasPointerCapture(b.pointerId)&&k.releasePointerCapture(b.pointerId),b.button===0&&b.ctrlKey===!1&&b.pointerType==="mouse"&&(E(b),b.preventDefault())}),onKeyDown:Me(i.onKeyDown,b=>{const k=v.current!=="";!(b.ctrlKey||b.altKey||b.metaKey)&&b.key.length===1&&C(b.key),!(k&&b.key===" ")&&JR.includes(b.key)&&(E(),b.preventDefault())})})})});Px.displayName=Rx;var Tx="SelectValue",Ox=x.forwardRef((t,e)=>{const{__scopeSelect:r,className:s,style:i,children:l,placeholder:u="",...d}=t,h=oo(Tx,r),{onValueNodeHasChildrenChange:p}=h,y=l!==void 0,v=Ve(e,h.onValueNodeChange);return mt(()=>{p(y)},[p,y]),g.jsx(ze.span,{...d,ref:v,style:{pointerEvents:"none"},children:Xx(h.value)?g.jsx(g.Fragment,{children:u}):l})});Ox.displayName=Tx;var oP="SelectIcon",jx=x.forwardRef((t,e)=>{const{__scopeSelect:r,children:s,...i}=t;return g.jsx(ze.span,{"aria-hidden":!0,...i,ref:e,children:s||"▼"})});jx.displayName=oP;var sP="SelectPortal",_x=t=>g.jsx(Yf,{asChild:!0,...t});_x.displayName=sP;var Vo="SelectContent",Ax=x.forwardRef((t,e)=>{const r=oo(Vo,t.__scopeSelect),[s,i]=x.useState();if(mt(()=>{i(new DocumentFragment)},[]),!r.open){const l=s;return l?na.createPortal(g.jsx(Lx,{scope:t.__scopeSelect,children:g.jsx(wc.Slot,{scope:t.__scopeSelect,children:g.jsx("div",{children:t.children})})}),l):null}return g.jsx(Ix,{...t,ref:e})});Ax.displayName=Vo;var Cn=10,[Lx,so]=Zs(Vo),iP="SelectContentImpl",aP=tR("SelectContent.RemoveScroll"),Ix=x.forwardRef((t,e)=>{const{__scopeSelect:r,position:s="item-aligned",onCloseAutoFocus:i,onEscapeKeyDown:l,onPointerDownOutside:u,side:d,sideOffset:h,align:p,alignOffset:y,arrowPadding:v,collisionBoundary:C,collisionPadding:w,sticky:E,hideWhenDetached:b,avoidCollisions:k,...T}=t,j=oo(Vo,r),[_,A]=x.useState(null),[F,V]=x.useState(null),B=Ve(e,ee=>A(ee)),[te,G]=x.useState(null),[W,le]=x.useState(null),K=bc(r),[Z,J]=x.useState(!1),de=x.useRef(!1);x.useEffect(()=>{if(_)return vx(_)},[_]),Kv();const ne=x.useCallback(ee=>{const[ye,...Se]=K().map(_e=>_e.ref.current),[Ne]=Se.slice(-1),Oe=document.activeElement;for(const _e of ee)if(_e===Oe||(_e==null||_e.scrollIntoView({block:"nearest"}),_e===ye&&F&&(F.scrollTop=0),_e===Ne&&F&&(F.scrollTop=F.scrollHeight),_e==null||_e.focus(),document.activeElement!==Oe))return},[K,F]),se=x.useCallback(()=>ne([te,_]),[ne,te,_]);x.useEffect(()=>{Z&&se()},[Z,se]);const{onOpenChange:$,triggerPointerDownPosRef:H}=j;x.useEffect(()=>{if(_){let ee={x:0,y:0};const ye=Ne=>{var Oe,_e;ee={x:Math.abs(Math.round(Ne.pageX)-(((Oe=H.current)==null?void 0:Oe.x)??0)),y:Math.abs(Math.round(Ne.pageY)-(((_e=H.current)==null?void 0:_e.y)??0))}},Se=Ne=>{ee.x<=10&&ee.y<=10?Ne.preventDefault():_.contains(Ne.target)||$(!1),document.removeEventListener("pointermove",ye),H.current=null};return H.current!==null&&(document.addEventListener("pointermove",ye),document.addEventListener("pointerup",Se,{capture:!0,once:!0})),()=>{document.removeEventListener("pointermove",ye),document.removeEventListener("pointerup",Se,{capture:!0})}}},[_,$,H]),x.useEffect(()=>{const ee=()=>$(!1);return window.addEventListener("blur",ee),window.addEventListener("resize",ee),()=>{window.removeEventListener("blur",ee),window.removeEventListener("resize",ee)}},[$]);const[Q,P]=Jx(ee=>{const ye=K().filter(Oe=>!Oe.disabled),Se=ye.find(Oe=>Oe.ref.current===document.activeElement),Ne=Zx(ye,ee,Se);Ne&&setTimeout(()=>Ne.ref.current.focus())}),M=x.useCallback((ee,ye,Se)=>{const Ne=!de.current&&!Se;(j.value!==void 0&&j.value===ye||Ne)&&(G(ee),Ne&&(de.current=!0))},[j.value]),ie=x.useCallback(()=>_==null?void 0:_.focus(),[_]),ae=x.useCallback((ee,ye,Se)=>{const Ne=!de.current&&!Se;(j.value!==void 0&&j.value===ye||Ne)&&le(ee)},[j.value]),me=s==="popper"?pf:Dx,be=me===pf?{side:d,sideOffset:h,align:p,alignOffset:y,arrowPadding:v,collisionBoundary:C,collisionPadding:w,sticky:E,hideWhenDetached:b,avoidCollisions:k}:{};return g.jsx(Lx,{scope:r,content:_,viewport:F,onViewportChange:V,itemRefCallback:M,selectedItem:te,onItemLeave:ie,itemTextRefCallback:ae,focusSelectedItem:se,selectedItemText:W,position:s,isPositioned:Z,searchRef:Q,children:g.jsx(Gf,{as:aP,allowPinchZoom:!0,children:g.jsx($f,{asChild:!0,trapped:j.open,onMountAutoFocus:ee=>{ee.preventDefault()},onUnmountAutoFocus:Me(i,ee=>{var ye;(ye=j.trigger)==null||ye.focus({preventScroll:!0}),ee.preventDefault()}),children:g.jsx(zf,{asChild:!0,disableOutsidePointerEvents:!0,onEscapeKeyDown:l,onPointerDownOutside:u,onFocusOutside:ee=>ee.preventDefault(),onDismiss:()=>j.onOpenChange(!1),children:g.jsx(me,{role:"listbox",id:j.contentId,"data-state":j.open?"open":"closed",dir:j.dir,onContextMenu:ee=>ee.preventDefault(),...T,...be,onPlaced:()=>J(!0),ref:B,style:{display:"flex",flexDirection:"column",outline:"none",...T.style},onKeyDown:Me(T.onKeyDown,ee=>{const ye=ee.ctrlKey||ee.altKey||ee.metaKey;if(ee.key==="Tab"&&ee.preventDefault(),!ye&&ee.key.length===1&&P(ee.key),["ArrowUp","ArrowDown","Home","End"].includes(ee.key)){let Ne=K().filter(Oe=>!Oe.disabled).map(Oe=>Oe.ref.current);if(["ArrowUp","End"].includes(ee.key)&&(Ne=Ne.slice().reverse()),["ArrowUp","ArrowDown"].includes(ee.key)){const Oe=ee.target,_e=Ne.indexOf(Oe);Ne=Ne.slice(_e+1)}setTimeout(()=>ne(Ne)),ee.preventDefault()}})})})})})})});Ix.displayName=iP;var lP="SelectItemAlignedPosition",Dx=x.forwardRef((t,e)=>{const{__scopeSelect:r,onPlaced:s,...i}=t,l=oo(Vo,r),u=so(Vo,r),[d,h]=x.useState(null),[p,y]=x.useState(null),v=Ve(e,B=>y(B)),C=bc(r),w=x.useRef(!1),E=x.useRef(!0),{viewport:b,selectedItem:k,selectedItemText:T,focusSelectedItem:j}=u,_=x.useCallback(()=>{if(l.trigger&&l.valueNode&&d&&p&&b&&k&&T){const B=l.trigger.getBoundingClientRect(),te=p.getBoundingClientRect(),G=l.valueNode.getBoundingClientRect(),W=T.getBoundingClientRect();if(l.dir!=="rtl"){const Oe=W.left-te.left,_e=G.left-Oe,et=B.left-_e,gt=B.width+et,On=Math.max(gt,te.width),dn=window.innerWidth-Cn,fn=af(_e,[Cn,Math.max(Cn,dn-On)]);d.style.minWidth=gt+"px",d.style.left=fn+"px"}else{const Oe=te.right-W.right,_e=window.innerWidth-G.right-Oe,et=window.innerWidth-B.right-_e,gt=B.width+et,On=Math.max(gt,te.width),dn=window.innerWidth-Cn,fn=af(_e,[Cn,Math.max(Cn,dn-On)]);d.style.minWidth=gt+"px",d.style.right=fn+"px"}const le=C(),K=window.innerHeight-Cn*2,Z=b.scrollHeight,J=window.getComputedStyle(p),de=parseInt(J.borderTopWidth,10),ne=parseInt(J.paddingTop,10),se=parseInt(J.borderBottomWidth,10),$=parseInt(J.paddingBottom,10),H=de+ne+Z+$+se,Q=Math.min(k.offsetHeight*5,H),P=window.getComputedStyle(b),M=parseInt(P.paddingTop,10),ie=parseInt(P.paddingBottom,10),ae=B.top+B.height/2-Cn,me=K-ae,be=k.offsetHeight/2,ee=k.offsetTop+be,ye=de+ne+ee,Se=H-ye;if(ye<=ae){const Oe=le.length>0&&k===le[le.length-1].ref.current;d.style.bottom="0px";const _e=p.clientHeight-b.offsetTop-b.offsetHeight,et=Math.max(me,be+(Oe?ie:0)+_e+se),gt=ye+et;d.style.height=gt+"px"}else{const Oe=le.length>0&&k===le[0].ref.current;d.style.top="0px";const et=Math.max(ae,de+b.offsetTop+(Oe?M:0)+be)+Se;d.style.height=et+"px",b.scrollTop=ye-ae+b.offsetTop}d.style.margin=`${Cn}px 0`,d.style.minHeight=Q+"px",d.style.maxHeight=K+"px",s==null||s(),requestAnimationFrame(()=>w.current=!0)}},[C,l.trigger,l.valueNode,d,p,b,k,T,l.dir,s]);mt(()=>_(),[_]);const[A,F]=x.useState();mt(()=>{p&&F(window.getComputedStyle(p).zIndex)},[p]);const V=x.useCallback(B=>{B&&E.current===!0&&(_(),j==null||j(),E.current=!1)},[_,j]);return g.jsx(uP,{scope:r,contentWrapper:d,shouldExpandOnScrollRef:w,onScrollButtonChange:V,children:g.jsx("div",{ref:h,style:{display:"flex",flexDirection:"column",position:"fixed",zIndex:A},children:g.jsx(ze.div,{...i,ref:v,style:{boxSizing:"border-box",maxHeight:"100%",...i.style}})})})});Dx.displayName=lP;var cP="SelectPopperPosition",pf=x.forwardRef((t,e)=>{const{__scopeSelect:r,align:s="start",collisionPadding:i=Cn,...l}=t,u=Sc(r);return g.jsx(J2,{...u,...l,ref:e,align:s,collisionPadding:i,style:{boxSizing:"border-box",...l.style,"--radix-select-content-transform-origin":"var(--radix-popper-transform-origin)","--radix-select-content-available-width":"var(--radix-popper-available-width)","--radix-select-content-available-height":"var(--radix-popper-available-height)","--radix-select-trigger-width":"var(--radix-popper-anchor-width)","--radix-select-trigger-height":"var(--radix-popper-anchor-height)"}})});pf.displayName=cP;var[uP,Xf]=Zs(Vo,{}),mf="SelectViewport",Mx=x.forwardRef((t,e)=>{const{__scopeSelect:r,nonce:s,...i}=t,l=so(mf,r),u=Xf(mf,r),d=Ve(e,l.onViewportChange),h=x.useRef(0);return g.jsxs(g.Fragment,{children:[g.jsx("style",{dangerouslySetInnerHTML:{__html:"[data-radix-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-select-viewport]::-webkit-scrollbar{display:none}"},nonce:s}),g.jsx(wc.Slot,{scope:r,children:g.jsx(ze.div,{"data-radix-select-viewport":"",role:"presentation",...i,ref:d,style:{position:"relative",flex:1,overflow:"hidden auto",...i.style},onScroll:Me(i.onScroll,p=>{const y=p.currentTarget,{contentWrapper:v,shouldExpandOnScrollRef:C}=u;if(C!=null&&C.current&&v){const w=Math.abs(h.current-y.scrollTop);if(w>0){const E=window.innerHeight-Cn*2,b=parseFloat(v.style.minHeight),k=parseFloat(v.style.height),T=Math.max(b,k);if(T0?A:0,v.style.justifyContent="flex-end")}}}h.current=y.scrollTop})})})]})});Mx.displayName=mf;var Fx="SelectGroup",[dP,fP]=Zs(Fx),hP=x.forwardRef((t,e)=>{const{__scopeSelect:r,...s}=t,i=Ps();return g.jsx(dP,{scope:r,id:i,children:g.jsx(ze.div,{role:"group","aria-labelledby":i,...s,ref:e})})});hP.displayName=Fx;var zx="SelectLabel",$x=x.forwardRef((t,e)=>{const{__scopeSelect:r,...s}=t,i=fP(zx,r);return g.jsx(ze.div,{id:i.id,...s,ref:e})});$x.displayName=zx;var nc="SelectItem",[pP,Ux]=Zs(nc),Bx=x.forwardRef((t,e)=>{const{__scopeSelect:r,value:s,disabled:i=!1,textValue:l,...u}=t,d=oo(nc,r),h=so(nc,r),p=d.value===s,[y,v]=x.useState(l??""),[C,w]=x.useState(!1),E=Ve(e,j=>{var _;return(_=h.itemRefCallback)==null?void 0:_.call(h,j,s,i)}),b=Ps(),k=x.useRef("touch"),T=()=>{i||(d.onValueChange(s),d.onOpenChange(!1))};if(s==="")throw new Error("A must have a value prop that is not an empty string. This is because the Select value can be set to an empty string to clear the selection and show the placeholder.");return g.jsx(pP,{scope:r,value:s,disabled:i,textId:b,isSelected:p,onItemTextChange:x.useCallback(j=>{v(_=>_||((j==null?void 0:j.textContent)??"").trim())},[]),children:g.jsx(wc.ItemSlot,{scope:r,value:s,disabled:i,textValue:y,children:g.jsx(ze.div,{role:"option","aria-labelledby":b,"data-highlighted":C?"":void 0,"aria-selected":p&&C,"data-state":p?"checked":"unchecked","aria-disabled":i||void 0,"data-disabled":i?"":void 0,tabIndex:i?void 0:-1,...u,ref:E,onFocus:Me(u.onFocus,()=>w(!0)),onBlur:Me(u.onBlur,()=>w(!1)),onClick:Me(u.onClick,()=>{k.current!=="mouse"&&T()}),onPointerUp:Me(u.onPointerUp,()=>{k.current==="mouse"&&T()}),onPointerDown:Me(u.onPointerDown,j=>{k.current=j.pointerType}),onPointerMove:Me(u.onPointerMove,j=>{var _;k.current=j.pointerType,i?(_=h.onItemLeave)==null||_.call(h):k.current==="mouse"&&j.currentTarget.focus({preventScroll:!0})}),onPointerLeave:Me(u.onPointerLeave,j=>{var _;j.currentTarget===document.activeElement&&((_=h.onItemLeave)==null||_.call(h))}),onKeyDown:Me(u.onKeyDown,j=>{var A;((A=h.searchRef)==null?void 0:A.current)!==""&&j.key===" "||(ZR.includes(j.key)&&T(),j.key===" "&&j.preventDefault())})})})})});Bx.displayName=nc;var Bi="SelectItemText",Hx=x.forwardRef((t,e)=>{const{__scopeSelect:r,className:s,style:i,...l}=t,u=oo(Bi,r),d=so(Bi,r),h=Ux(Bi,r),p=rP(Bi,r),[y,v]=x.useState(null),C=Ve(e,T=>v(T),h.onItemTextChange,T=>{var j;return(j=d.itemTextRefCallback)==null?void 0:j.call(d,T,h.value,h.disabled)}),w=y==null?void 0:y.textContent,E=x.useMemo(()=>g.jsx("option",{value:h.value,disabled:h.disabled,children:w},h.value),[h.disabled,h.value,w]),{onNativeOptionAdd:b,onNativeOptionRemove:k}=p;return mt(()=>(b(E),()=>k(E)),[b,k,E]),g.jsxs(g.Fragment,{children:[g.jsx(ze.span,{id:h.textId,...l,ref:C}),h.isSelected&&u.valueNode&&!u.valueNodeHasChildren?na.createPortal(l.children,u.valueNode):null]})});Hx.displayName=Bi;var Vx="SelectItemIndicator",Wx=x.forwardRef((t,e)=>{const{__scopeSelect:r,...s}=t;return Ux(Vx,r).isSelected?g.jsx(ze.span,{"aria-hidden":!0,...s,ref:e}):null});Wx.displayName=Vx;var gf="SelectScrollUpButton",Kx=x.forwardRef((t,e)=>{const r=so(gf,t.__scopeSelect),s=Xf(gf,t.__scopeSelect),[i,l]=x.useState(!1),u=Ve(e,s.onScrollButtonChange);return mt(()=>{if(r.viewport&&r.isPositioned){let d=function(){const p=h.scrollTop>0;l(p)};const h=r.viewport;return d(),h.addEventListener("scroll",d),()=>h.removeEventListener("scroll",d)}},[r.viewport,r.isPositioned]),i?g.jsx(Qx,{...t,ref:u,onAutoScroll:()=>{const{viewport:d,selectedItem:h}=r;d&&h&&(d.scrollTop=d.scrollTop-h.offsetHeight)}}):null});Kx.displayName=gf;var yf="SelectScrollDownButton",qx=x.forwardRef((t,e)=>{const r=so(yf,t.__scopeSelect),s=Xf(yf,t.__scopeSelect),[i,l]=x.useState(!1),u=Ve(e,s.onScrollButtonChange);return mt(()=>{if(r.viewport&&r.isPositioned){let d=function(){const p=h.scrollHeight-h.clientHeight,y=Math.ceil(h.scrollTop)h.removeEventListener("scroll",d)}},[r.viewport,r.isPositioned]),i?g.jsx(Qx,{...t,ref:u,onAutoScroll:()=>{const{viewport:d,selectedItem:h}=r;d&&h&&(d.scrollTop=d.scrollTop+h.offsetHeight)}}):null});qx.displayName=yf;var Qx=x.forwardRef((t,e)=>{const{__scopeSelect:r,onAutoScroll:s,...i}=t,l=so("SelectScrollButton",r),u=x.useRef(null),d=bc(r),h=x.useCallback(()=>{u.current!==null&&(window.clearInterval(u.current),u.current=null)},[]);return x.useEffect(()=>()=>h(),[h]),mt(()=>{var y;const p=d().find(v=>v.ref.current===document.activeElement);(y=p==null?void 0:p.ref.current)==null||y.scrollIntoView({block:"nearest"})},[d]),g.jsx(ze.div,{"aria-hidden":!0,...i,ref:e,style:{flexShrink:0,...i.style},onPointerDown:Me(i.onPointerDown,()=>{u.current===null&&(u.current=window.setInterval(s,50))}),onPointerMove:Me(i.onPointerMove,()=>{var p;(p=l.onItemLeave)==null||p.call(l),u.current===null&&(u.current=window.setInterval(s,50))}),onPointerLeave:Me(i.onPointerLeave,()=>{h()})})}),mP="SelectSeparator",Yx=x.forwardRef((t,e)=>{const{__scopeSelect:r,...s}=t;return g.jsx(ze.div,{"aria-hidden":!0,...s,ref:e})});Yx.displayName=mP;var vf="SelectArrow",gP=x.forwardRef((t,e)=>{const{__scopeSelect:r,...s}=t,i=Sc(r),l=oo(vf,r),u=so(vf,r);return l.open&&u.position==="popper"?g.jsx(Z2,{...i,...s,ref:e}):null});gP.displayName=vf;var yP="SelectBubbleInput",Gx=x.forwardRef(({__scopeSelect:t,value:e,...r},s)=>{const i=x.useRef(null),l=Ve(s,i),u=uR(e);return x.useEffect(()=>{const d=i.current;if(!d)return;const h=window.HTMLSelectElement.prototype,y=Object.getOwnPropertyDescriptor(h,"value").set;if(u!==e&&y){const v=new Event("change",{bubbles:!0});y.call(d,e),d.dispatchEvent(v)}},[u,e]),g.jsx(ze.select,{...r,style:{...gx,...r.style},ref:l,defaultValue:e})});Gx.displayName=yP;function Xx(t){return t===""||t===void 0}function Jx(t){const e=Vt(t),r=x.useRef(""),s=x.useRef(0),i=x.useCallback(u=>{const d=r.current+u;e(d),(function h(p){r.current=p,window.clearTimeout(s.current),p!==""&&(s.current=window.setTimeout(()=>h(""),1e3))})(d)},[e]),l=x.useCallback(()=>{r.current="",window.clearTimeout(s.current)},[]);return x.useEffect(()=>()=>window.clearTimeout(s.current),[]),[r,i,l]}function Zx(t,e,r){const i=e.length>1&&Array.from(e).every(p=>p===e[0])?e[0]:e,l=r?t.indexOf(r):-1;let u=vP(t,Math.max(l,0));i.length===1&&(u=u.filter(p=>p!==r));const h=u.find(p=>p.textValue.toLowerCase().startsWith(i.toLowerCase()));return h!==r?h:void 0}function vP(t,e){return t.map((r,s)=>t[(e+s)%t.length])}var xP=Nx,e0=Px,wP=Ox,bP=jx,SP=_x,t0=Ax,CP=Mx,n0=$x,r0=Bx,EP=Hx,kP=Wx,o0=Kx,s0=qx,i0=Yx;const ks=xP,Ns=wP,ko=x.forwardRef(({className:t,children:e,...r},s)=>g.jsxs(e0,{ref:s,className:Be("flex h-9 w-full items-center justify-between rounded-md border border-cyber-border-DEFAULT bg-cyber-bg-tertiary px-3 py-2 text-sm font-mono text-cyber-text-primary ring-offset-background placeholder:text-cyber-text-muted focus:outline-none focus:border-cyber-neon-cyan focus:shadow-[0_0_10px_rgb(var(--cyber-neon-cyan)/0.2)] disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 transition-all",t),...r,children:[e,g.jsx(bP,{asChild:!0,children:g.jsx(Pf,{className:"h-4 w-4 text-cyber-text-muted"})})]}));ko.displayName=e0.displayName;const a0=x.forwardRef(({className:t,...e},r)=>g.jsx(o0,{ref:r,className:Be("flex cursor-default items-center justify-center py-1 text-cyber-text-muted",t),...e,children:g.jsx(ev,{className:"h-4 w-4"})}));a0.displayName=o0.displayName;const l0=x.forwardRef(({className:t,...e},r)=>g.jsx(s0,{ref:r,className:Be("flex cursor-default items-center justify-center py-1 text-cyber-text-muted",t),...e,children:g.jsx(Pf,{className:"h-4 w-4"})}));l0.displayName=s0.displayName;const No=x.forwardRef(({className:t,children:e,position:r="popper",...s},i)=>g.jsx(SP,{children:g.jsxs(t0,{ref:i,className:Be("relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-cyber-border-DEFAULT bg-cyber-bg-panel text-cyber-text-primary shadow-[0_0_20px_rgba(0,0,0,0.5)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",r==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",t),position:r,...s,children:[g.jsx(a0,{}),g.jsx(CP,{className:Be("p-1",r==="popper"&&"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"),children:e}),g.jsx(l0,{})]})}));No.displayName=t0.displayName;const NP=x.forwardRef(({className:t,...e},r)=>g.jsx(n0,{ref:r,className:Be("py-1.5 pl-8 pr-2 text-sm font-semibold text-cyber-neon-cyan",t),...e}));NP.displayName=n0.displayName;const Ro=x.forwardRef(({className:t,children:e,...r},s)=>g.jsxs(r0,{ref:s,className:Be("relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm font-mono outline-none focus:bg-cyber-neon-cyan/20 focus:text-cyber-neon-cyan data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-colors",t),...r,children:[g.jsx("span",{className:"absolute left-2 flex h-3.5 w-3.5 items-center justify-center",children:g.jsx(kP,{children:g.jsx(Rf,{className:"h-4 w-4 text-cyber-neon-cyan"})})}),g.jsx(EP,{children:e})]}));Ro.displayName=r0.displayName;const RP=x.forwardRef(({className:t,...e},r)=>g.jsx(i0,{ref:r,className:Be("-mx-1 my-1 h-px bg-cyber-border-DEFAULT",t),...e}));RP.displayName=i0.displayName;const _d=[{code:"zh-CN",label:"中文"},{code:"en-US",label:"EN"}];function PP(){const{i18n:t}=Zt(),e=_d.find(r=>r.code===t.language)||_d[0];return g.jsxs(ks,{value:t.language,onValueChange:r=>t.changeLanguage(r),children:[g.jsxs(ko,{className:"w-20 h-7 text-xs font-mono border-cyber-border-subtle bg-cyber-bg-tertiary/50 hover:border-cyber-neon-cyan/50 transition-colors",children:[g.jsx(rv,{className:"w-3 h-3 mr-1 text-cyber-text-secondary"}),g.jsx(Ns,{children:e.label})]}),g.jsx(No,{children:_d.map(r=>g.jsx(Ro,{value:r.code,className:"text-xs font-mono",children:r.label},r.code))})]})}const c0="mediacrawler_theme";function TP(){return typeof window>"u"?"light":window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function OP(){if(typeof window>"u")return"light";const t=localStorage.getItem(c0);return t&&["light","dark","system"].includes(t)?t:"light"}function Jf(t){const e=document.documentElement;t==="dark"?e.classList.add("dark"):e.classList.remove("dark")}function u0(t){return t==="system"?TP():t}const d0=OP(),f0=u0(d0);typeof window<"u"&&Jf(f0);const xf=yv(t=>({theme:d0,resolvedTheme:f0,setTheme:e=>{const r=u0(e);localStorage.setItem(c0,e),Jf(r),t({theme:e,resolvedTheme:r})}}));typeof window<"u"&&window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",t=>{if(xf.getState().theme==="system"){const r=t.matches?"dark":"light";Jf(r),xf.setState({resolvedTheme:r})}});const Ad=[{value:"light",label:"Light",icon:BS},{value:"dark",label:"Dark",icon:DS},{value:"system",label:"Auto",icon:IS}];function jP(){const{theme:t,setTheme:e}=xf(),r=Ad.find(i=>i.value===t)||Ad[0],s=r.icon;return g.jsxs(ks,{value:t,onValueChange:i=>e(i),children:[g.jsxs(ko,{className:"w-20 h-7 text-xs font-mono border-cyber-border-subtle bg-cyber-bg-tertiary/50 hover:border-cyber-neon-cyan/50 transition-colors",children:[g.jsx(s,{className:"w-3 h-3 mr-1 text-cyber-text-secondary"}),g.jsx(Ns,{children:r.label})]}),g.jsx(No,{children:Ad.map(({value:i,label:l,icon:u})=>g.jsx(Ro,{value:i,className:"text-xs font-mono",children:g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(u,{className:"w-3 h-3"}),l]})},i))})]})}function _P({onShowDisclaimer:t}){const{t:e}=Zt(),{t:r}=Zt("license"),s=jt(l=>l.status);Kk();const i=s==="running";return g.jsx("header",{className:"h-14 flex-shrink-0 glass-panel border-b border-cyber-border-subtle relative z-10",children:g.jsxs("div",{className:"h-full px-4 flex items-center justify-between",children:[g.jsxs("div",{className:"flex items-center gap-3",children:[g.jsx(SS,{className:"w-5 h-5 text-cyber-neon-cyan"}),g.jsx("span",{className:"font-mono font-bold text-cyber-text-primary tracking-wider text-sm",children:"MediaCrawler"}),g.jsxs("a",{href:"https://github.com/NanmiCoder/MediaCrawler",target:"_blank",rel:"noopener noreferrer",className:"flex items-center gap-1.5 px-2 py-1 rounded-md border border-cyber-border-subtle hover:border-cyber-neon-cyan hover:shadow-glow-cyan-sm transition-all bg-cyber-bg-tertiary",children:[g.jsx(OS,{className:"w-4 h-4 text-cyber-text-secondary"}),g.jsx("span",{className:"text-xs font-mono text-cyber-text-secondary",children:"Star"})]}),i&&g.jsx(qi,{variant:"running",className:"text-[10px]",children:e("status.active")}),i&&g.jsx("span",{className:"w-2 h-2 bg-cyber-neon-green rounded-full shadow-glow-green-sm animate-pulse-fast"})]}),g.jsxs("button",{onClick:t,className:"flex items-center gap-3 px-4 py-1.5 rounded-lg border border-cyber-neon-orange/50 bg-cyber-neon-orange/10 hover:bg-cyber-neon-orange/20 transition-all cursor-pointer",children:[g.jsx(Of,{className:"w-4 h-4 text-cyber-neon-orange flex-shrink-0"}),g.jsxs("div",{className:"flex items-center gap-4 text-xs font-mono",children:[g.jsxs("span",{className:"text-cyber-neon-orange",children:[g.jsx("span",{className:"text-cyber-neon-pink font-bold",children:"1."})," ",r("content.line1")]}),g.jsxs("span",{className:"text-cyber-neon-orange",children:[g.jsx("span",{className:"text-cyber-neon-pink font-bold",children:"2."})," ",r("content.line2")]})]})]}),g.jsxs("div",{className:"flex items-center gap-3",children:[g.jsx(jP,{}),g.jsx(PP,{}),g.jsxs("div",{className:"hidden lg:flex items-center gap-2 text-xs font-mono",children:[g.jsxs("span",{className:"text-cyber-text-muted",children:[e("sidebar.api"),":"]}),g.jsx("span",{className:"text-cyber-neon-green",children:"v1.0.0"}),g.jsxs("div",{className:"flex items-center gap-1.5",children:[g.jsx(VS,{className:"w-3 h-3 text-cyber-text-secondary"}),g.jsx("span",{className:"text-cyber-text-secondary",children:e("sidebar.local")}),g.jsx("span",{className:"status-dot status-dot-online"})]})]})]})]})})}const wy={info:{text:"text-cyber-neon-cyan",bg:"bg-cyber-neon-cyan/10",glow:"shadow-[0_0_3px_rgba(0,255,255,0.3)]"},success:{text:"text-cyber-neon-green",bg:"bg-cyber-neon-green/10",glow:"shadow-[0_0_3px_rgba(0,255,65,0.3)]"},warning:{text:"text-cyber-neon-orange",bg:"bg-cyber-neon-orange/10",glow:"shadow-[0_0_3px_rgba(255,152,0,0.3)]"},error:{text:"text-cyber-neon-pink",bg:"bg-cyber-neon-pink/10",glow:"shadow-[0_0_3px_rgba(255,0,128,0.3)]"},debug:{text:"text-[#8b949e]",bg:"bg-[#21262d]",glow:""}},AP={info:"DATA",success:"OK",warning:"WARN",error:"ERR",debug:"DBG"};function LP({log:t}){const e=wy[t.level]||wy.info;return g.jsxs("div",{className:"flex gap-2 text-xs leading-relaxed font-mono group hover:bg-[#21262d]/50 px-1 -mx-1 rounded transition-colors",children:[g.jsxs("span",{className:"text-[#8b949e] flex-shrink-0 opacity-60 group-hover:opacity-100 transition-opacity",children:["[",t.timestamp,"]"]}),g.jsxs("span",{className:Be("flex-shrink-0 w-14 px-1 rounded text-center",e.bg,e.text,e.glow),children:["[",AP[t.level],"]"]}),g.jsx("span",{className:Be("break-all",e.text),children:t.message})]})}var IP=Symbol.for("react.lazy"),rc=Sf[" use ".trim().toString()];function DP(t){return typeof t=="object"&&t!==null&&"then"in t}function h0(t){return t!=null&&typeof t=="object"&&"$$typeof"in t&&t.$$typeof===IP&&"_payload"in t&&DP(t._payload)}function p0(t){const e=FP(t),r=x.forwardRef((s,i)=>{let{children:l,...u}=s;h0(l)&&typeof rc=="function"&&(l=rc(l._payload));const d=x.Children.toArray(l),h=d.find($P);if(h){const p=h.props.children,y=d.map(v=>v===h?x.Children.count(p)>1?x.Children.only(null):x.isValidElement(p)?p.props.children:null:v);return g.jsx(e,{...u,ref:i,children:x.isValidElement(p)?x.cloneElement(p,void 0,y):null})}return g.jsx(e,{...u,ref:i,children:l})});return r.displayName=`${t}.Slot`,r}var MP=p0("Slot");function FP(t){const e=x.forwardRef((r,s)=>{let{children:i,...l}=r;if(h0(i)&&typeof rc=="function"&&(i=rc(i._payload)),x.isValidElement(i)){const u=BP(i),d=UP(l,i.props);return i.type!==x.Fragment&&(d.ref=s?Gs(s,u):u),x.cloneElement(i,d)}return x.Children.count(i)>1?x.Children.only(null):null});return e.displayName=`${t}.SlotClone`,e}var zP=Symbol("radix.slottable");function $P(t){return x.isValidElement(t)&&typeof t.type=="function"&&"__radixId"in t.type&&t.type.__radixId===zP}function UP(t,e){const r={...e};for(const s in e){const i=t[s],l=e[s];/^on[A-Z]/.test(s)?i&&l?r[s]=(...d)=>{const h=l(...d);return i(...d),h}:i&&(r[s]=i):s==="style"?r[s]={...i,...l}:s==="className"&&(r[s]=[i,l].filter(Boolean).join(" "))}return{...t,...r}}function BP(t){var s,i;let e=(s=Object.getOwnPropertyDescriptor(t.props,"ref"))==null?void 0:s.get,r=e&&"isReactWarning"in e&&e.isReactWarning;return r?t.ref:(e=(i=Object.getOwnPropertyDescriptor(t,"ref"))==null?void 0:i.get,r=e&&"isReactWarning"in e&&e.isReactWarning,r?t.props.ref:t.props.ref||t.ref)}const HP=_f("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyber-neon-cyan disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",{variants:{variant:{default:"bg-cyber-neon-cyan/20 text-cyber-neon-cyan border border-cyber-neon-cyan/50 hover:bg-cyber-neon-cyan/30 hover:shadow-glow-cyan-sm active:scale-95",destructive:"bg-cyber-neon-pink/20 text-cyber-neon-pink border border-cyber-neon-pink/50 hover:bg-cyber-neon-pink/30 hover:shadow-glow-pink-sm active:scale-95",outline:"border border-cyber-border-DEFAULT bg-transparent hover:bg-cyber-bg-tertiary hover:border-cyber-neon-cyan/50 hover:text-cyber-neon-cyan",secondary:"bg-cyber-neon-green/20 text-cyber-neon-green border border-cyber-neon-green/50 hover:bg-cyber-neon-green/30 hover:shadow-glow-green-sm active:scale-95",ghost:"hover:bg-cyber-bg-tertiary hover:text-cyber-neon-cyan",link:"text-cyber-neon-cyan underline-offset-4 hover:underline",glow:"bg-cyber-neon-cyan/20 text-cyber-neon-cyan border border-cyber-neon-cyan/50 shadow-glow-cyan-sm hover:shadow-glow-cyan hover:bg-cyber-neon-cyan/30 active:scale-95"},size:{default:"h-10 px-4 py-2",sm:"h-9 rounded-md px-3",lg:"h-12 rounded-md px-8 text-base",icon:"h-10 w-10"}},defaultVariants:{variant:"default",size:"default"}}),Ct=x.forwardRef(({className:t,variant:e,size:r,asChild:s=!1,...i},l)=>{const u=s?MP:"button";return g.jsx(u,{className:Be(HP({variant:e,size:r,className:t})),ref:l,...i})});Ct.displayName="Button";function VP(t,e){return x.useReducer((r,s)=>e[r][s]??r,t)}var io=t=>{const{present:e,children:r}=t,s=WP(e),i=typeof r=="function"?r({present:s.isPresent}):x.Children.only(r),l=Ve(s.ref,KP(i));return typeof r=="function"||s.isPresent?x.cloneElement(i,{ref:l}):null};io.displayName="Presence";function WP(t){const[e,r]=x.useState(),s=x.useRef(null),i=x.useRef(t),l=x.useRef("none"),u=t?"mounted":"unmounted",[d,h]=VP(u,{mounted:{UNMOUNT:"unmounted",ANIMATION_OUT:"unmountSuspended"},unmountSuspended:{MOUNT:"mounted",ANIMATION_END:"unmounted"},unmounted:{MOUNT:"mounted"}});return x.useEffect(()=>{const p=Ll(s.current);l.current=d==="mounted"?p:"none"},[d]),mt(()=>{const p=s.current,y=i.current;if(y!==t){const C=l.current,w=Ll(p);t?h("MOUNT"):w==="none"||(p==null?void 0:p.display)==="none"?h("UNMOUNT"):h(y&&C!==w?"ANIMATION_OUT":"UNMOUNT"),i.current=t}},[t,h]),mt(()=>{if(e){let p;const y=e.ownerDocument.defaultView??window,v=w=>{const b=Ll(s.current).includes(CSS.escape(w.animationName));if(w.target===e&&b&&(h("ANIMATION_END"),!i.current)){const k=e.style.animationFillMode;e.style.animationFillMode="forwards",p=y.setTimeout(()=>{e.style.animationFillMode==="forwards"&&(e.style.animationFillMode=k)})}},C=w=>{w.target===e&&(l.current=Ll(s.current))};return e.addEventListener("animationstart",C),e.addEventListener("animationcancel",v),e.addEventListener("animationend",v),()=>{y.clearTimeout(p),e.removeEventListener("animationstart",C),e.removeEventListener("animationcancel",v),e.removeEventListener("animationend",v)}}else h("ANIMATION_END")},[e,h]),{isPresent:["mounted","unmountSuspended"].includes(d),ref:x.useCallback(p=>{s.current=p?getComputedStyle(p):null,r(p)},[])}}function Ll(t){return(t==null?void 0:t.animationName)||"none"}function KP(t){var s,i;let e=(s=Object.getOwnPropertyDescriptor(t.props,"ref"))==null?void 0:s.get,r=e&&"isReactWarning"in e&&e.isReactWarning;return r?t.ref:(e=(i=Object.getOwnPropertyDescriptor(t,"ref"))==null?void 0:i.get,r=e&&"isReactWarning"in e&&e.isReactWarning,r?t.props.ref:t.props.ref||t.ref)}function qP(t){const e=QP(t),r=x.forwardRef((s,i)=>{const{children:l,...u}=s,d=x.Children.toArray(l),h=d.find(GP);if(h){const p=h.props.children,y=d.map(v=>v===h?x.Children.count(p)>1?x.Children.only(null):x.isValidElement(p)?p.props.children:null:v);return g.jsx(e,{...u,ref:i,children:x.isValidElement(p)?x.cloneElement(p,void 0,y):null})}return g.jsx(e,{...u,ref:i,children:l})});return r.displayName=`${t}.Slot`,r}function QP(t){const e=x.forwardRef((r,s)=>{const{children:i,...l}=r;if(x.isValidElement(i)){const u=JP(i),d=XP(l,i.props);return i.type!==x.Fragment&&(d.ref=s?Gs(s,u):u),x.cloneElement(i,d)}return x.Children.count(i)>1?x.Children.only(null):null});return e.displayName=`${t}.SlotClone`,e}var YP=Symbol("radix.slottable");function GP(t){return x.isValidElement(t)&&typeof t.type=="function"&&"__radixId"in t.type&&t.type.__radixId===YP}function XP(t,e){const r={...e};for(const s in e){const i=t[s],l=e[s];/^on[A-Z]/.test(s)?i&&l?r[s]=(...d)=>{const h=l(...d);return i(...d),h}:i&&(r[s]=i):s==="style"?r[s]={...i,...l}:s==="className"&&(r[s]=[i,l].filter(Boolean).join(" "))}return{...t,...r}}function JP(t){var s,i;let e=(s=Object.getOwnPropertyDescriptor(t.props,"ref"))==null?void 0:s.get,r=e&&"isReactWarning"in e&&e.isReactWarning;return r?t.ref:(e=(i=Object.getOwnPropertyDescriptor(t,"ref"))==null?void 0:i.get,r=e&&"isReactWarning"in e&&e.isReactWarning,r?t.props.ref:t.props.ref||t.ref)}var Cc="Dialog",[m0]=aa(Cc),[ZP,Tn]=m0(Cc),g0=t=>{const{__scopeDialog:e,children:r,open:s,defaultOpen:i,onOpenChange:l,modal:u=!0}=t,d=x.useRef(null),h=x.useRef(null),[p,y]=ff({prop:s,defaultProp:i??!1,onChange:l,caller:Cc});return g.jsx(ZP,{scope:e,triggerRef:d,contentRef:h,contentId:Ps(),titleId:Ps(),descriptionId:Ps(),open:p,onOpenChange:y,onOpenToggle:x.useCallback(()=>y(v=>!v),[y]),modal:u,children:r})};g0.displayName=Cc;var y0="DialogTrigger",v0=x.forwardRef((t,e)=>{const{__scopeDialog:r,...s}=t,i=Tn(y0,r),l=Ve(e,i.triggerRef);return g.jsx(ze.button,{type:"button","aria-haspopup":"dialog","aria-expanded":i.open,"aria-controls":i.contentId,"data-state":th(i.open),...s,ref:l,onClick:Me(t.onClick,i.onOpenToggle)})});v0.displayName=y0;var Zf="DialogPortal",[eT,x0]=m0(Zf,{forceMount:void 0}),w0=t=>{const{__scopeDialog:e,forceMount:r,children:s,container:i}=t,l=Tn(Zf,e);return g.jsx(eT,{scope:e,forceMount:r,children:x.Children.map(s,u=>g.jsx(io,{present:r||l.open,children:g.jsx(Yf,{asChild:!0,container:i,children:u})}))})};w0.displayName=Zf;var oc="DialogOverlay",b0=x.forwardRef((t,e)=>{const r=x0(oc,t.__scopeDialog),{forceMount:s=r.forceMount,...i}=t,l=Tn(oc,t.__scopeDialog);return l.modal?g.jsx(io,{present:s||l.open,children:g.jsx(nT,{...i,ref:e})}):null});b0.displayName=oc;var tT=qP("DialogOverlay.RemoveScroll"),nT=x.forwardRef((t,e)=>{const{__scopeDialog:r,...s}=t,i=Tn(oc,r);return g.jsx(Gf,{as:tT,allowPinchZoom:!0,shards:[i.contentRef],children:g.jsx(ze.div,{"data-state":th(i.open),...s,ref:e,style:{pointerEvents:"auto",...s.style}})})}),Wo="DialogContent",S0=x.forwardRef((t,e)=>{const r=x0(Wo,t.__scopeDialog),{forceMount:s=r.forceMount,...i}=t,l=Tn(Wo,t.__scopeDialog);return g.jsx(io,{present:s||l.open,children:l.modal?g.jsx(rT,{...i,ref:e}):g.jsx(oT,{...i,ref:e})})});S0.displayName=Wo;var rT=x.forwardRef((t,e)=>{const r=Tn(Wo,t.__scopeDialog),s=x.useRef(null),i=Ve(e,r.contentRef,s);return x.useEffect(()=>{const l=s.current;if(l)return vx(l)},[]),g.jsx(C0,{...t,ref:i,trapFocus:r.open,disableOutsidePointerEvents:!0,onCloseAutoFocus:Me(t.onCloseAutoFocus,l=>{var u;l.preventDefault(),(u=r.triggerRef.current)==null||u.focus()}),onPointerDownOutside:Me(t.onPointerDownOutside,l=>{const u=l.detail.originalEvent,d=u.button===0&&u.ctrlKey===!0;(u.button===2||d)&&l.preventDefault()}),onFocusOutside:Me(t.onFocusOutside,l=>l.preventDefault())})}),oT=x.forwardRef((t,e)=>{const r=Tn(Wo,t.__scopeDialog),s=x.useRef(!1),i=x.useRef(!1);return g.jsx(C0,{...t,ref:e,trapFocus:!1,disableOutsidePointerEvents:!1,onCloseAutoFocus:l=>{var u,d;(u=t.onCloseAutoFocus)==null||u.call(t,l),l.defaultPrevented||(s.current||(d=r.triggerRef.current)==null||d.focus(),l.preventDefault()),s.current=!1,i.current=!1},onInteractOutside:l=>{var h,p;(h=t.onInteractOutside)==null||h.call(t,l),l.defaultPrevented||(s.current=!0,l.detail.originalEvent.type==="pointerdown"&&(i.current=!0));const u=l.target;((p=r.triggerRef.current)==null?void 0:p.contains(u))&&l.preventDefault(),l.detail.originalEvent.type==="focusin"&&i.current&&l.preventDefault()}})}),C0=x.forwardRef((t,e)=>{const{__scopeDialog:r,trapFocus:s,onOpenAutoFocus:i,onCloseAutoFocus:l,...u}=t,d=Tn(Wo,r),h=x.useRef(null),p=Ve(e,h);return Kv(),g.jsxs(g.Fragment,{children:[g.jsx($f,{asChild:!0,loop:!0,trapped:s,onMountAutoFocus:i,onUnmountAutoFocus:l,children:g.jsx(zf,{role:"dialog",id:d.contentId,"aria-describedby":d.descriptionId,"aria-labelledby":d.titleId,"data-state":th(d.open),...u,ref:p,onDismiss:()=>d.onOpenChange(!1)})}),g.jsxs(g.Fragment,{children:[g.jsx(sT,{titleId:d.titleId}),g.jsx(aT,{contentRef:h,descriptionId:d.descriptionId})]})]})}),eh="DialogTitle",E0=x.forwardRef((t,e)=>{const{__scopeDialog:r,...s}=t,i=Tn(eh,r);return g.jsx(ze.h2,{id:i.titleId,...s,ref:e})});E0.displayName=eh;var k0="DialogDescription",N0=x.forwardRef((t,e)=>{const{__scopeDialog:r,...s}=t,i=Tn(k0,r);return g.jsx(ze.p,{id:i.descriptionId,...s,ref:e})});N0.displayName=k0;var R0="DialogClose",P0=x.forwardRef((t,e)=>{const{__scopeDialog:r,...s}=t,i=Tn(R0,r);return g.jsx(ze.button,{type:"button",...s,ref:e,onClick:Me(t.onClick,()=>i.onOpenChange(!1))})});P0.displayName=R0;function th(t){return t?"open":"closed"}var T0="DialogTitleWarning",[y_,O0]=Xk(T0,{contentName:Wo,titleName:eh,docsSlug:"dialog"}),sT=({titleId:t})=>{const e=O0(T0),r=`\`${e.contentName}\` requires a \`${e.titleName}\` for the component to be accessible for screen reader users. If you want to hide the \`${e.titleName}\`, you can wrap it with our VisuallyHidden component. For more information, see https://radix-ui.com/primitives/docs/components/${e.docsSlug}`;return x.useEffect(()=>{t&&(document.getElementById(t)||console.error(r))},[r,t]),null},iT="DialogDescriptionWarning",aT=({contentRef:t,descriptionId:e})=>{const s=`Warning: Missing \`Description\` or \`aria-describedby={undefined}\` for {${O0(iT).contentName}}.`;return x.useEffect(()=>{var l;const i=(l=t.current)==null?void 0:l.getAttribute("aria-describedby");e&&i&&(document.getElementById(e)||console.warn(s))},[s,t,e]),null},lT=g0,cT=v0,uT=w0,j0=b0,_0=S0,A0=E0,L0=N0,dT=P0;const I0=lT,fT=cT,hT=uT,D0=x.forwardRef(({className:t,...e},r)=>g.jsx(j0,{ref:r,className:Be("fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",t),...e}));D0.displayName=j0.displayName;const nh=x.forwardRef(({className:t,children:e,...r},s)=>g.jsxs(hT,{children:[g.jsx(D0,{}),g.jsxs(_0,{ref:s,className:Be("fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 glass-panel-dark float-panel rounded-lg p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",t),...r,children:[e,g.jsxs(dT,{className:"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 hover:text-cyber-neon-cyan focus:outline-none focus:ring-2 focus:ring-cyber-neon-cyan focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",children:[g.jsx(jf,{className:"h-4 w-4"}),g.jsx("span",{className:"sr-only",children:"Close"})]})]})]}));nh.displayName=_0.displayName;const rh=({className:t,...e})=>g.jsx("div",{className:Be("flex flex-col space-y-1.5 text-center sm:text-left",t),...e});rh.displayName="DialogHeader";const oh=x.forwardRef(({className:t,...e},r)=>g.jsx(A0,{ref:r,className:Be("text-lg font-semibold leading-none tracking-tight text-cyber-neon-cyan font-mono",t),...e}));oh.displayName=A0.displayName;const pT=x.forwardRef(({className:t,...e},r)=>g.jsx(L0,{ref:r,className:Be("text-sm text-cyber-text-secondary",t),...e}));pT.displayName=L0.displayName;const M0=x.forwardRef(({className:t,...e},r)=>g.jsx("div",{ref:r,className:Be("rounded-lg border border-cyber-border-DEFAULT bg-cyber-bg-tertiary text-cyber-text-primary shadow-cyber-card transition-all hover:border-cyber-neon-cyan/30",t),...e}));M0.displayName="Card";const mT=x.forwardRef(({className:t,...e},r)=>g.jsx("div",{ref:r,className:Be("flex flex-col space-y-1.5 p-6",t),...e}));mT.displayName="CardHeader";const gT=x.forwardRef(({className:t,...e},r)=>g.jsx("h3",{ref:r,className:Be("text-2xl font-semibold leading-none tracking-tight text-cyber-neon-cyan",t),...e}));gT.displayName="CardTitle";const yT=x.forwardRef(({className:t,...e},r)=>g.jsx("p",{ref:r,className:Be("text-sm text-cyber-text-muted",t),...e}));yT.displayName="CardDescription";const F0=x.forwardRef(({className:t,...e},r)=>g.jsx("div",{ref:r,className:Be("p-6 pt-0",t),...e}));F0.displayName="CardContent";const vT=x.forwardRef(({className:t,...e},r)=>g.jsx("div",{ref:r,className:Be("flex items-center p-6 pt-0",t),...e}));vT.displayName="CardFooter";function xT(t,e){return x.useReducer((r,s)=>e[r][s]??r,t)}var sh="ScrollArea",[z0]=aa(sh),[wT,un]=z0(sh),$0=x.forwardRef((t,e)=>{const{__scopeScrollArea:r,type:s="hover",dir:i,scrollHideDelay:l=600,...u}=t,[d,h]=x.useState(null),[p,y]=x.useState(null),[v,C]=x.useState(null),[w,E]=x.useState(null),[b,k]=x.useState(null),[T,j]=x.useState(0),[_,A]=x.useState(0),[F,V]=x.useState(!1),[B,te]=x.useState(!1),G=Ve(e,le=>h(le)),W=Hv(i);return g.jsx(wT,{scope:r,type:s,dir:W,scrollHideDelay:l,scrollArea:d,viewport:p,onViewportChange:y,content:v,onContentChange:C,scrollbarX:w,onScrollbarXChange:E,scrollbarXEnabled:F,onScrollbarXEnabledChange:V,scrollbarY:b,onScrollbarYChange:k,scrollbarYEnabled:B,onScrollbarYEnabledChange:te,onCornerWidthChange:j,onCornerHeightChange:A,children:g.jsx(ze.div,{dir:W,...u,ref:G,style:{position:"relative","--radix-scroll-area-corner-width":T+"px","--radix-scroll-area-corner-height":_+"px",...t.style}})})});$0.displayName=sh;var U0="ScrollAreaViewport",B0=x.forwardRef((t,e)=>{const{__scopeScrollArea:r,children:s,nonce:i,...l}=t,u=un(U0,r),d=x.useRef(null),h=Ve(e,d,u.onViewportChange);return g.jsxs(g.Fragment,{children:[g.jsx("style",{dangerouslySetInnerHTML:{__html:"[data-radix-scroll-area-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-scroll-area-viewport]::-webkit-scrollbar{display:none}"},nonce:i}),g.jsx(ze.div,{"data-radix-scroll-area-viewport":"",...l,ref:h,style:{overflowX:u.scrollbarXEnabled?"scroll":"hidden",overflowY:u.scrollbarYEnabled?"scroll":"hidden",...t.style},children:g.jsx("div",{ref:u.onContentChange,style:{minWidth:"100%",display:"table"},children:s})})]})});B0.displayName=U0;var er="ScrollAreaScrollbar",ih=x.forwardRef((t,e)=>{const{forceMount:r,...s}=t,i=un(er,t.__scopeScrollArea),{onScrollbarXEnabledChange:l,onScrollbarYEnabledChange:u}=i,d=t.orientation==="horizontal";return x.useEffect(()=>(d?l(!0):u(!0),()=>{d?l(!1):u(!1)}),[d,l,u]),i.type==="hover"?g.jsx(bT,{...s,ref:e,forceMount:r}):i.type==="scroll"?g.jsx(ST,{...s,ref:e,forceMount:r}):i.type==="auto"?g.jsx(H0,{...s,ref:e,forceMount:r}):i.type==="always"?g.jsx(ah,{...s,ref:e}):null});ih.displayName=er;var bT=x.forwardRef((t,e)=>{const{forceMount:r,...s}=t,i=un(er,t.__scopeScrollArea),[l,u]=x.useState(!1);return x.useEffect(()=>{const d=i.scrollArea;let h=0;if(d){const p=()=>{window.clearTimeout(h),u(!0)},y=()=>{h=window.setTimeout(()=>u(!1),i.scrollHideDelay)};return d.addEventListener("pointerenter",p),d.addEventListener("pointerleave",y),()=>{window.clearTimeout(h),d.removeEventListener("pointerenter",p),d.removeEventListener("pointerleave",y)}}},[i.scrollArea,i.scrollHideDelay]),g.jsx(io,{present:r||l,children:g.jsx(H0,{"data-state":l?"visible":"hidden",...s,ref:e})})}),ST=x.forwardRef((t,e)=>{const{forceMount:r,...s}=t,i=un(er,t.__scopeScrollArea),l=t.orientation==="horizontal",u=kc(()=>h("SCROLL_END"),100),[d,h]=xT("hidden",{hidden:{SCROLL:"scrolling"},scrolling:{SCROLL_END:"idle",POINTER_ENTER:"interacting"},interacting:{SCROLL:"interacting",POINTER_LEAVE:"idle"},idle:{HIDE:"hidden",SCROLL:"scrolling",POINTER_ENTER:"interacting"}});return x.useEffect(()=>{if(d==="idle"){const p=window.setTimeout(()=>h("HIDE"),i.scrollHideDelay);return()=>window.clearTimeout(p)}},[d,i.scrollHideDelay,h]),x.useEffect(()=>{const p=i.viewport,y=l?"scrollLeft":"scrollTop";if(p){let v=p[y];const C=()=>{const w=p[y];v!==w&&(h("SCROLL"),u()),v=w};return p.addEventListener("scroll",C),()=>p.removeEventListener("scroll",C)}},[i.viewport,l,h,u]),g.jsx(io,{present:r||d!=="hidden",children:g.jsx(ah,{"data-state":d==="hidden"?"hidden":"visible",...s,ref:e,onPointerEnter:Me(t.onPointerEnter,()=>h("POINTER_ENTER")),onPointerLeave:Me(t.onPointerLeave,()=>h("POINTER_LEAVE"))})})}),H0=x.forwardRef((t,e)=>{const r=un(er,t.__scopeScrollArea),{forceMount:s,...i}=t,[l,u]=x.useState(!1),d=t.orientation==="horizontal",h=kc(()=>{if(r.viewport){const p=r.viewport.offsetWidth{const{orientation:r="vertical",...s}=t,i=un(er,t.__scopeScrollArea),l=x.useRef(null),u=x.useRef(0),[d,h]=x.useState({content:0,viewport:0,scrollbar:{size:0,paddingStart:0,paddingEnd:0}}),p=Q0(d.viewport,d.content),y={...s,sizes:d,onSizesChange:h,hasThumb:p>0&&p<1,onThumbChange:C=>l.current=C,onThumbPointerUp:()=>u.current=0,onThumbPointerDown:C=>u.current=C};function v(C,w){return PT(C,u.current,d,w)}return r==="horizontal"?g.jsx(CT,{...y,ref:e,onThumbPositionChange:()=>{if(i.viewport&&l.current){const C=i.viewport.scrollLeft,w=by(C,d,i.dir);l.current.style.transform=`translate3d(${w}px, 0, 0)`}},onWheelScroll:C=>{i.viewport&&(i.viewport.scrollLeft=C)},onDragScroll:C=>{i.viewport&&(i.viewport.scrollLeft=v(C,i.dir))}}):r==="vertical"?g.jsx(ET,{...y,ref:e,onThumbPositionChange:()=>{if(i.viewport&&l.current){const C=i.viewport.scrollTop,w=by(C,d);l.current.style.transform=`translate3d(0, ${w}px, 0)`}},onWheelScroll:C=>{i.viewport&&(i.viewport.scrollTop=C)},onDragScroll:C=>{i.viewport&&(i.viewport.scrollTop=v(C))}}):null}),CT=x.forwardRef((t,e)=>{const{sizes:r,onSizesChange:s,...i}=t,l=un(er,t.__scopeScrollArea),[u,d]=x.useState(),h=x.useRef(null),p=Ve(e,h,l.onScrollbarXChange);return x.useEffect(()=>{h.current&&d(getComputedStyle(h.current))},[h]),g.jsx(W0,{"data-orientation":"horizontal",...i,ref:p,sizes:r,style:{bottom:0,left:l.dir==="rtl"?"var(--radix-scroll-area-corner-width)":0,right:l.dir==="ltr"?"var(--radix-scroll-area-corner-width)":0,"--radix-scroll-area-thumb-width":Ec(r)+"px",...t.style},onThumbPointerDown:y=>t.onThumbPointerDown(y.x),onDragScroll:y=>t.onDragScroll(y.x),onWheelScroll:(y,v)=>{if(l.viewport){const C=l.viewport.scrollLeft+y.deltaX;t.onWheelScroll(C),G0(C,v)&&y.preventDefault()}},onResize:()=>{h.current&&l.viewport&&u&&s({content:l.viewport.scrollWidth,viewport:l.viewport.offsetWidth,scrollbar:{size:h.current.clientWidth,paddingStart:ic(u.paddingLeft),paddingEnd:ic(u.paddingRight)}})}})}),ET=x.forwardRef((t,e)=>{const{sizes:r,onSizesChange:s,...i}=t,l=un(er,t.__scopeScrollArea),[u,d]=x.useState(),h=x.useRef(null),p=Ve(e,h,l.onScrollbarYChange);return x.useEffect(()=>{h.current&&d(getComputedStyle(h.current))},[h]),g.jsx(W0,{"data-orientation":"vertical",...i,ref:p,sizes:r,style:{top:0,right:l.dir==="ltr"?0:void 0,left:l.dir==="rtl"?0:void 0,bottom:"var(--radix-scroll-area-corner-height)","--radix-scroll-area-thumb-height":Ec(r)+"px",...t.style},onThumbPointerDown:y=>t.onThumbPointerDown(y.y),onDragScroll:y=>t.onDragScroll(y.y),onWheelScroll:(y,v)=>{if(l.viewport){const C=l.viewport.scrollTop+y.deltaY;t.onWheelScroll(C),G0(C,v)&&y.preventDefault()}},onResize:()=>{h.current&&l.viewport&&u&&s({content:l.viewport.scrollHeight,viewport:l.viewport.offsetHeight,scrollbar:{size:h.current.clientHeight,paddingStart:ic(u.paddingTop),paddingEnd:ic(u.paddingBottom)}})}})}),[kT,V0]=z0(er),W0=x.forwardRef((t,e)=>{const{__scopeScrollArea:r,sizes:s,hasThumb:i,onThumbChange:l,onThumbPointerUp:u,onThumbPointerDown:d,onThumbPositionChange:h,onDragScroll:p,onWheelScroll:y,onResize:v,...C}=t,w=un(er,r),[E,b]=x.useState(null),k=Ve(e,G=>b(G)),T=x.useRef(null),j=x.useRef(""),_=w.viewport,A=s.content-s.viewport,F=Vt(y),V=Vt(h),B=kc(v,10);function te(G){if(T.current){const W=G.clientX-T.current.left,le=G.clientY-T.current.top;p({x:W,y:le})}}return x.useEffect(()=>{const G=W=>{const le=W.target;(E==null?void 0:E.contains(le))&&F(W,A)};return document.addEventListener("wheel",G,{passive:!1}),()=>document.removeEventListener("wheel",G,{passive:!1})},[_,E,A,F]),x.useEffect(V,[s,V]),Ws(E,B),Ws(w.content,B),g.jsx(kT,{scope:r,scrollbar:E,hasThumb:i,onThumbChange:Vt(l),onThumbPointerUp:Vt(u),onThumbPositionChange:V,onThumbPointerDown:Vt(d),children:g.jsx(ze.div,{...C,ref:k,style:{position:"absolute",...C.style},onPointerDown:Me(t.onPointerDown,G=>{G.button===0&&(G.target.setPointerCapture(G.pointerId),T.current=E.getBoundingClientRect(),j.current=document.body.style.webkitUserSelect,document.body.style.webkitUserSelect="none",w.viewport&&(w.viewport.style.scrollBehavior="auto"),te(G))}),onPointerMove:Me(t.onPointerMove,te),onPointerUp:Me(t.onPointerUp,G=>{const W=G.target;W.hasPointerCapture(G.pointerId)&&W.releasePointerCapture(G.pointerId),document.body.style.webkitUserSelect=j.current,w.viewport&&(w.viewport.style.scrollBehavior=""),T.current=null})})})}),sc="ScrollAreaThumb",K0=x.forwardRef((t,e)=>{const{forceMount:r,...s}=t,i=V0(sc,t.__scopeScrollArea);return g.jsx(io,{present:r||i.hasThumb,children:g.jsx(NT,{ref:e,...s})})}),NT=x.forwardRef((t,e)=>{const{__scopeScrollArea:r,style:s,...i}=t,l=un(sc,r),u=V0(sc,r),{onThumbPositionChange:d}=u,h=Ve(e,v=>u.onThumbChange(v)),p=x.useRef(void 0),y=kc(()=>{p.current&&(p.current(),p.current=void 0)},100);return x.useEffect(()=>{const v=l.viewport;if(v){const C=()=>{if(y(),!p.current){const w=TT(v,d);p.current=w,d()}};return d(),v.addEventListener("scroll",C),()=>v.removeEventListener("scroll",C)}},[l.viewport,y,d]),g.jsx(ze.div,{"data-state":u.hasThumb?"visible":"hidden",...i,ref:h,style:{width:"var(--radix-scroll-area-thumb-width)",height:"var(--radix-scroll-area-thumb-height)",...s},onPointerDownCapture:Me(t.onPointerDownCapture,v=>{const w=v.target.getBoundingClientRect(),E=v.clientX-w.left,b=v.clientY-w.top;u.onThumbPointerDown({x:E,y:b})}),onPointerUp:Me(t.onPointerUp,u.onThumbPointerUp)})});K0.displayName=sc;var lh="ScrollAreaCorner",q0=x.forwardRef((t,e)=>{const r=un(lh,t.__scopeScrollArea),s=!!(r.scrollbarX&&r.scrollbarY);return r.type!=="scroll"&&s?g.jsx(RT,{...t,ref:e}):null});q0.displayName=lh;var RT=x.forwardRef((t,e)=>{const{__scopeScrollArea:r,...s}=t,i=un(lh,r),[l,u]=x.useState(0),[d,h]=x.useState(0),p=!!(l&&d);return Ws(i.scrollbarX,()=>{var v;const y=((v=i.scrollbarX)==null?void 0:v.offsetHeight)||0;i.onCornerHeightChange(y),h(y)}),Ws(i.scrollbarY,()=>{var v;const y=((v=i.scrollbarY)==null?void 0:v.offsetWidth)||0;i.onCornerWidthChange(y),u(y)}),p?g.jsx(ze.div,{...s,ref:e,style:{width:l,height:d,position:"absolute",right:i.dir==="ltr"?0:void 0,left:i.dir==="rtl"?0:void 0,bottom:0,...t.style}}):null});function ic(t){return t?parseInt(t,10):0}function Q0(t,e){const r=t/e;return isNaN(r)?0:r}function Ec(t){const e=Q0(t.viewport,t.content),r=t.scrollbar.paddingStart+t.scrollbar.paddingEnd,s=(t.scrollbar.size-r)*e;return Math.max(s,18)}function PT(t,e,r,s="ltr"){const i=Ec(r),l=i/2,u=e||l,d=i-u,h=r.scrollbar.paddingStart+u,p=r.scrollbar.size-r.scrollbar.paddingEnd-d,y=r.content-r.viewport,v=s==="ltr"?[0,y]:[y*-1,0];return Y0([h,p],v)(t)}function by(t,e,r="ltr"){const s=Ec(e),i=e.scrollbar.paddingStart+e.scrollbar.paddingEnd,l=e.scrollbar.size-i,u=e.content-e.viewport,d=l-s,h=r==="ltr"?[0,u]:[u*-1,0],p=af(t,h);return Y0([0,u],[0,d])(p)}function Y0(t,e){return r=>{if(t[0]===t[1]||e[0]===e[1])return e[0];const s=(e[1]-e[0])/(t[1]-t[0]);return e[0]+s*(r-t[0])}}function G0(t,e){return t>0&&t{})=>{let r={left:t.scrollLeft,top:t.scrollTop},s=0;return(function i(){const l={left:t.scrollLeft,top:t.scrollTop},u=r.left!==l.left,d=r.top!==l.top;(u||d)&&e(),r=l,s=window.requestAnimationFrame(i)})(),()=>window.cancelAnimationFrame(s)};function kc(t,e){const r=Vt(t),s=x.useRef(0);return x.useEffect(()=>()=>window.clearTimeout(s.current),[]),x.useCallback(()=>{window.clearTimeout(s.current),s.current=window.setTimeout(r,e)},[r,e])}function Ws(t,e){const r=Vt(e);mt(()=>{let s=0;if(t){const i=new ResizeObserver(()=>{cancelAnimationFrame(s),s=window.requestAnimationFrame(r)});return i.observe(t),()=>{window.cancelAnimationFrame(s),i.unobserve(t)}}},[t,r])}var X0=$0,OT=B0,jT=q0;const J0=x.forwardRef(({className:t,children:e,...r},s)=>g.jsxs(X0,{ref:s,className:Be("relative overflow-hidden",t),...r,children:[g.jsx(OT,{className:"h-full w-full rounded-[inherit]",children:e}),g.jsx(Z0,{}),g.jsx(jT,{})]}));J0.displayName=X0.displayName;const Z0=x.forwardRef(({className:t,orientation:e="vertical",...r},s)=>g.jsx(ih,{ref:s,orientation:e,className:Be("flex touch-none select-none transition-colors",e==="vertical"&&"h-full w-2.5 border-l border-l-transparent p-[1px]",e==="horizontal"&&"h-2.5 flex-col border-t border-t-transparent p-[1px]",t),...r,children:g.jsx(K0,{className:"relative flex-1 rounded-full bg-border"})}));Z0.displayName=ih.displayName;const Nc=x.forwardRef(({className:t,type:e,...r},s)=>g.jsx("input",{type:e,className:Be("flex h-9 w-full rounded-md border border-cyber-border-DEFAULT bg-cyber-bg-tertiary px-3 py-2 text-sm font-mono text-cyber-text-primary ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-cyber-text-muted focus-visible:outline-none focus-visible:border-cyber-neon-cyan focus-visible:shadow-[0_0_10px_rgb(var(--cyber-neon-cyan)/0.2)] disabled:cursor-not-allowed disabled:opacity-50 transition-all",t),ref:s,...r}));Nc.displayName="Input";function _T({data:t,columns:e}){const{t:r}=Zt("data"),[s,i]=x.useState(""),l=x.useMemo(()=>e&&e.length>0?e:t.length===0?[]:Object.keys(t[0]),[t,e]),u=x.useMemo(()=>{if(!s)return t;const h=s.toLowerCase();return t.filter(p=>Object.values(p).some(y=>String(y??"").toLowerCase().includes(h)))},[t,s]),d=h=>h==null?"-":typeof h=="object"?JSON.stringify(h):String(h);return g.jsxs("div",{className:"h-full flex flex-col",children:[g.jsx("div",{className:"flex-shrink-0 mb-3",children:g.jsxs("div",{className:"relative",children:[g.jsx(FS,{className:"absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-cyber-text-muted"}),g.jsx(Nc,{placeholder:r("preview.searchPlaceholder"),value:s,onChange:h=>i(h.target.value),className:"pl-9 h-9 text-xs font-mono"})]})}),g.jsx(J0,{className:"flex-1 border border-cyber-border-DEFAULT rounded-lg",children:g.jsx("div",{className:"min-w-full",children:g.jsxs("table",{className:"w-full text-xs font-mono",children:[g.jsx("thead",{className:"sticky top-0 bg-cyber-bg-tertiary border-b border-cyber-border-DEFAULT",children:g.jsxs("tr",{children:[g.jsx("th",{className:"px-3 py-2 text-left text-cyber-text-muted w-12",children:"#"}),l.map(h=>g.jsx("th",{className:"px-3 py-2 text-left text-cyber-neon-cyan whitespace-nowrap",children:h},h))]})}),g.jsx("tbody",{children:u.map((h,p)=>g.jsxs("tr",{className:"border-b border-cyber-border-subtle hover:bg-cyber-bg-elevated/50 transition-colors",children:[g.jsx("td",{className:"px-3 py-2 text-cyber-text-muted",children:p+1}),l.map(y=>g.jsx("td",{className:"px-3 py-2 text-cyber-text-primary max-w-xs truncate",title:d(h[y]),children:d(h[y])},y))]},p))})]})})}),s&&g.jsx("div",{className:"flex-shrink-0 mt-2 text-xs text-cyber-text-muted font-mono",children:r("preview.showing",{filtered:u.length,total:t.length})})]})}function AT({file:t,open:e,onOpenChange:r}){const{t:s}=Zt("data"),{data:i,isLoading:l,error:u}=ta({queryKey:["filePreview",t.path],queryFn:async()=>{const{data:h}=await Xl.getFileContent(t.path,100);return h},enabled:e}),d=()=>{const h=Xl.getDownloadUrl(t.path);window.open(h,"_blank")};return g.jsx(I0,{open:e,onOpenChange:r,children:g.jsxs(nh,{className:"max-w-6xl max-h-[85vh] flex flex-col",children:[g.jsx(rh,{className:"flex-shrink-0",children:g.jsxs("div",{className:"flex items-center justify-between",children:[g.jsxs("div",{className:"flex items-center gap-3",children:[g.jsx(oh,{className:"font-mono text-cyber-neon-cyan",children:t.name}),g.jsxs(qi,{variant:"outline",className:"font-mono text-[10px]",children:[".",t.type.toUpperCase()]}),i&&g.jsx(qi,{variant:"default",className:"font-mono text-[10px]",children:s("preview.records",{count:i.total})})]}),g.jsxs(Ct,{variant:"outline",size:"sm",onClick:d,className:"font-mono text-xs",children:[g.jsx(nv,{className:"w-3 h-3 mr-1"}),s("preview.download")]})]})}),g.jsx("div",{className:"flex-1 overflow-hidden min-h-0 mt-4",children:l?g.jsx("div",{className:"flex items-center justify-center h-full",children:g.jsx("div",{className:"text-cyber-text-muted font-mono animate-pulse",children:s("preview.loading")})}):u?g.jsx("div",{className:"flex items-center justify-center h-full",children:g.jsx("div",{className:"text-cyber-neon-pink font-mono",children:s("preview.error")})}):i?g.jsx(_T,{data:i.data,columns:i.columns}):null})]})})}const LT={json:RS,csv:pd,xlsx:pd,xls:pd},IT={json:{icon:"text-cyber-neon-yellow",border:"hover:border-cyber-neon-yellow/50",badge:"border-cyber-neon-yellow/30 bg-cyber-neon-yellow/10 text-cyber-neon-yellow"},csv:{icon:"text-cyber-neon-green",border:"hover:border-cyber-neon-green/50",badge:"border-cyber-neon-green/30 bg-cyber-neon-green/10 text-cyber-neon-green"},xlsx:{icon:"text-cyber-neon-cyan",border:"hover:border-cyber-neon-cyan/50",badge:"border-cyber-neon-cyan/30 bg-cyber-neon-cyan/10 text-cyber-neon-cyan"},xls:{icon:"text-cyber-neon-cyan",border:"hover:border-cyber-neon-cyan/50",badge:"border-cyber-neon-cyan/30 bg-cyber-neon-cyan/10 text-cyber-neon-cyan"}};function DT({file:t}){const{t:e}=Zt("data"),[r,s]=x.useState(!1),i=LT[t.type]||PS,l=IT[t.type]||{icon:"text-cyber-text-muted",border:"hover:border-cyber-neon-cyan/50",badge:"border-cyber-border-DEFAULT bg-cyber-bg-tertiary text-cyber-text-secondary"},u=["json","csv","xlsx","xls"].includes(t.type.toLowerCase()),d=()=>{const h=Xl.getDownloadUrl(t.path);window.open(h,"_blank")};return g.jsxs(g.Fragment,{children:[g.jsxs(M0,{className:`relative overflow-hidden card-scan group transition-all ${l.border} hover:shadow-[0_0_15px_rgb(var(--cyber-neon-cyan)/0.15)]`,children:[g.jsx("div",{className:"absolute inset-0 bg-gradient-to-r from-transparent via-cyber-neon-cyan/5 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-700 pointer-events-none"}),g.jsxs(F0,{className:"p-4 relative",children:[g.jsxs("div",{className:"flex items-start gap-3",children:[g.jsx("div",{className:`p-2 rounded bg-cyber-bg-panel border border-cyber-border-DEFAULT ${l.icon}`,children:g.jsx(i,{className:"w-6 h-6"})}),g.jsxs("div",{className:"flex-1 min-w-0",children:[g.jsx("h3",{className:"font-mono font-medium text-sm text-cyber-text-primary truncate",title:t.name,children:t.name}),g.jsxs("p",{className:"text-xs text-cyber-text-muted mt-1 font-mono",children:[lE(t.size),t.record_count!==null&&g.jsxs("span",{className:"text-cyber-neon-green",children:[" | ",e("file.entries",{count:t.record_count})]})]}),g.jsx("p",{className:"text-xs text-cyber-text-muted mt-1 font-mono",children:cE(t.modified_at)})]})]}),g.jsxs("div",{className:"flex items-center justify-between mt-3 pt-3 border-t border-cyber-border-subtle",children:[g.jsxs(qi,{variant:"outline",className:`text-[10px] font-mono ${l.badge}`,children:[".",t.type.toUpperCase()]}),g.jsxs("div",{className:"flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity",children:[u&&g.jsxs(Ct,{variant:"ghost",size:"sm",className:"h-7 px-2 font-mono text-cyber-neon-cyan hover:text-cyber-neon-cyan hover:bg-cyber-neon-cyan/10",onClick:()=>s(!0),children:[g.jsx(NS,{className:"w-3 h-3 mr-1"}),e("file.preview")]}),g.jsxs(Ct,{variant:"ghost",size:"sm",className:"h-7 px-2 font-mono text-cyber-neon-cyan hover:text-cyber-neon-cyan hover:bg-cyber-neon-cyan/10",onClick:d,children:[g.jsx(nv,{className:"w-3 h-3 mr-1"}),e("file.extract")]})]})]})]})]}),u&&g.jsx(AT,{file:t,open:r,onOpenChange:s})]})}function MT(t){const e=t.match(/^(search_\w+?)_/);if(e)return e[1];const r=t.split("_");return r.length>=2?`${r[0]}_${r[1]}`:"other"}function FT(t){return{search_comments:"Comments",search_creators:"Creators",search_videos:"Videos",search_contents:"Contents",search_notes:"Notes",other:"Other"}[t]||t.replace(/_/g," ").replace(/\b\w/g,r=>r.toUpperCase())}function zT(){const{t}=Zt("data"),[e,r]=x.useState("all"),{data:s,isLoading:i,refetch:l,isRefetching:u}=ta({queryKey:["dataFiles"],queryFn:async()=>{const{data:v}=await Xl.getFiles();return v.files}}),d=s||[],{categories:h,groupedFiles:p}=x.useMemo(()=>{const v={};return d.forEach(w=>{const E=MT(w.name);v[E]||(v[E]=[]),v[E].push(w)}),{categories:Object.keys(v).sort((w,E)=>v[E].length-v[w].length),groupedFiles:v}},[d]),y=e==="all"?d:p[e]||[];return g.jsxs("div",{className:"h-full flex flex-col",children:[g.jsxs("div",{className:"flex items-center justify-between mb-4",children:[g.jsxs("div",{className:"flex items-center gap-3",children:[g.jsx("h2",{className:"text-lg font-mono font-bold text-cyber-neon-cyan glow-text-cyan tracking-wider",children:t("explorer.title")}),g.jsx(qi,{variant:"default",className:"font-mono",children:t("explorer.records",{count:d.length})})]}),g.jsxs(Ct,{variant:"outline",size:"sm",onClick:()=>l(),disabled:u,className:"font-mono",children:[g.jsx(Tf,{className:`w-4 h-4 ${u?"animate-spin":""}`}),t("explorer.rescan")]})]}),d.length>0&&h.length>1&&g.jsxs("div",{className:"flex items-center gap-2 mb-4 flex-wrap",children:[g.jsxs("button",{onClick:()=>r("all"),className:`px-3 py-1.5 rounded-md text-xs font-mono transition-all ${e==="all"?"bg-cyber-neon-cyan text-black font-bold":"bg-cyber-bg-tertiary text-cyber-text-secondary hover:text-cyber-text-primary border border-cyber-border-subtle hover:border-cyber-neon-cyan/50"}`,children:[t("explorer.allCategories")," (",d.length,")"]}),h.map(v=>g.jsxs("button",{onClick:()=>r(v),className:`px-3 py-1.5 rounded-md text-xs font-mono transition-all ${e===v?"bg-cyber-neon-cyan text-black font-bold":"bg-cyber-bg-tertiary text-cyber-text-secondary hover:text-cyber-text-primary border border-cyber-border-subtle hover:border-cyber-neon-cyan/50"}`,children:[FT(v)," (",p[v].length,")"]},v))]}),i?g.jsx("div",{className:"flex-1 flex items-center justify-center",children:g.jsx("div",{className:"text-cyber-text-muted font-mono animate-pulse",children:t("explorer.loading")})}):d.length===0?g.jsxs("div",{className:"flex-1 flex flex-col items-center justify-center text-center",children:[g.jsxs("div",{className:"relative",children:[g.jsx(TS,{className:"w-16 h-16 text-cyber-neon-cyan/30 mb-4"}),g.jsx("div",{className:"absolute inset-0 blur-xl bg-cyber-neon-cyan/10"})]}),g.jsx("h3",{className:"text-lg font-mono font-medium text-cyber-neon-cyan mb-2",children:t("explorer.noData")}),g.jsx("p",{className:"text-sm text-cyber-text-muted max-w-md font-mono",children:t("explorer.noDataHint")})]}):g.jsx("div",{className:"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4",children:y.map(v=>g.jsx(DT,{file:v},v.path))})]})}function $T(){const{t}=Zt("data");return g.jsxs(I0,{children:[g.jsx(fT,{asChild:!0,children:g.jsxs(Ct,{variant:"outline",size:"sm",className:"font-mono text-xs text-[#c9d1d9] border-[#30363d] bg-transparent hover:bg-[#21262d] hover:text-[#00ffff] hover:border-[#00ffff]/50",children:[g.jsx(tv,{className:"w-3.5 h-3.5"}),t("dialog.button")]})}),g.jsxs(nh,{className:"max-w-5xl max-h-[85vh] overflow-hidden",children:[g.jsx(rh,{children:g.jsx(oh,{children:t("dialog.title")})}),g.jsx("div",{className:"overflow-auto max-h-[calc(85vh-100px)] pr-2",children:g.jsx(zT,{})})]})]})}function UT(){const{t}=Zt("terminal"),[e,r]=x.useState(!1),s=jt(p=>p.logs),i=jt(p=>p.clearLogs),l=jt(p=>p.restoreLogs),u=jt(p=>p.clearedAfterLogId),d=jt(p=>p.status),h=x.useRef(null);return x.useEffect(()=>{h.current&&!e&&(h.current.scrollTop=h.current.scrollHeight)},[s,e]),g.jsxs("div",{className:`flex flex-col rounded-lg overflow-hidden transition-all duration-300 border border-cyber-border-subtle bg-[#0d1117] ${e?"h-12":"h-full"}`,children:[g.jsxs("div",{className:"flex items-center justify-between px-4 py-2.5 bg-[#161b22] border-b border-[#30363d] flex-shrink-0",children:[g.jsxs("div",{className:"flex items-center gap-3",children:[g.jsxs("div",{className:"flex gap-1.5",children:[g.jsx("span",{className:"w-2.5 h-2.5 rounded-full bg-cyber-neon-pink/80"}),g.jsx("span",{className:"w-2.5 h-2.5 rounded-full bg-cyber-neon-orange/80"}),g.jsx("span",{className:"w-2.5 h-2.5 rounded-full bg-cyber-neon-green/80"})]}),g.jsx("span",{className:"text-xs text-[#8b949e] font-mono tracking-wider",children:t("header.title")})]}),g.jsxs("div",{className:"flex items-center gap-3",children:[g.jsxs("div",{className:"flex items-center gap-3 text-xs font-mono",children:[g.jsx("span",{className:"text-[#8b949e]",children:t("header.entries",{count:s.length})}),d==="running"&&g.jsxs("div",{className:"flex items-center gap-1.5",children:[g.jsx("span",{className:"w-1.5 h-1.5 bg-cyber-neon-green rounded-full shadow-glow-green-sm animate-pulse-fast"}),g.jsx("span",{className:"text-cyber-neon-green",children:t("header.active")})]})]}),g.jsx($T,{}),u!==null&&g.jsx(Ct,{variant:"ghost",size:"sm",onClick:l,className:"h-7 px-2 text-[#8b949e] hover:text-[#00ffff] hover:bg-[#00ffff]/10",title:t("header.restore"),children:g.jsx(Tf,{className:"w-4 h-4"})}),g.jsx(Ct,{variant:"ghost",size:"sm",onClick:i,disabled:s.length===0,className:"h-7 px-2 text-[#8b949e] hover:text-[#ff0080] hover:bg-[#ff0080]/10 disabled:opacity-30",title:t("header.clear"),children:g.jsx(HS,{className:"w-4 h-4"})}),g.jsx(Ct,{variant:"ghost",size:"sm",onClick:()=>r(!e),className:"h-7 px-2 text-[#8b949e] hover:text-[#00ffff] hover:bg-[#00ffff]/10",children:e?g.jsx(Pf,{className:"w-4 h-4"}):g.jsx(ev,{className:"w-4 h-4"})})]})]}),!e&&g.jsxs(g.Fragment,{children:[g.jsxs("div",{ref:h,className:"flex-1 overflow-auto p-4 font-mono text-sm terminal-scroll bg-[#0d1117] min-h-0",children:[s.length===0?g.jsxs("div",{className:"space-y-4",children:[g.jsx("pre",{className:"text-cyber-neon-cyan/70 text-xs leading-tight",children:` ╔══════════════════════════════════════════════════════╗ ║ __ __ _ _ ____ ║ ║ | \\/ | ___ __| (_) __ _/ ___|_ __ __ ___ __ ║ ║ | |\\/| |/ _ \\/ _\` | |/ _\` | | | '__/ _\` \\ \\ /\\ / / ║ ║ | | | | __/ (_| | | (_| | |___| | | (_| |\\ V V / ║ ║ |_| |_|\\___|\\__,_|_|\\__,_|\\____|_| \\__,_| \\_/\\_/ ║ ║ ║ ║ [ NEURAL EXTRACTION UNIT v1.0 ] ║ ╚══════════════════════════════════════════════════════╝`}),g.jsxs("div",{className:"text-[#c9d1d9] text-xs space-y-1",children:[g.jsx("p",{className:"text-cyber-neon-green/70",children:t("banner.systemInit")}),g.jsx("p",{className:"text-[#8b949e]",children:t("banner.configHint")})]})]}):g.jsx("div",{className:"space-y-0.5",children:s.map(p=>g.jsx(LP,{log:p},p.id))}),d==="running"&&g.jsxs("div",{className:"flex items-center gap-1 mt-3",children:[g.jsx("span",{className:"text-cyber-neon-green/80",children:"root@crawler:~$"}),g.jsx("span",{className:"w-2 h-4 bg-cyber-neon-green/80 cursor-blink"})]})]}),g.jsx("div",{className:"px-4 py-2 border-t border-[#30363d] bg-[#161b22] flex items-center justify-end flex-shrink-0",children:g.jsx("div",{className:"text-xs font-mono text-[#8b949e]",children:d.toUpperCase()})})]})]})}let bt=null,So=null,Il=0;function BT(){const t=jt(r=>r.addLog),e=x.useRef(t);return x.useEffect(()=>{e.current=t},[t]),x.useEffect(()=>{Il++;const r=()=>{if(So&&(clearTimeout(So),So=null),bt&&(bt.readyState===WebSocket.OPEN||bt.readyState===WebSocket.CONNECTING))return;const i=window.location.protocol==="https:"?"wss:":"ws:",l=window.location.host,u=`${i}//${l}/api/ws/logs`,d=new WebSocket(u);bt=d,d.onopen=()=>{bt===d&&console.log("WebSocket connected")},d.onmessage=h=>{if(bt===d){if(h.data==="ping"){d.send("pong");return}if(h.data!=="pong")try{const p=JSON.parse(h.data);p.id&&p.message&&e.current(p)}catch{console.warn("Failed to parse WebSocket message:",h.data)}}},d.onclose=()=>{bt===d&&(console.log("WebSocket disconnected"),bt=null,Il>0&&(So=setTimeout(r,2e3)))},d.onerror=h=>{bt===d&&console.error("WebSocket error:",h)}};r();const s=setInterval(()=>{bt&&bt.readyState===WebSocket.OPEN&&bt.send("ping")},3e4);return()=>{if(Il--,clearInterval(s),Il===0&&(So&&(clearTimeout(So),So=null),bt)){const i=bt;bt=null,i.close()}}},[]),{ws:bt}}function HT(){return BT(),g.jsx("main",{className:"flex-1 flex flex-col overflow-hidden min-h-0 relative z-10",children:g.jsx(UT,{})})}function VT(){const{t}=Zt("license");return g.jsx("footer",{className:"h-24 flex-shrink-0 glass-panel border-t border-cyber-border-subtle",children:g.jsxs("div",{className:"h-full px-6 flex items-center justify-center gap-6",children:[g.jsx("div",{className:"w-14 h-14 rounded-lg overflow-hidden border-2 border-cyber-neon-cyan/60 flex-shrink-0 shadow-glow-cyan-sm",children:g.jsx("img",{src:"/logos/my_logo.png",alt:"程序员阿江-Relakkes",className:"w-full h-full object-cover"})}),g.jsxs("div",{className:"flex flex-col gap-1",children:[g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx("span",{className:"text-lg font-bold text-cyber-text-primary",children:t("author.name")}),g.jsx($S,{className:"w-5 h-5 text-cyber-neon-cyan animate-pulse"})]}),g.jsx("span",{className:"text-sm text-cyber-text-muted hidden sm:inline",children:t("author.description")}),g.jsxs("div",{className:"flex items-center gap-2 text-cyber-neon-cyan",children:[g.jsx(jS,{className:"w-4 h-4 fill-current animate-pulse"}),g.jsx("span",{className:"text-sm font-medium",children:t("author.slogan")})]})]}),g.jsxs("div",{className:"flex items-center gap-3",children:[g.jsx("a",{href:"https://github.com/NanmiCoder",target:"_blank",rel:"noopener noreferrer",className:"w-11 h-11 rounded-lg flex items-center justify-center border border-cyber-border-subtle hover:border-cyber-neon-cyan hover:shadow-glow-cyan-sm transition-all bg-cyber-bg-tertiary hover:scale-110",title:"GitHub",children:g.jsx("img",{src:"/logos/github.png",alt:"GitHub",className:"w-6 h-6 object-contain"})}),g.jsx("a",{href:"https://space.bilibili.com/434377496",target:"_blank",rel:"noopener noreferrer",className:"w-11 h-11 rounded-lg flex items-center justify-center border border-cyber-border-subtle hover:border-pink-400 hover:shadow-[0_0_10px_rgba(251,113,133,0.4)] transition-all bg-cyber-bg-tertiary hover:scale-110",title:"哔哩哔哩",children:g.jsx("img",{src:"/logos/bilibili_logo.png",alt:"Bilibili",className:"w-6 h-6 object-contain"})}),g.jsx("a",{href:"https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753",target:"_blank",rel:"noopener noreferrer",className:"w-11 h-11 rounded-lg flex items-center justify-center border border-cyber-border-subtle hover:border-red-400 hover:shadow-[0_0_10px_rgba(248,113,113,0.4)] transition-all bg-cyber-bg-tertiary hover:scale-110",title:"小红书",children:g.jsx("img",{src:"/logos/xiaohongshu_logo.png",alt:"小红书",className:"w-6 h-6 object-contain"})}),g.jsx("a",{href:"https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE",target:"_blank",rel:"noopener noreferrer",className:"w-11 h-11 rounded-lg flex items-center justify-center border border-cyber-border-subtle hover:border-cyber-text-primary hover:shadow-[0_0_10px_rgba(255,255,255,0.3)] transition-all bg-cyber-bg-tertiary hover:scale-110",title:"抖音",children:g.jsx("img",{src:"/logos/douyin.png",alt:"抖音",className:"w-6 h-6 object-contain"})})]})]})})}var WT=["a","button","div","form","h2","h3","img","input","label","li","nav","ol","p","select","span","svg","ul"],KT=WT.reduce((t,e)=>{const r=p0(`Primitive.${e}`),s=x.forwardRef((i,l)=>{const{asChild:u,...d}=i,h=u?r:e;return typeof window<"u"&&(window[Symbol.for("radix-ui")]=!0),g.jsx(h,{...d,ref:l})});return s.displayName=`Primitive.${e}`,{...t,[e]:s}},{}),qT="Label",ew=x.forwardRef((t,e)=>g.jsx(KT.label,{...t,ref:e,onMouseDown:r=>{var i;r.target.closest("button, input, select, textarea")||((i=t.onMouseDown)==null||i.call(t,r),!r.defaultPrevented&&r.detail>1&&r.preventDefault())}}));ew.displayName=qT;var tw=ew;const QT=_f("text-sm font-mono leading-none text-cyber-text-secondary peer-disabled:cursor-not-allowed peer-disabled:opacity-70"),nw=x.forwardRef(({className:t,...e},r)=>g.jsx(tw,{ref:r,className:Be(QT(),t),...e}));nw.displayName=tw.displayName;const Hl=x.forwardRef(({className:t,checked:e,onCheckedChange:r,...s},i)=>g.jsxs("label",{className:"inline-flex items-center cursor-pointer",children:[g.jsx("input",{type:"checkbox",className:"sr-only peer",ref:i,checked:e,onChange:l=>r==null?void 0:r(l.target.checked),...s}),g.jsx("div",{className:Be("h-4 w-4 shrink-0 rounded-sm border border-cyber-border-DEFAULT bg-cyber-bg-tertiary ring-offset-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-cyber-neon-cyan disabled:cursor-not-allowed disabled:opacity-50 peer-checked:bg-cyber-neon-cyan/20 peer-checked:border-cyber-neon-cyan peer-checked:shadow-glow-cyan-sm flex items-center justify-center transition-all",t),children:g.jsx(Rf,{className:Be("h-3 w-3 text-cyber-neon-cyan transition-opacity",e?"opacity-100":"opacity-0")})})]}));Hl.displayName="Checkbox";const YT={xhs:{video:[/xiaohongshu\.com\/explore\/([a-zA-Z0-9]+)/,/xiaohongshu\.com\/discovery\/item\/([a-zA-Z0-9]+)/,/xhslink\.com\/([a-zA-Z0-9]+)/],creator:[/xiaohongshu\.com\/user\/profile\/([a-zA-Z0-9]+)/]},dy:{video:[/douyin\.com\/video\/(\d+)/,/v\.douyin\.com\/([a-zA-Z0-9]+)/,/iesdouyin\.com\/share\/video\/(\d+)/],creator:[/douyin\.com\/user\/([a-zA-Z0-9_-]+)/]},bili:{video:[/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/,/bilibili\.com\/video\/(av\d+)/,/b23\.tv\/([a-zA-Z0-9]+)/],creator:[/space\.bilibili\.com\/(\d+)/]},wb:{video:[/weibo\.com\/\d+\/([a-zA-Z0-9]+)/,/m\.weibo\.cn\/status\/(\d+)/],creator:[/weibo\.com\/u\/(\d+)/,/weibo\.com\/([a-zA-Z0-9]+)$/]},ks:{video:[/kuaishou\.com\/short-video\/([a-zA-Z0-9_-]+)/,/v\.kuaishou\.com\/([a-zA-Z0-9]+)/],creator:[/kuaishou\.com\/profile\/([a-zA-Z0-9_-]+)/]}};function GT(t,e){const r=t.trim();if(!r.includes("/")&&!r.includes("."))return{id:r,type:"unknown",original:r,isValid:r.length>0};const s=YT[e];if(!s)return{id:r,type:"unknown",original:r,isValid:!1};for(const l of s.video){const u=r.match(l);if(u&&u[1])return{id:u[1],type:"video",original:r,isValid:!0}}for(const l of s.creator){const u=r.match(l);if(u&&u[1])return{id:u[1],type:"creator",original:r,isValid:!0}}const i=r.match(/([a-zA-Z0-9_-]{6,})/);return i?{id:i[1],type:"unknown",original:r,isValid:!1}:{id:r,type:"unknown",original:r,isValid:!1}}function XT(t,e){return t.trim()?t.split(/[,\n]+/).map(s=>s.trim()).filter(Boolean).map(s=>GT(s,e)):[]}function Sy({value:t,platform:e,type:r,onRemove:s,disabled:i}){const l=x.useMemo(()=>XT(t,e),[t,e]);if(l.length===0)return null;const u=d=>{if(i||!s)return;t.split(/[,\n]+/).map(p=>p.trim()).filter(Boolean).splice(d,1),s(d)};return g.jsxs("div",{className:"space-y-1.5 mt-2",children:[g.jsxs("div",{className:"text-[10px] text-cyber-text-muted font-mono",children:["已识别 ",l.length," 个",r==="detail"?"帖子/视频":"创作者",":"]}),g.jsx("div",{className:"flex flex-wrap gap-1.5",children:l.map((d,h)=>g.jsx(JT,{item:d,expectedType:r,onRemove:i?void 0:()=>u(h)},`${d.id}-${h}`))})]})}function JT({item:t,expectedType:e,onRemove:r}){const s=t.type==="unknown"||e==="detail"&&t.type==="video"||e==="creator"&&t.type==="creator",i=!t.isValid||!s;return g.jsxs("span",{className:` inline-flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-mono ${i?"bg-cyber-neon-orange/10 border border-cyber-neon-orange/30 text-cyber-neon-orange":"bg-cyber-neon-cyan/10 border border-cyber-neon-cyan/30 text-cyber-neon-cyan"} `,title:t.original,children:[i?g.jsx(Of,{className:"w-3 h-3 flex-shrink-0"}):g.jsx(Rf,{className:"w-3 h-3 flex-shrink-0"}),g.jsx("span",{className:"max-w-[120px] truncate",children:t.id.length>20?t.id.slice(0,8)+"..."+t.id.slice(-8):t.id}),r&&g.jsx("button",{type:"button",onClick:r,className:"hover:text-cyber-neon-pink transition-colors ml-0.5",children:g.jsx(jf,{className:"w-3 h-3"})})]})}function Ld({title:t,description:e,icon:r,children:s,className:i=""}){return g.jsxs("section",{className:`rounded-lg glass-panel float-panel overflow-hidden ${i}`,children:[g.jsxs("header",{className:"px-4 py-3 border-b border-cyber-border-subtle/50 flex items-center gap-3 bg-cyber-bg-tertiary/30",children:[g.jsx("div",{className:"h-8 w-8 rounded-md bg-cyber-bg-tertiary border border-cyber-border-subtle flex items-center justify-center flex-shrink-0",children:g.jsx(r,{className:"h-4 w-4 text-cyber-neon-cyan"})}),g.jsxs("div",{className:"min-w-0",children:[g.jsx("div",{className:"text-xs font-mono font-semibold text-cyber-text-primary tracking-wide",children:t}),g.jsx("div",{className:"text-[10px] text-cyber-text-muted leading-snug truncate",children:e})]})]}),g.jsx("div",{className:"p-4 space-y-4",children:s})]})}function dr({label:t,hint:e,children:r}){return g.jsxs("div",{className:"space-y-2",children:[g.jsxs("div",{className:"space-y-0.5",children:[g.jsx(nw,{className:"text-xs text-cyber-text-secondary font-mono",children:t}),e?g.jsx("p",{className:"text-[10px] text-cyber-text-muted leading-snug",children:e}):null]}),r]})}function ZT({value:t,onChange:e,placeholder:r,disabled:s}){const[i,l]=x.useState(""),u=t?t.split(",").map(p=>p.trim()).filter(Boolean):[],d=p=>{if(p.key==="Enter"){p.preventDefault();const y=i.trim();if(y&&!u.includes(y)){const v=[...u,y];e(v.join(",")),l("")}}},h=p=>{const y=u.filter(v=>v!==p);e(y.join(","))};return g.jsxs("div",{className:"space-y-2",children:[g.jsx(Nc,{value:i,onChange:p=>l(p.target.value),onKeyDown:d,placeholder:r,disabled:s,className:"h-9 text-xs"}),u.length>0&&g.jsx("div",{className:"flex flex-wrap gap-1.5",children:u.map(p=>g.jsxs("span",{className:"inline-flex items-center gap-1 px-2 py-1 rounded-md bg-cyber-neon-cyan/10 border border-cyber-neon-cyan/30 text-cyber-neon-cyan text-xs font-mono",children:[p,!s&&g.jsx("button",{type:"button",onClick:()=>h(p),className:"hover:text-cyber-neon-pink transition-colors",children:g.jsx(jf,{className:"w-3 h-3"})})]},p))})]})}function eO(){const{t}=Zt("config"),e=jt(b=>b.config),r=jt(b=>b.updateConfig),s=jt(b=>b.status),{data:i}=Yk(),{data:l}=Gk(),{mutate:u,isPending:d}=qk(),{mutate:h,isPending:p}=Qk(),y=s==="running"||s==="stopping",v=s==="running",C=d||p||s==="stopping",w=()=>{u(e)},E=()=>{h()};return g.jsxs("div",{className:"space-y-4 animate-slide-up",children:[g.jsxs("div",{className:"grid grid-cols-1 md:grid-cols-3 gap-4",children:[g.jsxs(Ld,{title:t("section.targetMatrix.title"),description:t("section.targetMatrix.description"),icon:rv,children:[g.jsx(dr,{label:t("field.platform"),children:g.jsxs(ks,{value:e.platform,onValueChange:b=>r({platform:b}),disabled:y,children:[g.jsx(ko,{className:"h-9 text-xs",children:g.jsx(Ns,{placeholder:t("field.platformPlaceholder")})}),g.jsx(No,{children:i==null?void 0:i.map(b=>g.jsx(Ro,{value:b.value,children:b.label},b.value))})]})}),g.jsxs("div",{className:"grid grid-cols-2 gap-3",children:[g.jsx(dr,{label:t("field.crawlType"),children:g.jsxs(ks,{value:e.crawler_type,onValueChange:b=>r({crawler_type:b}),disabled:y,children:[g.jsx(ko,{className:"h-9 text-xs",children:g.jsx(Ns,{placeholder:t("field.crawlTypePlaceholder")})}),g.jsx(No,{children:l==null?void 0:l.crawler_types.map(b=>g.jsx(Ro,{value:b.value,children:b.label},b.value))})]})}),g.jsx(dr,{label:t("field.startPage"),children:g.jsx(Nc,{type:"number",min:1,value:e.start_page,onChange:b=>r({start_page:parseInt(b.target.value)||1}),disabled:y,className:"h-9 text-xs"})})]}),e.crawler_type==="search"&&g.jsx(dr,{label:t("field.keywords"),hint:t("field.keywordsHint"),children:g.jsx(ZT,{placeholder:t("field.keywordsPlaceholder"),value:e.keywords,onChange:b=>r({keywords:b}),disabled:y})}),e.crawler_type==="detail"&&g.jsxs(dr,{label:t("field.specifiedIds"),hint:t("field.specifiedIdsHint"),children:[g.jsx("textarea",{value:e.specified_ids,onChange:b=>r({specified_ids:b.target.value}),disabled:y,placeholder:t(`field.specifiedIdsPlaceholder.${e.platform}`,t("field.specifiedIdsPlaceholder.default")),className:"min-h-[60px] w-full rounded-md border border-cyber-border-DEFAULT bg-cyber-bg-tertiary px-3 py-2 text-xs font-mono text-cyber-text-primary placeholder:text-cyber-text-muted focus-visible:outline-none focus-visible:border-cyber-neon-cyan/50 focus-visible:shadow-cyber-soft disabled:cursor-not-allowed disabled:opacity-50 transition-all resize-none"}),g.jsx(Sy,{value:e.specified_ids,platform:e.platform,type:"detail",disabled:y}),e.platform==="xhs"&&g.jsx("div",{className:"mt-2 rounded-lg border border-cyber-neon-orange/30 bg-cyber-neon-orange/5 p-2 text-[10px] leading-snug text-cyber-neon-orange font-mono",children:t("warning.xhsToken")})]}),e.crawler_type==="creator"&&g.jsxs(dr,{label:t("field.creatorIds"),hint:t("field.creatorIdsHint"),children:[g.jsx("textarea",{value:e.creator_ids,onChange:b=>r({creator_ids:b.target.value}),disabled:y,placeholder:t(`field.creatorIdsPlaceholder.${e.platform}`,t("field.creatorIdsPlaceholder.default")),className:"min-h-[60px] w-full rounded-md border border-cyber-border-DEFAULT bg-cyber-bg-tertiary px-3 py-2 text-xs font-mono text-cyber-text-primary placeholder:text-cyber-text-muted focus-visible:outline-none focus-visible:border-cyber-neon-cyan/50 focus-visible:shadow-cyber-soft disabled:cursor-not-allowed disabled:opacity-50 transition-all resize-none"}),g.jsx(Sy,{value:e.creator_ids,platform:e.platform,type:"creator",disabled:y}),e.platform==="xhs"&&g.jsx("div",{className:"mt-2 rounded-lg border border-cyber-neon-orange/30 bg-cyber-neon-orange/5 p-2 text-[10px] leading-snug text-cyber-neon-orange font-mono",children:t("warning.xhsToken")})]})]}),g.jsxs(Ld,{title:t("section.authMatrix.title"),description:t("section.authMatrix.description"),icon:_S,children:[g.jsx(dr,{label:t("field.loginMethod"),children:g.jsxs(ks,{value:e.login_type,onValueChange:b=>r({login_type:b}),disabled:y,children:[g.jsx(ko,{className:"h-9 text-xs",children:g.jsx(Ns,{placeholder:t("field.loginMethodPlaceholder")})}),g.jsx(No,{children:l==null?void 0:l.login_types.map(b=>g.jsx(Ro,{value:b.value,children:b.label},b.value))})]})}),e.login_type==="cookie"?g.jsx(dr,{label:t("field.cookies"),hint:t("field.cookiesHint"),children:g.jsx("textarea",{value:e.cookies,onChange:b=>r({cookies:b.target.value}),disabled:y,placeholder:t("field.cookiesPlaceholder"),className:"min-h-[80px] w-full rounded-md border border-cyber-border-DEFAULT bg-cyber-bg-tertiary px-3 py-2 text-xs font-mono text-cyber-text-primary placeholder:text-cyber-text-muted focus-visible:outline-none focus-visible:border-cyber-neon-cyan/50 focus-visible:shadow-cyber-soft disabled:cursor-not-allowed disabled:opacity-50 transition-all resize-none"})}):null,e.login_type==="cookie"&&(e.platform==="xhs"||e.platform==="dy")?g.jsx("div",{className:"rounded-lg border border-cyber-neon-orange/30 bg-cyber-neon-orange/5 p-3 text-[11px] leading-snug text-cyber-neon-orange font-mono",children:t("warning.cookieSlider")}):null]}),g.jsxs(Ld,{title:t("section.outputConfig.title"),description:t("section.outputConfig.description"),icon:tv,children:[g.jsx(dr,{label:t("field.saveFormat"),children:g.jsxs(ks,{value:e.save_option,onValueChange:b=>r({save_option:b}),disabled:y,children:[g.jsx(ko,{className:"h-9 text-xs",children:g.jsx(Ns,{placeholder:t("field.saveFormatPlaceholder")})}),g.jsx(No,{children:l==null?void 0:l.save_options.map(b=>g.jsx(Ro,{value:b.value,children:b.label},b.value))})]})}),g.jsxs("div",{className:"space-y-2",children:[g.jsxs("div",{className:"flex items-center gap-3 rounded-lg border border-cyber-border-subtle bg-cyber-bg-tertiary/30 p-2.5 hover:border-cyber-border-DEFAULT transition-colors",children:[g.jsx(Hl,{checked:e.enable_comments,onCheckedChange:b=>{const k=b===!0;r({enable_comments:k,enable_sub_comments:k?e.enable_sub_comments:!1})},disabled:y}),g.jsxs("div",{className:"flex items-center gap-2",children:[g.jsx(LS,{className:"h-3.5 w-3.5 text-cyber-text-secondary"}),g.jsx("p",{className:"text-xs font-mono text-cyber-text-primary",children:t("field.commentExtraction")})]})]}),g.jsxs("div",{className:"flex items-center gap-3 rounded-lg border border-cyber-border-subtle bg-cyber-bg-tertiary/30 p-2.5 hover:border-cyber-border-DEFAULT transition-colors",children:[g.jsx(Hl,{checked:e.enable_sub_comments,onCheckedChange:b=>r({enable_sub_comments:b===!0}),disabled:y||!e.enable_comments}),g.jsx("p",{className:"text-xs font-mono text-cyber-text-primary",children:t("field.subComments")})]}),g.jsxs("div",{className:"flex items-center gap-3 rounded-lg border border-cyber-border-subtle bg-cyber-bg-tertiary/30 p-2.5 hover:border-cyber-border-DEFAULT transition-colors",children:[g.jsx(Hl,{checked:e.headless,onCheckedChange:b=>r({headless:b===!0}),disabled:y}),g.jsxs("div",{className:"min-w-0 flex-1",children:[g.jsx("p",{className:"text-xs font-mono text-cyber-text-primary",children:t("field.headlessMode")}),g.jsx("p",{className:"text-[10px] text-cyber-text-muted leading-snug",children:t("field.headlessModeHint")})]})]})]})]})]}),g.jsx("div",{className:"w-full",children:v?g.jsxs(Ct,{onClick:E,disabled:C,className:"w-full h-12 bg-cyber-neon-pink text-white font-mono font-bold text-sm tracking-wider hover:bg-cyber-neon-pink/90 hover:shadow-glow-pink-sm transition-all",children:[g.jsx(US,{className:"w-4 h-4"}),t(p?"button.stopping":"button.terminate")]}):g.jsxs(Ct,{onClick:w,disabled:C,className:"w-full h-12 bg-cyber-neon-cyan text-cyber-bg-primary font-mono font-bold text-sm tracking-wider hover:bg-cyber-neon-cyan/90 hover:shadow-glow-cyan-sm transition-all",children:[g.jsx(MS,{className:"w-4 h-4"}),t(d?"button.initiating":"button.initiateScan")]})})]})}const rw="mediacrawler_env_checked";function tO(){return localStorage.getItem(rw)==="true"}function nO({onCheckComplete:t}){const{t:e}=Zt("env"),[r,s]=x.useState("checking"),[i,l]=x.useState(null),[u,d]=x.useState(!1),h=async()=>{s("checking"),l(null);try{const v=await Wk.check();l(v.data),v.data.success?(s("success"),localStorage.setItem(rw,"true"),setTimeout(()=>t(!0),1500)):s("error")}catch{l({success:!1,message:e("defaultError"),error:e("defaultErrorHint")}),s("error")}};x.useEffect(()=>{h()},[]);const p=()=>{t(!1)},y=()=>{h()};return g.jsx("div",{className:"fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50",children:g.jsxs("div",{className:"bg-cyber-bg-panel border border-cyber-border-DEFAULT rounded-lg shadow-cyber-card p-6 max-w-md w-full mx-4 relative",children:[g.jsx("div",{className:"absolute top-0 left-0 w-4 h-4 border-t-2 border-l-2 border-cyber-neon-cyan"}),g.jsx("div",{className:"absolute top-0 right-0 w-4 h-4 border-t-2 border-r-2 border-cyber-neon-cyan"}),g.jsx("div",{className:"absolute bottom-0 left-0 w-4 h-4 border-b-2 border-l-2 border-cyber-neon-cyan"}),g.jsx("div",{className:"absolute bottom-0 right-0 w-4 h-4 border-b-2 border-r-2 border-cyber-neon-cyan"}),g.jsxs("div",{className:"flex items-center gap-3 mb-4",children:[g.jsx(Of,{className:"w-6 h-6 text-cyber-neon-orange"}),g.jsx("h2",{className:"text-lg font-mono font-semibold text-cyber-neon-cyan glow-text-cyan",children:e("title")})]}),g.jsxs("div",{className:"bg-cyber-bg-tertiary border border-cyber-border-DEFAULT rounded-lg p-4 mb-4",children:[g.jsxs("div",{className:"flex items-center gap-3",children:[r==="checking"&&g.jsxs(g.Fragment,{children:[g.jsx(AS,{className:"w-5 h-5 text-cyber-neon-cyan animate-spin"}),g.jsx("span",{className:"text-cyber-text-primary font-mono text-sm",children:e("scanning")})]}),r==="success"&&g.jsxs(g.Fragment,{children:[g.jsx(CS,{className:"w-5 h-5 text-cyber-neon-green"}),g.jsx("span",{className:"text-cyber-neon-green font-mono text-sm",children:e("success",{message:i==null?void 0:i.message})})]}),r==="error"&&g.jsxs(g.Fragment,{children:[g.jsx(ES,{className:"w-5 h-5 text-cyber-neon-pink"}),g.jsx("span",{className:"text-cyber-neon-pink font-mono text-sm",children:e("error",{message:i==null?void 0:i.message})})]})]}),r==="error"&&(i==null?void 0:i.error)&&g.jsxs("div",{className:"mt-3",children:[g.jsx("button",{onClick:()=>d(!u),className:"text-sm text-cyber-neon-cyan hover:underline font-mono",children:e(u?"hideDetails":"showDetails")}),u&&g.jsx("pre",{className:"mt-2 p-3 bg-black text-cyber-neon-green rounded text-xs font-mono overflow-x-auto whitespace-pre-wrap border border-cyber-border-DEFAULT",children:i.error})]})]}),r==="error"&&g.jsxs("div",{className:"text-sm text-cyber-text-secondary mb-4 space-y-2 font-mono",children:[g.jsx("p",{className:"text-cyber-neon-orange",children:e("requirements")}),g.jsxs("ol",{className:"list-decimal list-inside space-y-1 pl-2 text-cyber-text-muted",children:[g.jsx("li",{children:e("requirementsList.1")}),g.jsx("li",{children:e("requirementsList.2")}),g.jsx("li",{children:e("requirementsList.3")})]})]}),g.jsxs("div",{className:"flex gap-3",children:[r==="error"&&g.jsxs(g.Fragment,{children:[g.jsx(Ct,{variant:"outline",className:"flex-1 font-mono",onClick:p,children:e("skipCheck")}),g.jsxs(Ct,{variant:"glow",className:"flex-1 font-mono",onClick:y,children:[g.jsx(Tf,{className:"w-4 h-4"}),e("retryCheck")]})]}),r==="checking"&&g.jsx(Ct,{variant:"outline",className:"w-full font-mono",onClick:p,children:e("skipCheck")})]})]})})}const ow="mediacrawler_license_accepted";function rO(){return localStorage.getItem(ow)==="true"}function oO({onAccept:t}){const{t:e}=Zt("license"),r=()=>{localStorage.setItem(ow,"true"),t()},s=()=>{try{window.close(),setTimeout(()=>{window.location.href="about:blank"},100)}catch{}setTimeout(()=>{document.body.innerHTML=`
访问已拒绝
您未同意使用条款,请关闭此标签页
`},200)};return g.jsx("div",{className:"fixed inset-0 bg-black/95 backdrop-blur-sm flex items-center justify-center z-[100] overflow-y-auto py-8",children:g.jsxs("div",{className:"bg-cyber-bg-panel border-2 border-cyber-neon-pink rounded-lg shadow-cyber-card p-6 max-w-2xl w-full mx-4 relative",children:[g.jsx("div",{className:"absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-cyber-neon-pink"}),g.jsx("div",{className:"absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-cyber-neon-pink"}),g.jsx("div",{className:"absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-cyber-neon-pink"}),g.jsx("div",{className:"absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-cyber-neon-pink"}),g.jsxs("div",{className:"flex items-center justify-center gap-3 mb-4",children:[g.jsx(zS,{className:"w-8 h-8 text-cyber-neon-pink animate-pulse"}),g.jsx("h2",{className:"text-xl font-mono font-bold text-cyber-neon-pink",children:e("title")})]}),g.jsx("div",{className:"text-center mb-4",children:g.jsx("span",{className:"text-base font-mono text-cyber-neon-orange",children:e("warning")})}),g.jsx("div",{className:"bg-black/50 border border-cyber-neon-pink/30 rounded-lg p-4 mb-4",children:g.jsxs("ul",{className:"space-y-2 text-sm font-mono",children:[g.jsxs("li",{className:"flex items-start gap-2",children:[g.jsx("span",{className:"text-cyber-neon-pink font-bold",children:"1."}),g.jsx("span",{className:"text-cyber-text-primary",children:e("content.line1")})]}),g.jsxs("li",{className:"flex items-start gap-2",children:[g.jsx("span",{className:"text-cyber-neon-pink font-bold",children:"2."}),g.jsx("span",{className:"text-cyber-text-primary",children:e("content.line2")})]}),g.jsxs("li",{className:"flex items-start gap-2",children:[g.jsx("span",{className:"text-cyber-neon-pink font-bold",children:"3."}),g.jsx("span",{className:"text-cyber-text-primary",children:e("content.line3")})]}),g.jsxs("li",{className:"flex items-start gap-2",children:[g.jsx("span",{className:"text-cyber-neon-pink font-bold",children:"4."}),g.jsx("span",{className:"text-cyber-text-primary",children:e("content.line4")})]})]})}),g.jsx("div",{className:"flex justify-center mb-6",children:g.jsxs("a",{href:"https://github.com/NanmiCoder/MediaCrawler/blob/main/LICENSE",target:"_blank",rel:"noopener noreferrer",className:"flex items-center gap-1.5 text-cyber-neon-cyan hover:underline text-sm font-mono",children:[g.jsx(kS,{className:"w-4 h-4"}),e("license")]})}),g.jsxs("div",{className:"flex gap-4",children:[g.jsx(Ct,{onClick:s,variant:"outline",className:"flex-1 font-mono border-cyber-neon-pink/50 text-cyber-neon-pink hover:bg-cyber-neon-pink/10",children:e("decline")}),g.jsx(Ct,{onClick:r,className:"flex-1 font-mono bg-cyber-neon-green text-black font-bold hover:bg-cyber-neon-green/90",children:e("confirm")})]})]})})}function sO(){const[t,e]=x.useState(()=>rO()),[r,s]=x.useState(()=>tO()),[i,l]=x.useState(!1),u=()=>{s(!0)},d=()=>{e(!0),l(!1)},h=()=>{l(!0)};return g.jsxs("div",{className:"flex flex-col h-screen cyber-grid overflow-hidden relative",children:[(!t||i)&&g.jsx(oO,{onAccept:d}),t&&!i&&!r&&g.jsx(nO,{onCheckComplete:u}),g.jsx(_P,{onShowDisclaimer:h}),g.jsxs("div",{className:"flex-1 flex flex-col gap-4 p-4 overflow-hidden min-h-0",children:[g.jsx("div",{className:"flex-shrink-0",children:g.jsx(eO,{})}),g.jsx(HT,{})]}),g.jsx(VT,{}),g.jsx(vS,{position:"top-right",toastOptions:{className:"glass-panel font-mono text-cyber-text-primary",style:{fontFamily:"JetBrains Mono, monospace"}}})]})}const{slice:iO,forEach:aO}=[];function lO(t){return aO.call(iO.call(arguments,1),e=>{if(e)for(const r in e)t[r]===void 0&&(t[r]=e[r])}),t}function cO(t){return typeof t!="string"?!1:[/<\s*script.*?>/i,/<\s*\/\s*script\s*>/i,/<\s*img.*?on\w+\s*=/i,/<\s*\w+\s*on\w+\s*=.*?>/i,/javascript\s*:/i,/vbscript\s*:/i,/expression\s*\(/i,/eval\s*\(/i,/alert\s*\(/i,/document\.cookie/i,/document\.write\s*\(/i,/window\.location/i,/innerHTML/i].some(r=>r.test(t))}const Cy=/^[\u0009\u0020-\u007e\u0080-\u00ff]+$/,uO=function(t,e){const s=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{path:"/"},i=encodeURIComponent(e);let l=`${t}=${i}`;if(s.maxAge>0){const u=s.maxAge-0;if(Number.isNaN(u))throw new Error("maxAge should be a Number");l+=`; Max-Age=${Math.floor(u)}`}if(s.domain){if(!Cy.test(s.domain))throw new TypeError("option domain is invalid");l+=`; Domain=${s.domain}`}if(s.path){if(!Cy.test(s.path))throw new TypeError("option path is invalid");l+=`; Path=${s.path}`}if(s.expires){if(typeof s.expires.toUTCString!="function")throw new TypeError("option expires is invalid");l+=`; Expires=${s.expires.toUTCString()}`}if(s.httpOnly&&(l+="; HttpOnly"),s.secure&&(l+="; Secure"),s.sameSite)switch(typeof s.sameSite=="string"?s.sameSite.toLowerCase():s.sameSite){case!0:l+="; SameSite=Strict";break;case"lax":l+="; SameSite=Lax";break;case"strict":l+="; SameSite=Strict";break;case"none":l+="; SameSite=None";break;default:throw new TypeError("option sameSite is invalid")}return s.partitioned&&(l+="; Partitioned"),l},Ey={create(t,e,r,s){let i=arguments.length>4&&arguments[4]!==void 0?arguments[4]:{path:"/",sameSite:"strict"};r&&(i.expires=new Date,i.expires.setTime(i.expires.getTime()+r*60*1e3)),s&&(i.domain=s),document.cookie=uO(t,e,i)},read(t){const e=`${t}=`,r=document.cookie.split(";");for(let s=0;s-1&&(i=window.location.hash.substring(window.location.hash.indexOf("?")));const u=i.substring(1).split("&");for(let d=0;d0&&u[d].substring(0,h)===e&&(r=u[d].substring(h+1))}}return r}},hO={name:"hash",lookup(t){var i;let{lookupHash:e,lookupFromHashIndex:r}=t,s;if(typeof window<"u"){const{hash:l}=window.location;if(l&&l.length>2){const u=l.substring(1);if(e){const d=u.split("&");for(let h=0;h0&&d[h].substring(0,p)===e&&(s=d[h].substring(p+1))}}if(s)return s;if(!s&&r>-1){const d=l.match(/\/([a-zA-Z-]*)/g);return Array.isArray(d)?(i=d[typeof r=="number"?r:0])==null?void 0:i.replace("/",""):void 0}}}return s}};let Cs=null;const ky=()=>{if(Cs!==null)return Cs;try{if(Cs=typeof window<"u"&&window.localStorage!==null,!Cs)return!1;const t="i18next.translate.boo";window.localStorage.setItem(t,"foo"),window.localStorage.removeItem(t)}catch{Cs=!1}return Cs};var pO={name:"localStorage",lookup(t){let{lookupLocalStorage:e}=t;if(e&&ky())return window.localStorage.getItem(e)||void 0},cacheUserLanguage(t,e){let{lookupLocalStorage:r}=e;r&&ky()&&window.localStorage.setItem(r,t)}};let Es=null;const Ny=()=>{if(Es!==null)return Es;try{if(Es=typeof window<"u"&&window.sessionStorage!==null,!Es)return!1;const t="i18next.translate.boo";window.sessionStorage.setItem(t,"foo"),window.sessionStorage.removeItem(t)}catch{Es=!1}return Es};var mO={name:"sessionStorage",lookup(t){let{lookupSessionStorage:e}=t;if(e&&Ny())return window.sessionStorage.getItem(e)||void 0},cacheUserLanguage(t,e){let{lookupSessionStorage:r}=e;r&&Ny()&&window.sessionStorage.setItem(r,t)}},gO={name:"navigator",lookup(t){const e=[];if(typeof navigator<"u"){const{languages:r,userLanguage:s,language:i}=navigator;if(r)for(let l=0;l0?e:void 0}},yO={name:"htmlTag",lookup(t){let{htmlTag:e}=t,r;const s=e||(typeof document<"u"?document.documentElement:null);return s&&typeof s.getAttribute=="function"&&(r=s.getAttribute("lang")),r}},vO={name:"path",lookup(t){var i;let{lookupFromPathIndex:e}=t;if(typeof window>"u")return;const r=window.location.pathname.match(/\/([a-zA-Z-]*)/g);return Array.isArray(r)?(i=r[typeof e=="number"?e:0])==null?void 0:i.replace("/",""):void 0}},xO={name:"subdomain",lookup(t){var i,l;let{lookupFromSubdomainIndex:e}=t;const r=typeof e=="number"?e+1:1,s=typeof window<"u"&&((l=(i=window.location)==null?void 0:i.hostname)==null?void 0:l.match(/^(\w{2,5})\.(([a-z0-9-]{1,63}\.[a-z]{2,6})|localhost)/i));if(s)return s[r]}};let sw=!1;try{document.cookie,sw=!0}catch{}const iw=["querystring","cookie","localStorage","sessionStorage","navigator","htmlTag"];sw||iw.splice(1,1);const wO=()=>({order:iw,lookupQuerystring:"lng",lookupCookie:"i18next",lookupLocalStorage:"i18nextLng",lookupSessionStorage:"i18nextLng",caches:["localStorage"],excludeCacheFor:["cimode"],convertDetectedLanguage:t=>t});class aw{constructor(e){let r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{};this.type="languageDetector",this.detectors={},this.init(e,r)}init(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{languageUtils:{}},r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},s=arguments.length>2&&arguments[2]!==void 0?arguments[2]:{};this.services=e,this.options=lO(r,this.options||{},wO()),typeof this.options.convertDetectedLanguage=="string"&&this.options.convertDetectedLanguage.indexOf("15897")>-1&&(this.options.convertDetectedLanguage=i=>i.replace("-","_")),this.options.lookupFromUrlIndex&&(this.options.lookupFromPathIndex=this.options.lookupFromUrlIndex),this.i18nOptions=s,this.addDetector(dO),this.addDetector(fO),this.addDetector(pO),this.addDetector(mO),this.addDetector(gO),this.addDetector(yO),this.addDetector(vO),this.addDetector(xO),this.addDetector(hO)}addDetector(e){return this.detectors[e.name]=e,this}detect(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:this.options.order,r=[];return e.forEach(s=>{if(this.detectors[s]){let i=this.detectors[s].lookup(this.options);i&&typeof i=="string"&&(i=[i]),i&&(r=r.concat(i))}}),r=r.filter(s=>s!=null&&!cO(s)).map(s=>this.options.convertDetectedLanguage(s)),this.services&&this.services.languageUtils&&this.services.languageUtils.getBestMatchFromCodes?r:r.length>0?r[0]:null}cacheUserLanguage(e){let r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:this.options.caches;r&&(this.options.excludeCacheFor&&this.options.excludeCacheFor.indexOf(e)>-1||r.forEach(s=>{this.detectors[s]&&this.detectors[s].cacheUserLanguage(e,this.options)}))}}aw.type="languageDetector";const bO={active:"运行中",standby:"待机",idle:"空闲",running:"运行中",stopping:"停止中",error:"错误"},SO={title:"MediaCrawler",api:"API",local:"本地",disclaimer:"仅供个人学习研究使用,禁止商业用途",license:"查看协议"},CO={loading:"加载中...",skip:"跳过检测",retry:"重新扫描"},EO={entries:"条记录",records:"条"},kO={status:bO,sidebar:SO,action:CO,unit:EO},NO={targetMatrix:{title:"目标配置",description:"平台、模式与搜索参数"},authMatrix:{title:"登录配置",description:"登录方式配置"},outputConfig:{title:"输出配置",description:"保存格式与评论选项"},runtime:{title:"运行参数",description:"运行时参数"}},RO={platform:"平台",platformPlaceholder:"选择平台",crawlType:"爬取类型",crawlTypePlaceholder:"选择类型",startPage:"起始页",keywords:"关键词",keywordsHint:"输入关键词后按回车添加",keywordsPlaceholder:"输入关键词,按回车添加...",specifiedIds:"帖子/视频 ID",specifiedIdsHint:"输入帖子或视频的ID/URL,每行一个或用逗号分隔",specifiedIdsPlaceholder:{bili:`示例: BV1xxxx https://www.bilibili.com/video/BV1xxxx`,xhs:`示例: https://www.xiaohongshu.com/explore/xxx?xsec_token=xxx (必须包含xsec_token参数)`,dy:`示例: 7525538910311632128 https://www.douyin.com/video/xxx https://v.douyin.com/xxx (短链接)`,wb:`示例: 4982041758140155 https://weibo.com/xxx/xxx`,ks:`示例: 3xf8enb8dbj6uig https://www.kuaishou.com/short-video/xxx`,default:"输入帖子/视频 ID 或 URL..."},creatorIds:"创作者 ID",creatorIdsHint:"输入创作者的ID/URL,每行一个或用逗号分隔",creatorIdsPlaceholder:{bili:`示例: 434377496 https://space.bilibili.com/434377496`,xhs:`示例: https://www.xiaohongshu.com/user/profile/xxx?xsec_token=xxx (必须包含xsec_token参数)`,dy:`示例: MS4wLjABAAAAxxx https://www.douyin.com/user/MS4wLjABAAAAxxx`,wb:`示例: 5533390220 https://weibo.com/u/5533390220`,ks:`示例: 3x84qugg4ch9zhs https://www.kuaishou.com/profile/xxx`,default:"输入创作者 ID 或 URL..."},loginMethod:"登录方式",loginMethodPlaceholder:"选择登录方式",cookies:"Cookies",cookiesHint:"粘贴 Cookie 字符串",cookiesPlaceholder:"在此粘贴 Cookies...",saveFormat:"保存格式",saveFormatPlaceholder:"选择格式",commentExtraction:"评论抓取",subComments:"子评论",headlessMode:"无头模式",headlessModeHint:"无 GUI 运行浏览器"},PO={initiateScan:"开始爬虫",initiating:"启动中...",terminate:"终止",stopping:"停止中..."},TO={cookieSlider:"[提示] 小红书和抖音平台不建议使用 Cookie 登录,因为可能会触发滑块验证",xhsToken:"[重要] 小红书 URL 必须包含 xsec_token 参数,请从浏览器复制完整 URL"},OO={section:NO,field:RO,button:PO,warning:TO},jO={title:"系统控制台",entries:"{{count}} 条记录",active:"活跃",clear:"清除",restore:"恢复所有日志"},_O={systemInit:"[系统] 系统初始化成功",configHint:"[信息] 配置参数并启动扫描序列..."},AO={awaiting:"等待命令..."},LO={header:jO,banner:_O,footer:AO},IO={button:"数据管理",title:"数据浏览器"},DO={title:"数据文件管理",records:"{{count}} 条",rescan:"重新扫描",loading:"[加载中] 正在扫描数据目录...",noData:"暂无数据",noDataHint:"启动爬虫开始数据采集,完成后结果将显示在此处。",allCategories:"全部"},MO={entries:"{{count}} 条",extract:"下载",preview:"预览"},FO={title:"数据预览",records:"{{count}} 条记录",download:"下载",loading:"[加载中] 正在解析数据...",error:"[错误] 数据解析失败",searchPlaceholder:"搜索数据...",showing:"显示 {{filtered}} / {{total}} 条记录"},zO={dialog:IO,explorer:DO,file:MO,preview:FO},$O="环境检测",UO="[扫描] 正在分析 MediaCrawler 环境...",BO="[成功] {{message}}",HO="[错误] {{message}}",VO="无法连接到 API 服务器",WO="请确保后端服务已启动 (uvicorn api.main:app --port 8080)",KO="[+] 显示详情",qO="[-] 隐藏详情",QO="[环境要求]",YO={1:"安装 uv 包管理器",2:"在项目根目录执行 uv sync",3:"确认所有依赖已安装"},GO="跳过检测",XO="重新检测",JO={title:$O,scanning:UO,success:BO,error:HO,defaultError:VO,defaultErrorHint:WO,showDetails:KO,hideDetails:qO,requirements:QO,requirementsList:YO,skipCheck:GO,retryCheck:XO},ZO="使用须知",ej="请仔细阅读以下条款",tj={line1:"本项目仅供个人学习和研究爬虫技术使用",line2:"严禁将本项目用于任何商业用途或盈利活动",line3:"使用本项目即表示您同意遵守相关法律法规",line4:"任何因违规使用造成的后果由使用者自行承担"},nj="查看完整协议",rj="访问 GitHub 仓库",oj="我已知晓并同意上述条款",sj="不同意,退出",ij={name:"程序员阿江-Relakkes",tagline:"40K+ Star 开源项目作者",description:"40K开源项目MediaCrawler作者,专注爬虫技术和AI Agent知识分享",support:"如果觉得项目有帮助,欢迎关注作者的社交媒体账号",slogan:"开源不易,求个关注 ⭐"},aj={title:ZO,warning:ej,content:tj,license:nj,github:rj,confirm:oj,decline:sj,author:ij},lj={active:"ACTIVE",standby:"STANDBY",idle:"IDLE",running:"RUNNING",stopping:"STOPPING",error:"ERROR"},cj={title:"MediaCrawler",api:"API",local:"LOCAL",disclaimer:"For personal learning only, commercial use prohibited",license:"License"},uj={loading:"Loading...",skip:"SKIP_CHECK",retry:"RETRY_SCAN"},dj={entries:"entries",records:"records"},fj={status:lj,sidebar:cj,action:uj,unit:dj},hj={targetMatrix:{title:"TARGET_MATRIX",description:"Platform, mode & search parameters"},authMatrix:{title:"AUTH_MATRIX",description:"Login method configuration"},outputConfig:{title:"OUTPUT_CONFIG",description:"Save format & comment options"},runtime:{title:"RUNTIME",description:"Runtime parameters"}},pj={platform:"PLATFORM",platformPlaceholder:"Select platform",crawlType:"CRAWL_TYPE",crawlTypePlaceholder:"Select type",startPage:"START_PAGE",keywords:"KEYWORDS",keywordsHint:"Press Enter to add keyword",keywordsPlaceholder:"Type keyword, press Enter to add...",specifiedIds:"POST/VIDEO_ID",specifiedIdsHint:"Enter post or video ID/URL, one per line or comma-separated",specifiedIdsPlaceholder:{bili:`Examples: BV1xxxx https://www.bilibili.com/video/BV1xxxx`,xhs:`Examples: https://www.xiaohongshu.com/explore/xxx?xsec_token=xxx (must include xsec_token)`,dy:`Examples: 7525538910311632128 https://www.douyin.com/video/xxx https://v.douyin.com/xxx (short link)`,wb:`Examples: 4982041758140155 https://weibo.com/xxx/xxx`,ks:`Examples: 3xf8enb8dbj6uig https://www.kuaishou.com/short-video/xxx`,default:"Enter post/video ID or URL..."},creatorIds:"CREATOR_ID",creatorIdsHint:"Enter creator ID/URL, one per line or comma-separated",creatorIdsPlaceholder:{bili:`Examples: 434377496 https://space.bilibili.com/434377496`,xhs:`Examples: https://www.xiaohongshu.com/user/profile/xxx?xsec_token=xxx (must include xsec_token)`,dy:`Examples: MS4wLjABAAAAxxx https://www.douyin.com/user/MS4wLjABAAAAxxx`,wb:`Examples: 5533390220 https://weibo.com/u/5533390220`,ks:`Examples: 3x84qugg4ch9zhs https://www.kuaishou.com/profile/xxx`,default:"Enter creator ID or URL..."},loginMethod:"LOGIN_METHOD",loginMethodPlaceholder:"Select login method",cookies:"COOKIES",cookiesHint:"Paste cookie string",cookiesPlaceholder:"Paste cookies here...",saveFormat:"SAVE_FORMAT",saveFormatPlaceholder:"Select format",commentExtraction:"Comment Extraction",subComments:"Sub-comments",headlessMode:"HEADLESS_MODE",headlessModeHint:"Run browser without GUI"},mj={initiateScan:"INITIATE SCAN",initiating:"INITIATING...",terminate:"TERMINATE",stopping:"STOPPING..."},gj={cookieSlider:"[Note] Cookie login is not recommended for Xiaohongshu and Douyin due to slider captcha",xhsToken:"[Important] Xiaohongshu URLs must contain xsec_token parameter, please copy the full URL from browser"},yj={section:hj,field:pj,button:mj,warning:gj},vj={title:"SYSTEM_CONSOLE",entries:"{{count}} entries",active:"ACTIVE",clear:"CLEAR",restore:"RESTORE ALL LOGS"},xj={systemInit:"[SYS] System initialized successfully",configHint:"[INFO] Configure parameters and initiate scan sequence..."},wj={awaiting:"awaiting_command..."},bj={header:vj,banner:xj,footer:wj},Sj={button:"PAYLOAD_MATRIX",title:"DATA_EXPLORER"},Cj={title:"CAPTURED_PAYLOAD_MATRIX",records:"{{count}} RECORDS",rescan:"RESCAN",loading:"[LOADING] Scanning payload directory...",noData:"NO_DATA_CAPTURED",noDataHint:"Initialize crawler to begin data extraction sequence. Results will appear here upon completion.",allCategories:"ALL"},Ej={entries:"{{count}} entries",extract:"EXTRACT",preview:"PREVIEW"},kj={title:"Data Preview",records:"{{count}} RECORDS",download:"DOWNLOAD",loading:"[LOADING] Decoding payload...",error:"[ERROR] Failed to decode payload",searchPlaceholder:"Search in data...",showing:"Showing {{filtered}} of {{total}} records"},Nj={dialog:Sj,explorer:Cj,file:Ej,preview:kj},Rj="ENV_DIAGNOSTICS",Pj="[SCAN] Analyzing MediaCrawler environment...",Tj="[OK] {{message}}",Oj="[ERR] {{message}}",jj="Cannot connect to API server",_j="Please ensure backend service is running (uvicorn api.main:app --port 8080)",Aj="[+] SHOW_DETAILS",Lj="[-] HIDE_DETAILS",Ij="[REQUIREMENTS]",Dj={1:"Install uv package manager",2:"Execute uv sync in project root",3:"Verify all dependencies installed"},Mj="SKIP_CHECK",Fj="RETRY_SCAN",zj={title:Rj,scanning:Pj,success:Tj,error:Oj,defaultError:jj,defaultErrorHint:_j,showDetails:Aj,hideDetails:Lj,requirements:Ij,requirementsList:Dj,skipCheck:Mj,retryCheck:Fj},$j="Usage Notice",Uj="Please read the following terms carefully",Bj={line1:"This project is for personal learning and research purposes only",line2:"Commercial use or profit-making activities are strictly prohibited",line3:"By using this project, you agree to comply with all applicable laws",line4:"Users bear full responsibility for any consequences of misuse"},Hj="View Full License",Vj="Visit GitHub Repository",Wj="I understand and agree to the above terms",Kj="Disagree, Exit",qj={name:"Relakkes (阿江)",tagline:"40K+ Star Open Source Author",description:"Author of MediaCrawler (40K stars), focused on crawler tech and AI Agent knowledge sharing",support:"If you find this project helpful, please follow the author on social media",slogan:"Open source is hard, please star ⭐"},Qj={title:$j,warning:Uj,content:Bj,license:Hj,github:Vj,confirm:Wj,decline:Kj,author:qj},Yj={"zh-CN":{common:kO,config:OO,terminal:LO,data:zO,env:JO,license:aj},"en-US":{common:fj,config:yj,terminal:bj,data:Nj,env:zj,license:Qj}};_t.use(aw).use(bC).init({resources:Yj,fallbackLng:"zh-CN",defaultNS:"common",interpolation:{escapeValue:!1},detection:{order:["localStorage","navigator"],caches:["localStorage"],lookupLocalStorage:"mediacrawler_language"}});const Gj=new A1({defaultOptions:{queries:{refetchOnWindowFocus:!1,retry:1}}});u1.createRoot(document.getElementById("root")).render(g.jsx(oe.StrictMode,{children:g.jsx(L1,{client:Gj,children:g.jsx(sO,{})})})); ================================================ FILE: api/webui/assets/index-OiBmsgXF.css ================================================ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:JetBrains Mono,Fira Code,Consolas,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background: 210 20% 98%;--foreground: 220 20% 15%;--card: 0 0% 100%;--card-foreground: 220 20% 15%;--popover: 0 0% 100%;--popover-foreground: 220 20% 15%;--primary: 187 80% 42%;--primary-foreground: 0 0% 100%;--secondary: 330 75% 55%;--secondary-foreground: 0 0% 100%;--muted: 210 20% 95%;--muted-foreground: 215 15% 45%;--accent: 187 80% 42%;--accent-foreground: 0 0% 100%;--destructive: 330 75% 55%;--destructive-foreground: 0 0% 100%;--border: 214 20% 88%;--input: 214 20% 88%;--ring: 187 80% 42%;--radius: .375rem;--cyber-bg-primary: 248 250 252;--cyber-bg-secondary: 241 245 249;--cyber-bg-tertiary: 226 232 240;--cyber-bg-panel: 255 255 255;--cyber-bg-elevated: 248 250 252;--cyber-neon-cyan: 6 182 212;--cyber-neon-cyan-dim: 8 145 178;--cyber-neon-pink: 236 72 153;--cyber-neon-pink-dim: 219 39 119;--cyber-neon-green: 34 197 94;--cyber-neon-green-dim: 22 163 74;--cyber-neon-orange: 249 115 22;--cyber-neon-yellow: 234 179 8;--cyber-neon-purple: 168 85 247;--cyber-text-primary: 15 23 42;--cyber-text-secondary: 71 85 105;--cyber-text-muted: 148 163 184;--cyber-border-default: 203 213 225;--cyber-border-subtle: 226 232 240;--cyber-border-glow: 6 182 212;--glass-bg: 255 255 255 / .85;--glass-border: 203 213 225 / .5;--glass-dark-bg: 241 245 249 / .9;--glass-dark-border: 203 213 225 / .6;--shadow-glow-opacity: .2;--shadow-glow-spread: 6px}.dark{--background: 233 67% 4%;--foreground: 210 20% 93%;--card: 215 21% 11%;--card-foreground: 210 20% 93%;--popover: 213 18% 13%;--popover-foreground: 210 20% 93%;--primary: 180 100% 50%;--primary-foreground: 233 67% 4%;--secondary: 330 100% 50%;--secondary-foreground: 0 0% 100%;--muted: 215 14% 17%;--muted-foreground: 215 16% 57%;--accent: 180 100% 50%;--accent-foreground: 233 67% 4%;--destructive: 330 100% 50%;--destructive-foreground: 0 0% 100%;--border: 215 14% 25%;--input: 215 14% 25%;--ring: 180 100% 50%;--cyber-bg-primary: 10 10 15;--cyber-bg-secondary: 13 17 23;--cyber-bg-tertiary: 22 27 34;--cyber-bg-panel: 28 33 40;--cyber-bg-elevated: 33 38 45;--cyber-neon-cyan: 0 255 255;--cyber-neon-cyan-dim: 37 177 191;--cyber-neon-pink: 255 0 128;--cyber-neon-pink-dim: 222 40 59;--cyber-neon-green: 0 255 65;--cyber-neon-green-dim: 57 255 20;--cyber-neon-orange: 255 152 0;--cyber-neon-yellow: 255 234 0;--cyber-neon-purple: 191 0 255;--cyber-text-primary: 230 237 243;--cyber-text-secondary: 139 148 158;--cyber-text-muted: 72 79 88;--cyber-border-default: 48 54 61;--cyber-border-subtle: 33 38 45;--cyber-border-glow: 0 255 255;--glass-bg: 22 27 34 / .7;--glass-border: 48 54 61 / .5;--glass-dark-bg: 13 17 23 / .85;--glass-dark-border: 48 54 61 / .4;--shadow-glow-opacity: .5;--shadow-glow-spread: 10px}*{border-color:rgb(var(--cyber-border-default))}body{background-color:rgb(var(--cyber-bg-primary));color:rgb(var(--cyber-text-primary));font-family:Inter,system-ui,sans-serif;font-size:15px;line-height:1.6;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.bottom-0{bottom:0}.left-0{left:0}.left-2{left:.5rem}.left-3{left:.75rem}.left-\[50\%\]{left:50%}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.top-1\/2{top:50%}.top-4{top:1rem}.top-\[50\%\]{top:50%}.z-10{z-index:10}.z-50{z-index:50}.z-\[100\]{z-index:100}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.my-1{margin-top:.25rem;margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-0\.5{margin-left:.125rem}.mr-1{margin-right:.25rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-96{max-height:24rem}.max-h-\[85vh\]{max-height:85vh}.max-h-\[calc\(85vh-100px\)\]{max-height:calc(85vh - 100px)}.min-h-0{min-height:0px}.min-h-\[60px\]{min-height:60px}.min-h-\[80px\]{min-height:80px}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-\[120px\]{max-width:120px}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.-translate-x-full{--tw-translate-x: -100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-\[-50\%\]{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-\[-50\%\]{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse-fast{animation:pulse 1.5s cubic-bezier(.4,0,.6,1) infinite}@keyframes slideUp{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}.animate-slide-up{animation:slideUp .3s ease-out forwards}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-t-2{border-top-width:2px}.border-\[\#30363d\]{--tw-border-opacity: 1;border-color:rgb(48 54 61 / var(--tw-border-opacity, 1))}.border-cyber-border-subtle{--tw-border-opacity: 1;border-color:rgb(var(--cyber-border-subtle) / var(--tw-border-opacity, 1))}.border-cyber-border-subtle\/50{border-color:rgb(var(--cyber-border-subtle) / .5)}.border-cyber-neon-cyan{--tw-border-opacity: 1;border-color:rgb(var(--cyber-neon-cyan) / var(--tw-border-opacity, 1))}.border-cyber-neon-cyan\/30{border-color:rgb(var(--cyber-neon-cyan) / .3)}.border-cyber-neon-cyan\/50{border-color:rgb(var(--cyber-neon-cyan) / .5)}.border-cyber-neon-cyan\/60{border-color:rgb(var(--cyber-neon-cyan) / .6)}.border-cyber-neon-green\/30{border-color:rgb(var(--cyber-neon-green) / .3)}.border-cyber-neon-green\/50{border-color:rgb(var(--cyber-neon-green) / .5)}.border-cyber-neon-orange\/30{border-color:rgb(var(--cyber-neon-orange) / .3)}.border-cyber-neon-orange\/50{border-color:rgb(var(--cyber-neon-orange) / .5)}.border-cyber-neon-pink{--tw-border-opacity: 1;border-color:rgb(var(--cyber-neon-pink) / var(--tw-border-opacity, 1))}.border-cyber-neon-pink\/30{border-color:rgb(var(--cyber-neon-pink) / .3)}.border-cyber-neon-pink\/50{border-color:rgb(var(--cyber-neon-pink) / .5)}.border-cyber-neon-yellow\/30{border-color:rgb(var(--cyber-neon-yellow) / .3)}.border-l-transparent{border-left-color:transparent}.border-t-transparent{border-top-color:transparent}.bg-\[\#0d1117\]{--tw-bg-opacity: 1;background-color:rgb(13 17 23 / var(--tw-bg-opacity, 1))}.bg-\[\#161b22\]{--tw-bg-opacity: 1;background-color:rgb(22 27 34 / var(--tw-bg-opacity, 1))}.bg-\[\#21262d\]{--tw-bg-opacity: 1;background-color:rgb(33 38 45 / var(--tw-bg-opacity, 1))}.bg-black{--tw-bg-opacity: 1;background-color:rgb(0 0 0 / var(--tw-bg-opacity, 1))}.bg-black\/50{background-color:#00000080}.bg-black\/60{background-color:#0009}.bg-black\/80{background-color:#000c}.bg-black\/95{background-color:#000000f2}.bg-border{background-color:hsl(var(--border))}.bg-cyber-bg-panel{--tw-bg-opacity: 1;background-color:rgb(var(--cyber-bg-panel) / var(--tw-bg-opacity, 1))}.bg-cyber-bg-tertiary{--tw-bg-opacity: 1;background-color:rgb(var(--cyber-bg-tertiary) / var(--tw-bg-opacity, 1))}.bg-cyber-bg-tertiary\/30{background-color:rgb(var(--cyber-bg-tertiary) / .3)}.bg-cyber-bg-tertiary\/50{background-color:rgb(var(--cyber-bg-tertiary) / .5)}.bg-cyber-neon-cyan{--tw-bg-opacity: 1;background-color:rgb(var(--cyber-neon-cyan) / var(--tw-bg-opacity, 1))}.bg-cyber-neon-cyan\/10{background-color:rgb(var(--cyber-neon-cyan) / .1)}.bg-cyber-neon-cyan\/20{background-color:rgb(var(--cyber-neon-cyan) / .2)}.bg-cyber-neon-green{--tw-bg-opacity: 1;background-color:rgb(var(--cyber-neon-green) / var(--tw-bg-opacity, 1))}.bg-cyber-neon-green\/10{background-color:rgb(var(--cyber-neon-green) / .1)}.bg-cyber-neon-green\/20{background-color:rgb(var(--cyber-neon-green) / .2)}.bg-cyber-neon-green\/80{background-color:rgb(var(--cyber-neon-green) / .8)}.bg-cyber-neon-orange\/10{background-color:rgb(var(--cyber-neon-orange) / .1)}.bg-cyber-neon-orange\/5{background-color:rgb(var(--cyber-neon-orange) / .05)}.bg-cyber-neon-orange\/80{background-color:rgb(var(--cyber-neon-orange) / .8)}.bg-cyber-neon-pink{--tw-bg-opacity: 1;background-color:rgb(var(--cyber-neon-pink) / var(--tw-bg-opacity, 1))}.bg-cyber-neon-pink\/10{background-color:rgb(var(--cyber-neon-pink) / .1)}.bg-cyber-neon-pink\/20{background-color:rgb(var(--cyber-neon-pink) / .2)}.bg-cyber-neon-pink\/80{background-color:rgb(var(--cyber-neon-pink) / .8)}.bg-cyber-neon-yellow\/10{background-color:rgb(var(--cyber-neon-yellow) / .1)}.bg-transparent{background-color:transparent}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-transparent{--tw-gradient-from: transparent var(--tw-gradient-from-position);--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.via-cyber-neon-cyan\/5{--tw-gradient-to: rgb(var(--cyber-neon-cyan) / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(var(--cyber-neon-cyan) / .05) var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.fill-current{fill:currentColor}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-\[1px\]{padding:1px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-4{padding-bottom:1rem}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pl-9{padding-left:2.25rem}.pr-2{padding-right:.5rem}.pt-0{padding-top:0}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:JetBrains Mono,Fira Code,Consolas,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-\[\#8b949e\]{--tw-text-opacity: 1;color:rgb(139 148 158 / var(--tw-text-opacity, 1))}.text-\[\#c9d1d9\]{--tw-text-opacity: 1;color:rgb(201 209 217 / var(--tw-text-opacity, 1))}.text-black{--tw-text-opacity: 1;color:rgb(0 0 0 / var(--tw-text-opacity, 1))}.text-cyber-bg-primary{--tw-text-opacity: 1;color:rgb(var(--cyber-bg-primary) / var(--tw-text-opacity, 1))}.text-cyber-neon-cyan{--tw-text-opacity: 1;color:rgb(var(--cyber-neon-cyan) / var(--tw-text-opacity, 1))}.text-cyber-neon-cyan\/30{color:rgb(var(--cyber-neon-cyan) / .3)}.text-cyber-neon-cyan\/70{color:rgb(var(--cyber-neon-cyan) / .7)}.text-cyber-neon-green{--tw-text-opacity: 1;color:rgb(var(--cyber-neon-green) / var(--tw-text-opacity, 1))}.text-cyber-neon-green\/70{color:rgb(var(--cyber-neon-green) / .7)}.text-cyber-neon-green\/80{color:rgb(var(--cyber-neon-green) / .8)}.text-cyber-neon-orange{--tw-text-opacity: 1;color:rgb(var(--cyber-neon-orange) / var(--tw-text-opacity, 1))}.text-cyber-neon-pink{--tw-text-opacity: 1;color:rgb(var(--cyber-neon-pink) / var(--tw-text-opacity, 1))}.text-cyber-neon-yellow{--tw-text-opacity: 1;color:rgb(var(--cyber-neon-yellow) / var(--tw-text-opacity, 1))}.text-cyber-text-muted{--tw-text-opacity: 1;color:rgb(var(--cyber-text-muted) / var(--tw-text-opacity, 1))}.text-cyber-text-primary{--tw-text-opacity: 1;color:rgb(var(--cyber-text-primary) / var(--tw-text-opacity, 1))}.text-cyber-text-secondary{--tw-text-opacity: 1;color:rgb(var(--cyber-text-secondary) / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.underline-offset-4{text-underline-offset:4px}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.shadow-\[0_0_20px_rgba\(0\,0\,0\,0\.5\)\]{--tw-shadow: 0 0 20px rgba(0,0,0,.5);--tw-shadow-colored: 0 0 20px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_3px_rgba\(0\,255\,255\,0\.3\)\]{--tw-shadow: 0 0 3px rgba(0,255,255,.3);--tw-shadow-colored: 0 0 3px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_3px_rgba\(0\,255\,65\,0\.3\)\]{--tw-shadow: 0 0 3px rgba(0,255,65,.3);--tw-shadow-colored: 0 0 3px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_3px_rgba\(255\,0\,128\,0\.3\)\]{--tw-shadow: 0 0 3px rgba(255,0,128,.3);--tw-shadow-colored: 0 0 3px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_3px_rgba\(255\,152\,0\,0\.3\)\]{--tw-shadow: 0 0 3px rgba(255,152,0,.3);--tw-shadow-colored: 0 0 3px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-cyber-card{--tw-shadow: 0 0 1px rgb(var(--cyber-neon-cyan) / .5), 0 4px 20px rgba(0, 0, 0, .3);--tw-shadow-colored: 0 0 1px var(--tw-shadow-color), 0 4px 20px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-glow-cyan-sm{--tw-shadow: 0 0 5px rgb(var(--cyber-neon-cyan) / .4), 0 0 10px rgb(var(--cyber-neon-cyan) / .2);--tw-shadow-colored: 0 0 5px var(--tw-shadow-color), 0 0 10px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-glow-green-sm{--tw-shadow: 0 0 5px rgb(var(--cyber-neon-green) / .4), 0 0 10px rgb(var(--cyber-neon-green) / .2);--tw-shadow-colored: 0 0 5px var(--tw-shadow-color), 0 0 10px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-offset-background{--tw-ring-offset-color: hsl(var(--background))}.blur-xl{--tw-blur: blur(24px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur: blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-700{transition-duration:.7s}@keyframes enter{0%{opacity:var(--tw-enter-opacity, 1);transform:translate3d(var(--tw-enter-translate-x, 0),var(--tw-enter-translate-y, 0),0) scale3d(var(--tw-enter-scale, 1),var(--tw-enter-scale, 1),var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity, 1);transform:translate3d(var(--tw-exit-translate-x, 0),var(--tw-exit-translate-y, 0),0) scale3d(var(--tw-exit-scale, 1),var(--tw-exit-scale, 1),var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))}}.duration-200{animation-duration:.2s}.duration-300{animation-duration:.3s}.duration-700{animation-duration:.7s}.running{animation-play-state:running}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:rgb(var(--cyber-bg-secondary));border-left:1px solid rgb(var(--cyber-neon-cyan) / .1)}::-webkit-scrollbar-thumb{background:linear-gradient(180deg,rgb(var(--cyber-neon-cyan) / .4),rgb(var(--cyber-neon-pink) / .3));border-radius:0}::-webkit-scrollbar-thumb:hover{background:linear-gradient(180deg,rgb(var(--cyber-neon-cyan) / .6),rgb(var(--cyber-neon-pink) / .5))}::-webkit-scrollbar-corner{background:rgb(var(--cyber-bg-secondary))}.terminal-scroll::-webkit-scrollbar{width:6px}.terminal-scroll::-webkit-scrollbar-track{background:#000}.terminal-scroll::-webkit-scrollbar-thumb{background:#00ff4166;border-radius:0}.terminal-scroll::-webkit-scrollbar-thumb:hover{background:#00ff4199}.cyber-grid{background-color:rgb(var(--cyber-bg-primary));background-image:linear-gradient(rgb(var(--cyber-neon-cyan) / .03) 1px,transparent 1px),linear-gradient(90deg,rgb(var(--cyber-neon-cyan) / .03) 1px,transparent 1px),radial-gradient(ellipse at 50% 0%,rgb(var(--cyber-neon-cyan) / .06) 0%,transparent 60%);background-size:50px 50px,50px 50px,100% 100%;background-position:-1px -1px,-1px -1px,center top}.dark .cyber-grid{background-image:linear-gradient(rgba(0,255,255,.02) 1px,transparent 1px),linear-gradient(90deg,rgba(0,255,255,.02) 1px,transparent 1px),radial-gradient(ellipse at 50% 0%,rgba(0,255,255,.08) 0%,transparent 60%)}.glass-panel{background:rgb(var(--glass-bg));backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid rgb(var(--glass-border))}.glass-panel-dark{background:rgb(var(--glass-dark-bg));backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgb(var(--glass-dark-border))}.float-panel{box-shadow:0 8px 32px #0000001a,0 0 1px rgb(var(--cyber-neon-cyan) / .1)}.dark .float-panel{box-shadow:0 8px 32px #0006,0 0 1px #00ffff26}.elevated-panel{box-shadow:0 12px 40px #00000026}.dark .elevated-panel{box-shadow:0 12px 40px #00000080}.glow-text-cyan{text-shadow:0 1px 3px rgb(var(--cyber-neon-cyan) / .3)}.dark .glow-text-cyan{text-shadow:0 0 5px rgba(0,255,255,.8),0 0 10px rgba(0,255,255,.5),0 0 20px rgba(0,255,255,.3)}.glow-text-pink{text-shadow:0 1px 3px rgb(var(--cyber-neon-pink) / .3)}.dark .glow-text-pink{text-shadow:0 0 5px rgba(255,0,128,.8),0 0 10px rgba(255,0,128,.5),0 0 20px rgba(255,0,128,.3)}.glow-text-green{text-shadow:0 1px 3px rgb(var(--cyber-neon-green) / .3)}.dark .glow-text-green{text-shadow:0 0 5px rgba(0,255,65,.8),0 0 10px rgba(0,255,65,.5),0 0 20px rgba(0,255,65,.3)}.glow-cyan{box-shadow:0 4px 12px rgb(var(--cyber-neon-cyan) / var(--shadow-glow-opacity))}.dark .glow-cyan{box-shadow:0 0 5px #00ffff80,0 0 10px #00ffff4d,0 0 20px #0ff3,inset 0 0 5px #00ffff1a}.glow-pink{box-shadow:0 4px 12px rgb(var(--cyber-neon-pink) / var(--shadow-glow-opacity))}.dark .glow-pink{box-shadow:0 0 5px #ff008080,0 0 10px #ff00804d,0 0 20px #ff008033}.glow-green{box-shadow:0 4px 12px rgb(var(--cyber-neon-green) / var(--shadow-glow-opacity))}.dark .glow-green{box-shadow:0 0 5px #00ff4180,0 0 10px #00ff414d,0 0 20px #00ff4133}.cyber-corner{position:relative}.cyber-corner:before,.cyber-corner:after{content:"";position:absolute;width:12px;height:12px;pointer-events:none}.cyber-corner:before{top:-1px;left:-1px;border-top:2px solid rgb(var(--cyber-neon-cyan) / .5);border-left:2px solid rgb(var(--cyber-neon-cyan) / .5)}.cyber-corner:after{bottom:-1px;right:-1px;border-bottom:2px solid rgb(var(--cyber-neon-cyan) / .5);border-right:2px solid rgb(var(--cyber-neon-cyan) / .5)}.cyber-corners{position:relative}.cyber-corners:before{content:"";position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(to right,rgb(var(--cyber-neon-cyan) / .5) 12px,transparent 12px) 0 0,linear-gradient(to right,rgb(var(--cyber-neon-cyan) / .5) 12px,transparent 12px) 0 100%,linear-gradient(to left,rgb(var(--cyber-neon-cyan) / .5) 12px,transparent 12px) 100% 0,linear-gradient(to left,rgb(var(--cyber-neon-cyan) / .5) 12px,transparent 12px) 100% 100%,linear-gradient(to bottom,rgb(var(--cyber-neon-cyan) / .5) 12px,transparent 12px) 0 0,linear-gradient(to bottom,rgb(var(--cyber-neon-cyan) / .5) 12px,transparent 12px) 100% 0,linear-gradient(to top,rgb(var(--cyber-neon-cyan) / .5) 12px,transparent 12px) 0 100%,linear-gradient(to top,rgb(var(--cyber-neon-cyan) / .5) 12px,transparent 12px) 100% 100%;background-repeat:no-repeat;background-size:2px 12px,2px 12px,2px 12px,2px 12px,12px 2px,12px 2px,12px 2px,12px 2px;pointer-events:none}.card-scan{position:relative;overflow:hidden}.card-scan:after{content:"";position:absolute;top:0;left:-100%;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgb(var(--cyber-neon-cyan) / .1),transparent);transition:left .6s ease-in-out;pointer-events:none}.card-scan:hover:after{left:100%}.cursor-blink{animation:cursorBlink 1s step-end infinite}@keyframes cursorBlink{0%,to{opacity:1}50%{opacity:0}}.status-dot{width:8px;height:8px;border-radius:50%}.status-dot-online{background:rgb(var(--cyber-neon-green));box-shadow:0 0 8px rgb(var(--cyber-neon-green) / .6);animation:pulseGlow 2s ease-in-out infinite}.status-dot-offline{background:rgb(var(--cyber-text-muted))}.status-dot-warning{background:rgb(var(--cyber-neon-orange));box-shadow:0 0 8px rgb(var(--cyber-neon-orange) / .6)}.status-dot-error{background:rgb(var(--cyber-neon-pink));box-shadow:0 0 8px rgb(var(--cyber-neon-pink) / .6)}@keyframes pulseGlow{0%,to{box-shadow:0 0 5px currentColor}50%{box-shadow:0 0 15px currentColor,0 0 25px currentColor}}.cyber-focus{transition:box-shadow .2s ease-in-out}.cyber-focus:focus{outline:none;box-shadow:0 0 0 1px rgb(var(--cyber-neon-cyan)),0 0 10px rgb(var(--cyber-neon-cyan) / .3);border-color:rgb(var(--cyber-neon-cyan))}::selection{background:rgb(var(--cyber-neon-cyan) / .3);color:rgb(var(--cyber-text-primary))}::-moz-selection{background:rgb(var(--cyber-neon-cyan) / .3);color:rgb(var(--cyber-text-primary))}.will-change-transform{will-change:transform}.gpu-accelerate{transform:translateZ(0);backface-visibility:hidden}.file\:border-0::file-selector-button{border-width:0px}.file\:bg-transparent::file-selector-button{background-color:transparent}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.file\:text-foreground::file-selector-button{color:hsl(var(--foreground))}.placeholder\:text-cyber-text-muted::-moz-placeholder{--tw-text-opacity: 1;color:rgb(var(--cyber-text-muted) / var(--tw-text-opacity, 1))}.placeholder\:text-cyber-text-muted::placeholder{--tw-text-opacity: 1;color:rgb(var(--cyber-text-muted) / var(--tw-text-opacity, 1))}.hover\:scale-110:hover{--tw-scale-x: 1.1;--tw-scale-y: 1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-\[\#00ffff\]\/50:hover{border-color:#00ffff80}.hover\:border-cyber-neon-cyan:hover{--tw-border-opacity: 1;border-color:rgb(var(--cyber-neon-cyan) / var(--tw-border-opacity, 1))}.hover\:border-cyber-neon-cyan\/30:hover{border-color:rgb(var(--cyber-neon-cyan) / .3)}.hover\:border-cyber-neon-cyan\/50:hover{border-color:rgb(var(--cyber-neon-cyan) / .5)}.hover\:border-cyber-neon-green\/50:hover{border-color:rgb(var(--cyber-neon-green) / .5)}.hover\:border-cyber-neon-yellow\/50:hover{border-color:rgb(var(--cyber-neon-yellow) / .5)}.hover\:border-cyber-text-primary:hover{--tw-border-opacity: 1;border-color:rgb(var(--cyber-text-primary) / var(--tw-border-opacity, 1))}.hover\:border-pink-400:hover{--tw-border-opacity: 1;border-color:rgb(244 114 182 / var(--tw-border-opacity, 1))}.hover\:border-red-400:hover{--tw-border-opacity: 1;border-color:rgb(248 113 113 / var(--tw-border-opacity, 1))}.hover\:bg-\[\#00ffff\]\/10:hover{background-color:#00ffff1a}.hover\:bg-\[\#21262d\]:hover{--tw-bg-opacity: 1;background-color:rgb(33 38 45 / var(--tw-bg-opacity, 1))}.hover\:bg-\[\#21262d\]\/50:hover{background-color:#21262d80}.hover\:bg-\[\#ff0080\]\/10:hover{background-color:#ff00801a}.hover\:bg-cyber-bg-elevated\/50:hover{background-color:rgb(var(--cyber-bg-elevated) / .5)}.hover\:bg-cyber-bg-tertiary:hover{--tw-bg-opacity: 1;background-color:rgb(var(--cyber-bg-tertiary) / var(--tw-bg-opacity, 1))}.hover\:bg-cyber-neon-cyan\/10:hover{background-color:rgb(var(--cyber-neon-cyan) / .1)}.hover\:bg-cyber-neon-cyan\/30:hover{background-color:rgb(var(--cyber-neon-cyan) / .3)}.hover\:bg-cyber-neon-cyan\/90:hover{background-color:rgb(var(--cyber-neon-cyan) / .9)}.hover\:bg-cyber-neon-green\/30:hover{background-color:rgb(var(--cyber-neon-green) / .3)}.hover\:bg-cyber-neon-green\/90:hover{background-color:rgb(var(--cyber-neon-green) / .9)}.hover\:bg-cyber-neon-orange\/20:hover{background-color:rgb(var(--cyber-neon-orange) / .2)}.hover\:bg-cyber-neon-pink\/10:hover{background-color:rgb(var(--cyber-neon-pink) / .1)}.hover\:bg-cyber-neon-pink\/30:hover{background-color:rgb(var(--cyber-neon-pink) / .3)}.hover\:bg-cyber-neon-pink\/90:hover{background-color:rgb(var(--cyber-neon-pink) / .9)}.hover\:text-\[\#00ffff\]:hover{--tw-text-opacity: 1;color:rgb(0 255 255 / var(--tw-text-opacity, 1))}.hover\:text-\[\#ff0080\]:hover{--tw-text-opacity: 1;color:rgb(255 0 128 / var(--tw-text-opacity, 1))}.hover\:text-cyber-neon-cyan:hover{--tw-text-opacity: 1;color:rgb(var(--cyber-neon-cyan) / var(--tw-text-opacity, 1))}.hover\:text-cyber-neon-pink:hover{--tw-text-opacity: 1;color:rgb(var(--cyber-neon-pink) / var(--tw-text-opacity, 1))}.hover\:text-cyber-text-primary:hover{--tw-text-opacity: 1;color:rgb(var(--cyber-text-primary) / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-\[0_0_10px_rgba\(248\,113\,113\,0\.4\)\]:hover{--tw-shadow: 0 0 10px rgba(248,113,113,.4);--tw-shadow-colored: 0 0 10px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-\[0_0_10px_rgba\(251\,113\,133\,0\.4\)\]:hover{--tw-shadow: 0 0 10px rgba(251,113,133,.4);--tw-shadow-colored: 0 0 10px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-\[0_0_10px_rgba\(255\,255\,255\,0\.3\)\]:hover{--tw-shadow: 0 0 10px rgba(255,255,255,.3);--tw-shadow-colored: 0 0 10px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-\[0_0_15px_rgb\(var\(--cyber-neon-cyan\)\/0\.15\)\]:hover{--tw-shadow: 0 0 15px rgb(var(--cyber-neon-cyan)/.15);--tw-shadow-colored: 0 0 15px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-glow-cyan:hover{--tw-shadow: 0 0 var(--shadow-glow-spread, 10px) rgb(var(--cyber-neon-cyan) / var(--shadow-glow-opacity, .5)), 0 0 calc(var(--shadow-glow-spread, 10px) * 2) rgb(var(--cyber-neon-cyan) / calc(var(--shadow-glow-opacity, .5) * .6)), 0 0 calc(var(--shadow-glow-spread, 10px) * 3) rgb(var(--cyber-neon-cyan) / calc(var(--shadow-glow-opacity, .5) * .2));--tw-shadow-colored: 0 0 var(--tw-shadow-color), 0 0 var(--tw-shadow-color), 0 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-glow-cyan-sm:hover{--tw-shadow: 0 0 5px rgb(var(--cyber-neon-cyan) / .4), 0 0 10px rgb(var(--cyber-neon-cyan) / .2);--tw-shadow-colored: 0 0 5px var(--tw-shadow-color), 0 0 10px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-glow-green-sm:hover{--tw-shadow: 0 0 5px rgb(var(--cyber-neon-green) / .4), 0 0 10px rgb(var(--cyber-neon-green) / .2);--tw-shadow-colored: 0 0 5px var(--tw-shadow-color), 0 0 10px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-glow-pink-sm:hover{--tw-shadow: 0 0 5px rgb(var(--cyber-neon-pink) / .4), 0 0 10px rgb(var(--cyber-neon-pink) / .2);--tw-shadow-colored: 0 0 5px var(--tw-shadow-color), 0 0 10px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.focus\:border-cyber-neon-cyan:focus{--tw-border-opacity: 1;border-color:rgb(var(--cyber-neon-cyan) / var(--tw-border-opacity, 1))}.focus\:bg-cyber-neon-cyan\/20:focus{background-color:rgb(var(--cyber-neon-cyan) / .2)}.focus\:text-cyber-neon-cyan:focus{--tw-text-opacity: 1;color:rgb(var(--cyber-neon-cyan) / var(--tw-text-opacity, 1))}.focus\:shadow-\[0_0_10px_rgb\(var\(--cyber-neon-cyan\)\/0\.2\)\]:focus{--tw-shadow: 0 0 10px rgb(var(--cyber-neon-cyan)/.2);--tw-shadow-colored: 0 0 10px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-cyber-neon-cyan:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(var(--cyber-neon-cyan) / var(--tw-ring-opacity, 1))}.focus\:ring-offset-2:focus{--tw-ring-offset-width: 2px}.focus-visible\:border-cyber-neon-cyan:focus-visible{--tw-border-opacity: 1;border-color:rgb(var(--cyber-neon-cyan) / var(--tw-border-opacity, 1))}.focus-visible\:border-cyber-neon-cyan\/50:focus-visible{border-color:rgb(var(--cyber-neon-cyan) / .5)}.focus-visible\:shadow-\[0_0_10px_rgb\(var\(--cyber-neon-cyan\)\/0\.2\)\]:focus-visible{--tw-shadow: 0 0 10px rgb(var(--cyber-neon-cyan)/.2);--tw-shadow-colored: 0 0 10px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.focus-visible\:shadow-cyber-soft:focus-visible{--tw-shadow: 0 4px 24px rgba(0, 0, 0, .2), 0 0 1px rgb(var(--cyber-neon-cyan) / .1);--tw-shadow-colored: 0 4px 24px var(--tw-shadow-color), 0 0 1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-1:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-cyber-neon-cyan:focus-visible{--tw-ring-opacity: 1;--tw-ring-color: rgb(var(--cyber-neon-cyan) / var(--tw-ring-opacity, 1))}.active\:scale-95:active{--tw-scale-x: .95;--tw-scale-y: .95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:translate-x-full{--tw-translate-x: 100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:border-cyber-neon-cyan{--tw-border-opacity: 1;border-color:rgb(var(--cyber-neon-cyan) / var(--tw-border-opacity, 1))}.peer:checked~.peer-checked\:bg-cyber-neon-cyan\/20{background-color:rgb(var(--cyber-neon-cyan) / .2)}.peer:checked~.peer-checked\:shadow-glow-cyan-sm{--tw-shadow: 0 0 5px rgb(var(--cyber-neon-cyan) / .4), 0 0 10px rgb(var(--cyber-neon-cyan) / .2);--tw-shadow-colored: 0 0 5px var(--tw-shadow-color), 0 0 10px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.peer:disabled~.peer-disabled\:cursor-not-allowed{cursor:not-allowed}.peer:disabled~.peer-disabled\:opacity-70{opacity:.7}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y: .25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x: -.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x: .25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y: -.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes accordion-up{0%{height:var(--radix-accordion-content-height)}to{height:0}}.data-\[state\=closed\]\:animate-accordion-up[data-state=closed]{animation:accordion-up .2s ease-out}@keyframes accordion-down{0%{height:0}to{height:var(--radix-accordion-content-height)}}.data-\[state\=open\]\:animate-accordion-down[data-state=open]{animation:accordion-down .2s ease-out}.data-\[state\=active\]\:bg-cyber-neon-cyan\/20[data-state=active]{background-color:rgb(var(--cyber-neon-cyan) / .2)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:hsl(var(--accent))}.data-\[state\=active\]\:text-cyber-neon-cyan[data-state=active]{--tw-text-opacity: 1;color:rgb(var(--cyber-neon-cyan) / var(--tw-text-opacity, 1))}.data-\[state\=open\]\:text-muted-foreground[data-state=open]{color:hsl(var(--muted-foreground))}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[state\=active\]\:shadow-glow-cyan-sm[data-state=active]{--tw-shadow: 0 0 5px rgb(var(--cyber-neon-cyan) / .4), 0 0 10px rgb(var(--cyber-neon-cyan) / .2);--tw-shadow-colored: 0 0 5px var(--tw-shadow-color), 0 0 10px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.data-\[state\=open\]\:animate-in[data-state=open]{animation-name:enter;animation-duration:.15s;--tw-enter-opacity: initial;--tw-enter-scale: initial;--tw-enter-rotate: initial;--tw-enter-translate-x: initial;--tw-enter-translate-y: initial}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation-name:exit;animation-duration:.15s;--tw-exit-opacity: initial;--tw-exit-scale: initial;--tw-exit-rotate: initial;--tw-exit-translate-x: initial;--tw-exit-translate-y: initial}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity: 0}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity: 0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale: .95}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale: .95}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y: -.5rem}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x: .5rem}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x: -.5rem}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y: .5rem}.data-\[state\=closed\]\:slide-out-to-left-1\/2[data-state=closed]{--tw-exit-translate-x: -50%}.data-\[state\=closed\]\:slide-out-to-top-\[48\%\][data-state=closed]{--tw-exit-translate-y: -48%}.data-\[state\=open\]\:slide-in-from-left-1\/2[data-state=open]{--tw-enter-translate-x: -50%}.data-\[state\=open\]\:slide-in-from-top-\[48\%\][data-state=open]{--tw-enter-translate-y: -48%}@media(min-width:640px){.sm\:inline{display:inline}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.sm\:text-left{text-align:left}}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:1024px){.lg\:flex{display:flex}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:1280px){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.\[\&\>span\]\:line-clamp-1>span{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1}.\[\&\[data-state\=open\]\>svg\]\:rotate-180[data-state=open]>svg{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{width:1rem;height:1rem}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0} ================================================ FILE: api/webui/index.html ================================================ MediaCrawler - Command Center
================================================ FILE: base/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/base/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 ================================================ FILE: base/base_crawler.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/base/base_crawler.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from abc import ABC, abstractmethod from typing import Dict, Optional from playwright.async_api import BrowserContext, BrowserType, Playwright class AbstractCrawler(ABC): @abstractmethod async def start(self): """ start crawler """ pass @abstractmethod async def search(self): """ search """ pass @abstractmethod async def launch_browser(self, chromium: BrowserType, playwright_proxy: Optional[Dict], user_agent: Optional[str], headless: bool = True) -> BrowserContext: """ launch browser :param chromium: chromium browser :param playwright_proxy: playwright proxy :param user_agent: user agent :param headless: headless mode :return: browser context """ pass async def launch_browser_with_cdp(self, playwright: Playwright, playwright_proxy: Optional[Dict], user_agent: Optional[str], headless: bool = True) -> BrowserContext: """ Launch browser using CDP mode (optional implementation) :param playwright: playwright instance :param playwright_proxy: playwright proxy configuration :param user_agent: user agent :param headless: headless mode :return: browser context """ # Default implementation: fallback to standard mode return await self.launch_browser(playwright.chromium, playwright_proxy, user_agent, headless) class AbstractLogin(ABC): @abstractmethod async def begin(self): pass @abstractmethod async def login_by_qrcode(self): pass @abstractmethod async def login_by_mobile(self): pass @abstractmethod async def login_by_cookies(self): pass class AbstractStore(ABC): @abstractmethod async def store_content(self, content_item: Dict): pass @abstractmethod async def store_comment(self, comment_item: Dict): pass # TODO support all platform # only xhs is supported, so @abstractmethod is commented @abstractmethod async def store_creator(self, creator: Dict): pass class AbstractStoreImage(ABC): # TODO: support all platform # only weibo is supported # @abstractmethod async def store_image(self, image_content_item: Dict): pass class AbstractStoreVideo(ABC): # TODO: support all platform # only weibo is supported # @abstractmethod async def store_video(self, video_content_item: Dict): pass class AbstractApiClient(ABC): @abstractmethod async def request(self, method, url, **kwargs): pass @abstractmethod async def update_cookies(self, browser_context: BrowserContext): pass ================================================ FILE: cache/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/cache/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 ================================================ FILE: cache/abs_cache.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/cache/abs_cache.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Name : Programmer AJiang-Relakkes # @Time : 2024/6/2 11:06 # @Desc : Abstract class from abc import ABC, abstractmethod from typing import Any, List, Optional class AbstractCache(ABC): @abstractmethod def get(self, key: str) -> Optional[Any]: """ Get the value of a key from the cache. This is an abstract method. Subclasses must implement this method. :param key: The key :return: """ raise NotImplementedError @abstractmethod def set(self, key: str, value: Any, expire_time: int) -> None: """ Set the value of a key in the cache. This is an abstract method. Subclasses must implement this method. :param key: The key :param value: The value :param expire_time: Expiration time :return: """ raise NotImplementedError @abstractmethod def keys(self, pattern: str) -> List[str]: """ Get all keys matching the pattern :param pattern: Matching pattern :return: """ raise NotImplementedError ================================================ FILE: cache/cache_factory.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/cache/cache_factory.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Name : Programmer AJiang-Relakkes # @Time : 2024/6/2 11:23 # @Desc : class CacheFactory: """ Cache factory class """ @staticmethod def create_cache(cache_type: str, *args, **kwargs): """ Create cache object :param cache_type: Cache type :param args: Arguments :param kwargs: Keyword arguments :return: """ if cache_type == 'memory': from .local_cache import ExpiringLocalCache return ExpiringLocalCache(*args, **kwargs) elif cache_type == 'redis': from .redis_cache import RedisCache return RedisCache() else: raise ValueError(f'Unknown cache type: {cache_type}') ================================================ FILE: cache/local_cache.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/cache/local_cache.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Name : Programmer AJiang-Relakkes # @Time : 2024/6/2 11:05 # @Desc : Local cache import asyncio import time from typing import Any, Dict, List, Optional, Tuple from cache.abs_cache import AbstractCache class ExpiringLocalCache(AbstractCache): def __init__(self, cron_interval: int = 10): """ Initialize local cache :param cron_interval: Time interval for scheduled cache cleanup :return: """ self._cron_interval = cron_interval self._cache_container: Dict[str, Tuple[Any, float]] = {} self._cron_task: Optional[asyncio.Task] = None # Start scheduled cleanup task self._schedule_clear() def __del__(self): """ Destructor function, cleanup scheduled task :return: """ if self._cron_task is not None: self._cron_task.cancel() def get(self, key: str) -> Optional[Any]: """ Get the value of a key from the cache :param key: :return: """ value, expire_time = self._cache_container.get(key, (None, 0)) if value is None: return None # If the key has expired, delete it and return None if expire_time < time.time(): del self._cache_container[key] return None return value def set(self, key: str, value: Any, expire_time: int) -> None: """ Set the value of a key in the cache :param key: :param value: :param expire_time: :return: """ self._cache_container[key] = (value, time.time() + expire_time) def keys(self, pattern: str) -> List[str]: """ Get all keys matching the pattern :param pattern: Matching pattern :return: """ if pattern == '*': return list(self._cache_container.keys()) # For local cache wildcard, temporarily replace * with empty string if '*' in pattern: pattern = pattern.replace('*', '') return [key for key in self._cache_container.keys() if pattern in key] def _schedule_clear(self): """ Start scheduled cleanup task :return: """ try: loop = asyncio.get_event_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) self._cron_task = loop.create_task(self._start_clear_cron()) def _clear(self): """ Clean up cache based on expiration time :return: """ for key, (value, expire_time) in self._cache_container.items(): if expire_time < time.time(): del self._cache_container[key] async def _start_clear_cron(self): """ Start scheduled cleanup task :return: """ while True: self._clear() await asyncio.sleep(self._cron_interval) if __name__ == '__main__': cache = ExpiringLocalCache(cron_interval=2) cache.set('name', 'Programmer AJiang-Relakkes', 3) print(cache.get('key')) print(cache.keys("*")) time.sleep(4) print(cache.get('key')) del cache time.sleep(1) print("done") ================================================ FILE: cache/redis_cache.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/cache/redis_cache.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Name : Programmer AJiang-Relakkes # @Time : 2024/5/29 22:57 # @Desc : RedisCache implementation import pickle import time from typing import Any, List from redis import Redis from redis.exceptions import ResponseError from cache.abs_cache import AbstractCache from config import db_config class RedisCache(AbstractCache): def __init__(self) -> None: # Connect to redis, return redis client self._redis_client = self._connet_redis() @staticmethod def _connet_redis() -> Redis: """ Connect to redis, return redis client, configure redis connection information as needed :return: """ return Redis( host=db_config.REDIS_DB_HOST, port=db_config.REDIS_DB_PORT, db=db_config.REDIS_DB_NUM, password=db_config.REDIS_DB_PWD, ) def get(self, key: str) -> Any: """ Get the value of a key from the cache and deserialize it :param key: :return: """ value = self._redis_client.get(key) if value is None: return None return pickle.loads(value) def set(self, key: str, value: Any, expire_time: int) -> None: """ Set the value of a key in the cache and serialize it :param key: :param value: :param expire_time: :return: """ self._redis_client.set(key, pickle.dumps(value), ex=expire_time) def keys(self, pattern: str) -> List[str]: """ Get all keys matching the pattern First try KEYS command, if not supported fallback to SCAN """ try: # Try KEYS command first (faster for standard Redis) return [key.decode() if isinstance(key, bytes) else key for key in self._redis_client.keys(pattern)] except ResponseError as e: # If KEYS is not supported (e.g., Redis Cluster or cloud Redis), use SCAN if "unknown command" in str(e).lower() or "keys" in str(e).lower(): keys_list: List[str] = [] cursor = 0 while True: cursor, keys = self._redis_client.scan(cursor=cursor, match=pattern, count=100) keys_list.extend([key.decode() if isinstance(key, bytes) else key for key in keys]) if cursor == 0: break return keys_list else: # Re-raise if it's a different error raise if __name__ == '__main__': redis_cache = RedisCache() # basic usage redis_cache.set("name", "Programmer AJiang-Relakkes", 1) print(redis_cache.get("name")) # Relakkes print(redis_cache.keys("*")) # ['name'] time.sleep(2) print(redis_cache.get("name")) # None # special python type usage # list redis_cache.set("list", [1, 2, 3], 10) _value = redis_cache.get("list") print(_value, f"value type:{type(_value)}") # [1, 2, 3] ================================================ FILE: cmd_arg/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/cmd_arg/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from .arg import * ================================================ FILE: cmd_arg/arg.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/cmd_arg/arg.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from __future__ import annotations import sys from enum import Enum from types import SimpleNamespace from typing import Iterable, Optional, Sequence, Type, TypeVar import typer from typing_extensions import Annotated import config from tools.utils import str2bool EnumT = TypeVar("EnumT", bound=Enum) class PlatformEnum(str, Enum): """Supported media platform enumeration""" XHS = "xhs" DOUYIN = "dy" KUAISHOU = "ks" BILIBILI = "bili" WEIBO = "wb" TIEBA = "tieba" ZHIHU = "zhihu" class LoginTypeEnum(str, Enum): """Login type enumeration""" QRCODE = "qrcode" PHONE = "phone" COOKIE = "cookie" class CrawlerTypeEnum(str, Enum): """Crawler type enumeration""" SEARCH = "search" DETAIL = "detail" CREATOR = "creator" class SaveDataOptionEnum(str, Enum): """Data save option enumeration""" CSV = "csv" DB = "db" JSON = "json" JSONL = "jsonl" SQLITE = "sqlite" MONGODB = "mongodb" EXCEL = "excel" POSTGRES = "postgres" class InitDbOptionEnum(str, Enum): """Database initialization option""" SQLITE = "sqlite" MYSQL = "mysql" POSTGRES = "postgres" def _to_bool(value: bool | str) -> bool: if isinstance(value, bool): return value return str2bool(value) def _coerce_enum( enum_cls: Type[EnumT], value: EnumT | str, default: EnumT, ) -> EnumT: """Safely convert a raw config value to an enum member.""" if isinstance(value, enum_cls): return value try: return enum_cls(value) except ValueError: typer.secho( f"⚠️ Config value '{value}' is not within the supported range of {enum_cls.__name__}, falling back to default value '{default.value}'.", fg=typer.colors.YELLOW, ) return default def _normalize_argv(argv: Optional[Sequence[str]]) -> Iterable[str]: if argv is None: return list(sys.argv[1:]) return list(argv) def _inject_init_db_default(args: Sequence[str]) -> list[str]: """Ensure bare --init_db defaults to sqlite for backward compatibility.""" normalized: list[str] = [] i = 0 while i < len(args): arg = args[i] normalized.append(arg) if arg == "--init_db": next_arg = args[i + 1] if i + 1 < len(args) else None if not next_arg or next_arg.startswith("-"): normalized.append(InitDbOptionEnum.SQLITE.value) i += 1 return normalized async def parse_cmd(argv: Optional[Sequence[str]] = None): """Parse command line arguments using Typer.""" app = typer.Typer(add_completion=False) @app.callback(invoke_without_command=True) def main( platform: Annotated[ PlatformEnum, typer.Option( "--platform", help="Media platform selection (xhs=XiaoHongShu | dy=Douyin | ks=Kuaishou | bili=Bilibili | wb=Weibo | tieba=Baidu Tieba | zhihu=Zhihu)", rich_help_panel="Basic Configuration", ), ] = _coerce_enum(PlatformEnum, config.PLATFORM, PlatformEnum.XHS), lt: Annotated[ LoginTypeEnum, typer.Option( "--lt", help="Login type (qrcode=QR Code | phone=Phone | cookie=Cookie)", rich_help_panel="Account Configuration", ), ] = _coerce_enum(LoginTypeEnum, config.LOGIN_TYPE, LoginTypeEnum.QRCODE), crawler_type: Annotated[ CrawlerTypeEnum, typer.Option( "--type", help="Crawler type (search=Search | detail=Detail | creator=Creator)", rich_help_panel="Basic Configuration", ), ] = _coerce_enum(CrawlerTypeEnum, config.CRAWLER_TYPE, CrawlerTypeEnum.SEARCH), start: Annotated[ int, typer.Option( "--start", help="Starting page number", rich_help_panel="Basic Configuration", ), ] = config.START_PAGE, keywords: Annotated[ str, typer.Option( "--keywords", help="Enter keywords, multiple keywords separated by commas", rich_help_panel="Basic Configuration", ), ] = config.KEYWORDS, get_comment: Annotated[ str, typer.Option( "--get_comment", help="Whether to crawl first-level comments, supports yes/true/t/y/1 or no/false/f/n/0", rich_help_panel="Comment Configuration", show_default=True, ), ] = str(config.ENABLE_GET_COMMENTS), get_sub_comment: Annotated[ str, typer.Option( "--get_sub_comment", help="Whether to crawl second-level comments, supports yes/true/t/y/1 or no/false/f/n/0", rich_help_panel="Comment Configuration", show_default=True, ), ] = str(config.ENABLE_GET_SUB_COMMENTS), headless: Annotated[ str, typer.Option( "--headless", help="Whether to enable headless mode (applies to both Playwright and CDP), supports yes/true/t/y/1 or no/false/f/n/0", rich_help_panel="Runtime Configuration", show_default=True, ), ] = str(config.HEADLESS), save_data_option: Annotated[ SaveDataOptionEnum, typer.Option( "--save_data_option", help="Data save option (csv=CSV file | db=MySQL database | json=JSON file | jsonl=JSONL file | sqlite=SQLite database | mongodb=MongoDB database | excel=Excel file | postgres=PostgreSQL database)", rich_help_panel="Storage Configuration", ), ] = _coerce_enum( SaveDataOptionEnum, config.SAVE_DATA_OPTION, SaveDataOptionEnum.JSONL ), init_db: Annotated[ Optional[InitDbOptionEnum], typer.Option( "--init_db", help="Initialize database table structure (sqlite | mysql | postgres)", rich_help_panel="Storage Configuration", ), ] = None, cookies: Annotated[ str, typer.Option( "--cookies", help="Cookie value used for Cookie login method", rich_help_panel="Account Configuration", ), ] = config.COOKIES, specified_id: Annotated[ str, typer.Option( "--specified_id", help="Post/video ID list in detail mode, multiple IDs separated by commas (supports full URL or ID)", rich_help_panel="Basic Configuration", ), ] = "", creator_id: Annotated[ str, typer.Option( "--creator_id", help="Creator ID list in creator mode, multiple IDs separated by commas (supports full URL or ID)", rich_help_panel="Basic Configuration", ), ] = "", max_comments_count_singlenotes: Annotated[ int, typer.Option( "--max_comments_count_singlenotes", help="Maximum number of first-level comments to crawl per post/video", rich_help_panel="Comment Configuration", ), ] = config.CRAWLER_MAX_COMMENTS_COUNT_SINGLENOTES, max_concurrency_num: Annotated[ int, typer.Option( "--max_concurrency_num", help="Maximum number of concurrent crawlers", rich_help_panel="Performance Configuration", ), ] = config.MAX_CONCURRENCY_NUM, save_data_path: Annotated[ str, typer.Option( "--save_data_path", help="Data save path, default is empty and will save to data folder", rich_help_panel="Storage Configuration", ), ] = config.SAVE_DATA_PATH, enable_ip_proxy: Annotated[ str, typer.Option( "--enable_ip_proxy", help="Whether to enable IP proxy, supports yes/true/t/y/1 or no/false/f/n/0", rich_help_panel="Proxy Configuration", show_default=True, ), ] = str(config.ENABLE_IP_PROXY), ip_proxy_pool_count: Annotated[ int, typer.Option( "--ip_proxy_pool_count", help="IP proxy pool count", rich_help_panel="Proxy Configuration", ), ] = config.IP_PROXY_POOL_COUNT, ip_proxy_provider_name: Annotated[ str, typer.Option( "--ip_proxy_provider_name", help="IP proxy provider name (kuaidaili | wandouhttp)", rich_help_panel="Proxy Configuration", ), ] = config.IP_PROXY_PROVIDER_NAME, ) -> SimpleNamespace: """MediaCrawler 命令行入口""" enable_comment = _to_bool(get_comment) enable_sub_comment = _to_bool(get_sub_comment) enable_headless = _to_bool(headless) enable_ip_proxy_value = _to_bool(enable_ip_proxy) init_db_value = init_db.value if init_db else None # Parse specified_id and creator_id into lists specified_id_list = [id.strip() for id in specified_id.split(",") if id.strip()] if specified_id else [] creator_id_list = [id.strip() for id in creator_id.split(",") if id.strip()] if creator_id else [] # override global config config.PLATFORM = platform.value config.LOGIN_TYPE = lt.value config.CRAWLER_TYPE = crawler_type.value config.START_PAGE = start config.KEYWORDS = keywords config.ENABLE_GET_COMMENTS = enable_comment config.ENABLE_GET_SUB_COMMENTS = enable_sub_comment config.HEADLESS = enable_headless config.CDP_HEADLESS = enable_headless config.SAVE_DATA_OPTION = save_data_option.value config.COOKIES = cookies config.CRAWLER_MAX_COMMENTS_COUNT_SINGLENOTES = max_comments_count_singlenotes config.MAX_CONCURRENCY_NUM = max_concurrency_num config.SAVE_DATA_PATH = save_data_path config.ENABLE_IP_PROXY = enable_ip_proxy_value config.IP_PROXY_POOL_COUNT = ip_proxy_pool_count config.IP_PROXY_PROVIDER_NAME = ip_proxy_provider_name # Set platform-specific ID lists for detail/creator mode if specified_id_list: if platform == PlatformEnum.XHS: config.XHS_SPECIFIED_NOTE_URL_LIST = specified_id_list elif platform == PlatformEnum.BILIBILI: config.BILI_SPECIFIED_ID_LIST = specified_id_list elif platform == PlatformEnum.DOUYIN: config.DY_SPECIFIED_ID_LIST = specified_id_list elif platform == PlatformEnum.WEIBO: config.WEIBO_SPECIFIED_ID_LIST = specified_id_list elif platform == PlatformEnum.KUAISHOU: config.KS_SPECIFIED_ID_LIST = specified_id_list if creator_id_list: if platform == PlatformEnum.XHS: config.XHS_CREATOR_ID_LIST = creator_id_list elif platform == PlatformEnum.BILIBILI: config.BILI_CREATOR_ID_LIST = creator_id_list elif platform == PlatformEnum.DOUYIN: config.DY_CREATOR_ID_LIST = creator_id_list elif platform == PlatformEnum.WEIBO: config.WEIBO_CREATOR_ID_LIST = creator_id_list elif platform == PlatformEnum.KUAISHOU: config.KS_CREATOR_ID_LIST = creator_id_list return SimpleNamespace( platform=config.PLATFORM, lt=config.LOGIN_TYPE, type=config.CRAWLER_TYPE, start=config.START_PAGE, keywords=config.KEYWORDS, get_comment=config.ENABLE_GET_COMMENTS, get_sub_comment=config.ENABLE_GET_SUB_COMMENTS, headless=config.HEADLESS, save_data_option=config.SAVE_DATA_OPTION, init_db=init_db_value, cookies=config.COOKIES, specified_id=specified_id, creator_id=creator_id, ) command = typer.main.get_command(app) cli_args = _normalize_argv(argv) cli_args = _inject_init_db_default(cli_args) try: result = command.main(args=cli_args, standalone_mode=False) if isinstance(result, int): # help/options handled by Typer; propagate exit code raise SystemExit(result) return result except typer.Exit as exc: # pragma: no cover - CLI exit paths raise SystemExit(exc.exit_code) from exc ================================================ FILE: config/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/config/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from .base_config import * from .db_config import * ================================================ FILE: config/base_config.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/config/base_config.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # Basic configuration PLATFORM = "xhs" # Platform, xhs | dy | ks | bili | wb | tieba | zhihu KEYWORDS = "编程副业,编程兼职" # Keyword search configuration, separated by English commas LOGIN_TYPE = "qrcode" # qrcode or phone or cookie COOKIES = "" CRAWLER_TYPE = ( "search" # Crawling type, search (keyword search) | detail (post details) | creator (creator homepage data) ) # Whether to enable IP proxy ENABLE_IP_PROXY = False # Number of proxy IP pools IP_PROXY_POOL_COUNT = 2 # Proxy IP provider name IP_PROXY_PROVIDER_NAME = "kuaidaili" # kuaidaili | wandouhttp # Setting to True will not open the browser (headless browser) # Setting False will open a browser # If Xiaohongshu keeps scanning the code to log in but fails, open the browser and manually pass the sliding verification code. # If Douyin keeps prompting failure, open the browser and see if mobile phone number verification appears after scanning the QR code to log in. If it does, manually go through it and try again. HEADLESS = False # Whether to save login status SAVE_LOGIN_STATE = True # ==================== CDP (Chrome DevTools Protocol) Configuration ==================== # Whether to enable CDP mode - use the user's existing Chrome/Edge browser to crawl, providing better anti-detection capabilities # Once enabled, the user's Chrome/Edge browser will be automatically detected and started, and controlled through the CDP protocol. # This method uses the real browser environment, including the user's extensions, cookies and settings, greatly reducing the risk of detection. ENABLE_CDP_MODE = True # CDP debug port, used to communicate with the browser # If the port is occupied, the system will automatically try the next available port CDP_DEBUG_PORT = 9222 # Custom browser path (optional) # If it is empty, the system will automatically detect the installation path of Chrome/Edge # Windows example: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" # macOS example: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" CUSTOM_BROWSER_PATH = "" # Whether to enable headless mode in CDP mode # NOTE: Even if set to True, some anti-detection features may not work well in headless mode CDP_HEADLESS = False # Browser startup timeout (seconds) BROWSER_LAUNCH_TIMEOUT = 60 # Whether to automatically close the browser when the program ends # Set to False to keep the browser running for easy debugging AUTO_CLOSE_BROWSER = True # Data saving type option configuration, supports: csv, db, json, jsonl, sqlite, excel, postgres. It is best to save to DB, with deduplication function. SAVE_DATA_OPTION = "jsonl" # csv or db or json or jsonl or sqlite or excel or postgres # Data saving path, if not specified by default, it will be saved to the data folder. SAVE_DATA_PATH = "" # Browser file configuration cached by the user's browser USER_DATA_DIR = "%s_user_data_dir" # %s will be replaced by platform name # The number of pages to start crawling starts from the first page by default START_PAGE = 1 # Control the number of crawled videos/posts CRAWLER_MAX_NOTES_COUNT = 15 # Controlling the number of concurrent crawlers MAX_CONCURRENCY_NUM = 1 # Whether to enable crawling media mode (including image or video resources), crawling media is not enabled by default ENABLE_GET_MEIDAS = False # Whether to enable comment crawling mode. Comment crawling is enabled by default. ENABLE_GET_COMMENTS = True # Control the number of crawled first-level comments (single video/post) CRAWLER_MAX_COMMENTS_COUNT_SINGLENOTES = 10 # Whether to enable the mode of crawling second-level comments. By default, crawling of second-level comments is not enabled. # If the old version of the project uses db, you need to refer to schema/tables.sql line 287 to add table fields. ENABLE_GET_SUB_COMMENTS = False # word cloud related # Whether to enable generating comment word clouds ENABLE_GET_WORDCLOUD = False # Custom words and their groups # Add rule: xx:yy where xx is a custom-added phrase, and yy is the group name to which the phrase xx is assigned. CUSTOM_WORDS = { "零几": "年份", # Recognize "zero points" as a whole "高频词": "专业术语", # Example custom words } # Deactivate (disabled) word file path STOP_WORDS_FILE = "./docs/hit_stopwords.txt" # Chinese font file path FONT_PATH = "./docs/STZHONGS.TTF" # Crawl interval CRAWLER_MAX_SLEEP_SEC = 2 # 是否禁用 SSL 证书验证。仅在使用企业代理、Burp Suite、mitmproxy 等会注入自签名证书的中间人代理时设为 True。 # 警告:禁用 SSL 验证将使所有流量暴露于中间人攻击风险,请勿在生产环境中开启。 DISABLE_SSL_VERIFY = False from .bilibili_config import * from .xhs_config import * from .dy_config import * from .ks_config import * from .weibo_config import * from .tieba_config import * from .zhihu_config import * ================================================ FILE: config/bilibili_config.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/config/bilibili_config.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # bilili platform configuration # Control the number of videos/posts crawled per day MAX_NOTES_PER_DAY = 1 # Specify Bilibili video URL list (supports complete URL or BV number) # Example: # - Full URL: "https://www.bilibili.com/video/BV1dwuKzmE26/?spm_id_from=333.1387.homepage.video_card.click" # - BV number: "BV1d54y1g7db" BILI_SPECIFIED_ID_LIST = [ "https://www.bilibili.com/video/BV1dwuKzmE26/?spm_id_from=333.1387.homepage.video_card.click", "BV1Sz4y1U77N", "BV14Q4y1n7jz", # ........................ ] # Specify the URL list of Bilibili creators (supports full URL or UID) # Example: # - Full URL: "https://space.bilibili.com/434377496?spm_id_from=333.1007.0.0" # - UID: "20813884" BILI_CREATOR_ID_LIST = [ "https://space.bilibili.com/434377496?spm_id_from=333.1007.0.0", "20813884", # ........................ ] # Specify time range START_DAY = "2024-01-01" END_DAY = "2024-01-01" # Search mode BILI_SEARCH_MODE = "normal" # Video definition (qn) configuration, common values: # 16=360p, 32=480p, 64=720p, 80=1080p, 112=1080p high bit rate, 116=1080p60, 120=4K # Note: Higher definition requires account/video support BILI_QN = 80 # Whether to crawl user information CREATOR_MODE = True # Start crawling user information page number START_CONTACTS_PAGE = 1 # Maximum number of crawled comments for a single video/post CRAWLER_MAX_CONTACTS_COUNT_SINGLENOTES = 100 # Maximum number of crawled dynamics for a single video/post CRAWLER_MAX_DYNAMICS_COUNT_SINGLENOTES = 50 ================================================ FILE: config/db_config.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/config/db_config.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import os # mysql config MYSQL_DB_PWD = os.getenv("MYSQL_DB_PWD", "123456") MYSQL_DB_USER = os.getenv("MYSQL_DB_USER", "root") MYSQL_DB_HOST = os.getenv("MYSQL_DB_HOST", "localhost") MYSQL_DB_PORT = os.getenv("MYSQL_DB_PORT", 3306) MYSQL_DB_NAME = os.getenv("MYSQL_DB_NAME", "media_crawler") mysql_db_config = { "user": MYSQL_DB_USER, "password": MYSQL_DB_PWD, "host": MYSQL_DB_HOST, "port": MYSQL_DB_PORT, "db_name": MYSQL_DB_NAME, } # redis config REDIS_DB_HOST = os.getenv("REDIS_DB_HOST", "127.0.0.1") # your redis host REDIS_DB_PWD = os.getenv("REDIS_DB_PWD", "123456") # your redis password REDIS_DB_PORT = os.getenv("REDIS_DB_PORT", 6379) # your redis port REDIS_DB_NUM = os.getenv("REDIS_DB_NUM", 0) # your redis db num # cache type CACHE_TYPE_REDIS = "redis" CACHE_TYPE_MEMORY = "memory" # sqlite config SQLITE_DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "database", "sqlite_tables.db") sqlite_db_config = { "db_path": SQLITE_DB_PATH } # mongodb config MONGODB_HOST = os.getenv("MONGODB_HOST", "localhost") MONGODB_PORT = os.getenv("MONGODB_PORT", 27017) MONGODB_USER = os.getenv("MONGODB_USER", "") MONGODB_PWD = os.getenv("MONGODB_PWD", "") MONGODB_DB_NAME = os.getenv("MONGODB_DB_NAME", "media_crawler") mongodb_config = { "host": MONGODB_HOST, "port": int(MONGODB_PORT), "user": MONGODB_USER, "password": MONGODB_PWD, "db_name": MONGODB_DB_NAME, } # postgres config POSTGRES_DB_PWD = os.getenv("POSTGRES_DB_PWD", "123456") POSTGRES_DB_USER = os.getenv("POSTGRES_DB_USER", "postgres") POSTGRES_DB_HOST = os.getenv("POSTGRES_DB_HOST", "localhost") POSTGRES_DB_PORT = os.getenv("POSTGRES_DB_PORT", 5432) POSTGRES_DB_NAME = os.getenv("POSTGRES_DB_NAME", "media_crawler") postgres_db_config = { "user": POSTGRES_DB_USER, "password": POSTGRES_DB_PWD, "host": POSTGRES_DB_HOST, "port": POSTGRES_DB_PORT, "db_name": POSTGRES_DB_NAME, } ================================================ FILE: config/dy_config.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/config/dy_config.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # Douyin platform configuration PUBLISH_TIME_TYPE = 0 # Specify DY video URL list (supports multiple formats) # Supported formats: # 1. Full video URL: "https://www.douyin.com/video/7525538910311632128" # 2. URL with modal_id: "https://www.douyin.com/user/xxx?modal_id=7525538910311632128" # 3. The search page has modal_id: "https://www.douyin.com/root/search/python?modal_id=7525538910311632128" # 4. Short link: "https://v.douyin.com/drIPtQ_WPWY/" # 5. Pure video ID: "7280854932641664319" DY_SPECIFIED_ID_LIST = [ "https://www.douyin.com/video/7525538910311632128", "https://v.douyin.com/drIPtQ_WPWY/", "https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE?from_tab_name=main&modal_id=7525538910311632128", "7202432992642387233", # ........................ ] # Specify DY creator URL list (supports full URL or sec_user_id) # Supported formats: # 1. Complete creator homepage URL: "https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE?from_tab_name=main" # 2. sec_user_id: "MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE" DY_CREATOR_ID_LIST = [ "https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE?from_tab_name=main", "MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE" # ........................ ] ================================================ FILE: config/ks_config.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/config/ks_config.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # Kuaishou platform configuration # Specify Kuaishou video URL list (supports complete URL or pure ID) # Supported formats: # 1. Full video URL: "https://www.kuaishou.com/short-video/3x3zxz4mjrsc8ke?authorId=3x84qugg4ch9zhs&streamSource=search" # 2. Pure video ID: "3xf8enb8dbj6uig" KS_SPECIFIED_ID_LIST = [ "https://www.kuaishou.com/short-video/3x3zxz4mjrsc8ke?authorId=3x84qugg4ch9zhs&streamSource=search&area=searchxxnull&searchKey=python", "3xf8enb8dbj6uig", # ........................ ] # Specify Kuaishou creator URL list (supports full URL or pure ID) # Supported formats: # 1. Creator homepage URL: "https://www.kuaishou.com/profile/3x84qugg4ch9zhs" # 2. Pure user_id: "3x4sm73aye7jq7i" KS_CREATOR_ID_LIST = [ "https://www.kuaishou.com/profile/3x84qugg4ch9zhs", "3x4sm73aye7jq7i", # ........................ ] ================================================ FILE: config/tieba_config.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/config/tieba_config.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # Tieba platform configuration # Specify Tieba ID list TIEBA_SPECIFIED_ID_LIST = [] # Specify a list of Tieba names TIEBA_NAME_LIST = [ # "Tomb Robbery Notes" ] # Specify Tieba user URL list TIEBA_CREATOR_URL_LIST = [ "https://tieba.baidu.com/home/main/?id=tb.1.7f139e2e.6CyEwxu3VJruH_-QqpCi6g&fr=frs", # ........................ ] ================================================ FILE: config/weibo_config.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/config/weibo_config.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # Weibo platform configuration # Search type, the specific enumeration value is in media_platform/weibo/field.py WEIBO_SEARCH_TYPE = "default" # Specify Weibo ID list WEIBO_SPECIFIED_ID_LIST = [ "4982041758140155", # ........................ ] # Specify Weibo user ID list WEIBO_CREATOR_ID_LIST = [ "5756404150", # ........................ ] # Whether to enable the function of crawling the full text of Weibo. It is enabled by default. # If turned on, it will increase the probability of being risk controlled, which is equivalent to a keyword search request that will traverse all posts and request the post details again. ENABLE_WEIBO_FULL_TEXT = True ================================================ FILE: config/xhs_config.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/config/xhs_config.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # Xiaohongshu platform configuration # Sorting method, the specific enumeration value is in media_platform/xhs/field.py SORT_TYPE = "popularity_descending" # Specify the note URL list, which must carry the xsec_token parameter XHS_SPECIFIED_NOTE_URL_LIST = [ "https://www.xiaohongshu.com/explore/64b95d01000000000c034587?xsec_token=AB0EFqJvINCkj6xOCKCQgfNNh8GdnBC_6XecG4QOddo3Q=&xsec_source=pc_cfeed" # ........................ ] # Specify the creator URL list, which needs to carry xsec_token and xsec_source parameters. XHS_CREATOR_ID_LIST = [ "https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753?xsec_token=ABYVg1evluJZZzpMX-VWzchxQ1qSNVW3r-jOEnKqMcgZw=&xsec_source=pc_search" # ........................ ] ================================================ FILE: config/zhihu_config.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/config/zhihu_config.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # Zhihu platform configuration # Specify Zhihu user URL list ZHIHU_CREATOR_URL_LIST = [ "https://www.zhihu.com/people/yd1234567", # ........................ ] # Specify Zhihu ID list ZHIHU_SPECIFIED_ID_LIST = [ "https://www.zhihu.com/question/826896610/answer/4885821440", # answer "https://zhuanlan.zhihu.com/p/673461588", # article "https://www.zhihu.com/zvideo/1539542068422144000", # video ] ================================================ FILE: constant/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/constant/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- ================================================ FILE: constant/baidu_tieba.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/constant/baidu_tieba.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- TIEBA_URL = 'https://tieba.baidu.com' ================================================ FILE: constant/zhihu.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/constant/zhihu.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- ZHIHU_URL = "https://www.zhihu.com" ZHIHU_ZHUANLAN_URL = "https://zhuanlan.zhihu.com" ANSWER_NAME = "answer" ARTICLE_NAME = "article" VIDEO_NAME = "zvideo" ================================================ FILE: database/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/database/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 ================================================ FILE: database/db.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/database/db.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # persist-1 # Reason: Refactored db.py into a module, removed direct execution entry point, fixed relative import issues. # Side effects: None # Rollback strategy: Restore this file. import asyncio import sys from pathlib import Path # Add project root to sys.path project_root = Path(__file__).resolve().parents[1] if str(project_root) not in sys.path: sys.path.append(str(project_root)) from tools import utils from database.db_session import create_tables async def init_table_schema(db_type: str): """ Initializes the database table schema. This will create tables based on the ORM models. Args: db_type: The type of database, 'sqlite' or 'mysql'. """ utils.logger.info(f"[init_table_schema] begin init {db_type} table schema ...") await create_tables(db_type) utils.logger.info(f"[init_table_schema] {db_type} table schema init successful") async def init_db(db_type: str = None): await init_table_schema(db_type) async def close(): """ Placeholder for closing database connections if needed in the future. """ pass ================================================ FILE: database/db_session.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/database/db_session.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker from contextlib import asynccontextmanager from .models import Base import config from config.db_config import mysql_db_config, sqlite_db_config, postgres_db_config # Keep a cache of engines _engines = {} async def create_database_if_not_exists(db_type: str): if db_type == "mysql" or db_type == "db": # Connect to the server without a database server_url = f"mysql+asyncmy://{mysql_db_config['user']}:{mysql_db_config['password']}@{mysql_db_config['host']}:{mysql_db_config['port']}" engine = create_async_engine(server_url, echo=False) async with engine.connect() as conn: await conn.execute(text(f"CREATE DATABASE IF NOT EXISTS {mysql_db_config['db_name']}")) await engine.dispose() elif db_type == "postgres": # Connect to the default 'postgres' database server_url = f"postgresql+asyncpg://{postgres_db_config['user']}:{postgres_db_config['password']}@{postgres_db_config['host']}:{postgres_db_config['port']}/postgres" print(f"[init_db] Connecting to Postgres: host={postgres_db_config['host']}, port={postgres_db_config['port']}, user={postgres_db_config['user']}, dbname=postgres") # Isolation level AUTOCOMMIT is required for CREATE DATABASE engine = create_async_engine(server_url, echo=False, isolation_level="AUTOCOMMIT") async with engine.connect() as conn: # Check if database exists result = await conn.execute(text(f"SELECT 1 FROM pg_database WHERE datname = '{postgres_db_config['db_name']}'")) if not result.scalar(): await conn.execute(text(f"CREATE DATABASE {postgres_db_config['db_name']}")) await engine.dispose() def get_async_engine(db_type: str = None): if db_type is None: db_type = config.SAVE_DATA_OPTION if db_type in _engines: return _engines[db_type] if db_type in ["json", "jsonl", "csv"]: return None if db_type == "sqlite": db_url = f"sqlite+aiosqlite:///{sqlite_db_config['db_path']}" elif db_type == "mysql" or db_type == "db": db_url = f"mysql+asyncmy://{mysql_db_config['user']}:{mysql_db_config['password']}@{mysql_db_config['host']}:{mysql_db_config['port']}/{mysql_db_config['db_name']}" elif db_type == "postgres": db_url = f"postgresql+asyncpg://{postgres_db_config['user']}:{postgres_db_config['password']}@{postgres_db_config['host']}:{postgres_db_config['port']}/{postgres_db_config['db_name']}" else: raise ValueError(f"Unsupported database type: {db_type}") engine = create_async_engine(db_url, echo=False) _engines[db_type] = engine return engine async def create_tables(db_type: str = None): if db_type is None: db_type = config.SAVE_DATA_OPTION await create_database_if_not_exists(db_type) engine = get_async_engine(db_type) if engine: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) @asynccontextmanager async def get_session() -> AsyncSession: engine = get_async_engine(config.SAVE_DATA_OPTION) if not engine: yield None return AsyncSessionFactory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) session = AsyncSessionFactory() try: yield session await session.commit() except Exception as e: await session.rollback() raise e finally: await session.close() ================================================ FILE: database/models.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/database/models.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from sqlalchemy import create_engine, Column, Integer, Text, String, BigInteger from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker Base = declarative_base() class BilibiliVideo(Base): __tablename__ = 'bilibili_video' id = Column(Integer, primary_key=True, comment='主键ID') video_id = Column(BigInteger, nullable=False, index=True, unique=True, comment='视频ID') video_url = Column(Text, nullable=False, comment='视频URL') user_id = Column(BigInteger, index=True, comment='用户ID') nickname = Column(Text, comment='用户昵称') avatar = Column(Text, comment='用户头像') liked_count = Column(Integer, comment='点赞数') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') video_type = Column(Text, comment='视频类型') title = Column(Text, comment='视频标题') desc = Column(Text, comment='视频描述') create_time = Column(BigInteger, index=True, comment='创建时间戳') disliked_count = Column(Text, comment='点踩数') video_play_count = Column(Text, comment='播放数') video_favorite_count = Column(Text, comment='收藏数') video_share_count = Column(Text, comment='分享数') video_coin_count = Column(Text, comment='硬币数') video_danmaku = Column(Text, comment='弹幕数') video_comment = Column(Text, comment='评论数') video_cover_url = Column(Text, comment='视频封面URL') source_keyword = Column(Text, default='', comment='来源关键词') class BilibiliVideoComment(Base): __tablename__ = 'bilibili_video_comment' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(String(255), comment='用户ID') nickname = Column(Text, comment='用户昵称') sex = Column(Text, comment='性别') sign = Column(Text, comment='签名') avatar = Column(Text, comment='头像') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') comment_id = Column(BigInteger, index=True, comment='评论ID') video_id = Column(BigInteger, index=True, comment='视频ID') content = Column(Text, comment='评论内容') create_time = Column(BigInteger, comment='创建时间戳') sub_comment_count = Column(Text, comment='子评论数') parent_comment_id = Column(String(255), comment='父评论ID') like_count = Column(Text, default='0', comment='点赞数') class BilibiliUpInfo(Base): __tablename__ = 'bilibili_up_info' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(BigInteger, index=True, comment='用户ID') nickname = Column(Text, comment='用户昵称') sex = Column(Text, comment='性别') sign = Column(Text, comment='签名') avatar = Column(Text, comment='头像') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') total_fans = Column(Integer, comment='总粉丝数') total_liked = Column(Integer, comment='总获赞数') user_rank = Column(Integer, comment='用户等级') is_official = Column(Integer, comment='是否官方认证') class BilibiliContactInfo(Base): __tablename__ = 'bilibili_contact_info' id = Column(Integer, primary_key=True, comment='主键ID') up_id = Column(BigInteger, index=True, comment='UP主ID') fan_id = Column(BigInteger, index=True, comment='粉丝ID') up_name = Column(Text, comment='UP主名称') fan_name = Column(Text, comment='粉丝名称') up_sign = Column(Text, comment='UP主签名') fan_sign = Column(Text, comment='粉丝签名') up_avatar = Column(Text, comment='UP主头像') fan_avatar = Column(Text, comment='粉丝头像') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') class BilibiliUpDynamic(Base): __tablename__ = 'bilibili_up_dynamic' id = Column(Integer, primary_key=True, comment='主键ID') dynamic_id = Column(BigInteger, index=True, comment='动态ID') user_id = Column(String(255), comment='用户ID') user_name = Column(Text, comment='用户名称') text = Column(Text, comment='动态内容') type = Column(Text, comment='动态类型') pub_ts = Column(BigInteger, comment='发布时间戳') total_comments = Column(Integer, comment='总评论数') total_forwards = Column(Integer, comment='总转发数') total_liked = Column(Integer, comment='总点赞数') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') class DouyinAweme(Base): __tablename__ = 'douyin_aweme' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(String(255), comment='用户ID') sec_uid = Column(String(255), comment='安全用户ID') short_user_id = Column(String(255), comment='短用户ID') user_unique_id = Column(String(255), comment='用户唯一ID') nickname = Column(Text, comment='用户昵称') avatar = Column(Text, comment='用户头像') user_signature = Column(Text, comment='用户签名') ip_location = Column(Text, comment='IP地址位置') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') aweme_id = Column(BigInteger, index=True, comment='作品ID') aweme_type = Column(Text, comment='作品类型') title = Column(Text, comment='作品标题') desc = Column(Text, comment='作品描述') create_time = Column(BigInteger, index=True, comment='创建时间戳') liked_count = Column(Text, comment='点赞数') comment_count = Column(Text, comment='评论数') share_count = Column(Text, comment='分享数') collected_count = Column(Text, comment='收藏数') aweme_url = Column(Text, comment='作品URL') cover_url = Column(Text, comment='封面URL') video_download_url = Column(Text, comment='视频下载URL') music_download_url = Column(Text, comment='音乐下载URL') note_download_url = Column(Text, comment='笔记下载URL') source_keyword = Column(Text, default='', comment='来源关键词') class DouyinAwemeComment(Base): __tablename__ = 'douyin_aweme_comment' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(String(255), comment='用户ID') sec_uid = Column(String(255), comment='安全用户ID') short_user_id = Column(String(255), comment='短用户ID') user_unique_id = Column(String(255), comment='用户唯一ID') nickname = Column(Text, comment='用户昵称') avatar = Column(Text, comment='用户头像') user_signature = Column(Text, comment='用户签名') ip_location = Column(Text, comment='IP地址位置') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') comment_id = Column(BigInteger, index=True, comment='评论ID') aweme_id = Column(BigInteger, index=True, comment='作品ID') content = Column(Text, comment='评论内容') create_time = Column(BigInteger, comment='创建时间戳') sub_comment_count = Column(Text, comment='子评论数') parent_comment_id = Column(String(255), comment='父评论ID') like_count = Column(Text, default='0', comment='点赞数') pictures = Column(Text, default='', comment='图片') class DyCreator(Base): __tablename__ = 'dy_creator' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(String(255), comment='用户ID') nickname = Column(Text, comment='用户昵称') avatar = Column(Text, comment='用户头像') ip_location = Column(Text, comment='IP地址位置') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') desc = Column(Text, comment='描述') gender = Column(Text, comment='性别') follows = Column(Text, comment='关注数') fans = Column(Text, comment='粉丝数') interaction = Column(Text, comment='互动数') videos_count = Column(String(255), comment='视频数量') class KuaishouVideo(Base): __tablename__ = 'kuaishou_video' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(String(64), comment='用户ID') nickname = Column(Text, comment='用户昵称') avatar = Column(Text, comment='用户头像') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') video_id = Column(String(255), index=True, comment='视频ID') video_type = Column(Text, comment='视频类型') title = Column(Text, comment='视频标题') desc = Column(Text, comment='视频描述') create_time = Column(BigInteger, index=True, comment='创建时间戳') liked_count = Column(Text, comment='点赞数') viewd_count = Column(Text, comment='观看数') video_url = Column(Text, comment='视频URL') video_cover_url = Column(Text, comment='视频封面URL') video_play_url = Column(Text, comment='视频播放URL') source_keyword = Column(Text, default='', comment='来源关键词') class KuaishouVideoComment(Base): __tablename__ = 'kuaishou_video_comment' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(Text, comment='用户ID') nickname = Column(Text, comment='用户昵称') avatar = Column(Text, comment='用户头像') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') comment_id = Column(BigInteger, index=True, comment='评论ID') video_id = Column(String(255), index=True, comment='视频ID') content = Column(Text, comment='评论内容') create_time = Column(BigInteger, comment='创建时间戳') sub_comment_count = Column(Text, comment='子评论数') class WeiboNote(Base): __tablename__ = 'weibo_note' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(String(255), comment='用户ID') nickname = Column(Text, comment='用户昵称') avatar = Column(Text, comment='用户头像') gender = Column(Text, comment='性别') profile_url = Column(Text, comment='个人主页URL') ip_location = Column(Text, default='', comment='IP地址位置') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') note_id = Column(BigInteger, index=True, comment='笔记ID') content = Column(Text, comment='笔记内容') create_time = Column(BigInteger, index=True, comment='创建时间戳') create_date_time = Column(String(255), index=True, comment='创建日期时间') liked_count = Column(Text, comment='点赞数') comments_count = Column(Text, comment='评论数') shared_count = Column(Text, comment='分享数') note_url = Column(Text, comment='笔记URL') source_keyword = Column(Text, default='', comment='来源关键词') class WeiboNoteComment(Base): __tablename__ = 'weibo_note_comment' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(String(255), comment='用户ID') nickname = Column(Text, comment='用户昵称') avatar = Column(Text, comment='用户头像') gender = Column(Text, comment='性别') profile_url = Column(Text, comment='个人主页URL') ip_location = Column(Text, default='', comment='IP地址位置') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') comment_id = Column(BigInteger, index=True, comment='评论ID') note_id = Column(BigInteger, index=True, comment='笔记ID') content = Column(Text, comment='评论内容') create_time = Column(BigInteger, comment='创建时间戳') create_date_time = Column(String(255), index=True, comment='创建日期时间') comment_like_count = Column(Text, comment='评论点赞数') sub_comment_count = Column(Text, comment='子评论数') parent_comment_id = Column(String(255), comment='父评论ID') class WeiboCreator(Base): __tablename__ = 'weibo_creator' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(String(255), comment='用户ID') nickname = Column(Text, comment='用户昵称') avatar = Column(Text, comment='用户头像') ip_location = Column(Text, comment='IP地址位置') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') desc = Column(Text, comment='描述') gender = Column(Text, comment='性别') follows = Column(Text, comment='关注数') fans = Column(Text, comment='粉丝数') tag_list = Column(Text, comment='标签列表') class XhsCreator(Base): __tablename__ = 'xhs_creator' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(String(255), comment='用户ID') nickname = Column(Text, comment='用户昵称') avatar = Column(Text, comment='用户头像') ip_location = Column(Text, comment='IP地址位置') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') desc = Column(Text, comment='描述') gender = Column(Text, comment='性别') follows = Column(Text, comment='关注数') fans = Column(Text, comment='粉丝数') interaction = Column(Text, comment='互动数') tag_list = Column(Text, comment='标签列表') class XhsNote(Base): __tablename__ = 'xhs_note' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(String(255), comment='用户ID') nickname = Column(Text, comment='用户昵称') avatar = Column(Text, comment='用户头像') ip_location = Column(Text, comment='IP地址位置') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') note_id = Column(String(255), index=True, comment='笔记ID') type = Column(Text, comment='笔记类型') title = Column(Text, comment='笔记标题') desc = Column(Text, comment='笔记描述') video_url = Column(Text, comment='视频URL') time = Column(BigInteger, index=True, comment='时间戳') last_update_time = Column(BigInteger, comment='最后更新时间戳') liked_count = Column(Text, comment='点赞数') collected_count = Column(Text, comment='收藏数') comment_count = Column(Text, comment='评论数') share_count = Column(Text, comment='分享数') image_list = Column(Text, comment='图片列表') tag_list = Column(Text, comment='标签列表') note_url = Column(Text, comment='笔记URL') source_keyword = Column(Text, default='', comment='来源关键词') xsec_token = Column(Text, comment='Xsec Token') class XhsNoteComment(Base): __tablename__ = 'xhs_note_comment' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(String(255), comment='用户ID') nickname = Column(Text, comment='用户昵称') avatar = Column(Text, comment='用户头像') ip_location = Column(Text, comment='IP地址位置') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') comment_id = Column(String(255), index=True, comment='评论ID') create_time = Column(BigInteger, index=True, comment='创建时间戳') note_id = Column(String(255), comment='笔记ID') content = Column(Text, comment='评论内容') sub_comment_count = Column(Integer, comment='子评论数') pictures = Column(Text, comment='图片') parent_comment_id = Column(String(255), comment='父评论ID') like_count = Column(Text, comment='点赞数') class TiebaNote(Base): __tablename__ = 'tieba_note' id = Column(Integer, primary_key=True, comment='主键ID') note_id = Column(String(644), index=True, comment='笔记ID') title = Column(Text, comment='笔记标题') desc = Column(Text, comment='笔记描述') note_url = Column(Text, comment='笔记URL') publish_time = Column(String(255), index=True, comment='发布时间') user_link = Column(Text, default='', comment='用户链接') user_nickname = Column(Text, default='', comment='用户昵称') user_avatar = Column(Text, default='', comment='用户头像') tieba_id = Column(String(255), default='', comment='贴吧ID') tieba_name = Column(Text, comment='贴吧名称') tieba_link = Column(Text, comment='贴吧链接') total_replay_num = Column(Integer, default=0, comment='总回复数') total_replay_page = Column(Integer, default=0, comment='总回复页数') ip_location = Column(Text, default='', comment='IP地址位置') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') source_keyword = Column(Text, default='', comment='来源关键词') class TiebaComment(Base): __tablename__ = 'tieba_comment' id = Column(Integer, primary_key=True, comment='主键ID') comment_id = Column(String(255), index=True, comment='评论ID') parent_comment_id = Column(String(255), default='', comment='父评论ID') content = Column(Text, comment='评论内容') user_link = Column(Text, default='', comment='用户链接') user_nickname = Column(Text, default='', comment='用户昵称') user_avatar = Column(Text, default='', comment='用户头像') tieba_id = Column(String(255), default='', comment='贴吧ID') tieba_name = Column(Text, comment='贴吧名称') tieba_link = Column(Text, comment='贴吧链接') publish_time = Column(String(255), index=True, comment='发布时间') ip_location = Column(Text, default='', comment='IP地址位置') sub_comment_count = Column(Integer, default=0, comment='子评论数') note_id = Column(String(255), index=True, comment='笔记ID') note_url = Column(Text, comment='笔记URL') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') class TiebaCreator(Base): __tablename__ = 'tieba_creator' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(String(64), comment='用户ID') user_name = Column(Text, comment='用户名') nickname = Column(Text, comment='用户昵称') avatar = Column(Text, comment='用户头像') ip_location = Column(Text, comment='IP地址位置') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') gender = Column(Text, comment='性别') follows = Column(Text, comment='关注数') fans = Column(Text, comment='粉丝数') registration_duration = Column(Text, comment='注册时长') class ZhihuContent(Base): __tablename__ = 'zhihu_content' id = Column(Integer, primary_key=True, comment='主键ID') content_id = Column(String(64), index=True, comment='内容ID') content_type = Column(Text, comment='内容类型') content_text = Column(Text, comment='内容文本') content_url = Column(Text, comment='内容URL') question_id = Column(String(255), comment='问题ID') title = Column(Text, comment='标题') desc = Column(Text, comment='描述') created_time = Column(String(32), index=True, comment='创建时间') updated_time = Column(Text, comment='更新时间') voteup_count = Column(Integer, default=0, comment='赞同数') comment_count = Column(Integer, default=0, comment='评论数') source_keyword = Column(Text, comment='来源关键词') user_id = Column(String(255), comment='用户ID') user_link = Column(Text, comment='用户链接') user_nickname = Column(Text, comment='用户昵称') user_avatar = Column(Text, comment='用户头像') user_url_token = Column(Text, comment='用户URL Token') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') # persist-1 # Reason: Fixed ORM model definition error, ensuring consistency with database table structure. # Side effects: None # Rollback strategy: Restore this line class ZhihuComment(Base): __tablename__ = 'zhihu_comment' id = Column(Integer, primary_key=True, comment='主键ID') comment_id = Column(String(64), index=True, comment='评论ID') parent_comment_id = Column(String(64), comment='父评论ID') content = Column(Text, comment='评论内容') publish_time = Column(String(32), index=True, comment='发布时间') ip_location = Column(Text, comment='IP地址位置') sub_comment_count = Column(Integer, default=0, comment='子评论数') like_count = Column(Integer, default=0, comment='点赞数') dislike_count = Column(Integer, default=0, comment='点踩数') content_id = Column(String(64), index=True, comment='内容ID') content_type = Column(Text, comment='内容类型') user_id = Column(String(64), comment='用户ID') user_link = Column(Text, comment='用户链接') user_nickname = Column(Text, comment='用户昵称') user_avatar = Column(Text, comment='用户头像') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') class ZhihuCreator(Base): __tablename__ = 'zhihu_creator' id = Column(Integer, primary_key=True, comment='主键ID') user_id = Column(String(64), unique=True, index=True, comment='用户ID') user_link = Column(Text, comment='用户链接') user_nickname = Column(Text, comment='用户昵称') user_avatar = Column(Text, comment='用户头像') url_token = Column(Text, comment='URL Token') gender = Column(Text, comment='性别') ip_location = Column(Text, comment='IP地址位置') follows = Column(Integer, default=0, comment='关注数') fans = Column(Integer, default=0, comment='粉丝数') anwser_count = Column(Integer, default=0, comment='回答数') video_count = Column(Integer, default=0, comment='视频数') question_count = Column(Integer, default=0, comment='问题数') article_count = Column(Integer, default=0, comment='文章数') column_count = Column(Integer, default=0, comment='专栏数') get_voteup_count = Column(Integer, default=0, comment='获赞数') add_ts = Column(BigInteger, comment='添加时间戳') last_modify_ts = Column(BigInteger, comment='最后修改时间戳') ================================================ FILE: database/mongodb_store_base.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/database/mongodb_store_base.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 """MongoDB storage base class: Provides connection management and common storage methods""" import asyncio from typing import Dict, List, Optional from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase, AsyncIOMotorCollection from config import db_config from tools import utils class MongoDBConnection: """MongoDB connection management (singleton pattern)""" _instance = None _client: Optional[AsyncIOMotorClient] = None _db: Optional[AsyncIOMotorDatabase] = None _lock = asyncio.Lock() def __new__(cls): if cls._instance is None: cls._instance = super(MongoDBConnection, cls).__new__(cls) return cls._instance async def get_client(self) -> AsyncIOMotorClient: """Get client""" if self._client is None: async with self._lock: if self._client is None: await self._connect() return self._client async def get_db(self) -> AsyncIOMotorDatabase: """Get database""" if self._db is None: async with self._lock: if self._db is None: await self._connect() return self._db async def _connect(self): """Establish connection""" try: mongo_config = db_config.mongodb_config host = mongo_config["host"] port = mongo_config["port"] user = mongo_config["user"] password = mongo_config["password"] db_name = mongo_config["db_name"] # Build connection URL (with/without authentication) if user and password: connection_url = f"mongodb://{user}:{password}@{host}:{port}/" else: connection_url = f"mongodb://{host}:{port}/" self._client = AsyncIOMotorClient(connection_url, serverSelectionTimeoutMS=5000) await self._client.server_info() # Test connection self._db = self._client[db_name] utils.logger.info(f"[MongoDBConnection] Connected to {host}:{port}/{db_name}") except Exception as e: utils.logger.error(f"[MongoDBConnection] Connection failed: {e}") raise async def close(self): """Close connection""" if self._client is not None: self._client.close() self._client = None self._db = None utils.logger.info("[MongoDBConnection] Connection closed") class MongoDBStoreBase: """MongoDB storage base class: Provides common CRUD operations""" def __init__(self, collection_prefix: str): """Initialize storage base class Args: collection_prefix: Platform prefix (xhs/douyin/bilibili, etc.) """ self.collection_prefix = collection_prefix self._connection = MongoDBConnection() async def get_collection(self, collection_suffix: str) -> AsyncIOMotorCollection: """Get collection: {prefix}_{suffix}""" db = await self._connection.get_db() collection_name = f"{self.collection_prefix}_{collection_suffix}" return db[collection_name] async def save_or_update(self, collection_suffix: str, query: Dict, data: Dict) -> bool: """Save or update data (upsert)""" try: collection = await self.get_collection(collection_suffix) await collection.update_one(query, {"$set": data}, upsert=True) return True except Exception as e: utils.logger.error(f"[MongoDBStoreBase] Save failed ({self.collection_prefix}_{collection_suffix}): {e}") return False async def find_one(self, collection_suffix: str, query: Dict) -> Optional[Dict]: """Query a single record""" try: collection = await self.get_collection(collection_suffix) return await collection.find_one(query) except Exception as e: utils.logger.error(f"[MongoDBStoreBase] Find one failed ({self.collection_prefix}_{collection_suffix}): {e}") return None async def find_many(self, collection_suffix: str, query: Dict, limit: int = 0) -> List[Dict]: """Query multiple records (limit=0 means no limit)""" try: collection = await self.get_collection(collection_suffix) cursor = collection.find(query) if limit > 0: cursor = cursor.limit(limit) return await cursor.to_list(length=None) except Exception as e: utils.logger.error(f"[MongoDBStoreBase] Find many failed ({self.collection_prefix}_{collection_suffix}): {e}") return [] async def create_index(self, collection_suffix: str, keys: List[tuple], unique: bool = False): """Create index: keys=[("field", 1)]""" try: collection = await self.get_collection(collection_suffix) await collection.create_index(keys, unique=unique) utils.logger.info(f"[MongoDBStoreBase] Index created on {self.collection_prefix}_{collection_suffix}") except Exception as e: utils.logger.error(f"[MongoDBStoreBase] Create index failed: {e}") ================================================ FILE: docs/.vitepress/config.mjs ================================================ import {defineConfig} from 'vitepress' import {withMermaid} from 'vitepress-plugin-mermaid' // https://vitepress.dev/reference/site-config export default withMermaid(defineConfig({ title: "MediaCrawler自媒体爬虫", description: "小红书爬虫,抖音爬虫, 快手爬虫, B站爬虫, 微博爬虫,百度贴吧爬虫,知乎爬虫...。 ", lastUpdated: true, base: '/MediaCrawler/', head: [ [ 'script', {async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-5TK7GF3KK1'} ], [ 'script', {}, `window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'G-5TK7GF3KK1');` ] ], themeConfig: { editLink: { pattern: 'https://github.com/NanmiCoder/MediaCrawler/tree/main/docs/:path' }, search: { provider: 'local' }, // https://vitepress.dev/reference/default-theme-config nav: [ {text: '首页', link: '/'}, {text: '联系我', link: '/作者介绍'}, {text: '支持我', link: '/知识付费介绍'}, ], sidebar: [ { text: '作者介绍', link: '/作者介绍', }, { text: 'MediaCrawler使用文档', items: [ {text: '基本使用', link: '/'}, {text: '项目架构文档', link: '/项目架构文档'}, {text: '常见问题汇总', link: '/常见问题'}, {text: 'IP代理使用', link: '/代理使用'}, {text: '词云图使用', link: '/词云图使用配置'}, {text: '项目目录结构', link: '/项目代码结构'}, {text: '手机号登录说明', link: '/手机号登录说明'}, ] }, { text: '知识付费', items: [ {text: '知识付费介绍', link: '/知识付费介绍'}, {text: 'MediaCrawlerPro订阅', link: '/mediacrawlerpro订阅'}, { text: 'MediaCrawler源码剖析课', link: 'https://relakkes.feishu.cn/wiki/JUgBwdhIeiSbAwkFCLkciHdAnhh' }, {text: '开发者咨询服务', link: '/开发者咨询'}, ] }, { text: 'MediaCrawler项目交流群', link: '/微信交流群', }, { text: '爬虫入门教程分享', items: [ {text: "我写的爬虫入门教程", link: 'https://github.com/NanmiCoder/CrawlerTutorial'} ] }, { text: 'MediaCrawler捐赠名单', items: [ {text: "捐赠名单", link: '/捐赠名单'} ] }, ], socialLinks: [ {icon: 'github', link: 'https://github.com/NanmiCoder/MediaCrawler'} ] } })) ================================================ FILE: docs/.vitepress/theme/DynamicAds.vue ================================================ ================================================ FILE: docs/.vitepress/theme/MyLayout.vue ================================================ ================================================ FILE: docs/.vitepress/theme/custom.css ================================================ /* .vitepress/theme/custom.css */ /** * Component: Sidebar * -------------------------------------------------------------------------- */ :root { --vp-sidebar-width: 285px; --vp-sidebar-bg-color: var(--vp-c-bg-alt); --vp-aside-width: 300px; } ================================================ FILE: docs/.vitepress/theme/index.js ================================================ // .vitepress/theme/index.js import DefaultTheme from 'vitepress/theme' import MyLayout from './MyLayout.vue' export default { extends: DefaultTheme, // 使用注入插槽的包装组件覆盖 Layout Layout: MyLayout } ================================================ FILE: docs/CDP模式使用指南.md ================================================ # CDP模式使用指南 ## 概述 CDP(Chrome DevTools Protocol)模式是一种高级的反检测爬虫技术,通过控制用户现有的Chrome/Edge浏览器来进行网页爬取。与传统的Playwright自动化相比,CDP模式具有以下优势: ### 🎯 主要优势 1. **真实浏览器环境**: 使用用户实际安装的浏览器,包含所有扩展、插件和个人设置 2. **更好的反检测能力**: 浏览器指纹更加真实,难以被网站检测为自动化工具 3. **保留用户状态**: 自动继承用户的登录状态、Cookie和浏览历史 4. **扩展支持**: 可以利用用户安装的广告拦截器、代理扩展等工具 5. **更自然的行为**: 浏览器行为模式更接近真实用户 ## 快速开始 ### 1. 启用CDP模式 在 `config/base_config.py` 中设置: ```python # 启用CDP模式 ENABLE_CDP_MODE = True # CDP调试端口(可选,默认9222) CDP_DEBUG_PORT = 9222 # 是否在无头模式下运行(建议设为False以获得最佳反检测效果) CDP_HEADLESS = False # 程序结束时是否自动关闭浏览器 AUTO_CLOSE_BROWSER = True ``` ### 2. 运行测试 ```bash # 运行CDP功能测试 python examples/cdp_example.py # 运行小红书爬虫(CDP模式) python main.py ``` ## 配置选项详解 ### 基础配置 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `ENABLE_CDP_MODE` | bool | False | 是否启用CDP模式 | | `CDP_DEBUG_PORT` | int | 9222 | CDP调试端口 | | `CDP_HEADLESS` | bool | False | CDP模式下的无头模式 | | `AUTO_CLOSE_BROWSER` | bool | True | 程序结束时是否关闭浏览器 | ### 高级配置 | 配置项 | 类型 | 默认值 | 说明 | |--------|------|--------|------| | `CUSTOM_BROWSER_PATH` | str | "" | 自定义浏览器路径 | | `BROWSER_LAUNCH_TIMEOUT` | int | 30 | 浏览器启动超时时间(秒) | ### 自定义浏览器路径 如果系统自动检测失败,可以手动指定浏览器路径: ```python # Windows示例 CUSTOM_BROWSER_PATH = r"C:\Program Files\Google\Chrome\Application\chrome.exe" # macOS示例 CUSTOM_BROWSER_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" # Linux示例 CUSTOM_BROWSER_PATH = "/usr/bin/google-chrome" ``` ## 支持的浏览器 ### Windows - Google Chrome (稳定版、Beta、Dev、Canary) - Microsoft Edge (稳定版、Beta、Dev、Canary) ### macOS - Google Chrome (稳定版、Beta、Dev、Canary) - Microsoft Edge (稳定版、Beta、Dev、Canary) ### Linux - Google Chrome / Chromium - Microsoft Edge ## 使用示例 ### 基本使用 ```python import asyncio from playwright.async_api import async_playwright from tools.cdp_browser import CDPBrowserManager async def main(): cdp_manager = CDPBrowserManager() async with async_playwright() as playwright: # 启动CDP浏览器 browser_context = await cdp_manager.launch_and_connect( playwright=playwright, user_agent="自定义User-Agent", headless=False ) # 创建页面并访问网站 page = await browser_context.new_page() await page.goto("https://example.com") # 执行爬取操作... # 清理资源 await cdp_manager.cleanup() asyncio.run(main()) ``` ### 在爬虫中使用 CDP模式已集成到所有平台爬虫中,只需启用配置即可: ```python # 在config/base_config.py中 ENABLE_CDP_MODE = True # 然后正常运行爬虫 python main.py ``` ## 故障排除 ### 常见问题 #### 1. 浏览器检测失败 **错误**: `未找到可用的浏览器` **解决方案**: - 确保已安装Chrome或Edge浏览器 - 检查浏览器是否在标准路径下 - 使用`CUSTOM_BROWSER_PATH`指定浏览器路径 #### 2. 端口被占用 **错误**: `无法找到可用的端口` **解决方案**: - 关闭其他使用调试端口的程序 - 修改`CDP_DEBUG_PORT`为其他端口 - 系统会自动尝试下一个可用端口 #### 3. 浏览器启动超时 **错误**: `浏览器在30秒内未能启动` **解决方案**: - 增加`BROWSER_LAUNCH_TIMEOUT`值 - 检查系统资源是否充足 - 尝试关闭其他占用资源的程序 #### 4. CDP连接失败 **错误**: `CDP连接失败` **解决方案**: - 检查防火墙设置 - 确保localhost访问正常 - 尝试重启浏览器 ### 调试技巧 #### 1. 启用详细日志 ```python import logging logging.basicConfig(level=logging.DEBUG) ``` #### 2. 手动测试CDP连接 ```bash # 手动启动Chrome chrome --remote-debugging-port=9222 # 访问调试页面 curl http://localhost:9222/json ``` #### 3. 检查浏览器进程 ```bash # Windows tasklist | findstr chrome # macOS/Linux ps aux | grep chrome ``` ## 最佳实践 ### 1. 反检测优化 - 保持`CDP_HEADLESS = False`以获得最佳反检测效果 - 使用真实的User-Agent字符串 - 避免过于频繁的请求 ### 2. 性能优化 - 合理设置`AUTO_CLOSE_BROWSER` - 复用浏览器实例而不是频繁重启 - 监控内存使用情况 ### 3. 安全考虑 - 不要在生产环境中保存敏感Cookie - 定期清理浏览器数据 - 注意用户隐私保护 ### 4. 兼容性 - 测试不同浏览器版本的兼容性 - 准备回退方案(标准Playwright模式) - 监控目标网站的反爬策略变化 ## 技术原理 CDP模式的工作原理: 1. **浏览器检测**: 自动扫描系统中的Chrome/Edge安装路径 2. **进程启动**: 使用`--remote-debugging-port`参数启动浏览器 3. **CDP连接**: 通过WebSocket连接到浏览器的调试接口 4. **Playwright集成**: 使用`connectOverCDP`方法接管浏览器控制 5. **上下文管理**: 创建或复用浏览器上下文进行操作 这种方式绕过了传统WebDriver的检测机制,提供了更加隐蔽的自动化能力。 ## 更新日志 ### v1.0.0 - 初始版本发布 - 支持Windows和macOS的Chrome/Edge检测 - 集成到所有平台爬虫 - 提供完整的配置选项和错误处理 ## 贡献 欢迎提交Issue和Pull Request来改进CDP模式功能。 ## 许可证 本功能遵循项目的整体许可证条款,仅供学习和研究使用。 ================================================ FILE: docs/data_storage_guide.md ================================================ # 数据保存指南 / Data Storage Guide ### 💾 数据保存 MediaCrawler 支持多种数据存储方式,您可以根据需求选择最适合的方案: #### 存储方式 - **CSV 文件**:支持保存到 CSV 中(`data/` 目录下) - **JSON 文件**:支持保存到 JSON 中(`data/` 目录下) - **JSONL 文件**:支持保存到 JSONL 中(`data/` 目录下)— 默认格式,每行一个 JSON 对象,追加写入性能好 - **Excel 文件**:支持保存到格式化的 Excel 文件(`data/` 目录下)✨ 新功能 - 多工作表支持(内容、评论、创作者) - 专业格式化(标题样式、自动列宽、边框) - 易于分析和分享 - **数据库存储** - 使用参数 `--init_db` 进行数据库初始化(使用`--init_db`时不需要携带其他optional) - **SQLite 数据库**:轻量级数据库,无需服务器,适合个人使用(推荐) 1. 初始化:`--init_db sqlite` 2. 数据存储:`--save_data_option sqlite` - **MySQL 数据库**:支持关系型数据库 MySQL 中保存(需要提前创建数据库) 1. 初始化:`--init_db mysql` 2. 数据存储:`--save_data_option db`(db 参数为兼容历史更新保留) - **PostgreSQL 数据库**:支持高级关系型数据库 PostgreSQL 中保存(推荐生产环境使用) 1. 初始化:`--init_db postgres` 2. 数据存储:`--save_data_option postgres` #### 使用示例 ```shell # 使用 Excel 存储数据(推荐用于数据分析)✨ 新功能 uv run main.py --platform xhs --lt qrcode --type search --save_data_option excel # 初始化 SQLite 数据库 uv run main.py --init_db sqlite # 使用 SQLite 存储数据 uv run main.py --platform xhs --lt qrcode --type search --save_data_option sqlite ``` ```shell # 初始化 MySQL 数据库 uv run main.py --init_db mysql # 使用 MySQL 存储数据(为适配历史更新,db参数进行沿用) uv run main.py --platform xhs --lt qrcode --type search --save_data_option db ``` ```shell # 初始化 PostgreSQL 数据库 uv run main.py --init_db postgres # 使用 PostgreSQL 存储数据 uv run main.py --platform xhs --lt qrcode --type search --save_data_option postgres ``` ```shell # 使用 CSV 存储数据 uv run main.py --platform xhs --lt qrcode --type search --save_data_option csv # 使用 JSON 存储数据 uv run main.py --platform xhs --lt qrcode --type search --save_data_option json # 使用 JSONL 存储数据(默认格式,无需指定) uv run main.py --platform xhs --lt qrcode --type search --save_data_option jsonl ``` #### 详细文档 - **Excel 导出详细指南**:查看 [Excel 导出指南](excel_export_guide.md) - **数据库配置**:参考 [常见问题](常见问题.md) --- ================================================ FILE: docs/excel_export_guide.md ================================================ # Excel Export Guide ## Overview MediaCrawler now supports exporting crawled data to formatted Excel files (.xlsx) with professional styling and multiple sheets for contents, comments, and creators. ## Features - **Multi-sheet workbooks**: Separate sheets for Contents, Comments, and Creators - **Professional formatting**: - Styled headers with blue background and white text - Auto-adjusted column widths - Cell borders and text wrapping - Clean, readable layout - **Smart export**: Empty sheets are automatically removed - **Organized storage**: Files saved to `data/{platform}/` directory with timestamps ## Installation Excel export requires the `openpyxl` library: ```bash # Using uv (recommended) uv sync # Or using pip pip install openpyxl ``` ## Usage ### Basic Usage 1. **Configure Excel export** in `config/base_config.py`: ```python SAVE_DATA_OPTION = "excel" # Change from jsonl/json/csv/db to excel ``` 2. **Run the crawler**: ```bash # Xiaohongshu example uv run main.py --platform xhs --lt qrcode --type search # Douyin example uv run main.py --platform dy --lt qrcode --type search # Bilibili example uv run main.py --platform bili --lt qrcode --type search ``` 3. **Find your Excel file** in `data/{platform}/` directory: - Filename format: `{platform}_{crawler_type}_{timestamp}.xlsx` - Example: `xhs_search_20250128_143025.xlsx` ### Command Line Examples ```bash # Search by keywords and export to Excel uv run main.py --platform xhs --lt qrcode --type search --save_data_option excel # Crawl specific posts and export to Excel uv run main.py --platform xhs --lt qrcode --type detail --save_data_option excel # Crawl creator profile and export to Excel uv run main.py --platform xhs --lt qrcode --type creator --save_data_option excel ``` ## Excel File Structure ### Contents Sheet Contains post/video information: - `note_id`: Unique post identifier - `title`: Post title - `desc`: Post description - `user_id`: Author user ID - `nickname`: Author nickname - `liked_count`: Number of likes - `comment_count`: Number of comments - `share_count`: Number of shares - `ip_location`: IP location - `image_list`: Comma-separated image URLs - `tag_list`: Comma-separated tags - `note_url`: Direct link to post - And more platform-specific fields... ### Comments Sheet Contains comment information: - `comment_id`: Unique comment identifier - `note_id`: Associated post ID - `content`: Comment text - `user_id`: Commenter user ID - `nickname`: Commenter nickname - `like_count`: Comment likes - `create_time`: Comment timestamp - `ip_location`: Commenter location - `sub_comment_count`: Number of replies - And more... ### Creators Sheet Contains creator/author information: - `user_id`: Unique user identifier - `nickname`: Display name - `gender`: Gender - `avatar`: Profile picture URL - `desc`: Bio/description - `fans`: Follower count - `follows`: Following count - `interaction`: Total interactions - And more... ## Advantages Over Other Formats ### vs CSV - ✅ Multiple sheets in one file - ✅ Professional formatting - ✅ Better handling of special characters - ✅ Auto-adjusted column widths - ✅ No encoding issues ### vs JSON - ✅ Human-readable tabular format - ✅ Easy to open in Excel/Google Sheets - ✅ Better for data analysis - ✅ Easier to share with non-technical users ### vs Database - ✅ No database setup required - ✅ Portable single-file format - ✅ Easy to share and archive - ✅ Works offline ## Tips & Best Practices 1. **Large datasets**: For very large crawls (>10,000 rows), consider using database storage instead for better performance 2. **Data analysis**: Excel files work great with: - Microsoft Excel - Google Sheets - LibreOffice Calc - Python pandas: `pd.read_excel('file.xlsx')` 3. **Combining data**: You can merge multiple Excel files using: ```python import pandas as pd df1 = pd.read_excel('file1.xlsx', sheet_name='Contents') df2 = pd.read_excel('file2.xlsx', sheet_name='Contents') combined = pd.concat([df1, df2]) combined.to_excel('combined.xlsx', index=False) ``` 4. **File size**: Excel files are typically 2-3x larger than CSV but smaller than JSON ## Troubleshooting ### "openpyxl not installed" error ```bash # Install openpyxl uv add openpyxl # or pip install openpyxl ``` ### Excel file not created Check that: 1. `SAVE_DATA_OPTION = "excel"` in config 2. Crawler successfully collected data 3. No errors in console output 4. `data/{platform}/` directory exists ### Empty Excel file This happens when: - No data was crawled (check keywords/IDs) - Login failed (check login status) - Platform blocked requests (check IP/rate limits) ## Example Output After running a successful crawl, you'll see: ``` [ExcelStoreBase] Initialized Excel export to: data/xhs/xhs_search_20250128_143025.xlsx [ExcelStoreBase] Stored content to Excel: 7123456789 [ExcelStoreBase] Stored comment to Excel: comment_123 ... [Main] Excel file saved successfully ``` Your Excel file will have: - Professional blue headers - Clean borders - Wrapped text for long content - Auto-sized columns - Separate organized sheets ## Advanced Usage ### Programmatic Access ```python from store.excel_store_base import ExcelStoreBase # Create store store = ExcelStoreBase(platform="xhs", crawler_type="search") # Store data await store.store_content({ "note_id": "123", "title": "Test Post", "liked_count": 100 }) # Save to file store.flush() ``` ### Custom Formatting You can extend `ExcelStoreBase` to customize formatting: ```python from store.excel_store_base import ExcelStoreBase class CustomExcelStore(ExcelStoreBase): def _apply_header_style(self, sheet, row_num=1): # Custom header styling super()._apply_header_style(sheet, row_num) # Add your customizations here ``` ## Support For issues or questions: - Check [常见问题](常见问题.md) - Open an issue on GitHub - Join the WeChat discussion group --- **Note**: Excel export is designed for learning and research purposes. Please respect platform terms of service and rate limits. ================================================ FILE: docs/hit_stopwords.txt ================================================ \n ——— 》), )÷(1- ”, )、 =( : → ℃ & * 一一 ~~~~ ’ . 『 .一 ./ -- 』 =″ 【 [*] }> [⑤]] [①D] c] ng昉 * // [ ] [②e] [②g] ={ } ,也 ‘ A [①⑥] [②B] [①a] [④a] [①③] [③h] ③] 1. -- [②b] ’‘ ××× [①⑧] 0:2 =[ [⑤b] [②c] [④b] [②③] [③a] [④c] [①⑤] [①⑦] [①g] ∈[ [①⑨] [①④] [①c] [②f] [②⑧] [②①] [①C] [③c] [③g] [②⑤] [②②] 一. [①h] .数 [] [①B] 数/ [①i] [③e] [①①] [④d] [④e] [③b] [⑤a] [①A] [②⑧] [②⑦] [①d] [②j] 〕〔 ][ :// ′∈ [②④ [⑤e] 12% b] ... ................... …………………………………………………③ ZXFITL [③F] 」 [①o] ]∧′=[ ∪φ∈ ′| {- ②c } [③①] R.L. [①E] Ψ -[*]- ↑ .日 [②d] [② [②⑦] [②②] [③e] [①i] [①B] [①h] [①d] [①g] [①②] [②a] f] [⑩] a] [①e] [②h] [②⑥] [③d] [②⑩] e] 〉 】 元/吨 [②⑩] 2.3% 5:0 [①] :: [②] [③] [④] [⑤] [⑥] [⑦] [⑧] [⑨] …… —— ? 、 。 “ ” 《 》 ! , : ; ? . , . ' ? · ——— ── ? — < > ( ) 〔 〕 [ ] ( ) - + ~ × / / ① ② ③ ④ ⑤ ⑥ ⑦ ⑧ ⑨ ⑩ Ⅲ В " ; # @ γ μ φ φ. × Δ ■ ▲ sub exp sup sub Lex # % & ' + +ξ ++ - -β < <± <Δ <λ <φ << = = =☆ =- > >λ _ ~± ~+ [⑤f] [⑤d] [②i] ≈ [②G] [①f] LI ㈧ [- ...... 〉 [③⑩] 第二 一番 一直 一个 一些 许多 种 有的是 也就是说 末##末 啊 阿 哎 哎呀 哎哟 唉 俺 俺们 按 按照 吧 吧哒 把 罢了 被 本 本着 比 比方 比如 鄙人 彼 彼此 边 别 别的 别说 并 并且 不比 不成 不单 不但 不独 不管 不光 不过 不仅 不拘 不论 不怕 不然 不如 不特 不惟 不问 不只 朝 朝着 趁 趁着 乘 冲 除 除此之外 除非 除了 此 此间 此外 从 从而 打 待 但 但是 当 当着 到 得 的 的话 等 等等 地 第 叮咚 对 对于 多 多少 而 而况 而且 而是 而外 而言 而已 尔后 反过来 反过来说 反之 非但 非徒 否则 嘎 嘎登 该 赶 个 各 各个 各位 各种 各自 给 根据 跟 故 故此 固然 关于 管 归 果然 果真 过 哈 哈哈 呵 和 何 何处 何况 何时 嘿 哼 哼唷 呼哧 乎 哗 还是 还有 换句话说 换言之 或 或是 或者 极了 及 及其 及至 即 即便 即或 即令 即若 即使 几 几时 己 既 既然 既是 继而 加之 假如 假若 假使 鉴于 将 较 较之 叫 接着 结果 借 紧接着 进而 尽 尽管 经 经过 就 就是 就是说 据 具体地说 具体说来 开始 开外 靠 咳 可 可见 可是 可以 况且 啦 来 来着 离 例如 哩 连 连同 两者 了 临 另 另外 另一方面 论 嘛 吗 慢说 漫说 冒 么 每 每当 们 莫若 某 某个 某些 拿 哪 哪边 哪儿 哪个 哪里 哪年 哪怕 哪天 哪些 哪样 那 那边 那儿 那个 那会儿 那里 那么 那么些 那么样 那时 那些 那样 乃 乃至 呢 能 你 你们 您 宁 宁可 宁肯 宁愿 哦 呕 啪达 旁人 呸 凭 凭借 其 其次 其二 其他 其它 其一 其余 其中 起 起见 起见 岂但 恰恰相反 前后 前者 且 然而 然后 然则 让 人家 任 任何 任凭 如 如此 如果 如何 如其 如若 如上所述 若 若非 若是 啥 上下 尚且 设若 设使 甚而 甚么 甚至 省得 时候 什么 什么样 使得 是 是的 首先 谁 谁知 顺 顺着 似的 虽 虽然 虽说 虽则 随 随着 所 所以 他 他们 他人 它 它们 她 她们 倘 倘或 倘然 倘若 倘使 腾 替 通过 同 同时 哇 万一 往 望 为 为何 为了 为什么 为着 喂 嗡嗡 我 我们 呜 呜呼 乌乎 无论 无宁 毋宁 嘻 吓 相对而言 像 向 向着 嘘 呀 焉 沿 沿着 要 要不 要不然 要不是 要么 要是 也 也罢 也好 一 一般 一旦 一方面 一来 一切 一样 一则 依 依照 矣 以 以便 以及 以免 以至 以至于 以致 抑或 因 因此 因而 因为 哟 用 由 由此可见 由于 有 有的 有关 有些 又 于 于是 于是乎 与 与此同时 与否 与其 越是 云云 哉 再说 再者 在 在下 咱 咱们 则 怎 怎么 怎么办 怎么样 怎样 咋 照 照着 者 这 这边 这儿 这个 这会儿 这就是说 这里 这么 这么点儿 这么些 这么样 这时 这些 这样 正如 吱 之 之类 之所以 之一 只是 只限 只要 只有 至 至于 诸位 着 着呢 自 自从 自个儿 自各儿 自己 自家 自身 综上所述 总的来看 总的来说 总的说来 总而言之 总之 纵 纵令 纵然 纵使 遵照 作为 兮 呃 呗 咚 咦 喏 啐 喔唷 嗬 嗯 嗳 ================================================ FILE: docs/index.md ================================================ # MediaCrawler使用方法 ## 项目文档 - [项目架构文档](项目架构文档.md) - 系统架构、模块设计、数据流向(含 Mermaid 图表) ## 推荐:使用 uv 管理依赖 ### 1. 前置依赖 - 安装 [uv](https://docs.astral.sh/uv/getting-started/installation),并用 `uv --version` 验证。 - Python 版本建议使用 **3.11**(当前依赖基于该版本构建)。 - 安装 Node.js(抖音、知乎等平台需要),版本需 `>= 16.0.0`。 ### 2. 同步 Python 依赖 ```shell # 进入项目根目录 cd MediaCrawler # 使用 uv 保证 Python 版本和依赖一致性 uv sync ``` ### 3. 安装 Playwright 浏览器驱动 ```shell uv run playwright install ``` > 项目已支持使用 Playwright 连接本地 Chrome。如需使用 CDP 方式,可在 `config/base_config.py` 中调整 `xhs` 和 `dy` 的相关配置。 ### 4. 运行爬虫程序 ```shell # 项目默认未开启评论爬取,如需评论请在 config/base_config.py 中修改 ENABLE_GET_COMMENTS # 其他功能开关也可在 config/base_config.py 查看,均有中文注释 # 从配置中读取关键词搜索并爬取帖子与评论 uv run main.py --platform xhs --lt qrcode --type search # 从配置中读取指定帖子ID列表并爬取帖子与评论 uv run main.py --platform xhs --lt qrcode --type detail # 使用 SQLite 数据库存储数据(推荐个人用户使用) uv run main.py --platform xhs --lt qrcode --type search --save_data_option sqlite # 使用 MySQL 数据库存储数据 uv run main.py --platform xhs --lt qrcode --type search --save_data_option db # 其他平台示例 uv run main.py --help ``` ## 备选:Python 原生 venv(不推荐) > 如果爬取抖音或知乎,需要提前安装 Node.js,版本 `>= 16`。 ```shell # 进入项目根目录 cd MediaCrawler # 创建虚拟环境(示例 Python 版本:3.11,requirements 基于该版本) python -m venv venv # macOS & Linux 激活虚拟环境 source venv/bin/activate # Windows 激活虚拟环境 venv\Scripts\activate ``` ```shell # 安装依赖与驱动 pip install -r requirements.txt playwright install ``` ```shell # 运行爬虫程序(venv 环境) python main.py --platform xhs --lt qrcode --type search python main.py --platform xhs --lt qrcode --type detail python main.py --platform xhs --lt qrcode --type search --save_data_option sqlite python main.py --platform xhs --lt qrcode --type search --save_data_option db python main.py --help ``` ## 💾 数据存储 支持多种数据存储方式: - **CSV 文件**: 支持保存至 CSV (位于 `data/` 目录下) - **JSON 文件**: 支持保存至 JSON (位于 `data/` 目录下) - **数据库存储** - 使用 `--init_db` 参数进行数据库初始化 (使用 `--init_db` 时,无需其他可选参数) - **SQLite 数据库**: 轻量级数据库,无需服务器,适合个人使用 (推荐) 1. 初始化: `--init_db sqlite` 2. 数据存储: `--save_data_option sqlite` - **MySQL 数据库**: 支持保存至关系型数据库 MySQL (需提前创建数据库) 1. 初始化: `--init_db mysql` 2. 数据存储: `--save_data_option db` (db 参数为兼容历史更新保留) ## 免责声明 > **免责声明:** > > 大家请以学习为目的使用本仓库,爬虫违法违规的案件:https://github.com/HiddenStrawberry/Crawler_Illegal_Cases_In_China
> >本项目的所有内容仅供学习和参考之用,禁止用于商业用途。任何人或组织不得将本仓库的内容用于非法用途或侵犯他人合法权益。本仓库所涉及的爬虫技术仅用于学习和研究,不得用于对其他平台进行大规模爬虫或其他非法行为。对于因使用本仓库内容而引起的任何法律责任,本仓库不承担任何责任。使用本仓库的内容即表示您同意本免责声明的所有条款和条件。 ================================================ FILE: docs/mediacrawlerpro订阅.md ================================================ # 订阅MediaCrawlerPro版本源码访问权限 ## 获取Pro版本的访问权限 > MediaCrawler开源超过一年了,相信该仓库帮过不少朋友低门槛的学习和了解爬虫。维护真的耗费了大量精力和人力
> > 所以Pro版本不会开源,可以订阅Pro版本让我更加有动力去更新。
> > 如果感兴趣可以加我微信,订阅Pro版本访问权限哦,有门槛💰。
> > 仅针对想学习Pro版本源码实现的用户,如果是公司或者商业化盈利性质的就不要加我了,谢谢🙏 > > 代码设计拓展性强,可以自己扩展更多的爬虫平台,更多的数据存储方式,相信对你架构这种爬虫代码有所帮助。 > > > **MediaCrawlerPro项目主页地址** > [MediaCrawlerPro Github主页地址](https://github.com/MediaCrawlerPro) 扫描下方我的个人微信,备注:pro版本(如果图片展示不出来,可以直接添加我的微信号:relakkes) ![relakkes_weichat.JPG](static/images/relakkes_weichat.jpg) ## Pro版本诞生的背景 [MediaCrawler](https://github.com/NanmiCoder/MediaCrawler)这个项目开源至今获得了大量的关注,同时也暴露出来了一系列问题,比如: - 能否支持多账号? - 能否在linux部署? - 能否去掉playwright的依赖? - 有没有更简单的部署方法? - 有没有针对新手上门槛更低的方法? 诸如上面的此类问题,想要在原有项目上去动刀,无疑是增加了复杂度,可能导致后续的维护更加困难。 出于可持续维护、简便易用、部署简单等目的,对MediaCrawler进行彻底重构。 ## 项目介绍 ### [MediaCrawler](https://github.com/NanmiCoder/MediaCrawler)的Pro版本python实现 **小红书爬虫**,**抖音爬虫**, **快手爬虫**, **B站爬虫**, **微博爬虫**,**百度贴吧**,**知乎爬虫**...。 支持多种平台的爬虫,支持多种数据的爬取,支持多种数据的存储,最重要的**完美支持多账号+IP代理池,让你的爬虫更加稳定**。 相较于MediaCrawler,Pro版本最大的变化: - 去掉了playwright的依赖,不再将Playwright集成到爬虫主干中,依赖过重。 - 增加了Docker,Docker-compose的方式部署,让部署更加简单。 - 多账号+IP代理池的支持,让爬虫更加稳定。 - 新增签名服务,解耦签名逻辑,让爬虫更加灵活。 ================================================ FILE: docs/代理使用.md ================================================ # 代理 IP 使用说明 > 还是得跟大家再次强调下,不要对一些自媒体平台进行大规模爬虫或其他非法行为,要踩缝纫机的哦🤣 ## 简易的流程图 ![代理 IP 使用流程图](static/images/代理IP%20流程图.drawio.png) ## 选择一个代理IP提供商 ### 快代理 [快代理使用文档](快代理使用文档.md) ### 豌豆HTTP文档查看 [豌豆HTTP使用文档](豌豆HTTP使用文档.md) ================================================ FILE: docs/作者介绍.md ================================================ # 关于作者 > 大家都叫我阿江,网名:程序员阿江-Relakkes,目前是一名独立开发者,专注于 AI Agent 和爬虫相关的开发工作,All in AI。 - [Github万星开源自媒体爬虫仓库MediaCrawler作者](https://github.com/NanmiCoder/MediaCrawler) - 全栈程序员,熟悉Python、Golang、JavaScript,工作中主要用Golang。 - 曾经主导并参与过百万级爬虫采集系统架构设计与编码 - 爬虫是一种技术兴趣爱好,参与爬虫有一种对抗的感觉,越难越兴奋。 - 目前专注于 AI Agent 领域,积极探索 AI 技术的应用与创新 - 如果你有 AI Agent 相关的项目需要合作,欢迎联系我,我有很多时间可以投入 ## 微信联系方式 ![relakkes_weichat.JPG](static/images/relakkes_weichat.jpg) ## B站主页地址 https://space.bilibili.com/434377496 ## 抖音主页地址 https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE?previous_page=app_code_link ## 小红书主页地址 https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753?xhsshare=CopyLink&appuid=5f58bd990000000001003753&apptime=1724737153 ================================================ FILE: docs/原生环境管理文档.md ================================================ # 本地原生环境管理 ## 推荐方案:使用 uv 管理依赖 ### 1. 前置依赖 - 安装 [uv](https://docs.astral.sh/uv/getting-started/installation),并使用 `uv --version` 验证。 - Python 版本建议使用 **3.11**(当前依赖基于该版本构建)。 - 安装 Node.js(抖音、知乎等平台需要),版本需 `>= 16.0.0`。 ### 2. 同步 Python 依赖 ```shell # 进入项目根目录 cd MediaCrawler # 使用 uv 保证 Python 版本和依赖一致性 uv sync ``` ### 3. 安装 Playwright 浏览器驱动 ```shell uv run playwright install ``` > 项目已支持使用 Playwright 连接本地 Chrome。如需使用 CDP 方式,可在 `config/base_config.py` 中调整 `xhs` 和 `dy` 的相关配置。 ### 4. 运行爬虫程序 ```shell # 项目默认未开启评论爬取,如需评论请在 config/base_config.py 中修改 ENABLE_GET_COMMENTS # 其他功能开关也可在 config/base_config.py 查看,均有中文注释 # 从配置中读取关键词搜索并爬取帖子与评论 uv run main.py --platform xhs --lt qrcode --type search # 从配置中读取指定帖子ID列表并爬取帖子与评论 uv run main.py --platform xhs --lt qrcode --type detail # 其他平台示例 uv run main.py --help ``` ## 备选方案:Python 原生 venv(不推荐) ### 创建并激活虚拟环境 > 如果爬取抖音或知乎,需要提前安装 Node.js,版本 `>= 16`。 ```shell # 进入项目根目录 cd MediaCrawler # 创建虚拟环境(示例 Python 版本:3.11,requirements 基于该版本) python -m venv venv # macOS & Linux 激活虚拟环境 source venv/bin/activate # Windows 激活虚拟环境 venv\Scripts\activate ``` ### 安装依赖与驱动 ```shell pip install -r requirements.txt playwright install ``` ### 运行爬虫程序(venv 环境) ```shell # 从配置中读取关键词搜索并爬取帖子与评论 python main.py --platform xhs --lt qrcode --type search # 从配置中读取指定帖子ID列表并爬取帖子与评论 python main.py --platform xhs --lt qrcode --type detail # 更多示例 python main.py --help ``` ================================================ FILE: docs/常见问题.md ================================================ # 常见程序运行出错问题 ## 缺少node环境导致的问题 Q: 爬取抖音和知乎报错: `execjs._exceptions.ProgramError: SyntaxError: 缺少 ';'`
A: 该错误为缺少 nodejs 环境,这个错误可以通过安装 nodejs 环境来解决,版本大于等:`v16`
Q: 使用Cookie爬取抖音报错: execjs._exceptions.ProgramError: TypeError: Cannot read property 'JS_MD5_NO_COMMON_JS' of null A: windows电脑去网站下载`https://nodejs.org/en/blog/release/v16.8.0` Windows 64-bit Installer 版本,一直下一步即可。 ## xhs登录出现滑块一直验证不通过问题 Q: 小红书扫码登录成功后,浏览器一直在验证滑块,无法登录?
A: 这种情况一般是因为使用playwright浏览器驱动被识别出来的问题,可以尝试删除项目目录下的`brower_data`文件夹,重新走登录流程。
## 如何指定关键词 Q: 可以指定关键词爬取吗?
A: 在config/base_config.py 中 KEYWORDS 参数用于控制需要爬取的关键词
## 如何指定帖子 Q: 可以指定帖子爬取吗?
A:在config/base_config.py 中 XHS_SPECIFIED_ID_LIST 参数用于控制需要指定爬取的帖子ID列表
## 爬取失效 Q: 刚开始能爬取数据,过一段时间就是失效了?
A:出现这种情况多半是由于你的账号触发了平台风控机制了,❗️❗️请勿大规模对平台进行爬虫,影响平台。
## 如何更换另一个账号 Q: 如何更换登录账号?
A:删除项目根目录下的 brower_data/ 文件夹即可
## playwright超时问题 Q: 报错 `playwright._impl._api_types.TimeoutError: Timeout 30000ms exceeded.`
A: 出现这种情况检查下开梯子没有
## 如果配置playwright浏览器驱动过滑块验证 Q: 小红书扫码登录成功后如何手动验证? A: 打开 config/base_config.py 文件, 找到 HEADLESS 配置项, 将其设置为 False, 此时重启项目, 在浏览器中手动通过验证码
## 词云图生成 Q: 如何配置词云图的生成? A: 打开 config/base_config.py 文件, 找到`ENABLE_GET_WORDCLOUD` 以及`ENABLE_GET_COMMENTS` 两个配置项,将其都设为True即可使用该功能。
## 词云图添加禁用词和自定义词组 Q: 如何给词云图添加禁用词和自定义词组? A: 打开 `docs/hit_stopwords.txt` 输入禁用词(注意一个词语一行)。打开 config/base_config.py 文件找到 `CUSTOM_WORDS `按格式添加自定义词组即可。
================================================ FILE: docs/开发者咨询.md ================================================ # 开发者咨询 ## 咨询价格 提供200/小时的咨询服务,最低收费为1小时,帮你快速解决项目中遇到的问题 ##### 支持的提问类别 - MediaCrawler项目源码解读、安装、部署、使用问题 - 爬虫项目开发问题 - Python、Golang、JavaScript等编程问题 - JS逆向问题 - 其他问题(职业规划、工作经验等) ## 加我微信 > 备注:咨服服务 > ![微信二维码](static/images/relakkes_weichat.jpg) ================================================ FILE: docs/微信交流群.md ================================================ # MediaCrawler项目微信交流群 👏👏👏 汇聚爬虫技术爱好者,共同学习,共同进步。 ❗️❗️❗️群内禁止广告,禁止发各类违规和MediaCrawler不相关的问题 ## 加群方式 > 备注:github,会有拉群小助手自动拉你进群。 > > 如果图片展示不出来或过期,可以直接添加我的微信号:relakkes,并备注github,会有拉群小助手自动拉你进群 ![relakkes_wechat](static/images/QIWEI.png) ================================================ FILE: docs/快代理使用文档.md ================================================ ## 快代理使用文档(支持个人和企业用户) ## 准备代理 IP 信息 点击 快代理 官网注册并实名认证(国内使用代理 IP 必须要实名,懂的都懂) ## 获取 IP 代理的密钥信息 从 快代理 官网获取免费试用,如下图所示 ![img.png](static/images/img.png) 注意:选择私密代理 ![img_1.png](static/images/img_1.png) 选择开通试用 ![img_2.png](static/images/img_2.png) 初始化一个快代理的示例,如下代码所示,需要4个参数 ```python # 文件地址: proxy/providers/kuai_daili_proxy.py # -*- coding: utf-8 -*- def new_kuai_daili_proxy() -> KuaiDaiLiProxy: """ 构造快代理HTTP实例 Returns: """ return KuaiDaiLiProxy( kdl_secret_id=os.getenv("kdl_secret_id", "你的快代理secert_id"), kdl_signature=os.getenv("kdl_signature", "你的快代理签名"), kdl_user_name=os.getenv("kdl_user_name", "你的快代理用户名"), kdl_user_pwd=os.getenv("kdl_user_pwd", "你的快代理密码"), ) ``` 在试用的订单中可以看到这四个参数,如下图所示 `kdl_user_name`、`kdl_user_pwd` ![img_3.png](static/images/img_3.png) `kdl_secret_id`、`kdl_signature` ![img_4.png](static/images/img_4.png) ================================================ FILE: docs/手机号登录说明.md ================================================ # 关于手机号+验证码登录的说明 > 配置过程相当复杂,不建议采用该种方式 当在浏览器模拟人为发起手机号登录请求时,使用短信转发软件将验证码发送至爬虫端回填,完成自动登录 准备工作: - 安卓机1台(IOS没去研究,理论上监控短信也是可行的) - 安装短信转发软件 [参考仓库](https://github.com/pppscn/SmsForwarder) - 转发软件中配置WEBHOOK相关的信息,主要分为 消息模板(请查看本项目中的recv_sms_notification.py)、一个能push短信通知的API地址 - push的API地址一般是需要绑定一个域名的(当然也可以是内网的IP地址),我用的是内网穿透方式,会有一个免费的域名绑定到内网的web server,内网穿透工具 [ngrok](https://ngrok.com/docs/) - 安装redis并设置一个密码 [redis安装](https://www.cnblogs.com/hunanzp/p/12304622.html) - 执行 `python recv_sms_notification.py` 等待短信转发器发送HTTP通知 - 执行手机号登录的爬虫程序 `python main.py --platform xhs --lt phone` 备注: - 短信转发软件会不会监控自己手机上其他短信内容?(理论上应该不会,因为[短信转发仓库](https://github.com/pppscn/SmsForwarder) star还是蛮多的) ================================================ FILE: docs/捐赠名单.md ================================================ ## 捐赠MediaCrawler开源项目 > 捐赠时请务必备注您的昵称,我会在捐赠名单中表达对您的感谢 ## 赞赏二维码

微信赞赏

微信赞赏二维码

支付宝赞赏

支付宝赞赏二维码
# MediaCrawler捐赠名单 > 再次感谢下面的捐赠者们对MediaCrawler的鼎力支持,是你们的支持让MediaCrawler的更新有了动力。 PS:如果打赏时请备注捐赠者,如有遗漏请联系我添加(有时候消息多可能会漏掉,十分抱歉) | 捐赠者 | 捐赠金额 | 捐赠日期 | | ----------- | -------- | ---------- | | RichardYU | 99 元 | 2025-06-19 | | Z.FB | 20 元 | 2025-04-10 | | 若成 | 20 元 | 2025-04-01 | | Puple_twirl | 20 元 | 2025-03-30 | | N--F | 20 元 | 2025-03-13 | | 财* | 20 元 | 2025-03-06 | | 布莱** | 1 元 | 2025-01-27 | | xldmilktea | 20 元 | 2025-01-25 | | ChenWenLon | 20 元 | 2025-01-07 | | steam | 20 元 | 2024-12-20 | | mike | 20 元 | 2024-12-17 | | thechnolog | 5 元 | 2024-11-05 | | yinzhou | 100 元 | 2024-10-21 | | Tnk_se | 50 元 | 2024-10-21 | | 望、7 | 66 元 | 2024-09-26 | | 凌凌7 | 200 元 | 2024-09-19 | | yutao | 20 元 | 2024-09-19 | | Urtb* | 100 元 | 2024-09-07 | | Tornado | 66 元 | 2024-09-04 | | srhedbj | 50 元 | 2024-08-20 | | *嘉 | 20 元 | 2024-08-15 | | *良 | 50 元 | 2024-08-13 | | *皓 | 50 元 | 2024-03-18 | | *刚 | 50 元 | 2024-03-18 | | *乐 | 20 元 | 2024-03-17 | | *木 | 20 元 | 2024-03-17 | | *诚 | 20 元 | 2024-03-17 | | Strem Gamer | 20 元 | 2024-03-16 | | *鑫 | 20 元 | 2024-03-14 | | Yuzu | 20 元 | 2024-03-07 | | **宁 | 100 元 | 2024-03-03 | | **媛 | 20 元 | 2024-03-03 | | Scarlett | 20 元 | 2024-02-16 | | Asun | 20 元 | 2024-01-30 | | 何* | 100 元 | 2024-01-21 | | allen | 20 元 | 2024-01-10 | | llllll | 20 元 | 2024-01-07 | | 邝*元 | 20 元 | 2023-12-29 | | 50chen | 50 元 | 2023-12-22 | | xiongot | 20 元 | 2023-12-17 | | atom.hu | 20 元 | 2023-12-16 | | 一呆 | 20 元 | 2023-12-01 | | 坠落 | 50 元 | 2023-11-08 | ================================================ FILE: docs/知识付费介绍.md ================================================ # 知识付费介绍 开源是一种无私奉献,从MediaCrawler开源到现在有一年多,它并没有带给我多少实质性的东西,就拿收入来说,赞助费、赞赏等等全部加起来还没有之前一个月的薪水。 后面搞了MediaCrawler源码剖析课程之后,收入稍微好一点,但也是群里兄弟对我开源的支持,在此也非常感谢你们~ 但是我依然坚持持续开源,从开始的xhs、dy 2个平台支持,到现在已经有**7个平台**支持,每一次增加一个平台其实都会耗费很大的时间去写代码和调试代码。。。。 在今天跟一个群里好朋友聊天,他说:开源开发者也要活下去。你不要不好意思做知识付费,你的劳动是有价值的。 他点醒我了,因此我把我所提供的知识付费内容放在下面,有需要的朋友可以看看~ ## MediaCrawlerPro项目源码订阅服务 [mediacrawlerpro订阅文档说明](mediacrawlerpro订阅.md) ## MediaCrawler源码剖析视频课程 [mediacrawler源码课程介绍](https://relakkes.feishu.cn/wiki/JUgBwdhIeiSbAwkFCLkciHdAnhh) ================================================ FILE: docs/词云图使用配置.md ================================================ # 关于词云图相关操作 ## 1.如何正确调用词云图 > ps:保存格式为json或jsonl文件时,才会生成词云图。其他存储方式添加词云图将在近期添加。 需要修改的配置项(./config/base_config.py): ```python # 数据保存类型选项配置,支持多种类型:csv、db、json、jsonl等 #此处需要为json或jsonl格式保存,原因如上 SAVE_DATA_OPTION = "jsonl" # csv or db or json or jsonl ``` ```python # 是否开启爬评论模式, 默认不开启爬评论 #此处为True,需要爬取评论才可以生成评论的词云图。 ENABLE_GET_COMMENTS = True ``` ```python #词云相关 #是否开启生成评论词云图 #打开词云图功能 ENABLE_GET_WORDCLOUD = True ``` ```python # 添加自定义词语及其分组 #添加规则:xx:yy 其中xx为自定义添加的词组,yy为将xx该词组分到的组名。 CUSTOM_WORDS = { '零几': '年份', # 将“零几”识别为一个整体 '高频词': '专业术语' # 示例自定义词 } ``` ```python #停用(禁用)词文件路径 STOP_WORDS_FILE = "./docs/hit_stopwords.txt" ``` ```python #中文字体文件路径 FONT_PATH= "./docs/STZHONGS.TTF" ``` **相关解释** - 自定义词组的添加,`xx:yy` 中`xx`为自定义词语,`yy`为`xx`分配词语的组别。`yy`可以随便给任意值。 - 如果需要添加禁用词,请在./docs/hit_stopwords.txt添加禁用词(保证格式正确,一个词语一行) - `FONT_PATH`为生成词云图中中文字体的格式,默认为宋体。可以自行添加字体文件,修改路径。 ## 2.生成词云图的位置 ![image-20240627204928601](https://rosyrain.oss-cn-hangzhou.aliyuncs.com/img2/202406272049662.png) 如图,在data文件下的`words文件夹`下,其中json为词频统计文件,png为词云图。原本的评论内容在`jsonl文件夹`(或`json文件夹`)下。 ================================================ FILE: docs/豌豆HTTP使用文档.md ================================================ ## 豌豆HTTP代理使用文档 (只支持企业用户) ## 准备代理 IP 信息 点击 豌豆HTTP代理 官网注册并实名认证(国内使用代理 IP 必须要实名,懂的都懂) ## 获取 IP 代理的密钥信息 appkey 从 豌豆HTTP代理 官网获取免费试用,如下图所示 ![img.png](static/images/wd_http_img.png) 选择自己需要的套餐 ![img_4.png](static/images/wd_http_img_4.png) 初始化一个豌豆HTTP代理的示例,如下代码所示,需要1个参数: app_key ```python # 文件地址: proxy/providers/wandou_http_proxy.py # -*- coding: utf-8 -*- def new_wandou_http_proxy() -> WanDouHttpProxy: """ 构造豌豆HTTP实例 Returns: """ return WanDouHttpProxy( app_key=os.getenv( "wandou_app_key", "你的豌豆HTTP app_key" ), # 通过环境变量的方式获取豌豆HTTP app_key ) ``` 在个人中心的`开放接口`找到 `app_key`,如下图所示 ![img_2.png](static/images/wd_http_img_2.png) ================================================ FILE: docs/项目代码结构.md ================================================ # 项目代码结构 ``` MediaCrawler ├── base │ └── base_crawler.py # 项目的抽象基类 ├── cache │ ├── abs_cache.py # 缓存抽象基类 │ ├── cache_factory.py # 缓存工厂 │ ├── local_cache.py # 本地缓存实现 │ └── redis_cache.py # Redis缓存实现 ├── cmd_arg │ └── arg.py # 命令行参数定义 ├── config │ ├── base_config.py # 基础配置 │ ├── db_config.py # 数据库配置 │ └── ... # 各平台配置文件 ├── constant │ └── ... # 各平台常量定义 ├── database │ ├── db.py # 数据库ORM,封装增删改查 │ ├── db_session.py # 数据库会话管理 │ └── models.py # 数据库模型定义 ├── docs │ └── ... # 项目文档 ├── libs │ ├── douyin.js # 抖音Sign函数 │ ├── stealth.min.js # 去除浏览器自动化特征的JS │ └── zhihu.js # 知乎Sign函数 ├── media_platform │ ├── bilibili # B站采集实现 │ ├── douyin # 抖音采集实现 │ ├── kuaishou # 快手采集实现 │ ├── tieba # 百度贴吧采集实现 │ ├── weibo # 微博采集实现 │ ├── xhs # 小红书采集实现 │ └── zhihu # 知乎采集实现 ├── model │ ├── m_baidu_tieba.py # 百度贴吧数据模型 │ ├── m_douyin.py # 抖音数据模型 │ ├── m_kuaishou.py # 快手数据模型 │ ├── m_weibo.py # 微博数据模型 │ ├── m_xiaohongshu.py # 小红书数据模型 │ └── m_zhihu.py # 知乎数据模型 ├── proxy │ ├── base_proxy.py # 代理基类 │ ├── providers # 代理提供商实现 │ ├── proxy_ip_pool.py # 代理IP池 │ └── types.py # 代理类型定义 ├── store │ ├── bilibili # B站数据存储实现 │ ├── douyin # 抖音数据存储实现 │ ├── kuaishou # 快手数据存储实现 │ ├── tieba # 贴吧数据存储实现 │ ├── weibo # 微博数据存储实现 │ ├── xhs # 小红书数据存储实现 │ └── zhihu # 知乎数据存储实现 ├── test │ ├── test_db_sync.py # 数据库同步测试 │ ├── test_proxy_ip_pool.py # 代理IP池测试 │ └── ... # 其他测试用例 ├── tools │ ├── browser_launcher.py # 浏览器启动器 │ ├── cdp_browser.py # CDP浏览器控制 │ ├── crawler_util.py # 爬虫工具函数 │ ├── utils.py # 通用工具函数 │ └── ... ├── main.py # 程序入口, 支持 --init_db 参数来初始化数据库 ├── recv_sms.py # 短信转发HTTP SERVER接口 └── var.py # 全局上下文变量定义 ``` ================================================ FILE: docs/项目架构文档.md ================================================ # MediaCrawler 项目架构文档 ## 1. 项目概述 ### 1.1 项目简介 MediaCrawler 是一个多平台自媒体爬虫框架,采用 Python 异步编程实现,支持爬取主流社交媒体平台的内容、评论和创作者信息。 ### 1.2 支持的平台 | 平台 | 代号 | 主要功能 | |------|------|---------| | 小红书 | `xhs` | 笔记搜索、详情、创作者 | | 抖音 | `dy` | 视频搜索、详情、创作者 | | 快手 | `ks` | 视频搜索、详情、创作者 | | B站 | `bili` | 视频搜索、详情、UP主 | | 微博 | `wb` | 微博搜索、详情、博主 | | 百度贴吧 | `tieba` | 帖子搜索、详情 | | 知乎 | `zhihu` | 问答搜索、详情、答主 | ### 1.3 核心功能特性 - **多平台支持**:统一的爬虫接口,支持 7 大主流平台 - **多种登录方式**:二维码、手机号、Cookie 三种登录方式 - **多种存储方式**:CSV、JSON、JSONL、SQLite、MySQL、MongoDB、Excel - **反爬虫对策**:CDP 模式、代理 IP 池、请求签名 - **异步高并发**:基于 asyncio 的异步架构,高效并发爬取 - **词云生成**:自动生成评论词云图 --- ## 2. 系统架构总览 ### 2.1 高层架构图 ```mermaid flowchart TB subgraph Entry["入口层"] main["main.py
程序入口"] cmdarg["cmd_arg
命令行参数"] config["config
配置管理"] end subgraph Core["核心爬虫层"] factory["CrawlerFactory
爬虫工厂"] base["AbstractCrawler
爬虫基类"] subgraph Platforms["平台实现"] xhs["XiaoHongShuCrawler"] dy["DouYinCrawler"] ks["KuaishouCrawler"] bili["BilibiliCrawler"] wb["WeiboCrawler"] tieba["TieBaCrawler"] zhihu["ZhihuCrawler"] end end subgraph Client["API客户端层"] absClient["AbstractApiClient
客户端基类"] xhsClient["XiaoHongShuClient"] dyClient["DouYinClient"] ksClient["KuaiShouClient"] biliClient["BilibiliClient"] wbClient["WeiboClient"] tiebaClient["BaiduTieBaClient"] zhihuClient["ZhiHuClient"] end subgraph Storage["数据存储层"] storeFactory["StoreFactory
存储工厂"] csv["CSV存储"] json["JSON存储"] sqlite["SQLite存储"] mysql["MySQL存储"] mongodb["MongoDB存储"] excel["Excel存储"] end subgraph Infra["基础设施层"] browser["浏览器管理
Playwright/CDP"] proxy["代理IP池"] cache["缓存系统"] login["登录管理"] end main --> factory cmdarg --> main config --> main factory --> base base --> Platforms Platforms --> Client Client --> Storage Client --> Infra Storage --> storeFactory storeFactory --> csv & json & sqlite & mysql & mongodb & excel ``` ### 2.2 数据流向图 ```mermaid flowchart LR subgraph Input["输入"] keywords["关键词/ID"] config["配置参数"] end subgraph Process["处理流程"] browser["启动浏览器"] login["登录认证"] search["搜索/爬取"] parse["数据解析"] comment["获取评论"] end subgraph Output["输出"] content["内容数据"] comments["评论数据"] creator["创作者数据"] media["媒体文件"] end subgraph Storage["存储"] file["文件存储
CSV/JSON/Excel"] db["数据库
SQLite/MySQL"] nosql["NoSQL
MongoDB"] end keywords --> browser config --> browser browser --> login login --> search search --> parse parse --> comment parse --> content comment --> comments parse --> creator parse --> media content & comments & creator --> file & db & nosql media --> file ``` --- ## 3. 目录结构 ``` MediaCrawler/ ├── main.py # 程序入口 ├── var.py # 全局上下文变量 ├── pyproject.toml # 项目配置 │ ├── base/ # 基础抽象类 │ └── base_crawler.py # 爬虫、登录、存储、客户端基类 │ ├── config/ # 配置管理 │ ├── base_config.py # 核心配置 │ ├── db_config.py # 数据库配置 │ └── {platform}_config.py # 平台特定配置 │ ├── media_platform/ # 平台爬虫实现 │ ├── xhs/ # 小红书 │ ├── douyin/ # 抖音 │ ├── kuaishou/ # 快手 │ ├── bilibili/ # B站 │ ├── weibo/ # 微博 │ ├── tieba/ # 百度贴吧 │ └── zhihu/ # 知乎 │ ├── store/ # 数据存储 │ ├── excel_store_base.py # Excel存储基类 │ └── {platform}/ # 各平台存储实现 │ ├── database/ # 数据库层 │ ├── models.py # ORM模型定义 │ ├── db_session.py # 数据库会话管理 │ └── mongodb_store_base.py # MongoDB基类 │ ├── proxy/ # 代理管理 │ ├── proxy_ip_pool.py # IP池管理 │ ├── proxy_mixin.py # 代理刷新混入 │ └── providers/ # 代理提供商 │ ├── cache/ # 缓存系统 │ ├── abs_cache.py # 缓存抽象类 │ ├── local_cache.py # 本地缓存 │ └── redis_cache.py # Redis缓存 │ ├── tools/ # 工具模块 │ ├── app_runner.py # 应用运行管理 │ ├── browser_launcher.py # 浏览器启动 │ ├── cdp_browser.py # CDP浏览器管理 │ ├── crawler_util.py # 爬虫工具 │ └── async_file_writer.py # 异步文件写入 │ ├── model/ # 数据模型 │ └── m_{platform}.py # Pydantic模型 │ ├── libs/ # JS脚本库 │ └── stealth.min.js # 反检测脚本 │ └── cmd_arg/ # 命令行参数 └── arg.py # 参数定义 ``` --- ## 4. 核心模块详解 ### 4.1 爬虫基类体系 ```mermaid classDiagram class AbstractCrawler { <> +start()* 启动爬虫 +search()* 搜索功能 +launch_browser() 启动浏览器 +launch_browser_with_cdp() CDP模式启动 } class AbstractLogin { <> +begin()* 开始登录 +login_by_qrcode()* 二维码登录 +login_by_mobile()* 手机号登录 +login_by_cookies()* Cookie登录 } class AbstractStore { <> +store_content()* 存储内容 +store_comment()* 存储评论 +store_creator()* 存储创作者 +store_image()* 存储图片 +store_video()* 存储视频 } class AbstractApiClient { <> +request()* HTTP请求 +update_cookies()* 更新Cookies } class ProxyRefreshMixin { +init_proxy_pool() 初始化代理池 +_refresh_proxy_if_expired() 刷新过期代理 } class XiaoHongShuCrawler { +xhs_client: XiaoHongShuClient +start() +search() +get_specified_notes() +get_creators_and_notes() } class XiaoHongShuClient { +playwright_page: Page +cookie_dict: Dict +request() +pong() 检查登录状态 +get_note_by_keyword() +get_note_by_id() } AbstractCrawler <|-- XiaoHongShuCrawler AbstractApiClient <|-- XiaoHongShuClient ProxyRefreshMixin <|-- XiaoHongShuClient ``` ### 4.2 爬虫生命周期 ```mermaid sequenceDiagram participant Main as main.py participant Factory as CrawlerFactory participant Crawler as XiaoHongShuCrawler participant Browser as Playwright/CDP participant Login as XiaoHongShuLogin participant Client as XiaoHongShuClient participant Store as StoreFactory Main->>Factory: create_crawler("xhs") Factory-->>Main: crawler实例 Main->>Crawler: start() alt 启用IP代理 Crawler->>Crawler: create_ip_pool() end alt CDP模式 Crawler->>Browser: launch_browser_with_cdp() else 标准模式 Crawler->>Browser: launch_browser() end Browser-->>Crawler: browser_context Crawler->>Crawler: create_xhs_client() Crawler->>Client: pong() 检查登录状态 alt 未登录 Crawler->>Login: begin() Login->>Login: login_by_qrcode/mobile/cookie Login-->>Crawler: 登录成功 end alt search模式 Crawler->>Client: get_note_by_keyword() Client-->>Crawler: 搜索结果 loop 获取详情 Crawler->>Client: get_note_by_id() Client-->>Crawler: 笔记详情 end else detail模式 Crawler->>Client: get_note_by_id() else creator模式 Crawler->>Client: get_creator_info() end Crawler->>Store: store_content/comment/creator Store-->>Crawler: 存储完成 Main->>Crawler: cleanup() Crawler->>Browser: close() ``` ### 4.3 平台爬虫实现结构 每个平台目录包含以下核心文件: ``` media_platform/{platform}/ ├── __init__.py # 模块导出 ├── core.py # 爬虫主实现类 ├── client.py # API客户端 ├── login.py # 登录实现 ├── field.py # 字段/枚举定义 ├── exception.py # 异常定义 ├── help.py # 辅助函数 └── {特殊实现}.py # 平台特定逻辑 ``` ### 4.4 三种爬虫模式 | 模式 | 配置值 | 功能描述 | 适用场景 | |------|--------|---------|---------| | 搜索模式 | `search` | 根据关键词搜索内容 | 批量获取特定主题内容 | | 详情模式 | `detail` | 获取指定ID的详情 | 精确获取已知内容 | | 创作者模式 | `creator` | 获取创作者所有内容 | 追踪特定博主/UP主 | --- ## 5. 数据存储层 ### 5.1 存储架构图 ```mermaid classDiagram class AbstractStore { <> +store_content()* +store_comment()* +store_creator()* } class StoreFactory { +STORES: Dict +create_store() AbstractStore } class CsvStoreImplement { +async_file_writer: AsyncFileWriter +store_content() +store_comment() } class JsonStoreImplement { +async_file_writer: AsyncFileWriter +store_content() +store_comment() } class DbStoreImplement { +session: AsyncSession +store_content() +store_comment() } class SqliteStoreImplement { +session: AsyncSession +store_content() +store_comment() } class MongoStoreImplement { +mongo_base: MongoDBStoreBase +store_content() +store_comment() } class ExcelStoreImplement { +excel_base: ExcelStoreBase +store_content() +store_comment() } AbstractStore <|-- CsvStoreImplement AbstractStore <|-- JsonStoreImplement AbstractStore <|-- DbStoreImplement AbstractStore <|-- SqliteStoreImplement AbstractStore <|-- MongoStoreImplement AbstractStore <|-- ExcelStoreImplement StoreFactory --> AbstractStore ``` ### 5.2 存储工厂模式 ```python # 以抖音为例 class DouyinStoreFactory: STORES = { "csv": DouyinCsvStoreImplement, "db": DouyinDbStoreImplement, "json": DouyinJsonStoreImplement, "sqlite": DouyinSqliteStoreImplement, "mongodb": DouyinMongoStoreImplement, "excel": DouyinExcelStoreImplement, } @staticmethod def create_store() -> AbstractStore: store_class = DouyinStoreFactory.STORES.get(config.SAVE_DATA_OPTION) return store_class() ``` ### 5.3 存储方式对比 | 存储方式 | 配置值 | 优点 | 适用场景 | |---------|--------|-----|---------| | CSV | `csv` | 简单、通用 | 小规模数据、快速查看 | | JSON | `json` | 结构完整、易解析 | API对接、数据交换 | | JSONL | `jsonl` | 追加写入、性能好 | 大规模数据、增量爬取(默认) | | SQLite | `sqlite` | 轻量、无需服务 | 本地开发、小型项目 | | MySQL | `db` | 性能好、支持并发 | 生产环境、大规模数据 | | MongoDB | `mongodb` | 灵活、易扩展 | 非结构化数据、快速迭代 | | Excel | `excel` | 可视化、易分享 | 报告、数据分析 | --- ## 6. 基础设施层 ### 6.1 代理系统架构 ```mermaid flowchart TB subgraph Config["配置"] enable["ENABLE_IP_PROXY"] provider["IP_PROXY_PROVIDER"] count["IP_PROXY_POOL_COUNT"] end subgraph Pool["代理池管理"] pool["ProxyIpPool"] load["load_proxies()"] validate["_is_valid_proxy()"] get["get_proxy()"] refresh["get_or_refresh_proxy()"] end subgraph Providers["代理提供商"] kuaidl["快代理
KuaiDaiLiProxy"] wandou["万代理
WanDouHttpProxy"] jishu["技术IP
JiShuHttpProxy"] end subgraph Client["API客户端"] mixin["ProxyRefreshMixin"] request["request()"] end enable --> pool provider --> Providers count --> load pool --> load load --> validate validate --> Providers pool --> get pool --> refresh mixin --> refresh mixin --> Client request --> mixin ``` ### 6.2 登录流程 ```mermaid flowchart TB Start([开始登录]) --> CheckType{登录类型?} CheckType -->|qrcode| QR[显示二维码] QR --> WaitScan[等待扫描] WaitScan --> CheckQR{扫描成功?} CheckQR -->|是| SaveCookie[保存Cookie] CheckQR -->|否| WaitScan CheckType -->|phone| Phone[输入手机号] Phone --> SendCode[发送验证码] SendCode --> Slider{需要滑块?} Slider -->|是| DoSlider[滑动验证] DoSlider --> InputCode[输入验证码] Slider -->|否| InputCode InputCode --> Verify[验证登录] Verify --> SaveCookie CheckType -->|cookie| LoadCookie[加载已保存Cookie] LoadCookie --> VerifyCookie{Cookie有效?} VerifyCookie -->|是| SaveCookie VerifyCookie -->|否| Fail[登录失败] SaveCookie --> UpdateContext[更新浏览器上下文] UpdateContext --> End([登录完成]) ``` ### 6.3 浏览器管理 ```mermaid flowchart LR subgraph Mode["启动模式"] standard["标准模式
Playwright"] cdp["CDP模式
Chrome DevTools"] end subgraph Standard["标准模式流程"] launch["chromium.launch()"] context["new_context()"] stealth["注入stealth.js"] end subgraph CDP["CDP模式流程"] detect["检测浏览器路径"] start["启动浏览器进程"] connect["connect_over_cdp()"] cdpContext["获取已有上下文"] end subgraph Features["特性"] f1["用户数据持久化"] f2["扩展和设置继承"] f3["反检测能力增强"] end standard --> Standard cdp --> CDP CDP --> Features ``` ### 6.4 缓存系统 ```mermaid classDiagram class AbstractCache { <> +get(key)* 获取缓存 +set(key, value, expire)* 设置缓存 +keys(pattern)* 获取所有键 } class ExpiringLocalCache { -_cache: Dict -_expire_times: Dict +get(key) +set(key, value, expire_time) +keys(pattern) -_is_expired(key) } class RedisCache { -_client: Redis +get(key) +set(key, value, expire_time) +keys(pattern) } class CacheFactory { +create_cache(type) AbstractCache } AbstractCache <|-- ExpiringLocalCache AbstractCache <|-- RedisCache CacheFactory --> AbstractCache ``` --- ## 7. 数据模型 ### 7.1 ORM模型关系 ```mermaid erDiagram DouyinAweme { int id PK string aweme_id UK string aweme_type string title string desc int create_time int liked_count int collected_count int comment_count int share_count string user_id FK datetime add_ts datetime last_modify_ts } DouyinAwemeComment { int id PK string comment_id UK string aweme_id FK string content int create_time int sub_comment_count string user_id datetime add_ts datetime last_modify_ts } DyCreator { int id PK string user_id UK string nickname string avatar string desc int follower_count int total_favorited datetime add_ts datetime last_modify_ts } DouyinAweme ||--o{ DouyinAwemeComment : "has" DyCreator ||--o{ DouyinAweme : "creates" ``` ### 7.2 各平台数据表 | 平台 | 内容表 | 评论表 | 创作者表 | |------|--------|--------|---------| | 抖音 | DouyinAweme | DouyinAwemeComment | DyCreator | | 小红书 | XHSNote | XHSNoteComment | XHSCreator | | 快手 | KuaishouVideo | KuaishouVideoComment | KsCreator | | B站 | BilibiliVideo | BilibiliVideoComment | BilibiliUpInfo | | 微博 | WeiboNote | WeiboNoteComment | WeiboCreator | | 贴吧 | TiebaNote | TiebaNoteComment | - | | 知乎 | ZhihuContent | ZhihuContentComment | ZhihuCreator | --- ## 8. 配置系统 ### 8.1 核心配置项 ```python # config/base_config.py # 平台选择 PLATFORM = "xhs" # xhs, dy, ks, bili, wb, tieba, zhihu # 登录配置 LOGIN_TYPE = "qrcode" # qrcode, phone, cookie SAVE_LOGIN_STATE = True # 爬虫配置 CRAWLER_TYPE = "search" # search, detail, creator KEYWORDS = "编程副业,编程兼职" CRAWLER_MAX_NOTES_COUNT = 15 MAX_CONCURRENCY_NUM = 1 # 评论配置 ENABLE_GET_COMMENTS = True ENABLE_GET_SUB_COMMENTS = False CRAWLER_MAX_COMMENTS_COUNT_SINGLENOTES = 10 # 浏览器配置 HEADLESS = False ENABLE_CDP_MODE = True CDP_DEBUG_PORT = 9222 # 代理配置 ENABLE_IP_PROXY = False IP_PROXY_PROVIDER = "kuaidaili" IP_PROXY_POOL_COUNT = 2 # 存储配置 SAVE_DATA_OPTION = "jsonl" # csv, db, json, jsonl, sqlite, mongodb, excel, postgres ``` ### 8.2 数据库配置 ```python # config/db_config.py # MySQL MYSQL_DB_HOST = "localhost" MYSQL_DB_PORT = 3306 MYSQL_DB_NAME = "media_crawler" # Redis REDIS_DB_HOST = "127.0.0.1" REDIS_DB_PORT = 6379 # MongoDB MONGODB_HOST = "localhost" MONGODB_PORT = 27017 # SQLite SQLITE_DB_PATH = "database/sqlite_tables.db" ``` --- ## 9. 工具模块 ### 9.1 工具函数概览 | 模块 | 文件 | 主要功能 | |------|------|---------| | 应用运行器 | `app_runner.py` | 信号处理、优雅退出、清理管理 | | 浏览器启动 | `browser_launcher.py` | 检测浏览器路径、启动浏览器进程 | | CDP管理 | `cdp_browser.py` | CDP连接、浏览器上下文管理 | | 爬虫工具 | `crawler_util.py` | 二维码识别、验证码处理、User-Agent | | 文件写入 | `async_file_writer.py` | 异步CSV/JSON写入、词云生成 | | 滑块验证 | `slider_util.py` | 滑动验证码破解 | | 时间工具 | `time_util.py` | 时间戳转换、日期处理 | ### 9.2 应用运行管理 ```mermaid flowchart TB Start([程序启动]) --> Run["run(app_main, app_cleanup)"] Run --> Main["执行 app_main()"] Main --> Running{运行中} Running -->|正常完成| Cleanup1["执行 app_cleanup()"] Running -->|SIGINT/SIGTERM| Signal["捕获信号"] Signal --> First{第一次信号?} First -->|是| Cleanup2["启动清理流程"] First -->|否| Force["强制退出"] Cleanup1 & Cleanup2 --> Cancel["取消其他任务"] Cancel --> Wait["等待任务完成
(超时15秒)"] Wait --> End([程序退出]) Force --> End ``` --- ## 10. 模块依赖关系 ```mermaid flowchart TB subgraph Entry["入口层"] main["main.py"] config["config/"] cmdarg["cmd_arg/"] end subgraph Core["核心层"] base["base/base_crawler.py"] platforms["media_platform/*/"] end subgraph Client["客户端层"] client["*/client.py"] login["*/login.py"] end subgraph Storage["存储层"] store["store/"] database["database/"] end subgraph Infra["基础设施"] proxy["proxy/"] cache["cache/"] tools["tools/"] end subgraph External["外部依赖"] playwright["Playwright"] httpx["httpx"] sqlalchemy["SQLAlchemy"] motor["Motor/MongoDB"] end main --> config main --> cmdarg main --> Core Core --> base platforms --> base platforms --> Client client --> proxy client --> httpx login --> tools platforms --> Storage Storage --> sqlalchemy Storage --> motor client --> playwright tools --> playwright proxy --> cache ``` --- ## 11. 扩展指南 ### 11.1 添加新平台 1. 在 `media_platform/` 下创建新目录 2. 实现以下核心文件: - `core.py` - 继承 `AbstractCrawler` - `client.py` - 继承 `AbstractApiClient` 和 `ProxyRefreshMixin` - `login.py` - 继承 `AbstractLogin` - `field.py` - 定义平台枚举 3. 在 `store/` 下创建对应存储目录 4. 在 `main.py` 的 `CrawlerFactory.CRAWLERS` 中注册 ### 11.2 添加新存储方式 1. 在 `store/` 下创建新的存储实现类 2. 继承 `AbstractStore` 基类 3. 实现 `store_content`、`store_comment`、`store_creator` 方法 4. 在各平台的 `StoreFactory.STORES` 中注册 ### 11.3 添加新代理提供商 1. 在 `proxy/providers/` 下创建新的代理类 2. 继承 `BaseProxy` 基类 3. 实现 `get_proxy()` 方法 4. 在配置中注册 --- ## 12. 快速参考 ### 12.1 常用命令 ```bash # 启动爬虫 python main.py # 指定平台 python main.py --platform xhs # 指定登录方式 python main.py --lt qrcode # 指定爬虫类型 python main.py --type search ``` ### 12.2 关键文件路径 | 用途 | 文件路径 | |------|---------| | 程序入口 | `main.py` | | 核心配置 | `config/base_config.py` | | 数据库配置 | `config/db_config.py` | | 爬虫基类 | `base/base_crawler.py` | | ORM模型 | `database/models.py` | | 代理池 | `proxy/proxy_ip_pool.py` | | CDP浏览器 | `tools/cdp_browser.py` | --- *文档生成时间: 2025-12-18* ================================================ FILE: libs/douyin.js ================================================ // All the content in this article is only for learning and communication use, not for any other purpose, strictly prohibited for commercial use and illegal use, otherwise all the consequences are irrelevant to the author! // copy from https://github.com/ShilongLee/Crawler/tree/main/lib/js thanks for ShilongLee function rc4_encrypt(plaintext, key) { var s = []; for (var i = 0; i < 256; i++) { s[i] = i; } var j = 0; for (var i = 0; i < 256; i++) { j = (j + s[i] + key.charCodeAt(i % key.length)) % 256; var temp = s[i]; s[i] = s[j]; s[j] = temp; } var i = 0; var j = 0; var cipher = []; for (var k = 0; k < plaintext.length; k++) { i = (i + 1) % 256; j = (j + s[i]) % 256; var temp = s[i]; s[i] = s[j]; s[j] = temp; var t = (s[i] + s[j]) % 256; cipher.push(String.fromCharCode(s[t] ^ plaintext.charCodeAt(k))); } return cipher.join(''); } function le(e, r) { return (e << (r %= 32) | e >>> 32 - r) >>> 0 } function de(e) { return 0 <= e && e < 16 ? 2043430169 : 16 <= e && e < 64 ? 2055708042 : void console['error']("invalid j for constant Tj") } function pe(e, r, t, n) { return 0 <= e && e < 16 ? (r ^ t ^ n) >>> 0 : 16 <= e && e < 64 ? (r & t | r & n | t & n) >>> 0 : (console['error']('invalid j for bool function FF'), 0) } function he(e, r, t, n) { return 0 <= e && e < 16 ? (r ^ t ^ n) >>> 0 : 16 <= e && e < 64 ? (r & t | ~r & n) >>> 0 : (console['error']('invalid j for bool function GG'), 0) } function reset() { this.reg[0] = 1937774191, this.reg[1] = 1226093241, this.reg[2] = 388252375, this.reg[3] = 3666478592, this.reg[4] = 2842636476, this.reg[5] = 372324522, this.reg[6] = 3817729613, this.reg[7] = 2969243214, this["chunk"] = [], this["size"] = 0 } function write(e) { var a = "string" == typeof e ? function (e) { n = encodeURIComponent(e)['replace'](/%([0-9A-F]{2})/g, (function (e, r) { return String['fromCharCode']("0x" + r) } )) , a = new Array(n['length']); return Array['prototype']['forEach']['call'](n, (function (e, r) { a[r] = e.charCodeAt(0) } )), a }(e) : e; this.size += a.length; var f = 64 - this['chunk']['length']; if (a['length'] < f) this['chunk'] = this['chunk'].concat(a); else for (this['chunk'] = this['chunk'].concat(a.slice(0, f)); this['chunk'].length >= 64;) this['_compress'](this['chunk']), f < a['length'] ? this['chunk'] = a['slice'](f, Math['min'](f + 64, a['length'])) : this['chunk'] = [], f += 64 } function sum(e, t) { e && (this['reset'](), this['write'](e)), this['_fill'](); for (var f = 0; f < this.chunk['length']; f += 64) this._compress(this['chunk']['slice'](f, f + 64)); var i = null; if (t == 'hex') { i = ""; for (f = 0; f < 8; f++) i += se(this['reg'][f]['toString'](16), 8, "0") } else for (i = new Array(32), f = 0; f < 8; f++) { var c = this.reg[f]; i[4 * f + 3] = (255 & c) >>> 0, c >>>= 8, i[4 * f + 2] = (255 & c) >>> 0, c >>>= 8, i[4 * f + 1] = (255 & c) >>> 0, c >>>= 8, i[4 * f] = (255 & c) >>> 0 } return this['reset'](), i } function _compress(t) { if (t < 64) console.error("compress error: not enough data"); else { for (var f = function (e) { for (var r = new Array(132), t = 0; t < 16; t++) r[t] = e[4 * t] << 24, r[t] |= e[4 * t + 1] << 16, r[t] |= e[4 * t + 2] << 8, r[t] |= e[4 * t + 3], r[t] >>>= 0; for (var n = 16; n < 68; n++) { var a = r[n - 16] ^ r[n - 9] ^ le(r[n - 3], 15); a = a ^ le(a, 15) ^ le(a, 23), r[n] = (a ^ le(r[n - 13], 7) ^ r[n - 6]) >>> 0 } for (n = 0; n < 64; n++) r[n + 68] = (r[n] ^ r[n + 4]) >>> 0; return r }(t), i = this['reg'].slice(0), c = 0; c < 64; c++) { var o = le(i[0], 12) + i[4] + le(de(c), c) , s = ((o = le(o = (4294967295 & o) >>> 0, 7)) ^ le(i[0], 12)) >>> 0 , u = pe(c, i[0], i[1], i[2]); u = (4294967295 & (u = u + i[3] + s + f[c + 68])) >>> 0; var b = he(c, i[4], i[5], i[6]); b = (4294967295 & (b = b + i[7] + o + f[c])) >>> 0, i[3] = i[2], i[2] = le(i[1], 9), i[1] = i[0], i[0] = u, i[7] = i[6], i[6] = le(i[5], 19), i[5] = i[4], i[4] = (b ^ le(b, 9) ^ le(b, 17)) >>> 0 } for (var l = 0; l < 8; l++) this['reg'][l] = (this['reg'][l] ^ i[l]) >>> 0 } } function _fill() { var a = 8 * this['size'] , f = this['chunk']['push'](128) % 64; for (64 - f < 8 && (f -= 64); f < 56; f++) this.chunk['push'](0); for (var i = 0; i < 4; i++) { var c = Math['floor'](a / 4294967296); this['chunk'].push(c >>> 8 * (3 - i) & 255) } for (i = 0; i < 4; i++) this['chunk']['push'](a >>> 8 * (3 - i) & 255) } function SM3() { this.reg = []; this.chunk = []; this.size = 0; this.reset() } SM3.prototype.reset = reset; SM3.prototype.write = write; SM3.prototype.sum = sum; SM3.prototype._compress = _compress; SM3.prototype._fill = _fill; function result_encrypt(long_str, num = null) { let s_obj = { "s0": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", "s1": "Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=", "s2": "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=", "s3": "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe", "s4": "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe" } let constant = { "0": 16515072, "1": 258048, "2": 4032, "str": s_obj[num], } let result = ""; let lound = 0; let long_int = get_long_int(lound, long_str); for (let i = 0; i < long_str.length / 3 * 4; i++) { if (Math.floor(i / 4) !== lound) { lound += 1; long_int = get_long_int(lound, long_str); } let key = i % 4; switch (key) { case 0: temp_int = (long_int & constant["0"]) >> 18; result += constant["str"].charAt(temp_int); break; case 1: temp_int = (long_int & constant["1"]) >> 12; result += constant["str"].charAt(temp_int); break; case 2: temp_int = (long_int & constant["2"]) >> 6; result += constant["str"].charAt(temp_int); break; case 3: temp_int = long_int & 63; result += constant["str"].charAt(temp_int); break; default: break; } } return result; } function get_long_int(round, long_str) { round = round * 3; return (long_str.charCodeAt(round) << 16) | (long_str.charCodeAt(round + 1) << 8) | (long_str.charCodeAt(round + 2)); } function gener_random(random, option) { return [ (random & 255 & 170) | option[0] & 85, // 163 (random & 255 & 85) | option[0] & 170, //87 (random >> 8 & 255 & 170) | option[1] & 85, //37 (random >> 8 & 255 & 85) | option[1] & 170, //41 ] } ////////////////////////////////////////////// function generate_rc4_bb_str(url_search_params, user_agent, window_env_str, suffix = "cus", Arguments = [0, 1, 14]) { let sm3 = new SM3() let start_time = Date.now() /** * 进行3次加密处理 * 1: url_search_params两次sm3之的结果 * 2: 对后缀两次sm3之的结果 * 3: 对ua处理之后的结果 */ // url_search_params两次sm3之的结果 let url_search_params_list = sm3.sum(sm3.sum(url_search_params + suffix)) // 对后缀两次sm3之的结果 let cus = sm3.sum(sm3.sum(suffix)) // 对ua处理之后的结果 let ua = sm3.sum(result_encrypt(rc4_encrypt(user_agent, String.fromCharCode.apply(null, [0.00390625, 1, Arguments[2]])), "s3")) // let end_time = Date.now() // b let b = { 8: 3, // 固定 10: end_time, //3次加密结束时间 15: { "aid": 6383, "pageId": 6241, "boe": false, "ddrt": 7, "paths": { "include": [ {}, {}, {}, {}, {}, {}, {} ], "exclude": [] }, "track": { "mode": 0, "delay": 300, "paths": [] }, "dump": true, "rpU": "" }, 16: start_time, //3次加密开始时间 18: 44, //固定 19: [1, 0, 1, 5], } //3次加密开始时间 b[20] = (b[16] >> 24) & 255 b[21] = (b[16] >> 16) & 255 b[22] = (b[16] >> 8) & 255 b[23] = b[16] & 255 b[24] = (b[16] / 256 / 256 / 256 / 256) >> 0 b[25] = (b[16] / 256 / 256 / 256 / 256 / 256) >> 0 // 参数Arguments [0, 1, 14, ...] // let Arguments = [0, 1, 14] b[26] = (Arguments[0] >> 24) & 255 b[27] = (Arguments[0] >> 16) & 255 b[28] = (Arguments[0] >> 8) & 255 b[29] = Arguments[0] & 255 b[30] = (Arguments[1] / 256) & 255 b[31] = (Arguments[1] % 256) & 255 b[32] = (Arguments[1] >> 24) & 255 b[33] = (Arguments[1] >> 16) & 255 b[34] = (Arguments[2] >> 24) & 255 b[35] = (Arguments[2] >> 16) & 255 b[36] = (Arguments[2] >> 8) & 255 b[37] = Arguments[2] & 255 // (url_search_params + "cus") 两次sm3之的结果 /**let url_search_params_list = [ 91, 186, 35, 86, 143, 253, 6, 76, 34, 21, 167, 148, 7, 42, 192, 219, 188, 20, 182, 85, 213, 74, 213, 147, 37, 155, 93, 139, 85, 118, 228, 213 ]*/ b[38] = url_search_params_list[21] b[39] = url_search_params_list[22] // ("cus") 对后缀两次sm3之的结果 /** * let cus = [ 136, 101, 114, 147, 58, 77, 207, 201, 215, 162, 154, 93, 248, 13, 142, 160, 105, 73, 215, 241, 83, 58, 51, 43, 255, 38, 168, 141, 216, 194, 35, 236 ]*/ b[40] = cus[21] b[41] = cus[22] // 对ua处理之后的结果 /** * let ua = [ 129, 190, 70, 186, 86, 196, 199, 53, 99, 38, 29, 209, 243, 17, 157, 69, 147, 104, 53, 23, 114, 126, 66, 228, 135, 30, 168, 185, 109, 156, 251, 88 ]*/ b[42] = ua[23] b[43] = ua[24] //3次加密结束时间 b[44] = (b[10] >> 24) & 255 b[45] = (b[10] >> 16) & 255 b[46] = (b[10] >> 8) & 255 b[47] = b[10] & 255 b[48] = b[8] b[49] = (b[10] / 256 / 256 / 256 / 256) >> 0 b[50] = (b[10] / 256 / 256 / 256 / 256 / 256) >> 0 // object配置项 b[51] = b[15]['pageId'] b[52] = (b[15]['pageId'] >> 24) & 255 b[53] = (b[15]['pageId'] >> 16) & 255 b[54] = (b[15]['pageId'] >> 8) & 255 b[55] = b[15]['pageId'] & 255 b[56] = b[15]['aid'] b[57] = b[15]['aid'] & 255 b[58] = (b[15]['aid'] >> 8) & 255 b[59] = (b[15]['aid'] >> 16) & 255 b[60] = (b[15]['aid'] >> 24) & 255 // 中间进行了环境检测 // 代码索引: 2496 索引值: 17 (索引64关键条件) // '1536|747|1536|834|0|30|0|0|1536|834|1536|864|1525|747|24|24|Win32'.charCodeAt()得到65位数组 /** * let window_env_list = [49, 53, 51, 54, 124, 55, 52, 55, 124, 49, 53, 51, 54, 124, 56, 51, 52, 124, 48, 124, 51, * 48, 124, 48, 124, 48, 124, 49, 53, 51, 54, 124, 56, 51, 52, 124, 49, 53, 51, 54, 124, 56, * 54, 52, 124, 49, 53, 50, 53, 124, 55, 52, 55, 124, 50, 52, 124, 50, 52, 124, 87, 105, 110, * 51, 50] */ let window_env_list = []; for (let index = 0; index < window_env_str.length; index++) { window_env_list.push(window_env_str.charCodeAt(index)) } b[64] = window_env_list.length b[65] = b[64] & 255 b[66] = (b[64] >> 8) & 255 b[69] = [].length b[70] = b[69] & 255 b[71] = (b[69] >> 8) & 255 b[72] = b[18] ^ b[20] ^ b[26] ^ b[30] ^ b[38] ^ b[40] ^ b[42] ^ b[21] ^ b[27] ^ b[31] ^ b[35] ^ b[39] ^ b[41] ^ b[43] ^ b[22] ^ b[28] ^ b[32] ^ b[36] ^ b[23] ^ b[29] ^ b[33] ^ b[37] ^ b[44] ^ b[45] ^ b[46] ^ b[47] ^ b[48] ^ b[49] ^ b[50] ^ b[24] ^ b[25] ^ b[52] ^ b[53] ^ b[54] ^ b[55] ^ b[57] ^ b[58] ^ b[59] ^ b[60] ^ b[65] ^ b[66] ^ b[70] ^ b[71] let bb = [ b[18], b[20], b[52], b[26], b[30], b[34], b[58], b[38], b[40], b[53], b[42], b[21], b[27], b[54], b[55], b[31], b[35], b[57], b[39], b[41], b[43], b[22], b[28], b[32], b[60], b[36], b[23], b[29], b[33], b[37], b[44], b[45], b[59], b[46], b[47], b[48], b[49], b[50], b[24], b[25], b[65], b[66], b[70], b[71] ] bb = bb.concat(window_env_list).concat(b[72]) return rc4_encrypt(String.fromCharCode.apply(null, bb), String.fromCharCode.apply(null, [121])); } function generate_random_str() { let random_str_list = [] random_str_list = random_str_list.concat(gener_random(Math.random() * 10000, [3, 45])) random_str_list = random_str_list.concat(gener_random(Math.random() * 10000, [1, 0])) random_str_list = random_str_list.concat(gener_random(Math.random() * 10000, [1, 5])) return String.fromCharCode.apply(null, random_str_list) } function sign(url_search_params, user_agent, arguments) { /** * url_search_params:"device_platform=webapp&aid=6383&channel=channel_pc_web&update_version_code=170400&pc_client_type=1&version_code=170400&version_name=17.4.0&cookie_enabled=true&screen_width=1536&screen_height=864&browser_language=zh-CN&browser_platform=Win32&browser_name=Chrome&browser_version=123.0.0.0&browser_online=true&engine_name=Blink&engine_version=123.0.0.0&os_name=Windows&os_version=10&cpu_core_num=16&device_memory=8&platform=PC&downlink=10&effective_type=4g&round_trip_time=50&webid=7362810250930783783&msToken=VkDUvz1y24CppXSl80iFPr6ez-3FiizcwD7fI1OqBt6IICq9RWG7nCvxKb8IVi55mFd-wnqoNkXGnxHrikQb4PuKob5Q-YhDp5Um215JzlBszkUyiEvR" * user_agent:"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" */ let result_str = generate_random_str() + generate_rc4_bb_str( url_search_params, user_agent, "1536|747|1536|834|0|30|0|0|1536|834|1536|864|1525|747|24|24|Win32", "cus", arguments ); return result_encrypt(result_str, "s4") + "="; } function sign_datail(params, userAgent) { return sign(params, userAgent, [0, 1, 14]) } function sign_reply(params, userAgent) { return sign(params, userAgent, [0, 1, 8]) } ================================================ FILE: libs/zhihu.js ================================================ // copy from https://github.com/tiam-bloom/zhihuQuestionAnswer/blob/main/zhihuvmp.js thanks to tiam-bloom // 仅供学习交流使用,严禁用于商业用途,也不要滥用,否则后果自负 // modified by relakkes const crypto = require('crypto'); // 导入加密模块 let init_str = "6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWBTsuw7QetbKdoPyAl+hN9rgE"; var h = { zk: [1170614578, 1024848638, 1413669199, -343334464, -766094290, -1373058082, -143119608, -297228157, 1933479194, -971186181, -406453910, 460404854, -547427574, -1891326262, -1679095901, 2119585428, -2029270069, 2035090028, -1521520070, -5587175, -77751101, -2094365853, -1243052806, 1579901135, 1321810770, 456816404, -1391643889, -229302305, 330002838, -788960546, 363569021, -1947871109], zb: [20, 223, 245, 7, 248, 2, 194, 209, 87, 6, 227, 253, 240, 128, 222, 91, 237, 9, 125, 157, 230, 93, 252, 205, 90, 79, 144, 199, 159, 197, 186, 167, 39, 37, 156, 198, 38, 42, 43, 168, 217, 153, 15, 103, 80, 189, 71, 191, 97, 84, 247, 95, 36, 69, 14, 35, 12, 171, 28, 114, 178, 148, 86, 182, 32, 83, 158, 109, 22, 255, 94, 238, 151, 85, 77, 124, 254, 18, 4, 26, 123, 176, 232, 193, 131, 172, 143, 142, 150, 30, 10, 146, 162, 62, 224, 218, 196, 229, 1, 192, 213, 27, 110, 56, 231, 180, 138, 107, 242, 187, 54, 120, 19, 44, 117, 228, 215, 203, 53, 239, 251, 127, 81, 11, 133, 96, 204, 132, 41, 115, 73, 55, 249, 147, 102, 48, 122, 145, 106, 118, 74, 190, 29, 16, 174, 5, 177, 129, 63, 113, 99, 31, 161, 76, 246, 34, 211, 13, 60, 68, 207, 160, 65, 111, 82, 165, 67, 169, 225, 57, 112, 244, 155, 51, 236, 200, 233, 58, 61, 47, 100, 137, 185, 64, 17, 70, 234, 163, 219, 108, 170, 166, 59, 149, 52, 105, 24, 212, 78, 173, 45, 0, 116, 226, 119, 136, 206, 135, 175, 195, 25, 92, 121, 208, 126, 139, 3, 75, 141, 21, 130, 98, 241, 40, 154, 66, 184, 49, 181, 46, 243, 88, 101, 183, 8, 23, 72, 188, 104, 179, 210, 134, 250, 201, 164, 89, 216, 202, 220, 50, 221, 152, 140, 33, 235, 214] }; function i(e, t, n) { t[n] = 255 & e >>> 24, t[n + 1] = 255 & e >>> 16, t[n + 2] = 255 & e >>> 8, t[n + 3] = 255 & e } function Q(e, t) { return (4294967295 & e) << t | e >>> 32 - t } function B(e, t) { return (255 & e[t]) << 24 | (255 & e[t + 1]) << 16 | (255 & e[t + 2]) << 8 | 255 & e[t + 3] } function G(e) { var t = new Array(4) , n = new Array(4); i(e, t, 0), n[0] = h.zb[255 & t[0]], n[1] = h.zb[255 & t[1]], n[2] = h.zb[255 & t[2]], n[3] = h.zb[255 & t[3]]; var r = B(n, 0); return r ^ Q(r, 2) ^ Q(r, 10) ^ Q(r, 18) ^ Q(r, 24) } function array_0_16_offset(e) { var t = new Array(16) , n = new Array(36); n[0] = B(e, 0), n[1] = B(e, 4), n[2] = B(e, 8), n[3] = B(e, 12); for (var r = 0; r < 32; r++) { var o = G(n[r + 1] ^ n[r + 2] ^ n[r + 3] ^ h.zk[r]); n[r + 4] = n[r] ^ o } return i(n[35], t, 0), i(n[34], t, 4), i(n[33], t, 8), i(n[32], t, 12), t } function array_16_48_offset(e, t) { for (var n = [], r = e.length, i = 0; 0 < r; r -= 16) { for (var o = e.slice(16 * i, 16 * (i + 1)), a = new Array(16), c = 0; c < 16; c++) a[c] = o[c] ^ t[c]; t = array_0_16_offset(a), n = n.concat(t), i++ } return n } function encode_0_16(array_0_16) { let result = []; let array_offset = [48, 53, 57, 48, 53, 51, 102, 55, 100, 49, 53, 101, 48, 49, 100, 55]; for (let i = 0; i < array_0_16.length; i++) { let a = array_0_16[i] ^ array_offset[i], b = a ^ 42; result.push(b) } return array_0_16_offset(result) } function encode(ar) { let b = ar[1] << 8, c = ar[0] | b, d = ar[2] << 16, e = c | d, result_array = [], x6 = 6; result_array.push(e & 63); while (result_array.length < 4) { let a = e >>> x6; result_array.push(a & 63); x6 += 6; } return result_array } function get_init_array(encode_md5) { let init_array = [] for (let i = 0; i < encode_md5.length; i++) { init_array.push(encode_md5.charCodeAt(i)) } init_array.unshift(0) init_array.unshift(Math.floor(Math.random() * 127)) while (init_array.length < 48) { init_array.push(14) } let array_0_16 = encode_0_16(init_array.slice(0, 16)), array_16_48 = array_16_48_offset(init_array.slice(16, 48), array_0_16), array_result = array_0_16.concat(array_16_48); return array_result } function get_zse_96(encode_md5) { let result_array = [], init_array = get_init_array(encode_md5), result = ""; for (let i = 47; i >= 0; i -= 4) { init_array[i] ^= 58 } init_array.reverse() for (let j = 3; j <= init_array.length; j += 3) { let ar = init_array.slice(j - 3, j); result_array = result_array.concat(encode(ar)) } for (let index = 0; index < result_array.length; index++) { result += init_str.charAt(result_array[index]) } result = '2.0_' + result return result } /***********************relakkes modify*******************************************************/ /** * 从cookies中提取dc0的值 * @param cookies * @returns {string} */ const extract_dc0_value_from_cookies = function (cookies) { const t9 = RegExp("d_c0=([^;]+)") const tt = t9.exec(cookies); const dc0 = tt && tt[1] return tt && tt[1] } /** * 获取zhihu sign value 对python暴漏的接口 * @param url 请求的路由参数 * @param cookies 请求的cookies,需要包含dc0这个key * @returns {*} */ function get_sign(url, cookies) { const ta = "101_3_3.0" const dc0 = extract_dc0_value_from_cookies(cookies) const tc = "3_2.0aR_sn77yn6O92wOB8hPZnQr0EMYxc4f18wNBUgpTQ6nxERFZfTY0-4Lm-h3_tufIwJS8gcxTgJS_AuPZNcXCTwxI78YxEM20s4PGDwN8gGcYAupMWufIoLVqr4gxrRPOI0cY7HL8qun9g93mFukyigcmebS_FwOYPRP0E4rZUrN9DDom3hnynAUMnAVPF_PhaueTFH9fQL39OCCqYTxfb0rfi9wfPhSM6vxGDJo_rBHpQGNmBBLqPJHK2_w8C9eTVMO9Z9NOrMtfhGH_DgpM-BNM1DOxScLG3gg1Hre1FCXKQcXKkrSL1r9GWDXMk8wqBLNmbRH96BtOFqVZ7UYG3gC8D9cMS7Y9UrHLVCLZPJO8_CL_6GNCOg_zhJS8PbXmGTcBpgxfkieOPhNfthtf2gC_qD3YOce8nCwG2uwBOqeMoML9NBC1xb9yk6SuJhHLK7SM6LVfCve_3vLKlqcL6TxL_UosDvHLxrHmWgxBQ8Xs" const params_join_str = [ta, url, dc0, tc].join("+") const params_md5_value = crypto.createHash('md5').update(params_join_str).digest('hex') return { "x-zst-81": tc, "x-zse-96": get_zse_96(params_md5_value), } } ================================================ FILE: main.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/main.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import sys import io # Force UTF-8 encoding for stdout/stderr to prevent encoding errors # when outputting Chinese characters in non-UTF-8 terminals if sys.stdout and hasattr(sys.stdout, 'buffer'): if sys.stdout.encoding and sys.stdout.encoding.lower() != 'utf-8': sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') if sys.stderr and hasattr(sys.stderr, 'buffer'): if sys.stderr.encoding and sys.stderr.encoding.lower() != 'utf-8': sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') import asyncio from typing import Optional, Type import cmd_arg import config from database import db from base.base_crawler import AbstractCrawler from media_platform.bilibili import BilibiliCrawler from media_platform.douyin import DouYinCrawler from media_platform.kuaishou import KuaishouCrawler from media_platform.tieba import TieBaCrawler from media_platform.weibo import WeiboCrawler from media_platform.xhs import XiaoHongShuCrawler from media_platform.zhihu import ZhihuCrawler from tools.async_file_writer import AsyncFileWriter from var import crawler_type_var class CrawlerFactory: CRAWLERS: dict[str, Type[AbstractCrawler]] = { "xhs": XiaoHongShuCrawler, "dy": DouYinCrawler, "ks": KuaishouCrawler, "bili": BilibiliCrawler, "wb": WeiboCrawler, "tieba": TieBaCrawler, "zhihu": ZhihuCrawler, } @staticmethod def create_crawler(platform: str) -> AbstractCrawler: crawler_class = CrawlerFactory.CRAWLERS.get(platform) if not crawler_class: supported = ", ".join(sorted(CrawlerFactory.CRAWLERS)) raise ValueError(f"Invalid media platform: {platform!r}. Supported: {supported}") return crawler_class() crawler: Optional[AbstractCrawler] = None def _flush_excel_if_needed() -> None: if config.SAVE_DATA_OPTION != "excel": return try: from store.excel_store_base import ExcelStoreBase ExcelStoreBase.flush_all() print("[Main] Excel files saved successfully") except Exception as e: print(f"[Main] Error flushing Excel data: {e}") async def _generate_wordcloud_if_needed() -> None: if config.SAVE_DATA_OPTION not in ("json", "jsonl") or not config.ENABLE_GET_WORDCLOUD: return try: file_writer = AsyncFileWriter( platform=config.PLATFORM, crawler_type=crawler_type_var.get(), ) await file_writer.generate_wordcloud_from_comments() except Exception as e: print(f"[Main] Error generating wordcloud: {e}") async def main() -> None: global crawler args = await cmd_arg.parse_cmd() if args.init_db: await db.init_db(args.init_db) print(f"Database {args.init_db} initialized successfully.") return crawler = CrawlerFactory.create_crawler(platform=config.PLATFORM) await crawler.start() _flush_excel_if_needed() # Generate wordcloud after crawling is complete # Only for JSON save mode await _generate_wordcloud_if_needed() async def async_cleanup() -> None: global crawler if crawler: if getattr(crawler, "cdp_manager", None): try: await crawler.cdp_manager.cleanup(force=True) except Exception as e: error_msg = str(e).lower() if "closed" not in error_msg and "disconnected" not in error_msg: print(f"[Main] Error cleaning up CDP browser: {e}") elif getattr(crawler, "browser_context", None): try: await crawler.browser_context.close() except Exception as e: error_msg = str(e).lower() if "closed" not in error_msg and "disconnected" not in error_msg: print(f"[Main] Error closing browser context: {e}") if config.SAVE_DATA_OPTION in ("db", "sqlite"): await db.close() if __name__ == "__main__": from tools.app_runner import run def _force_stop() -> None: c = crawler if not c: return cdp_manager = getattr(c, "cdp_manager", None) launcher = getattr(cdp_manager, "launcher", None) if not launcher: return try: launcher.cleanup() except Exception: pass run(main, async_cleanup, cleanup_timeout_seconds=15.0, on_first_interrupt=_force_stop) ================================================ FILE: media_platform/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 ================================================ FILE: media_platform/bilibili/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/bilibili/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/2 18:36 # @Desc : from .core import * ================================================ FILE: media_platform/bilibili/client.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/bilibili/client.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/2 18:44 # @Desc : bilibili request client import asyncio import json import random from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union from urllib.parse import urlencode import httpx from playwright.async_api import BrowserContext, Page from tools.httpx_util import make_async_client import config from base.base_crawler import AbstractApiClient from proxy.proxy_mixin import ProxyRefreshMixin from tools import utils if TYPE_CHECKING: from proxy.proxy_ip_pool import ProxyIpPool from .exception import DataFetchError from .field import CommentOrderType, SearchOrderType from .help import BilibiliSign class BilibiliClient(AbstractApiClient, ProxyRefreshMixin): def __init__( self, timeout=60, # For media crawling, Bilibili long videos need a longer timeout proxy=None, *, headers: Dict[str, str], playwright_page: Page, cookie_dict: Dict[str, str], proxy_ip_pool: Optional["ProxyIpPool"] = None, ): self.proxy = proxy self.timeout = timeout self.headers = headers self._host = "https://api.bilibili.com" self.playwright_page = playwright_page self.cookie_dict = cookie_dict # Initialize proxy pool (from ProxyRefreshMixin) self.init_proxy_pool(proxy_ip_pool) async def request(self, method, url, **kwargs) -> Any: # Check if proxy has expired before each request await self._refresh_proxy_if_expired() async with make_async_client(proxy=self.proxy) as client: response = await client.request(method, url, timeout=self.timeout, **kwargs) try: data: Dict = response.json() except json.JSONDecodeError: utils.logger.error(f"[BilibiliClient.request] Failed to decode JSON from response. status_code: {response.status_code}, response_text: {response.text}") raise DataFetchError(f"Failed to decode JSON, content: {response.text}") if data.get("code") != 0: raise DataFetchError(data.get("message", "unkonw error")) else: return data.get("data", {}) async def pre_request_data(self, req_data: Dict) -> Dict: """ Send request to sign request parameters Need to get wbi_img_urls parameter from localStorage, value as follows: https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png-https://i0.hdslb.com/bfs/wbi/4932caff0ff746eab6f01bf08b70ac45.png :param req_data: :return: """ if not req_data: return {} img_key, sub_key = await self.get_wbi_keys() return BilibiliSign(img_key, sub_key).sign(req_data) async def get_wbi_keys(self) -> Tuple[str, str]: """ Get the latest img_key and sub_key :return: """ local_storage = await self.playwright_page.evaluate("() => window.localStorage") wbi_img_urls = local_storage.get("wbi_img_urls", "") if not wbi_img_urls: img_url_from_storage = local_storage.get("wbi_img_url") sub_url_from_storage = local_storage.get("wbi_sub_url") if img_url_from_storage and sub_url_from_storage: wbi_img_urls = f"{img_url_from_storage}-{sub_url_from_storage}" if wbi_img_urls and "-" in wbi_img_urls: img_url, sub_url = wbi_img_urls.split("-") else: resp = await self.request(method="GET", url=self._host + "/x/web-interface/nav") img_url: str = resp['wbi_img']['img_url'] sub_url: str = resp['wbi_img']['sub_url'] img_key = img_url.rsplit('/', 1)[1].split('.')[0] sub_key = sub_url.rsplit('/', 1)[1].split('.')[0] return img_key, sub_key async def get(self, uri: str, params=None, enable_params_sign: bool = True) -> Dict: final_uri = uri if enable_params_sign: params = await self.pre_request_data(params) if isinstance(params, dict): final_uri = (f"{uri}?" f"{urlencode(params)}") return await self.request(method="GET", url=f"{self._host}{final_uri}", headers=self.headers) async def post(self, uri: str, data: dict) -> Dict: data = await self.pre_request_data(data) json_str = json.dumps(data, separators=(',', ':'), ensure_ascii=False) return await self.request(method="POST", url=f"{self._host}{uri}", data=json_str, headers=self.headers) async def pong(self) -> bool: """get a note to check if login state is ok""" utils.logger.info("[BilibiliClient.pong] Begin pong bilibili...") ping_flag = False try: check_login_uri = "/x/web-interface/nav" response = await self.get(check_login_uri) if response.get("isLogin"): utils.logger.info("[BilibiliClient.pong] Use cache login state get web interface successfull!") ping_flag = True except Exception as e: utils.logger.error(f"[BilibiliClient.pong] Pong bilibili failed: {e}, and try to login again...") ping_flag = False return ping_flag async def update_cookies(self, browser_context: BrowserContext): cookie_str, cookie_dict = utils.convert_cookies(await browser_context.cookies()) self.headers["Cookie"] = cookie_str self.cookie_dict = cookie_dict async def search_video_by_keyword( self, keyword: str, page: int = 1, page_size: int = 20, order: SearchOrderType = SearchOrderType.DEFAULT, pubtime_begin_s: int = 0, pubtime_end_s: int = 0, ) -> Dict: """ KuaiShou web search api :param keyword: Search keyword :param page: Page number for pagination :param page_size: Number of items per page :param order: Sort order for search results, default is comprehensive sorting :param pubtime_begin_s: Publish time start timestamp :param pubtime_end_s: Publish time end timestamp :return: """ uri = "/x/web-interface/wbi/search/type" post_data = { "search_type": "video", "keyword": keyword, "page": page, "page_size": page_size, "order": order.value, "pubtime_begin_s": pubtime_begin_s, "pubtime_end_s": pubtime_end_s } return await self.get(uri, post_data) async def get_video_info(self, aid: Union[int, None] = None, bvid: Union[str, None] = None) -> Dict: """ Bilibli web video detail api, choose one parameter between aid and bvid :param aid: Video aid :param bvid: Video bvid :return: """ if not aid and not bvid: raise ValueError("Please provide at least one parameter: aid or bvid") uri = "/x/web-interface/view/detail" params = dict() if aid: params.update({"aid": aid}) else: params.update({"bvid": bvid}) return await self.get(uri, params, enable_params_sign=False) async def get_video_play_url(self, aid: int, cid: int) -> Dict: """ Bilibli web video play url api :param aid: Video aid :param cid: cid :return: """ if not aid or not cid or aid <= 0 or cid <= 0: raise ValueError("aid and cid must exist") uri = "/x/player/wbi/playurl" qn_value = getattr(config, "BILI_QN", 80) params = { "avid": aid, "cid": cid, "qn": qn_value, "fourk": 1, "fnval": 1, "platform": "pc", } return await self.get(uri, params, enable_params_sign=True) async def get_video_media(self, url: str) -> Union[bytes, None]: # Follow CDN 302 redirects and treat any 2xx as success (some endpoints return 206) async with make_async_client(proxy=self.proxy, follow_redirects=True) as client: try: response = await client.request("GET", url, timeout=self.timeout, headers=self.headers) response.raise_for_status() if 200 <= response.status_code < 300: return response.content utils.logger.error( f"[BilibiliClient.get_video_media] Unexpected status {response.status_code} for {url}" ) return None except httpx.HTTPError as exc: # some wrong when call httpx.request method, such as connection error, client error, server error or response status code is not 2xx utils.logger.error(f"[BilibiliClient.get_video_media] {exc.__class__.__name__} for {exc.request.url} - {exc}") # Keep original exception type name for developer debugging return None async def get_video_comments( self, video_id: str, order_mode: CommentOrderType = CommentOrderType.DEFAULT, next: int = 0, ) -> Dict: """get video comments :param video_id: Video ID :param order_mode: Sort order :param next: Comment page selection :return: """ uri = "/x/v2/reply/wbi/main" post_data = {"oid": video_id, "mode": order_mode.value, "type": 1, "ps": 20, "next": next} return await self.get(uri, post_data) async def get_video_all_comments( self, video_id: str, crawl_interval: float = 1.0, is_fetch_sub_comments=False, callback: Optional[Callable] = None, max_count: int = 10, ): """ get video all comments include sub comments :param video_id: :param crawl_interval: :param is_fetch_sub_comments: :param callback: max_count: Maximum number of comments to crawl per note :return: """ result = [] is_end = False next_page = 0 max_retries = 3 while not is_end and len(result) < max_count: comments_res = None for attempt in range(max_retries): try: comments_res = await self.get_video_comments(video_id, CommentOrderType.DEFAULT, next_page) break # Success except DataFetchError as e: if attempt < max_retries - 1: delay = 5 * (2**attempt) + random.uniform(0, 1) utils.logger.warning(f"[BilibiliClient.get_video_all_comments] Retrying video_id {video_id} in {delay:.2f}s... (Attempt {attempt + 1}/{max_retries})") await asyncio.sleep(delay) else: utils.logger.error(f"[BilibiliClient.get_video_all_comments] Max retries reached for video_id: {video_id}. Skipping comments. Error: {e}") is_end = True break if not comments_res: break cursor_info: Dict = comments_res.get("cursor") if not cursor_info: utils.logger.warning(f"[BilibiliClient.get_video_all_comments] Could not find 'cursor' in response for video_id: {video_id}. Skipping.") break comment_list: List[Dict] = comments_res.get("replies", []) # Check if is_end and next exist if "is_end" not in cursor_info or "next" not in cursor_info: utils.logger.warning(f"[BilibiliClient.get_video_all_comments] 'is_end' or 'next' not in cursor for video_id: {video_id}. Assuming end of comments.") is_end = True else: is_end = cursor_info.get("is_end") next_page = cursor_info.get("next") if not isinstance(is_end, bool): utils.logger.warning(f"[BilibiliClient.get_video_all_comments] 'is_end' is not a boolean for video_id: {video_id}. Assuming end of comments.") is_end = True if is_fetch_sub_comments: for comment in comment_list: comment_id = comment['rpid'] if (comment.get("rcount", 0) > 0): {await self.get_video_all_level_two_comments(video_id, comment_id, CommentOrderType.DEFAULT, 10, crawl_interval, callback)} if len(result) + len(comment_list) > max_count: comment_list = comment_list[:max_count - len(result)] if callback: # If there is a callback function, execute it await callback(video_id, comment_list) await asyncio.sleep(crawl_interval) if not is_fetch_sub_comments: result.extend(comment_list) continue return result async def get_video_all_level_two_comments( self, video_id: str, level_one_comment_id: int, order_mode: CommentOrderType, ps: int = 10, crawl_interval: float = 1.0, callback: Optional[Callable] = None, ) -> Dict: """ get video all level two comments for a level one comment :param video_id: Video ID :param level_one_comment_id: Level one comment ID :param order_mode: :param ps: Number of comments per page :param crawl_interval: :param callback: :return: """ pn = 1 while True: result = await self.get_video_level_two_comments(video_id, level_one_comment_id, pn, ps, order_mode) comment_list: List[Dict] = result.get("replies", []) if callback: # If there is a callback function, execute it await callback(video_id, comment_list) await asyncio.sleep(crawl_interval) if (int(result["page"]["count"]) <= pn * ps): break pn += 1 async def get_video_level_two_comments( self, video_id: str, level_one_comment_id: int, pn: int, ps: int, order_mode: CommentOrderType, ) -> Dict: """get video level two comments :param video_id: Video ID :param level_one_comment_id: Level one comment ID :param order_mode: Sort order :return: """ uri = "/x/v2/reply/reply" post_data = { "oid": video_id, "mode": order_mode.value, "type": 1, "ps": ps, "pn": pn, "root": level_one_comment_id, } result = await self.get(uri, post_data) return result async def get_creator_videos(self, creator_id: str, pn: int, ps: int = 30, order_mode: SearchOrderType = SearchOrderType.LAST_PUBLISH) -> Dict: """get all videos for a creator :param creator_id: Creator ID :param pn: Page number :param ps: Number of videos per page :param order_mode: Sort order :return: """ uri = "/x/space/wbi/arc/search" post_data = { "mid": creator_id, "pn": pn, "ps": ps, "order": order_mode, } return await self.get(uri, post_data) async def get_creator_info(self, creator_id: int) -> Dict: """ get creator info :param creator_id: Creator ID """ uri = "/x/space/wbi/acc/info" post_data = { "mid": creator_id, } return await self.get(uri, post_data) async def get_creator_fans( self, creator_id: int, pn: int, ps: int = 24, ) -> Dict: """ get creator fans :param creator_id: Creator ID :param pn: Start page number :param ps: Number of items per page :return: """ uri = "/x/relation/fans" post_data = { 'vmid': creator_id, "pn": pn, "ps": ps, "gaia_source": "main_web", } return await self.get(uri, post_data) async def get_creator_followings( self, creator_id: int, pn: int, ps: int = 24, ) -> Dict: """ get creator followings :param creator_id: Creator ID :param pn: Start page number :param ps: Number of items per page :return: """ uri = "/x/relation/followings" post_data = { "vmid": creator_id, "pn": pn, "ps": ps, "gaia_source": "main_web", } return await self.get(uri, post_data) async def get_creator_dynamics(self, creator_id: int, offset: str = ""): """ get creator comments :param creator_id: Creator ID :param offset: Parameter required for sending request :return: """ uri = "/x/polymer/web-dynamic/v1/feed/space" post_data = { "offset": offset, "host_mid": creator_id, "platform": "web", } return await self.get(uri, post_data) async def get_creator_all_fans( self, creator_info: Dict, crawl_interval: float = 1.0, callback: Optional[Callable] = None, max_count: int = 100, ) -> List: """ get creator all fans :param creator_info: :param crawl_interval: :param callback: :param max_count: Maximum number of fans to crawl for a creator :return: List of creator fans """ creator_id = creator_info["id"] result = [] pn = config.START_CONTACTS_PAGE while len(result) < max_count: fans_res: Dict = await self.get_creator_fans(creator_id, pn=pn) fans_list: List[Dict] = fans_res.get("list", []) pn += 1 if len(result) + len(fans_list) > max_count: fans_list = fans_list[:max_count - len(result)] if callback: # If there is a callback function, execute it await callback(creator_info, fans_list) await asyncio.sleep(crawl_interval) if not fans_list: break result.extend(fans_list) return result async def get_creator_all_followings( self, creator_info: Dict, crawl_interval: float = 1.0, callback: Optional[Callable] = None, max_count: int = 100, ) -> List: """ get creator all followings :param creator_info: :param crawl_interval: :param callback: :param max_count: Maximum number of followings to crawl for a creator :return: List of creator followings """ creator_id = creator_info["id"] result = [] pn = config.START_CONTACTS_PAGE while len(result) < max_count: followings_res: Dict = await self.get_creator_followings(creator_id, pn=pn) followings_list: List[Dict] = followings_res.get("list", []) pn += 1 if len(result) + len(followings_list) > max_count: followings_list = followings_list[:max_count - len(result)] if callback: # If there is a callback function, execute it await callback(creator_info, followings_list) await asyncio.sleep(crawl_interval) if not followings_list: break result.extend(followings_list) return result async def get_creator_all_dynamics( self, creator_info: Dict, crawl_interval: float = 1.0, callback: Optional[Callable] = None, max_count: int = 20, ) -> List: """ get creator all followings :param creator_info: :param crawl_interval: :param callback: :param max_count: Maximum number of dynamics to crawl for a creator :return: List of creator dynamics """ creator_id = creator_info["id"] result = [] offset = "" has_more = True while has_more and len(result) < max_count: dynamics_res = await self.get_creator_dynamics(creator_id, offset) dynamics_list: List[Dict] = dynamics_res["items"] has_more = dynamics_res["has_more"] offset = dynamics_res["offset"] if len(result) + len(dynamics_list) > max_count: dynamics_list = dynamics_list[:max_count - len(result)] if callback: await callback(creator_info, dynamics_list) await asyncio.sleep(crawl_interval) result.extend(dynamics_list) return result ================================================ FILE: media_platform/bilibili/core.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/bilibili/core.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/2 18:44 # @Desc : Bilibili Crawler import asyncio import os # import random # Removed as we now use fixed config.CRAWLER_MAX_SLEEP_SEC intervals from asyncio import Task from typing import Dict, List, Optional, Tuple, Union from datetime import datetime, timedelta import pandas as pd from playwright.async_api import ( BrowserContext, BrowserType, Page, Playwright, async_playwright, ) from playwright._impl._errors import TargetClosedError import config from base.base_crawler import AbstractCrawler from proxy.proxy_ip_pool import IpInfoModel, create_ip_pool from store import bilibili as bilibili_store from tools import utils from tools.cdp_browser import CDPBrowserManager from var import crawler_type_var, source_keyword_var from .client import BilibiliClient from .exception import DataFetchError from .field import SearchOrderType from .help import parse_video_info_from_url, parse_creator_info_from_url from .login import BilibiliLogin class BilibiliCrawler(AbstractCrawler): context_page: Page bili_client: BilibiliClient browser_context: BrowserContext cdp_manager: Optional[CDPBrowserManager] def __init__(self): self.index_url = "https://www.bilibili.com" self.user_agent = utils.get_user_agent() self.cdp_manager = None self.ip_proxy_pool = None # Proxy IP pool for automatic proxy refresh async def start(self): playwright_proxy_format, httpx_proxy_format = None, None if config.ENABLE_IP_PROXY: self.ip_proxy_pool = await create_ip_pool(config.IP_PROXY_POOL_COUNT, enable_validate_ip=True) ip_proxy_info: IpInfoModel = await self.ip_proxy_pool.get_proxy() playwright_proxy_format, httpx_proxy_format = utils.format_proxy_info(ip_proxy_info) async with async_playwright() as playwright: # Choose launch mode based on configuration if config.ENABLE_CDP_MODE: utils.logger.info("[BilibiliCrawler] Launching browser using CDP mode") self.browser_context = await self.launch_browser_with_cdp( playwright, playwright_proxy_format, self.user_agent, headless=config.CDP_HEADLESS, ) else: utils.logger.info("[BilibiliCrawler] Launching browser using standard mode") # Launch a browser context. chromium = playwright.chromium self.browser_context = await self.launch_browser(chromium, None, self.user_agent, headless=config.HEADLESS) # stealth.min.js is a js script to prevent the website from detecting the crawler. await self.browser_context.add_init_script(path="libs/stealth.min.js") self.context_page = await self.browser_context.new_page() await self.context_page.goto(self.index_url) # Create a client to interact with the xiaohongshu website. self.bili_client = await self.create_bilibili_client(httpx_proxy_format) if not await self.bili_client.pong(): login_obj = BilibiliLogin( login_type=config.LOGIN_TYPE, login_phone="", # your phone number browser_context=self.browser_context, context_page=self.context_page, cookie_str=config.COOKIES, ) await login_obj.begin() await self.bili_client.update_cookies(browser_context=self.browser_context) crawler_type_var.set(config.CRAWLER_TYPE) if config.CRAWLER_TYPE == "search": await self.search() elif config.CRAWLER_TYPE == "detail": # Get the information and comments of the specified post await self.get_specified_videos(config.BILI_SPECIFIED_ID_LIST) elif config.CRAWLER_TYPE == "creator": if config.CREATOR_MODE: for creator_url in config.BILI_CREATOR_ID_LIST: try: creator_info = parse_creator_info_from_url(creator_url) utils.logger.info(f"[BilibiliCrawler.start] Parsed creator ID: {creator_info.creator_id} from {creator_url}") await self.get_creator_videos(int(creator_info.creator_id)) except ValueError as e: utils.logger.error(f"[BilibiliCrawler.start] Failed to parse creator URL: {e}") continue else: await self.get_all_creator_details(config.BILI_CREATOR_ID_LIST) else: pass utils.logger.info("[BilibiliCrawler.start] Bilibili Crawler finished ...") async def search(self): """ search bilibili video """ # Search for video and retrieve their comment information. if config.BILI_SEARCH_MODE == "normal": await self.search_by_keywords() elif config.BILI_SEARCH_MODE == "all_in_time_range": await self.search_by_keywords_in_time_range(daily_limit=False) elif config.BILI_SEARCH_MODE == "daily_limit_in_time_range": await self.search_by_keywords_in_time_range(daily_limit=True) else: utils.logger.warning(f"Unknown BILI_SEARCH_MODE: {config.BILI_SEARCH_MODE}") @staticmethod async def get_pubtime_datetime( start: str = config.START_DAY, end: str = config.END_DAY, ) -> Tuple[str, str]: """ Get bilibili publish start timestamp pubtime_begin_s and publish end timestamp pubtime_end_s --- :param start: Publish date start time, YYYY-MM-DD :param end: Publish date end time, YYYY-MM-DD Note --- - Search time range is from start to end, including both start and end - To search content from the same day, to include search content from that day, pubtime_end_s should be pubtime_begin_s plus one day minus one second, i.e., the last second of start day - For example, searching only 2024-01-05 content, pubtime_begin_s = 1704384000, pubtime_end_s = 1704470399 Converted to readable datetime objects: pubtime_begin_s = datetime.datetime(2024, 1, 5, 0, 0), pubtime_end_s = datetime.datetime(2024, 1, 5, 23, 59, 59) - To search content from start to end, to include search content from end day, pubtime_end_s should be pubtime_end_s plus one day minus one second, i.e., the last second of end day - For example, searching 2024-01-05 - 2024-01-06 content, pubtime_begin_s = 1704384000, pubtime_end_s = 1704556799 Converted to readable datetime objects: pubtime_begin_s = datetime.datetime(2024, 1, 5, 0, 0), pubtime_end_s = datetime.datetime(2024, 1, 6, 23, 59, 59) """ # Convert start and end to datetime objects start_day: datetime = datetime.strptime(start, "%Y-%m-%d") end_day: datetime = datetime.strptime(end, "%Y-%m-%d") if start_day > end_day: raise ValueError("Wrong time range, please check your start and end argument, to ensure that the start cannot exceed end") elif start_day == end_day: # Searching content from the same day end_day = (start_day + timedelta(days=1) - timedelta(seconds=1)) # Set end_day to start_day + 1 day - 1 second else: # Searching from start to end end_day = (end_day + timedelta(days=1) - timedelta(seconds=1)) # Set end_day to end_day + 1 day - 1 second # Convert back to timestamps return str(int(start_day.timestamp())), str(int(end_day.timestamp())) async def search_by_keywords(self): """ search bilibili video with keywords in normal mode :return: """ utils.logger.info("[BilibiliCrawler.search_by_keywords] Begin search bilibli keywords") bili_limit_count = 20 # bilibili limit page fixed value if config.CRAWLER_MAX_NOTES_COUNT < bili_limit_count: config.CRAWLER_MAX_NOTES_COUNT = bili_limit_count start_page = config.START_PAGE # start page number for keyword in config.KEYWORDS.split(","): source_keyword_var.set(keyword) utils.logger.info(f"[BilibiliCrawler.search_by_keywords] Current search keyword: {keyword}") page = 1 while (page - start_page + 1) * bili_limit_count <= config.CRAWLER_MAX_NOTES_COUNT: if page < start_page: utils.logger.info(f"[BilibiliCrawler.search_by_keywords] Skip page: {page}") page += 1 continue utils.logger.info(f"[BilibiliCrawler.search_by_keywords] search bilibili keyword: {keyword}, page: {page}") video_id_list: List[str] = [] videos_res = await self.bili_client.search_video_by_keyword( keyword=keyword, page=page, page_size=bili_limit_count, order=SearchOrderType.DEFAULT, pubtime_begin_s=0, # Publish date start timestamp pubtime_end_s=0, # Publish date end timestamp ) video_list: List[Dict] = videos_res.get("result") if not video_list: utils.logger.info(f"[BilibiliCrawler.search_by_keywords] No more videos for '{keyword}', moving to next keyword.") break semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list = [] try: task_list = [self.get_video_info_task(aid=video_item.get("aid"), bvid="", semaphore=semaphore) for video_item in video_list] except Exception as e: utils.logger.warning(f"[BilibiliCrawler.search_by_keywords] error in the task list. The video for this page will not be included. {e}") video_items = await asyncio.gather(*task_list) for video_item in video_items: if video_item: video_id_list.append(video_item.get("View").get("aid")) await bilibili_store.update_bilibili_video(video_item) await bilibili_store.update_up_info(video_item) await self.get_bilibili_video(video_item, semaphore) page += 1 # Sleep after page navigation await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[BilibiliCrawler.search_by_keywords] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after page {page-1}") await self.batch_get_video_comments(video_id_list) async def search_by_keywords_in_time_range(self, daily_limit: bool): """ Search bilibili video with keywords in a given time range. :param daily_limit: if True, strictly limit the number of notes per day and total. """ utils.logger.info(f"[BilibiliCrawler.search_by_keywords_in_time_range] Begin search with daily_limit={daily_limit}") bili_limit_count = 20 start_page = config.START_PAGE for keyword in config.KEYWORDS.split(","): source_keyword_var.set(keyword) utils.logger.info(f"[BilibiliCrawler.search_by_keywords_in_time_range] Current search keyword: {keyword}") total_notes_crawled_for_keyword = 0 for day in pd.date_range(start=config.START_DAY, end=config.END_DAY, freq="D"): if (daily_limit and total_notes_crawled_for_keyword >= config.CRAWLER_MAX_NOTES_COUNT): utils.logger.info(f"[BilibiliCrawler.search] Reached CRAWLER_MAX_NOTES_COUNT limit for keyword '{keyword}', skipping remaining days.") break if (not daily_limit and total_notes_crawled_for_keyword >= config.CRAWLER_MAX_NOTES_COUNT): utils.logger.info(f"[BilibiliCrawler.search] Reached CRAWLER_MAX_NOTES_COUNT limit for keyword '{keyword}', skipping remaining days.") break pubtime_begin_s, pubtime_end_s = await self.get_pubtime_datetime(start=day.strftime("%Y-%m-%d"), end=day.strftime("%Y-%m-%d")) page = 1 notes_count_this_day = 0 while True: if notes_count_this_day >= config.MAX_NOTES_PER_DAY: utils.logger.info(f"[BilibiliCrawler.search] Reached MAX_NOTES_PER_DAY limit for {day.ctime()}.") break if (daily_limit and total_notes_crawled_for_keyword >= config.CRAWLER_MAX_NOTES_COUNT): utils.logger.info(f"[BilibiliCrawler.search] Reached CRAWLER_MAX_NOTES_COUNT limit for keyword '{keyword}'.") break if (not daily_limit and total_notes_crawled_for_keyword >= config.CRAWLER_MAX_NOTES_COUNT): break try: utils.logger.info(f"[BilibiliCrawler.search] search bilibili keyword: {keyword}, date: {day.ctime()}, page: {page}") video_id_list: List[str] = [] videos_res = await self.bili_client.search_video_by_keyword( keyword=keyword, page=page, page_size=bili_limit_count, order=SearchOrderType.DEFAULT, pubtime_begin_s=pubtime_begin_s, pubtime_end_s=pubtime_end_s, ) video_list: List[Dict] = videos_res.get("result") if not video_list: utils.logger.info(f"[BilibiliCrawler.search] No more videos for '{keyword}' on {day.ctime()}, moving to next day.") break semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list = [self.get_video_info_task(aid=video_item.get("aid"), bvid="", semaphore=semaphore) for video_item in video_list] video_items = await asyncio.gather(*task_list) for video_item in video_items: if video_item: if (daily_limit and total_notes_crawled_for_keyword >= config.CRAWLER_MAX_NOTES_COUNT): break if (not daily_limit and total_notes_crawled_for_keyword >= config.CRAWLER_MAX_NOTES_COUNT): break if notes_count_this_day >= config.MAX_NOTES_PER_DAY: break notes_count_this_day += 1 total_notes_crawled_for_keyword += 1 video_id_list.append(video_item.get("View").get("aid")) await bilibili_store.update_bilibili_video(video_item) await bilibili_store.update_up_info(video_item) await self.get_bilibili_video(video_item, semaphore) page += 1 # Sleep after page navigation await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[BilibiliCrawler.search_by_keywords_in_time_range] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after page {page-1}") await self.batch_get_video_comments(video_id_list) except Exception as e: utils.logger.error(f"[BilibiliCrawler.search] Error searching on {day.ctime()}: {e}") break async def batch_get_video_comments(self, video_id_list: List[str]): """ batch get video comments :param video_id_list: :return: """ if not config.ENABLE_GET_COMMENTS: utils.logger.info(f"[BilibiliCrawler.batch_get_note_comments] Crawling comment mode is not enabled") return utils.logger.info(f"[BilibiliCrawler.batch_get_video_comments] video ids:{video_id_list}") semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list: List[Task] = [] for video_id in video_id_list: task = asyncio.create_task(self.get_comments(video_id, semaphore), name=video_id) task_list.append(task) await asyncio.gather(*task_list) async def get_comments(self, video_id: str, semaphore: asyncio.Semaphore): """ get comment for video id :param video_id: :param semaphore: :return: """ async with semaphore: try: utils.logger.info(f"[BilibiliCrawler.get_comments] begin get video_id: {video_id} comments ...") await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[BilibiliCrawler.get_comments] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after fetching comments for video {video_id}") await self.bili_client.get_video_all_comments( video_id=video_id, crawl_interval=config.CRAWLER_MAX_SLEEP_SEC, is_fetch_sub_comments=config.ENABLE_GET_SUB_COMMENTS, callback=bilibili_store.batch_update_bilibili_video_comments, max_count=config.CRAWLER_MAX_COMMENTS_COUNT_SINGLENOTES, ) except DataFetchError as ex: utils.logger.error(f"[BilibiliCrawler.get_comments] get video_id: {video_id} comment error: {ex}") except Exception as e: utils.logger.error(f"[BilibiliCrawler.get_comments] may be been blocked, err:{e}") # Propagate the exception to be caught by the main loop raise async def get_creator_videos(self, creator_id: int): """ get videos for a creator :return: """ ps = 30 pn = 1 while True: result = await self.bili_client.get_creator_videos(creator_id, pn, ps) video_bvids_list = [video["bvid"] for video in result["list"]["vlist"]] await self.get_specified_videos(video_bvids_list) if int(result["page"]["count"]) <= pn * ps: break await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[BilibiliCrawler.get_creator_videos] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after page {pn}") pn += 1 async def get_specified_videos(self, video_url_list: List[str]): """ get specified videos info from URLs or BV IDs :param video_url_list: List of video URLs or BV IDs :return: """ utils.logger.info("[BilibiliCrawler.get_specified_videos] Parsing video URLs...") bvids_list = [] for video_url in video_url_list: try: video_info = parse_video_info_from_url(video_url) bvids_list.append(video_info.video_id) utils.logger.info(f"[BilibiliCrawler.get_specified_videos] Parsed video ID: {video_info.video_id} from {video_url}") except ValueError as e: utils.logger.error(f"[BilibiliCrawler.get_specified_videos] Failed to parse video URL: {e}") continue semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list = [self.get_video_info_task(aid=0, bvid=video_id, semaphore=semaphore) for video_id in bvids_list] video_details = await asyncio.gather(*task_list) video_aids_list = [] for video_detail in video_details: if video_detail is not None: video_item_view: Dict = video_detail.get("View") video_aid: str = video_item_view.get("aid") if video_aid: video_aids_list.append(video_aid) await bilibili_store.update_bilibili_video(video_detail) await bilibili_store.update_up_info(video_detail) await self.get_bilibili_video(video_detail, semaphore) await self.batch_get_video_comments(video_aids_list) async def get_video_info_task(self, aid: int, bvid: str, semaphore: asyncio.Semaphore) -> Optional[Dict]: """ Get video detail task :param aid: :param bvid: :param semaphore: :return: """ async with semaphore: try: result = await self.bili_client.get_video_info(aid=aid, bvid=bvid) # Sleep after fetching video details await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[BilibiliCrawler.get_video_info_task] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after fetching video details {bvid or aid}") return result except DataFetchError as ex: utils.logger.error(f"[BilibiliCrawler.get_video_info_task] Get video detail error: {ex}") return None except KeyError as ex: utils.logger.error(f"[BilibiliCrawler.get_video_info_task] have not fund note detail video_id:{bvid}, err: {ex}") return None async def get_video_play_url_task(self, aid: int, cid: int, semaphore: asyncio.Semaphore) -> Union[Dict, None]: """ Get video play url :param aid: :param cid: :param semaphore: :return: """ async with semaphore: try: result = await self.bili_client.get_video_play_url(aid=aid, cid=cid) return result except DataFetchError as ex: utils.logger.error(f"[BilibiliCrawler.get_video_play_url_task] Get video play url error: {ex}") return None except KeyError as ex: utils.logger.error(f"[BilibiliCrawler.get_video_play_url_task] have not fund play url from :{aid}|{cid}, err: {ex}") return None async def create_bilibili_client(self, httpx_proxy: Optional[str]) -> BilibiliClient: """ create bilibili client :param httpx_proxy: httpx proxy :return: bilibili client """ utils.logger.info("[BilibiliCrawler.create_bilibili_client] Begin create bilibili API client ...") cookie_str, cookie_dict = utils.convert_cookies(await self.browser_context.cookies()) bilibili_client_obj = BilibiliClient( proxy=httpx_proxy, headers={ "User-Agent": self.user_agent, "Cookie": cookie_str, "Origin": "https://www.bilibili.com", "Referer": "https://www.bilibili.com", "Content-Type": "application/json;charset=UTF-8", }, playwright_page=self.context_page, cookie_dict=cookie_dict, proxy_ip_pool=self.ip_proxy_pool, # Pass proxy pool for automatic refresh ) return bilibili_client_obj async def launch_browser( self, chromium: BrowserType, playwright_proxy: Optional[Dict], user_agent: Optional[str], headless: bool = True, ) -> BrowserContext: """ launch browser and create browser context :param chromium: chromium browser :param playwright_proxy: playwright proxy :param user_agent: user agent :param headless: headless mode :return: browser context """ utils.logger.info("[BilibiliCrawler.launch_browser] Begin create browser context ...") if config.SAVE_LOGIN_STATE: # feat issue #14 # we will save login state to avoid login every time user_data_dir = os.path.join(os.getcwd(), "browser_data", config.USER_DATA_DIR % config.PLATFORM) # type: ignore browser_context = await chromium.launch_persistent_context( user_data_dir=user_data_dir, accept_downloads=True, headless=headless, proxy=playwright_proxy, # type: ignore viewport={ "width": 1920, "height": 1080 }, user_agent=user_agent, channel="chrome", # Use system's stable Chrome version ) return browser_context else: # type: ignore browser = await chromium.launch(headless=headless, proxy=playwright_proxy, channel="chrome") browser_context = await browser.new_context(viewport={"width": 1920, "height": 1080}, user_agent=user_agent) return browser_context async def launch_browser_with_cdp( self, playwright: Playwright, playwright_proxy: Optional[Dict], user_agent: Optional[str], headless: bool = True, ) -> BrowserContext: """ Launch browser using CDP mode """ try: self.cdp_manager = CDPBrowserManager() browser_context = await self.cdp_manager.launch_and_connect( playwright=playwright, playwright_proxy=playwright_proxy, user_agent=user_agent, headless=headless, ) # Display browser information browser_info = await self.cdp_manager.get_browser_info() utils.logger.info(f"[BilibiliCrawler] CDP browser info: {browser_info}") return browser_context except Exception as e: utils.logger.error(f"[BilibiliCrawler] CDP mode launch failed, fallback to standard mode: {e}") # Fallback to standard mode chromium = playwright.chromium return await self.launch_browser(chromium, playwright_proxy, user_agent, headless) async def close(self): """Close browser context""" try: # If using CDP mode, special handling is required if self.cdp_manager: await self.cdp_manager.cleanup() self.cdp_manager = None elif self.browser_context: await self.browser_context.close() utils.logger.info("[BilibiliCrawler.close] Browser context closed ...") except TargetClosedError: utils.logger.warning("[BilibiliCrawler.close] Browser context was already closed.") except Exception as e: utils.logger.error(f"[BilibiliCrawler.close] An error occurred during close: {e}") async def get_bilibili_video(self, video_item: Dict, semaphore: asyncio.Semaphore): """ download bilibili video :param video_item: :param semaphore: :return: """ if not config.ENABLE_GET_MEIDAS: utils.logger.info(f"[BilibiliCrawler.get_bilibili_video] Crawling image mode is not enabled") return video_item_view: Dict = video_item.get("View") aid = video_item_view.get("aid") cid = video_item_view.get("cid") result = await self.get_video_play_url_task(aid, cid, semaphore) if result is None: utils.logger.info("[BilibiliCrawler.get_bilibili_video] get video play url failed") return durl_list = result.get("durl") max_size = -1 video_url = "" for durl in durl_list: size = durl.get("size") if size > max_size: max_size = size video_url = durl.get("url") if video_url == "": utils.logger.info("[BilibiliCrawler.get_bilibili_video] get video url failed") return content = await self.bili_client.get_video_media(video_url) await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[BilibiliCrawler.get_bilibili_video] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after fetching video {aid}") if content is None: return extension_file_name = f"video.mp4" await bilibili_store.store_video(aid, content, extension_file_name) async def get_all_creator_details(self, creator_url_list: List[str]): """ creator_url_list: get details for creator from creator URL list """ utils.logger.info(f"[BilibiliCrawler.get_all_creator_details] Crawling the details of creators") utils.logger.info(f"[BilibiliCrawler.get_all_creator_details] Parsing creator URLs...") creator_id_list = [] for creator_url in creator_url_list: try: creator_info = parse_creator_info_from_url(creator_url) creator_id_list.append(int(creator_info.creator_id)) utils.logger.info(f"[BilibiliCrawler.get_all_creator_details] Parsed creator ID: {creator_info.creator_id} from {creator_url}") except ValueError as e: utils.logger.error(f"[BilibiliCrawler.get_all_creator_details] Failed to parse creator URL: {e}") continue utils.logger.info(f"[BilibiliCrawler.get_all_creator_details] creator ids:{creator_id_list}") semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list: List[Task] = [] try: for creator_id in creator_id_list: task = asyncio.create_task(self.get_creator_details(creator_id, semaphore), name=str(creator_id)) task_list.append(task) except Exception as e: utils.logger.warning(f"[BilibiliCrawler.get_all_creator_details] error in the task list. The creator will not be included. {e}") await asyncio.gather(*task_list) async def get_creator_details(self, creator_id: int, semaphore: asyncio.Semaphore): """ get details for creator id :param creator_id: :param semaphore: :return: """ async with semaphore: creator_unhandled_info: Dict = await self.bili_client.get_creator_info(creator_id) creator_info: Dict = { "id": creator_id, "name": creator_unhandled_info.get("name"), "sign": creator_unhandled_info.get("sign"), "avatar": creator_unhandled_info.get("face"), } await self.get_fans(creator_info, semaphore) await self.get_followings(creator_info, semaphore) await self.get_dynamics(creator_info, semaphore) async def get_fans(self, creator_info: Dict, semaphore: asyncio.Semaphore): """ get fans for creator id :param creator_info: :param semaphore: :return: """ creator_id = creator_info["id"] async with semaphore: try: utils.logger.info(f"[BilibiliCrawler.get_fans] begin get creator_id: {creator_id} fans ...") await self.bili_client.get_creator_all_fans( creator_info=creator_info, crawl_interval=config.CRAWLER_MAX_SLEEP_SEC, callback=bilibili_store.batch_update_bilibili_creator_fans, max_count=config.CRAWLER_MAX_CONTACTS_COUNT_SINGLENOTES, ) except DataFetchError as ex: utils.logger.error(f"[BilibiliCrawler.get_fans] get creator_id: {creator_id} fans error: {ex}") except Exception as e: utils.logger.error(f"[BilibiliCrawler.get_fans] may be been blocked, err:{e}") async def get_followings(self, creator_info: Dict, semaphore: asyncio.Semaphore): """ get followings for creator id :param creator_info: :param semaphore: :return: """ creator_id = creator_info["id"] async with semaphore: try: utils.logger.info(f"[BilibiliCrawler.get_followings] begin get creator_id: {creator_id} followings ...") await self.bili_client.get_creator_all_followings( creator_info=creator_info, crawl_interval=config.CRAWLER_MAX_SLEEP_SEC, callback=bilibili_store.batch_update_bilibili_creator_followings, max_count=config.CRAWLER_MAX_CONTACTS_COUNT_SINGLENOTES, ) except DataFetchError as ex: utils.logger.error(f"[BilibiliCrawler.get_followings] get creator_id: {creator_id} followings error: {ex}") except Exception as e: utils.logger.error(f"[BilibiliCrawler.get_followings] may be been blocked, err:{e}") async def get_dynamics(self, creator_info: Dict, semaphore: asyncio.Semaphore): """ get dynamics for creator id :param creator_info: :param semaphore: :return: """ creator_id = creator_info["id"] async with semaphore: try: utils.logger.info(f"[BilibiliCrawler.get_dynamics] begin get creator_id: {creator_id} dynamics ...") await self.bili_client.get_creator_all_dynamics( creator_info=creator_info, crawl_interval=config.CRAWLER_MAX_SLEEP_SEC, callback=bilibili_store.batch_update_bilibili_creator_dynamics, max_count=config.CRAWLER_MAX_DYNAMICS_COUNT_SINGLENOTES, ) except DataFetchError as ex: utils.logger.error(f"[BilibiliCrawler.get_dynamics] get creator_id: {creator_id} dynamics error: {ex}") except Exception as e: utils.logger.error(f"[BilibiliCrawler.get_dynamics] may be been blocked, err:{e}") ================================================ FILE: media_platform/bilibili/exception.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/bilibili/exception.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/2 18:44 # @Desc : from httpx import RequestError class DataFetchError(RequestError): """something error when fetch""" class IPBlockError(RequestError): """fetch so fast that the server block us ip""" ================================================ FILE: media_platform/bilibili/field.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/bilibili/field.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/3 16:20 # @Desc : from enum import Enum class SearchOrderType(Enum): # Comprehensive sorting DEFAULT = "" # Most clicks MOST_CLICK = "click" # Latest published LAST_PUBLISH = "pubdate" # Most danmu (comments) MOST_DANMU = "dm" # Most bookmarks MOST_MARK = "stow" class CommentOrderType(Enum): # By popularity only DEFAULT = 0 # By popularity + time MIXED = 1 # By time TIME = 2 ================================================ FILE: media_platform/bilibili/help.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/bilibili/help.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/2 23:26 # @Desc : bilibili request parameter signing # Reverse engineering implementation reference: https://socialsisteryi.github.io/bilibili-API-collect/docs/misc/sign/wbi.html#wbi%E7%AD%BE%E5%90%8D%E7%AE%97%E6%B3%95 import re import urllib.parse from hashlib import md5 from typing import Dict from model.m_bilibili import VideoUrlInfo, CreatorUrlInfo from tools import utils class BilibiliSign: def __init__(self, img_key: str, sub_key: str): self.img_key = img_key self.sub_key = sub_key self.map_table = [ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52 ] def get_salt(self) -> str: """ Get the salted key :return: """ salt = "" mixin_key = self.img_key + self.sub_key for mt in self.map_table: salt += mixin_key[mt] return salt[:32] def sign(self, req_data: Dict) -> Dict: """ Add current timestamp to request parameters, sort keys in dictionary order, then URL encode the parameters and combine with salt to generate md5 for w_rid parameter :param req_data: :return: """ current_ts = utils.get_unix_timestamp() req_data.update({"wts": current_ts}) req_data = dict(sorted(req_data.items())) req_data = { # Filter "!'()*" characters from values k: ''.join(filter(lambda ch: ch not in "!'()*", str(v))) for k, v in req_data.items() } query = urllib.parse.urlencode(req_data) salt = self.get_salt() wbi_sign = md5((query + salt).encode()).hexdigest() # Calculate w_rid req_data['w_rid'] = wbi_sign return req_data def parse_video_info_from_url(url: str) -> VideoUrlInfo: """ Parse video ID from Bilibili video URL Args: url: Bilibili video link - https://www.bilibili.com/video/BV1dwuKzmE26/?spm_id_from=333.1387.homepage.video_card.click - https://www.bilibili.com/video/BV1d54y1g7db - BV1d54y1g7db (directly pass BV number) Returns: VideoUrlInfo: Object containing video ID """ # If the input is already a BV number, return directly if url.startswith("BV"): return VideoUrlInfo(video_id=url) # Use regex to extract BV number # Match /video/BV... or /video/av... format bv_pattern = r'/video/(BV[a-zA-Z0-9]+)' match = re.search(bv_pattern, url) if match: video_id = match.group(1) return VideoUrlInfo(video_id=video_id) raise ValueError(f"Unable to parse video ID from URL: {url}") def parse_creator_info_from_url(url: str) -> CreatorUrlInfo: """ Parse creator ID from Bilibili creator space URL Args: url: Bilibili creator space link - https://space.bilibili.com/434377496?spm_id_from=333.1007.0.0 - https://space.bilibili.com/20813884 - 434377496 (directly pass UID) Returns: CreatorUrlInfo: Object containing creator ID """ # If the input is already a numeric ID, return directly if url.isdigit(): return CreatorUrlInfo(creator_id=url) # Use regex to extract UID # Match /space.bilibili.com/number format uid_pattern = r'space\.bilibili\.com/(\d+)' match = re.search(uid_pattern, url) if match: creator_id = match.group(1) return CreatorUrlInfo(creator_id=creator_id) raise ValueError(f"Unable to parse creator ID from URL: {url}") if __name__ == '__main__': # Test video URL parsing video_url1 = "https://www.bilibili.com/video/BV1dwuKzmE26/?spm_id_from=333.1387.homepage.video_card.click" video_url2 = "BV1d54y1g7db" print("Video URL parsing test:") print(f"URL1: {video_url1} -> {parse_video_info_from_url(video_url1)}") print(f"URL2: {video_url2} -> {parse_video_info_from_url(video_url2)}") # Test creator URL parsing creator_url1 = "https://space.bilibili.com/434377496?spm_id_from=333.1007.0.0" creator_url2 = "20813884" print("\nCreator URL parsing test:") print(f"URL1: {creator_url1} -> {parse_creator_info_from_url(creator_url1)}") print(f"URL2: {creator_url2} -> {parse_creator_info_from_url(creator_url2)}") ================================================ FILE: media_platform/bilibili/login.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/bilibili/login.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/2 18:44 # @Desc : bilibili login implementation class import asyncio import functools import sys from typing import Optional from playwright.async_api import BrowserContext, Page from tenacity import (RetryError, retry, retry_if_result, stop_after_attempt, wait_fixed) import config from base.base_crawler import AbstractLogin from tools import utils class BilibiliLogin(AbstractLogin): def __init__(self, login_type: str, browser_context: BrowserContext, context_page: Page, login_phone: Optional[str] = "", cookie_str: str = "" ): config.LOGIN_TYPE = login_type self.browser_context = browser_context self.context_page = context_page self.login_phone = login_phone self.cookie_str = cookie_str async def begin(self): """Start login bilibili""" utils.logger.info("[BilibiliLogin.begin] Begin login Bilibili ...") if config.LOGIN_TYPE == "qrcode": await self.login_by_qrcode() elif config.LOGIN_TYPE == "phone": await self.login_by_mobile() elif config.LOGIN_TYPE == "cookie": await self.login_by_cookies() else: raise ValueError( "[BilibiliLogin.begin] Invalid Login Type Currently only supported qrcode or phone or cookie ...") @retry(stop=stop_after_attempt(600), wait=wait_fixed(1), retry=retry_if_result(lambda value: value is False)) async def check_login_state(self) -> bool: """ Check if the current login status is successful and return True otherwise return False retry decorator will retry 20 times if the return value is False, and the retry interval is 1 second if max retry times reached, raise RetryError """ current_cookie = await self.browser_context.cookies() _, cookie_dict = utils.convert_cookies(current_cookie) if cookie_dict.get("SESSDATA", "") or cookie_dict.get("DedeUserID"): return True return False async def login_by_qrcode(self): """login bilibili website and keep webdriver login state""" utils.logger.info("[BilibiliLogin.login_by_qrcode] Begin login bilibili by qrcode ...") # click login button login_button_ele = self.context_page.locator( "xpath=//div[@class='right-entry__outside go-login-btn']//div" ) await login_button_ele.click() await asyncio.sleep(1) # find login qrcode qrcode_img_selector = "//div[@class='login-scan-box']//img" base64_qrcode_img = await utils.find_login_qrcode( self.context_page, selector=qrcode_img_selector ) if not base64_qrcode_img: utils.logger.info("[BilibiliLogin.login_by_qrcode] login failed , have not found qrcode please check ....") sys.exit() # show login qrcode partial_show_qrcode = functools.partial(utils.show_qrcode, base64_qrcode_img) asyncio.get_running_loop().run_in_executor(executor=None, func=partial_show_qrcode) utils.logger.info(f"[BilibiliLogin.login_by_qrcode] Waiting for scan code login, remaining time is 20s") try: await self.check_login_state() except RetryError: utils.logger.info("[BilibiliLogin.login_by_qrcode] Login bilibili failed by qrcode login method ...") sys.exit() wait_redirect_seconds = 5 utils.logger.info( f"[BilibiliLogin.login_by_qrcode] Login successful then wait for {wait_redirect_seconds} seconds redirect ...") await asyncio.sleep(wait_redirect_seconds) async def login_by_mobile(self): pass async def login_by_cookies(self): utils.logger.info("[BilibiliLogin.login_by_qrcode] Begin login bilibili by cookie ...") for key, value in utils.convert_str_cookie_to_dict(self.cookie_str).items(): await self.browser_context.add_cookies([{ 'name': key, 'value': value, 'domain': ".bilibili.com", 'path': "/" }]) ================================================ FILE: media_platform/douyin/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/douyin/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from .core import DouYinCrawler ================================================ FILE: media_platform/douyin/client.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/douyin/client.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import asyncio import copy import json import urllib.parse from typing import TYPE_CHECKING, Any, Callable, Dict, Union, Optional import httpx from playwright.async_api import BrowserContext from base.base_crawler import AbstractApiClient from proxy.proxy_mixin import ProxyRefreshMixin from tools import utils from tools.httpx_util import make_async_client from var import request_keyword_var if TYPE_CHECKING: from proxy.proxy_ip_pool import ProxyIpPool from .exception import * from .field import * from .help import * class DouYinClient(AbstractApiClient, ProxyRefreshMixin): def __init__( self, timeout=60, # If the crawl media option is turned on, Douyin’s short videos will require a longer timeout. proxy=None, *, headers: Dict, playwright_page: Optional[Page], cookie_dict: Dict, proxy_ip_pool: Optional["ProxyIpPool"] = None, ): self.proxy = proxy self.timeout = timeout self.headers = headers self._host = "https://www.douyin.com" self.playwright_page = playwright_page self.cookie_dict = cookie_dict # Initialize proxy pool (from ProxyRefreshMixin) self.init_proxy_pool(proxy_ip_pool) async def __process_req_params( self, uri: str, params: Optional[Dict] = None, headers: Optional[Dict] = None, request_method="GET", ): if not params: return headers = headers or self.headers local_storage: Dict = await self.playwright_page.evaluate("() => window.localStorage") # type: ignore common_params = { "device_platform": "webapp", "aid": "6383", "channel": "channel_pc_web", "version_code": "190600", "version_name": "19.6.0", "update_version_code": "170400", "pc_client_type": "1", "cookie_enabled": "true", "browser_language": "zh-CN", "browser_platform": "MacIntel", "browser_name": "Chrome", "browser_version": "125.0.0.0", "browser_online": "true", "engine_name": "Blink", "os_name": "Mac OS", "os_version": "10.15.7", "cpu_core_num": "8", "device_memory": "8", "engine_version": "109.0", "platform": "PC", "screen_width": "2560", "screen_height": "1440", 'effective_type': '4g', "round_trip_time": "50", "webid": get_web_id(), "msToken": local_storage.get("xmst"), } params.update(common_params) query_string = urllib.parse.urlencode(params) # 20240927 a-bogus update (JS version) post_data = {} if request_method == "POST": post_data = params if "/v1/web/general/search" not in uri: a_bogus = await get_a_bogus(uri, query_string, post_data, headers["User-Agent"], self.playwright_page) params["a_bogus"] = a_bogus async def request(self, method, url, **kwargs): # Check whether the proxy has expired before each request await self._refresh_proxy_if_expired() async with make_async_client(proxy=self.proxy) as client: response = await client.request(method, url, timeout=self.timeout, **kwargs) try: if response.text == "" or response.text == "blocked": utils.logger.error(f"request params incrr, response.text: {response.text}") raise Exception("account blocked") return response.json() except Exception as e: raise DataFetchError(f"{e}, {response.text}") async def get(self, uri: str, params: Optional[Dict] = None, headers: Optional[Dict] = None): """ GET请求 """ await self.__process_req_params(uri, params, headers) headers = headers or self.headers return await self.request(method="GET", url=f"{self._host}{uri}", params=params, headers=headers) async def post(self, uri: str, data: dict, headers: Optional[Dict] = None): await self.__process_req_params(uri, data, headers) headers = headers or self.headers return await self.request(method="POST", url=f"{self._host}{uri}", data=data, headers=headers) async def pong(self, browser_context: BrowserContext) -> bool: local_storage = await self.playwright_page.evaluate("() => window.localStorage") if local_storage.get("HasUserLogin", "") == "1": return True _, cookie_dict = utils.convert_cookies(await browser_context.cookies()) return cookie_dict.get("LOGIN_STATUS") == "1" async def update_cookies(self, browser_context: BrowserContext): cookie_str, cookie_dict = utils.convert_cookies(await browser_context.cookies()) self.headers["Cookie"] = cookie_str self.cookie_dict = cookie_dict async def search_info_by_keyword( self, keyword: str, offset: int = 0, search_channel: SearchChannelType = SearchChannelType.GENERAL, sort_type: SearchSortType = SearchSortType.GENERAL, publish_time: PublishTimeType = PublishTimeType.UNLIMITED, search_id: str = "", ): """ DouYin Web Search API :param keyword: :param offset: :param search_channel: :param sort_type: :param publish_time: · :param search_id: · :return: """ query_params = { 'search_channel': search_channel.value, 'enable_history': '1', 'keyword': keyword, 'search_source': 'tab_search', 'query_correct_type': '1', 'is_filter_search': '0', 'from_group_id': '7378810571505847586', 'offset': offset, 'count': '15', 'need_filter_settings': '1', 'list_type': 'multi', 'search_id': search_id, } if sort_type.value != SearchSortType.GENERAL.value or publish_time.value != PublishTimeType.UNLIMITED.value: query_params["filter_selected"] = json.dumps({"sort_type": str(sort_type.value), "publish_time": str(publish_time.value)}) query_params["is_filter_search"] = 1 query_params["search_source"] = "tab_search" referer_url = f"https://www.douyin.com/search/{keyword}?aid=f594bbd9-a0e2-4651-9319-ebe3cb6298c1&type=general" headers = copy.copy(self.headers) headers["Referer"] = urllib.parse.quote(referer_url, safe=':/') return await self.get("/aweme/v1/web/general/search/single/", query_params, headers=headers) async def get_video_by_id(self, aweme_id: str) -> Any: """ DouYin Video Detail API :param aweme_id: :return: """ params = {"aweme_id": aweme_id} headers = copy.copy(self.headers) del headers["Origin"] res = await self.get("/aweme/v1/web/aweme/detail/", params, headers) return res.get("aweme_detail", {}) async def get_aweme_comments(self, aweme_id: str, cursor: int = 0): """get note comments """ uri = "/aweme/v1/web/comment/list/" params = {"aweme_id": aweme_id, "cursor": cursor, "count": 20, "item_type": 0} keywords = request_keyword_var.get() referer_url = "https://www.douyin.com/search/" + keywords + '?aid=3a3cec5a-9e27-4040-b6aa-ef548c2c1138&publish_time=0&sort_type=0&source=search_history&type=general' headers = copy.copy(self.headers) headers["Referer"] = urllib.parse.quote(referer_url, safe=':/') return await self.get(uri, params) async def get_sub_comments(self, aweme_id: str, comment_id: str, cursor: int = 0): """ 获取子评论 """ uri = "/aweme/v1/web/comment/list/reply/" params = { 'comment_id': comment_id, "cursor": cursor, "count": 20, "item_type": 0, "item_id": aweme_id, } keywords = request_keyword_var.get() referer_url = "https://www.douyin.com/search/" + keywords + '?aid=3a3cec5a-9e27-4040-b6aa-ef548c2c1138&publish_time=0&sort_type=0&source=search_history&type=general' headers = copy.copy(self.headers) headers["Referer"] = urllib.parse.quote(referer_url, safe=':/') return await self.get(uri, params) async def get_aweme_all_comments( self, aweme_id: str, crawl_interval: float = 1.0, is_fetch_sub_comments=False, callback: Optional[Callable] = None, max_count: int = 10, ): """ 获取帖子的所有评论,包括子评论 :param aweme_id: 帖子ID :param crawl_interval: 抓取间隔 :param is_fetch_sub_comments: 是否抓取子评论 :param callback: 回调函数,用于处理抓取到的评论 :param max_count: 一次帖子爬取的最大评论数量 :return: 评论列表 """ result = [] comments_has_more = 1 comments_cursor = 0 while comments_has_more and len(result) < max_count: comments_res = await self.get_aweme_comments(aweme_id, comments_cursor) comments_has_more = comments_res.get("has_more", 0) comments_cursor = comments_res.get("cursor", 0) comments = comments_res.get("comments", []) if not comments: continue if len(result) + len(comments) > max_count: comments = comments[:max_count - len(result)] result.extend(comments) if callback: # If there is a callback function, execute the callback function await callback(aweme_id, comments) await asyncio.sleep(crawl_interval) if not is_fetch_sub_comments: continue # Get secondary reviews for comment in comments: reply_comment_total = comment.get("reply_comment_total") if reply_comment_total > 0: comment_id = comment.get("cid") sub_comments_has_more = 1 sub_comments_cursor = 0 while sub_comments_has_more: sub_comments_res = await self.get_sub_comments(aweme_id, comment_id, sub_comments_cursor) sub_comments_has_more = sub_comments_res.get("has_more", 0) sub_comments_cursor = sub_comments_res.get("cursor", 0) sub_comments = sub_comments_res.get("comments", []) if not sub_comments: continue result.extend(sub_comments) if callback: # If there is a callback function, execute the callback function await callback(aweme_id, sub_comments) await asyncio.sleep(crawl_interval) return result async def get_user_info(self, sec_user_id: str): uri = "/aweme/v1/web/user/profile/other/" params = { "sec_user_id": sec_user_id, "publish_video_strategy_type": 2, "personal_center_strategy": 1, } return await self.get(uri, params) async def get_user_aweme_posts(self, sec_user_id: str, max_cursor: str = "") -> Dict: uri = "/aweme/v1/web/aweme/post/" params = { "sec_user_id": sec_user_id, "count": 18, "max_cursor": max_cursor, "locate_query": "false", "publish_video_strategy_type": 2, 'verifyFp': 'verify_ma3hrt8n_q2q2HyYA_uLyO_4N6D_BLvX_E2LgoGmkA1BU', 'fp': 'verify_ma3hrt8n_q2q2HyYA_uLyO_4N6D_BLvX_E2LgoGmkA1BU' } return await self.get(uri, params) async def get_all_user_aweme_posts(self, sec_user_id: str, callback: Optional[Callable] = None): posts_has_more = 1 max_cursor = "" result = [] while posts_has_more == 1: aweme_post_res = await self.get_user_aweme_posts(sec_user_id, max_cursor) posts_has_more = aweme_post_res.get("has_more", 0) max_cursor = aweme_post_res.get("max_cursor") aweme_list = aweme_post_res.get("aweme_list") if aweme_post_res.get("aweme_list") else [] utils.logger.info(f"[DouYinClient.get_all_user_aweme_posts] get sec_user_id:{sec_user_id} video len : {len(aweme_list)}") if callback: await callback(aweme_list) result.extend(aweme_list) return result async def get_aweme_media(self, url: str) -> Union[bytes, None]: async with make_async_client(proxy=self.proxy) as client: try: response = await client.request("GET", url, timeout=self.timeout, follow_redirects=True) response.raise_for_status() if not response.reason_phrase == "OK": utils.logger.error(f"[DouYinClient.get_aweme_media] request {url} err, res:{response.text}") return None else: return response.content except httpx.HTTPError as exc: # some wrong when call httpx.request method, such as connection error, client error, server error or response status code is not 2xx utils.logger.error(f"[DouYinClient.get_aweme_media] {exc.__class__.__name__} for {exc.request.url} - {exc}") # Keep the original exception type name for developers to debug return None async def resolve_short_url(self, short_url: str) -> str: """ 解析抖音短链接,获取重定向后的真实URL Args: short_url: 短链接,如 https://v.douyin.com/iF12345ABC/ Returns: 重定向后的完整URL """ async with make_async_client(proxy=self.proxy, follow_redirects=False) as client: try: utils.logger.info(f"[DouYinClient.resolve_short_url] Resolving short URL: {short_url}") response = await client.get(short_url, timeout=10) # Short links usually return a 302 redirect if response.status_code in [301, 302, 303, 307, 308]: redirect_url = response.headers.get("Location", "") utils.logger.info(f"[DouYinClient.resolve_short_url] Resolved to: {redirect_url}") return redirect_url else: utils.logger.warning(f"[DouYinClient.resolve_short_url] Unexpected status code: {response.status_code}") return "" except Exception as e: utils.logger.error(f"[DouYinClient.resolve_short_url] Failed to resolve short URL: {e}") return "" ================================================ FILE: media_platform/douyin/core.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/douyin/core.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import asyncio import os import random from asyncio import Task from typing import Any, Dict, List, Optional, Tuple from playwright.async_api import ( BrowserContext, BrowserType, Page, Playwright, async_playwright, ) import config from base.base_crawler import AbstractCrawler from proxy.proxy_ip_pool import IpInfoModel, create_ip_pool from store import douyin as douyin_store from tools import utils from tools.cdp_browser import CDPBrowserManager from var import crawler_type_var, source_keyword_var from .client import DouYinClient from .exception import DataFetchError from .field import PublishTimeType from .help import parse_video_info_from_url, parse_creator_info_from_url from .login import DouYinLogin class DouYinCrawler(AbstractCrawler): context_page: Page dy_client: DouYinClient browser_context: BrowserContext cdp_manager: Optional[CDPBrowserManager] def __init__(self) -> None: self.index_url = "https://www.douyin.com" self.cdp_manager = None self.ip_proxy_pool = None # Proxy IP pool for automatic proxy refresh async def start(self) -> None: playwright_proxy_format, httpx_proxy_format = None, None if config.ENABLE_IP_PROXY: self.ip_proxy_pool = await create_ip_pool(config.IP_PROXY_POOL_COUNT, enable_validate_ip=True) ip_proxy_info: IpInfoModel = await self.ip_proxy_pool.get_proxy() playwright_proxy_format, httpx_proxy_format = utils.format_proxy_info(ip_proxy_info) async with async_playwright() as playwright: # Select startup mode based on configuration if config.ENABLE_CDP_MODE: utils.logger.info("[DouYinCrawler] 使用CDP模式启动浏览器") self.browser_context = await self.launch_browser_with_cdp( playwright, playwright_proxy_format, None, headless=config.CDP_HEADLESS, ) else: utils.logger.info("[DouYinCrawler] 使用标准模式启动浏览器") # Launch a browser context. chromium = playwright.chromium self.browser_context = await self.launch_browser( chromium, playwright_proxy_format, user_agent=None, headless=config.HEADLESS, ) # stealth.min.js is a js script to prevent the website from detecting the crawler. await self.browser_context.add_init_script(path="libs/stealth.min.js") self.context_page = await self.browser_context.new_page() await self.context_page.goto(self.index_url) self.dy_client = await self.create_douyin_client(httpx_proxy_format) if not await self.dy_client.pong(browser_context=self.browser_context): login_obj = DouYinLogin( login_type=config.LOGIN_TYPE, login_phone="", # you phone number browser_context=self.browser_context, context_page=self.context_page, cookie_str=config.COOKIES, ) await login_obj.begin() await self.dy_client.update_cookies(browser_context=self.browser_context) crawler_type_var.set(config.CRAWLER_TYPE) if config.CRAWLER_TYPE == "search": # Search for notes and retrieve their comment information. await self.search() elif config.CRAWLER_TYPE == "detail": # Get the information and comments of the specified post await self.get_specified_awemes() elif config.CRAWLER_TYPE == "creator": # Get the information and comments of the specified creator await self.get_creators_and_videos() utils.logger.info("[DouYinCrawler.start] Douyin Crawler finished ...") async def search(self) -> None: utils.logger.info("[DouYinCrawler.search] Begin search douyin keywords") dy_limit_count = 10 # douyin limit page fixed value if config.CRAWLER_MAX_NOTES_COUNT < dy_limit_count: config.CRAWLER_MAX_NOTES_COUNT = dy_limit_count start_page = config.START_PAGE # start page number for keyword in config.KEYWORDS.split(","): source_keyword_var.set(keyword) utils.logger.info(f"[DouYinCrawler.search] Current keyword: {keyword}") aweme_list: List[str] = [] page = 0 dy_search_id = "" while (page - start_page + 1) * dy_limit_count <= config.CRAWLER_MAX_NOTES_COUNT: if page < start_page: utils.logger.info(f"[DouYinCrawler.search] Skip {page}") page += 1 continue try: utils.logger.info(f"[DouYinCrawler.search] search douyin keyword: {keyword}, page: {page}") posts_res = await self.dy_client.search_info_by_keyword( keyword=keyword, offset=page * dy_limit_count - dy_limit_count, publish_time=PublishTimeType(config.PUBLISH_TIME_TYPE), search_id=dy_search_id, ) if posts_res.get("data") is None or posts_res.get("data") == []: utils.logger.info(f"[DouYinCrawler.search] search douyin keyword: {keyword}, page: {page} is empty,{posts_res.get('data')}`") break except DataFetchError: utils.logger.error(f"[DouYinCrawler.search] search douyin keyword: {keyword} failed") break page += 1 if "data" not in posts_res: utils.logger.error(f"[DouYinCrawler.search] search douyin keyword: {keyword} failed,账号也许被风控了。") break dy_search_id = posts_res.get("extra", {}).get("logid", "") page_aweme_list = [] for post_item in posts_res.get("data"): try: aweme_info: Dict = (post_item.get("aweme_info") or post_item.get("aweme_mix_info", {}).get("mix_items")[0]) except TypeError: continue aweme_list.append(aweme_info.get("aweme_id", "")) page_aweme_list.append(aweme_info.get("aweme_id", "")) await douyin_store.update_douyin_aweme(aweme_item=aweme_info) await self.get_aweme_media(aweme_item=aweme_info) # Batch get note comments for the current page await self.batch_get_note_comments(page_aweme_list) # Sleep after each page navigation await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[DouYinCrawler.search] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after page {page-1}") utils.logger.info(f"[DouYinCrawler.search] keyword:{keyword}, aweme_list:{aweme_list}") async def get_specified_awemes(self): """Get the information and comments of the specified post from URLs or IDs""" utils.logger.info("[DouYinCrawler.get_specified_awemes] Parsing video URLs...") aweme_id_list = [] for video_url in config.DY_SPECIFIED_ID_LIST: try: video_info = parse_video_info_from_url(video_url) # Handling short links if video_info.url_type == "short": utils.logger.info(f"[DouYinCrawler.get_specified_awemes] Resolving short link: {video_url}") resolved_url = await self.dy_client.resolve_short_url(video_url) if resolved_url: # Extract video ID from parsed URL video_info = parse_video_info_from_url(resolved_url) utils.logger.info(f"[DouYinCrawler.get_specified_awemes] Short link resolved to aweme ID: {video_info.aweme_id}") else: utils.logger.error(f"[DouYinCrawler.get_specified_awemes] Failed to resolve short link: {video_url}") continue aweme_id_list.append(video_info.aweme_id) utils.logger.info(f"[DouYinCrawler.get_specified_awemes] Parsed aweme ID: {video_info.aweme_id} from {video_url}") except ValueError as e: utils.logger.error(f"[DouYinCrawler.get_specified_awemes] Failed to parse video URL: {e}") continue semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list = [self.get_aweme_detail(aweme_id=aweme_id, semaphore=semaphore) for aweme_id in aweme_id_list] aweme_details = await asyncio.gather(*task_list) for aweme_detail in aweme_details: if aweme_detail is not None: await douyin_store.update_douyin_aweme(aweme_item=aweme_detail) await self.get_aweme_media(aweme_item=aweme_detail) await self.batch_get_note_comments(aweme_id_list) async def get_aweme_detail(self, aweme_id: str, semaphore: asyncio.Semaphore) -> Any: """Get note detail""" async with semaphore: try: result = await self.dy_client.get_video_by_id(aweme_id) # Sleep after fetching aweme detail await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[DouYinCrawler.get_aweme_detail] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after fetching aweme {aweme_id}") return result except DataFetchError as ex: utils.logger.error(f"[DouYinCrawler.get_aweme_detail] Get aweme detail error: {ex}") return None except KeyError as ex: utils.logger.error(f"[DouYinCrawler.get_aweme_detail] have not fund note detail aweme_id:{aweme_id}, err: {ex}") return None async def batch_get_note_comments(self, aweme_list: List[str]) -> None: """ Batch get note comments """ if not config.ENABLE_GET_COMMENTS: utils.logger.info(f"[DouYinCrawler.batch_get_note_comments] Crawling comment mode is not enabled") return task_list: List[Task] = [] semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) for aweme_id in aweme_list: task = asyncio.create_task(self.get_comments(aweme_id, semaphore), name=aweme_id) task_list.append(task) if len(task_list) > 0: await asyncio.wait(task_list) async def get_comments(self, aweme_id: str, semaphore: asyncio.Semaphore) -> None: async with semaphore: try: # Pass the list of keywords to the get_aweme_all_comments method # Use fixed crawling interval crawl_interval = config.CRAWLER_MAX_SLEEP_SEC await self.dy_client.get_aweme_all_comments( aweme_id=aweme_id, crawl_interval=crawl_interval, is_fetch_sub_comments=config.ENABLE_GET_SUB_COMMENTS, callback=douyin_store.batch_update_dy_aweme_comments, max_count=config.CRAWLER_MAX_COMMENTS_COUNT_SINGLENOTES, ) # Sleep after fetching comments await asyncio.sleep(crawl_interval) utils.logger.info(f"[DouYinCrawler.get_comments] Sleeping for {crawl_interval} seconds after fetching comments for aweme {aweme_id}") utils.logger.info(f"[DouYinCrawler.get_comments] aweme_id: {aweme_id} comments have all been obtained and filtered ...") except DataFetchError as e: utils.logger.error(f"[DouYinCrawler.get_comments] aweme_id: {aweme_id} get comments failed, error: {e}") async def get_creators_and_videos(self) -> None: """ Get the information and videos of the specified creator from URLs or IDs """ utils.logger.info("[DouYinCrawler.get_creators_and_videos] Begin get douyin creators") utils.logger.info("[DouYinCrawler.get_creators_and_videos] Parsing creator URLs...") for creator_url in config.DY_CREATOR_ID_LIST: try: creator_info_parsed = parse_creator_info_from_url(creator_url) user_id = creator_info_parsed.sec_user_id utils.logger.info(f"[DouYinCrawler.get_creators_and_videos] Parsed sec_user_id: {user_id} from {creator_url}") except ValueError as e: utils.logger.error(f"[DouYinCrawler.get_creators_and_videos] Failed to parse creator URL: {e}") continue creator_info: Dict = await self.dy_client.get_user_info(user_id) if creator_info: await douyin_store.save_creator(user_id, creator=creator_info) # Get all video information of the creator all_video_list = await self.dy_client.get_all_user_aweme_posts(sec_user_id=user_id, callback=self.fetch_creator_video_detail) video_ids = [video_item.get("aweme_id") for video_item in all_video_list] await self.batch_get_note_comments(video_ids) async def fetch_creator_video_detail(self, video_list: List[Dict]): """ Concurrently obtain the specified post list and save the data """ semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list = [self.get_aweme_detail(post_item.get("aweme_id"), semaphore) for post_item in video_list] note_details = await asyncio.gather(*task_list) for aweme_item in note_details: if aweme_item is not None: await douyin_store.update_douyin_aweme(aweme_item=aweme_item) await self.get_aweme_media(aweme_item=aweme_item) async def create_douyin_client(self, httpx_proxy: Optional[str]) -> DouYinClient: """Create douyin client""" cookie_str, cookie_dict = utils.convert_cookies(await self.browser_context.cookies()) # type: ignore douyin_client = DouYinClient( proxy=httpx_proxy, headers={ "User-Agent": await self.context_page.evaluate("() => navigator.userAgent"), "Cookie": cookie_str, "Host": "www.douyin.com", "Origin": "https://www.douyin.com/", "Referer": "https://www.douyin.com/", "Content-Type": "application/json;charset=UTF-8", }, playwright_page=self.context_page, cookie_dict=cookie_dict, proxy_ip_pool=self.ip_proxy_pool, # Pass proxy pool for automatic refresh ) return douyin_client async def launch_browser( self, chromium: BrowserType, playwright_proxy: Optional[Dict], user_agent: Optional[str], headless: bool = True, ) -> BrowserContext: """Launch browser and create browser context""" if config.SAVE_LOGIN_STATE: user_data_dir = os.path.join(os.getcwd(), "browser_data", config.USER_DATA_DIR % config.PLATFORM) # type: ignore browser_context = await chromium.launch_persistent_context( user_data_dir=user_data_dir, accept_downloads=True, headless=headless, proxy=playwright_proxy, # type: ignore viewport={ "width": 1920, "height": 1080 }, user_agent=user_agent, ) # type: ignore return browser_context else: browser = await chromium.launch(headless=headless, proxy=playwright_proxy) # type: ignore browser_context = await browser.new_context(viewport={"width": 1920, "height": 1080}, user_agent=user_agent) return browser_context async def launch_browser_with_cdp( self, playwright: Playwright, playwright_proxy: Optional[Dict], user_agent: Optional[str], headless: bool = True, ) -> BrowserContext: """ 使用CDP模式启动浏览器 """ try: self.cdp_manager = CDPBrowserManager() browser_context = await self.cdp_manager.launch_and_connect( playwright=playwright, playwright_proxy=playwright_proxy, user_agent=user_agent, headless=headless, ) # Add anti-detection script await self.cdp_manager.add_stealth_script() # Show browser information browser_info = await self.cdp_manager.get_browser_info() utils.logger.info(f"[DouYinCrawler] CDP浏览器信息: {browser_info}") return browser_context except Exception as e: utils.logger.error(f"[DouYinCrawler] CDP模式启动失败,回退到标准模式: {e}") # Fall back to standard mode chromium = playwright.chromium return await self.launch_browser(chromium, playwright_proxy, user_agent, headless) async def close(self) -> None: """Close browser context""" # If you use CDP mode, special processing is required if self.cdp_manager: await self.cdp_manager.cleanup() self.cdp_manager = None else: await self.browser_context.close() utils.logger.info("[DouYinCrawler.close] Browser context closed ...") async def get_aweme_media(self, aweme_item: Dict): """ 获取抖音媒体,自动判断媒体类型是短视频还是帖子图片并下载 Args: aweme_item (Dict): 抖音作品详情 """ if not config.ENABLE_GET_MEIDAS: utils.logger.info(f"[DouYinCrawler.get_aweme_media] Crawling image mode is not enabled") return # List of note urls. If it is a short video type, an empty list will be returned. note_download_url: List[str] = douyin_store._extract_note_image_list(aweme_item) # The video URL will always exist, but when it is a short video type, the file is actually an audio file. video_download_url: str = douyin_store._extract_video_download_url(aweme_item) # TODO: Douyin does not adopt the audio and video separation strategy, so the audio can be separated from the original video and will not be extracted for the time being. if note_download_url: await self.get_aweme_images(aweme_item) else: await self.get_aweme_video(aweme_item) async def get_aweme_images(self, aweme_item: Dict): """ get aweme images. please use get_aweme_media Args: aweme_item (Dict): 抖音作品详情 """ if not config.ENABLE_GET_MEIDAS: return aweme_id = aweme_item.get("aweme_id") # List of note urls. If it is a short video type, an empty list will be returned. note_download_url: List[str] = douyin_store._extract_note_image_list(aweme_item) if not note_download_url: return picNum = 0 for url in note_download_url: if not url: continue content = await self.dy_client.get_aweme_media(url) await asyncio.sleep(random.random()) if content is None: continue extension_file_name = f"{picNum:>03d}.jpeg" picNum += 1 await douyin_store.update_dy_aweme_image(aweme_id, content, extension_file_name) async def get_aweme_video(self, aweme_item: Dict): """ get aweme videos. please use get_aweme_media Args: aweme_item (Dict): 抖音作品详情 """ if not config.ENABLE_GET_MEIDAS: return aweme_id = aweme_item.get("aweme_id") # The video URL will always exist, but when it is a short video type, the file is actually an audio file. video_download_url: str = douyin_store._extract_video_download_url(aweme_item) if not video_download_url: return content = await self.dy_client.get_aweme_media(video_download_url) await asyncio.sleep(random.random()) if content is None: return extension_file_name = f"video.mp4" await douyin_store.update_dy_aweme_video(aweme_id, content, extension_file_name) ================================================ FILE: media_platform/douyin/exception.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/douyin/exception.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from httpx import RequestError class DataFetchError(RequestError): """something error when fetch""" class IPBlockError(RequestError): """fetch so fast that the server block us ip""" ================================================ FILE: media_platform/douyin/field.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/douyin/field.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from enum import Enum class SearchChannelType(Enum): """search channel type""" GENERAL = "aweme_general" # General VIDEO = "aweme_video_web" # Video USER = "aweme_user_web" # User LIVE = "aweme_live" # Live class SearchSortType(Enum): """search sort type""" GENERAL = 0 # Comprehensive sorting MOST_LIKE = 1 # Most likes LATEST = 2 # Latest published class PublishTimeType(Enum): """publish time type""" UNLIMITED = 0 # Unlimited ONE_DAY = 1 # Within one day ONE_WEEK = 7 # Within one week SIX_MONTH = 180 # Within six months ================================================ FILE: media_platform/douyin/help.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/douyin/help.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Name: Programmer Ajiang-Relakkes # @Time : 2024/6/10 02:24 # @Desc : Get a_bogus parameter, for learning and communication only, do not use for commercial purposes, contact author to delete if infringement import random import re from typing import Optional import execjs from playwright.async_api import Page from model.m_douyin import VideoUrlInfo, CreatorUrlInfo from tools.crawler_util import extract_url_params_to_dict douyin_sign_obj = execjs.compile(open('libs/douyin.js', encoding='utf-8-sig').read()) def get_web_id(): """ Generate random webid Returns: """ def e(t): if t is not None: return str(t ^ (int(16 * random.random()) >> (t // 4))) else: return ''.join( [str(int(1e7)), '-', str(int(1e3)), '-', str(int(4e3)), '-', str(int(8e3)), '-', str(int(1e11))] ) web_id = ''.join( e(int(x)) if x in '018' else x for x in e(None) ) return web_id.replace('-', '')[:19] async def get_a_bogus(url: str, params: str, post_data: dict, user_agent: str, page: Page = None): """ Get a_bogus parameter, currently does not support POST request type signature """ return get_a_bogus_from_js(url, params, user_agent) def get_a_bogus_from_js(url: str, params: str, user_agent: str): """ Get a_bogus parameter through js Args: url: params: user_agent: Returns: """ sign_js_name = "sign_datail" if "/reply" in url: sign_js_name = "sign_reply" return douyin_sign_obj.call(sign_js_name, params, user_agent) async def get_a_bogus_from_playwright(params: str, post_data: dict, user_agent: str, page: Page): """ Get a_bogus parameter through playwright playwright version is deprecated Returns: """ if not post_data: post_data = "" a_bogus = await page.evaluate( "([params, post_data, ua]) => window.bdms.init._v[2].p[42].apply(null, [0, 1, 8, params, post_data, ua])", [params, post_data, user_agent]) return a_bogus def parse_video_info_from_url(url: str) -> VideoUrlInfo: """ Parse video ID from Douyin video URL Supports the following formats: 1. Normal video link: https://www.douyin.com/video/7525082444551310602 2. Link with modal_id parameter: - https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE?modal_id=7525082444551310602 - https://www.douyin.com/root/search/python?modal_id=7471165520058862848 3. Short link: https://v.douyin.com/iF12345ABC/ (requires client parsing) 4. Pure ID: 7525082444551310602 Args: url: Douyin video link or ID Returns: VideoUrlInfo: Object containing video ID """ # If it's a pure numeric ID, return directly if url.isdigit(): return VideoUrlInfo(aweme_id=url, url_type="normal") # Check if it's a short link (v.douyin.com) if "v.douyin.com" in url or url.startswith("http") and len(url) < 50 and "video" not in url: return VideoUrlInfo(aweme_id="", url_type="short") # Requires client parsing # Try to extract modal_id from URL parameters params = extract_url_params_to_dict(url) modal_id = params.get("modal_id") if modal_id: return VideoUrlInfo(aweme_id=modal_id, url_type="modal") # Extract ID from standard video URL: /video/number video_pattern = r'/video/(\d+)' match = re.search(video_pattern, url) if match: aweme_id = match.group(1) return VideoUrlInfo(aweme_id=aweme_id, url_type="normal") raise ValueError(f"Unable to parse video ID from URL: {url}") def parse_creator_info_from_url(url: str) -> CreatorUrlInfo: """ Parse creator ID (sec_user_id) from Douyin creator homepage URL Supports the following formats: 1. Creator homepage: https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE?from_tab_name=main 2. Pure ID: MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE Args: url: Douyin creator homepage link or sec_user_id Returns: CreatorUrlInfo: Object containing creator ID """ # If it's a pure ID format (usually starts with MS4wLjABAAAA), return directly if url.startswith("MS4wLjABAAAA") or (not url.startswith("http") and "douyin.com" not in url): return CreatorUrlInfo(sec_user_id=url) # Extract sec_user_id from creator homepage URL: /user/xxx user_pattern = r'/user/([^/?]+)' match = re.search(user_pattern, url) if match: sec_user_id = match.group(1) return CreatorUrlInfo(sec_user_id=sec_user_id) raise ValueError(f"Unable to parse creator ID from URL: {url}") if __name__ == '__main__': # Test video URL parsing print("=== Video URL Parsing Test ===") test_urls = [ "https://www.douyin.com/video/7525082444551310602", "https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE?from_tab_name=main&modal_id=7525082444551310602", "https://www.douyin.com/root/search/python?aid=b733a3b0-4662-4639-9a72-c2318fba9f3f&modal_id=7471165520058862848&type=general", "7525082444551310602", ] for url in test_urls: try: result = parse_video_info_from_url(url) print(f"✓ URL: {url[:80]}...") print(f" Result: {result}\n") except Exception as e: print(f"✗ URL: {url}") print(f" Error: {e}\n") # Test creator URL parsing print("=== Creator URL Parsing Test ===") test_creator_urls = [ "https://www.douyin.com/user/MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE?from_tab_name=main", "MS4wLjABAAAATJPY7LAlaa5X-c8uNdWkvz0jUGgpw4eeXIwu_8BhvqE", ] for url in test_creator_urls: try: result = parse_creator_info_from_url(url) print(f"✓ URL: {url[:80]}...") print(f" Result: {result}\n") except Exception as e: print(f"✗ URL: {url}") print(f" Error: {e}\n") ================================================ FILE: media_platform/douyin/login.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/douyin/login.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import asyncio import functools import sys from typing import Optional from playwright.async_api import BrowserContext, Page from playwright.async_api import TimeoutError as PlaywrightTimeoutError from tenacity import (RetryError, retry, retry_if_result, stop_after_attempt, wait_fixed) import config from base.base_crawler import AbstractLogin from cache.cache_factory import CacheFactory from tools import utils class DouYinLogin(AbstractLogin): def __init__(self, login_type: str, browser_context: BrowserContext, # type: ignore context_page: Page, # type: ignore login_phone: Optional[str] = "", cookie_str: Optional[str] = "" ): config.LOGIN_TYPE = login_type self.browser_context = browser_context self.context_page = context_page self.login_phone = login_phone self.scan_qrcode_time = 60 self.cookie_str = cookie_str async def begin(self): """ Start login douyin website The verification accuracy of the slider verification is not very good... If there are no special requirements, it is recommended not to use Douyin login, or use cookie login """ # popup login dialog await self.popup_login_dialog() # select login type if config.LOGIN_TYPE == "qrcode": await self.login_by_qrcode() elif config.LOGIN_TYPE == "phone": await self.login_by_mobile() elif config.LOGIN_TYPE == "cookie": await self.login_by_cookies() else: raise ValueError("[DouYinLogin.begin] Invalid Login Type Currently only supported qrcode or phone or cookie ...") # If the page redirects to the slider verification page, need to slide again await asyncio.sleep(6) current_page_title = await self.context_page.title() if "验证码中间页" in current_page_title: await self.check_page_display_slider(move_step=3, slider_level="hard") # check login state utils.logger.info(f"[DouYinLogin.begin] login finished then check login state ...") try: await self.check_login_state() except RetryError: utils.logger.info("[DouYinLogin.begin] login failed please confirm ...") sys.exit() # wait for redirect wait_redirect_seconds = 5 utils.logger.info(f"[DouYinLogin.begin] Login successful then wait for {wait_redirect_seconds} seconds redirect ...") await asyncio.sleep(wait_redirect_seconds) @retry(stop=stop_after_attempt(600), wait=wait_fixed(1), retry=retry_if_result(lambda value: value is False)) async def check_login_state(self): """Check if the current login status is successful and return True otherwise return False""" current_cookie = await self.browser_context.cookies() _, cookie_dict = utils.convert_cookies(current_cookie) for page in self.browser_context.pages: try: local_storage = await page.evaluate("() => window.localStorage") if local_storage.get("HasUserLogin", "") == "1": return True except Exception as e: # utils.logger.warn(f"[DouYinLogin] check_login_state waring: {e}") await asyncio.sleep(0.1) if cookie_dict.get("LOGIN_STATUS") == "1": return True return False async def popup_login_dialog(self): """If the login dialog box does not pop up automatically, we will manually click the login button""" dialog_selector = "xpath=//div[@id='login-panel-new']" try: # check dialog box is auto popup and wait for 10 seconds await self.context_page.wait_for_selector(dialog_selector, timeout=1000 * 10) except Exception as e: utils.logger.error(f"[DouYinLogin.popup_login_dialog] login dialog box does not pop up automatically, error: {e}") utils.logger.info("[DouYinLogin.popup_login_dialog] login dialog box does not pop up automatically, we will manually click the login button") login_button_ele = self.context_page.locator("xpath=//p[text() = '登录']") await login_button_ele.click() await asyncio.sleep(0.5) async def login_by_qrcode(self): utils.logger.info("[DouYinLogin.login_by_qrcode] Begin login douyin by qrcode...") qrcode_img_selector = "xpath=//div[@id='animate_qrcode_container']//img" base64_qrcode_img = await utils.find_login_qrcode( self.context_page, selector=qrcode_img_selector ) if not base64_qrcode_img: utils.logger.info("[DouYinLogin.login_by_qrcode] login qrcode not found please confirm ...") sys.exit() partial_show_qrcode = functools.partial(utils.show_qrcode, base64_qrcode_img) asyncio.get_running_loop().run_in_executor(executor=None, func=partial_show_qrcode) await asyncio.sleep(2) async def login_by_mobile(self): utils.logger.info("[DouYinLogin.login_by_mobile] Begin login douyin by mobile ...") mobile_tap_ele = self.context_page.locator("xpath=//li[text() = '验证码登录']") await mobile_tap_ele.click() await self.context_page.wait_for_selector("xpath=//article[@class='web-login-mobile-code']") mobile_input_ele = self.context_page.locator("xpath=//input[@placeholder='手机号']") await mobile_input_ele.fill(self.login_phone) await asyncio.sleep(0.5) send_sms_code_btn = self.context_page.locator("xpath=//span[text() = '获取验证码']") await send_sms_code_btn.click() # Check if there is slider verification await self.check_page_display_slider(move_step=10, slider_level="easy") cache_client = CacheFactory.create_cache(config.CACHE_TYPE_MEMORY) max_get_sms_code_time = 60 * 2 # Maximum time to get verification code is 2 minutes while max_get_sms_code_time > 0: utils.logger.info(f"[DouYinLogin.login_by_mobile] get douyin sms code from redis remaining time {max_get_sms_code_time}s ...") await asyncio.sleep(1) sms_code_key = f"dy_{self.login_phone}" sms_code_value = cache_client.get(sms_code_key) if not sms_code_value: max_get_sms_code_time -= 1 continue sms_code_input_ele = self.context_page.locator("xpath=//input[@placeholder='请输入验证码']") await sms_code_input_ele.fill(value=sms_code_value.decode()) await asyncio.sleep(0.5) submit_btn_ele = self.context_page.locator("xpath=//button[@class='web-login-button']") await submit_btn_ele.click() # Click login # todo ... should also check the correctness of the verification code, it may be incorrect break async def check_page_display_slider(self, move_step: int = 10, slider_level: str = "easy"): """ Check if slider verification appears on the page :return: """ # Wait for slider verification to appear back_selector = "#captcha-verify-image" try: await self.context_page.wait_for_selector(selector=back_selector, state="visible", timeout=30 * 1000) except PlaywrightTimeoutError: # No slider verification, return directly return gap_selector = 'xpath=//*[@id="captcha_container"]/div/div[2]/img[2]' max_slider_try_times = 20 slider_verify_success = False while not slider_verify_success: if max_slider_try_times <= 0: utils.logger.error("[DouYinLogin.check_page_display_slider] slider verify failed ...") sys.exit() try: await self.move_slider(back_selector, gap_selector, move_step, slider_level) await asyncio.sleep(1) # If the slider is too slow or verification failed, it will prompt "The operation is too slow", click the refresh button here page_content = await self.context_page.content() if "操作过慢" in page_content or "提示重新操作" in page_content: utils.logger.info("[DouYinLogin.check_page_display_slider] slider verify failed, retry ...") await self.context_page.click(selector="//a[contains(@class, 'secsdk_captcha_refresh')]") continue # After successful sliding, wait for the slider to disappear await self.context_page.wait_for_selector(selector=back_selector, state="hidden", timeout=1000) # If the slider disappears, it means the verification is successful, break the loop. If not, it means the verification failed, the above line will throw an exception and be caught to continue the loop utils.logger.info("[DouYinLogin.check_page_display_slider] slider verify success ...") slider_verify_success = True except Exception as e: utils.logger.error(f"[DouYinLogin.check_page_display_slider] slider verify failed, error: {e}") await asyncio.sleep(1) max_slider_try_times -= 1 utils.logger.info(f"[DouYinLogin.check_page_display_slider] remaining slider try times: {max_slider_try_times}") continue async def move_slider(self, back_selector: str, gap_selector: str, move_step: int = 10, slider_level="easy"): """ Move the slider to the right to complete the verification :param back_selector: Selector for the slider verification background image :param gap_selector: Selector for the slider verification slider :param move_step: Controls the ratio of single movement speed, default is 1, meaning the distance moves in 0.1 seconds no matter how far, larger value means slower :param slider_level: Slider difficulty easy hard, corresponding to the slider for mobile verification code and the slider in the middle of verification code :return: """ # get slider background image slider_back_elements = await self.context_page.wait_for_selector( selector=back_selector, timeout=1000 * 10, # wait 10 seconds ) slide_back = str(await slider_back_elements.get_property("src")) # type: ignore # get slider gap image gap_elements = await self.context_page.wait_for_selector( selector=gap_selector, timeout=1000 * 10, # wait 10 seconds ) gap_src = str(await gap_elements.get_property("src")) # type: ignore # Identify slider position slide_app = utils.Slide(gap=gap_src, bg=slide_back) distance = slide_app.discern() # Get movement trajectory tracks = utils.get_tracks(distance, slider_level) new_1 = tracks[-1] - (sum(tracks) - distance) tracks.pop() tracks.append(new_1) # Drag slider to specified position according to trajectory element = await self.context_page.query_selector(gap_selector) bounding_box = await element.bounding_box() # type: ignore await self.context_page.mouse.move(bounding_box["x"] + bounding_box["width"] / 2, # type: ignore bounding_box["y"] + bounding_box["height"] / 2) # type: ignore # Get x coordinate center position x = bounding_box["x"] + bounding_box["width"] / 2 # type: ignore # Simulate sliding operation await element.hover() # type: ignore await self.context_page.mouse.down() for track in tracks: # Loop mouse movement according to trajectory # steps controls the ratio of single movement speed, default is 1, meaning the distance moves in 0.1 seconds no matter how far, larger value means slower await self.context_page.mouse.move(x + track, 0, steps=move_step) x += track await self.context_page.mouse.up() async def login_by_cookies(self): utils.logger.info("[DouYinLogin.login_by_cookies] Begin login douyin by cookie ...") for key, value in utils.convert_str_cookie_to_dict(self.cookie_str).items(): await self.browser_context.add_cookies([{ 'name': key, 'value': value, 'domain': ".douyin.com", 'path': "/" }]) ================================================ FILE: media_platform/kuaishou/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/kuaishou/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- from .core import KuaishouCrawler ================================================ FILE: media_platform/kuaishou/client.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/kuaishou/client.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- import asyncio import json from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional from urllib.parse import urlencode import httpx from playwright.async_api import BrowserContext, Page import config from base.base_crawler import AbstractApiClient from proxy.proxy_mixin import ProxyRefreshMixin from tools import utils from tools.httpx_util import make_async_client if TYPE_CHECKING: from proxy.proxy_ip_pool import ProxyIpPool from .exception import DataFetchError from .graphql import KuaiShouGraphQL class KuaiShouClient(AbstractApiClient, ProxyRefreshMixin): def __init__( self, timeout=10, proxy=None, *, headers: Dict[str, str], playwright_page: Page, cookie_dict: Dict[str, str], proxy_ip_pool: Optional["ProxyIpPool"] = None, ): self.proxy = proxy self.timeout = timeout self.headers = headers self._host = "https://www.kuaishou.com/graphql" self._rest_host = "https://www.kuaishou.com" self.playwright_page = playwright_page self.cookie_dict = cookie_dict self.graphql = KuaiShouGraphQL() # Initialize proxy pool (from ProxyRefreshMixin) self.init_proxy_pool(proxy_ip_pool) async def request(self, method, url, **kwargs) -> Any: # Check if proxy is expired before each request await self._refresh_proxy_if_expired() async with make_async_client(proxy=self.proxy) as client: response = await client.request(method, url, timeout=self.timeout, **kwargs) data: Dict = response.json() if data.get("errors"): raise DataFetchError(data.get("errors", "unkonw error")) else: return data.get("data", {}) async def get(self, uri: str, params=None) -> Dict: final_uri = uri if isinstance(params, dict): final_uri = f"{uri}?" f"{urlencode(params)}" return await self.request( method="GET", url=f"{self._host}{final_uri}", headers=self.headers ) async def post(self, uri: str, data: dict) -> Dict: json_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False) return await self.request( method="POST", url=f"{self._host}{uri}", data=json_str, headers=self.headers ) async def request_rest_v2(self, uri: str, data: dict) -> Dict: """ Make REST API V2 request (for comment endpoints) :param uri: API endpoint path :param data: request body :return: response data """ await self._refresh_proxy_if_expired() json_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False) async with make_async_client(proxy=self.proxy) as client: response = await client.request( method="POST", url=f"{self._rest_host}{uri}", data=json_str, timeout=self.timeout, headers=self.headers, ) result: Dict = response.json() if result.get("result") != 1: raise DataFetchError(f"REST API V2 error: {result}") return result async def pong(self) -> bool: """get a note to check if login state is ok""" utils.logger.info("[KuaiShouClient.pong] Begin pong kuaishou...") ping_flag = False try: post_data = { "operationName": "visionProfileUserList", "variables": { "ftype": 1, }, "query": self.graphql.get("vision_profile_user_list"), } res = await self.post("", post_data) if res.get("visionProfileUserList", {}).get("result") == 1: ping_flag = True except Exception as e: utils.logger.error( f"[KuaiShouClient.pong] Pong kuaishou failed: {e}, and try to login again..." ) ping_flag = False return ping_flag async def update_cookies(self, browser_context: BrowserContext): cookie_str, cookie_dict = utils.convert_cookies(await browser_context.cookies()) self.headers["Cookie"] = cookie_str self.cookie_dict = cookie_dict async def search_info_by_keyword( self, keyword: str, pcursor: str, search_session_id: str = "" ): """ KuaiShou web search api :param keyword: search keyword :param pcursor: limite page curson :param search_session_id: search session id :return: """ post_data = { "operationName": "visionSearchPhoto", "variables": { "keyword": keyword, "pcursor": pcursor, "page": "search", "searchSessionId": search_session_id, }, "query": self.graphql.get("search_query"), } return await self.post("", post_data) async def get_video_info(self, photo_id: str) -> Dict: """ Kuaishou web video detail api :param photo_id: :return: """ post_data = { "operationName": "visionVideoDetail", "variables": {"photoId": photo_id, "page": "search"}, "query": self.graphql.get("video_detail"), } return await self.post("", post_data) async def get_video_comments(self, photo_id: str, pcursor: str = "") -> Dict: """Get video first-level comments using REST API V2 :param photo_id: video id you want to fetch :param pcursor: pagination cursor, defaults to "" :return: dict with rootCommentsV2, pcursorV2, commentCountV2 """ post_data = { "photoId": photo_id, "pcursor": pcursor, } return await self.request_rest_v2("/rest/v/photo/comment/list", post_data) async def get_video_sub_comments( self, photo_id: str, root_comment_id: int, pcursor: str = "" ) -> Dict: """Get video second-level comments using REST API V2 :param photo_id: video id you want to fetch :param root_comment_id: parent comment id (must be int type) :param pcursor: pagination cursor, defaults to "" :return: dict with subCommentsV2, pcursorV2 """ post_data = { "photoId": photo_id, "pcursor": pcursor, "rootCommentId": root_comment_id, # Must be int type for V2 API } return await self.request_rest_v2("/rest/v/photo/comment/sublist", post_data) async def get_creator_profile(self, userId: str) -> Dict: post_data = { "operationName": "visionProfile", "variables": {"userId": userId}, "query": self.graphql.get("vision_profile"), } return await self.post("", post_data) async def get_video_by_creater(self, userId: str, pcursor: str = "") -> Dict: post_data = { "operationName": "visionProfilePhotoList", "variables": {"page": "profile", "pcursor": pcursor, "userId": userId}, "query": self.graphql.get("vision_profile_photo_list"), } return await self.post("", post_data) async def get_video_all_comments( self, photo_id: str, crawl_interval: float = 1.0, callback: Optional[Callable] = None, max_count: int = 10, ): """ Get video all comments including sub comments (V2 REST API) :param photo_id: video id :param crawl_interval: delay between requests (seconds) :param callback: callback function for processing comments :param max_count: max number of comments to fetch :return: list of all comments """ result = [] pcursor = "" while pcursor != "no_more" and len(result) < max_count: comments_res = await self.get_video_comments(photo_id, pcursor) # V2 API returns data at top level, not nested in visionCommentList pcursor = comments_res.get("pcursorV2", "no_more") comments = comments_res.get("rootCommentsV2", []) if len(result) + len(comments) > max_count: comments = comments[: max_count - len(result)] if callback: # If there is a callback function, execute the callback function await callback(photo_id, comments) result.extend(comments) await asyncio.sleep(crawl_interval) sub_comments = await self.get_comments_all_sub_comments( comments, photo_id, crawl_interval, callback ) result.extend(sub_comments) return result async def get_comments_all_sub_comments( self, comments: List[Dict], photo_id, crawl_interval: float = 1.0, callback: Optional[Callable] = None, ) -> List[Dict]: """ Get all second-level comments under specified first-level comments (V2 REST API) Args: comments: Comment list photo_id: Video ID crawl_interval: Delay unit for crawling comments once (seconds) callback: Callback after one comment crawl ends Returns: List of sub comments """ if not config.ENABLE_GET_SUB_COMMENTS: utils.logger.info( f"[KuaiShouClient.get_comments_all_sub_comments] Crawling sub_comment mode is not enabled" ) return [] result = [] for comment in comments: # V2 API uses hasSubComments (boolean) instead of subCommentsPcursor (string) has_sub_comments = comment.get("hasSubComments", False) if not has_sub_comments: continue # V2 API uses comment_id (int) instead of commentId (string) root_comment_id = comment.get("comment_id") if not root_comment_id: continue sub_comment_pcursor = "" while sub_comment_pcursor != "no_more": comments_res = await self.get_video_sub_comments( photo_id, root_comment_id, sub_comment_pcursor ) # V2 API returns data at top level sub_comment_pcursor = comments_res.get("pcursorV2", "no_more") sub_comments = comments_res.get("subCommentsV2", []) if callback and sub_comments: await callback(photo_id, sub_comments) await asyncio.sleep(crawl_interval) result.extend(sub_comments) return result async def get_creator_info(self, user_id: str) -> Dict: """ eg: https://www.kuaishou.com/profile/3x4jtnbfter525a Kuaishou user homepage """ visionProfile = await self.get_creator_profile(user_id) return visionProfile.get("userProfile") async def get_all_videos_by_creator( self, user_id: str, crawl_interval: float = 1.0, callback: Optional[Callable] = None, ) -> List[Dict]: """ Get all posts published by the specified user, this method will continue to find all post information under a user Args: user_id: User ID crawl_interval: Delay unit for crawling once (seconds) callback: Update callback function after one page crawl ends Returns: """ result = [] pcursor = "" while pcursor != "no_more": videos_res = await self.get_video_by_creater(user_id, pcursor) if not videos_res: utils.logger.error( f"[KuaiShouClient.get_all_videos_by_creator] The current creator may have been banned by ks, so they cannot access the data." ) break vision_profile_photo_list = videos_res.get("visionProfilePhotoList", {}) pcursor = vision_profile_photo_list.get("pcursor", "") videos = vision_profile_photo_list.get("feeds", []) utils.logger.info( f"[KuaiShouClient.get_all_videos_by_creator] got user_id:{user_id} videos len : {len(videos)}" ) if callback: await callback(videos) await asyncio.sleep(crawl_interval) result.extend(videos) return result ================================================ FILE: media_platform/kuaishou/core.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/kuaishou/core.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import asyncio import os # import random # Removed as we now use fixed config.CRAWLER_MAX_SLEEP_SEC intervals import time from asyncio import Task from typing import Dict, List, Optional, Tuple from playwright.async_api import ( BrowserContext, BrowserType, Page, Playwright, async_playwright, ) import config from base.base_crawler import AbstractCrawler from model.m_kuaishou import VideoUrlInfo, CreatorUrlInfo from proxy.proxy_ip_pool import IpInfoModel, create_ip_pool from store import kuaishou as kuaishou_store from tools import utils from tools.cdp_browser import CDPBrowserManager from var import comment_tasks_var, crawler_type_var, source_keyword_var from .client import KuaiShouClient from .exception import DataFetchError from .help import parse_video_info_from_url, parse_creator_info_from_url from .login import KuaishouLogin class KuaishouCrawler(AbstractCrawler): context_page: Page ks_client: KuaiShouClient browser_context: BrowserContext cdp_manager: Optional[CDPBrowserManager] def __init__(self): self.index_url = "https://www.kuaishou.com" self.user_agent = utils.get_user_agent() self.cdp_manager = None self.ip_proxy_pool = None # Proxy IP pool, used for automatic proxy refresh async def start(self): playwright_proxy_format, httpx_proxy_format = None, None if config.ENABLE_IP_PROXY: self.ip_proxy_pool = await create_ip_pool( config.IP_PROXY_POOL_COUNT, enable_validate_ip=True ) ip_proxy_info: IpInfoModel = await self.ip_proxy_pool.get_proxy() playwright_proxy_format, httpx_proxy_format = utils.format_proxy_info( ip_proxy_info ) async with async_playwright() as playwright: # Select startup mode based on configuration if config.ENABLE_CDP_MODE: utils.logger.info("[KuaishouCrawler] Launching browser using CDP mode") self.browser_context = await self.launch_browser_with_cdp( playwright, playwright_proxy_format, self.user_agent, headless=config.CDP_HEADLESS, ) else: utils.logger.info("[KuaishouCrawler] Launching browser using standard mode") # Launch a browser context. chromium = playwright.chromium self.browser_context = await self.launch_browser( chromium, None, self.user_agent, headless=config.HEADLESS ) # stealth.min.js is a js script to prevent the website from detecting the crawler. await self.browser_context.add_init_script(path="libs/stealth.min.js") self.context_page = await self.browser_context.new_page() await self.context_page.goto(f"{self.index_url}?isHome=1") # Create a client to interact with the kuaishou website. self.ks_client = await self.create_ks_client(httpx_proxy_format) if not await self.ks_client.pong(): login_obj = KuaishouLogin( login_type=config.LOGIN_TYPE, login_phone=httpx_proxy_format, browser_context=self.browser_context, context_page=self.context_page, cookie_str=config.COOKIES, ) await login_obj.begin() await self.ks_client.update_cookies( browser_context=self.browser_context ) crawler_type_var.set(config.CRAWLER_TYPE) if config.CRAWLER_TYPE == "search": # Search for videos and retrieve their comment information. await self.search() elif config.CRAWLER_TYPE == "detail": # Get the information and comments of the specified post await self.get_specified_videos() elif config.CRAWLER_TYPE == "creator": # Get creator's information and their videos and comments await self.get_creators_and_videos() else: pass utils.logger.info("[KuaishouCrawler.start] Kuaishou Crawler finished ...") async def search(self): utils.logger.info("[KuaishouCrawler.search] Begin search kuaishou keywords") ks_limit_count = 20 # kuaishou limit page fixed value if config.CRAWLER_MAX_NOTES_COUNT < ks_limit_count: config.CRAWLER_MAX_NOTES_COUNT = ks_limit_count start_page = config.START_PAGE for keyword in config.KEYWORDS.split(","): search_session_id = "" source_keyword_var.set(keyword) utils.logger.info( f"[KuaishouCrawler.search] Current search keyword: {keyword}" ) page = 1 while ( page - start_page + 1 ) * ks_limit_count <= config.CRAWLER_MAX_NOTES_COUNT: if page < start_page: utils.logger.info(f"[KuaishouCrawler.search] Skip page: {page}") page += 1 continue utils.logger.info( f"[KuaishouCrawler.search] search kuaishou keyword: {keyword}, page: {page}" ) video_id_list: List[str] = [] videos_res = await self.ks_client.search_info_by_keyword( keyword=keyword, pcursor=str(page), search_session_id=search_session_id, ) if not videos_res: utils.logger.error( f"[KuaishouCrawler.search] search info by keyword:{keyword} not found data" ) continue vision_search_photo: Dict = videos_res.get("visionSearchPhoto") if vision_search_photo.get("result") != 1: utils.logger.error( f"[KuaishouCrawler.search] search info by keyword:{keyword} not found data " ) continue search_session_id = vision_search_photo.get("searchSessionId", "") for video_detail in vision_search_photo.get("feeds"): video_id_list.append(video_detail.get("photo", {}).get("id")) await kuaishou_store.update_kuaishou_video(video_item=video_detail) # batch fetch video comments page += 1 # Sleep after page navigation await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[KuaishouCrawler.search] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after page {page-1}") await self.batch_get_video_comments(video_id_list) async def get_specified_videos(self): """Get the information and comments of the specified post""" utils.logger.info("[KuaishouCrawler.get_specified_videos] Parsing video URLs...") video_ids = [] for video_url in config.KS_SPECIFIED_ID_LIST: try: video_info = parse_video_info_from_url(video_url) video_ids.append(video_info.video_id) utils.logger.info(f"Parsed video ID: {video_info.video_id} from {video_url}") except ValueError as e: utils.logger.error(f"Failed to parse video URL: {e}") continue semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list = [ self.get_video_info_task(video_id=video_id, semaphore=semaphore) for video_id in video_ids ] video_details = await asyncio.gather(*task_list) for video_detail in video_details: if video_detail is not None: await kuaishou_store.update_kuaishou_video(video_detail) await self.batch_get_video_comments(video_ids) async def get_video_info_task( self, video_id: str, semaphore: asyncio.Semaphore ) -> Optional[Dict]: """Get video detail task""" async with semaphore: try: result = await self.ks_client.get_video_info(video_id) # Sleep after fetching video details await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[KuaishouCrawler.get_video_info_task] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after fetching video details {video_id}") utils.logger.info( f"[KuaishouCrawler.get_video_info_task] Get video_id:{video_id} info result: {result} ..." ) return result.get("visionVideoDetail") except DataFetchError as ex: utils.logger.error( f"[KuaishouCrawler.get_video_info_task] Get video detail error: {ex}" ) return None except KeyError as ex: utils.logger.error( f"[KuaishouCrawler.get_video_info_task] have not fund video detail video_id:{video_id}, err: {ex}" ) return None async def batch_get_video_comments(self, video_id_list: List[str]): """ batch get video comments :param video_id_list: :return: """ if not config.ENABLE_GET_COMMENTS: utils.logger.info( f"[KuaishouCrawler.batch_get_video_comments] Crawling comment mode is not enabled" ) return utils.logger.info( f"[KuaishouCrawler.batch_get_video_comments] video ids:{video_id_list}" ) semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list: List[Task] = [] for video_id in video_id_list: task = asyncio.create_task( self.get_comments(video_id, semaphore), name=video_id ) task_list.append(task) comment_tasks_var.set(task_list) await asyncio.gather(*task_list) async def get_comments(self, video_id: str, semaphore: asyncio.Semaphore): """ get comment for video id :param video_id: :param semaphore: :return: """ async with semaphore: try: utils.logger.info( f"[KuaishouCrawler.get_comments] begin get video_id: {video_id} comments ..." ) # Sleep before fetching comments await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[KuaishouCrawler.get_comments] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds before fetching comments for video {video_id}") await self.ks_client.get_video_all_comments( photo_id=video_id, crawl_interval=config.CRAWLER_MAX_SLEEP_SEC, callback=kuaishou_store.batch_update_ks_video_comments, max_count=config.CRAWLER_MAX_COMMENTS_COUNT_SINGLENOTES, ) except DataFetchError as ex: utils.logger.error( f"[KuaishouCrawler.get_comments] get video_id: {video_id} comment error: {ex}" ) except Exception as e: utils.logger.error( f"[KuaishouCrawler.get_comments] may be been blocked, err:{e}" ) # use time.sleeep block main coroutine instead of asyncio.sleep and cacel running comment task # maybe kuaishou block our request, we will take a nap and update the cookie again current_running_tasks = comment_tasks_var.get() for task in current_running_tasks: task.cancel() time.sleep(20) await self.context_page.goto(f"{self.index_url}?isHome=1") await self.ks_client.update_cookies( browser_context=self.browser_context ) async def create_ks_client(self, httpx_proxy: Optional[str]) -> KuaiShouClient: """Create ks client""" utils.logger.info( "[KuaishouCrawler.create_ks_client] Begin create kuaishou API client ..." ) cookie_str, cookie_dict = utils.convert_cookies( await self.browser_context.cookies() ) ks_client_obj = KuaiShouClient( proxy=httpx_proxy, headers={ "User-Agent": self.user_agent, "Cookie": cookie_str, "Origin": self.index_url, "Referer": self.index_url, "Content-Type": "application/json;charset=UTF-8", }, playwright_page=self.context_page, cookie_dict=cookie_dict, proxy_ip_pool=self.ip_proxy_pool, # Pass proxy pool for automatic refresh ) return ks_client_obj async def launch_browser( self, chromium: BrowserType, playwright_proxy: Optional[Dict], user_agent: Optional[str], headless: bool = True, ) -> BrowserContext: """Launch browser and create browser context""" utils.logger.info( "[KuaishouCrawler.launch_browser] Begin create browser context ..." ) if config.SAVE_LOGIN_STATE: user_data_dir = os.path.join( os.getcwd(), "browser_data", config.USER_DATA_DIR % config.PLATFORM ) # type: ignore browser_context = await chromium.launch_persistent_context( user_data_dir=user_data_dir, accept_downloads=True, headless=headless, proxy=playwright_proxy, # type: ignore viewport={"width": 1920, "height": 1080}, user_agent=user_agent, channel="chrome", # Use system's stable Chrome version ) return browser_context else: browser = await chromium.launch(headless=headless, proxy=playwright_proxy, channel="chrome") # type: ignore browser_context = await browser.new_context( viewport={"width": 1920, "height": 1080}, user_agent=user_agent ) return browser_context async def launch_browser_with_cdp( self, playwright: Playwright, playwright_proxy: Optional[Dict], user_agent: Optional[str], headless: bool = True, ) -> BrowserContext: """ Launch browser using CDP mode """ try: self.cdp_manager = CDPBrowserManager() browser_context = await self.cdp_manager.launch_and_connect( playwright=playwright, playwright_proxy=playwright_proxy, user_agent=user_agent, headless=headless, ) # Display browser information browser_info = await self.cdp_manager.get_browser_info() utils.logger.info(f"[KuaishouCrawler] CDP browser info: {browser_info}") return browser_context except Exception as e: utils.logger.error( f"[KuaishouCrawler] CDP mode launch failed, fallback to standard mode: {e}" ) # Fallback to standard mode chromium = playwright.chromium return await self.launch_browser( chromium, playwright_proxy, user_agent, headless ) async def get_creators_and_videos(self) -> None: """Get creator's videos and retrieve their comment information.""" utils.logger.info( "[KuaiShouCrawler.get_creators_and_videos] Begin get kuaishou creators" ) for creator_url in config.KS_CREATOR_ID_LIST: try: # Parse creator URL to get user_id creator_info: CreatorUrlInfo = parse_creator_info_from_url(creator_url) utils.logger.info(f"[KuaiShouCrawler.get_creators_and_videos] Parse creator URL info: {creator_info}") user_id = creator_info.user_id # get creator detail info from web html content createor_info: Dict = await self.ks_client.get_creator_info(user_id=user_id) if createor_info: await kuaishou_store.save_creator(user_id, creator=createor_info) except ValueError as e: utils.logger.error(f"[KuaiShouCrawler.get_creators_and_videos] Failed to parse creator URL: {e}") continue # Get all video information of the creator all_video_list = await self.ks_client.get_all_videos_by_creator( user_id=user_id, crawl_interval=config.CRAWLER_MAX_SLEEP_SEC, callback=self.fetch_creator_video_detail, ) video_ids = [ video_item.get("photo", {}).get("id") for video_item in all_video_list ] await self.batch_get_video_comments(video_ids) async def fetch_creator_video_detail(self, video_list: List[Dict]): """ Concurrently obtain the specified post list and save the data """ semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list = [ self.get_video_info_task(post_item.get("photo", {}).get("id"), semaphore) for post_item in video_list ] video_details = await asyncio.gather(*task_list) for video_detail in video_details: if video_detail is not None: await kuaishou_store.update_kuaishou_video(video_detail) async def close(self): """Close browser context""" # If using CDP mode, need special handling if self.cdp_manager: await self.cdp_manager.cleanup() self.cdp_manager = None else: await self.browser_context.close() utils.logger.info("[KuaishouCrawler.close] Browser context closed ...") ================================================ FILE: media_platform/kuaishou/exception.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/kuaishou/exception.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from httpx import RequestError class DataFetchError(RequestError): """something error when fetch""" class IPBlockError(RequestError): """fetch so fast that the server block us ip""" ================================================ FILE: media_platform/kuaishou/field.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/kuaishou/field.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- ================================================ FILE: media_platform/kuaishou/graphql/comment_list.graphql ================================================ query commentListQuery($photoId: String, $pcursor: String) { visionCommentList(photoId: $photoId, pcursor: $pcursor) { commentCount pcursor rootComments { commentId authorId authorName content headurl timestamp likedCount realLikedCount liked status authorLiked subCommentCount subCommentsPcursor subComments { commentId authorId authorName content headurl timestamp likedCount realLikedCount liked status authorLiked replyToUserName replyTo __typename } __typename } __typename } } ================================================ FILE: media_platform/kuaishou/graphql/search_query.graphql ================================================ fragment photoContent on PhotoEntity { __typename id duration caption originCaption likeCount viewCount commentCount realLikeCount coverUrl photoUrl photoH265Url manifest manifestH265 videoResource coverUrls { url __typename } timestamp expTag animatedCoverUrl distance videoRatio liked stereoType profileUserTopPhoto musicBlocked } fragment recoPhotoFragment on recoPhotoEntity { __typename id duration caption originCaption likeCount viewCount commentCount realLikeCount coverUrl photoUrl photoH265Url manifest manifestH265 videoResource coverUrls { url __typename } timestamp expTag animatedCoverUrl distance videoRatio liked stereoType profileUserTopPhoto musicBlocked } fragment feedContent on Feed { type author { id name headerUrl following headerUrls { url __typename } __typename } photo { ...photoContent ...recoPhotoFragment __typename } canAddComment llsid status currentPcursor tags { type name __typename } __typename } query visionSearchPhoto($keyword: String, $pcursor: String, $searchSessionId: String, $page: String, $webPageArea: String) { visionSearchPhoto(keyword: $keyword, pcursor: $pcursor, searchSessionId: $searchSessionId, page: $page, webPageArea: $webPageArea) { result llsid webPageArea feeds { ...feedContent __typename } searchSessionId pcursor aladdinBanner { imgUrl link __typename } __typename } } ================================================ FILE: media_platform/kuaishou/graphql/video_detail.graphql ================================================ query visionVideoDetail($photoId: String, $type: String, $page: String, $webPageArea: String) { visionVideoDetail(photoId: $photoId, type: $type, page: $page, webPageArea: $webPageArea) { status type author { id name following headerUrl __typename } photo { id duration caption likeCount realLikeCount coverUrl photoUrl liked timestamp expTag llsid viewCount videoRatio stereoType musicBlocked manifest { mediaType businessType version adaptationSet { id duration representation { id defaultSelect backupUrl codecs url height width avgBitrate maxBitrate m3u8Slice qualityType qualityLabel frameRate featureP2sp hidden disableAdaptive __typename } __typename } __typename } manifestH265 photoH265Url coronaCropManifest coronaCropManifestH265 croppedPhotoH265Url croppedPhotoUrl videoResource __typename } tags { type name __typename } commentLimit { canAddComment __typename } llsid danmakuSwitch __typename } } ================================================ FILE: media_platform/kuaishou/graphql/vision_profile.graphql ================================================ query visionProfile($userId: String) { visionProfile(userId: $userId) { result hostName userProfile { ownerCount { fan photo follow photo_public __typename } profile { gender user_name user_id headurl user_text user_profile_bg_url __typename } isFollowing __typename } __typename } } ================================================ FILE: media_platform/kuaishou/graphql/vision_profile_photo_list.graphql ================================================ fragment photoContent on PhotoEntity { __typename id duration caption originCaption likeCount viewCount commentCount realLikeCount coverUrl photoUrl photoH265Url manifest manifestH265 videoResource coverUrls { url __typename } timestamp expTag animatedCoverUrl distance videoRatio liked stereoType profileUserTopPhoto musicBlocked riskTagContent riskTagUrl } fragment recoPhotoFragment on recoPhotoEntity { __typename id duration caption originCaption likeCount viewCount commentCount realLikeCount coverUrl photoUrl photoH265Url manifest manifestH265 videoResource coverUrls { url __typename } timestamp expTag animatedCoverUrl distance videoRatio liked stereoType profileUserTopPhoto musicBlocked riskTagContent riskTagUrl } fragment feedContent on Feed { type author { id name headerUrl following headerUrls { url __typename } __typename } photo { ...photoContent ...recoPhotoFragment __typename } canAddComment llsid status currentPcursor tags { type name __typename } __typename } query visionProfilePhotoList($pcursor: String, $userId: String, $page: String, $webPageArea: String) { visionProfilePhotoList(pcursor: $pcursor, userId: $userId, page: $page, webPageArea: $webPageArea) { result llsid webPageArea feeds { ...feedContent __typename } hostName pcursor __typename } } ================================================ FILE: media_platform/kuaishou/graphql/vision_profile_user_list.graphql ================================================ query visionProfileUserList($pcursor: String, $ftype: Int) { visionProfileUserList(pcursor: $pcursor, ftype: $ftype) { result fols { user_name headurl user_text isFollowing user_id __typename } hostName pcursor __typename } } ================================================ FILE: media_platform/kuaishou/graphql/vision_sub_comment_list.graphql ================================================ mutation visionSubCommentList($photoId: String, $rootCommentId: String, $pcursor: String) { visionSubCommentList(photoId: $photoId, rootCommentId: $rootCommentId, pcursor: $pcursor) { pcursor subComments { commentId authorId authorName content headurl timestamp likedCount realLikedCount liked status authorLiked replyToUserName replyTo __typename } __typename } } ================================================ FILE: media_platform/kuaishou/graphql.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/kuaishou/graphql.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # Kuaishou's data transmission is based on GraphQL # This class is responsible for obtaining some GraphQL schemas from typing import Dict class KuaiShouGraphQL: graphql_queries: Dict[str, str]= {} def __init__(self): self.graphql_dir = "media_platform/kuaishou/graphql/" self.load_graphql_queries() def load_graphql_queries(self): graphql_files = ["search_query.graphql", "video_detail.graphql", "comment_list.graphql", "vision_profile.graphql","vision_profile_photo_list.graphql","vision_profile_user_list.graphql","vision_sub_comment_list.graphql"] for file in graphql_files: with open(self.graphql_dir + file, mode="r") as f: query_name = file.split(".")[0] self.graphql_queries[query_name] = f.read() def get(self, query_name: str) -> str: return self.graphql_queries.get(query_name, "Query not found") ================================================ FILE: media_platform/kuaishou/help.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/kuaishou/help.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- import re from model.m_kuaishou import VideoUrlInfo, CreatorUrlInfo def parse_video_info_from_url(url: str) -> VideoUrlInfo: """ Parse video ID from Kuaishou video URL Supports the following formats: 1. Full video URL: "https://www.kuaishou.com/short-video/3x3zxz4mjrsc8ke?authorId=3x84qugg4ch9zhs&streamSource=search" 2. Pure video ID: "3x3zxz4mjrsc8ke" Args: url: Kuaishou video link or video ID Returns: VideoUrlInfo: Object containing video ID """ # If it doesn't contain http and doesn't contain kuaishou.com, consider it as pure ID if not url.startswith("http") and "kuaishou.com" not in url: return VideoUrlInfo(video_id=url, url_type="normal") # Extract ID from standard video URL: /short-video/video_ID video_pattern = r'/short-video/([a-zA-Z0-9_-]+)' match = re.search(video_pattern, url) if match: video_id = match.group(1) return VideoUrlInfo(video_id=video_id, url_type="normal") raise ValueError(f"Unable to parse video ID from URL: {url}") def parse_creator_info_from_url(url: str) -> CreatorUrlInfo: """ Parse creator ID from Kuaishou creator homepage URL Supports the following formats: 1. Creator homepage: "https://www.kuaishou.com/profile/3x84qugg4ch9zhs" 2. Pure ID: "3x4sm73aye7jq7i" Args: url: Kuaishou creator homepage link or user_id Returns: CreatorUrlInfo: Object containing creator ID """ # If it doesn't contain http and doesn't contain kuaishou.com, consider it as pure ID if not url.startswith("http") and "kuaishou.com" not in url: return CreatorUrlInfo(user_id=url) # Extract user_id from creator homepage URL: /profile/xxx user_pattern = r'/profile/([a-zA-Z0-9_-]+)' match = re.search(user_pattern, url) if match: user_id = match.group(1) return CreatorUrlInfo(user_id=user_id) raise ValueError(f"Unable to parse creator ID from URL: {url}") if __name__ == '__main__': # Test video URL parsing print("=== Video URL Parsing Test ===") test_video_urls = [ "https://www.kuaishou.com/short-video/3x3zxz4mjrsc8ke?authorId=3x84qugg4ch9zhs&streamSource=search&area=searchxxnull&searchKey=python", "3xf8enb8dbj6uig", ] for url in test_video_urls: try: result = parse_video_info_from_url(url) print(f"✓ URL: {url[:80]}...") print(f" Result: {result}\n") except Exception as e: print(f"✗ URL: {url}") print(f" Error: {e}\n") # Test creator URL parsing print("=== Creator URL Parsing Test ===") test_creator_urls = [ "https://www.kuaishou.com/profile/3x84qugg4ch9zhs", "3x4sm73aye7jq7i", ] for url in test_creator_urls: try: result = parse_creator_info_from_url(url) print(f"✓ URL: {url[:80]}...") print(f" Result: {result}\n") except Exception as e: print(f"✗ URL: {url}") print(f" Error: {e}\n") ================================================ FILE: media_platform/kuaishou/login.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/kuaishou/login.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import asyncio import functools import sys from typing import Optional from playwright.async_api import BrowserContext, Page from tenacity import (RetryError, retry, retry_if_result, stop_after_attempt, wait_fixed) import config from base.base_crawler import AbstractLogin from tools import utils class KuaishouLogin(AbstractLogin): def __init__(self, login_type: str, browser_context: BrowserContext, context_page: Page, login_phone: Optional[str] = "", cookie_str: str = "" ): config.LOGIN_TYPE = login_type self.browser_context = browser_context self.context_page = context_page self.login_phone = login_phone self.cookie_str = cookie_str async def begin(self): """Start login xiaohongshu""" utils.logger.info("[KuaishouLogin.begin] Begin login kuaishou ...") if config.LOGIN_TYPE == "qrcode": await self.login_by_qrcode() elif config.LOGIN_TYPE == "phone": await self.login_by_mobile() elif config.LOGIN_TYPE == "cookie": await self.login_by_cookies() else: raise ValueError("[KuaishouLogin.begin] Invalid Login Type Currently only supported qrcode or phone or cookie ...") @retry(stop=stop_after_attempt(600), wait=wait_fixed(1), retry=retry_if_result(lambda value: value is False)) async def check_login_state(self) -> bool: """ Check if the current login status is successful and return True otherwise return False retry decorator will retry 20 times if the return value is False, and the retry interval is 1 second if max retry times reached, raise RetryError """ current_cookie = await self.browser_context.cookies() _, cookie_dict = utils.convert_cookies(current_cookie) kuaishou_pass_token = cookie_dict.get("passToken") if kuaishou_pass_token: return True return False async def login_by_qrcode(self): """login kuaishou website and keep webdriver login state""" utils.logger.info("[KuaishouLogin.login_by_qrcode] Begin login kuaishou by qrcode ...") # click login button login_button_ele = self.context_page.locator( "xpath=//p[text()='登录']" ) await login_button_ele.click() # find login qrcode qrcode_img_selector = "//div[@class='qrcode-img']//img" base64_qrcode_img = await utils.find_login_qrcode( self.context_page, selector=qrcode_img_selector ) if not base64_qrcode_img: utils.logger.info("[KuaishouLogin.login_by_qrcode] login failed , have not found qrcode please check ....") sys.exit() # show login qrcode partial_show_qrcode = functools.partial(utils.show_qrcode, base64_qrcode_img) asyncio.get_running_loop().run_in_executor(executor=None, func=partial_show_qrcode) utils.logger.info(f"[KuaishouLogin.login_by_qrcode] waiting for scan code login, remaining time is 20s") try: await self.check_login_state() except RetryError: utils.logger.info("[KuaishouLogin.login_by_qrcode] Login kuaishou failed by qrcode login method ...") sys.exit() wait_redirect_seconds = 5 utils.logger.info(f"[KuaishouLogin.login_by_qrcode] Login successful then wait for {wait_redirect_seconds} seconds redirect ...") await asyncio.sleep(wait_redirect_seconds) async def login_by_mobile(self): pass async def login_by_cookies(self): utils.logger.info("[KuaishouLogin.login_by_cookies] Begin login kuaishou by cookie ...") for key, value in utils.convert_str_cookie_to_dict(self.cookie_str).items(): await self.browser_context.add_cookies([{ 'name': key, 'value': value, 'domain': ".kuaishou.com", 'path': "/" }]) ================================================ FILE: media_platform/tieba/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/tieba/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- from .core import TieBaCrawler ================================================ FILE: media_platform/tieba/client.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/tieba/client.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import asyncio import json from typing import Any, Callable, Dict, List, Optional, Union from urllib.parse import urlencode, quote import requests from playwright.async_api import BrowserContext, Page from tenacity import RetryError, retry, stop_after_attempt, wait_fixed import config from base.base_crawler import AbstractApiClient from model.m_baidu_tieba import TiebaComment, TiebaCreator, TiebaNote from proxy.proxy_ip_pool import ProxyIpPool from tools import utils from .field import SearchNoteType, SearchSortType from .help import TieBaExtractor class BaiduTieBaClient(AbstractApiClient): def __init__( self, timeout=10, ip_pool=None, default_ip_proxy=None, headers: Dict[str, str] = None, playwright_page: Optional[Page] = None, ): self.ip_pool: Optional[ProxyIpPool] = ip_pool self.timeout = timeout # Use provided headers (including real browser UA) or default headers self.headers = headers or { "User-Agent": utils.get_user_agent(), "Cookie": "", } self._host = "https://tieba.baidu.com" self._page_extractor = TieBaExtractor() self.default_ip_proxy = default_ip_proxy self.playwright_page = playwright_page # Playwright page object def _sync_request(self, method, url, proxy=None, **kwargs): """ Synchronous requests method Args: method: Request method url: Request URL proxy: Proxy IP **kwargs: Other request parameters Returns: Response object """ # Construct proxy dictionary proxies = None if proxy: proxies = { "http": proxy, "https": proxy, } # Send request response = requests.request( method=method, url=url, headers=self.headers, proxies=proxies, timeout=self.timeout, **kwargs ) return response async def _refresh_proxy_if_expired(self) -> None: """ Check if proxy is expired and automatically refresh if necessary """ if self.ip_pool is None: return if self.ip_pool.is_current_proxy_expired(): utils.logger.info( "[BaiduTieBaClient._refresh_proxy_if_expired] Proxy expired, refreshing..." ) new_proxy = await self.ip_pool.get_or_refresh_proxy() # Update proxy URL _, self.default_ip_proxy = utils.format_proxy_info(new_proxy) utils.logger.info( f"[BaiduTieBaClient._refresh_proxy_if_expired] New proxy: {new_proxy.ip}:{new_proxy.port}" ) @retry(stop=stop_after_attempt(3), wait=wait_fixed(1)) async def request(self, method, url, return_ori_content=False, proxy=None, **kwargs) -> Union[str, Any]: """ Common request method wrapper for requests, handles request responses Args: method: Request method url: Request URL return_ori_content: Whether to return original content proxy: Proxy IP **kwargs: Other request parameters, such as headers, request body, etc. Returns: """ # Check if proxy is expired before each request await self._refresh_proxy_if_expired() actual_proxy = proxy if proxy else self.default_ip_proxy # Execute synchronous requests in thread pool response = await asyncio.to_thread( self._sync_request, method, url, actual_proxy, **kwargs ) if response.status_code != 200: utils.logger.error(f"Request failed, method: {method}, url: {url}, status code: {response.status_code}") utils.logger.error(f"Request failed, response: {response.text}") raise Exception(f"Request failed, method: {method}, url: {url}, status code: {response.status_code}") if response.text == "" or response.text == "blocked": utils.logger.error(f"request params incorrect, response.text: {response.text}") raise Exception("account blocked") if return_ori_content: return response.text return response.json() async def get(self, uri: str, params=None, return_ori_content=False, **kwargs) -> Any: """ GET request with header signing Args: uri: Request route params: Request parameters return_ori_content: Whether to return original content Returns: """ final_uri = uri if isinstance(params, dict): final_uri = (f"{uri}?" f"{urlencode(params)}") try: res = await self.request(method="GET", url=f"{self._host}{final_uri}", return_ori_content=return_ori_content, **kwargs) return res except RetryError as e: if self.ip_pool: proxie_model = await self.ip_pool.get_proxy() _, proxy = utils.format_proxy_info(proxie_model) res = await self.request(method="GET", url=f"{self._host}{final_uri}", return_ori_content=return_ori_content, proxy=proxy, **kwargs) self.default_ip_proxy = proxy return res utils.logger.error(f"[BaiduTieBaClient.get] Reached maximum retry attempts, IP is blocked, please try a new IP proxy: {e}") raise Exception(f"[BaiduTieBaClient.get] Reached maximum retry attempts, IP is blocked, please try a new IP proxy: {e}") async def post(self, uri: str, data: dict, **kwargs) -> Dict: """ POST request with header signing Args: uri: Request route data: Request body parameters Returns: """ json_str = json.dumps(data, separators=(',', ':'), ensure_ascii=False) return await self.request(method="POST", url=f"{self._host}{uri}", data=json_str, **kwargs) async def pong(self, browser_context: BrowserContext = None) -> bool: """ Check if login state is still valid Uses Cookie detection instead of API calls to avoid detection Args: browser_context: Browser context object Returns: bool: True if logged in, False if not logged in """ utils.logger.info("[BaiduTieBaClient.pong] Begin to check tieba login state by cookies...") if not browser_context: utils.logger.warning("[BaiduTieBaClient.pong] browser_context is None, assume not logged in") return False try: # Get cookies from browser and check key login cookies _, cookie_dict = utils.convert_cookies(await browser_context.cookies()) # Baidu Tieba login identifiers: STOKEN or PTOKEN stoken = cookie_dict.get("STOKEN") ptoken = cookie_dict.get("PTOKEN") bduss = cookie_dict.get("BDUSS") # Baidu universal login cookie if stoken or ptoken or bduss: utils.logger.info(f"[BaiduTieBaClient.pong] Login state verified by cookies (STOKEN: {bool(stoken)}, PTOKEN: {bool(ptoken)}, BDUSS: {bool(bduss)})") return True else: utils.logger.info("[BaiduTieBaClient.pong] No valid login cookies found, need to login") return False except Exception as e: utils.logger.error(f"[BaiduTieBaClient.pong] Check login state failed: {e}, assume not logged in") return False async def update_cookies(self, browser_context: BrowserContext): """ Update cookies method provided by API client, usually called after successful login Args: browser_context: Browser context object Returns: """ cookie_str, cookie_dict = utils.convert_cookies(await browser_context.cookies()) self.headers["Cookie"] = cookie_str utils.logger.info("[BaiduTieBaClient.update_cookies] Cookie has been updated") async def get_notes_by_keyword( self, keyword: str, page: int = 1, page_size: int = 10, sort: SearchSortType = SearchSortType.TIME_DESC, note_type: SearchNoteType = SearchNoteType.FIXED_THREAD, ) -> List[TiebaNote]: """ Search Tieba posts by keyword (uses Playwright to access page, avoiding API detection) Args: keyword: Keyword page: Page number page_size: Page size sort: Result sort method note_type: Post type (main thread | main thread + reply mixed mode) Returns: """ if not self.playwright_page: utils.logger.error("[BaiduTieBaClient.get_notes_by_keyword] playwright_page is None, cannot use browser mode") raise Exception("playwright_page is required for browser-based search") # Construct search URL # Example: https://tieba.baidu.com/f/search/res?ie=utf-8&qw=keyword search_url = f"{self._host}/f/search/res" params = { "ie": "utf-8", "qw": keyword, "rn": page_size, "pn": page, "sm": sort.value, "only_thread": note_type.value, } # Concatenate full URL full_url = f"{search_url}?{urlencode(params)}" utils.logger.info(f"[BaiduTieBaClient.get_notes_by_keyword] Accessing search page: {full_url}") try: # Use Playwright to access search page await self.playwright_page.goto(full_url, wait_until="domcontentloaded") # Wait for page loading, using delay setting from config file await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) # Get page HTML content page_content = await self.playwright_page.content() utils.logger.info(f"[BaiduTieBaClient.get_notes_by_keyword] Successfully retrieved search page HTML, length: {len(page_content)}") # Extract search results notes = self._page_extractor.extract_search_note_list(page_content) utils.logger.info(f"[BaiduTieBaClient.get_notes_by_keyword] Extracted {len(notes)} posts") return notes except Exception as e: utils.logger.error(f"[BaiduTieBaClient.get_notes_by_keyword] Search failed: {e}") raise async def get_note_by_id(self, note_id: str) -> TiebaNote: """ Get post details by post ID (uses Playwright to access page, avoiding API detection) Args: note_id: Post ID Returns: TiebaNote: Post detail object """ if not self.playwright_page: utils.logger.error("[BaiduTieBaClient.get_note_by_id] playwright_page is None, cannot use browser mode") raise Exception("playwright_page is required for browser-based note detail fetching") # Construct post detail URL note_url = f"{self._host}/p/{note_id}" utils.logger.info(f"[BaiduTieBaClient.get_note_by_id] Accessing post detail page: {note_url}") try: # Use Playwright to access post detail page await self.playwright_page.goto(note_url, wait_until="domcontentloaded") # Wait for page loading, using delay setting from config file await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) # Get page HTML content page_content = await self.playwright_page.content() utils.logger.info(f"[BaiduTieBaClient.get_note_by_id] Successfully retrieved post detail HTML, length: {len(page_content)}") # Extract post details note_detail = self._page_extractor.extract_note_detail(page_content) return note_detail except Exception as e: utils.logger.error(f"[BaiduTieBaClient.get_note_by_id] Failed to get post details: {e}") raise async def get_note_all_comments( self, note_detail: TiebaNote, crawl_interval: float = 1.0, callback: Optional[Callable] = None, max_count: int = 10, ) -> List[TiebaComment]: """ Get all first-level comments for specified post (uses Playwright to access page, avoiding API detection) Args: note_detail: Post detail object crawl_interval: Crawl delay interval in seconds callback: Callback function after one post crawl completes max_count: Maximum number of comments to crawl per post Returns: List[TiebaComment]: Comment list """ if not self.playwright_page: utils.logger.error("[BaiduTieBaClient.get_note_all_comments] playwright_page is None, cannot use browser mode") raise Exception("playwright_page is required for browser-based comment fetching") result: List[TiebaComment] = [] current_page = 1 while note_detail.total_replay_page >= current_page and len(result) < max_count: # Construct comment page URL comment_url = f"{self._host}/p/{note_detail.note_id}?pn={current_page}" utils.logger.info(f"[BaiduTieBaClient.get_note_all_comments] Accessing comment page: {comment_url}") try: # Use Playwright to access comment page await self.playwright_page.goto(comment_url, wait_until="domcontentloaded") # Wait for page loading, using delay setting from config file await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) # Get page HTML content page_content = await self.playwright_page.content() # Extract comments comments = self._page_extractor.extract_tieba_note_parment_comments( page_content, note_id=note_detail.note_id ) if not comments: utils.logger.info(f"[BaiduTieBaClient.get_note_all_comments] Page {current_page} has no comments, stopping crawl") break # Limit comment count if len(result) + len(comments) > max_count: comments = comments[:max_count - len(result)] if callback: await callback(note_detail.note_id, comments) result.extend(comments) # Get all sub-comments await self.get_comments_all_sub_comments( comments, crawl_interval=crawl_interval, callback=callback ) await asyncio.sleep(crawl_interval) current_page += 1 except Exception as e: utils.logger.error(f"[BaiduTieBaClient.get_note_all_comments] Failed to get page {current_page} comments: {e}") break utils.logger.info(f"[BaiduTieBaClient.get_note_all_comments] Total retrieved {len(result)} first-level comments") return result async def get_comments_all_sub_comments( self, comments: List[TiebaComment], crawl_interval: float = 1.0, callback: Optional[Callable] = None, ) -> List[TiebaComment]: """ Get all sub-comments for specified comments (uses Playwright to access page, avoiding API detection) Args: comments: Comment list crawl_interval: Crawl delay interval in seconds callback: Callback function after one post crawl completes Returns: List[TiebaComment]: Sub-comment list """ if not config.ENABLE_GET_SUB_COMMENTS: return [] if not self.playwright_page: utils.logger.error("[BaiduTieBaClient.get_comments_all_sub_comments] playwright_page is None, cannot use browser mode") raise Exception("playwright_page is required for browser-based sub-comment fetching") all_sub_comments: List[TiebaComment] = [] for parment_comment in comments: if parment_comment.sub_comment_count == 0: continue current_page = 1 max_sub_page_num = parment_comment.sub_comment_count // 10 + 1 while max_sub_page_num >= current_page: # Construct sub-comment URL sub_comment_url = ( f"{self._host}/p/comment?" f"tid={parment_comment.note_id}&" f"pid={parment_comment.comment_id}&" f"fid={parment_comment.tieba_id}&" f"pn={current_page}" ) utils.logger.info(f"[BaiduTieBaClient.get_comments_all_sub_comments] Accessing sub-comment page: {sub_comment_url}") try: # Use Playwright to access sub-comment page await self.playwright_page.goto(sub_comment_url, wait_until="domcontentloaded") # Wait for page loading, using delay setting from config file await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) # Get page HTML content page_content = await self.playwright_page.content() # Extract sub-comments sub_comments = self._page_extractor.extract_tieba_note_sub_comments( page_content, parent_comment=parment_comment ) if not sub_comments: utils.logger.info( f"[BaiduTieBaClient.get_comments_all_sub_comments] " f"Comment {parment_comment.comment_id} page {current_page} has no sub-comments, stopping crawl" ) break if callback: await callback(parment_comment.note_id, sub_comments) all_sub_comments.extend(sub_comments) await asyncio.sleep(crawl_interval) current_page += 1 except Exception as e: utils.logger.error( f"[BaiduTieBaClient.get_comments_all_sub_comments] " f"Failed to get comment {parment_comment.comment_id} page {current_page} sub-comments: {e}" ) break utils.logger.info(f"[BaiduTieBaClient.get_comments_all_sub_comments] Total retrieved {len(all_sub_comments)} sub-comments") return all_sub_comments async def get_notes_by_tieba_name(self, tieba_name: str, page_num: int) -> List[TiebaNote]: """ Get post list by Tieba name (uses Playwright to access page, avoiding API detection) Args: tieba_name: Tieba name page_num: Page number Returns: List[TiebaNote]: Post list """ if not self.playwright_page: utils.logger.error("[BaiduTieBaClient.get_notes_by_tieba_name] playwright_page is None, cannot use browser mode") raise Exception("playwright_page is required for browser-based tieba note fetching") # Construct Tieba post list URL tieba_url = f"{self._host}/f?kw={quote(tieba_name)}&pn={page_num}" utils.logger.info(f"[BaiduTieBaClient.get_notes_by_tieba_name] Accessing Tieba page: {tieba_url}") try: # Use Playwright to access Tieba page await self.playwright_page.goto(tieba_url, wait_until="domcontentloaded") # Wait for page loading, using delay setting from config file await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) # Get page HTML content page_content = await self.playwright_page.content() utils.logger.info(f"[BaiduTieBaClient.get_notes_by_tieba_name] Successfully retrieved Tieba page HTML, length: {len(page_content)}") # Extract post list notes = self._page_extractor.extract_tieba_note_list(page_content) utils.logger.info(f"[BaiduTieBaClient.get_notes_by_tieba_name] Extracted {len(notes)} posts") return notes except Exception as e: utils.logger.error(f"[BaiduTieBaClient.get_notes_by_tieba_name] Failed to get Tieba post list: {e}") raise async def get_creator_info_by_url(self, creator_url: str) -> str: """ Get creator information by creator URL (uses Playwright to access page, avoiding API detection) Args: creator_url: Creator homepage URL Returns: str: Page HTML content """ if not self.playwright_page: utils.logger.error("[BaiduTieBaClient.get_creator_info_by_url] playwright_page is None, cannot use browser mode") raise Exception("playwright_page is required for browser-based creator info fetching") utils.logger.info(f"[BaiduTieBaClient.get_creator_info_by_url] Accessing creator homepage: {creator_url}") try: # Use Playwright to access creator homepage await self.playwright_page.goto(creator_url, wait_until="domcontentloaded") # Wait for page loading, using delay setting from config file await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) # Get page HTML content page_content = await self.playwright_page.content() utils.logger.info(f"[BaiduTieBaClient.get_creator_info_by_url] Successfully retrieved creator homepage HTML, length: {len(page_content)}") return page_content except Exception as e: utils.logger.error(f"[BaiduTieBaClient.get_creator_info_by_url] Failed to get creator homepage: {e}") raise async def get_notes_by_creator(self, user_name: str, page_number: int) -> Dict: """ Get creator's posts by creator (uses Playwright to access page, avoiding API detection) Args: user_name: Creator username page_number: Page number Returns: Dict: Dictionary containing post data """ if not self.playwright_page: utils.logger.error("[BaiduTieBaClient.get_notes_by_creator] playwright_page is None, cannot use browser mode") raise Exception("playwright_page is required for browser-based creator notes fetching") # Construct creator post list URL creator_url = f"{self._host}/home/get/getthread?un={quote(user_name)}&pn={page_number}&id=utf-8&_={utils.get_current_timestamp()}" utils.logger.info(f"[BaiduTieBaClient.get_notes_by_creator] Accessing creator post list: {creator_url}") try: # Use Playwright to access creator post list page await self.playwright_page.goto(creator_url, wait_until="domcontentloaded") # Wait for page loading, using delay setting from config file await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) # Get page content (this API returns JSON) page_content = await self.playwright_page.content() # Extract JSON data (page will contain
 tag or is directly JSON)
            try:
                # Try to extract JSON from page
                json_text = await self.playwright_page.evaluate("() => document.body.innerText")
                result = json.loads(json_text)
                utils.logger.info(f"[BaiduTieBaClient.get_notes_by_creator] Successfully retrieved creator post data")
                return result
            except json.JSONDecodeError as e:
                utils.logger.error(f"[BaiduTieBaClient.get_notes_by_creator] JSON parsing failed: {e}")
                utils.logger.error(f"[BaiduTieBaClient.get_notes_by_creator] Page content: {page_content[:500]}")
                raise Exception(f"Failed to parse JSON from creator notes page: {e}")

        except Exception as e:
            utils.logger.error(f"[BaiduTieBaClient.get_notes_by_creator] Failed to get creator post list: {e}")
            raise

    async def get_all_notes_by_creator_user_name(
        self,
        user_name: str,
        crawl_interval: float = 1.0,
        callback: Optional[Callable] = None,
        max_note_count: int = 0,
        creator_page_html_content: str = None,
    ) -> List[TiebaNote]:
        """
        Get all creator posts by creator username
        Args:
            user_name: Creator username
            crawl_interval: Crawl delay interval in seconds
            callback: Callback function after one post crawl completes, an awaitable function
            max_note_count: Maximum number of posts to retrieve, if 0 then get all
            creator_page_html_content: Creator homepage HTML content

        Returns:

        """
        # Baidu Tieba is special, the first 10 posts are directly displayed on the homepage and need special handling, cannot be obtained through API
        result: List[TiebaNote] = []
        if creator_page_html_content:
            thread_id_list = (self._page_extractor.extract_tieba_thread_id_list_from_creator_page(creator_page_html_content))
            utils.logger.info(f"[BaiduTieBaClient.get_all_notes_by_creator] got user_name:{user_name} thread_id_list len : {len(thread_id_list)}")
            note_detail_task = [self.get_note_by_id(thread_id) for thread_id in thread_id_list]
            notes = await asyncio.gather(*note_detail_task)
            if callback:
                await callback(notes)
            result.extend(notes)

        notes_has_more = 1
        page_number = 1
        page_per_count = 20
        total_get_count = 0
        while notes_has_more == 1 and (max_note_count == 0 or total_get_count < max_note_count):
            notes_res = await self.get_notes_by_creator(user_name, page_number)
            if not notes_res or notes_res.get("no") != 0:
                utils.logger.error(f"[WeiboClient.get_notes_by_creator] got user_name:{user_name} notes failed, notes_res: {notes_res}")
                break
            notes_data = notes_res.get("data")
            notes_has_more = notes_data.get("has_more")
            notes = notes_data["thread_list"]
            utils.logger.info(f"[WeiboClient.get_all_notes_by_creator] got user_name:{user_name} notes len : {len(notes)}")

            note_detail_task = [self.get_note_by_id(note['thread_id']) for note in notes]
            notes = await asyncio.gather(*note_detail_task)
            if callback:
                await callback(notes)
            await asyncio.sleep(crawl_interval)
            result.extend(notes)
            page_number += 1
            total_get_count += page_per_count
        return result


================================================
FILE: media_platform/tieba/core.py
================================================
# -*- coding: utf-8 -*-
# Copyright (c) 2025 relakkes@gmail.com
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/tieba/core.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#

# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。


import asyncio
import os
from asyncio import Task
from typing import Dict, List, Optional, Tuple

from playwright.async_api import (
    BrowserContext,
    BrowserType,
    Page,
    Playwright,
    async_playwright,
)

import config
from base.base_crawler import AbstractCrawler
from model.m_baidu_tieba import TiebaCreator, TiebaNote
from proxy.proxy_ip_pool import IpInfoModel, ProxyIpPool, create_ip_pool
from store import tieba as tieba_store
from tools import utils
from tools.cdp_browser import CDPBrowserManager
from var import crawler_type_var, source_keyword_var

from .client import BaiduTieBaClient
from .field import SearchNoteType, SearchSortType
from .help import TieBaExtractor
from .login import BaiduTieBaLogin


class TieBaCrawler(AbstractCrawler):
    context_page: Page
    tieba_client: BaiduTieBaClient
    browser_context: BrowserContext
    cdp_manager: Optional[CDPBrowserManager]

    def __init__(self) -> None:
        self.index_url = "https://tieba.baidu.com"
        self.user_agent = utils.get_user_agent()
        self._page_extractor = TieBaExtractor()
        self.cdp_manager = None

    async def start(self) -> None:
        """
        Start the crawler
        Returns:

        """
        playwright_proxy_format, httpx_proxy_format = None, None
        if config.ENABLE_IP_PROXY:
            utils.logger.info(
                "[BaiduTieBaCrawler.start] Begin create ip proxy pool ..."
            )
            ip_proxy_pool = await create_ip_pool(
                config.IP_PROXY_POOL_COUNT, enable_validate_ip=True
            )
            ip_proxy_info: IpInfoModel = await ip_proxy_pool.get_proxy()
            playwright_proxy_format, httpx_proxy_format = utils.format_proxy_info(ip_proxy_info)
            utils.logger.info(
                f"[BaiduTieBaCrawler.start] Init default ip proxy, value: {httpx_proxy_format}"
            )

        async with async_playwright() as playwright:
            # Choose startup mode based on configuration
            if config.ENABLE_CDP_MODE:
                utils.logger.info("[BaiduTieBaCrawler] Launching browser in CDP mode")
                self.browser_context = await self.launch_browser_with_cdp(
                    playwright,
                    playwright_proxy_format,
                    self.user_agent,
                    headless=config.CDP_HEADLESS,
                )
            else:
                utils.logger.info("[BaiduTieBaCrawler] Launching browser in standard mode")
                # Launch a browser context.
                chromium = playwright.chromium
                self.browser_context = await self.launch_browser(
                    chromium,
                    playwright_proxy_format,
                    self.user_agent,
                    headless=config.HEADLESS,
                )

            # Inject anti-detection scripts - for Baidu's special detection
            await self._inject_anti_detection_scripts()

            self.context_page = await self.browser_context.new_page()

            # First visit Baidu homepage, then click Tieba link to avoid triggering security verification
            await self._navigate_to_tieba_via_baidu()

            # Create a client to interact with the baidutieba website.
            self.tieba_client = await self.create_tieba_client(
                httpx_proxy_format,
                ip_proxy_pool if config.ENABLE_IP_PROXY else None
            )

            # Check login status and perform login if necessary
            if not await self.tieba_client.pong(browser_context=self.browser_context):
                login_obj = BaiduTieBaLogin(
                    login_type=config.LOGIN_TYPE,
                    login_phone="",  # your phone number
                    browser_context=self.browser_context,
                    context_page=self.context_page,
                    cookie_str=config.COOKIES,
                )
                await login_obj.begin()
                await self.tieba_client.update_cookies(browser_context=self.browser_context)

            crawler_type_var.set(config.CRAWLER_TYPE)
            if config.CRAWLER_TYPE == "search":
                # Search for notes and retrieve their comment information.
                await self.search()
                await self.get_specified_tieba_notes()
            elif config.CRAWLER_TYPE == "detail":
                # Get the information and comments of the specified post
                await self.get_specified_notes()
            elif config.CRAWLER_TYPE == "creator":
                # Get creator's information and their notes and comments
                await self.get_creators_and_notes()
            else:
                pass

            utils.logger.info("[BaiduTieBaCrawler.start] Tieba Crawler finished ...")

    async def search(self) -> None:
        """
        Search for notes and retrieve their comment information.
        Returns:

        """
        utils.logger.info(
            "[BaiduTieBaCrawler.search] Begin search baidu tieba keywords"
        )
        tieba_limit_count = 10  # tieba limit page fixed value
        if config.CRAWLER_MAX_NOTES_COUNT < tieba_limit_count:
            config.CRAWLER_MAX_NOTES_COUNT = tieba_limit_count
        start_page = config.START_PAGE
        for keyword in config.KEYWORDS.split(","):
            source_keyword_var.set(keyword)
            utils.logger.info(
                f"[BaiduTieBaCrawler.search] Current search keyword: {keyword}"
            )
            page = 1
            while (
                page - start_page + 1
            ) * tieba_limit_count <= config.CRAWLER_MAX_NOTES_COUNT:
                if page < start_page:
                    utils.logger.info(f"[BaiduTieBaCrawler.search] Skip page {page}")
                    page += 1
                    continue
                try:
                    utils.logger.info(
                        f"[BaiduTieBaCrawler.search] search tieba keyword: {keyword}, page: {page}"
                    )
                    notes_list: List[TiebaNote] = (
                        await self.tieba_client.get_notes_by_keyword(
                            keyword=keyword,
                            page=page,
                            page_size=tieba_limit_count,
                            sort=SearchSortType.TIME_DESC,
                            note_type=SearchNoteType.FIXED_THREAD,
                        )
                    )
                    if not notes_list:
                        utils.logger.info(
                            f"[BaiduTieBaCrawler.search] Search note list is empty"
                        )
                        break
                    utils.logger.info(
                        f"[BaiduTieBaCrawler.search] Note list len: {len(notes_list)}"
                    )
                    await self.get_specified_notes(
                        note_id_list=[note_detail.note_id for note_detail in notes_list]
                    )

                    # Sleep after page navigation
                    await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC)
                    utils.logger.info(f"[TieBaCrawler.search] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after page {page}")

                    page += 1
                except Exception as ex:
                    utils.logger.error(
                        f"[BaiduTieBaCrawler.search] Search keywords error, current page: {page}, current keyword: {keyword}, err: {ex}"
                    )
                    break

    async def get_specified_tieba_notes(self):
        """
        Get the information and comments of the specified post by tieba name
        Returns:

        """
        tieba_limit_count = 50
        if config.CRAWLER_MAX_NOTES_COUNT < tieba_limit_count:
            config.CRAWLER_MAX_NOTES_COUNT = tieba_limit_count
        for tieba_name in config.TIEBA_NAME_LIST:
            utils.logger.info(
                f"[BaiduTieBaCrawler.get_specified_tieba_notes] Begin get tieba name: {tieba_name}"
            )
            page_number = 0
            while page_number <= config.CRAWLER_MAX_NOTES_COUNT:
                note_list: List[TiebaNote] = (
                    await self.tieba_client.get_notes_by_tieba_name(
                        tieba_name=tieba_name, page_num=page_number
                    )
                )
                if not note_list:
                    utils.logger.info(
                        f"[BaiduTieBaCrawler.get_specified_tieba_notes] Get note list is empty"
                    )
                    break

                utils.logger.info(
                    f"[BaiduTieBaCrawler.get_specified_tieba_notes] tieba name: {tieba_name} note list len: {len(note_list)}"
                )
                await self.get_specified_notes([note.note_id for note in note_list])

                # Sleep after processing notes
                await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC)
                utils.logger.info(f"[TieBaCrawler.get_specified_tieba_notes] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after processing notes from page {page_number}")

                page_number += tieba_limit_count

    async def get_specified_notes(
        self, note_id_list: List[str] = config.TIEBA_SPECIFIED_ID_LIST
    ):
        """
        Get the information and comments of the specified post
        Args:
            note_id_list:

        Returns:

        """
        semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM)
        task_list = [
            self.get_note_detail_async_task(note_id=note_id, semaphore=semaphore)
            for note_id in note_id_list
        ]
        note_details = await asyncio.gather(*task_list)
        note_details_model: List[TiebaNote] = []
        for note_detail in note_details:
            if note_detail is not None:
                note_details_model.append(note_detail)
                await tieba_store.update_tieba_note(note_detail)
        await self.batch_get_note_comments(note_details_model)

    async def get_note_detail_async_task(
        self, note_id: str, semaphore: asyncio.Semaphore
    ) -> Optional[TiebaNote]:
        """
        Get note detail
        Args:
            note_id: baidu tieba note id
            semaphore: asyncio semaphore

        Returns:

        """
        async with semaphore:
            try:
                utils.logger.info(
                    f"[BaiduTieBaCrawler.get_note_detail] Begin get note detail, note_id: {note_id}"
                )
                note_detail: TiebaNote = await self.tieba_client.get_note_by_id(note_id)

                # Sleep after fetching note details
                await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC)
                utils.logger.info(f"[TieBaCrawler.get_note_detail_async_task] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after fetching note details {note_id}")

                if not note_detail:
                    utils.logger.error(
                        f"[BaiduTieBaCrawler.get_note_detail] Get note detail error, note_id: {note_id}"
                    )
                    return None
                return note_detail
            except Exception as ex:
                utils.logger.error(
                    f"[BaiduTieBaCrawler.get_note_detail] Get note detail error: {ex}"
                )
                return None
            except KeyError as ex:
                utils.logger.error(
                    f"[BaiduTieBaCrawler.get_note_detail] have not fund note detail note_id:{note_id}, err: {ex}"
                )
                return None

    async def batch_get_note_comments(self, note_detail_list: List[TiebaNote]):
        """
        Batch get note comments
        Args:
            note_detail_list:

        Returns:

        """
        if not config.ENABLE_GET_COMMENTS:
            return

        semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM)
        task_list: List[Task] = []
        for note_detail in note_detail_list:
            task = asyncio.create_task(
                self.get_comments_async_task(note_detail, semaphore),
                name=note_detail.note_id,
            )
            task_list.append(task)
        await asyncio.gather(*task_list)

    async def get_comments_async_task(
        self, note_detail: TiebaNote, semaphore: asyncio.Semaphore
    ):
        """
        Get comments async task
        Args:
            note_detail:
            semaphore:

        Returns:

        """
        async with semaphore:
            utils.logger.info(
                f"[BaiduTieBaCrawler.get_comments] Begin get note id comments {note_detail.note_id}"
            )

            # Sleep before fetching comments
            await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC)
            utils.logger.info(f"[TieBaCrawler.get_comments_async_task] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds before fetching comments for note {note_detail.note_id}")

            await self.tieba_client.get_note_all_comments(
                note_detail=note_detail,
                crawl_interval=config.CRAWLER_MAX_SLEEP_SEC,
                callback=tieba_store.batch_update_tieba_note_comments,
                max_count=config.CRAWLER_MAX_COMMENTS_COUNT_SINGLENOTES,
            )

    async def get_creators_and_notes(self) -> None:
        """
        Get creator's information and their notes and comments
        Returns:

        """
        utils.logger.info(
            "[WeiboCrawler.get_creators_and_notes] Begin get weibo creators"
        )
        for creator_url in config.TIEBA_CREATOR_URL_LIST:
            creator_page_html_content = await self.tieba_client.get_creator_info_by_url(
                creator_url=creator_url
            )
            creator_info: TiebaCreator = self._page_extractor.extract_creator_info(
                creator_page_html_content
            )
            if creator_info:
                utils.logger.info(
                    f"[WeiboCrawler.get_creators_and_notes] creator info: {creator_info}"
                )
                if not creator_info:
                    raise Exception("Get creator info error")

                await tieba_store.save_creator(user_info=creator_info)

                # Get all note information of the creator
                all_notes_list = (
                    await self.tieba_client.get_all_notes_by_creator_user_name(
                        user_name=creator_info.user_name,
                        crawl_interval=0,
                        callback=tieba_store.batch_update_tieba_notes,
                        max_note_count=config.CRAWLER_MAX_NOTES_COUNT,
                        creator_page_html_content=creator_page_html_content,
                    )
                )

                await self.batch_get_note_comments(all_notes_list)

            else:
                utils.logger.error(
                    f"[WeiboCrawler.get_creators_and_notes] get creator info error, creator_url:{creator_url}"
                )

    async def _navigate_to_tieba_via_baidu(self):
        """
        Simulate real user access path:
        1. First visit Baidu homepage (https://www.baidu.com/)
        2. Wait for page to load
        3. Click "Tieba" link in top navigation bar
        4. Jump to Tieba homepage

        This avoids triggering Baidu's security verification
        """
        utils.logger.info("[TieBaCrawler] Simulating real user access path...")

        try:
            # Step 1: Visit Baidu homepage
            utils.logger.info("[TieBaCrawler] Step 1: Visiting Baidu homepage https://www.baidu.com/")
            await self.context_page.goto("https://www.baidu.com/", wait_until="domcontentloaded")

            # Step 2: Wait for page loading, using delay setting from config file
            utils.logger.info(f"[TieBaCrawler] Step 2: Waiting {config.CRAWLER_MAX_SLEEP_SEC} seconds to simulate user browsing...")
            await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC)

            # Step 3: Find and click "Tieba" link
            utils.logger.info("[TieBaCrawler] Step 3: Finding and clicking 'Tieba' link...")

            # Try multiple selectors to ensure finding the Tieba link
            tieba_selectors = [
                'a[href="http://tieba.baidu.com/"]',
                'a[href="https://tieba.baidu.com/"]',
                'a.mnav:has-text("贴吧")',
                'text=贴吧',
            ]

            tieba_link = None
            for selector in tieba_selectors:
                try:
                    tieba_link = await self.context_page.wait_for_selector(selector, timeout=5000)
                    if tieba_link:
                        utils.logger.info(f"[TieBaCrawler] Found Tieba link (selector: {selector})")
                        break
                except Exception:
                    continue

            if not tieba_link:
                utils.logger.warning("[TieBaCrawler] Tieba link not found, directly accessing Tieba homepage")
                await self.context_page.goto(self.index_url, wait_until="domcontentloaded")
                return

            # Step 4: Click Tieba link (check if it will open in a new tab)
            utils.logger.info("[TieBaCrawler] Step 4: Clicking Tieba link...")

            # Check link's target attribute
            target_attr = await tieba_link.get_attribute("target")
            utils.logger.info(f"[TieBaCrawler] Link target attribute: {target_attr}")

            if target_attr == "_blank":
                # If it's a new tab, need to wait for new page and switch
                utils.logger.info("[TieBaCrawler] Link will open in new tab, waiting for new page...")

                async with self.browser_context.expect_page() as new_page_info:
                    await tieba_link.click()

                # Get newly opened page
                new_page = await new_page_info.value
                await new_page.wait_for_load_state("domcontentloaded")

                # Close old Baidu homepage
                await self.context_page.close()

                # Switch to new Tieba page
                self.context_page = new_page
                utils.logger.info("[TieBaCrawler] Successfully switched to new tab (Tieba page)")
            else:
                # If it's same tab navigation, wait for navigation normally
                utils.logger.info("[TieBaCrawler] Link navigates in current tab...")
                async with self.context_page.expect_navigation(wait_until="domcontentloaded"):
                    await tieba_link.click()

            # Step 5: Wait for page to stabilize, using delay setting from config file
            utils.logger.info(f"[TieBaCrawler] Step 5: Page loaded, waiting {config.CRAWLER_MAX_SLEEP_SEC} seconds...")
            await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC)

            current_url = self.context_page.url
            utils.logger.info(f"[TieBaCrawler] Successfully entered Tieba via Baidu homepage! Current URL: {current_url}")

        except Exception as e:
            utils.logger.error(f"[TieBaCrawler] Failed to access Tieba via Baidu homepage: {e}")
            utils.logger.info("[TieBaCrawler] Fallback: directly accessing Tieba homepage")
            await self.context_page.goto(self.index_url, wait_until="domcontentloaded")

    async def _inject_anti_detection_scripts(self):
        """
        Inject anti-detection JavaScript scripts
        For Baidu Tieba's special detection mechanism
        """
        utils.logger.info("[TieBaCrawler] Injecting anti-detection scripts...")

        # Lightweight anti-detection script, only covering key detection points
        anti_detection_js = """
        // Override navigator.webdriver
        Object.defineProperty(navigator, 'webdriver', {
            get: () => undefined,
            configurable: true
        });

        // Override window.navigator.chrome
        if (!window.navigator.chrome) {
            window.navigator.chrome = {
                runtime: {},
                loadTimes: function() {},
                csi: function() {},
                app: {}
            };
        }

        // Override Permissions API
        const originalQuery = window.navigator.permissions.query;
        window.navigator.permissions.query = (parameters) => (
            parameters.name === 'notifications' ?
                Promise.resolve({ state: Notification.permission }) :
                originalQuery(parameters)
        );

        // Override plugins length (make it look like there are plugins)
        Object.defineProperty(navigator, 'plugins', {
            get: () => [1, 2, 3, 4, 5],
            configurable: true
        });

        // Override languages
        Object.defineProperty(navigator, 'languages', {
            get: () => ['zh-CN', 'zh', 'en'],
            configurable: true
        });

        // Remove window.cdc_ and other ChromeDriver remnants
        delete window.cdc_adoQpoasnfa76pfcZLmcfl_Array;
        delete window.cdc_adoQpoasnfa76pfcZLmcfl_Promise;
        delete window.cdc_adoQpoasnfa76pfcZLmcfl_Symbol;

        console.log('[Anti-Detection] Scripts injected successfully');
        """

        await self.browser_context.add_init_script(anti_detection_js)
        utils.logger.info("[TieBaCrawler] Anti-detection scripts injected")

    async def create_tieba_client(
        self, httpx_proxy: Optional[str], ip_pool: Optional[ProxyIpPool] = None
    ) -> BaiduTieBaClient:
        """
        Create tieba client with real browser User-Agent and complete headers
        Args:
            httpx_proxy: HTTP proxy
            ip_pool: IP proxy pool

        Returns:
            BaiduTieBaClient instance
        """
        utils.logger.info("[TieBaCrawler.create_tieba_client] Begin create tieba API client...")

        # Extract User-Agent from real browser to avoid detection
        user_agent = await self.context_page.evaluate("() => navigator.userAgent")
        utils.logger.info(f"[TieBaCrawler.create_tieba_client] Extracted User-Agent from browser: {user_agent}")

        cookie_str, cookie_dict = utils.convert_cookies(await self.browser_context.cookies())

        # Build complete browser request headers, simulating real browser behavior
        tieba_client = BaiduTieBaClient(
            timeout=10,
            ip_pool=ip_pool,
            default_ip_proxy=httpx_proxy,
            headers={
                "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
                "Accept-Language": "zh-CN,zh;q=0.9",
                "Accept-Encoding": "gzip, deflate, br",
                "Connection": "keep-alive",
                "User-Agent": user_agent,  # Use real browser UA
                "Cookie": cookie_str,
                "Host": "tieba.baidu.com",
                "Referer": "https://tieba.baidu.com/",
                "Sec-Fetch-Dest": "document",
                "Sec-Fetch-Mode": "navigate",
                "Sec-Fetch-Site": "same-origin",
                "Sec-Fetch-User": "?1",
                "Upgrade-Insecure-Requests": "1",
                "sec-ch-ua": '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
                "sec-ch-ua-mobile": "?0",
                "sec-ch-ua-platform": '"macOS"',
            },
            playwright_page=self.context_page,  # Pass in playwright page object
        )
        return tieba_client

    async def launch_browser(
        self,
        chromium: BrowserType,
        playwright_proxy: Optional[Dict],
        user_agent: Optional[str],
        headless: bool = True,
    ) -> BrowserContext:
        """
        Launch browser and create browser
        Args:
            chromium:
            playwright_proxy:
            user_agent:
            headless:

        Returns:

        """
        utils.logger.info(
            "[BaiduTieBaCrawler.launch_browser] Begin create browser context ..."
        )
        if config.SAVE_LOGIN_STATE:
            # feat issue #14
            # we will save login state to avoid login every time
            user_data_dir = os.path.join(
                os.getcwd(), "browser_data", config.USER_DATA_DIR % config.PLATFORM
            )  # type: ignore
            browser_context = await chromium.launch_persistent_context(
                user_data_dir=user_data_dir,
                accept_downloads=True,
                headless=headless,
                proxy=playwright_proxy,  # type: ignore
                viewport={"width": 1920, "height": 1080},
                user_agent=user_agent,
                channel="chrome",  # Use system's stable Chrome version
            )
            return browser_context
        else:
            browser = await chromium.launch(headless=headless, proxy=playwright_proxy, channel="chrome")  # type: ignore
            browser_context = await browser.new_context(
                viewport={"width": 1920, "height": 1080}, user_agent=user_agent
            )
            return browser_context

    async def launch_browser_with_cdp(
        self,
        playwright: Playwright,
        playwright_proxy: Optional[Dict],
        user_agent: Optional[str],
        headless: bool = True,
    ) -> BrowserContext:
        """
        Launch browser using CDP mode
        """
        try:
            self.cdp_manager = CDPBrowserManager()
            browser_context = await self.cdp_manager.launch_and_connect(
                playwright=playwright,
                playwright_proxy=playwright_proxy,
                user_agent=user_agent,
                headless=headless,
            )

            # Display browser information
            browser_info = await self.cdp_manager.get_browser_info()
            utils.logger.info(f"[TieBaCrawler] CDP browser info: {browser_info}")

            return browser_context

        except Exception as e:
            utils.logger.error(f"[TieBaCrawler] CDP mode launch failed, falling back to standard mode: {e}")
            # Fall back to standard mode
            chromium = playwright.chromium
            return await self.launch_browser(
                chromium, playwright_proxy, user_agent, headless
            )

    async def close(self):
        """
        Close browser context
        Returns:

        """
        # If using CDP mode, need special handling
        if self.cdp_manager:
            await self.cdp_manager.cleanup()
            self.cdp_manager = None
        else:
            await self.browser_context.close()
        utils.logger.info("[BaiduTieBaCrawler.close] Browser context closed ...")


================================================
FILE: media_platform/tieba/field.py
================================================
# -*- coding: utf-8 -*-
# Copyright (c) 2025 relakkes@gmail.com
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/tieba/field.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#

# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。


from enum import Enum


class SearchSortType(Enum):
    """search sort type"""
    # Sort by time in descending order
    TIME_DESC = "1"
    # Sort by time in ascending order
    TIME_ASC = "0"
    # Sort by relevance
    RELEVANCE_ORDER = "2"


class SearchNoteType(Enum):
    # Only view main posts
    MAIN_THREAD = "1"
    # Mixed mode (posts + replies)
    FIXED_THREAD = "0"


================================================
FILE: media_platform/tieba/help.py
================================================
# -*- coding: utf-8 -*-
# Copyright (c) 2025 relakkes@gmail.com
#
# This file is part of MediaCrawler project.
# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/tieba/help.py
# GitHub: https://github.com/NanmiCoder
# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
#

# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
# 1. 不得用于任何商业用途。
# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
# 3. 不得进行大规模爬取或对平台造成运营干扰。
# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
# 5. 不得用于任何非法或不当的用途。
#
# 详细许可条款请参阅项目根目录下的LICENSE文件。
# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。


# -*- coding: utf-8 -*-
import html
import json
import re
from typing import Dict, List, Tuple
from urllib.parse import parse_qs, unquote

from parsel import Selector

from constant import baidu_tieba as const
from model.m_baidu_tieba import TiebaComment, TiebaCreator, TiebaNote
from tools import utils

GENDER_MALE = "sex_male"
GENDER_FEMALE = "sex_female"


class TieBaExtractor:
    def __init__(self):
        pass

    @staticmethod
    def extract_search_note_list(page_content: str) -> List[TiebaNote]:
        """
        Extract Tieba post list from keyword search result pages, still missing reply count and reply page data
        Args:
            page_content: HTML string of page content

        Returns:
            List of Tieba post objects
        """
        xpath_selector = "//div[@class='s_post']"
        post_list = Selector(text=page_content).xpath(xpath_selector)
        result: List[TiebaNote] = []
        for post in post_list:
            tieba_note = TiebaNote(note_id=post.xpath(".//span[@class='p_title']/a/@data-tid").get(default='').strip(),
                                   title=post.xpath(".//span[@class='p_title']/a/text()").get(default='').strip(),
                                   desc=post.xpath(".//div[@class='p_content']/text()").get(default='').strip(),
                                   note_url=const.TIEBA_URL + post.xpath(".//span[@class='p_title']/a/@href").get(
                                       default=''),
                                   user_nickname=post.xpath(".//a[starts-with(@href, '/home/main')]/font/text()").get(
                                       default='').strip(), user_link=const.TIEBA_URL + post.xpath(
                    ".//a[starts-with(@href, '/home/main')]/@href").get(default=''),
                                   tieba_name=post.xpath(".//a[@class='p_forum']/font/text()").get(default='').strip(),
                                   tieba_link=const.TIEBA_URL + post.xpath(".//a[@class='p_forum']/@href").get(
                                       default=''),
                                   publish_time=post.xpath(".//font[@class='p_green p_date']/text()").get(
                                       default='').strip(), )
            result.append(tieba_note)
        return result

    def extract_tieba_note_list(self, page_content: str) -> List[TiebaNote]:
        """
        Extract Tieba post list from Tieba page
        Args:
            page_content: HTML string of page content

        Returns:
            List of Tieba post objects
        """
        page_content = page_content.replace('        








                        
网球风云吧 关注:48,523贴子:5,418,043

【强烈恭喜】全红婵陈宇汐包揽跳水女子10米台巴黎奥运金银牌!

只看楼主收藏回复

中国队第22金!无悬念!



IP属地:福建来自Android客户端1楼2024-08-06 22:09回复
    全后卫冕成功,还是动作质量高,小炸也赢了


    IP属地:福建来自Android客户端2楼2024-08-06 22:10
    收起回复
      全后卫冕,太好了


      IP属地:江苏来自Android客户端3楼2024-08-06 22:10
      收起回复
        毫无悬念


        IP属地:上海来自Android客户端4楼2024-08-06 22:10
        收起回复
          皇后回宫


          IP属地:广西来自Android客户端5楼2024-08-06 22:10
          收起回复
            可惜了,既生婵何生汐


            IP属地:湖北来自Android客户端6楼2024-08-06 22:10
            收起回复
              全最后水花那么大 居然不是8分


              IP属地:中国澳门来自Android客户端7楼2024-08-06 22:10
              收起回复
                除了第三跳小炸一下,其余的都很棒了…


                IP属地:四川来自iPhone客户端8楼2024-08-06 22:10
                收起回复
                  陈完美发挥了还是打不过,没办法


                  IP属地:福建来自iPhone客户端9楼2024-08-06 22:10
                  收起回复


                    IP属地:广东来自Android客户端10楼2024-08-06 22:11
                    回复
                      恭喜全,陈也蛮惨的,好在是有女双金


                      IP属地:江苏来自iPhone客户端11楼2024-08-06 22:11
                      收起回复
                        陈芋汐简直就是跳水队版孙颖莎


                        IP属地:陕西来自Android客户端12楼2024-08-06 22:11
                        收起回复
                          恭喜全后卫冕 也恭喜汐贵妃银牌,汐贵妃挺遗憾的,不管怎么样还是恭喜两位


                          IP属地:广东来自iPhone客户端13楼2024-08-06 22:11
                          收起回复
                            强烈恭喜


                            IP属地:广西来自Android客户端14楼2024-08-06 22:11
                            回复
                              恭喜全后卫冕成功


                              IP属地:江苏来自iPhone客户端15楼2024-08-06 22:11
                              回复
                                恭喜全后卫冕


                                IP属地:上海来自Android客户端16楼2024-08-06 22:11
                                收起回复
                                  全后真的是后。。。确实今天有点紧,正常应该在440-450左右。。。


                                  IP属地:上海17楼2024-08-06 22:11
                                  收起回复
                                    这俩看谁能先熬过对方吧,恭喜


                                    IP属地:上海18楼2024-08-06 22:11
                                    收起回复
                                      全身体姿态确实更好看


                                      IP属地:广西来自Android客户端19楼2024-08-06 22:12
                                      收起回复
                                        质量好,分数没啥问题,主要是207不炸基本没悬念


                                        IP属地:上海来自Android客户端20楼2024-08-06 22:12
                                        收起回复
                                          恭喜


                                          IP属地:山东来自Android客户端21楼2024-08-06 22:12
                                          收起回复
                                            陈这个周期是不是压着全,吊打了,结果巴黎还是输了好难过哦


                                            IP属地:上海来自Android客户端22楼2024-08-06 22:12
                                            收起回复
                                              207没炸炸了6组动作也没想到


                                              IP属地:山东来自iPhone客户端23楼2024-08-06 22:12
                                              收起回复
                                                恭喜两位,都很棒


                                                IP属地:广东来自iPhone客户端24楼2024-08-06 22:12
                                                收起回复
                                                  汐贵妃有点惨。。相比预赛半决赛已经特别好了,今天机会很大的。。。


                                                  IP属地:上海25楼2024-08-06 22:12
                                                  收起回复
                                                    稳稳的幸福


                                                    IP属地:安徽来自iPhone客户端26楼2024-08-06 22:12
                                                    回复
                                                      心疼陈宇汐一秒


                                                      IP属地:安徽27楼2024-08-06 22:12
                                                      收起回复
                                                        全后居然因为赢而哭,真的长大不少,汐贵妃好无奈


                                                        IP属地:广东来自iPhone客户端28楼2024-08-06 22:12
                                                        收起回复
                                                          陈真的太遗憾了


                                                          IP属地:安徽来自Android客户端29楼2024-08-06 22:12
                                                          回复
                                                            汐贵妃最后神情有点落寞


                                                            IP属地:湖南来自iPhone客户端30楼2024-08-06 22:12
                                                            收起回复
                                                              广告
                                                              ================================================ FILE: media_platform/tieba/test_data/note_detail.html ================================================ 对于一个父亲来说,这个女儿14岁就死了【以太比特吧】_百度贴吧
                                                              以太比特吧 关注:309,573贴子:5,368,434

                                                              对于一个父亲来说,这个女儿14岁就死了

                                                              只看楼主收藏回复

                                                              点击展开,查看完整图片


                                                              IP属地:广东来自Android客户端1楼2024-08-05 16:56回复
                                                                本来觉得就凭14岁的这点叛逆父亲不再理她觉得这个这个父亲是有点问题的,后来看到母亲也不理了,我就知道这女的肯定隐藏了很多自己干得垃圾事没说,她活该


                                                                IP属地:广东来自Android客户端2楼2024-08-05 17:07
                                                                收起回复
                                                                  • 铭寒号废了重练一个而已,只是她妈后来才明白这一点
                                                                  • youxi卡米糯小错一般都能包容,能这样多半是原则上大是大非
                                                                  • 你的隔壁王哥十四岁能把人逼到没有一点犹豫的跳楼,有多大的学习压力想过没?这种家庭内为了子女成才会不记一切代价,甚至是以折磨的方式,而之后的一切变故都是由于这次跳楼父亲不闻不问的态度,换作是你心灰意泠后只会做的比他更过分,亲情破裂会让最后一丝克制也一同丧失。
                                                                  • 快拉黑尔父回复 你的隔壁王哥 :闷油瓶的话还能理解一下,小太妹为了得到什么说跳就跳我是一点也不怀疑也不同情的。你现在同情小心以后糟老罪咯。真要对她不好也不至于长大了好多事想明白了反而一直想修复关系。
                                                                  • 你的隔壁王哥回复 快拉黑尔父 :十四岁第一次逃学,还在担心父母会不会打他,说明在此之前完全就是个乖乖女。初三才逃第一次学,如果是太妹初二就已经插着翅膀到处飞了,而且跳楼母亲没有任何心里准备,就说明在以往的形象里是不可能做出这事,说明从一开始就只是正常女学生。
                                                                    • 快拉黑尔父回复 你的隔壁王哥 :人变成太妹,性格一完全变成了很难理解吗?初中时代常有的事
                                                                    • 你的隔壁王哥回复 快拉黑尔父 :如果说是太妹,那么跳楼之前必然会有各种前车之鉴,换句话说为了得到某样需求常用跳楼作为威胁。这种头也不回没有任何犹豫的跳楼,显然不是为了得到什么,就是单纯的寻死,你觉得太妹会这么纯粹的寻死吗?太妹的心理承受能力可高多了,只有未经世事的小白心里破防了才会这么干脆。
                                                                    • 快拉黑尔父回复 你的隔壁王哥 :完全的一面之词,结果可以看到的是什么?14岁钱的好父亲当她死了。对她一直很好的母亲也断了联系。想修复关系的反而是她。告诉你一个众所周知的事,人发言,一定,一定会下意识的美化自己。这是下意识。然后你再看看这个故事。
                                                                    • 快拉黑尔父回复 你的隔壁王哥 :而你所说的这个想索求什么,全包含在了一句叛逆期懂得都懂这一句话里面隐藏了。这就是她下意识的掩盖的事了。
                                                                    • 你的隔壁王哥回复 快拉黑尔父 :你要分析心理啊,纯粹的寻死只会在心里破防的时候才会存在,你如果接触过混社会的太妹,你就会发现他们会以寻求刺激为炫耀的资本,在这种群体内心理承受能力高的离谱。要想让一个学生心里破防,只能让她的天塌了,脆弱的心里才会在极短时间内崩溃,只有长期压抑才会产生这种心理。
                                                                  • 我也说一句

                                                                    还有118条回复,点击查看

                                                                  这女的晚上不回家她爹去找她,被黄毛打进医院,也没来医院看过,最后和黄毛结婚也不来往。想起三套房想爆她爹金币,结果找不到求助平台。幸好她爹跑得快。


                                                                  IP属地:福建来自Android客户端4楼2024-08-05 17:38
                                                                  收起回复
                                                                    我知道,可怜之人,必有()


                                                                    IP属地:浙江来自Android客户端7楼2024-08-05 18:38
                                                                    收起回复
                                                                      太假了,混社会不良太妹,还考高中,选专业。当没有大专么


                                                                      IP属地:天津来自Android客户端8楼2024-08-05 18:43
                                                                      收起回复
                                                                        边倪m蓖


                                                                        IP属地:广东来自Android客户端9楼2024-08-05 18:52
                                                                        回复
                                                                          父亲问题很大,应该在14岁那年再生一个或者领养一个


                                                                          IP属地:河北来自Android客户端10楼2024-08-05 18:59
                                                                          收起回复
                                                                            她爸怎么忍住不创小号的


                                                                            IP属地:浙江来自Android客户端12楼2024-08-05 19:09
                                                                            收起回复
                                                                              站在作者的角度来看,肯定都是挑了对自己及其有利的东西来说了,然而


                                                                              IP属地:四川来自Android客户端13楼2024-08-05 19:11
                                                                              收起回复
                                                                                这个好像是之前新闻里的


                                                                                IP属地:江苏来自Android客户端17楼2024-08-05 19:31
                                                                                收起回复
                                                                                  叛逆期你懂的这6个字包含了不知道多少事父母没对他发火而是耐心劝导也不知道包含了多少,我不好说,而且14岁逃学混社会初三高一的学生这么弄基本也是烂了


                                                                                  IP属地:黑龙江来自Android客户端21楼2024-08-05 20:06
                                                                                  收起回复
                                                                                    我们群有个女的。。。他说他爹家暴。。。喝点酒打她跟他妈。。她还轻生过。。。慢慢的的了解了。。。。他爹好像没那么不堪。。。一个月4000多生活费给她。。。她上学都打出租车。。。他爹还不怎么喝酒。。。他有抑郁症他爹还带她去看病。。。。还学了中医给她食补。。但是他就记得他爹喝酒打她跟他妈,。。。。我就纳了闷了。。。。这两个版本的故事不大对。。。。女人嘴里没实话啊。。。。。她说她爹喝酒打他妈,他直接拿水果刀给他爹捅了。淌了好多血,所以他爹送她进精神病院 反正挺混乱的。。。挺漂亮的一个高中女孩,就喜欢酒吧喝酒。。蹦迪。。。说全班男的都给她表过白。。。但是就喜欢小混混。。。
                                                                                    我得出一个结论。这家伙真有病。。。。她爹绝对对她不错。。。。。也是贱高中家庭好,还喜欢混混很蹦迪。。。。高考才两百还是三百多让同学骂了一顿。。。。破防了在群里哭跑路了。。。


                                                                                    IP属地:山东来自Android客户端22楼2024-08-05 20:19
                                                                                    收起回复
                                                                                      女的独生,八成是结婚嫁了混混日子不如意,想着爆父母金币3套房,后来连母亲都躲着她足以说明一切


                                                                                      IP属地:广东来自Android客户端23楼2024-08-05 20:35
                                                                                      收起回复
                                                                                        活该,早点死别耽误别人


                                                                                        IP属地:江西来自Android客户端24楼2024-08-05 20:37
                                                                                        回复
                                                                                          对自己闭口不谈,不好评价


                                                                                          IP属地:安徽来自Android客户端25楼2024-08-05 22:54
                                                                                          回复
                                                                                            再叛逆也不至于寻死
                                                                                            硬要死那就满足你当你死了


                                                                                            IP属地:广西来自Android客户端26楼2024-08-05 22:57
                                                                                            回复
                                                                                              xxn的话一个标点符号都不能信


                                                                                              IP属地:广西来自Android客户端27楼2024-08-05 23:03
                                                                                              回复
                                                                                                故事太过于离谱,是没讲完还是编的


                                                                                                IP属地:湖北来自Android客户端28楼2024-08-05 23:05
                                                                                                收起回复
                                                                                                  她的母亲从前那么希望这个家和好,对女儿也很好,结果突然也躲着她


                                                                                                  IP属地:新疆来自Android客户端30楼2024-08-05 23:32
                                                                                                  回复
                                                                                                    一眼就是避重就轻,能说的都是最轻的了


                                                                                                    IP属地:广东来自iPhone客户端31楼2024-08-05 23:45
                                                                                                    收起回复
                                                                                                      网传的被隐瞒的另一部分故事,不保真


                                                                                                      IP属地:湖南来自Android客户端32楼2024-08-06 00:08
                                                                                                      收起回复
                                                                                                        一般人做不到的绝情,可疑


                                                                                                        IP属地:陕西来自Android客户端33楼2024-08-06 00:08
                                                                                                        收起回复
                                                                                                          快马加编


                                                                                                          IP属地:四川来自Android客户端35楼2024-08-06 00:13
                                                                                                          回复
                                                                                                            默认信xxn说的话已经很反映现在的环境了


                                                                                                            IP属地:上海来自iPhone客户端36楼2024-08-06 00:30
                                                                                                            回复
                                                                                                              這是最後一個教訓了
                                                                                                              父親給的最後一個教訓,停止了你的反叛期,永久有效


                                                                                                              IP属地:中国香港来自Android客户端37楼2024-08-06 00:39
                                                                                                              回复


                                                                                                                IP属地:河北来自Android客户端38楼2024-08-06 00:39
                                                                                                                回复
                                                                                                                  哇,是没头没尾的讲故事,甚至比聊天记录还干净,这下不得不信了


                                                                                                                  IP属地:湖北来自Android客户端39楼2024-08-06 00:40
                                                                                                                  收起回复


                                                                                                                    IP属地:湖北来自iPhone客户端40楼2024-08-06 00:46
                                                                                                                    收起回复
                                                                                                                      叛逆期,是我懂的那个吗?
                                                                                                                      就是咒他爸要死还找烂仔来对付他爸,给人当街一顿打自己跑路了那个吗?
                                                                                                                      要我说,父母都体现出最大的斯文和忍让了,换作素质低点的可能牙齿都给人干碎了。


                                                                                                                      IP属地:广西来自Android客户端41楼2024-08-06 00:52
                                                                                                                      收起回复
                                                                                                                        自己犯贱能怪谁呢


                                                                                                                        IP属地:浙江来自Android客户端42楼2024-08-06 00:55
                                                                                                                        回复
                                                                                                                          ================================================ FILE: media_platform/tieba/test_data/note_sub_comments.html ================================================
                                                                                                                        • heinzfrentzen :
                                                                                                                          2024-8-6 22:11 回复
                                                                                                                        • 可爱的搬运工94 :陈芋汐水花也不小
                                                                                                                          2024-8-6 22:12 回复
                                                                                                                        • 国际体坛巨星青椒肉丝 :你怀孕了吗 老是呕吐
                                                                                                                          2024-8-6 22:12 回复
                                                                                                                        • 茗花少帅 :你就只看水花,不看空中姿态吗
                                                                                                                          2024-8-6 22:12 回复
                                                                                                                        • 东华武兰 :经典只看水花
                                                                                                                          2024-8-6 22:12 回复
                                                                                                                        • 上下班要注意 :额,分数正常吧
                                                                                                                          2024-8-6 22:13 回复
                                                                                                                        • 静看蚂蚁上树 : 回复 国际体坛巨星青椒肉丝 :吃酸黄瓜吃多了
                                                                                                                          2024-8-6 22:14 回复
                                                                                                                        • 不懂取啥名字😜 : 请你去跟国际泳联投诉
                                                                                                                          2024-8-6 22:15 回复
                                                                                                                        • 💫泽赫拉💯 :第五跳陈空中分腿了,空中姿态明显全红婵更好
                                                                                                                          2024-8-6 22:17 回复
                                                                                                                        • 嗯嗯哦哦啊啊🐶 : 回复 美味蟹黄堡💞 :你不会看起跳高度和空中姿态?
                                                                                                                          2024-8-6 22:17 回复
                                                                                                                        • 我也说一句

                                                                                                                          1 2 下一页 尾页

                                                                                                                        • ================================================ FILE: media_platform/tieba/test_data/search_keyword_notes.html ================================================
                                                                                                                          武汉交互空间科技:富士康10亿加码中国大陆,印度为何逐渐“失宠
                                                                                                                          全球知名的电子制造服务巨头富士康的母公司鸿海精密工业股份有限公司正式对外发布了一则重大投资公告,富士康将在郑州投资建设新事业总部大楼,承载新事业总部功能。这一战略举措不仅彰显了富士康对中国市场持续深化的承诺与信心,也预示着该集团业务版图的新一轮扩张与升级。 项目一期选址位于郑东新区,建筑面积约700公亩,总投资约10亿元人民币。主要建设总部管理中心、研发中心和工程中心、战略产业发展中心、战略产业金融平台、
                                                                                                                          贴吧:武汉交互空间作者:VR虚拟达人 2024-08-05 16:45
                                                                                                                          请各位急用玛尼的小心,骗子最多
                                                                                                                          这里面到处是骗子,大家小心。特别那些叫出村背货的,基本是卖园区,天下没有那么好的事。就是有这好事,我们在边境上的人,比你们最清楚,轮不到你们,边境上比你们胆子大的人大把,你一不熟悉小路,为什么叫你带货。东南亚带货的集结地,一般在南宁,防城港,昆明,西双版纳,临沧然后师机接了走小路出去,南宁,防城港坐船出去。好多都是二十几手的中介,之前卖园区一个三十万,现在不知道行情,但好多园区不收
                                                                                                                          贴吧:背包客作者:贴吧用户_GC64AUS 2024-08-03 07:35
                                                                                                                          *2025泰国冷链制冷运输展*东南亚外贸出口
                                                                                                                          **2025泰国曼谷国际冷库、空调制冷、仓储暨冷链运输展 *2025泰国冷链制冷运输展*东南亚外贸出口-观展游览考察 展出时间:2025-7月(具体时间待定) 展出地点:泰国曼谷会展中心 展会周期:一年一届 组展单位:北京励航国际商务会展有限公司 人员跟团观展补贴!为您节省成本,寻找适合您的市场: 本公司为您提供观展考察机会,让您在大型展会上获得世界同行**科技的资料同时,感受异域文化气息。展会现场走展考察→→当地游览→→当地相关市
                                                                                                                          贴吧:国际展会作者:zhaot_188 2024-07-19 15:44
                                                                                                                          京湘楼创始人肖鑫:创立于北京,植根长沙,百年美食传承
                                                                                                                          来源标题:京湘楼创始人肖鑫:创立于北京,植根长沙,百年美食传承 京湘楼(KING HERO)品牌创始人:肖鑫 京湘楼,KING HERO,集酱板鸭、肥肠、鸭头、鸭脖、鸭肠、小龙虾、牛蛙、捆鸡、鸡爪、鱼嘴巴、鱼尾、鱿鱼、牛肉、猪头肉等特色食品卤制,加工、包装与生产经营。2022年3月在北京朝阳区双井开设了第一家“京湘楼·鲜卤集市”卤味熟食快餐店,2023年5月在湖南省长沙市开福区注册成立了“长沙京湘楼品牌管理有限公司”,以“京湘楼”作为品
                                                                                                                          贴吧:京湘楼作者:天神渡尘 2024-07-17 23:43
                                                                                                                          广州能争取到迪士尼与环球落户吗?
                                                                                                                          不是二选一,而是全都要。上一组数据,上海迪士尼2016年开业就接待游客超过1.2亿人次,香港迪士尼2023全年游客人数才640万人次,约等于无,这么低的入园人次已经引来迪士尼方面的不悦。 美国有两个迪士尼,说实话迪士尼的门票并不高,普通人都去的起,中国完全有能力建两到三个迪士尼,欧洲只有第一个迪士尼,因为它的人口只有中国的一半,假设中国人一年吃一包盐,一年就是14包,那么欧洲就是七亿包盐,盐再便宜,欧洲人也不可能一人吃
                                                                                                                          贴吧:地理作者:SeaRoutes 2024-07-13 20:17
                                                                                                                          #城市GDP#广州应该全力去争取迪士尼和环球影城
                                                                                                                          不是二选一,而是全都要。上一组数据,上海迪士尼2016年开业就接待游客超过1.2亿人次,香港迪士尼2023全年游客人数才640万人次,约等于无,这么低的入园人次已经引来迪士尼方面的不悦。 美国有两个迪士尼,说实话迪士尼的门票并不高,普通人都去的起,中国完全有能力建两到三个迪士尼,欧洲只有第一个迪士尼,因为它的人口只有中国的一半,假设中国人一年吃一包盐,一年就是14包,那么欧洲就是七亿包盐,盐再便宜,欧洲人也不可能一人吃
                                                                                                                          贴吧:城市gdp作者:SeaRoutes 2024-07-13 20:14
                                                                                                                          云南省首批《云南日报》昆明新闻头条聚焦阳宗海省级物流枢纽建设
                                                                                                                          7月11日《云南日报》昆明新闻头条刊发文章《阳宗海风景名胜区立足“衔接西部陆海新通道与中老铁路”优势——加速28个物流枢纽设施建设》聚焦昆明阳宗海风景名胜区系统推进省级物流枢纽建设和功能提升深挖比较优势壮大物流产业据云南省发展和改革委员会在昆明召开的新闻发布会上公布,今年全省共有5地纳入云南省第一批省级物流枢纽和省级骨干冷链物流基地建设名单,其中,昆明市有两家获批,阳宗海物流枢纽上榜!一起来看近日,云南省
                                                                                                                          贴吧:昆明作者: 2024-07-12 23:04
                                                                                                                          寻找弟弟,很久没跟家里联系
                                                                                                                          Kk四期世纪园区,寻找弟弟,外号大佐,F3 2楼,公司cj集团
                                                                                                                          贴吧:东南亚作者:贴吧用户_GC2CtRa 2024-07-11 07:53
                                                                                                                          拉美 非洲 东南亚 南亚等发展中国家不太可能普及八小时双休吧?
                                                                                                                          拉美 和 东南亚的泰国 之类的连毒枭和黑色产业都管不好感觉普及八小时双休不太可能 缅甸和非洲军阀林立 跟军阀谈八小时双休那么不开玩笑?缅北诈骗园区就能看出来。
                                                                                                                          贴吧:历史作者:yoursagain 2024-07-10 09:00
                                                                                                                          东南亚,园区【 工 价 低 】
                                                                                                                          贴吧:园区招商作者:QQ59052966 2024-06-30 12:09
                                                                                                                          ================================================ FILE: media_platform/tieba/test_data/tieba_note_list.html ================================================ 盗墓笔记吧-百度贴吧--喜爱盗墓笔记的有爱稻米聚集地--盗墓笔记吧致力于为广大喜爱《盗墓笔记》的吧友服务,传递官方最新资讯,小说相关同人作品,鼓励吧友原创精品,解密分析、图片、文章等。
                                                                                                                          ================================================ FILE: media_platform/weibo/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/weibo/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/23 15:40 # @Desc : from .client import WeiboClient from .core import WeiboCrawler from .login import WeiboLogin ================================================ FILE: media_platform/weibo/client.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/weibo/client.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/23 15:40 # @Desc : Weibo crawler API request client import asyncio import copy import json import re from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Union from urllib.parse import parse_qs, unquote, urlencode import httpx from httpx import Response from playwright.async_api import BrowserContext, Page from tools.httpx_util import make_async_client from tenacity import retry, stop_after_attempt, wait_fixed import config from proxy.proxy_mixin import ProxyRefreshMixin from tools import utils if TYPE_CHECKING: from proxy.proxy_ip_pool import ProxyIpPool from .exception import DataFetchError from .field import SearchType class WeiboClient(ProxyRefreshMixin): def __init__( self, timeout=60, # If media crawling is enabled, Weibo images need a longer timeout proxy=None, *, headers: Dict[str, str], playwright_page: Page, cookie_dict: Dict[str, str], proxy_ip_pool: Optional["ProxyIpPool"] = None, ): self.proxy = proxy self.timeout = timeout self.headers = headers self._host = "https://m.weibo.cn" self.playwright_page = playwright_page self.cookie_dict = cookie_dict self._image_agent_host = "https://i1.wp.com/" # Initialize proxy pool (from ProxyRefreshMixin) self.init_proxy_pool(proxy_ip_pool) @retry(stop=stop_after_attempt(5), wait=wait_fixed(3)) async def request(self, method, url, **kwargs) -> Union[Response, Dict]: # Check if proxy is expired before each request await self._refresh_proxy_if_expired() enable_return_response = kwargs.pop("return_response", False) async with make_async_client(proxy=self.proxy) as client: response = await client.request(method, url, timeout=self.timeout, **kwargs) if enable_return_response: return response try: data: Dict = response.json() except json.decoder.JSONDecodeError: # issue: #771 Search API returns error 432, retry multiple times + update h5 cookies utils.logger.error(f"[WeiboClient.request] request {method}:{url} err code: {response.status_code} res:{response.text}") await self.playwright_page.goto(self._host) await asyncio.sleep(2) await self.update_cookies(browser_context=self.playwright_page.context) raise DataFetchError(f"get response code error: {response.status_code}") ok_code = data.get("ok") if ok_code == 0: # response error utils.logger.error(f"[WeiboClient.request] request {method}:{url} err, res:{data}") raise DataFetchError(data.get("msg", "response error")) elif ok_code != 1: # unknown error utils.logger.error(f"[WeiboClient.request] request {method}:{url} err, res:{data}") raise DataFetchError(data.get("msg", "unknown error")) else: # response right return data.get("data", {}) async def get(self, uri: str, params=None, headers=None, **kwargs) -> Union[Response, Dict]: final_uri = uri if isinstance(params, dict): final_uri = (f"{uri}?" f"{urlencode(params)}") if headers is None: headers = self.headers return await self.request(method="GET", url=f"{self._host}{final_uri}", headers=headers, **kwargs) async def post(self, uri: str, data: dict) -> Dict: json_str = json.dumps(data, separators=(',', ':'), ensure_ascii=False) return await self.request(method="POST", url=f"{self._host}{uri}", data=json_str, headers=self.headers) async def pong(self) -> bool: """get a note to check if login state is ok""" utils.logger.info("[WeiboClient.pong] Begin pong weibo...") ping_flag = False try: uri = "/api/config" resp_data: Dict = await self.request(method="GET", url=f"{self._host}{uri}", headers=self.headers) if resp_data.get("login"): ping_flag = True else: utils.logger.error(f"[WeiboClient.pong] cookie may be invalid and again login...") except Exception as e: utils.logger.error(f"[WeiboClient.pong] Pong weibo failed: {e}, and try to login again...") ping_flag = False return ping_flag async def update_cookies(self, browser_context: BrowserContext, urls: Optional[List[str]] = None): """ Update cookies from browser context :param browser_context: Browser context :param urls: Optional list of URLs to filter cookies (e.g., ["https://m.weibo.cn"]) If provided, only cookies for these URLs will be retrieved """ if urls: cookies = await browser_context.cookies(urls=urls) utils.logger.info(f"[WeiboClient.update_cookies] Updating cookies for specific URLs: {urls}") else: cookies = await browser_context.cookies() utils.logger.info("[WeiboClient.update_cookies] Updating all cookies") cookie_str, cookie_dict = utils.convert_cookies(cookies) self.headers["Cookie"] = cookie_str self.cookie_dict = cookie_dict utils.logger.info(f"[WeiboClient.update_cookies] Cookie updated successfully, total: {len(cookie_dict)} cookies") async def get_note_by_keyword( self, keyword: str, page: int = 1, search_type: SearchType = SearchType.DEFAULT, ) -> Dict: """ search note by keyword :param keyword: Search keyword for Weibo :param page: Pagination parameter - current page number :param search_type: Search type, see SearchType enum in weibo/field.py :return: """ uri = "/api/container/getIndex" containerid = f"100103type={search_type.value}&q={keyword}" params = { "containerid": containerid, "page_type": "searchall", "page": page, } return await self.get(uri, params) async def get_note_comments(self, mid_id: str, max_id: int, max_id_type: int = 0) -> Dict: """get notes comments :param mid_id: Weibo ID :param max_id: Pagination parameter ID :param max_id_type: Pagination parameter ID type :return: """ uri = "/comments/hotflow" params = { "id": mid_id, "mid": mid_id, "max_id_type": max_id_type, } if max_id > 0: params.update({"max_id": max_id}) referer_url = f"https://m.weibo.cn/detail/{mid_id}" headers = copy.copy(self.headers) headers["Referer"] = referer_url return await self.get(uri, params, headers=headers) async def get_note_all_comments( self, note_id: str, crawl_interval: float = 1.0, callback: Optional[Callable] = None, max_count: int = 10, ): """ get note all comments include sub comments :param note_id: :param crawl_interval: :param callback: :param max_count: :return: """ result = [] is_end = False max_id = -1 max_id_type = 0 while not is_end and len(result) < max_count: comments_res = await self.get_note_comments(note_id, max_id, max_id_type) max_id: int = comments_res.get("max_id") max_id_type: int = comments_res.get("max_id_type") comment_list: List[Dict] = comments_res.get("data", []) is_end = max_id == 0 if len(result) + len(comment_list) > max_count: comment_list = comment_list[:max_count - len(result)] if callback: # If callback function exists, execute it await callback(note_id, comment_list) await asyncio.sleep(crawl_interval) result.extend(comment_list) sub_comment_result = await self.get_comments_all_sub_comments(note_id, comment_list, callback) result.extend(sub_comment_result) return result @staticmethod async def get_comments_all_sub_comments( note_id: str, comment_list: List[Dict], callback: Optional[Callable] = None, ) -> List[Dict]: """ Get all sub-comments of comments Args: note_id: comment_list: callback: Returns: """ if not config.ENABLE_GET_SUB_COMMENTS: utils.logger.info(f"[WeiboClient.get_comments_all_sub_comments] Crawling sub_comment mode is not enabled") return [] res_sub_comments = [] for comment in comment_list: sub_comments = comment.get("comments") if sub_comments and isinstance(sub_comments, list): await callback(note_id, sub_comments) res_sub_comments.extend(sub_comments) return res_sub_comments async def get_note_info_by_id(self, note_id: str) -> Dict: """ Get note details by note ID :param note_id: :return: """ url = f"{self._host}/detail/{note_id}" async with make_async_client(proxy=self.proxy) as client: response = await client.request("GET", url, timeout=self.timeout, headers=self.headers) if response.status_code != 200: raise DataFetchError(f"get weibo detail err: {response.text}") match = re.search(r'var \$render_data = (\[.*?\])\[0\]', response.text, re.DOTALL) if match: render_data_json = match.group(1) render_data_dict = json.loads(render_data_json) note_detail = render_data_dict[0].get("status") note_item = {"mblog": note_detail} return note_item else: utils.logger.info(f"[WeiboClient.get_note_info_by_id] $render_data value not found") return dict() async def get_note_image(self, image_url: str) -> bytes: image_url = image_url[8:] # Remove https:// sub_url = image_url.split("/") image_url = "" for i in range(len(sub_url)): if i == 1: image_url += "large/" # Get high-resolution images elif i == len(sub_url) - 1: image_url += sub_url[i] else: image_url += sub_url[i] + "/" # Weibo image hosting has anti-hotlinking, so proxy access is needed # Since Weibo images are accessed through i1.wp.com, we need to concatenate the URL final_uri = (f"{self._image_agent_host}" f"{image_url}") async with make_async_client(proxy=self.proxy) as client: try: response = await client.request("GET", final_uri, timeout=self.timeout) response.raise_for_status() if not response.reason_phrase == "OK": utils.logger.error(f"[WeiboClient.get_note_image] request {final_uri} err, res:{response.text}") return None else: return response.content except httpx.HTTPError as exc: # some wrong when call httpx.request method, such as connection error, client error, server error or response status code is not 2xx utils.logger.error(f"[DouYinClient.get_aweme_media] {exc.__class__.__name__} for {exc.request.url} - {exc}") # Keep original exception type name for developer debugging return None async def get_creator_container_info(self, creator_id: str) -> Dict: """ Get user's container ID, container information represents the real API request path fid_container_id: Container ID for user's Weibo detail API lfid_container_id: Container ID for user's Weibo list API Args: creator_id: User ID Returns: Dictionary with container IDs """ response = await self.get(f"/u/{creator_id}", return_response=True) m_weibocn_params = response.cookies.get("M_WEIBOCN_PARAMS") if not m_weibocn_params: raise DataFetchError("get containerid failed") m_weibocn_params_dict = parse_qs(unquote(m_weibocn_params)) return {"fid_container_id": m_weibocn_params_dict.get("fid", [""])[0], "lfid_container_id": m_weibocn_params_dict.get("lfid", [""])[0]} async def get_creator_info_by_id(self, creator_id: str) -> Dict: """ Get user details by user ID Args: creator_id: Returns: """ uri = "/api/container/getIndex" containerid = f"100505{creator_id}" params = { "jumpfrom": "weibocom", "type": "uid", "value": creator_id, "containerid":containerid, } user_res = await self.get(uri, params) return user_res async def get_notes_by_creator( self, creator: str, container_id: str, since_id: str = "0", ) -> Dict: """ Get creator's notes Args: creator: Creator ID container_id: Container ID since_id: ID of the last note from previous page Returns: """ uri = "/api/container/getIndex" params = { "jumpfrom": "weibocom", "type": "uid", "value": creator, "containerid": container_id, "since_id": since_id, } return await self.get(uri, params) async def get_all_notes_by_creator_id( self, creator_id: str, container_id: str, crawl_interval: float = 1.0, callback: Optional[Callable] = None, ) -> List[Dict]: """ Get all posts published by a specified user, this method will continuously fetch all posts from a user Args: creator_id: Creator user ID container_id: Container ID for the user crawl_interval: Interval between requests in seconds callback: Optional callback function to process notes Returns: List of all notes """ result = [] notes_has_more = True since_id = "" crawler_total_count = 0 while notes_has_more: notes_res = await self.get_notes_by_creator(creator_id, container_id, since_id) if not notes_res: utils.logger.error(f"[WeiboClient.get_notes_by_creator] The current creator may have been banned by Weibo, so they cannot access the data.") break since_id = notes_res.get("cardlistInfo", {}).get("since_id", "0") if "cards" not in notes_res: utils.logger.info(f"[WeiboClient.get_all_notes_by_creator] No 'notes' key found in response: {notes_res}") break notes = notes_res["cards"] utils.logger.info(f"[WeiboClient.get_all_notes_by_creator] got user_id:{creator_id} notes len : {len(notes)}") notes = [note for note in notes if note.get("card_type") == 9] if callback: await callback(notes) await asyncio.sleep(crawl_interval) result.extend(notes) crawler_total_count += 10 notes_has_more = notes_res.get("cardlistInfo", {}).get("total", 0) > crawler_total_count return result ================================================ FILE: media_platform/weibo/core.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/weibo/core.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/23 15:41 # @Desc : Weibo crawler main workflow code import asyncio import os # import random # Removed as we now use fixed config.CRAWLER_MAX_SLEEP_SEC intervals from asyncio import Task from typing import Dict, List, Optional, Tuple from playwright.async_api import ( BrowserContext, BrowserType, Page, Playwright, async_playwright, ) import config from base.base_crawler import AbstractCrawler from proxy.proxy_ip_pool import IpInfoModel, create_ip_pool from store import weibo as weibo_store from tools import utils from tools.cdp_browser import CDPBrowserManager from var import crawler_type_var, source_keyword_var from .client import WeiboClient from .exception import DataFetchError from .field import SearchType from .help import filter_search_result_card from .login import WeiboLogin class WeiboCrawler(AbstractCrawler): context_page: Page wb_client: WeiboClient browser_context: BrowserContext cdp_manager: Optional[CDPBrowserManager] def __init__(self): self.index_url = "https://www.weibo.com" self.mobile_index_url = "https://m.weibo.cn" self.user_agent = utils.get_user_agent() self.mobile_user_agent = utils.get_mobile_user_agent() self.cdp_manager = None self.ip_proxy_pool = None # Proxy IP pool for automatic proxy refresh async def start(self): playwright_proxy_format, httpx_proxy_format = None, None if config.ENABLE_IP_PROXY: self.ip_proxy_pool = await create_ip_pool(config.IP_PROXY_POOL_COUNT, enable_validate_ip=True) ip_proxy_info: IpInfoModel = await self.ip_proxy_pool.get_proxy() playwright_proxy_format, httpx_proxy_format = utils.format_proxy_info(ip_proxy_info) async with async_playwright() as playwright: # Select launch mode based on configuration if config.ENABLE_CDP_MODE: utils.logger.info("[WeiboCrawler] Launching browser with CDP mode") self.browser_context = await self.launch_browser_with_cdp( playwright, playwright_proxy_format, self.mobile_user_agent, headless=config.CDP_HEADLESS, ) else: utils.logger.info("[WeiboCrawler] Launching browser with standard mode") # Launch a browser context. chromium = playwright.chromium self.browser_context = await self.launch_browser(chromium, None, self.mobile_user_agent, headless=config.HEADLESS) # stealth.min.js is a js script to prevent the website from detecting the crawler. await self.browser_context.add_init_script(path="libs/stealth.min.js") self.context_page = await self.browser_context.new_page() await self.context_page.goto(self.index_url) await asyncio.sleep(2) # Create a client to interact with the xiaohongshu website. self.wb_client = await self.create_weibo_client(httpx_proxy_format) if not await self.wb_client.pong(): login_obj = WeiboLogin( login_type=config.LOGIN_TYPE, login_phone="", # your phone number browser_context=self.browser_context, context_page=self.context_page, cookie_str=config.COOKIES, ) await login_obj.begin() # After successful login, redirect to mobile website and update mobile cookies utils.logger.info("[WeiboCrawler.start] redirect weibo mobile homepage and update cookies on mobile platform") await self.context_page.goto(self.mobile_index_url) await asyncio.sleep(3) # Only get mobile cookies to avoid confusion between PC and mobile cookies await self.wb_client.update_cookies( browser_context=self.browser_context, urls=[self.mobile_index_url] ) crawler_type_var.set(config.CRAWLER_TYPE) if config.CRAWLER_TYPE == "search": # Search for video and retrieve their comment information. await self.search() elif config.CRAWLER_TYPE == "detail": # Get the information and comments of the specified post await self.get_specified_notes() elif config.CRAWLER_TYPE == "creator": # Get creator's information and their notes and comments await self.get_creators_and_notes() else: pass utils.logger.info("[WeiboCrawler.start] Weibo Crawler finished ...") async def search(self): """ search weibo note with keywords :return: """ utils.logger.info("[WeiboCrawler.search] Begin search weibo keywords") weibo_limit_count = 10 # weibo limit page fixed value if config.CRAWLER_MAX_NOTES_COUNT < weibo_limit_count: config.CRAWLER_MAX_NOTES_COUNT = weibo_limit_count start_page = config.START_PAGE # Set the search type based on the configuration for weibo if config.WEIBO_SEARCH_TYPE == "default": search_type = SearchType.DEFAULT elif config.WEIBO_SEARCH_TYPE == "real_time": search_type = SearchType.REAL_TIME elif config.WEIBO_SEARCH_TYPE == "popular": search_type = SearchType.POPULAR elif config.WEIBO_SEARCH_TYPE == "video": search_type = SearchType.VIDEO else: utils.logger.error(f"[WeiboCrawler.search] Invalid WEIBO_SEARCH_TYPE: {config.WEIBO_SEARCH_TYPE}") return for keyword in config.KEYWORDS.split(","): source_keyword_var.set(keyword) utils.logger.info(f"[WeiboCrawler.search] Current search keyword: {keyword}") page = 1 while (page - start_page + 1) * weibo_limit_count <= config.CRAWLER_MAX_NOTES_COUNT: if page < start_page: utils.logger.info(f"[WeiboCrawler.search] Skip page: {page}") page += 1 continue utils.logger.info(f"[WeiboCrawler.search] search weibo keyword: {keyword}, page: {page}") search_res = await self.wb_client.get_note_by_keyword(keyword=keyword, page=page, search_type=search_type) note_id_list: List[str] = [] note_list = filter_search_result_card(search_res.get("cards")) # If full text fetching is enabled, batch get full text of posts note_list = await self.batch_get_notes_full_text(note_list) for note_item in note_list: if note_item: mblog: Dict = note_item.get("mblog") if mblog: note_id_list.append(mblog.get("id")) await weibo_store.update_weibo_note(note_item) await self.get_note_images(mblog) page += 1 # Sleep after page navigation await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[WeiboCrawler.search] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after page {page-1}") await self.batch_get_notes_comments(note_id_list) async def get_specified_notes(self): """ get specified notes info :return: """ semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list = [self.get_note_info_task(note_id=note_id, semaphore=semaphore) for note_id in config.WEIBO_SPECIFIED_ID_LIST] video_details = await asyncio.gather(*task_list) for note_item in video_details: if note_item: await weibo_store.update_weibo_note(note_item) await self.batch_get_notes_comments(config.WEIBO_SPECIFIED_ID_LIST) async def get_note_info_task(self, note_id: str, semaphore: asyncio.Semaphore) -> Optional[Dict]: """ Get note detail task :param note_id: :param semaphore: :return: """ async with semaphore: try: result = await self.wb_client.get_note_info_by_id(note_id) # Sleep after fetching note details await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[WeiboCrawler.get_note_info_task] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after fetching note details {note_id}") return result except DataFetchError as ex: utils.logger.error(f"[WeiboCrawler.get_note_info_task] Get note detail error: {ex}") return None except KeyError as ex: utils.logger.error(f"[WeiboCrawler.get_note_info_task] have not fund note detail note_id:{note_id}, err: {ex}") return None async def batch_get_notes_comments(self, note_id_list: List[str]): """ batch get notes comments :param note_id_list: :return: """ if not config.ENABLE_GET_COMMENTS: utils.logger.info(f"[WeiboCrawler.batch_get_note_comments] Crawling comment mode is not enabled") return utils.logger.info(f"[WeiboCrawler.batch_get_notes_comments] note ids:{note_id_list}") semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list: List[Task] = [] for note_id in note_id_list: task = asyncio.create_task(self.get_note_comments(note_id, semaphore), name=note_id) task_list.append(task) await asyncio.gather(*task_list) async def get_note_comments(self, note_id: str, semaphore: asyncio.Semaphore): """ get comment for note id :param note_id: :param semaphore: :return: """ async with semaphore: try: utils.logger.info(f"[WeiboCrawler.get_note_comments] begin get note_id: {note_id} comments ...") # Sleep before fetching comments await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[WeiboCrawler.get_note_comments] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds before fetching comments for note {note_id}") await self.wb_client.get_note_all_comments( note_id=note_id, crawl_interval=config.CRAWLER_MAX_SLEEP_SEC, # Use fixed interval instead of random callback=weibo_store.batch_update_weibo_note_comments, max_count=config.CRAWLER_MAX_COMMENTS_COUNT_SINGLENOTES, ) except DataFetchError as ex: utils.logger.error(f"[WeiboCrawler.get_note_comments] get note_id: {note_id} comment error: {ex}") except Exception as e: utils.logger.error(f"[WeiboCrawler.get_note_comments] may be been blocked, err:{e}") async def get_note_images(self, mblog: Dict): """ get note images :param mblog: :return: """ if not config.ENABLE_GET_MEIDAS: utils.logger.info(f"[WeiboCrawler.get_note_images] Crawling image mode is not enabled") return pics: List = mblog.get("pics") if not pics: return for pic in pics: if isinstance(pic, str): url = pic pid = url.split("/")[-1].split(".")[0] elif isinstance(pic, dict): url = pic.get("url") pid = pic.get("pid", "") else: continue if not url: continue content = await self.wb_client.get_note_image(url) await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[WeiboCrawler.get_note_images] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after fetching image") if content != None: extension_file_name = url.split(".")[-1] await weibo_store.update_weibo_note_image(pid, content, extension_file_name) async def get_creators_and_notes(self) -> None: """ Get creator's information and their notes and comments Returns: """ utils.logger.info("[WeiboCrawler.get_creators_and_notes] Begin get weibo creators") for user_id in config.WEIBO_CREATOR_ID_LIST: createor_info_res: Dict = await self.wb_client.get_creator_info_by_id(creator_id=user_id) if createor_info_res: createor_info: Dict = createor_info_res.get("userInfo", {}) utils.logger.info(f"[WeiboCrawler.get_creators_and_notes] creator info: {createor_info}") if not createor_info: raise DataFetchError("Get creator info error") await weibo_store.save_creator(user_id, user_info=createor_info) # Create a wrapper callback to get full text before saving data async def save_notes_with_full_text(note_list: List[Dict]): # If full text fetching is enabled, batch get full text first updated_note_list = await self.batch_get_notes_full_text(note_list) await weibo_store.batch_update_weibo_notes(updated_note_list) # Get all note information of the creator all_notes_list = await self.wb_client.get_all_notes_by_creator_id( creator_id=user_id, container_id=f"107603{user_id}", crawl_interval=0, callback=save_notes_with_full_text, ) note_ids = [note_item.get("mblog", {}).get("id") for note_item in all_notes_list if note_item.get("mblog", {}).get("id")] await self.batch_get_notes_comments(note_ids) else: utils.logger.error(f"[WeiboCrawler.get_creators_and_notes] get creator info error, creator_id:{user_id}") async def create_weibo_client(self, httpx_proxy: Optional[str]) -> WeiboClient: """Create xhs client""" utils.logger.info("[WeiboCrawler.create_weibo_client] Begin create weibo API client ...") cookie_str, cookie_dict = utils.convert_cookies(await self.browser_context.cookies(urls=[self.mobile_index_url])) weibo_client_obj = WeiboClient( proxy=httpx_proxy, headers={ "User-Agent": utils.get_mobile_user_agent(), "Cookie": cookie_str, "Origin": "https://m.weibo.cn", "Referer": "https://m.weibo.cn", "Content-Type": "application/json;charset=UTF-8", }, playwright_page=self.context_page, cookie_dict=cookie_dict, proxy_ip_pool=self.ip_proxy_pool, # Pass proxy pool for automatic refresh ) return weibo_client_obj async def launch_browser( self, chromium: BrowserType, playwright_proxy: Optional[Dict], user_agent: Optional[str], headless: bool = True, ) -> BrowserContext: """Launch browser and create browser context""" utils.logger.info("[WeiboCrawler.launch_browser] Begin create browser context ...") if config.SAVE_LOGIN_STATE: user_data_dir = os.path.join(os.getcwd(), "browser_data", config.USER_DATA_DIR % config.PLATFORM) # type: ignore browser_context = await chromium.launch_persistent_context( user_data_dir=user_data_dir, accept_downloads=True, headless=headless, proxy=playwright_proxy, # type: ignore viewport={ "width": 1920, "height": 1080 }, user_agent=user_agent, channel="chrome", # Use system's Chrome stable version ) return browser_context else: browser = await chromium.launch(headless=headless, proxy=playwright_proxy, channel="chrome") # type: ignore browser_context = await browser.new_context(viewport={"width": 1920, "height": 1080}, user_agent=user_agent) return browser_context async def launch_browser_with_cdp( self, playwright: Playwright, playwright_proxy: Optional[Dict], user_agent: Optional[str], headless: bool = True, ) -> BrowserContext: """ Launch browser with CDP mode """ try: self.cdp_manager = CDPBrowserManager() browser_context = await self.cdp_manager.launch_and_connect( playwright=playwright, playwright_proxy=playwright_proxy, user_agent=user_agent, headless=headless, ) # Display browser information browser_info = await self.cdp_manager.get_browser_info() utils.logger.info(f"[WeiboCrawler] CDP browser info: {browser_info}") return browser_context except Exception as e: utils.logger.error(f"[WeiboCrawler] CDP mode startup failed, falling back to standard mode: {e}") # Fallback to standard mode chromium = playwright.chromium return await self.launch_browser(chromium, playwright_proxy, user_agent, headless) async def get_note_full_text(self, note_item: Dict) -> Dict: """ Get full text content of a post If the post content is truncated (isLongText=True), request the detail API to get complete content :param note_item: Post data, contains mblog field :return: Updated post data """ if not config.ENABLE_WEIBO_FULL_TEXT: return note_item mblog = note_item.get("mblog", {}) if not mblog: return note_item # Check if it's a long text is_long_text = mblog.get("isLongText", False) if not is_long_text: return note_item note_id = mblog.get("id") if not note_id: return note_item try: utils.logger.info(f"[WeiboCrawler.get_note_full_text] Fetching full text for note: {note_id}") full_note = await self.wb_client.get_note_info_by_id(note_id) if full_note and full_note.get("mblog"): # Replace original content with complete content note_item["mblog"] = full_note["mblog"] utils.logger.info(f"[WeiboCrawler.get_note_full_text] Successfully fetched full text for note: {note_id}") # Sleep after request to avoid rate limiting await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) except DataFetchError as ex: utils.logger.error(f"[WeiboCrawler.get_note_full_text] Failed to fetch full text for note {note_id}: {ex}") except Exception as ex: utils.logger.error(f"[WeiboCrawler.get_note_full_text] Unexpected error for note {note_id}: {ex}") return note_item async def batch_get_notes_full_text(self, note_list: List[Dict]) -> List[Dict]: """ Batch get full text content of posts :param note_list: List of posts :return: Updated list of posts """ if not config.ENABLE_WEIBO_FULL_TEXT: return note_list result = [] for note_item in note_list: updated_note = await self.get_note_full_text(note_item) result.append(updated_note) return result async def close(self): """Close browser context""" # Special handling if using CDP mode if self.cdp_manager: await self.cdp_manager.cleanup() self.cdp_manager = None else: await self.browser_context.close() utils.logger.info("[WeiboCrawler.close] Browser context closed ...") ================================================ FILE: media_platform/weibo/exception.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/weibo/exception.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/2 18:44 # @Desc : from httpx import RequestError class DataFetchError(RequestError): """something error when fetch""" class IPBlockError(RequestError): """fetch so fast that the server block us ip""" ================================================ FILE: media_platform/weibo/field.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/weibo/field.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/23 15:41 # @Desc : from enum import Enum class SearchType(Enum): # Comprehensive DEFAULT = "1" # Real-time REAL_TIME = "61" # Popular POPULAR = "60" # Video VIDEO = "64" ================================================ FILE: media_platform/weibo/help.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/weibo/help.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/24 17:37 # @Desc : from typing import Dict, List def filter_search_result_card(card_list: List[Dict]) -> List[Dict]: """ Filter Weibo search results, only keep data with card_type of 9 :param card_list: List of card items from search results :return: Filtered list of note items """ note_list: List[Dict] = [] for card_item in card_list: if card_item.get("card_type") == 9: note_list.append(card_item) if len(card_item.get("card_group", [])) > 0: card_group = card_item.get("card_group") for card_group_item in card_group: if card_group_item.get("card_type") == 9: note_list.append(card_group_item) return note_list ================================================ FILE: media_platform/weibo/login.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/weibo/login.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 # -*- coding: utf-8 -*- # @Author : relakkes@gmail.com # @Time : 2023/12/23 15:42 # @Desc : Weibo login implementation import asyncio import functools import sys from typing import Optional from playwright.async_api import BrowserContext, Page from tenacity import (RetryError, retry, retry_if_result, stop_after_attempt, wait_fixed) import config from base.base_crawler import AbstractLogin from tools import utils class WeiboLogin(AbstractLogin): def __init__(self, login_type: str, browser_context: BrowserContext, context_page: Page, login_phone: Optional[str] = "", cookie_str: str = "" ): config.LOGIN_TYPE = login_type self.browser_context = browser_context self.context_page = context_page self.login_phone = login_phone self.cookie_str = cookie_str self.weibo_sso_login_url = "https://passport.weibo.com/sso/signin?entry=miniblog&source=miniblog" async def begin(self): """Start login weibo""" utils.logger.info("[WeiboLogin.begin] Begin login weibo ...") if config.LOGIN_TYPE == "qrcode": await self.login_by_qrcode() elif config.LOGIN_TYPE == "phone": await self.login_by_mobile() elif config.LOGIN_TYPE == "cookie": await self.login_by_cookies() else: raise ValueError( "[WeiboLogin.begin] Invalid Login Type Currently only supported qrcode or phone or cookie ...") @retry(stop=stop_after_attempt(600), wait=wait_fixed(1), retry=retry_if_result(lambda value: value is False)) async def check_login_state(self, no_logged_in_session: str) -> bool: """ Check if the current login status is successful and return True otherwise return False retry decorator will retry 20 times if the return value is False, and the retry interval is 1 second if max retry times reached, raise RetryError """ current_cookie = await self.browser_context.cookies() _, cookie_dict = utils.convert_cookies(current_cookie) if cookie_dict.get("SSOLoginState"): return True current_web_session = cookie_dict.get("WBPSESS") if current_web_session != no_logged_in_session: return True return False async def login_by_qrcode(self): """login weibo website and keep webdriver login state""" utils.logger.info("[WeiboLogin.login_by_qrcode] Begin login weibo by qrcode ...") await self.context_page.goto(self.weibo_sso_login_url) # find login qrcode qrcode_img_selector = "xpath=//img[@class='w-full h-full']" base64_qrcode_img = await utils.find_login_qrcode( self.context_page, selector=qrcode_img_selector ) if not base64_qrcode_img: utils.logger.info("[WeiboLogin.login_by_qrcode] login failed , have not found qrcode please check ....") sys.exit() # show login qrcode partial_show_qrcode = functools.partial(utils.show_qrcode, base64_qrcode_img) asyncio.get_running_loop().run_in_executor(executor=None, func=partial_show_qrcode) utils.logger.info(f"[WeiboLogin.login_by_qrcode] Waiting for scan code login, remaining time is 20s") # get not logged session current_cookie = await self.browser_context.cookies() _, cookie_dict = utils.convert_cookies(current_cookie) no_logged_in_session = cookie_dict.get("WBPSESS") try: await self.check_login_state(no_logged_in_session) except RetryError: utils.logger.info("[WeiboLogin.login_by_qrcode] Login weibo failed by qrcode login method ...") sys.exit() wait_redirect_seconds = 5 utils.logger.info( f"[WeiboLogin.login_by_qrcode] Login successful then wait for {wait_redirect_seconds} seconds redirect ...") await asyncio.sleep(wait_redirect_seconds) async def login_by_mobile(self): pass async def login_by_cookies(self): utils.logger.info("[WeiboLogin.login_by_qrcode] Begin login weibo by cookie ...") for key, value in utils.convert_str_cookie_to_dict(self.cookie_str).items(): await self.browser_context.add_cookies([{ 'name': key, 'value': value, 'domain': ".weibo.cn", 'path': "/" }]) ================================================ FILE: media_platform/xhs/__init__.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/xhs/__init__.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from .core import XiaoHongShuCrawler from .field import * ================================================ FILE: media_platform/xhs/client.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/xhs/client.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import asyncio import json from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union from urllib.parse import urlencode import httpx from playwright.async_api import BrowserContext, Page from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_not_exception_type from tools.httpx_util import make_async_client import config from base.base_crawler import AbstractApiClient from proxy.proxy_mixin import ProxyRefreshMixin from tools import utils if TYPE_CHECKING: from proxy.proxy_ip_pool import ProxyIpPool from .exception import DataFetchError, IPBlockError, NoteNotFoundError from .field import SearchNoteType, SearchSortType from .help import get_search_id from .extractor import XiaoHongShuExtractor from .playwright_sign import sign_with_playwright class XiaoHongShuClient(AbstractApiClient, ProxyRefreshMixin): def __init__( self, timeout=60, # If media crawling is enabled, Xiaohongshu long videos need longer timeout proxy=None, *, headers: Dict[str, str], playwright_page: Page, cookie_dict: Dict[str, str], proxy_ip_pool: Optional["ProxyIpPool"] = None, ): self.proxy = proxy self.timeout = timeout self.headers = headers self._host = "https://edith.xiaohongshu.com" self._domain = "https://www.xiaohongshu.com" self.IP_ERROR_STR = "Network connection error, please check network settings or restart" self.IP_ERROR_CODE = 300012 self.NOTE_NOT_FOUND_CODE = -510000 self.NOTE_ABNORMAL_STR = "Note status abnormal, please check later" self.NOTE_ABNORMAL_CODE = -510001 self.playwright_page = playwright_page self.cookie_dict = cookie_dict self._extractor = XiaoHongShuExtractor() # Initialize proxy pool (from ProxyRefreshMixin) self.init_proxy_pool(proxy_ip_pool) async def _pre_headers(self, url: str, params: Optional[Dict] = None, payload: Optional[Dict] = None) -> Dict: """Request header parameter signing (using playwright injection method) Args: url: Request URL params: GET request parameters payload: POST request parameters Returns: Dict: Signed request header parameters """ a1_value = self.cookie_dict.get("a1", "") # Determine request data, method and URI if params is not None: data = params method = "GET" elif payload is not None: data = payload method = "POST" else: raise ValueError("params or payload is required") # Generate signature using playwright injection method signs = await sign_with_playwright( page=self.playwright_page, uri=url, data=data, a1=a1_value, method=method, ) headers = { "X-S": signs["x-s"], "X-T": signs["x-t"], "x-S-Common": signs["x-s-common"], "X-B3-Traceid": signs["x-b3-traceid"], } self.headers.update(headers) return self.headers @retry(stop=stop_after_attempt(3), wait=wait_fixed(1), retry=retry_if_not_exception_type(NoteNotFoundError)) async def request(self, method, url, **kwargs) -> Union[str, Any]: """ Wrapper for httpx common request method, processes request response Args: method: Request method url: Request URL **kwargs: Other request parameters, such as headers, body, etc. Returns: """ # Check if proxy is expired before each request await self._refresh_proxy_if_expired() # return response.text return_response = kwargs.pop("return_response", False) async with make_async_client(proxy=self.proxy) as client: response = await client.request(method, url, timeout=self.timeout, **kwargs) if response.status_code == 471 or response.status_code == 461: # someday someone maybe will bypass captcha verify_type = response.headers["Verifytype"] verify_uuid = response.headers["Verifyuuid"] msg = f"CAPTCHA appeared, request failed, Verifytype: {verify_type}, Verifyuuid: {verify_uuid}, Response: {response}" utils.logger.error(msg) raise Exception(msg) if return_response: return response.text data: Dict = response.json() if data["success"]: return data.get("data", data.get("success", {})) elif data["code"] == self.IP_ERROR_CODE: raise IPBlockError(self.IP_ERROR_STR) elif data["code"] in (self.NOTE_NOT_FOUND_CODE, self.NOTE_ABNORMAL_CODE): raise NoteNotFoundError(f"Note not found or abnormal, code: {data['code']}") else: err_msg = data.get("msg", None) or f"{response.text}" raise DataFetchError(err_msg) async def get(self, uri: str, params: Optional[Dict] = None) -> Dict: """ GET request, signs request headers Args: uri: Request route params: Request parameters Returns: """ headers = await self._pre_headers(uri, params) full_url = f"{self._host}{uri}" return await self.request( method="GET", url=full_url, headers=headers, params=params ) async def post(self, uri: str, data: dict, **kwargs) -> Dict: """ POST request, signs request headers Args: uri: Request route data: Request body parameters Returns: """ headers = await self._pre_headers(uri, payload=data) json_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False) return await self.request( method="POST", url=f"{self._host}{uri}", data=json_str, headers=headers, **kwargs, ) async def get_note_media(self, url: str) -> Union[bytes, None]: # Check if proxy is expired before request await self._refresh_proxy_if_expired() async with make_async_client(proxy=self.proxy) as client: try: response = await client.request("GET", url, timeout=self.timeout) response.raise_for_status() if not response.reason_phrase == "OK": utils.logger.error( f"[XiaoHongShuClient.get_note_media] request {url} err, res:{response.text}" ) return None else: return response.content except ( httpx.HTTPError ) as exc: # some wrong when call httpx.request method, such as connection error, client error, server error or response status code is not 2xx utils.logger.error( f"[XiaoHongShuClient.get_aweme_media] {exc.__class__.__name__} for {exc.request.url} - {exc}" ) # Keep original exception type name for developer debugging return None async def query_self(self) -> Optional[Dict]: """ Query self user info to check login state Returns: Dict: User info if logged in, None otherwise """ uri = "/api/sns/web/v1/user/selfinfo" headers = await self._pre_headers(uri, params={}) async with make_async_client(proxy=self.proxy) as client: response = await client.get(f"{self._host}{uri}", headers=headers) if response.status_code == 200: return response.json() return None async def pong(self) -> bool: """ Check if login state is still valid by querying self user info Returns: bool: True if logged in, False otherwise """ utils.logger.info("[XiaoHongShuClient.pong] Begin to check login state...") ping_flag = False try: self_info: Dict = await self.query_self() if self_info and self_info.get("data", {}).get("result", {}).get("success"): ping_flag = True except Exception as e: utils.logger.error( f"[XiaoHongShuClient.pong] Check login state failed: {e}, and try to login again..." ) ping_flag = False utils.logger.info(f"[XiaoHongShuClient.pong] Login state result: {ping_flag}") return ping_flag async def update_cookies(self, browser_context: BrowserContext): """ Update cookies method provided by API client, usually called after successful login Args: browser_context: Browser context object Returns: """ cookie_str, cookie_dict = utils.convert_cookies(await browser_context.cookies()) self.headers["Cookie"] = cookie_str self.cookie_dict = cookie_dict async def get_note_by_keyword( self, keyword: str, search_id: str = get_search_id(), page: int = 1, page_size: int = 20, sort: SearchSortType = SearchSortType.GENERAL, note_type: SearchNoteType = SearchNoteType.ALL, ) -> Dict: """ Search notes by keyword Args: keyword: Keyword parameter page: Page number page_size: Page data length sort: Search result sorting specification note_type: Type of note to search Returns: """ uri = "/api/sns/web/v1/search/notes" data = { "keyword": keyword, "page": page, "page_size": page_size, "search_id": search_id, "sort": sort.value, "note_type": note_type.value, } return await self.post(uri, data) async def get_note_by_id( self, note_id: str, xsec_source: str, xsec_token: str, ) -> Dict: """ Get note detail API Args: note_id: Note ID xsec_source: Channel source xsec_token: Token returned from search keyword result list Returns: """ if xsec_source == "": xsec_source = "pc_search" data = { "source_note_id": note_id, "image_formats": ["jpg", "webp", "avif"], "extra": {"need_body_topic": 1}, "xsec_source": xsec_source, "xsec_token": xsec_token, } uri = "/api/sns/web/v1/feed" res = await self.post(uri, data) if res and res.get("items"): res_dict: Dict = res["items"][0]["note_card"] return res_dict # When crawling frequently, some notes may have results while others don't utils.logger.error( f"[XiaoHongShuClient.get_note_by_id] get note id:{note_id} empty and res:{res}" ) return dict() async def get_note_comments( self, note_id: str, xsec_token: str, cursor: str = "", ) -> Dict: """ Get first-level comments API Args: note_id: Note ID xsec_token: Verification token cursor: Pagination cursor Returns: """ uri = "/api/sns/web/v2/comment/page" params = { "note_id": note_id, "cursor": cursor, "top_comment_id": "", "image_formats": "jpg,webp,avif", "xsec_token": xsec_token, } return await self.get(uri, params) async def get_note_sub_comments( self, note_id: str, root_comment_id: str, xsec_token: str, num: int = 10, cursor: str = "", ): """ Get sub-comments under specified parent comment API Args: note_id: Post ID of sub-comments root_comment_id: Root comment ID xsec_token: Verification token num: Pagination quantity cursor: Pagination cursor Returns: """ uri = "/api/sns/web/v2/comment/sub/page" params = { "note_id": note_id, "root_comment_id": root_comment_id, "num": str(num), "cursor": cursor, "image_formats": "jpg,webp,avif", "top_comment_id": "", "xsec_token": xsec_token, } return await self.get(uri, params) async def get_note_all_comments( self, note_id: str, xsec_token: str, crawl_interval: float = 1.0, callback: Optional[Callable] = None, max_count: int = 10, ) -> List[Dict]: """ Get all first-level comments under specified note, this method will continuously find all comment information under a post Args: note_id: Note ID xsec_token: Verification token crawl_interval: Crawl delay per note (seconds) callback: Callback after one note crawl ends max_count: Maximum number of comments to crawl per note Returns: """ result = [] comments_has_more = True comments_cursor = "" while comments_has_more and len(result) < max_count: comments_res = await self.get_note_comments( note_id=note_id, xsec_token=xsec_token, cursor=comments_cursor ) comments_has_more = comments_res.get("has_more", False) comments_cursor = comments_res.get("cursor", "") if "comments" not in comments_res: utils.logger.info( f"[XiaoHongShuClient.get_note_all_comments] No 'comments' key found in response: {comments_res}" ) break comments = comments_res["comments"] if len(result) + len(comments) > max_count: comments = comments[: max_count - len(result)] if callback: await callback(note_id, comments) await asyncio.sleep(crawl_interval) result.extend(comments) sub_comments = await self.get_comments_all_sub_comments( comments=comments, xsec_token=xsec_token, crawl_interval=crawl_interval, callback=callback, ) result.extend(sub_comments) return result async def get_comments_all_sub_comments( self, comments: List[Dict], xsec_token: str, crawl_interval: float = 1.0, callback: Optional[Callable] = None, ) -> List[Dict]: """ Get all second-level comments under specified first-level comments, this method will continuously find all second-level comment information under first-level comments Args: comments: Comment list xsec_token: Verification token crawl_interval: Crawl delay per comment (seconds) callback: Callback after one comment crawl ends Returns: """ if not config.ENABLE_GET_SUB_COMMENTS: utils.logger.info( f"[XiaoHongShuCrawler.get_comments_all_sub_comments] Crawling sub_comment mode is not enabled" ) return [] result = [] for comment in comments: try: note_id = comment.get("note_id") sub_comments = comment.get("sub_comments") if sub_comments and callback: await callback(note_id, sub_comments) sub_comment_has_more = comment.get("sub_comment_has_more") if not sub_comment_has_more: continue root_comment_id = comment.get("id") sub_comment_cursor = comment.get("sub_comment_cursor") while sub_comment_has_more: try: comments_res = await self.get_note_sub_comments( note_id=note_id, root_comment_id=root_comment_id, xsec_token=xsec_token, num=10, cursor=sub_comment_cursor, ) if comments_res is None: utils.logger.info( f"[XiaoHongShuClient.get_comments_all_sub_comments] No response found for note_id: {note_id}" ) break sub_comment_has_more = comments_res.get("has_more", False) sub_comment_cursor = comments_res.get("cursor", "") if "comments" not in comments_res: utils.logger.info( f"[XiaoHongShuClient.get_comments_all_sub_comments] No 'comments' key found in response: {comments_res}" ) break comments = comments_res["comments"] if callback: await callback(note_id, comments) await asyncio.sleep(crawl_interval) result.extend(comments) except DataFetchError as e: utils.logger.warning( f"[XiaoHongShuClient.get_comments_all_sub_comments] Failed to get sub-comments for note_id: {note_id}, root_comment_id: {root_comment_id}, error: {e}. Skipping this comment's sub-comments." ) break # Break out of the sub-comment acquisition loop of the current comment and continue processing the next comment except Exception as e: utils.logger.error( f"[XiaoHongShuClient.get_comments_all_sub_comments] Unexpected error when getting sub-comments for note_id: {note_id}, root_comment_id: {root_comment_id}, error: {e}" ) break except Exception as e: utils.logger.error( f"[XiaoHongShuClient.get_comments_all_sub_comments] Error processing comment: {comment.get('id', 'unknown')}, error: {e}. Continuing with next comment." ) continue # Continue to next comment return result async def get_creator_info( self, user_id: str, xsec_token: str = "", xsec_source: str = "" ) -> Dict: """ Get user profile brief information by parsing user homepage HTML The PC user homepage has window.__INITIAL_STATE__ variable, just parse it Args: user_id: User ID xsec_token: Verification token (optional, pass if included in URL) xsec_source: Channel source (optional, pass if included in URL) Returns: Dict: Creator information """ # Build URI, add xsec parameters to URL if available uri = f"/user/profile/{user_id}" if xsec_token and xsec_source: uri = f"{uri}?xsec_token={xsec_token}&xsec_source={xsec_source}" html_content = await self.request( "GET", self._domain + uri, return_response=True, headers=self.headers ) return self._extractor.extract_creator_info_from_html(html_content) async def get_notes_by_creator( self, creator: str, cursor: str, page_size: int = 30, xsec_token: str = "", xsec_source: str = "pc_feed", ) -> Dict: """ Get creator's notes Args: creator: Creator ID cursor: Last note ID from previous page page_size: Page data length xsec_token: Verification token xsec_source: Channel source Returns: """ uri = f"/api/sns/web/v1/user_posted" params = { "num": page_size, "cursor": cursor, "user_id": creator, "xsec_token": xsec_token, "xsec_source": xsec_source, } return await self.get(uri, params) async def get_all_notes_by_creator( self, user_id: str, crawl_interval: float = 1.0, callback: Optional[Callable] = None, xsec_token: str = "", xsec_source: str = "pc_feed", ) -> List[Dict]: """ Get all posts published by specified user, this method will continuously find all post information under a user Args: user_id: User ID crawl_interval: Crawl delay (seconds) callback: Update callback function after one pagination crawl ends xsec_token: Verification token xsec_source: Channel source Returns: """ result = [] notes_has_more = True notes_cursor = "" while notes_has_more and len(result) < config.CRAWLER_MAX_NOTES_COUNT: notes_res = await self.get_notes_by_creator( user_id, notes_cursor, xsec_token=xsec_token, xsec_source=xsec_source ) if not notes_res: utils.logger.error( f"[XiaoHongShuClient.get_notes_by_creator] The current creator may have been banned by xhs, so they cannot access the data." ) break notes_has_more = notes_res.get("has_more", False) notes_cursor = notes_res.get("cursor", "") if "notes" not in notes_res: utils.logger.info( f"[XiaoHongShuClient.get_all_notes_by_creator] No 'notes' key found in response: {notes_res}" ) break notes = notes_res["notes"] utils.logger.info( f"[XiaoHongShuClient.get_all_notes_by_creator] got user_id:{user_id} notes len : {len(notes)}" ) remaining = config.CRAWLER_MAX_NOTES_COUNT - len(result) if remaining <= 0: break notes_to_add = notes[:remaining] if callback: await callback(notes_to_add) result.extend(notes_to_add) await asyncio.sleep(crawl_interval) utils.logger.info( f"[XiaoHongShuClient.get_all_notes_by_creator] Finished getting notes for user {user_id}, total: {len(result)}" ) return result async def get_note_short_url(self, note_id: str) -> Dict: """ Get note short URL Args: note_id: Note ID Returns: """ uri = f"/api/sns/web/short_url" data = {"original_url": f"{self._domain}/discovery/item/{note_id}"} return await self.post(uri, data=data, return_response=True) @retry(stop=stop_after_attempt(3), wait=wait_fixed(1)) async def get_note_by_id_from_html( self, note_id: str, xsec_source: str, xsec_token: str, enable_cookie: bool = False, ) -> Optional[Dict]: """ Get note details by parsing note detail page HTML, this interface may fail, retry 3 times here copy from https://github.com/ReaJason/xhs/blob/eb1c5a0213f6fbb592f0a2897ee552847c69ea2d/xhs/core.py#L217-L259 thanks for ReaJason Args: note_id: xsec_source: xsec_token: enable_cookie: Returns: """ url = ( "https://www.xiaohongshu.com/explore/" + note_id + f"?xsec_token={xsec_token}&xsec_source={xsec_source}" ) copy_headers = self.headers.copy() if not enable_cookie: del copy_headers["Cookie"] html = await self.request( method="GET", url=url, return_response=True, headers=copy_headers ) return self._extractor.extract_note_detail_from_html(note_id, html) ================================================ FILE: media_platform/xhs/core.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/xhs/core.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import asyncio import os import random from asyncio import Task from typing import Dict, List, Optional from playwright.async_api import ( BrowserContext, BrowserType, Page, Playwright, async_playwright, ) from tenacity import RetryError import config from base.base_crawler import AbstractCrawler from model.m_xiaohongshu import NoteUrlInfo, CreatorUrlInfo from proxy.proxy_ip_pool import IpInfoModel, create_ip_pool from store import xhs as xhs_store from tools import utils from tools.cdp_browser import CDPBrowserManager from var import crawler_type_var, source_keyword_var from .client import XiaoHongShuClient from .exception import DataFetchError, NoteNotFoundError from .field import SearchSortType from .help import parse_note_info_from_note_url, parse_creator_info_from_url, get_search_id from .login import XiaoHongShuLogin class XiaoHongShuCrawler(AbstractCrawler): context_page: Page xhs_client: XiaoHongShuClient browser_context: BrowserContext cdp_manager: Optional[CDPBrowserManager] def __init__(self) -> None: self.index_url = "https://www.xiaohongshu.com" # self.user_agent = utils.get_user_agent() self.user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" self.cdp_manager = None self.ip_proxy_pool = None # Proxy IP pool for automatic proxy refresh async def start(self) -> None: playwright_proxy_format, httpx_proxy_format = None, None if config.ENABLE_IP_PROXY: self.ip_proxy_pool = await create_ip_pool(config.IP_PROXY_POOL_COUNT, enable_validate_ip=True) ip_proxy_info: IpInfoModel = await self.ip_proxy_pool.get_proxy() playwright_proxy_format, httpx_proxy_format = utils.format_proxy_info(ip_proxy_info) async with async_playwright() as playwright: # Choose launch mode based on configuration if config.ENABLE_CDP_MODE: utils.logger.info("[XiaoHongShuCrawler] Launching browser using CDP mode") self.browser_context = await self.launch_browser_with_cdp( playwright, playwright_proxy_format, self.user_agent, headless=config.CDP_HEADLESS, ) else: utils.logger.info("[XiaoHongShuCrawler] Launching browser using standard mode") # Launch a browser context. chromium = playwright.chromium self.browser_context = await self.launch_browser( chromium, playwright_proxy_format, self.user_agent, headless=config.HEADLESS, ) # stealth.min.js is a js script to prevent the website from detecting the crawler. await self.browser_context.add_init_script(path="libs/stealth.min.js") self.context_page = await self.browser_context.new_page() await self.context_page.goto(self.index_url) # Create a client to interact with the Xiaohongshu website. self.xhs_client = await self.create_xhs_client(httpx_proxy_format) if not await self.xhs_client.pong(): login_obj = XiaoHongShuLogin( login_type=config.LOGIN_TYPE, login_phone="", # input your phone number browser_context=self.browser_context, context_page=self.context_page, cookie_str=config.COOKIES, ) await login_obj.begin() await self.xhs_client.update_cookies(browser_context=self.browser_context) crawler_type_var.set(config.CRAWLER_TYPE) if config.CRAWLER_TYPE == "search": # Search for notes and retrieve their comment information. await self.search() elif config.CRAWLER_TYPE == "detail": # Get the information and comments of the specified post await self.get_specified_notes() elif config.CRAWLER_TYPE == "creator": # Get creator's information and their notes and comments await self.get_creators_and_notes() else: pass utils.logger.info("[XiaoHongShuCrawler.start] Xhs Crawler finished ...") async def search(self) -> None: """Search for notes and retrieve their comment information.""" utils.logger.info("[XiaoHongShuCrawler.search] Begin search Xiaohongshu keywords") xhs_limit_count = 20 # Xiaohongshu limit page fixed value if config.CRAWLER_MAX_NOTES_COUNT < xhs_limit_count: config.CRAWLER_MAX_NOTES_COUNT = xhs_limit_count start_page = config.START_PAGE for keyword in config.KEYWORDS.split(","): source_keyword_var.set(keyword) utils.logger.info(f"[XiaoHongShuCrawler.search] Current search keyword: {keyword}") page = 1 search_id = get_search_id() while (page - start_page + 1) * xhs_limit_count <= config.CRAWLER_MAX_NOTES_COUNT: if page < start_page: utils.logger.info(f"[XiaoHongShuCrawler.search] Skip page {page}") page += 1 continue try: utils.logger.info(f"[XiaoHongShuCrawler.search] search Xiaohongshu keyword: {keyword}, page: {page}") note_ids: List[str] = [] xsec_tokens: List[str] = [] notes_res = await self.xhs_client.get_note_by_keyword( keyword=keyword, search_id=search_id, page=page, sort=(SearchSortType(config.SORT_TYPE) if config.SORT_TYPE != "" else SearchSortType.GENERAL), ) utils.logger.info(f"[XiaoHongShuCrawler.search] Search notes response: {notes_res}") if not notes_res or not notes_res.get("has_more", False): utils.logger.info("[XiaoHongShuCrawler.search] No more content!") break semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list = [ self.get_note_detail_async_task( note_id=post_item.get("id"), xsec_source=post_item.get("xsec_source"), xsec_token=post_item.get("xsec_token"), semaphore=semaphore, ) for post_item in notes_res.get("items", {}) if post_item.get("model_type") not in ("rec_query", "hot_query") ] note_details = await asyncio.gather(*task_list) for note_detail in note_details: if note_detail: await xhs_store.update_xhs_note(note_detail) await self.get_notice_media(note_detail) note_ids.append(note_detail.get("note_id")) xsec_tokens.append(note_detail.get("xsec_token")) page += 1 utils.logger.info(f"[XiaoHongShuCrawler.search] Note details: {note_details}") await self.batch_get_note_comments(note_ids, xsec_tokens) # Sleep after each page navigation await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[XiaoHongShuCrawler.search] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after page {page-1}") except DataFetchError: utils.logger.error("[XiaoHongShuCrawler.search] Get note detail error") break async def get_creators_and_notes(self) -> None: """Get creator's notes and retrieve their comment information.""" utils.logger.info("[XiaoHongShuCrawler.get_creators_and_notes] Begin get Xiaohongshu creators") for creator_url in config.XHS_CREATOR_ID_LIST: try: # Parse creator URL to get user_id and security tokens creator_info: CreatorUrlInfo = parse_creator_info_from_url(creator_url) utils.logger.info(f"[XiaoHongShuCrawler.get_creators_and_notes] Parse creator URL info: {creator_info}") user_id = creator_info.user_id # get creator detail info from web html content createor_info: Dict = await self.xhs_client.get_creator_info( user_id=user_id, xsec_token=creator_info.xsec_token, xsec_source=creator_info.xsec_source ) if createor_info: await xhs_store.save_creator(user_id, creator=createor_info) except ValueError as e: utils.logger.error(f"[XiaoHongShuCrawler.get_creators_and_notes] Failed to parse creator URL: {e}") continue # Use fixed crawling interval crawl_interval = config.CRAWLER_MAX_SLEEP_SEC # Get all note information of the creator all_notes_list = await self.xhs_client.get_all_notes_by_creator( user_id=user_id, crawl_interval=crawl_interval, callback=self.fetch_creator_notes_detail, xsec_token=creator_info.xsec_token, xsec_source=creator_info.xsec_source, ) note_ids = [] xsec_tokens = [] for note_item in all_notes_list: note_ids.append(note_item.get("note_id")) xsec_tokens.append(note_item.get("xsec_token")) await self.batch_get_note_comments(note_ids, xsec_tokens) async def fetch_creator_notes_detail(self, note_list: List[Dict]): """Concurrently obtain the specified post list and save the data""" semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list = [ self.get_note_detail_async_task( note_id=post_item.get("note_id"), xsec_source=post_item.get("xsec_source"), xsec_token=post_item.get("xsec_token"), semaphore=semaphore, ) for post_item in note_list ] note_details = await asyncio.gather(*task_list) for note_detail in note_details: if note_detail: await xhs_store.update_xhs_note(note_detail) await self.get_notice_media(note_detail) async def get_specified_notes(self): """Get the information and comments of the specified post Note: Must specify note_id, xsec_source, xsec_token """ get_note_detail_task_list = [] for full_note_url in config.XHS_SPECIFIED_NOTE_URL_LIST: note_url_info: NoteUrlInfo = parse_note_info_from_note_url(full_note_url) utils.logger.info(f"[XiaoHongShuCrawler.get_specified_notes] Parse note url info: {note_url_info}") crawler_task = self.get_note_detail_async_task( note_id=note_url_info.note_id, xsec_source=note_url_info.xsec_source, xsec_token=note_url_info.xsec_token, semaphore=asyncio.Semaphore(config.MAX_CONCURRENCY_NUM), ) get_note_detail_task_list.append(crawler_task) need_get_comment_note_ids = [] xsec_tokens = [] note_details = await asyncio.gather(*get_note_detail_task_list) for note_detail in note_details: if note_detail: need_get_comment_note_ids.append(note_detail.get("note_id", "")) xsec_tokens.append(note_detail.get("xsec_token", "")) await xhs_store.update_xhs_note(note_detail) await self.get_notice_media(note_detail) await self.batch_get_note_comments(need_get_comment_note_ids, xsec_tokens) async def get_note_detail_async_task( self, note_id: str, xsec_source: str, xsec_token: str, semaphore: asyncio.Semaphore, ) -> Optional[Dict]: """Get note detail Args: note_id: xsec_source: xsec_token: semaphore: Returns: Dict: note detail """ note_detail = None utils.logger.info(f"[get_note_detail_async_task] Begin get note detail, note_id: {note_id}") async with semaphore: try: try: note_detail = await self.xhs_client.get_note_by_id(note_id, xsec_source, xsec_token) except RetryError: pass if not note_detail: note_detail = await self.xhs_client.get_note_by_id_from_html(note_id, xsec_source, xsec_token, enable_cookie=True) if not note_detail: raise Exception(f"[get_note_detail_async_task] Failed to get note detail, Id: {note_id}") note_detail.update({"xsec_token": xsec_token, "xsec_source": xsec_source}) # Sleep after fetching note detail await asyncio.sleep(config.CRAWLER_MAX_SLEEP_SEC) utils.logger.info(f"[get_note_detail_async_task] Sleeping for {config.CRAWLER_MAX_SLEEP_SEC} seconds after fetching note {note_id}") return note_detail except NoteNotFoundError as ex: utils.logger.warning(f"[XiaoHongShuCrawler.get_note_detail_async_task] Note not found: {note_id}, {ex}") return None except DataFetchError as ex: utils.logger.error(f"[XiaoHongShuCrawler.get_note_detail_async_task] Get note detail error: {ex}") return None except KeyError as ex: utils.logger.error(f"[XiaoHongShuCrawler.get_note_detail_async_task] have not fund note detail note_id:{note_id}, err: {ex}") return None async def batch_get_note_comments(self, note_list: List[str], xsec_tokens: List[str]): """Batch get note comments""" if not config.ENABLE_GET_COMMENTS: utils.logger.info(f"[XiaoHongShuCrawler.batch_get_note_comments] Crawling comment mode is not enabled") return utils.logger.info(f"[XiaoHongShuCrawler.batch_get_note_comments] Begin batch get note comments, note list: {note_list}") semaphore = asyncio.Semaphore(config.MAX_CONCURRENCY_NUM) task_list: List[Task] = [] for index, note_id in enumerate(note_list): task = asyncio.create_task( self.get_comments(note_id=note_id, xsec_token=xsec_tokens[index], semaphore=semaphore), name=note_id, ) task_list.append(task) await asyncio.gather(*task_list) async def get_comments(self, note_id: str, xsec_token: str, semaphore: asyncio.Semaphore): """Get note comments with keyword filtering and quantity limitation""" async with semaphore: utils.logger.info(f"[XiaoHongShuCrawler.get_comments] Begin get note id comments {note_id}") # Use fixed crawling interval crawl_interval = config.CRAWLER_MAX_SLEEP_SEC await self.xhs_client.get_note_all_comments( note_id=note_id, xsec_token=xsec_token, crawl_interval=crawl_interval, callback=xhs_store.batch_update_xhs_note_comments, max_count=config.CRAWLER_MAX_COMMENTS_COUNT_SINGLENOTES, ) # Sleep after fetching comments await asyncio.sleep(crawl_interval) utils.logger.info(f"[XiaoHongShuCrawler.get_comments] Sleeping for {crawl_interval} seconds after fetching comments for note {note_id}") async def create_xhs_client(self, httpx_proxy: Optional[str]) -> XiaoHongShuClient: """Create Xiaohongshu client""" utils.logger.info("[XiaoHongShuCrawler.create_xhs_client] Begin create Xiaohongshu API client ...") cookie_str, cookie_dict = utils.convert_cookies(await self.browser_context.cookies()) xhs_client_obj = XiaoHongShuClient( proxy=httpx_proxy, headers={ "accept": "application/json, text/plain, */*", "accept-language": "zh-CN,zh;q=0.9", "cache-control": "no-cache", "content-type": "application/json;charset=UTF-8", "origin": "https://www.xiaohongshu.com", "pragma": "no-cache", "priority": "u=1, i", "referer": "https://www.xiaohongshu.com/", "sec-ch-ua": '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"', "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": '"Windows"', "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-site", "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Cookie": cookie_str, }, playwright_page=self.context_page, cookie_dict=cookie_dict, proxy_ip_pool=self.ip_proxy_pool, # Pass proxy pool for automatic refresh ) return xhs_client_obj async def launch_browser( self, chromium: BrowserType, playwright_proxy: Optional[Dict], user_agent: Optional[str], headless: bool = True, ) -> BrowserContext: """Launch browser and create browser context""" utils.logger.info("[XiaoHongShuCrawler.launch_browser] Begin create browser context ...") if config.SAVE_LOGIN_STATE: # feat issue #14 # we will save login state to avoid login every time user_data_dir = os.path.join(os.getcwd(), "browser_data", config.USER_DATA_DIR % config.PLATFORM) # type: ignore browser_context = await chromium.launch_persistent_context( user_data_dir=user_data_dir, accept_downloads=True, headless=headless, proxy=playwright_proxy, # type: ignore viewport={ "width": 1920, "height": 1080 }, user_agent=user_agent, ) return browser_context else: browser = await chromium.launch(headless=headless, proxy=playwright_proxy) # type: ignore browser_context = await browser.new_context(viewport={"width": 1920, "height": 1080}, user_agent=user_agent) return browser_context async def launch_browser_with_cdp( self, playwright: Playwright, playwright_proxy: Optional[Dict], user_agent: Optional[str], headless: bool = True, ) -> BrowserContext: """Launch browser using CDP mode""" try: self.cdp_manager = CDPBrowserManager() browser_context = await self.cdp_manager.launch_and_connect( playwright=playwright, playwright_proxy=playwright_proxy, user_agent=user_agent, headless=headless, ) # Display browser information browser_info = await self.cdp_manager.get_browser_info() utils.logger.info(f"[XiaoHongShuCrawler] CDP browser info: {browser_info}") return browser_context except Exception as e: utils.logger.error(f"[XiaoHongShuCrawler] CDP mode launch failed, falling back to standard mode: {e}") # Fall back to standard mode chromium = playwright.chromium return await self.launch_browser(chromium, playwright_proxy, user_agent, headless) async def close(self): """Close browser context""" # Special handling if using CDP mode if self.cdp_manager: await self.cdp_manager.cleanup() self.cdp_manager = None else: await self.browser_context.close() utils.logger.info("[XiaoHongShuCrawler.close] Browser context closed ...") async def get_notice_media(self, note_detail: Dict): if not config.ENABLE_GET_MEIDAS: utils.logger.info(f"[XiaoHongShuCrawler.get_notice_media] Crawling image mode is not enabled") return await self.get_note_images(note_detail) await self.get_notice_video(note_detail) async def get_note_images(self, note_item: Dict): """Get note images. Please use get_notice_media Args: note_item: Note item dictionary """ if not config.ENABLE_GET_MEIDAS: return note_id = note_item.get("note_id") image_list: List[Dict] = note_item.get("image_list", []) for img in image_list: if img.get("url_default") != "": img.update({"url": img.get("url_default")}) if not image_list: return picNum = 0 for pic in image_list: url = pic.get("url") if not url: continue content = await self.xhs_client.get_note_media(url) await asyncio.sleep(random.random()) if content is None: continue extension_file_name = f"{picNum}.jpg" picNum += 1 await xhs_store.update_xhs_note_image(note_id, content, extension_file_name) async def get_notice_video(self, note_item: Dict): """Get note videos. Please use get_notice_media Args: note_item: Note item dictionary """ if not config.ENABLE_GET_MEIDAS: return note_id = note_item.get("note_id") videos = xhs_store.get_video_url_arr(note_item) if not videos: return videoNum = 0 for url in videos: content = await self.xhs_client.get_note_media(url) await asyncio.sleep(random.random()) if content is None: continue extension_file_name = f"{videoNum}.mp4" videoNum += 1 await xhs_store.update_xhs_note_video(note_id, content, extension_file_name) ================================================ FILE: media_platform/xhs/exception.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/xhs/exception.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 from httpx import RequestError class DataFetchError(RequestError): """something error when fetch""" class IPBlockError(RequestError): """fetch so fast that the server block us ip""" class NoteNotFoundError(RequestError): """Note does not exist or is abnormal""" ================================================ FILE: media_platform/xhs/extractor.py ================================================ # -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/media_platform/xhs/extractor.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 import json import re from typing import Dict, Optional import humps class XiaoHongShuExtractor: def __init__(self): pass def extract_note_detail_from_html(self, note_id: str, html: str) -> Optional[Dict]: """Extract note details from HTML Args: html (str): HTML string Returns: Dict: Note details dictionary """ if "noteDetailMap" not in html: # Either a CAPTCHA appeared or the note doesn't exist return None state = re.findall(r"window.__INITIAL_STATE__=({.*})", html)[ 0 ].replace("undefined", '""') if state != "{}": note_dict = humps.decamelize(json.loads(state)) return note_dict["note"]["note_detail_map"][note_id]["note"] return None def extract_creator_info_from_html(self, html: str) -> Optional[Dict]: """Extract user information from HTML Args: html (str): HTML string Returns: Dict: User information dictionary """ match = re.search( r"