[
  {
    "path": ".dockerignore",
    "content": ".venv\n.git\n.idea\ncookies\nconf.py\nsau_frontend/dist\nsau_frontend/node_modules\nsau_frontend/package-lock.json"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n#poetry.lock\n\n# pdm\n#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n#pdm.lock\n#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it\n#   in version control.\n#   https://pdm.fming.dev/#use-with-ide\n.pdm.toml\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n#  and can be added to the global gitignore or merged into this file.  For a more nuclear\n#  option (not recommended) you can uncomment the following to ignore the entire idea folder.\n.idea/\n\n.DS_Store\n\n# ignore cookie file\ntencent_uploader/*.json\nyoutube_uploader/*.json\ndouyin_uploader/*.json\nbilibili_uploader/*.json\ntk_uploader/*.json\n\n# 配置文件\nconf.py\n\n# 账号文件\ncookies\n\n# Frontend\n.vite/\ndist/\nnode_modules/\npackage-lock.json\n\n# database\ndb/database.db\n\n# 临时文件夹\ncookiesFile\nuploadFile\nvideoFile\n\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "## Project Overview\n\nThis project, `social-auto-upload`, is a powerful automation tool designed to help content creators and operators efficiently publish video content to multiple domestic and international mainstream social media platforms in one click. The project implements video upload, scheduled release and other functions for platforms such as `Douyin`, `Bilibili`, `Xiaohongshu`, `Kuaishou`, `WeChat Channel`, `Baijiahao` and `TikTok`.\n\nThe project consists of a Python backend and a Vue.js frontend.\n\n**Backend:**\n\n*   Framework: Flask\n*   Core Functionality:\n    *   Handles file uploads and management.\n    *   Interacts with a SQLite database to store information about files and user accounts.\n    *   Uses `playwright` for browser automation to interact with social media platforms.\n    *   Provides a RESTful API for the frontend to consume.\n    *   Uses Server-Sent Events (SSE) for real-time communication with the frontend during the login process.\n\n**Frontend:**\n\n*   Framework: Vue.js\n*   Build Tool: Vite\n*   UI Library: Element Plus\n*   State Management: Pinia\n*   Routing: Vue Router\n*   Core Functionality:\n    *   Provides a web interface for managing social media accounts, video files, and publishing videos.\n    *   Communicates with the backend via a RESTful API.\n\n**Command-line Interface:**\n\nThe project also provides a command-line interface (CLI) for users who prefer to work from the terminal. The CLI supports two main actions:\n\n*   `login`: To log in to a social media platform.\n*   `upload`: To upload a video to a social media platform, with an option to schedule the upload.\n\n## Building and Running\n\n### Backend\n\n1.  **Install dependencies:**\n    ```bash\n    pip install -r requirements.txt\n    ```\n\n2.  **Install Playwright browser drivers:**\n    ```bash\n    playwright install chromium\n    ```\n\n3.  **Initialize the database:**\n    ```bash\n    python db/createTable.py\n    ```\n\n4.  **Run the backend server:**\n    ```bash\n    python sau_backend.py\n    ```\n    The backend server will start on `http://localhost:5409`.\n\n### Frontend\n\n1.  **Navigate to the frontend directory:**\n    ```bash\n    cd sau_frontend\n    ```\n\n2.  **Install dependencies:**\n    ```bash\n    npm install\n    ```\n\n3.  **Run the development server:**\n    ```bash\n    npm run dev\n    ```\n    The frontend development server will start on `http://localhost:5173`.\n\n### Command-line Interface\n\nTo use the CLI, you can run the `cli_main.py` script with the appropriate arguments.\n\n**Login:**\n\n```bash\npython cli_main.py <platform> <account_name> login\n```\n\n**Upload:**\n\n```bash\npython cli_main.py <platform> <account_name> upload <video_file> [-pt {0,1}] [-t YYYY-MM-DD HH:MM]\n```\n\n## Development Conventions\n\n*   The backend code is located in the root directory and the `myUtils` and `uploader` directories.\n*   The frontend code is located in the `sau_frontend` directory.\n*   The project uses a SQLite database for data storage. The database file is located at `db/database.db`.\n*   The `conf.example.py` file should be copied to `conf.py` and configured with the appropriate settings.\n*   The `requirements.txt` file lists the Python dependencies.\n*   The `package.json` file in the `sau_frontend` directory lists the frontend dependencies.\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:22.21.1 AS builder\n\nWORKDIR /app\n\nRUN npm config set registry https://registry.npmmirror.com\n\nCOPY sau_frontend .\n\nRUN npm install\n\nENV NODE_ENV=production\nENV PATH=/app/node_modules/.bin:$PATH\n\n#   替换前端中的地址\nRUN sed -i 's#\\${baseUrl}##g' /app/src/views/AccountManagement.vue\nRUN sed -i \"s#\\${import\\.meta\\.env\\.VITE_API_BASE_URL || 'http:\\/\\/localhost:5409'}##g\" /app/src/api/material.js\nRUN sed -i 's#localhost:5409##g' /app/.env.production\n\nRUN npm run build\n\n\nFROM python:3.10.19\n\nWORKDIR /app\n\nENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright\n\nRUN apt-get update && apt-get install -y --no-install-recommends libnss3 \\\n    libnspr4 \\\n    libdbus-1-3 \\\n    libatk1.0-0 \\\n    libatk-bridge2.0-0 \\\n    libatspi2.0-0 \\\n    libxcomposite1 \\\n    libxdamage1 \\\n    libxfixes3 \\\n    libxrandr2 \\\n    libgbm1 \\\n    libxkbcommon0 \\\n    libasound2 && rm -rf /var/lib/apt/lists/*\n\nRUN pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple\n\nCOPY requirements.txt requirements.txt\n\nRUN pip install -r requirements.txt\n\nRUN playwright install chromium-headless-shell\n\nCOPY . .\n\nCOPY --from=builder /app/dist/index.html /app\nCOPY --from=builder /app/dist/assets /app/assets\nCOPY --from=builder /app/dist/vite.svg /app/assets\n\nRUN cp conf.example.py conf.py\n\nRUN mkdir -p /app/videoFile\nRUN mkdir -p /app/cookiesFile\n\nEXPOSE 5409\n\nCMD [\"python\", \"sau_backend.py\"]\n"
  },
  {
    "path": "README.md",
    "content": "# social-auto-upload\n\n`social-auto-upload` 是一个强大的自动化工具，旨在帮助内容创作者和运营者高效地将视频内容一键发布到多个国内外主流社交媒体平台。\n项目实现了对 `抖音`、`Bilibili`、`小红书`、`快手`、`视频号`、`百家号` 以及 `TikTok` 等平台的视频上传、定时发布等功能。\n结合各平台 `uploader` 模块，您可以轻松配置和扩展支持的平台，并通过示例脚本快速上手。\n\n<img src=\"media/show/tkupload.gif\" alt=\"tiktok show\" width=\"800\"/>\n\n## 目录\n\n- [💡 功能特性](#💡功能特性)\n- [🚀 支持的平台](#🚀支持的平台)\n- [💾 安装指南](#💾安装指南)\n- [🏁 快速开始](#🏁快速开始)\n- [🐇 项目背景](#🐇项目背景)\n- [📃 详细文档](#📃详细文档)\n- [🐾 交流与支持](#🐾交流与支持)\n- [🤝 贡献指南](#🤝贡献指南)\n- [📜 许可证](#📜许可证)\n- [⭐ Star History](#⭐Star-History)\n\n## 💡功能特性\n\n### 已支持平台\n\n-   **国内平台**:\n    -   [x] 抖音\n    -   [x] 视频号\n    -   [x] Bilibili\n    -   [x] 小红书\n    -   [x] 快手\n    -   [x] 百家号\n-   **国外平台**:\n    -   [x] TikTok\n\n### 核心功能\n\n-   [x] 定时上传 (Cron Job / Scheduled Upload)\n-   [ ] Cookie 管理 (部分实现，持续优化中)\n-   [ ] 国外平台 Proxy 设置 (部分实现)\n\n### 计划支持与开发中\n\n-   **平台扩展**:\n    -   [ ] YouTube\n-   **功能增强**:\n    -   [x] 更易用的版本 (GUI / CLI 交互优化)\n    -   [x] API 封装\n    -   [x] Docker 部署\n    -   [ ] 自动化上传 (更智能的调度策略)\n    -   [ ] 多线程/异步上传优化\n    -   [ ] Slack/消息推送通知\n\n### 2025.10.30目前现状\n该项目本人很长一段时间没维护了，有比较大的问题也是能简单快速修复就修复掉\n\n因为我自己也在创业，每天时间都用不完\n\n目前问题主要集中在\n1. 小红书部分，这部分是直接适用xhs这个库来实现的\n2. web 端（vue版本），这个版本是群友LeeDebug他帮忙做的（再次感谢他）\n\n因为我日常也在用，我用的不是web端，而是最初`uploader`文件夹里的版本，也就是文档里提到的部分https://sap-doc.nasdaddy.com/\n所以这里一般遇到的问题，我都会尝试去解决，一并推送到该仓库\n\n目前能遇到的问题，基本上都比较小，可能是元素变化导致的\n在初期设计的时候，其实我已经参考了某些不可变元素去选择，极大的避免了后期因为平台页面修改导致的元素变化\n\n该项目不仅仅是技术人员，有不少是非技术的从业人员，他们是没能力修复一个简单弱小的bug\n为了能帮助更多的人，所以呼吁**技术小伙伴**\n\n如果大家\n- 修复了一些bug\n- 增加一些对大家有帮助的功能\n\n请积极的提出pr，我会想尽可能的确认后合并的，在此感谢大家对于开源项目的支持，帮助更多的人\n\n我自己也会尽100%的力量，在自己项目稳定后，修bug，加更多的平台，开发出gradio版本（更易部署），大家谅解\n\n---\n\n## 🚀支持的平台\n\n本项目通过各平台对应的 `uploader` 模块实现视频上传功能。您可以在 `examples` 目录下找到各个平台的使用示例脚本。\n\n每个示例脚本展示了如何配置和调用相应的 uploader。\n\n## 💾安装指南\n\n1.  **克隆项目**:\n    ```bash\n    git clone https://github.com/dreammis/social-auto-upload.git\n    cd social-auto-upload\n    ```\n\n2.  **安装依赖**:\n    建议在虚拟环境中安装依赖。\n    ```bash\n    conda create -n social-auto-upload python=3.10\n    conda activate social-auto-upload\n    # 挂载清华镜像 or 命令行代理\n    pip install -r requirements.txt\n    ```\n\n3.  **安装 Playwright 浏览器驱动**:\n    ```bash\n    playwright install chromium firefox\n    ```\n    根据您的需求，至少需要安装 `chromium`。`firefox` 主要用于 TikTok 上传（旧版）。\n\n4.  **修改配置文件**:\n    复制 `conf.example.py` 并重命名为 `conf.py`。\n    在 `conf.py` 中，您需要配置以下内容：\n    -   `LOCAL_CHROME_PATH`: 本地 Chrome 浏览器的路径，比如 `C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe` 保存。\n    \n    **临时解决方案**\n\n    需要在根目录创建 `cookiesFile` 和 `videoFile` 两个文件夹，分别是 存储cookie文件 和 存储上传文件 的文件夹\n\n5.  **配置数据库**:\n    如果 db/database.db 文件不存在，您可以运行以下命令来初始化数据库：\n    ```bash\n    cd db\n    python createTable.py\n    ```\n    此命令将初始化 SQLite 数据库。\n\n6.  **启动后端项目**:\n    ```bash\n    python sau_backend.py\n    ```\n    后端项目将在 `http://localhost:5409` 启动。\n\n7.  **启动前端项目**:\n    ```bash\n    cd sau_frontend\n    npm install\n    npm run dev\n    ```\n    前端项目将在 `http://localhost:5173` 启动，在浏览器中打开此链接即可访问。\n\n\n> 非程序员用户可以参考：[新手级教程](https://juejin.cn/post/7372114027840208911)\n\n\n## 🏁快速开始\n\n1.  **准备 Cookie**: \n    大多数平台需要登录后的 Cookie 信息才能进行操作。请参照 examples 目录下各 `get_xxx_cookie.py` 脚本（例如 get_douyin_cookie.py, get_ks_cookie.py）的说明，运行脚本以生成并保存 Cookie 文件（通常在 `cookies/[PLATFORM]_uploader/account.json`）。\n\n2.  **准备视频文件**: \n    将需要上传的视频文件（通常为 `.mp4` 格式）放置在 videos 目录下。\n    部分平台支持视频封面，可以将封面图片（例如 `.png` 格式，与视频同名）也放在此目录。\n    如果需要上传标题及标签，请在视频文件旁边创建一个同名的 `.txt` 文件，内容为标题和标签，以换行分隔。\n\n3.  **修改并运行示例脚本**:\n    打开 examples 目录中您想使用的平台的上传脚本（例如 upload_video_to_douyin.py）。\n    -   根据脚本内的注释和说明，确认 Cookie 文件路径、视频文件路径等配置是否正确。\n    -   您可以修改脚本以适应您的具体需求，例如批量上传、自定义标题、标签等。\n\n4.  **执行上传**:\n    运行修改后的示例脚本，例如：\n    ```bash\n    python examples/upload_video_to_douyin.py\n    ```\n\n## Docker 环境\n### 自己构建镜像\n1. **构建Docker镜像**:\n    ```\n   docker build -t social-auto-upload:latest .\n   ```\n2. **运行Docker容器**:\n    ```\n   docker run -d -it -p 5409:5409 social-auto-upload:latest\n   ```\n### 使用预构建镜像\n1. **拉取镜像**:\n    ```\n   docker pull gzxy/social-auto-upload:latest\n   ```\n2. **运行Docker容器**:\n    ```\n   docker run -d -it -p 5409:5409 gzxy/social-auto-upload:latest\n   ```\n启动容器后访问：[http://localhost:5409](http://localhost:5409)\n\n## 🐇项目背景\n\n该项目最初是我个人用于自动化管理社交媒体视频发布的工具。我的主要发布策略是提前一天设置定时发布，因此项目中很多定时发布相关的逻辑是基于“第二天”的时间进行计算的。\n\n如果您需要立即发布或其他定制化的发布策略，欢迎研究源码或在社区提问。\n\n## 📃详细文档\n\n更详细的文档和说明，请查看：[social-auto-upload 官方文档](https://sap-doc.nasdaddy.com/)\n\n## 🐾交流与支持\n\n[☕ Donate as u like](https://www.buymeacoffee.com/hysn2001m) - 如果您觉得这个项目对您有帮助，可以考虑赞助。\n\n如果您也是独立开发者、技术爱好者，对 #技术变现 #AI创业 #跨境电商 #自动化工具 #视频创作 等话题感兴趣，欢迎加入社群交流。\n\n### Creator\n\n<table>\n    <td align=\"center\">\n        <a href=\"https://sap-doc.nasdaddy.com/\">\n            <img src=\"media/mp.jpg\" width=\"200px\" alt=\"NasDaddy公众号\"/>\n            <br />\n            <sub><b>微信公众号</b></sub>\n        </a>\n        <br />\n        <a href=\"https://github.com/dreammis/social-auto-upload/commits?author=dreammis\" title=\"Code\">💻</a>\n        <br />\n        关注公众号，后台回复 `上传` 获取加群方式\n    </td>\n    <td align=\"center\">\n        <a href=\"https://sap-doc.nasdaddy.com/\">\n            <img src=\"media/QR.png\" width=\"200px\" alt=\"赞赏码/入群引导\"/>\n            <br />\n            <sub><b>交流群 (通过公众号获取)</b></sub>\n        </a>\n        <br />\n        <a href=\"https://sap-doc.nasdaddy.com/\" title=\"Documentation\">📖</a>\n        <br />\n        如果您觉得项目有用，可以考虑打赏支持一下\n    </td>\n</table>\n\n### Active Core Team\n\n<table>\n    <td align=\"center\">\n        <a href=\"https://leedebug.github.io/\">\n            <img src=\"media/edan-qrcode.png\" width=\"200px\" alt=\"Edan Lee\"/>\n            <br />\n            <sub><b>Edan Lee</b></sub>\n        </a>\n        <br />\n        <a href=\"https://github.com/dreammis/social-auto-upload/commits?author=LeeDebug\" title=\"Code\">💻</a>\n        <a href=\"https://leedebug.github.io/\" title=\"Documentation\">📖</a>\n        <br />\n        封装了 api 接口和 web 前端管理界面\n        <br />\n        （请注明来意：进群、学习、企业咨询等）\n    </td>\n</table>\n\n## 🤝贡献指南\n\n欢迎各种形式的贡献，包括但不限于：\n\n-   提交 Bug报告 和 Feature请求。\n-   改进代码、文档。\n-   分享使用经验和教程。\n\n如果您希望贡献代码，请遵循以下步骤：\n\n1.  Fork 本仓库。\n2.  创建一个新的分支 (`git checkout -b feature/YourFeature` 或 `bugfix/YourBugfix`)。\n3.  提交您的更改 (`git commit -m 'Add some feature'`)。\n4.  Push到您的分支 (`git push origin feature/YourFeature`)。\n5.  创建一个 Pull Request。\n\n## 📜许可证\n\n本项目暂时采用 [MIT License](LICENSE) 开源许可证。\n\n## ⭐Star-History\n\n> 如果这个项目对您有帮助，请给一个 ⭐ Star 以表示支持！\n\n[![Star History Chart](https://api.star-history.com/svg?repos=dreammis/social-auto-upload&type=Date)](https://star-history.com/#dreammis/social-auto-upload&Date)\n"
  },
  {
    "path": "__init__.py",
    "content": ""
  },
  {
    "path": "cli_main.py",
    "content": "import argparse\nimport asyncio\nfrom datetime import datetime\nfrom os.path import exists\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\nfrom uploader.douyin_uploader.main import douyin_setup, DouYinVideo\nfrom uploader.ks_uploader.main import ks_setup, KSVideo\nfrom uploader.tencent_uploader.main import weixin_setup, TencentVideo\nfrom uploader.tk_uploader.main_chrome import tiktok_setup, TiktokVideo\nfrom utils.base_social_media import get_supported_social_media, get_cli_action, SOCIAL_MEDIA_DOUYIN, \\\n    SOCIAL_MEDIA_TENCENT, SOCIAL_MEDIA_TIKTOK, SOCIAL_MEDIA_KUAISHOU\nfrom utils.constant import TencentZoneTypes\nfrom utils.files_times import get_title_and_hashtags\n\n\ndef parse_schedule(schedule_raw):\n    if schedule_raw:\n        schedule = datetime.strptime(schedule_raw, '%Y-%m-%d %H:%M')\n    else:\n        schedule = None\n    return schedule\n\n\nasync def main():\n    # 主解析器\n    parser = argparse.ArgumentParser(description=\"Upload video to multiple social-media.\")\n    parser.add_argument(\"platform\", metavar='platform', choices=get_supported_social_media(), help=\"Choose social-media platform: douyin tencent tiktok kuaishou\")\n\n    parser.add_argument(\"account_name\", type=str, help=\"Account name for the platform: xiaoA\")\n    subparsers = parser.add_subparsers(dest=\"action\", metavar='action', help=\"Choose action\", required=True)\n\n    actions = get_cli_action()\n    for action in actions:\n        action_parser = subparsers.add_parser(action, help=f'{action} operation')\n        if action == 'login':\n            # Login 不需要额外参数\n            continue\n        elif action == 'upload':\n            action_parser.add_argument(\"video_file\", help=\"Path to the Video file\")\n            action_parser.add_argument(\"-pt\", \"--publish_type\", type=int, choices=[0, 1],\n                                       help=\"0 for immediate, 1 for scheduled\", default=0)\n            action_parser.add_argument('-t', '--schedule', help='Schedule UTC time in %Y-%m-%d %H:%M format')\n\n    # 解析命令行参数\n    args = parser.parse_args()\n    # 参数校验\n    if args.action == 'upload':\n        if not exists(args.video_file):\n            raise FileNotFoundError(f'Could not find the video file at {args[\"video_file\"]}')\n        if args.publish_type == 1 and not args.schedule:\n            parser.error(\"The schedule must must be specified for scheduled publishing.\")\n\n    account_file = Path(BASE_DIR / \"cookies\" / f\"{args.platform}_{args.account_name}.json\")\n    account_file.parent.mkdir(exist_ok=True)\n\n    # 根据 action 处理不同的逻辑\n    if args.action == 'login':\n        print(f\"Logging in with account {args.account_name} on platform {args.platform}\")\n        if args.platform == SOCIAL_MEDIA_DOUYIN:\n            await douyin_setup(str(account_file), handle=True)\n        elif args.platform == SOCIAL_MEDIA_TIKTOK:\n            await tiktok_setup(str(account_file), handle=True)\n        elif args.platform == SOCIAL_MEDIA_TENCENT:\n            await weixin_setup(str(account_file), handle=True)\n        elif args.platform == SOCIAL_MEDIA_KUAISHOU:\n            await ks_setup(str(account_file), handle=True)\n    elif args.action == 'upload':\n        title, tags = get_title_and_hashtags(args.video_file)\n        video_file = args.video_file\n\n        if args.publish_type == 0:\n            print(\"Uploading immediately...\")\n            publish_date = 0\n        else:\n            print(\"Scheduling videos...\")\n            publish_date = parse_schedule(args.schedule)\n\n        if args.platform == SOCIAL_MEDIA_DOUYIN:\n            await douyin_setup(account_file, handle=False)\n            app = DouYinVideo(title, video_file, tags, publish_date, account_file)\n        elif args.platform == SOCIAL_MEDIA_TIKTOK:\n            await tiktok_setup(account_file, handle=True)\n            app = TiktokVideo(title, video_file, tags, publish_date, account_file)\n        elif args.platform == SOCIAL_MEDIA_TENCENT:\n            await weixin_setup(account_file, handle=True)\n            category = TencentZoneTypes.LIFESTYLE.value  # 标记原创需要否则不需要传\n            app = TencentVideo(title, video_file, tags, publish_date, account_file, category)\n        elif args.platform == SOCIAL_MEDIA_KUAISHOU:\n            await ks_setup(account_file, handle=True)\n            app = KSVideo(title, video_file, tags, publish_date, account_file)\n        else:\n            print(\"Wrong platform, please check your input\")\n            exit()\n\n        await app.main()\n\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n"
  },
  {
    "path": "conf.example.py",
    "content": "from pathlib import Path\n\nBASE_DIR = Path(__file__).parent.resolve()\nXHS_SERVER = \"http://127.0.0.1:11901\"\nLOCAL_CHROME_PATH = \"\"   # change me necessary！ for example C:/Program Files/Google/Chrome/Application/chrome.exe\nLOCAL_CHROME_HEADLESS = False\n"
  },
  {
    "path": "db/createTable.py",
    "content": "import sqlite3\nimport json\nimport os\n\n# 数据库文件路径（如果不存在会自动创建）\ndb_file = './database.db'\n\n# 如果数据库已存在，则删除旧的表（可选）\n# if os.path.exists(db_file):\n#     os.remove(db_file)\n\n# 连接到SQLite数据库（如果文件不存在则会自动创建）\nconn = sqlite3.connect(db_file)\ncursor = conn.cursor()\n\n# 创建账号记录表\ncursor.execute('''\nCREATE TABLE IF NOT EXISTS user_info (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    type INTEGER NOT NULL,\n    filePath TEXT NOT NULL,  -- 存储文件路径\n    userName TEXT NOT NULL,\n    status INTEGER DEFAULT 0\n)\n''')\n\n# 创建文件记录表\ncursor.execute('''CREATE TABLE IF NOT EXISTS file_records (\n    id INTEGER PRIMARY KEY AUTOINCREMENT, -- 唯一标识每条记录\n    filename TEXT NOT NULL,               -- 文件名\n    filesize REAL,                     -- 文件大小（单位：MB）\n    upload_time DATETIME DEFAULT CURRENT_TIMESTAMP, -- 上传时间，默认当前时间\n    file_path TEXT                        -- 文件路径\n)\n''')\n\n\n# 提交更改\nconn.commit()\nprint(\"✅ 表创建成功\")\n# 关闭连接\nconn.close()"
  },
  {
    "path": "examples/__init__.py",
    "content": ""
  },
  {
    "path": "examples/get_baijiahao_cookie.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\nfrom uploader.baijiahao_uploader.main import baijiahao_setup\n\nif __name__ == '__main__':\n    account_file = Path(BASE_DIR / \"cookies\" / \"baijiahao_uploader\" / \"account.json\")\n    account_file.parent.mkdir(exist_ok=True)\n    cookie_setup = asyncio.run(baijiahao_setup(str(account_file), handle=True))\n"
  },
  {
    "path": "examples/get_bilibili_cookie.py",
    "content": "# cd uploader/bilibili_uploader\n# biliup.exe -u account.json login\n"
  },
  {
    "path": "examples/get_douyin_cookie.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\nfrom uploader.douyin_uploader.main import douyin_setup\n\nif __name__ == '__main__':\n    account_file = Path(BASE_DIR / \"cookies\" / \"douyin_uploader\" / \"account.json\")\n    account_file.parent.mkdir(exist_ok=True)\n    cookie_setup = asyncio.run(douyin_setup(str(account_file), handle=True))\n"
  },
  {
    "path": "examples/get_kuaishou_cookie.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\nfrom uploader.ks_uploader.main import ks_setup\n\nif __name__ == '__main__':\n    account_file = Path(BASE_DIR / \"cookies\" / \"ks_uploader\" / \"account.json\")\n    account_file.parent.mkdir(exist_ok=True)\n    cookie_setup = asyncio.run(ks_setup(str(account_file), handle=True))\n"
  },
  {
    "path": "examples/get_tencent_cookie.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\nfrom uploader.tencent_uploader.main import weixin_setup\n\nif __name__ == '__main__':\n    account_file = Path(BASE_DIR / \"cookies\" / \"tencent_uploader\" / \"account.json\")\n    account_file.parent.mkdir(exist_ok=True)\n    cookie_setup = asyncio.run(weixin_setup(str(account_file), handle=True))\n"
  },
  {
    "path": "examples/get_tk_cookie.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\nfrom uploader.tk_uploader.main_chrome import tiktok_setup\n\nif __name__ == '__main__':\n    account_file = Path(BASE_DIR / \"cookies\" / \"tk_uploader\" / \"account.json\")\n    account_file.parent.mkdir(exist_ok=True)\n    cookie_setup = asyncio.run(tiktok_setup(str(account_file), handle=True))\n"
  },
  {
    "path": "examples/get_xiaohongshu_cookie.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\nfrom uploader.xiaohongshu_uploader.main import xiaohongshu_setup\n\nif __name__ == '__main__':\n    account_file = Path(BASE_DIR / \"cookies\" / \"xiaohongshu_uploader\" / \"account.json\")\n    account_file.parent.mkdir(exist_ok=True)\n    cookie_setup = asyncio.run(xiaohongshu_setup(str(account_file), handle=True))\n"
  },
  {
    "path": "examples/upload_video_to_baijiahao.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\nfrom uploader.baijiahao_uploader.main import baijiahao_setup, BaiJiaHaoVideo\nfrom utils.files_times import generate_schedule_time_next_day, get_title_and_hashtags\n\n\nif __name__ == '__main__':\n    filepath = Path(BASE_DIR) / \"videos\"\n    account_file = Path(BASE_DIR / \"cookies\" / \"baijiahao_uploader\" / \"account.json\")\n    # 获取视频目录\n    folder_path = Path(filepath)\n    # 获取文件夹中的所有文件\n    files = list(folder_path.glob(\"*.mp4\"))\n    file_num = len(files)\n    publish_datetimes = generate_schedule_time_next_day(file_num, 1, daily_times=[16])\n    cookie_setup = asyncio.run(baijiahao_setup(account_file, handle=False))\n    for index, file in enumerate(files):\n        title, tags = get_title_and_hashtags(str(file))\n        thumbnail_path = file.with_suffix('.png')\n        # 打印视频文件名、标题和 hashtag\n        print(f\"视频文件名：{file}\")\n        print(f\"标题：{title}\")\n        print(f\"Hashtag：{tags}\")\n        app = BaiJiaHaoVideo(title, file, tags, publish_datetimes[index], account_file)\n        asyncio.run(app.main(), debug=False)\n"
  },
  {
    "path": "examples/upload_video_to_bilibili.py",
    "content": "import time\nfrom pathlib import Path\n\nfrom uploader.bilibili_uploader.main import read_cookie_json_file, extract_keys_from_json, random_emoji, BilibiliUploader\nfrom conf import BASE_DIR\nfrom utils.constant import VideoZoneTypes\nfrom utils.files_times import generate_schedule_time_next_day, get_title_and_hashtags\n\nif __name__ == '__main__':\n    filepath = Path(BASE_DIR) / \"videos\"\n    # how to get cookie, see the file of get_bilibili_cookie.py.\n    account_file = Path(BASE_DIR / \"cookies\" / \"bilibili_uploader\" / \"account.json\")\n    if not account_file.exists():\n        print(f\"{account_file.name} 配置文件不存在\")\n        exit()\n    cookie_data = read_cookie_json_file(account_file)\n    cookie_data = extract_keys_from_json(cookie_data)\n\n    tid = VideoZoneTypes.SPORTS_FOOTBALL.value  # 设置分区id\n    # 获取视频目录\n    folder_path = Path(filepath)\n    # 获取文件夹中的所有文件\n    files = list(folder_path.glob(\"*.mp4\"))\n    file_num = len(files)\n    timestamps = generate_schedule_time_next_day(file_num, 1, daily_times=[16], timestamps=True)\n\n    for index, file in enumerate(files):\n        title, tags = get_title_and_hashtags(str(file))\n        # just avoid error, bilibili don't allow same title of video.\n        title += random_emoji()\n        tags_str = ','.join([tag for tag in tags])\n        # 打印视频文件名、标题和 hashtag\n        print(f\"视频文件名：{file}\")\n        print(f\"标题：{title}\")\n        print(f\"Hashtag：{tags}\")\n        # I set desc same as title, do what u like.\n        desc = title\n        bili_uploader = BilibiliUploader(cookie_data, file, title, desc, tid, tags, timestamps[index])\n        bili_uploader.upload()\n\n        # life is beautiful don't so rush. be kind be patience\n        time.sleep(30)\n"
  },
  {
    "path": "examples/upload_video_to_douyin.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\nfrom uploader.douyin_uploader.main import douyin_setup, DouYinVideo\nfrom utils.files_times import generate_schedule_time_next_day, get_title_and_hashtags\n\n\nif __name__ == '__main__':\n    filepath = Path(BASE_DIR) / \"videos\"\n    account_file = Path(BASE_DIR / \"cookies\" / \"douyin_uploader\" / \"account.json\")\n    # 获取视频目录\n    folder_path = Path(filepath)\n    # 获取文件夹中的所有文件\n    files = list(folder_path.glob(\"*.mp4\"))\n    file_num = len(files)\n    publish_datetimes = generate_schedule_time_next_day(file_num, 1, daily_times=[16])\n    cookie_setup = asyncio.run(douyin_setup(account_file, handle=False))\n    for index, file in enumerate(files):\n        title, tags = get_title_and_hashtags(str(file))\n        thumbnail_path = file.with_suffix('.png')\n        # 打印视频文件名、标题和 hashtag\n        print(f\"视频文件名：{file}\")\n        print(f\"标题：{title}\")\n        print(f\"Hashtag：{tags}\")\n        # 暂时没有时间修复封面上传，故先隐藏掉该功能\n        # if thumbnail_path.exists():\n            # app = DouYinVideo(title, file, tags, publish_datetimes[index], account_file, thumbnail_path=thumbnail_path)\n        # else:\n        app = DouYinVideo(title, file, tags, publish_datetimes[index], account_file)\n        asyncio.run(app.main(), debug=False)\n"
  },
  {
    "path": "examples/upload_video_to_kuaishou.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\nfrom uploader.ks_uploader.main import ks_setup, KSVideo\nfrom utils.files_times import generate_schedule_time_next_day, get_title_and_hashtags\n\n\nif __name__ == '__main__':\n    filepath = Path(BASE_DIR) / \"videos\"\n    account_file = Path(BASE_DIR / \"cookies\" / \"ks_uploader\" / \"account.json\")\n    # 获取视频目录\n    folder_path = Path(filepath)\n    # 获取文件夹中的所有文件\n    files = list(folder_path.glob(\"*.mp4\"))\n    file_num = len(files)\n    publish_datetimes = generate_schedule_time_next_day(file_num, 1, daily_times=[16])\n    cookie_setup = asyncio.run(ks_setup(account_file, handle=False))\n    for index, file in enumerate(files):\n        title, tags = get_title_and_hashtags(str(file))\n        # 打印视频文件名、标题和 hashtag\n        print(f\"视频文件名：{file}\")\n        print(f\"标题：{title}\")\n        print(f\"Hashtag：{tags}\")\n        app = KSVideo(title, file, tags, publish_datetimes[index], account_file)\n        asyncio.run(app.main(), debug=False)\n"
  },
  {
    "path": "examples/upload_video_to_tencent.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\nfrom uploader.tencent_uploader.main import weixin_setup, TencentVideo\nfrom utils.constant import TencentZoneTypes\nfrom utils.files_times import generate_schedule_time_next_day, get_title_and_hashtags\n\n\nif __name__ == '__main__':\n    filepath = Path(BASE_DIR) / \"videos\"\n    account_file = Path(BASE_DIR / \"cookies\" / \"tencent_uploader\" / \"account.json\")\n    # 获取视频目录\n    folder_path = Path(filepath)\n    # 获取文件夹中的所有文件\n    files = list(folder_path.glob(\"*.mp4\"))\n    file_num = len(files)\n    publish_datetimes = generate_schedule_time_next_day(file_num, 1, daily_times=[16])\n    cookie_setup = asyncio.run(weixin_setup(account_file, handle=True))\n    category = TencentZoneTypes.LIFESTYLE.value  # 标记原创需要否则不需要传\n    for index, file in enumerate(files):\n        title, tags = get_title_and_hashtags(str(file))\n        # 打印视频文件名、标题和 hashtag\n        print(f\"视频文件名：{file}\")\n        print(f\"标题：{title}\")\n        print(f\"Hashtag：{tags}\")\n        app = TencentVideo(title, file, tags, publish_datetimes[index], account_file, category)\n        asyncio.run(app.main(), debug=False)\n"
  },
  {
    "path": "examples/upload_video_to_tiktok.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\n# from tk_uploader.main import tiktok_setup, TiktokVideo\nfrom uploader.tk_uploader.main_chrome import tiktok_setup, TiktokVideo\nfrom utils.files_times import generate_schedule_time_next_day, get_title_and_hashtags\n\n\nif __name__ == '__main__':\n    filepath = Path(BASE_DIR) / \"videos\"\n    account_file = Path(BASE_DIR / \"cookies\" / \"tk_uploader\" / \"account.json\")\n    folder_path = Path(filepath)\n    # get video files from folder\n    files = list(folder_path.glob(\"*.mp4\"))\n    file_num = len(files)\n    publish_datetimes = generate_schedule_time_next_day(file_num, 1, daily_times=[16])\n    cookie_setup = asyncio.run(tiktok_setup(account_file, handle=True))\n    for index, file in enumerate(files):\n        title, tags = get_title_and_hashtags(str(file))\n        thumbnail_path = file.with_suffix('.png')\n        print(f\"video_file_name：{file}\")\n        print(f\"video_title：{title}\")\n        print(f\"video_hashtag：{tags}\")\n        if thumbnail_path.exists():\n            print(f\"thumbnail_file_name：{thumbnail_path}\")\n            app = TiktokVideo(title, file, tags, publish_datetimes[index], account_file, thumbnail_path)\n        else:\n            app = TiktokVideo(title, file, tags, publish_datetimes[index], account_file)\n        asyncio.run(app.main(), debug=False)\n"
  },
  {
    "path": "examples/upload_video_to_xhs.py",
    "content": "import configparser\nfrom pathlib import Path\nfrom time import sleep\n\nfrom xhs import XhsClient\n\nfrom conf import BASE_DIR\nfrom utils.files_times import generate_schedule_time_next_day, get_title_and_hashtags\nfrom uploader.xhs_uploader.main import sign_local, beauty_print\n\nconfig = configparser.RawConfigParser()\nconfig.read(Path(BASE_DIR / \"uploader\" / \"xhs_uploader\" / \"accounts.ini\"))\n\n\nif __name__ == '__main__':\n    filepath = Path(BASE_DIR) / \"videos\"\n    # 获取视频目录\n    folder_path = Path(filepath)\n    # 获取文件夹中的所有文件\n    files = list(folder_path.glob(\"*.mp4\"))\n    file_num = len(files)\n\n    cookies = config['account1']['cookies']\n    xhs_client = XhsClient(cookies, sign=sign_local, timeout=60)\n    # auth cookie\n    # 注意：该校验cookie方式可能并没那么准确\n    try:\n        xhs_client.get_video_first_frame_image_id(\"3214\")\n    except:\n        print(\"cookie 失效\")\n        exit()\n\n    publish_datetimes = generate_schedule_time_next_day(file_num, 1, daily_times=[16])\n\n    for index, file in enumerate(files):\n        title, tags = get_title_and_hashtags(str(file))\n        # 加入到标题 补充标题（xhs 可以填1000字不写白不写）\n        tags_str = ' '.join(['#' + tag for tag in tags])\n        hash_tags_str = ''\n        hash_tags = []\n\n        # 打印视频文件名、标题和 hashtag\n        print(f\"视频文件名：{file}\")\n        print(f\"标题：{title}\")\n        print(f\"Hashtag：{tags}\")\n\n        topics = []\n        # 获取hashtag\n        for i in tags[:3]:\n            topic_official = xhs_client.get_suggest_topic(i)\n            if topic_official:\n                topic_official[0]['type'] = 'topic'\n                topic_one = topic_official[0]\n                hash_tag_name = topic_one['name']\n                hash_tags.append(hash_tag_name)\n                topics.append(topic_one)\n\n        hash_tags_str = ' ' + ' '.join(['#' + tag + '[话题]#' for tag in hash_tags])\n\n        note = xhs_client.create_video_note(title=title[:20], video_path=str(file),\n                                            desc=title + tags_str + hash_tags_str,\n                                            topics=topics,\n                                            is_private=False,\n                                            post_time=publish_datetimes[index].strftime(\"%Y-%m-%d %H:%M:%S\"))\n\n        beauty_print(note)\n        # 强制休眠30s，避免风控（必要）\n        sleep(30)\n"
  },
  {
    "path": "examples/upload_video_to_xiaohongshu.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\nfrom uploader.xiaohongshu_uploader.main import xiaohongshu_setup, XiaoHongShuVideo\nfrom utils.files_times import generate_schedule_time_next_day, get_title_and_hashtags\n\n\nif __name__ == '__main__':\n    filepath = Path(BASE_DIR) / \"videos\"\n    account_file = Path(BASE_DIR / \"cookies\" / \"xiaohongshu_uploader\" / \"58a391ba-4082-11f0-a321-44e51723d63c.json\")\n    # 获取视频目录\n    folder_path = Path(filepath)\n    # 获取文件夹中的所有文件\n    files = list(folder_path.glob(\"*.mp4\"))\n    file_num = len(files)\n    publish_datetimes = generate_schedule_time_next_day(file_num, 1, daily_times=[16])\n    cookie_setup = asyncio.run(xiaohongshu_setup(account_file, handle=False))\n    for index, file in enumerate(files):\n        title, tags = get_title_and_hashtags(str(file))\n        thumbnail_path = file.with_suffix('.png')\n        # 打印视频文件名、标题和 hashtag\n        print(f\"视频文件名：{file}\")\n        print(f\"标题：{title}\")\n        print(f\"Hashtag：{tags}\")\n        # 暂时没有时间修复封面上传，故先隐藏掉该功能\n        # if thumbnail_path.exists():\n            # app = XiaoHongShuVideo(title, file, tags, publish_datetimes[index], account_file, thumbnail_path=thumbnail_path)\n        # else:\n        app = XiaoHongShuVideo(title, file, tags, 0, account_file)\n        asyncio.run(app.main(), debug=False)\n"
  },
  {
    "path": "myUtils/__init__.py",
    "content": ""
  },
  {
    "path": "myUtils/auth.py",
    "content": "import asyncio\nimport configparser\nimport os\n\nfrom playwright.async_api import async_playwright\nfrom xhs import XhsClient\n\nfrom conf import BASE_DIR, LOCAL_CHROME_HEADLESS\nfrom utils.base_social_media import set_init_script\nfrom utils.log import tencent_logger, kuaishou_logger, douyin_logger\nfrom pathlib import Path\nfrom uploader.xhs_uploader.main import sign_local\n\n\nasync def cookie_auth_douyin(account_file):\n    async with async_playwright() as playwright:\n        browser = await playwright.chromium.launch(headless=LOCAL_CHROME_HEADLESS)\n        context = await browser.new_context(storage_state=account_file)\n        context = await set_init_script(context)\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://creator.douyin.com/creator-micro/content/upload\")\n        try:\n            await page.wait_for_url(\"https://creator.douyin.com/creator-micro/content/upload\", timeout=5000)\n            # 2024.06.17 抖音创作者中心改版\n            # 判断\n            # 等待“扫码登录”元素出现，超时 5 秒（如果 5 秒没出现，说明 cookie 有效）\n            try:\n                await page.get_by_text(\"扫码登录\").wait_for(timeout=5000)\n                douyin_logger.error(\"[+] cookie 失效，需要扫码登录\")\n                return False\n            except:\n                douyin_logger.success(\"[+]  cookie 有效\")\n                return True\n        except:\n            douyin_logger.error(\"[+] 等待5秒 cookie 失效\")\n            await context.close()\n            await browser.close()\n            return False\n\n\nasync def cookie_auth_tencent(account_file):\n    async with async_playwright() as playwright:\n        browser = await playwright.chromium.launch(headless=LOCAL_CHROME_HEADLESS)\n        context = await browser.new_context(storage_state=account_file)\n        context = await set_init_script(context)\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://channels.weixin.qq.com/platform/post/create\")\n        try:\n            await page.wait_for_selector('div.title-name:has-text(\"微信小店\")', timeout=5000)  # 等待5秒\n            tencent_logger.error(\"[+] 等待5秒 cookie 失效\")\n            return False\n        except:\n            tencent_logger.success(\"[+] cookie 有效\")\n            return True\n\n\nasync def cookie_auth_ks(account_file):\n    async with async_playwright() as playwright:\n        browser = await playwright.chromium.launch(headless=LOCAL_CHROME_HEADLESS)\n        context = await browser.new_context(storage_state=account_file)\n        context = await set_init_script(context)\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://cp.kuaishou.com/article/publish/video\")\n        try:\n            await page.wait_for_selector(\"div.names div.container div.name:text('机构服务')\", timeout=5000)  # 等待5秒\n\n            kuaishou_logger.info(\"[+] 等待5秒 cookie 失效\")\n            return False\n        except:\n            kuaishou_logger.success(\"[+] cookie 有效\")\n            return True\n\n\nasync def cookie_auth_xhs(account_file):\n    async with async_playwright() as playwright:\n        browser = await playwright.chromium.launch(headless=LOCAL_CHROME_HEADLESS)\n        context = await browser.new_context(storage_state=account_file)\n        context = await set_init_script(context)\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://creator.xiaohongshu.com/creator-micro/content/upload\")\n        try:\n            await page.wait_for_url(\"https://creator.xiaohongshu.com/creator-micro/content/upload\", timeout=5000)\n        except:\n            print(\"[+] 等待5秒 cookie 失效\")\n            await context.close()\n            await browser.close()\n            return False\n        # 2024.06.17 抖音创作者中心改版\n        if await page.get_by_text('手机号登录').count() or await page.get_by_text('扫码登录').count():\n            print(\"[+] 等待5秒 cookie 失效\")\n            return False\n        else:\n            print(\"[+] cookie 有效\")\n            return True\n\n\nasync def check_cookie(type, file_path):\n    match type:\n        # 小红书\n        case 1:\n            return await cookie_auth_xhs(Path(BASE_DIR / \"cookiesFile\" / file_path))\n        # 视频号\n        case 2:\n            return await cookie_auth_tencent(Path(BASE_DIR / \"cookiesFile\" / file_path))\n        # 抖音\n        case 3:\n            return await cookie_auth_douyin(Path(BASE_DIR / \"cookiesFile\" / file_path))\n        # 快手\n        case 4:\n            return await cookie_auth_ks(Path(BASE_DIR / \"cookiesFile\" / file_path))\n        case _:\n            return False\n\n# a = asyncio.run(check_cookie(1,\"3a6cfdc0-3d51-11f0-8507-44e51723d63c.json\"))\n# print(a)\n"
  },
  {
    "path": "myUtils/login.py",
    "content": "import asyncio\nimport sqlite3\n\nfrom playwright.async_api import async_playwright\n\nfrom myUtils.auth import check_cookie\nfrom utils.base_social_media import set_init_script\nimport uuid\nfrom pathlib import Path\nfrom conf import BASE_DIR, LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH\n\n# 统一获取浏览器启动配置（防风控+引入本地浏览器）\ndef get_browser_options():\n    options = {\n        'headless': LOCAL_CHROME_HEADLESS,\n        'args': [\n            '--disable-blink-features=AutomationControlled',  # 核心防爬屏蔽：去掉 window.navigator.webdriver 标签\n            '--lang=zh-CN',\n            '--disable-infobars',\n            '--start-maximized'\n        ]\n    }\n    # 如果用户在 conf.py 里配置了本地 Chrome，就用本地的，这样成功率极高\n    if LOCAL_CHROME_PATH:\n        options['executable_path'] = LOCAL_CHROME_PATH\n\n    return options\n\n# 抖音登录\nasync def douyin_cookie_gen(id,status_queue):\n    url_changed_event = asyncio.Event()\n    async def on_url_change():\n        # 检查是否是主框架的变化\n        if page.url != original_url:\n            url_changed_event.set()\n    async with async_playwright() as playwright:\n        options = get_browser_options()\n        # Make sure to run headed.\n        browser = await playwright.chromium.launch(**options)\n        # Setup context however you like.\n        context = await browser.new_context()  # Pass any options\n        context = await set_init_script(context)\n        # Pause the page, and start recording manually.\n        page = await context.new_page()\n        await page.goto(\"https://creator.douyin.com/\")\n        original_url = page.url\n        img_locator = page.get_by_role(\"img\", name=\"二维码\")\n        # 获取 src 属性值\n        src = await img_locator.get_attribute(\"src\")\n        print(\"✅ 图片地址:\", src)\n        status_queue.put(src)\n        # 监听页面的 'framenavigated' 事件，只关注主框架的变化\n        page.on('framenavigated',\n                lambda frame: asyncio.create_task(on_url_change()) if frame == page.main_frame else None)\n        try:\n            # 等待 URL 变化或超时\n            await asyncio.wait_for(url_changed_event.wait(), timeout=200)  # 最多等待 200 秒\n            print(\"监听页面跳转成功\")\n        except asyncio.TimeoutError:\n            print(\"监听页面跳转超时\")\n            await page.close()\n            await context.close()\n            await browser.close()\n            status_queue.put(\"500\")\n            return None\n        uuid_v1 = uuid.uuid1()\n        print(f\"UUID v1: {uuid_v1}\")\n        # 确保cookiesFile目录存在\n        cookies_dir = Path(BASE_DIR / \"cookiesFile\")\n        cookies_dir.mkdir(exist_ok=True)\n        await context.storage_state(path=cookies_dir / f\"{uuid_v1}.json\")\n        result = await check_cookie(3, f\"{uuid_v1}.json\")\n        if not result:\n            status_queue.put(\"500\")\n            await page.close()\n            await context.close()\n            await browser.close()\n            return None\n        await page.close()\n        await context.close()\n        await browser.close()\n        with sqlite3.connect(Path(BASE_DIR / \"db\" / \"database.db\")) as conn:\n            cursor = conn.cursor()\n            cursor.execute('''\n                                INSERT INTO user_info (type, filePath, userName, status)\n                                VALUES (?, ?, ?, ?)\n                                ''', (3, f\"{uuid_v1}.json\", id, 1))\n            conn.commit()\n            print(\"✅ 用户状态已记录\")\n        status_queue.put(\"200\")\n\n\n# 视频号登录\nasync def get_tencent_cookie(id,status_queue):\n    url_changed_event = asyncio.Event()\n    async def on_url_change():\n        # 检查是否是主框架的变化\n        if page.url != original_url:\n            url_changed_event.set()\n\n    async with async_playwright() as playwright:\n        options = {\n            'args': [\n                '--lang en-GB'\n            ],\n            'headless': LOCAL_CHROME_HEADLESS,  # Set headless option here\n        }\n        # Make sure to run headed.\n        browser = await playwright.chromium.launch(**options)\n        # Setup context however you like.\n        context = await browser.new_context()  # Pass any options\n        # Pause the page, and start recording manually.\n        context = await set_init_script(context)\n        page = await context.new_page()\n        await page.goto(\"https://channels.weixin.qq.com\")\n        original_url = page.url\n\n        # 监听页面的 'framenavigated' 事件，只关注主框架的变化\n        page.on('framenavigated',\n                lambda frame: asyncio.create_task(on_url_change()) if frame == page.main_frame else None)\n\n        # 等待 iframe 出现（最多等 60 秒）\n        iframe_locator = page.frame_locator(\"iframe\").first\n\n        # 获取 iframe 中的第一个 img 元素\n        img_locator = iframe_locator.get_by_role(\"img\").first\n\n        # 获取 src 属性值\n        src = await img_locator.get_attribute(\"src\")\n        print(\"✅ 图片地址:\", src)\n        status_queue.put(src)\n\n        try:\n            # 等待 URL 变化或超时\n            await asyncio.wait_for(url_changed_event.wait(), timeout=200)  # 最多等待 200 秒\n            print(\"监听页面跳转成功\")\n        except asyncio.TimeoutError:\n            status_queue.put(\"500\")\n            print(\"监听页面跳转超时\")\n            await page.close()\n            await context.close()\n            await browser.close()\n            return None\n        uuid_v1 = uuid.uuid1()\n        print(f\"UUID v1: {uuid_v1}\")\n        # 确保cookiesFile目录存在\n        cookies_dir = Path(BASE_DIR / \"cookiesFile\")\n        cookies_dir.mkdir(exist_ok=True)\n        await context.storage_state(path=cookies_dir / f\"{uuid_v1}.json\")\n        result = await check_cookie(2,f\"{uuid_v1}.json\")\n        if not result:\n            status_queue.put(\"500\")\n            await page.close()\n            await context.close()\n            await browser.close()\n            return None\n        await page.close()\n        await context.close()\n        await browser.close()\n\n        with sqlite3.connect(Path(BASE_DIR / \"db\" / \"database.db\")) as conn:\n            cursor = conn.cursor()\n            cursor.execute('''\n                                INSERT INTO user_info (type, filePath, userName, status)\n                                VALUES (?, ?, ?, ?)\n                                ''', (2, f\"{uuid_v1}.json\", id, 1))\n            conn.commit()\n            print(\"✅ 用户状态已记录\")\n        status_queue.put(\"200\")\n\n# 快手登录\nasync def get_ks_cookie(id,status_queue):\n    url_changed_event = asyncio.Event()\n    async def on_url_change():\n        # 检查是否是主框架的变化\n        if page.url != original_url:\n            url_changed_event.set()\n    async with async_playwright() as playwright:\n        options = {\n            'args': [\n                '--lang en-GB'\n            ],\n            'headless': LOCAL_CHROME_HEADLESS,  # Set headless option here\n        }\n        # Make sure to run headed.\n        browser = await playwright.chromium.launch(**options)\n        # Setup context however you like.\n        context = await browser.new_context()  # Pass any options\n        context = await set_init_script(context)\n        # Pause the page, and start recording manually.\n        page = await context.new_page()\n        await page.goto(\"https://cp.kuaishou.com\")\n\n        # 定位并点击“立即登录”按钮（类型为 link）\n        await page.get_by_role(\"link\", name=\"立即登录\").click()\n        await page.get_by_text(\"扫码登录\").click()\n        img_locator = page.get_by_role(\"img\", name=\"qrcode\")\n        # 获取 src 属性值\n        src = await img_locator.get_attribute(\"src\")\n        original_url = page.url\n        print(\"✅ 图片地址:\", src)\n        status_queue.put(src)\n        # 监听页面的 'framenavigated' 事件，只关注主框架的变化\n        page.on('framenavigated',\n                lambda frame: asyncio.create_task(on_url_change()) if frame == page.main_frame else None)\n\n        try:\n            # 等待 URL 变化或超时\n            await asyncio.wait_for(url_changed_event.wait(), timeout=200)  # 最多等待 200 秒\n            print(\"监听页面跳转成功\")\n        except asyncio.TimeoutError:\n            status_queue.put(\"500\")\n            print(\"监听页面跳转超时\")\n            await page.close()\n            await context.close()\n            await browser.close()\n            return None\n        uuid_v1 = uuid.uuid1()\n        print(f\"UUID v1: {uuid_v1}\")\n        # 确保cookiesFile目录存在\n        cookies_dir = Path(BASE_DIR / \"cookiesFile\")\n        cookies_dir.mkdir(exist_ok=True)\n        await context.storage_state(path=cookies_dir / f\"{uuid_v1}.json\")\n        result = await check_cookie(4, f\"{uuid_v1}.json\")\n        if not result:\n            status_queue.put(\"500\")\n            await page.close()\n            await context.close()\n            await browser.close()\n            return None\n        await page.close()\n        await context.close()\n        await browser.close()\n\n        with sqlite3.connect(Path(BASE_DIR / \"db\" / \"database.db\")) as conn:\n            cursor = conn.cursor()\n            cursor.execute('''\n                                        INSERT INTO user_info (type, filePath, userName, status)\n                                        VALUES (?, ?, ?, ?)\n                                        ''', (4, f\"{uuid_v1}.json\", id, 1))\n            conn.commit()\n            print(\"✅ 用户状态已记录\")\n        status_queue.put(\"200\")\n\n# 小红书登录\nasync def xiaohongshu_cookie_gen(id,status_queue):\n    url_changed_event = asyncio.Event()\n\n    async def on_url_change():\n        # 检查是否是主框架的变化\n        if page.url != original_url:\n            url_changed_event.set()\n\n    async with async_playwright() as playwright:\n        options = {\n            'args': [\n                '--lang en-GB'\n            ],\n            'headless': LOCAL_CHROME_HEADLESS,  # Set headless option here\n        }\n        # Make sure to run headed.\n        browser = await playwright.chromium.launch(**options)\n        # Setup context however you like.\n        context = await browser.new_context()  # Pass any options\n        context = await set_init_script(context)\n        # Pause the page, and start recording manually.\n        page = await context.new_page()\n        await page.goto(\"https://creator.xiaohongshu.com/\")\n        await page.locator('img.css-wemwzq').click()\n\n        img_locator = page.get_by_role(\"img\").nth(2)\n        # 获取 src 属性值\n        src = await img_locator.get_attribute(\"src\")\n        original_url = page.url\n        print(\"✅ 图片地址:\", src)\n        status_queue.put(src)\n        # 监听页面的 'framenavigated' 事件，只关注主框架的变化\n        page.on('framenavigated',\n                lambda frame: asyncio.create_task(on_url_change()) if frame == page.main_frame else None)\n\n        try:\n            # 等待 URL 变化或超时\n            await asyncio.wait_for(url_changed_event.wait(), timeout=200)  # 最多等待 200 秒\n            print(\"监听页面跳转成功\")\n        except asyncio.TimeoutError:\n            status_queue.put(\"500\")\n            print(\"监听页面跳转超时\")\n            await page.close()\n            await context.close()\n            await browser.close()\n            return None\n        uuid_v1 = uuid.uuid1()\n        print(f\"UUID v1: {uuid_v1}\")\n        # 确保cookiesFile目录存在\n        cookies_dir = Path(BASE_DIR / \"cookiesFile\")\n        cookies_dir.mkdir(exist_ok=True)\n        await context.storage_state(path=cookies_dir / f\"{uuid_v1}.json\")\n        result = await check_cookie(1, f\"{uuid_v1}.json\")\n        if not result:\n            status_queue.put(\"500\")\n            await page.close()\n            await context.close()\n            await browser.close()\n            return None\n        await page.close()\n        await context.close()\n        await browser.close()\n\n        with sqlite3.connect(Path(BASE_DIR / \"db\" / \"database.db\")) as conn:\n            cursor = conn.cursor()\n            cursor.execute('''\n                           INSERT INTO user_info (type, filePath, userName, status)\n                           VALUES (?, ?, ?, ?)\n                           ''', (1, f\"{uuid_v1}.json\", id, 1))\n            conn.commit()\n            print(\"✅ 用户状态已记录\")\n        status_queue.put(\"200\")\n\n# a = asyncio.run(xiaohongshu_cookie_gen(4,None))\n# print(a)\n"
  },
  {
    "path": "myUtils/postVideo.py",
    "content": "import asyncio\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\nfrom uploader.douyin_uploader.main import DouYinVideo\nfrom uploader.ks_uploader.main import KSVideo\nfrom uploader.tencent_uploader.main import TencentVideo\nfrom uploader.xiaohongshu_uploader.main import XiaoHongShuVideo\nfrom utils.constant import TencentZoneTypes\nfrom utils.files_times import generate_schedule_time_next_day\n\n\ndef post_video_tencent(title,files,tags,account_file,category=TencentZoneTypes.LIFESTYLE.value,enableTimer=False,videos_per_day = 1, daily_times=None,start_days = 0, is_draft=False):\n    # 生成文件的完整路径\n    account_file = [Path(BASE_DIR / \"cookiesFile\" / file) for file in account_file]\n    files = [Path(BASE_DIR / \"videoFile\" / file) for file in files]\n    if enableTimer:\n        publish_datetimes = generate_schedule_time_next_day(len(files), videos_per_day, daily_times,start_days)\n    else:\n        publish_datetimes = [0 for i in range(len(files))]\n    for index, file in enumerate(files):\n        for cookie in account_file:\n            print(f\"文件路径{str(file)}\")\n            # 打印视频文件名、标题和 hashtag\n            print(f\"视频文件名：{file}\")\n            print(f\"标题：{title}\")\n            print(f\"Hashtag：{tags}\")\n            app = TencentVideo(title, str(file), tags, publish_datetimes[index], cookie, category, is_draft)\n            asyncio.run(app.main(), debug=False)\n\n\ndef post_video_DouYin(title,files,tags,account_file,category=TencentZoneTypes.LIFESTYLE.value,enableTimer=False,videos_per_day = 1, daily_times=None,start_days = 0,\n                      thumbnail_path = '',\n                      productLink = '', productTitle = ''):\n    # 生成文件的完整路径\n    account_file = [Path(BASE_DIR / \"cookiesFile\" / file) for file in account_file]\n    files = [Path(BASE_DIR / \"videoFile\" / file) for file in files]\n    if enableTimer:\n        publish_datetimes = generate_schedule_time_next_day(len(files), videos_per_day, daily_times,start_days)\n    else:\n        publish_datetimes = [0 for i in range(len(files))]\n    for index, file in enumerate(files):\n        for cookie in account_file:\n            print(f\"文件路径{str(file)}\")\n            # 打印视频文件名、标题和 hashtag\n            print(f\"视频文件名：{file}\")\n            print(f\"标题：{title}\")\n            print(f\"Hashtag：{tags}\")\n            app = DouYinVideo(title, str(file), tags, publish_datetimes[index], cookie, thumbnail_path, productLink, productTitle)\n            asyncio.run(app.main(), debug=False)\n\n\ndef post_video_ks(title,files,tags,account_file,category=TencentZoneTypes.LIFESTYLE.value,enableTimer=False,videos_per_day = 1, daily_times=None,start_days = 0):\n    # 生成文件的完整路径\n    account_file = [Path(BASE_DIR / \"cookiesFile\" / file) for file in account_file]\n    files = [Path(BASE_DIR / \"videoFile\" / file) for file in files]\n    if enableTimer:\n        publish_datetimes = generate_schedule_time_next_day(len(files), videos_per_day, daily_times,start_days)\n    else:\n        publish_datetimes = [0 for i in range(len(files))]\n    for index, file in enumerate(files):\n        for cookie in account_file:\n            print(f\"文件路径{str(file)}\")\n            # 打印视频文件名、标题和 hashtag\n            print(f\"视频文件名：{file}\")\n            print(f\"标题：{title}\")\n            print(f\"Hashtag：{tags}\")\n            app = KSVideo(title, str(file), tags, publish_datetimes[index], cookie)\n            asyncio.run(app.main(), debug=False)\n\ndef post_video_xhs(title,files,tags,account_file,category=TencentZoneTypes.LIFESTYLE.value,enableTimer=False,videos_per_day = 1, daily_times=None,start_days = 0):\n    # 生成文件的完整路径\n    account_file = [Path(BASE_DIR / \"cookiesFile\" / file) for file in account_file]\n    files = [Path(BASE_DIR / \"videoFile\" / file) for file in files]\n    file_num = len(files)\n    if enableTimer:\n        publish_datetimes = generate_schedule_time_next_day(file_num, videos_per_day, daily_times,start_days)\n    else:\n        publish_datetimes = 0\n    for index, file in enumerate(files):\n        for cookie in account_file:\n            # 打印视频文件名、标题和 hashtag\n            print(f\"视频文件名：{file}\")\n            print(f\"标题：{title}\")\n            print(f\"Hashtag：{tags}\")\n            app = XiaoHongShuVideo(title, file, tags, publish_datetimes, cookie)\n            asyncio.run(app.main(), debug=False)\n\n\n\n# post_video(\"333\",[\"demo.mp4\"],\"d\",\"d\")\n# post_video_DouYin(\"333\",[\"demo.mp4\"],\"d\",\"d\")"
  },
  {
    "path": "sau_backend/README.md",
    "content": "## 启动项目：\npython 版本：3.10\n1. 安装依赖\n    pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple\n2. 删除 db 目录下 database.db（如果没有直接运行createTable.py即可），运行 createTable.py 重新建库，避免出现脏数据\n3. 修改 conf.py最下方 LOCAL_CHROME_PATH 为本地 chrome 浏览器地址\n4. 运行根目录的 sau_backend.py\n5. type字段（平台标识） 1 小红书 2 视频号 3 抖音 4 快手\n## 接口说明\n1. /upload post\n    上传接口，上传成功会返回文件的唯一id，后期靠这个发布视频\n2. /login id参数 用户名 type参数 平台标识：登录流程，前端和后端建立sse连接，后端获取到图片base64编码后返回给前端，前端接受扫码后后端存库后返回200，前端主动断开连接，然后调取/getValidAccounts获取当前所有可用账号\n3. /getValidAccounts 会获取当前所有可用cookie，时间较慢，会逐个校验cookie，status 1 有效 0 无效cookie\n4. /postVideo 发布视频接口 post json传参\n    file_list      /upload获取的文件唯一标识\n    account_list   /getValidAccounts获取的filePath字段\n    type           类型字段（平台标识）\n    title          视频标题\n    tags           视频tag 列表，不带#\n    category       原作者说是原创表示，0表示不是原创其他表示为原创，但测试该字段没有效果\n    enableTimer    是否开启定时发布，默认关闭，开启传True，如果开启，下面三个必传，否则不传\n    videos_per_day 每天发布几个视频\n    daily_times    每天发布视频的时间，整形列表，与上面列表长度保持一致\n    start_days     开始天数，0 代表明天开始定时发布 1 代表明天的明天\n    以上三个字段是我的理解，不知道对不对，也不知道原作者为什么要这么设置\n## 数据库说明\n见当前目录下 db目录，py文件是创建脚本，db文件是sqlite数据库\n## 文件说明\ncookiesFile文件夹 存储cookie文件\nmyUtils文件夹 存储自己封装的python模块\nvideoFile文件夹 文件上传存放位置\nweb 文件夹 web路由目录\nconf.py 全局配置，记得修改配置中 LOCAL_CHROME_PATH 为本机浏览器地址"
  },
  {
    "path": "sau_backend.py",
    "content": "import asyncio\nimport os\nimport sqlite3\nimport threading\nimport time\nimport uuid\nfrom pathlib import Path\nfrom queue import Queue\nfrom flask_cors import CORS\nfrom myUtils.auth import check_cookie\nfrom flask import Flask, request, jsonify, Response, render_template, send_from_directory\nfrom conf import BASE_DIR\nfrom myUtils.login import get_tencent_cookie, douyin_cookie_gen, get_ks_cookie, xiaohongshu_cookie_gen\nfrom myUtils.postVideo import post_video_tencent, post_video_DouYin, post_video_ks, post_video_xhs\n\nactive_queues = {}\napp = Flask(__name__)\n\n#允许所有来源跨域访问\nCORS(app)\n\n# 限制上传文件大小为160MB\napp.config['MAX_CONTENT_LENGTH'] = 160 * 1024 * 1024\n\n# 获取当前目录（假设 index.html 和 assets 在这里）\ncurrent_dir = os.path.dirname(os.path.abspath(__file__))\n\n# 处理所有静态资源请求（未来打包用）\n@app.route('/assets/<filename>')\ndef custom_static(filename):\n    return send_from_directory(os.path.join(current_dir, 'assets'), filename)\n\n# 处理 favicon.ico 静态资源（未来打包用）\n@app.route('/favicon.ico')\ndef favicon():\n    return send_from_directory(os.path.join(current_dir, 'assets'), 'vite.svg')\n\n@app.route('/vite.svg')\ndef vite_svg():\n    return send_from_directory(os.path.join(current_dir, 'assets'), 'vite.svg')\n\n# （未来打包用）\n@app.route('/')\ndef index():  # put application's code here\n    return send_from_directory(current_dir, 'index.html')\n\n@app.route('/upload', methods=['POST'])\ndef upload_file():\n    if 'file' not in request.files:\n        return jsonify({\n            \"code\": 400,\n            \"data\": None,\n            \"msg\": \"No file part in the request\"\n        }), 400\n    file = request.files['file']\n    if file.filename == '':\n        return jsonify({\n            \"code\": 400,\n            \"data\": None,\n            \"msg\": \"No selected file\"\n        }), 400\n    try:\n        # 保存文件到指定位置\n        uuid_v1 = uuid.uuid1()\n        print(f\"UUID v1: {uuid_v1}\")\n        filepath = Path(BASE_DIR / \"videoFile\" / f\"{uuid_v1}_{file.filename}\")\n        file.save(filepath)\n        return jsonify({\"code\":200,\"msg\": \"File uploaded successfully\", \"data\": f\"{uuid_v1}_{file.filename}\"}), 200\n    except Exception as e:\n        return jsonify({\"code\":500,\"msg\": str(e),\"data\":None}), 500\n\n@app.route('/getFile', methods=['GET'])\ndef get_file():\n    # 获取 filename 参数\n    filename = request.args.get('filename')\n\n    if not filename:\n        return jsonify({\"code\": 400, \"msg\": \"filename is required\", \"data\": None}), 400\n\n    # 防止路径穿越攻击\n    if '..' in filename or filename.startswith('/'):\n        return jsonify({\"code\": 400, \"msg\": \"Invalid filename\", \"data\": None}), 400\n\n    # 拼接完整路径\n    file_path = str(Path(BASE_DIR / \"videoFile\"))\n\n    # 返回文件\n    return send_from_directory(file_path,filename)\n\n\n@app.route('/uploadSave', methods=['POST'])\ndef upload_save():\n    if 'file' not in request.files:\n        return jsonify({\n            \"code\": 400,\n            \"data\": None,\n            \"msg\": \"No file part in the request\"\n        }), 400\n\n    file = request.files['file']\n    if file.filename == '':\n        return jsonify({\n            \"code\": 400,\n            \"data\": None,\n            \"msg\": \"No selected file\"\n        }), 400\n\n    # 获取表单中的自定义文件名（可选）\n    custom_filename = request.form.get('filename', None)\n    if custom_filename:\n        filename = custom_filename + \".\" + file.filename.split('.')[-1]\n    else:\n        filename = file.filename\n\n    try:\n        # 生成 UUID v1\n        uuid_v1 = uuid.uuid1()\n        print(f\"UUID v1: {uuid_v1}\")\n\n        # 构造文件名和路径\n        final_filename = f\"{uuid_v1}_{filename}\"\n        filepath = Path(BASE_DIR / \"videoFile\" / f\"{uuid_v1}_{filename}\")\n\n        # 保存文件\n        file.save(filepath)\n\n        with sqlite3.connect(Path(BASE_DIR / \"db\" / \"database.db\")) as conn:\n            cursor = conn.cursor()\n            cursor.execute('''\n                                INSERT INTO file_records (filename, filesize, file_path)\n            VALUES (?, ?, ?)\n                                ''', (filename, round(float(os.path.getsize(filepath)) / (1024 * 1024),2), final_filename))\n            conn.commit()\n            print(\"✅ 上传文件已记录\")\n\n        return jsonify({\n            \"code\": 200,\n            \"msg\": \"File uploaded and saved successfully\",\n            \"data\": {\n                \"filename\": filename,\n                \"filepath\": final_filename\n            }\n        }), 200\n\n    except Exception as e:\n        print(f\"Upload failed: {e}\")\n        return jsonify({\n            \"code\": 500,\n            \"msg\": f\"upload failed: {e}\",\n            \"data\": None\n        }), 500\n\n@app.route('/getFiles', methods=['GET'])\ndef get_all_files():\n    try:\n        # 使用 with 自动管理数据库连接\n        with sqlite3.connect(Path(BASE_DIR / \"db\" / \"database.db\")) as conn:\n            conn.row_factory = sqlite3.Row  # 允许通过列名访问结果\n            cursor = conn.cursor()\n\n            # 查询所有记录\n            cursor.execute(\"SELECT * FROM file_records\")\n            rows = cursor.fetchall()\n\n            # 将结果转为字典列表，并提取UUID\n            data = []\n            for row in rows:\n                row_dict = dict(row)\n                # 从 file_path 中提取 UUID (文件名的第一部分，下划线前)\n                if row_dict.get('file_path'):\n                    file_path_parts = row_dict['file_path'].split('_', 1)  # 只分割第一个下划线\n                    if len(file_path_parts) > 0:\n                        row_dict['uuid'] = file_path_parts[0]  # UUID 部分\n                    else:\n                        row_dict['uuid'] = ''\n                else:\n                    row_dict['uuid'] = ''\n                data.append(row_dict)\n\n            return jsonify({\n                \"code\": 200,\n                \"msg\": \"success\",\n                \"data\": data\n            }), 200\n    except Exception as e:\n        return jsonify({\n            \"code\": 500,\n            \"msg\": str(\"get file failed!\"),\n            \"data\": None\n        }), 500\n\n\n@app.route(\"/getAccounts\", methods=['GET'])\ndef getAccounts():\n    \"\"\"快速获取所有账号信息，不进行cookie验证\"\"\"\n    try:\n        with sqlite3.connect(Path(BASE_DIR / \"db\" / \"database.db\")) as conn:\n            conn.row_factory = sqlite3.Row\n            cursor = conn.cursor()\n            cursor.execute('''\n            SELECT * FROM user_info''')\n            rows = cursor.fetchall()\n            rows_list = [list(row) for row in rows]\n\n            print(\"\\n📋 当前数据表内容（快速获取）：\")\n            for row in rows:\n                print(row)\n\n            return jsonify(\n                {\n                    \"code\": 200,\n                    \"msg\": None,\n                    \"data\": rows_list\n                }), 200\n    except Exception as e:\n        print(f\"获取账号列表时出错: {str(e)}\")\n        return jsonify({\n            \"code\": 500,\n            \"msg\": f\"获取账号列表失败: {str(e)}\",\n            \"data\": None\n        }), 500\n\n\n@app.route(\"/getValidAccounts\",methods=['GET'])\nasync def getValidAccounts():\n    with sqlite3.connect(Path(BASE_DIR / \"db\" / \"database.db\")) as conn:\n        cursor = conn.cursor()\n        cursor.execute('''\n        SELECT * FROM user_info''')\n        rows = cursor.fetchall()\n        rows_list = [list(row) for row in rows]\n        print(\"\\n📋 当前数据表内容：\")\n        for row in rows:\n            print(row)\n        for row in rows_list:\n            flag = await check_cookie(row[1],row[2])\n            if not flag:\n                row[4] = 0\n                cursor.execute('''\n                UPDATE user_info \n                SET status = ? \n                WHERE id = ?\n                ''', (0,row[0]))\n                conn.commit()\n                print(\"✅ 用户状态已更新\")\n        for row in rows:\n            print(row)\n        return jsonify(\n                        {\n                            \"code\": 200,\n                            \"msg\": None,\n                            \"data\": rows_list\n                        }),200\n\n@app.route('/deleteFile', methods=['GET'])\ndef delete_file():\n    file_id = request.args.get('id')\n\n    if not file_id or not file_id.isdigit():\n        return jsonify({\n            \"code\": 400,\n            \"msg\": \"Invalid or missing file ID\",\n            \"data\": None\n        }), 400\n\n    try:\n        # 获取数据库连接\n        with sqlite3.connect(Path(BASE_DIR / \"db\" / \"database.db\")) as conn:\n            conn.row_factory = sqlite3.Row\n            cursor = conn.cursor()\n\n            # 查询要删除的记录\n            cursor.execute(\"SELECT * FROM file_records WHERE id = ?\", (file_id,))\n            record = cursor.fetchone()\n\n            if not record:\n                return jsonify({\n                    \"code\": 404,\n                    \"msg\": \"File not found\",\n                    \"data\": None\n                }), 404\n\n            record = dict(record)\n\n            # 获取文件路径并删除实际文件\n            file_path = Path(BASE_DIR / \"videoFile\" / record['file_path'])\n            if file_path.exists():\n                try:\n                    file_path.unlink()  # 删除文件\n                    print(f\"✅ 实际文件已删除: {file_path}\")\n                except Exception as e:\n                    print(f\"⚠️ 删除实际文件失败: {e}\")\n                    # 即使删除文件失败，也要继续删除数据库记录，避免数据不一致\n            else:\n                print(f\"⚠️ 实际文件不存在: {file_path}\")\n\n            # 删除数据库记录\n            cursor.execute(\"DELETE FROM file_records WHERE id = ?\", (file_id,))\n            conn.commit()\n\n        return jsonify({\n            \"code\": 200,\n            \"msg\": \"File deleted successfully\",\n            \"data\": {\n                \"id\": record['id'],\n                \"filename\": record['filename']\n            }\n        }), 200\n\n    except Exception as e:\n        return jsonify({\n            \"code\": 500,\n            \"msg\": str(\"delete failed!\"),\n            \"data\": None\n        }), 500\n\n@app.route('/deleteAccount', methods=['GET'])\ndef delete_account():\n    account_id = request.args.get('id')\n\n    if not account_id or not account_id.isdigit():\n        return jsonify({\n            \"code\": 400,\n            \"msg\": \"Invalid or missing account ID\",\n            \"data\": None\n        }), 400\n\n    account_id = int(account_id)\n\n    try:\n        # 获取数据库连接\n        with sqlite3.connect(Path(BASE_DIR / \"db\" / \"database.db\")) as conn:\n            conn.row_factory = sqlite3.Row\n            cursor = conn.cursor()\n\n            # 查询要删除的记录\n            cursor.execute(\"SELECT * FROM user_info WHERE id = ?\", (account_id,))\n            record = cursor.fetchone()\n\n            if not record:\n                return jsonify({\n                    \"code\": 404,\n                    \"msg\": \"account not found\",\n                    \"data\": None\n                }), 404\n\n            record = dict(record)\n\n            # 删除关联的cookie文件\n            if record.get('filePath'):\n                cookie_file_path = Path(BASE_DIR / \"cookiesFile\" / record['filePath'])\n                if cookie_file_path.exists():\n                    try:\n                        cookie_file_path.unlink()\n                        print(f\"✅ Cookie文件已删除: {cookie_file_path}\")\n                    except Exception as e:\n                        print(f\"⚠️ 删除Cookie文件失败: {e}\")\n\n            # 删除数据库记录\n            cursor.execute(\"DELETE FROM user_info WHERE id = ?\", (account_id,))\n            conn.commit()\n\n        return jsonify({\n            \"code\": 200,\n            \"msg\": \"account deleted successfully\",\n            \"data\": None\n        }), 200\n\n    except Exception as e:\n        return jsonify({\n            \"code\": 500,\n            \"msg\": f\"delete failed: {str(e)}\",\n            \"data\": None\n        }), 500\n\n\n# SSE 登录接口\n@app.route('/login')\ndef login():\n    # 1 小红书 2 视频号 3 抖音 4 快手\n    type = request.args.get('type')\n    # 账号名\n    id = request.args.get('id')\n\n    # 模拟一个用于异步通信的队列\n    status_queue = Queue()\n    active_queues[id] = status_queue\n\n    def on_close():\n        print(f\"清理队列: {id}\")\n        del active_queues[id]\n    # 启动异步任务线程\n    thread = threading.Thread(target=run_async_function, args=(type,id,status_queue), daemon=True)\n    thread.start()\n    response = Response(sse_stream(status_queue,), mimetype='text/event-stream')\n    response.headers['Cache-Control'] = 'no-cache'\n    response.headers['X-Accel-Buffering'] = 'no'  # 关键：禁用 Nginx 缓冲\n    response.headers['Content-Type'] = 'text/event-stream'\n    response.headers['Connection'] = 'keep-alive'\n    return response\n\n@app.route('/postVideo', methods=['POST'])\ndef postVideo():\n    # 获取JSON数据\n    data = request.get_json()\n\n    if not data:\n        return jsonify({\"code\": 400, \"msg\": \"请求数据不能为空\", \"data\": None}), 400\n\n    # 从JSON数据中提取fileList和accountList\n    file_list = data.get('fileList', [])\n    account_list = data.get('accountList', [])\n    type = data.get('type')\n    title = data.get('title')\n    tags = data.get('tags')\n    category = data.get('category')\n    enableTimer = data.get('enableTimer')\n    if category == 0:\n        category = None\n    productLink = data.get('productLink', '')\n    productTitle = data.get('productTitle', '')\n    thumbnail_path = data.get('thumbnail', '')\n    is_draft = data.get('isDraft', False)  # 新增参数：是否保存为草稿\n\n    videos_per_day = data.get('videosPerDay')\n    daily_times = data.get('dailyTimes')\n    start_days = data.get('startDays')\n\n    # 参数校验\n    if not file_list:\n        return jsonify({\"code\": 400, \"msg\": \"文件列表不能为空\", \"data\": None}), 400\n    if not account_list:\n        return jsonify({\"code\": 400, \"msg\": \"账号列表不能为空\", \"data\": None}), 400\n    if not type:\n        return jsonify({\"code\": 400, \"msg\": \"平台类型不能为空\", \"data\": None}), 400\n    if not title:\n        return jsonify({\"code\": 400, \"msg\": \"标题不能为空\", \"data\": None}), 400\n\n    # 打印获取到的数据（仅作为示例）\n    print(\"File List:\", file_list)\n    print(\"Account List:\", account_list)\n\n    try:\n        match type:\n            case 1:\n                post_video_xhs(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,\n                                   start_days)\n            case 2:\n                post_video_tencent(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,\n                                   start_days, is_draft)\n            case 3:\n                post_video_DouYin(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,\n                          start_days, thumbnail_path, productLink, productTitle)\n            case 4:\n                post_video_ks(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,\n                          start_days)\n            case _:\n                return jsonify({\"code\": 400, \"msg\": f\"不支持的平台类型: {type}\", \"data\": None}), 400\n\n        # 返回响应给客户端\n        return jsonify(\n            {\n                \"code\": 200,\n                \"msg\": \"发布任务已提交\",\n                \"data\": None\n            }), 200\n    except Exception as e:\n        print(f\"发布视频时出错: {str(e)}\")\n        return jsonify({\n            \"code\": 500,\n            \"msg\": f\"发布失败: {str(e)}\",\n            \"data\": None\n        }), 500\n\n\n@app.route('/updateUserinfo', methods=['POST'])\ndef updateUserinfo():\n    # 获取JSON数据\n    data = request.get_json()\n\n    # 从JSON数据中提取 type 和 userName\n    user_id = data.get('id')\n    type = data.get('type')\n    userName = data.get('userName')\n    try:\n        # 获取数据库连接\n        with sqlite3.connect(Path(BASE_DIR / \"db\" / \"database.db\")) as conn:\n            conn.row_factory = sqlite3.Row\n            cursor = conn.cursor()\n\n            # 更新数据库记录\n            cursor.execute('''\n                           UPDATE user_info\n                           SET type     = ?,\n                               userName = ?\n                           WHERE id = ?;\n                           ''', (type, userName, user_id))\n            conn.commit()\n\n        return jsonify({\n            \"code\": 200,\n            \"msg\": \"account update successfully\",\n            \"data\": None\n        }), 200\n\n    except Exception as e:\n        return jsonify({\n            \"code\": 500,\n            \"msg\": str(\"update failed!\"),\n            \"data\": None\n        }), 500\n\n@app.route('/postVideoBatch', methods=['POST'])\ndef postVideoBatch():\n    data_list = request.get_json()\n\n    if not isinstance(data_list, list):\n        return jsonify({\"code\": 400, \"msg\": \"Expected a JSON array\", \"data\": None}), 400\n    for data in data_list:\n        # 从JSON数据中提取fileList和accountList\n        file_list = data.get('fileList', [])\n        account_list = data.get('accountList', [])\n        type = data.get('type')\n        title = data.get('title')\n        tags = data.get('tags')\n        category = data.get('category')\n        enableTimer = data.get('enableTimer')\n        if category == 0:\n            category = None\n        productLink = data.get('productLink', '')\n        productTitle = data.get('productTitle', '')\n        is_draft = data.get('isDraft', False)\n\n        videos_per_day = data.get('videosPerDay')\n        daily_times = data.get('dailyTimes')\n        start_days = data.get('startDays')\n        # 打印获取到的数据（仅作为示例）\n        print(\"File List:\", file_list)\n        print(\"Account List:\", account_list)\n        match type:\n            case 1:\n                post_video_xhs(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,\n                               start_days)\n            case 2:\n                post_video_tencent(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,\n                                   start_days, is_draft)\n            case 3:\n                post_video_DouYin(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,\n                          start_days, productLink, productTitle)\n            case 4:\n                post_video_ks(title, file_list, tags, account_list, category, enableTimer, videos_per_day, daily_times,\n                          start_days)\n    # 返回响应给客户端\n    return jsonify(\n        {\n            \"code\": 200,\n            \"msg\": None,\n            \"data\": None\n        }), 200\n\n# Cookie文件上传API\n@app.route('/uploadCookie', methods=['POST'])\ndef upload_cookie():\n    try:\n        if 'file' not in request.files:\n            return jsonify({\n                \"code\": 400,\n                \"msg\": \"没有找到Cookie文件\",\n                \"data\": None\n            }), 400\n\n        file = request.files['file']\n        if file.filename == '':\n            return jsonify({\n                \"code\": 400,\n                \"msg\": \"Cookie文件名不能为空\",\n                \"data\": None\n            }), 400\n\n        if not file.filename.endswith('.json'):\n            return jsonify({\n                \"code\": 400,\n                \"msg\": \"Cookie文件必须是JSON格式\",\n                \"data\": None\n            }), 400\n\n        # 获取账号信息\n        account_id = request.form.get('id')\n        platform = request.form.get('platform')\n\n        if not account_id or not platform:\n            return jsonify({\n                \"code\": 400,\n                \"msg\": \"缺少账号ID或平台信息\",\n                \"data\": None\n            }), 400\n\n        # 从数据库获取账号的文件路径\n        with sqlite3.connect(Path(BASE_DIR / \"db\" / \"database.db\")) as conn:\n            conn.row_factory = sqlite3.Row\n            cursor = conn.cursor()\n            cursor.execute('SELECT filePath FROM user_info WHERE id = ?', (account_id,))\n            result = cursor.fetchone()\n\n        if not result:\n            return jsonify({\n                \"code\": 500,\n                \"msg\": \"账号不存在\",\n                \"data\": None\n            }), 404\n\n        # 保存上传的Cookie文件到对应路径\n        cookie_file_path = Path(BASE_DIR / \"cookiesFile\" / result['filePath'])\n        cookie_file_path.parent.mkdir(parents=True, exist_ok=True)\n\n        file.save(str(cookie_file_path))\n\n        # 更新数据库中的账号信息（可选，比如更新更新时间）\n        # 这里可以根据需要添加额外的处理逻辑\n\n        return jsonify({\n            \"code\": 200,\n            \"msg\": \"Cookie文件上传成功\",\n            \"data\": None\n        }), 200\n\n    except Exception as e:\n        print(f\"上传Cookie文件时出错: {str(e)}\")\n        return jsonify({\n            \"code\": 500,\n            \"msg\": f\"上传Cookie文件失败: {str(e)}\",\n            \"data\": None\n        }), 500\n\n\n# Cookie文件下载API\n@app.route('/downloadCookie', methods=['GET'])\ndef download_cookie():\n    try:\n        file_path = request.args.get('filePath')\n        if not file_path:\n            return jsonify({\n                \"code\": 500,\n                \"msg\": \"缺少文件路径参数\",\n                \"data\": None\n            }), 400\n\n        # 验证文件路径的安全性，防止路径遍历攻击\n        cookie_file_path = Path(BASE_DIR / \"cookiesFile\" / file_path).resolve()\n        base_path = Path(BASE_DIR / \"cookiesFile\").resolve()\n\n        if not cookie_file_path.is_relative_to(base_path):\n            return jsonify({\n                \"code\": 500,\n                \"msg\": \"非法文件路径\",\n                \"data\": None\n            }), 400\n\n        if not cookie_file_path.exists():\n            return jsonify({\n                \"code\": 500,\n                \"msg\": \"Cookie文件不存在\",\n                \"data\": None\n            }), 404\n\n        # 返回文件\n        return send_from_directory(\n            directory=str(cookie_file_path.parent),\n            path=cookie_file_path.name,\n            as_attachment=True\n        )\n\n    except Exception as e:\n        print(f\"下载Cookie文件时出错: {str(e)}\")\n        return jsonify({\n            \"code\": 500,\n            \"msg\": f\"下载Cookie文件失败: {str(e)}\",\n            \"data\": None\n        }), 500\n\n\n# 包装函数：在线程中运行异步函数\ndef run_async_function(type,id,status_queue):\n    match type:\n        case '1':\n            loop = asyncio.new_event_loop()\n            asyncio.set_event_loop(loop)\n            loop.run_until_complete(xiaohongshu_cookie_gen(id, status_queue))\n            loop.close()\n        case '2':\n            loop = asyncio.new_event_loop()\n            asyncio.set_event_loop(loop)\n            loop.run_until_complete(get_tencent_cookie(id,status_queue))\n            loop.close()\n        case '3':\n            loop = asyncio.new_event_loop()\n            asyncio.set_event_loop(loop)\n            loop.run_until_complete(douyin_cookie_gen(id,status_queue))\n            loop.close()\n        case '4':\n            loop = asyncio.new_event_loop()\n            asyncio.set_event_loop(loop)\n            loop.run_until_complete(get_ks_cookie(id,status_queue))\n            loop.close()\n\n# SSE 流生成器函数\ndef sse_stream(status_queue):\n    while True:\n        if not status_queue.empty():\n            msg = status_queue.get()\n            yield f\"data: {msg}\\n\\n\"\n        else:\n            # 避免 CPU 占满\n            time.sleep(0.1)\n\nif __name__ == '__main__':\n    app.run(host='0.0.0.0' ,port=5409)\n"
  },
  {
    "path": "sau_frontend/README.md",
    "content": "# Vue3 + Vite 项目\n\n一个基于 Vue3、Vite、Element Plus、Pinia、Vue Router 和 Axios 的现代化前端项目模板。\n\n## 🚀 特性\n\n- ⚡️ **Vite** - 极速的构建工具\n- 🖖 **Vue 3** - 渐进式 JavaScript 框架\n- 🎨 **Element Plus** - 基于 Vue 3 的组件库\n- 🗂 **Vue Router** - 官方路由管理器（WebHash 模式）\n- 📦 **Pinia** - 新一代状态管理\n- 🔗 **Axios** - HTTP 请求库（已封装）\n- 🎯 **Sass** - CSS 预处理器\n- 📁 **规范化目录结构** - views 存放页面，components 存放组件\n- 🔧 **完整配置** - 包含开发和生产环境配置\n\n## 📦 安装\n\n```bash\n# 安装依赖\nnpm install\n\n# 启动开发服务器\nnpm run dev\n\n# 构建生产版本\nnpm run build\n\n# 预览生产构建\nnpm run preview\n```\n\n## 📁 项目结构\n\n```\nsrc/\n├── api/                 # API 接口\n│   ├── index.js        # API 统一导出\n│   └── user.js         # 用户相关 API\n├── components/          # 公共组件\n│   └── HelloWorld.vue  # 示例组件\n├── router/             # 路由配置\n│   └── index.js        # 路由主文件\n├── stores/             # 状态管理\n│   ├── index.js        # Pinia 配置\n│   └── user.js         # 用户状态\n├── styles/             # 样式文件\n│   ├── index.scss      # 主样式文件\n│   ├── reset.scss      # 重置样式\n│   └── variables.scss  # 样式变量\n├── utils/              # 工具函数\n│   └── request.js      # HTTP 请求封装\n├── views/              # 页面组件\n│   ├── Home.vue        # 首页\n│   └── About.vue       # 关于页面\n├── App.vue             # 根组件\n└── main.js             # 入口文件\n```\n\n## 🔧 配置说明\n\n### 环境变量\n\n- `.env` - 通用环境变量\n- `.env.development` - 开发环境变量\n- `.env.production` - 生产环境变量\n\n### 路由配置\n\n项目使用 Vue Router 4，配置为 WebHash 模式，路由文件位于 `src/router/index.js`。\n\n### 状态管理\n\n使用 Pinia 进行状态管理，store 文件位于 `src/stores/` 目录。\n\n### HTTP 请求\n\nAxios 已经过封装，包含：\n- 请求/响应拦截器\n- 错误处理\n- Token 自动添加\n- 统一的响应格式处理\n\n使用方式：\n```javascript\nimport { http } from '@/utils/request'\n\n// GET 请求\nconst data = await http.get('/api/users')\n\n// POST 请求\nconst result = await http.post('/api/users', { name: 'John' })\n```\n\n### 样式系统\n\n- 使用 Sass 作为 CSS 预处理器\n- 已删除所有浏览器默认样式\n- 提供了完整的样式变量和工具类\n- 支持 Element Plus 主题定制\n\n## 🎨 组件库\n\n项目集成了 Element Plus，所有组件都可以直接使用：\n\n```vue\n<template>\n  <el-button type=\"primary\">按钮</el-button>\n  <el-input v-model=\"input\" placeholder=\"请输入内容\"></el-input>\n</template>\n```\n\n## 📝 开发规范\n\n1. **页面组件** 放在 `src/views/` 目录\n2. **公共组件** 放在 `src/components/` 目录\n3. **使用 setup 语法糖** 编写组件\n4. **样式使用 Sass** 并遵循 BEM 命名规范\n5. **API 请求** 统一放在 `src/api/` 目录\n6. **状态管理** 按模块划分，放在 `src/stores/` 目录\n\n## 🚀 部署\n\n```bash\n# 构建生产版本\nnpm run build\n\n# 构建完成后，dist 目录包含所有静态文件\n# 可以部署到任何静态文件服务器\n```\n\n## 📄 许可证\n\nMIT License\n"
  },
  {
    "path": "sau_frontend/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/vite.svg\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>SAU自媒体自动化运营系统</title>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "sau_frontend/package.json",
    "content": "{\n  \"name\": \"sau-admin\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"author\": \"Edan.Lee\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"@element-plus/icons-vue\": \"^2.3.1\",\n    \"axios\": \"^1.9.0\",\n    \"element-plus\": \"^2.9.11\",\n    \"pinia\": \"^3.0.2\",\n    \"sass\": \"^1.89.1\",\n    \"vue\": \"^3.5.13\",\n    \"vue-router\": \"^4.5.1\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-vue\": \"^5.2.3\",\n    \"vite\": \"^6.3.5\"\n  }\n}\n"
  },
  {
    "path": "sau_frontend/src/App.vue",
    "content": "<template>\n  <div id=\"app\">\n    <el-container>\n      <el-aside :width=\"isCollapse ? '64px' : '200px'\">\n        <div class=\"sidebar\">\n          <div class=\"logo\">\n            <img v-show=\"isCollapse\" src=\"/vite.svg\" alt=\"Logo\" class=\"logo-img\">\n            <h2 v-show=\"!isCollapse\">自媒体自动化运营系统</h2>\n          </div>\n          <el-menu\n            :router=\"true\"\n            :default-active=\"activeMenu\"\n            :collapse=\"isCollapse\"\n            class=\"sidebar-menu\"\n            background-color=\"#001529\"\n            text-color=\"#fff\"\n            active-text-color=\"#409EFF\"\n          >\n            <el-menu-item index=\"/\">\n              <el-icon><HomeFilled /></el-icon>\n              <span>首页</span>\n            </el-menu-item>\n            <el-menu-item index=\"/account-management\">\n              <el-icon><User /></el-icon>\n              <span>账号管理</span>\n            </el-menu-item>\n            <el-menu-item index=\"/material-management\">\n              <el-icon><Picture /></el-icon>\n              <span>素材管理</span>\n            </el-menu-item>\n            <el-menu-item index=\"/publish-center\">\n              <el-icon><Upload /></el-icon>\n              <span>发布中心</span>\n            </el-menu-item>\n            <el-menu-item index=\"/about\">\n              <el-icon><DataAnalysis /></el-icon>\n              <span>关于</span>\n            </el-menu-item>\n          </el-menu>\n        </div>\n      </el-aside>\n      <el-container>\n        <el-header>\n          <div class=\"header-content\">\n            <div class=\"header-left\">\n              <el-icon class=\"toggle-sidebar\" @click=\"toggleSidebar\"><Fold /></el-icon>\n            </div>\n            <div class=\"header-right\">\n              <!-- 账号信息已移除 -->\n            </div>\n          </div>\n        </el-header>\n        <el-main>\n          <router-view />\n        </el-main>\n      </el-container>\n    </el-container>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed } from 'vue'\nimport { useRoute } from 'vue-router'\nimport {\n  HomeFilled, User, DataAnalysis,\n  Fold, Picture, Upload\n} from '@element-plus/icons-vue'\n\nconst route = useRoute()\n\n// 当前激活的菜单项\nconst activeMenu = computed(() => {\n  return route.path\n})\n\n// 侧边栏折叠状态\nconst isCollapse = ref(false)\n\n// 切换侧边栏折叠状态\nconst toggleSidebar = () => {\n  isCollapse.value = !isCollapse.value\n}\n</script>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/variables.scss' as *;\n\n#app {\n  min-height: 100vh;\n}\n\n.el-container {\n  height: 100vh;\n}\n\n.el-aside {\n  background-color: #001529;\n  color: #fff;\n  height: 100vh;\n  overflow: hidden;\n  transition: width 0.3s;\n  \n  .sidebar {\n    display: flex;\n    flex-direction: column;\n    height: 100%;\n    \n    .logo {\n      height: 60px;\n      padding: 0 16px;\n      display: flex;\n      align-items: center;\n      background-color: #002140;\n      overflow: hidden;\n      \n      .logo-img {\n        width: 32px;\n        height: 32px;\n        margin-right: 12px;\n      }\n      \n      h2 {\n        color: #fff;\n        font-size: 16px;\n        font-weight: 600;\n        white-space: nowrap;\n        margin: 0;\n      }\n    }\n    \n    .sidebar-menu {\n      border-right: none;\n      flex: 1;\n      \n      .el-menu-item {\n        display: flex;\n        align-items: center;\n        \n        .el-icon {\n          margin-right: 10px;\n          font-size: 18px;\n        }\n      }\n    }\n  }\n}\n\n.el-header {\n  background-color: #fff;\n  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);\n  padding: 0;\n  height: 60px;\n  \n  .header-content {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    height: 100%;\n    padding: 0 16px;\n    \n    .header-left {\n      .toggle-sidebar {\n        font-size: 20px;\n        cursor: pointer;\n        color: $text-regular;\n        \n        &:hover {\n          color: $primary-color;\n        }\n      }\n    }\n    \n    .header-right {\n      .user-dropdown {\n        display: flex;\n        align-items: center;\n        cursor: pointer;\n        \n        .username {\n          margin: 0 8px;\n          color: $text-regular;\n        }\n        \n        .el-icon {\n          font-size: 12px;\n          color: $text-secondary;\n        }\n      }\n    }\n  }\n}\n\n.el-main {\n  background-color: $bg-color-page;\n  padding: 20px;\n  overflow-y: auto;\n}\n</style>\n"
  },
  {
    "path": "sau_frontend/src/api/account.js",
    "content": "import { http } from '@/utils/request'\n\n// 账号管理相关API\nexport const accountApi = {\n  // 获取有效账号列表（带验证）\n  getValidAccounts() {\n    return http.get('/getValidAccounts')\n  },\n\n  // 获取账号列表（不带验证，快速加载）\n  getAccounts() {\n    return http.get('/getAccounts')\n  },\n\n  // 添加账号\n  addAccount(data) {\n    return http.post('/account', data)\n  },\n\n  // 更新账号\n  updateAccount(data) {\n    return http.post('/updateUserinfo', data)\n  },\n\n  // 删除账号\n  deleteAccount(id) {\n    return http.get(`/deleteAccount?id=${id}`)\n  }\n}"
  },
  {
    "path": "sau_frontend/src/api/index.js",
    "content": "// API 统一导出\nexport * from './user'\nexport * from './account'\nexport * from './material'\n\n// 可以在这里添加其他API模块的导出\n// export * from './product'\n// export * from './order'\n// export * from './common'"
  },
  {
    "path": "sau_frontend/src/api/material.js",
    "content": "import { http } from '@/utils/request'\n\n// 素材管理API\nexport const materialApi = {\n  // 获取所有素材\n  getAllMaterials: () => {\n    return http.get('/getFiles')\n  },\n  \n  // 上传素材\n  uploadMaterial: (formData, onUploadProgress) => {\n    // 使用http.upload方法，它已经配置了正确的Content-Type\n    return http.upload('/uploadSave', formData, onUploadProgress)\n  },\n  \n  // 删除素材\n  deleteMaterial: (id) => {\n    return http.get(`/deleteFile?id=${id}`)\n  },\n  \n  // 下载素材\n  downloadMaterial: (filePath) => {\n    return `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:5409'}/download/${filePath}`\n  },\n  \n  // 获取素材预览URL\n  getMaterialPreviewUrl: (filename) => {\n    return `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:5409'}/getFile?filename=${filename}`\n  }\n}"
  },
  {
    "path": "sau_frontend/src/api/user.js",
    "content": "import { http } from '@/utils/request'\n\n// 用户相关API（预留）\n// 注意：当前后端暂无用户认证接口，以下为预留定义\nexport const userApi = {}\n"
  },
  {
    "path": "sau_frontend/src/main.js",
    "content": "import { createApp } from 'vue'\nimport App from './App.vue'\nimport router from './router'\nimport pinia from './stores'\nimport ElementPlus from 'element-plus'\nimport 'element-plus/dist/index.css'\nimport * as ElementPlusIconsVue from '@element-plus/icons-vue'\nimport './styles/index.scss'\n\nconst app = createApp(App)\n\n// 注册 Element Plus 图标\nfor (const [key, component] of Object.entries(ElementPlusIconsVue)) {\n  app.component(key, component)\n}\n\napp.use(router)\napp.use(pinia)\napp.use(ElementPlus)\napp.mount('#app')\n"
  },
  {
    "path": "sau_frontend/src/router/index.js",
    "content": "import { createRouter, createWebHashHistory } from 'vue-router'\nimport Dashboard from '../views/Dashboard.vue'\nimport AccountManagement from '../views/AccountManagement.vue'\nimport MaterialManagement from '../views/MaterialManagement.vue'\nimport PublishCenter from '../views/PublishCenter.vue'\nimport About from '../views/About.vue'\n\nconst routes = [\n  {\n    path: '/',\n    name: 'Dashboard',\n    component: Dashboard\n  },\n  {\n    path: '/account-management',\n    name: 'AccountManagement',\n    component: AccountManagement\n  },\n  {\n    path: '/material-management',\n    name: 'MaterialManagement',\n    component: MaterialManagement\n  },\n  {\n    path: '/publish-center',\n    name: 'PublishCenter',\n    component: PublishCenter\n  },\n  {\n    path: '/about',\n    name: 'About',\n    component: About\n  }\n]\n\nconst router = createRouter({\n  history: createWebHashHistory(),\n  routes\n})\n\nexport default router"
  },
  {
    "path": "sau_frontend/src/stores/account.js",
    "content": "import { defineStore } from 'pinia'\nimport { ref } from 'vue'\n\nexport const useAccountStore = defineStore('account', () => {\n  // 存储所有账号信息\n  const accounts = ref([])\n  \n  // 平台类型映射\n  const platformTypes = {\n    1: '小红书',\n    2: '视频号',\n    3: '抖音',\n    4: '快手'\n  }\n  \n  // 设置账号列表\n  const setAccounts = (accountsData) => {\n    // 转换后端返回的数据格式为前端使用的格式\n    accounts.value = accountsData.map(item => {\n      return {\n        id: item[0],\n        type: item[1],\n        filePath: item[2],\n        name: item[3],\n        status: item[4] === -1 ? '验证中' : (item[4] === 1 ? '正常' : '异常'),\n        platform: platformTypes[item[1]] || '未知'\n      }\n    })\n  }\n  \n  // 添加账号\n  const addAccount = (account) => {\n    accounts.value.push(account)\n  }\n  \n  // 更新账号\n  const updateAccount = (id, updatedAccount) => {\n    const index = accounts.value.findIndex(acc => acc.id === id)\n    if (index !== -1) {\n      accounts.value[index] = { ...accounts.value[index], ...updatedAccount }\n    }\n  }\n  \n  // 删除账号\n  const deleteAccount = (id) => {\n    accounts.value = accounts.value.filter(acc => acc.id !== id)\n  }\n  \n  // 根据平台获取账号\n  const getAccountsByPlatform = (platform) => {\n    return accounts.value.filter(acc => acc.platform === platform)\n  }\n  \n  return {\n    accounts,\n    setAccounts,\n    addAccount,\n    updateAccount,\n    deleteAccount,\n    getAccountsByPlatform\n  }\n})"
  },
  {
    "path": "sau_frontend/src/stores/app.js",
    "content": "import { defineStore } from 'pinia'\nimport { ref } from 'vue'\n\nexport const useAppStore = defineStore('app', () => {\n  // 是否是第一次进入账号管理页面\n  const isFirstTimeAccountManagement = ref(true)\n  \n  // 是否是第一次进入素材管理页面\n  const isFirstTimeMaterialManagement = ref(true)\n\n  // 账号管理页面刷新状态\n  const isAccountRefreshing = ref(false)\n\n  // 素材列表数据\n  const materials = ref([])\n  \n  // 设置账号管理页面已访问\n  const setAccountManagementVisited = () => {\n    isFirstTimeAccountManagement.value = false\n  }\n  \n  // 设置素材管理页面已访问\n  const setMaterialManagementVisited = () => {\n    isFirstTimeMaterialManagement.value = false\n  }\n  \n  // 重置所有访问状态（用于重新登录或刷新应用时）\n  const resetVisitStatus = () => {\n    isFirstTimeAccountManagement.value = true\n    isFirstTimeMaterialManagement.value = true\n  }\n\n  // 更新素材列表\n  const setMaterials = (materialList) => {\n    materials.value = materialList\n  }\n\n  // 添加新素材\n  const addMaterial = (material) => {\n    materials.value.push(material)\n  }\n\n  // 删除素材\n  const removeMaterial = (materialId) => {\n    const index = materials.value.findIndex(m => m.id === materialId)\n    if (index > -1) {\n      materials.value.splice(index, 1)\n    }\n  }\n  \n  // 设置账号管理页面刷新状态\n  const setAccountRefreshing = (status) => {\n    isAccountRefreshing.value = status\n  }\n\n  return {\n    isFirstTimeAccountManagement,\n    isFirstTimeMaterialManagement,\n    isAccountRefreshing,\n    materials,\n    setAccountManagementVisited,\n    setMaterialManagementVisited,\n    resetVisitStatus,\n    setMaterials,\n    addMaterial,\n    removeMaterial,\n    setAccountRefreshing\n  }\n})"
  },
  {
    "path": "sau_frontend/src/stores/index.js",
    "content": "import { createPinia } from 'pinia'\nimport { useUserStore } from './user'\nimport { useAccountStore } from './account'\nimport { useAppStore } from './app'\n\nconst pinia = createPinia()\n\nexport default pinia\nexport { useUserStore, useAccountStore, useAppStore }"
  },
  {
    "path": "sau_frontend/src/stores/user.js",
    "content": "import { defineStore } from 'pinia'\nimport { ref } from 'vue'\n\nexport const useUserStore = defineStore('user', () => {\n  const userInfo = ref({\n    name: '',\n    email: ''\n  })\n  \n  const isLoggedIn = ref(false)\n  \n  const setUserInfo = (info) => {\n    userInfo.value = info\n    isLoggedIn.value = true\n  }\n  \n  const logout = () => {\n    userInfo.value = {\n      name: '',\n      email: ''\n    }\n    isLoggedIn.value = false\n  }\n  \n  return {\n    userInfo,\n    isLoggedIn,\n    setUserInfo,\n    logout\n  }\n})"
  },
  {
    "path": "sau_frontend/src/styles/index.scss",
    "content": "// 导入重置样式\n@use './reset.scss';\n\n// 导入变量\n@use './variables.scss' as *;\n\n// 全局样式\nbody {\n  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',\n    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',\n    sans-serif;\n  color: $text-primary;\n  background-color: $bg-color-page;\n}\n\n#app {\n  min-height: 100vh;\n}\n\n// 通用工具类\n.text-center {\n  text-align: center;\n}\n\n.text-left {\n  text-align: left;\n}\n\n.text-right {\n  text-align: right;\n}\n\n.flex {\n  display: flex;\n}\n\n.flex-center {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n}\n\n.flex-between {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.flex-column {\n  display: flex;\n  flex-direction: column;\n}\n\n.w-full {\n  width: 100%;\n}\n\n.h-full {\n  height: 100%;\n}\n\n// 间距工具类\n.m-0 { margin: 0; }\n.mt-0 { margin-top: 0; }\n.mr-0 { margin-right: 0; }\n.mb-0 { margin-bottom: 0; }\n.ml-0 { margin-left: 0; }\n\n.m-1 { margin: $spacing-xs; }\n.mt-1 { margin-top: $spacing-xs; }\n.mr-1 { margin-right: $spacing-xs; }\n.mb-1 { margin-bottom: $spacing-xs; }\n.ml-1 { margin-left: $spacing-xs; }\n\n.m-2 { margin: $spacing-sm; }\n.mt-2 { margin-top: $spacing-sm; }\n.mr-2 { margin-right: $spacing-sm; }\n.mb-2 { margin-bottom: $spacing-sm; }\n.ml-2 { margin-left: $spacing-sm; }\n\n.m-3 { margin: $spacing-md; }\n.mt-3 { margin-top: $spacing-md; }\n.mr-3 { margin-right: $spacing-md; }\n.mb-3 { margin-bottom: $spacing-md; }\n.ml-3 { margin-left: $spacing-md; }\n\n.m-4 { margin: $spacing-lg; }\n.mt-4 { margin-top: $spacing-lg; }\n.mr-4 { margin-right: $spacing-lg; }\n.mb-4 { margin-bottom: $spacing-lg; }\n.ml-4 { margin-left: $spacing-lg; }\n\n.p-0 { padding: 0; }\n.pt-0 { padding-top: 0; }\n.pr-0 { padding-right: 0; }\n.pb-0 { padding-bottom: 0; }\n.pl-0 { padding-left: 0; }\n\n.p-1 { padding: $spacing-xs; }\n.pt-1 { padding-top: $spacing-xs; }\n.pr-1 { padding-right: $spacing-xs; }\n.pb-1 { padding-bottom: $spacing-xs; }\n.pl-1 { padding-left: $spacing-xs; }\n\n.p-2 { padding: $spacing-sm; }\n.pt-2 { padding-top: $spacing-sm; }\n.pr-2 { padding-right: $spacing-sm; }\n.pb-2 { padding-bottom: $spacing-sm; }\n.pl-2 { padding-left: $spacing-sm; }\n\n.p-3 { padding: $spacing-md; }\n.pt-3 { padding-top: $spacing-md; }\n.pr-3 { padding-right: $spacing-md; }\n.pb-3 { padding-bottom: $spacing-md; }\n.pl-3 { padding-left: $spacing-md; }\n\n.p-4 { padding: $spacing-lg; }\n.pt-4 { padding-top: $spacing-lg; }\n.pr-4 { padding-right: $spacing-lg; }\n.pb-4 { padding-bottom: $spacing-lg; }\n.pl-4 { padding-left: $spacing-lg; }"
  },
  {
    "path": "sau_frontend/src/styles/reset.scss",
    "content": "/* CSS Reset - 删除浏览器默认样式 */\n\n/* 1. Use a more-intuitive box-sizing model */\n*, *::before, *::after {\n  box-sizing: border-box;\n}\n\n/* 2. Remove default margin and padding */\n* {\n  margin: 0;\n  padding: 0;\n}\n\n/* 3. Allow percentage-based heights in the application */\nhtml, body {\n  height: 100%;\n}\n\n/* 4. Add accessible line-height and improve text rendering */\nbody {\n  line-height: 1.5;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',\n    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',\n    sans-serif;\n}\n\n/* 5. Improve media defaults */\nimg, picture, video, canvas, svg {\n  display: block;\n  max-width: 100%;\n}\n\n/* 6. Remove built-in form typography styles */\ninput, button, textarea, select {\n  font: inherit;\n}\n\n/* 7. Avoid text overflows */\np, h1, h2, h3, h4, h5, h6 {\n  overflow-wrap: break-word;\n}\n\n/* 8. Create a root stacking context */\n#root, #__next, #app {\n  isolation: isolate;\n}\n\n/* 9. Remove list styles */\nul, ol {\n  list-style: none;\n}\n\n/* 10. Remove default button styles */\nbutton {\n  background: none;\n  border: none;\n  cursor: pointer;\n}\n\n/* 11. Remove default link styles */\na {\n  text-decoration: none;\n  color: inherit;\n}\n\n/* 12. Remove default table styles */\ntable {\n  border-collapse: collapse;\n  border-spacing: 0;\n}\n\n/* 13. Remove default fieldset styles */\nfieldset {\n  border: none;\n}\n\n/* 14. Remove default legend styles */\nlegend {\n  display: table;\n}\n\n/* 15. Remove default details/summary styles */\ndetails {\n  display: block;\n}\n\nsummary {\n  display: list-item;\n}\n\n/* 16. Remove default hr styles */\nhr {\n  border: none;\n  height: 1px;\n  background: #ccc;\n}\n\n/* 17. Remove default blockquote styles */\nblockquote {\n  quotes: none;\n}\n\nblockquote:before,\nblockquote:after {\n  content: '';\n  content: none;\n}\n\n/* 18. Remove default cite styles */\ncite {\n  font-style: normal;\n}\n\n/* 19. Remove default address styles */\naddress {\n  font-style: normal;\n}"
  },
  {
    "path": "sau_frontend/src/styles/variables.scss",
    "content": "// 颜色变量\n$primary-color: #409eff;\n$success-color: #67c23a;\n$warning-color: #e6a23c;\n$danger-color: #f56c6c;\n$info-color: #909399;\n\n// 文字颜色\n$text-primary: #303133;\n$text-regular: #606266;\n$text-secondary: #909399;\n$text-placeholder: #c0c4cc;\n\n// 边框颜色\n$border-base: #dcdfe6;\n$border-light: #e4e7ed;\n$border-lighter: #ebeef5;\n$border-extra-light: #f2f6fc;\n\n// 背景颜色\n$bg-color: #ffffff;\n$bg-color-page: #f2f3f5;\n$bg-color-overlay: #ffffff;\n\n// 字体大小\n$font-size-extra-large: 20px;\n$font-size-large: 18px;\n$font-size-medium: 16px;\n$font-size-base: 14px;\n$font-size-small: 13px;\n$font-size-extra-small: 12px;\n\n// 间距\n$spacing-xs: 4px;\n$spacing-sm: 8px;\n$spacing-md: 16px;\n$spacing-lg: 24px;\n$spacing-xl: 32px;\n\n// 圆角\n$border-radius-base: 4px;\n$border-radius-small: 2px;\n$border-radius-round: 20px;\n$border-radius-circle: 50%;\n\n// 阴影\n$box-shadow-base: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04);\n$box-shadow-dark: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.12);\n$box-shadow-light: 0 2px 12px 0 rgba(0, 0, 0, 0.1);\n\n// 层级\n$z-index-normal: 1;\n$z-index-top: 1000;\n$z-index-popper: 2000;"
  },
  {
    "path": "sau_frontend/src/utils/request.js",
    "content": "import axios from 'axios'\nimport { ElMessage } from 'element-plus'\n\n// 创建axios实例\nconst request = axios.create({\n  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5409',\n  headers: {\n    'Content-Type': 'application/json'\n  }\n})\n\n// 请求拦截器\nrequest.interceptors.request.use(\n  (config) => {\n    // 可以在这里添加token等认证信息\n    const token = localStorage.getItem('token')\n    if (token) {\n      config.headers.Authorization = `Bearer ${token}`\n    }\n    return config\n  },\n  (error) => {\n    console.error('请求错误:', error)\n    return Promise.reject(error)\n  }\n)\n\n// 响应拦截器\nrequest.interceptors.response.use(\n  (response) => {\n    const { data } = response\n    \n    // 根据后端接口规范处理响应\n    if (data.code === 200 || data.success) {\n      return data\n    } else {\n      ElMessage.error(data.msg || data.message || '请求失败')\n      return Promise.reject(new Error(data.msg || data.message || '请求失败'))\n    }\n  },\n  (error) => {\n    console.error('响应错误:', error)\n    \n    // 处理HTTP错误状态码\n    if (error.response) {\n      const { status } = error.response\n      switch (status) {\n        case 401:\n          ElMessage.error('未授权，请重新登录')\n          // 可以在这里处理登录跳转\n          break\n        case 403:\n          ElMessage.error('拒绝访问')\n          break\n        case 404:\n          ElMessage.error('请求地址不存在')\n          break\n        case 500:\n          ElMessage.error('服务器内部错误')\n          break\n        default:\n          ElMessage.error('网络错误')\n      }\n    } else {\n      ElMessage.error('网络连接失败')\n    }\n    \n    return Promise.reject(error)\n  }\n)\n\n// 封装常用的请求方法\nexport const http = {\n  get(url, params) {\n    return request.get(url, { params })\n  },\n  \n  post(url, data, config = {}) {\n    return request.post(url, data, config)\n  },\n  \n  put(url, data, config = {}) {\n    return request.put(url, data, config)\n  },\n  \n  delete(url, params) {\n    return request.delete(url, { params })\n  },\n  \n  upload(url, formData, onUploadProgress) {\n    return request.post(url, formData, {\n      headers: {\n        'Content-Type': 'multipart/form-data'\n      },\n      onUploadProgress\n    })\n  }\n}\n\nexport default request"
  },
  {
    "path": "sau_frontend/src/views/About.vue",
    "content": "<template>\n  <div class=\"about\">\n    <el-card class=\"about-card\">\n      <div class=\"about-header\">\n        <h1>自媒体自动化运营系统</h1>\n        <p class=\"version\">social-auto-upload</p>\n      </div>\n\n      <el-divider />\n\n      <div class=\"about-section\">\n        <h3>系统简介</h3>\n        <p>\n          本系统是一款强大的自动化工具，帮助内容创作者和运营人员一键将视频内容高效发布到多个国内外主流社交媒体平台。\n          支持视频上传、定时发布等功能。\n        </p>\n      </div>\n\n      <div class=\"about-section\">\n        <h3>支持平台</h3>\n        <div class=\"platform-tags\">\n          <el-tag type=\"danger\">抖音</el-tag>\n          <el-tag type=\"success\">快手</el-tag>\n          <el-tag type=\"warning\">视频号</el-tag>\n          <el-tag type=\"info\">小红书</el-tag>\n        </div>\n      </div>\n\n      <div class=\"about-section\">\n        <h3>核心功能</h3>\n        <ul class=\"feature-list\">\n          <li>多平台账号管理与登录状态维护</li>\n          <li>视频素材上传与管理</li>\n          <li>一键多平台发布</li>\n          <li>定时发布与批量发布</li>\n          <li>Cookie 导入导出</li>\n        </ul>\n      </div>\n\n      <div class=\"about-section\">\n        <h3>技术栈</h3>\n        <div class=\"tech-tags\">\n          <el-tag effect=\"plain\">Vue 3</el-tag>\n          <el-tag effect=\"plain\">Element Plus</el-tag>\n          <el-tag effect=\"plain\">Pinia</el-tag>\n          <el-tag effect=\"plain\">Flask</el-tag>\n          <el-tag effect=\"plain\">Playwright</el-tag>\n          <el-tag effect=\"plain\">SQLite</el-tag>\n        </div>\n      </div>\n    </el-card>\n  </div>\n</template>\n\n<script setup>\n// 关于页面组件\n</script>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/variables.scss' as *;\n\n.about {\n  max-width: 700px;\n  margin: 0 auto;\n\n  .about-card {\n    .about-header {\n      text-align: center;\n\n      h1 {\n        color: $text-primary;\n        margin: 0 0 8px 0;\n        font-size: 24px;\n      }\n\n      .version {\n        color: $text-secondary;\n        font-size: 14px;\n        margin: 0;\n      }\n    }\n\n    .about-section {\n      margin-bottom: 24px;\n\n      h3 {\n        font-size: 16px;\n        color: $text-primary;\n        margin: 0 0 12px 0;\n      }\n\n      p {\n        color: $text-secondary;\n        line-height: 1.8;\n        margin: 0;\n      }\n\n      .platform-tags,\n      .tech-tags {\n        display: flex;\n        flex-wrap: wrap;\n        gap: 10px;\n      }\n\n      .feature-list {\n        margin: 0;\n        padding-left: 20px;\n        color: $text-secondary;\n        line-height: 2;\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "sau_frontend/src/views/AccountManagement.vue",
    "content": "<template>\n  <div class=\"account-management\">\n    <div class=\"page-header\">\n      <h1>账号管理</h1>\n    </div>\n    \n    <div class=\"account-tabs\">\n      <el-tabs v-model=\"activeTab\" class=\"account-tabs-nav\">\n        <el-tab-pane label=\"全部\" name=\"all\">\n          <div class=\"account-list-container\">\n            <div class=\"account-search\">\n              <el-input\n                v-model=\"searchKeyword\"\n                placeholder=\"输入名称或账号搜索\"\n                prefix-icon=\"Search\"\n                clearable\n                @clear=\"handleSearch\"\n                @input=\"handleSearch\"\n              />\n              <div class=\"action-buttons\">\n                <el-button type=\"primary\" @click=\"handleAddAccount\">添加账号</el-button>\n                <el-button type=\"info\" @click=\"fetchAccounts\" :loading=\"false\">\n                  <el-icon :class=\"{ 'is-loading': appStore.isAccountRefreshing }\"><Refresh /></el-icon>\n                  <span v-if=\"appStore.isAccountRefreshing\">刷新中</span>\n                </el-button>\n              </div>\n            </div>\n            \n            <div v-if=\"filteredAccounts.length > 0\" class=\"account-list\">\n              <el-table :data=\"filteredAccounts\" style=\"width: 100%\">\n                <el-table-column label=\"头像\" width=\"80\">\n                  <template #default=\"scope\">\n                    <el-avatar :src=\"getDefaultAvatar(scope.row.name)\" :size=\"40\" />\n                  </template>\n                </el-table-column>\n                <el-table-column prop=\"name\" label=\"名称\" width=\"180\" />\n                <el-table-column prop=\"platform\" label=\"平台\">\n                  <template #default=\"scope\">\n                    <el-tag\n                      :type=\"getPlatformTagType(scope.row.platform)\"\n                      effect=\"plain\"\n                    >\n                      {{ scope.row.platform }}\n                    </el-tag>\n                  </template>\n                </el-table-column>\n                <el-table-column prop=\"status\" label=\"状态\">\n                  <template #default=\"scope\">\n                    <el-tag\n                      :type=\"getStatusTagType(scope.row.status)\"\n                      effect=\"plain\"\n                      :class=\"{'clickable-status': isStatusClickable(scope.row.status)}\"\n                      @click=\"handleStatusClick(scope.row)\"\n                    >\n                      <el-icon :class=\"scope.row.status === '验证中' ? 'is-loading' : ''\" v-if=\"scope.row.status === '验证中'\">\n                        <Loading />\n                      </el-icon>\n                      {{ scope.row.status }}\n                    </el-tag>\n                  </template>\n                </el-table-column>\n                <el-table-column label=\"操作\">\n                  <template #default=\"scope\">\n                    <el-button size=\"small\" @click=\"handleEdit(scope.row)\">编辑</el-button>\n                    <el-button size=\"small\" type=\"primary\" :icon=\"Download\" @click=\"handleDownloadCookie(scope.row)\">下载Cookie</el-button>\n                    <el-button size=\"small\" type=\"info\" :icon=\"Upload\" @click=\"handleUploadCookie(scope.row)\">上传Cookie</el-button>\n                    <el-button size=\"small\" type=\"danger\" @click=\"handleDelete(scope.row)\">删除</el-button>\n                  </template>\n                </el-table-column>\n              </el-table>\n            </div>\n            \n            <div v-else class=\"empty-data\">\n              <el-empty description=\"暂无账号数据\" />\n            </div>\n          </div>\n        </el-tab-pane>\n        \n        <el-tab-pane label=\"快手\" name=\"kuaishou\">\n          <div class=\"account-list-container\">\n            <div class=\"account-search\">\n              <el-input\n                v-model=\"searchKeyword\"\n                placeholder=\"输入名称或账号搜索\"\n                prefix-icon=\"Search\"\n                clearable\n                @clear=\"handleSearch\"\n                @input=\"handleSearch\"\n              />\n              <div class=\"action-buttons\">\n                <el-button type=\"primary\" @click=\"handleAddAccount\">添加账号</el-button>\n                <el-button type=\"info\" @click=\"fetchAccounts\" :loading=\"false\">\n                  <el-icon :class=\"{ 'is-loading': appStore.isAccountRefreshing }\"><Refresh /></el-icon>\n                  <span v-if=\"appStore.isAccountRefreshing\">刷新中</span>\n                </el-button>\n              </div>\n            </div>\n            \n            <div v-if=\"filteredKuaishouAccounts.length > 0\" class=\"account-list\">\n              <el-table :data=\"filteredKuaishouAccounts\" style=\"width: 100%\">\n                <el-table-column label=\"头像\" width=\"80\">\n                  <template #default=\"scope\">\n                    <el-avatar :src=\"getDefaultAvatar(scope.row.name)\" :size=\"40\" />\n                  </template>\n                </el-table-column>\n                <el-table-column prop=\"name\" label=\"名称\" width=\"180\" />\n                <el-table-column prop=\"platform\" label=\"平台\">\n                  <template #default=\"scope\">\n                    <el-tag\n                      :type=\"getPlatformTagType(scope.row.platform)\"\n                      effect=\"plain\"\n                    >\n                      {{ scope.row.platform }}\n                    </el-tag>\n                  </template>\n                </el-table-column>\n                <el-table-column prop=\"status\" label=\"状态\">\n                  <template #default=\"scope\">\n                    <el-tag\n                      :type=\"getStatusTagType(scope.row.status)\"\n                      effect=\"plain\"\n                      :class=\"{'clickable-status': isStatusClickable(scope.row.status)}\"\n                      @click=\"handleStatusClick(scope.row)\"\n                    >\n                      <el-icon :class=\"scope.row.status === '验证中' ? 'is-loading' : ''\" v-if=\"scope.row.status === '验证中'\">\n                        <Loading />\n                      </el-icon>\n                      {{ scope.row.status }}\n                    </el-tag>\n                  </template>\n                </el-table-column>\n                <el-table-column label=\"操作\">\n                  <template #default=\"scope\">\n                    <el-button size=\"small\" @click=\"handleEdit(scope.row)\">编辑</el-button>\n                    <el-button size=\"small\" type=\"primary\" :icon=\"Download\" @click=\"handleDownloadCookie(scope.row)\">下载Cookie</el-button>\n                    <el-button size=\"small\" type=\"info\" :icon=\"Upload\" @click=\"handleUploadCookie(scope.row)\">上传Cookie</el-button>\n                    <el-button size=\"small\" type=\"danger\" @click=\"handleDelete(scope.row)\">删除</el-button>\n                  </template>\n                </el-table-column>\n              </el-table>\n            </div>\n            \n            <div v-else class=\"empty-data\">\n              <el-empty description=\"暂无快手账号数据\" />\n            </div>\n          </div>\n        </el-tab-pane>\n        \n        <el-tab-pane label=\"抖音\" name=\"douyin\">\n          <div class=\"account-list-container\">\n            <div class=\"account-search\">\n              <el-input\n                v-model=\"searchKeyword\"\n                placeholder=\"输入名称或账号搜索\"\n                prefix-icon=\"Search\"\n                clearable\n                @clear=\"handleSearch\"\n                @input=\"handleSearch\"\n              />\n              <div class=\"action-buttons\">\n                <el-button type=\"primary\" @click=\"handleAddAccount\">添加账号</el-button>\n                <el-button type=\"info\" @click=\"fetchAccounts\" :loading=\"false\">\n                  <el-icon :class=\"{ 'is-loading': appStore.isAccountRefreshing }\"><Refresh /></el-icon>\n                  <span v-if=\"appStore.isAccountRefreshing\">刷新中</span>\n                </el-button>\n              </div>\n            </div>\n            \n            <div v-if=\"filteredDouyinAccounts.length > 0\" class=\"account-list\">\n              <el-table :data=\"filteredDouyinAccounts\" style=\"width: 100%\">\n                <el-table-column label=\"头像\" width=\"80\">\n                  <template #default=\"scope\">\n                    <el-avatar :src=\"getDefaultAvatar(scope.row.name)\" :size=\"40\" />\n                  </template>\n                </el-table-column>\n                <el-table-column prop=\"name\" label=\"名称\" width=\"180\" />\n                <el-table-column prop=\"platform\" label=\"平台\">\n                  <template #default=\"scope\">\n                    <el-tag\n                      :type=\"getPlatformTagType(scope.row.platform)\"\n                      effect=\"plain\"\n                    >\n                      {{ scope.row.platform }}\n                    </el-tag>\n                  </template>\n                </el-table-column>\n                <el-table-column prop=\"status\" label=\"状态\">\n                  <template #default=\"scope\">\n                    <el-tag\n                      :type=\"getStatusTagType(scope.row.status)\"\n                      effect=\"plain\"\n                      :class=\"{'clickable-status': isStatusClickable(scope.row.status)}\"\n                      @click=\"handleStatusClick(scope.row)\"\n                    >\n                      <el-icon :class=\"scope.row.status === '验证中' ? 'is-loading' : ''\" v-if=\"scope.row.status === '验证中'\">\n                        <Loading />\n                      </el-icon>\n                      {{ scope.row.status }}\n                    </el-tag>\n                  </template>\n                </el-table-column>\n                <el-table-column label=\"操作\">\n                  <template #default=\"scope\">\n                    <el-button size=\"small\" @click=\"handleEdit(scope.row)\">编辑</el-button>\n                    <el-button size=\"small\" type=\"primary\" :icon=\"Download\" @click=\"handleDownloadCookie(scope.row)\">下载Cookie</el-button>\n                    <el-button size=\"small\" type=\"info\" :icon=\"Upload\" @click=\"handleUploadCookie(scope.row)\">上传Cookie</el-button>\n                    <el-button size=\"small\" type=\"danger\" @click=\"handleDelete(scope.row)\">删除</el-button>\n                  </template>\n                </el-table-column>\n              </el-table>\n            </div>\n            \n            <div v-else class=\"empty-data\">\n              <el-empty description=\"暂无抖音账号数据\" />\n            </div>\n          </div>\n        </el-tab-pane>\n        \n        <el-tab-pane label=\"视频号\" name=\"channels\">\n          <div class=\"account-list-container\">\n            <div class=\"account-search\">\n              <el-input\n                v-model=\"searchKeyword\"\n                placeholder=\"输入名称或账号搜索\"\n                prefix-icon=\"Search\"\n                clearable\n                @clear=\"handleSearch\"\n                @input=\"handleSearch\"\n              />\n              <div class=\"action-buttons\">\n                <el-button type=\"primary\" @click=\"handleAddAccount\">添加账号</el-button>\n                <el-button type=\"info\" @click=\"fetchAccounts\" :loading=\"false\">\n                  <el-icon :class=\"{ 'is-loading': appStore.isAccountRefreshing }\"><Refresh /></el-icon>\n                  <span v-if=\"appStore.isAccountRefreshing\">刷新中</span>\n                </el-button>\n              </div>\n            </div>\n            \n            <div v-if=\"filteredChannelsAccounts.length > 0\" class=\"account-list\">\n              <el-table :data=\"filteredChannelsAccounts\" style=\"width: 100%\">\n                <el-table-column label=\"头像\" width=\"80\">\n                  <template #default=\"scope\">\n                    <el-avatar :src=\"getDefaultAvatar(scope.row.name)\" :size=\"40\" />\n                  </template>\n                </el-table-column>\n                <el-table-column prop=\"name\" label=\"名称\" width=\"180\" />\n                <el-table-column prop=\"platform\" label=\"平台\">\n                  <template #default=\"scope\">\n                    <el-tag\n                      :type=\"getPlatformTagType(scope.row.platform)\"\n                      effect=\"plain\"\n                    >\n                      {{ scope.row.platform }}\n                    </el-tag>\n                  </template>\n                </el-table-column>\n                <el-table-column prop=\"status\" label=\"状态\">\n                  <template #default=\"scope\">\n                    <el-tag\n                      :type=\"getStatusTagType(scope.row.status)\"\n                      effect=\"plain\"\n                      :class=\"{'clickable-status': isStatusClickable(scope.row.status)}\"\n                      @click=\"handleStatusClick(scope.row)\"\n                    >\n                      <el-icon :class=\"scope.row.status === '验证中' ? 'is-loading' : ''\" v-if=\"scope.row.status === '验证中'\">\n                        <Loading />\n                      </el-icon>\n                      {{ scope.row.status }}\n                    </el-tag>\n                  </template>\n                </el-table-column>\n                <el-table-column label=\"操作\">\n                  <template #default=\"scope\">\n                    <el-button size=\"small\" @click=\"handleEdit(scope.row)\">编辑</el-button>\n                    <el-button size=\"small\" type=\"primary\" :icon=\"Download\" @click=\"handleDownloadCookie(scope.row)\">下载Cookie</el-button>\n                    <el-button size=\"small\" type=\"info\" :icon=\"Upload\" @click=\"handleUploadCookie(scope.row)\">上传Cookie</el-button>\n                    <el-button size=\"small\" type=\"danger\" @click=\"handleDelete(scope.row)\">删除</el-button>\n                  </template>\n                </el-table-column>\n              </el-table>\n            </div>\n            \n            <div v-else class=\"empty-data\">\n              <el-empty description=\"暂无视频号账号数据\" />\n            </div>\n          </div>\n        </el-tab-pane>\n        \n        <el-tab-pane label=\"小红书\" name=\"xiaohongshu\">\n          <div class=\"account-list-container\">\n            <div class=\"account-search\">\n              <el-input\n                v-model=\"searchKeyword\"\n                placeholder=\"输入名称或账号搜索\"\n                prefix-icon=\"Search\"\n                clearable\n                @clear=\"handleSearch\"\n                @input=\"handleSearch\"\n              />\n              <div class=\"action-buttons\">\n                <el-button type=\"primary\" @click=\"handleAddAccount\">添加账号</el-button>\n                <el-button type=\"info\" @click=\"fetchAccounts\" :loading=\"false\">\n                  <el-icon :class=\"{ 'is-loading': appStore.isAccountRefreshing }\"><Refresh /></el-icon>\n                  <span v-if=\"appStore.isAccountRefreshing\">刷新中</span>\n                </el-button>\n              </div>\n            </div>\n            \n            <div v-if=\"filteredXiaohongshuAccounts.length > 0\" class=\"account-list\">\n              <el-table :data=\"filteredXiaohongshuAccounts\" style=\"width: 100%\">\n                <el-table-column label=\"头像\" width=\"80\">\n                  <template #default=\"scope\">\n                    <el-avatar :src=\"getDefaultAvatar(scope.row.name)\" :size=\"40\" />\n                  </template>\n                </el-table-column>\n                <el-table-column prop=\"name\" label=\"名称\" width=\"180\" />\n                <el-table-column prop=\"platform\" label=\"平台\">\n                  <template #default=\"scope\">\n                    <el-tag\n                      :type=\"getPlatformTagType(scope.row.platform)\"\n                      effect=\"plain\"\n                    >\n                      {{ scope.row.platform }}\n                    </el-tag>\n                  </template>\n                </el-table-column>\n                <el-table-column prop=\"status\" label=\"状态\">\n                  <template #default=\"scope\">\n                    <el-tag\n                      :type=\"getStatusTagType(scope.row.status)\"\n                      effect=\"plain\"\n                      :class=\"{'clickable-status': isStatusClickable(scope.row.status)}\"\n                      @click=\"handleStatusClick(scope.row)\"\n                    >\n                      <el-icon :class=\"scope.row.status === '验证中' ? 'is-loading' : ''\" v-if=\"scope.row.status === '验证中'\">\n                        <Loading />\n                      </el-icon>\n                      {{ scope.row.status }}\n                    </el-tag>\n                  </template>\n                </el-table-column>\n                <el-table-column label=\"操作\">\n                  <template #default=\"scope\">\n                    <el-button size=\"small\" @click=\"handleEdit(scope.row)\">编辑</el-button>\n                    <el-button size=\"small\" type=\"primary\" :icon=\"Download\" @click=\"handleDownloadCookie(scope.row)\">下载Cookie</el-button>\n                    <el-button size=\"small\" type=\"info\" :icon=\"Upload\" @click=\"handleUploadCookie(scope.row)\">上传Cookie</el-button>\n                    <el-button size=\"small\" type=\"danger\" @click=\"handleDelete(scope.row)\">删除</el-button>\n                  </template>\n                </el-table-column>\n              </el-table>\n            </div>\n            \n            <div v-else class=\"empty-data\">\n              <el-empty description=\"暂无小红书账号数据\" />\n            </div>\n          </div>\n        </el-tab-pane>\n      </el-tabs>\n    </div>\n    \n    <!-- 添加/编辑账号对话框 -->\n    <el-dialog\n      v-model=\"dialogVisible\"\n      :title=\"dialogType === 'add' ? '添加账号' : '编辑账号'\"\n      width=\"500px\"\n      :close-on-click-modal=\"false\"\n      :close-on-press-escape=\"!sseConnecting\"\n      :show-close=\"!sseConnecting\"\n    >\n      <el-form :model=\"accountForm\" label-width=\"80px\" :rules=\"rules\" ref=\"accountFormRef\">\n        <el-form-item label=\"平台\" prop=\"platform\">\n          <el-select \n            v-model=\"accountForm.platform\" \n            placeholder=\"请选择平台\" \n            style=\"width: 100%\"\n            :disabled=\"dialogType === 'edit' || sseConnecting\"\n          >\n            <el-option label=\"快手\" value=\"快手\" />\n            <el-option label=\"抖音\" value=\"抖音\" />\n            <el-option label=\"视频号\" value=\"视频号\" />\n            <el-option label=\"小红书\" value=\"小红书\" />\n          </el-select>\n        </el-form-item>\n        <el-form-item label=\"名称\" prop=\"name\">\n          <el-input \n            v-model=\"accountForm.name\" \n            placeholder=\"请输入账号名称\" \n            :disabled=\"sseConnecting\"\n          />\n        </el-form-item>\n        \n        <!-- 二维码显示区域 -->\n        <div v-if=\"sseConnecting\" class=\"qrcode-container\">\n          <div v-if=\"qrCodeData && !loginStatus\" class=\"qrcode-wrapper\">\n            <p class=\"qrcode-tip\">请使用对应平台APP扫描二维码登录</p>\n            <img :src=\"qrCodeData\" alt=\"登录二维码\" class=\"qrcode-image\" />\n          </div>\n          <div v-else-if=\"!qrCodeData && !loginStatus\" class=\"loading-wrapper\">\n            <el-icon class=\"is-loading\"><Refresh /></el-icon>\n            <span>请求中...</span>\n          </div>\n          <div v-else-if=\"loginStatus === '200'\" class=\"success-wrapper\">\n            <el-icon><CircleCheckFilled /></el-icon>\n            <span>添加成功</span>\n          </div>\n          <div v-else-if=\"loginStatus === '500'\" class=\"error-wrapper\">\n            <el-icon><CircleCloseFilled /></el-icon>\n            <span>添加失败，请稍后再试</span>\n          </div>\n        </div>\n      </el-form>\n      <template #footer>\n        <span class=\"dialog-footer\">\n          <el-button @click=\"dialogVisible = false\">取消</el-button>\n          <el-button \n            type=\"primary\" \n            @click=\"submitAccountForm\" \n            :loading=\"sseConnecting\" \n            :disabled=\"sseConnecting\"\n          >\n            {{ sseConnecting ? '请求中' : '确认' }}\n          </el-button>\n        </span>\n      </template>\n    </el-dialog>\n  </div>\n</template>\n\n<script setup>\nimport { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'\nimport { Refresh, CircleCheckFilled, CircleCloseFilled, Download, Upload, Loading } from '@element-plus/icons-vue'\nimport { ElMessage, ElMessageBox } from 'element-plus'\nimport { accountApi } from '@/api/account'\nimport { useAccountStore } from '@/stores/account'\nimport { useAppStore } from '@/stores/app'\nimport { http } from '@/utils/request'\n\n// 获取账号状态管理\nconst accountStore = useAccountStore()\n// 获取应用状态管理\nconst appStore = useAppStore()\n\n// 当前激活的标签页\nconst activeTab = ref('all')\n\n// 搜索关键词\nconst searchKeyword = ref('')\n\n// 获取账号数据（快速，不验证）\nconst fetchAccountsQuick = async () => {\n  try {\n    const res = await accountApi.getAccounts()\n    if (res.code === 200 && res.data) {\n      // 将所有账号的状态暂时设为\"验证中\"\n      const accountsWithPendingStatus = res.data.map(account => {\n        const updatedAccount = [...account];\n        updatedAccount[4] = -1; // -1 表示验证中的临时状态\n        return updatedAccount;\n      });\n      accountStore.setAccounts(accountsWithPendingStatus);\n    }\n  } catch (error) {\n    console.error('快速获取账号数据失败:', error)\n  }\n}\n\n// 获取账号数据（带验证）\nconst fetchAccounts = async () => {\n  if (appStore.isAccountRefreshing) return\n\n  appStore.setAccountRefreshing(true)\n\n  try {\n    const res = await accountApi.getValidAccounts()\n    if (res.code === 200 && res.data) {\n      accountStore.setAccounts(res.data)\n      ElMessage.success('账号数据获取成功')\n      // 标记为已访问\n      if (appStore.isFirstTimeAccountManagement) {\n        appStore.setAccountManagementVisited()\n      }\n    } else {\n      ElMessage.error('获取账号数据失败')\n    }\n  } catch (error) {\n    console.error('获取账号数据失败:', error)\n    ElMessage.error('获取账号数据失败')\n  } finally {\n    appStore.setAccountRefreshing(false)\n  }\n}\n\n// 后台验证所有账号（优化版本，使用setTimeout避免阻塞UI）\nconst validateAllAccountsInBackground = async () => {\n  // 使用setTimeout将验证过程放在下一个事件循环，避免阻塞UI\n  setTimeout(async () => {\n    try {\n      const res = await accountApi.getValidAccounts()\n      if (res.code === 200 && res.data) {\n        accountStore.setAccounts(res.data)\n      }\n    } catch (error) {\n      console.error('后台验证账号失败:', error)\n    }\n  }, 0)\n}\n\n// 页面加载时获取账号数据\nonMounted(() => {\n  // 快速获取账号列表（不验证），立即显示\n  fetchAccountsQuick()\n\n  // 在后台验证所有账号\n  setTimeout(() => {\n    validateAllAccountsInBackground()\n  }, 100) // 稍微延迟一下，让用户看到快速加载的效果\n})\n\n// 获取平台标签类型\nconst getPlatformTagType = (platform) => {\n  const typeMap = {\n    '快手': 'success',\n    '抖音': 'danger',\n    '视频号': 'warning',\n    '小红书': 'info'\n  }\n  return typeMap[platform] || 'info'\n}\n\n// 判断状态是否可点击（异常状态可点击）\nconst isStatusClickable = (status) => {\n  return status === '异常'; // 只有异常状态可点击，验证中不可点击\n}\n\n// 获取状态标签类型\nconst getStatusTagType = (status) => {\n  if (status === '验证中') {\n    return 'info'; // 验证中使用灰色\n  } else if (status === '正常') {\n    return 'success'; // 正常使用绿色\n  } else {\n    return 'danger'; // 无效使用红色\n  }\n}\n\n// 处理状态点击事件\nconst handleStatusClick = (row) => {\n  if (isStatusClickable(row.status)) {\n    // 触发重新登录流程\n    handleReLogin(row)\n  }\n}\n\n// 过滤后的账号列表\nconst filteredAccounts = computed(() => {\n  if (!searchKeyword.value) return accountStore.accounts\n  return accountStore.accounts.filter(account =>\n    account.name.includes(searchKeyword.value)\n  )\n})\n\n// 按平台过滤的账号列表\nconst filteredKuaishouAccounts = computed(() => {\n  return filteredAccounts.value.filter(account => account.platform === '快手')\n})\n\nconst filteredDouyinAccounts = computed(() => {\n  return filteredAccounts.value.filter(account => account.platform === '抖音')\n})\n\nconst filteredChannelsAccounts = computed(() => {\n  return filteredAccounts.value.filter(account => account.platform === '视频号')\n})\n\nconst filteredXiaohongshuAccounts = computed(() => {\n  return filteredAccounts.value.filter(account => account.platform === '小红书')\n})\n\n// 搜索处理\nconst handleSearch = () => {\n  // 搜索逻辑已通过计算属性实现\n}\n\n// 对话框相关\nconst dialogVisible = ref(false)\nconst dialogType = ref('add') // 'add' 或 'edit'\nconst accountFormRef = ref(null)\n\n// 账号表单\nconst accountForm = reactive({\n  id: null,\n  name: '',\n  platform: '',\n  status: '正常'\n})\n\n// 表单验证规则\nconst rules = {\n  platform: [{ required: true, message: '请选择平台', trigger: 'change' }],\n  name: [{ required: true, message: '请输入账号名称', trigger: 'blur' }]\n}\n\n// SSE连接状态\nconst sseConnecting = ref(false)\nconst qrCodeData = ref('')\nconst loginStatus = ref('')\n\n// 添加账号\nconst handleAddAccount = () => {\n  dialogType.value = 'add'\n  Object.assign(accountForm, {\n    id: null,\n    name: '',\n    platform: '',\n    status: '正常'\n  })\n  // 重置SSE状态\n  sseConnecting.value = false\n  qrCodeData.value = ''\n  loginStatus.value = ''\n  dialogVisible.value = true\n}\n\n// 编辑账号\nconst handleEdit = (row) => {\n  dialogType.value = 'edit'\n  Object.assign(accountForm, {\n    id: row.id,\n    name: row.name,\n    platform: row.platform,\n    status: row.status\n  })\n  dialogVisible.value = true\n}\n\n// 删除账号\nconst handleDelete = (row) => {\n  ElMessageBox.confirm(\n    `确定要删除账号 ${row.name} 吗？`,\n    '警告',\n    {\n      confirmButtonText: '确定',\n      cancelButtonText: '取消',\n      type: 'warning',\n    }\n  )\n    .then(async () => {\n      try {\n        // 调用API删除账号\n        const response = await accountApi.deleteAccount(row.id)\n\n        if (response.code === 200) {\n          // 从状态管理中删除账号\n          accountStore.deleteAccount(row.id)\n          ElMessage({\n            type: 'success',\n            message: '删除成功',\n          })\n        } else {\n          ElMessage.error(response.msg || '删除失败')\n        }\n      } catch (error) {\n        console.error('删除账号失败:', error)\n        ElMessage.error('删除账号失败')\n      }\n    })\n    .catch(() => {\n      // 取消删除\n    })\n}\n\n// 下载Cookie文件\nconst handleDownloadCookie = (row) => {\n  // 从后端获取Cookie文件\n  const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5409'\n  const downloadUrl = `${baseUrl}/downloadCookie?filePath=${encodeURIComponent(row.filePath)}`\n\n  // 创建一个隐藏的链接来触发下载\n  const link = document.createElement('a')\n  link.href = downloadUrl\n  link.download = `${row.name}_cookie.json`\n  link.target = '_blank'\n  link.style.display = 'none'\n  document.body.appendChild(link)\n  link.click()\n  document.body.removeChild(link)\n}\n\n// 上传Cookie文件\nconst handleUploadCookie = (row) => {\n  // 创建一个隐藏的文件输入框\n  const input = document.createElement('input')\n  input.type = 'file'\n  input.accept = '.json'\n  input.style.display = 'none'\n  document.body.appendChild(input)\n\n  input.onchange = async (event) => {\n    const file = event.target.files[0]\n    if (!file) return\n\n    // 检查文件类型\n    if (!file.name.endsWith('.json')) {\n      ElMessage.error('请选择JSON格式的Cookie文件')\n      document.body.removeChild(input)\n      return\n    }\n\n    try {\n      // 创建FormData对象\n      const formData = new FormData()\n      formData.append('file', file)\n      formData.append('id', row.id)\n      formData.append('platform', row.platform)\n\n      // 使用统一的http封装发送上传请求\n      const result = await http.upload('/uploadCookie', formData)\n\n      ElMessage.success('Cookie文件上传成功')\n      // 刷新账号列表以显示更新\n      fetchAccounts()\n    } catch (error) {\n      ElMessage.error('Cookie文件上传失败')\n    } finally {\n      document.body.removeChild(input)\n    }\n  }\n\n  input.click()\n}\n\n// 重新登录账号\nconst handleReLogin = (row) => {\n  // 设置表单信息\n  dialogType.value = 'edit'\n  Object.assign(accountForm, {\n    id: row.id,\n    name: row.name,\n    platform: row.platform,\n    status: row.status\n  })\n\n  // 重置SSE状态\n  sseConnecting.value = false\n  qrCodeData.value = ''\n  loginStatus.value = ''\n\n  // 显示对话框\n  dialogVisible.value = true\n\n  // 立即开始登录流程\n  setTimeout(() => {\n    connectSSE(row.platform, row.name)\n  }, 300)\n}\n\n// 获取默认头像\nconst getDefaultAvatar = (name) => {\n  // 使用简单的默认头像，可以基于用户名生成不同的颜色\n  return `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random`\n}\n\n// SSE事件源对象\nlet eventSource = null\n\n// 关闭SSE连接\nconst closeSSEConnection = () => {\n  if (eventSource) {\n    eventSource.close()\n    eventSource = null\n  }\n}\n\n// 建立SSE连接\nconst connectSSE = (platform, name) => {\n  // 关闭可能存在的连接\n  closeSSEConnection()\n\n  // 设置连接状态\n  sseConnecting.value = true\n  qrCodeData.value = ''\n  loginStatus.value = ''\n\n  // 获取平台类型编号\n  const platformTypeMap = {\n    '小红书': '1',\n    '视频号': '2',\n    '抖音': '3',\n    '快手': '4'\n  }\n\n  const type = platformTypeMap[platform] || '1'\n\n  // 创建SSE连接\n  const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5409'\n  const url = `${baseUrl}/login?type=${type}&id=${encodeURIComponent(name)}`\n\n  eventSource = new EventSource(url)\n\n  // 监听消息\n  eventSource.onmessage = (event) => {\n    const data = event.data\n\n    // 如果还没有二维码数据，且数据长度较长，认为是二维码\n    if (!qrCodeData.value && data.length > 100) {\n      try {\n        if (data.startsWith('data:image')) {\n          qrCodeData.value = data\n        } else {\n          qrCodeData.value = `data:image/png;base64,${data}`\n        }\n      } catch (error) {\n        // 处理二维码数据出错\n      }\n    }\n    // 如果收到状态码\n    else if (data === '200' || data === '500') {\n      loginStatus.value = data\n\n      // 如果登录成功\n      if (data === '200') {\n        setTimeout(() => {\n          // 关闭连接\n          closeSSEConnection()\n\n          // 1秒后关闭对话框并开始刷新\n          setTimeout(() => {\n            dialogVisible.value = false\n            sseConnecting.value = false\n\n            // 根据是否是重新登录显示不同提示\n            ElMessage.success(dialogType.value === 'edit' ? '重新登录成功' : '账号添加成功')\n\n            // 显示更新账号信息提示\n            ElMessage({\n              type: 'info',\n              message: '正在同步账号信息...',\n              duration: 0\n            })\n\n            // 触发刷新操作\n            fetchAccounts().then(() => {\n              // 刷新完成后关闭提示\n              ElMessage.closeAll()\n              ElMessage.success('账号信息已更新')\n            })\n          }, 1000)\n        }, 1000)\n      } else {\n        // 登录失败，关闭连接\n        closeSSEConnection()\n\n        // 2秒后重置状态，允许重试\n        setTimeout(() => {\n          sseConnecting.value = false\n          qrCodeData.value = ''\n          loginStatus.value = ''\n        }, 2000)\n      }\n    }\n  }\n\n  // 监听错误\n  eventSource.onerror = (error) => {\n    console.error('SSE连接错误:', error)\n    ElMessage.error('连接服务器失败，请稍后再试')\n    closeSSEConnection()\n    sseConnecting.value = false\n  }\n}\n\n// 提交账号表单\nconst submitAccountForm = () => {\n  accountFormRef.value.validate(async (valid) => {\n    if (valid) {\n      if (dialogType.value === 'add') {\n        // 建立SSE连接\n        connectSSE(accountForm.platform, accountForm.name)\n      } else {\n        // 编辑账号逻辑\n        try {\n          // 将平台名称转换为类型数字\n          const platformTypeMap = {\n            '小红书': 1,\n            '视频号': 2,\n            '抖音': 3,\n            '快手': 4\n          };\n          const type = platformTypeMap[accountForm.platform] || 1;\n\n          const res = await accountApi.updateAccount({\n            id: accountForm.id,\n            type: type,\n            userName: accountForm.name\n          })\n          if (res.code === 200) {\n            // 更新状态管理中的账号\n            const updatedAccount = {\n              id: accountForm.id,\n              name: accountForm.name,\n              platform: accountForm.platform,\n              status: accountForm.status // Keep the existing status\n            };\n            accountStore.updateAccount(accountForm.id, updatedAccount)\n            ElMessage.success('更新成功')\n            dialogVisible.value = false\n            // 刷新账号列表\n            fetchAccounts()\n          } else {\n            ElMessage.error(res.msg || '更新账号失败')\n          }\n        } catch (error) {\n          console.error('更新账号失败:', error)\n          ElMessage.error('更新账号失败')\n        }\n      }\n    } else {\n      return false\n    }\n  })\n}\n\n// 组件卸载前关闭SSE连接\nonBeforeUnmount(() => {\n  closeSSEConnection()\n})\n</script>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/variables.scss' as *;\n\n@keyframes rotate {\n  from {\n    transform: rotate(0deg);\n  }\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n.account-management {\n  .page-header {\n    margin-bottom: 20px;\n    \n    h1 {\n      font-size: 24px;\n      color: $text-primary;\n      margin: 0;\n    }\n  }\n  \n  .account-tabs {\n    background-color: #fff;\n    border-radius: 4px;\n    box-shadow: $box-shadow-light;\n    \n    .account-tabs-nav {\n      padding: 20px;\n    }\n  }\n  \n  .account-list-container {\n    .account-search {\n      display: flex;\n      justify-content: space-between;\n      margin-bottom: 20px;\n      \n      .el-input {\n        width: 300px;\n      }\n      \n      .action-buttons {\n        display: flex;\n        gap: 10px;\n        \n        .el-icon.is-loading {\n          animation: rotate 1s linear infinite;\n        }\n      }\n    }\n    \n    .account-list {\n      margin-bottom: 20px;\n    }\n    \n    .empty-data {\n      padding: 40px 0;\n    }\n  }\n  \n  // 二维码容器样式\n  .clickable-status {\n    cursor: pointer;\n    transition: all 0.3s;\n\n    &:hover {\n      transform: scale(1.05);\n      box-shadow: 0 0 8px rgba(0, 0, 0, 0.15);\n    }\n  }\n\n  .qrcode-container {\n    margin-top: 20px;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    min-height: 250px;\n    \n    .qrcode-wrapper {\n      text-align: center;\n      \n      .qrcode-tip {\n        margin-bottom: 15px;\n        color: #606266;\n      }\n      \n      .qrcode-image {\n        max-width: 200px;\n        max-height: 200px;\n        border: 1px solid #ebeef5;\n        background-color: black;\n      }\n    }\n    \n    .loading-wrapper, .success-wrapper, .error-wrapper {\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n      justify-content: center;\n      gap: 10px;\n      \n      .el-icon {\n        font-size: 48px;\n        \n        &.is-loading {\n          animation: rotate 1s linear infinite;\n        }\n      }\n      \n      span {\n        font-size: 16px;\n      }\n    }\n    \n    .success-wrapper .el-icon {\n      color: #67c23a;\n    }\n    \n    .error-wrapper .el-icon {\n      color: #f56c6c;\n    }\n  }\n}\n</style>"
  },
  {
    "path": "sau_frontend/src/views/Dashboard.vue",
    "content": "<template>\n  <div class=\"dashboard\">\n    <div class=\"page-header\">\n      <h1>自媒体自动化运营系统</h1>\n    </div>\n\n    <div class=\"dashboard-content\">\n      <el-row :gutter=\"20\">\n        <!-- 账号统计卡片 -->\n        <el-col :span=\"8\">\n          <el-card class=\"stat-card\">\n            <div class=\"stat-card-content\">\n              <div class=\"stat-icon\">\n                <el-icon><User /></el-icon>\n              </div>\n              <div class=\"stat-info\">\n                <div class=\"stat-value\">{{ accountStats.total }}</div>\n                <div class=\"stat-label\">账号总数</div>\n              </div>\n            </div>\n            <div class=\"stat-footer\">\n              <div class=\"stat-detail\">\n                <span>正常: {{ accountStats.normal }}</span>\n                <span>异常: {{ accountStats.abnormal }}</span>\n              </div>\n            </div>\n          </el-card>\n        </el-col>\n\n        <!-- 平台统计卡片 -->\n        <el-col :span=\"8\">\n          <el-card class=\"stat-card\">\n            <div class=\"stat-card-content\">\n              <div class=\"stat-icon platform-icon\">\n                <el-icon><Platform /></el-icon>\n              </div>\n              <div class=\"stat-info\">\n                <div class=\"stat-value\">{{ platformStats.total }}</div>\n                <div class=\"stat-label\">已接入平台</div>\n              </div>\n            </div>\n            <div class=\"stat-footer\">\n              <div class=\"stat-detail\">\n                <el-tooltip content=\"快手账号\" placement=\"top\">\n                  <el-tag size=\"small\" type=\"success\">{{ platformStats.kuaishou }}</el-tag>\n                </el-tooltip>\n                <el-tooltip content=\"抖音账号\" placement=\"top\">\n                  <el-tag size=\"small\" type=\"danger\">{{ platformStats.douyin }}</el-tag>\n                </el-tooltip>\n                <el-tooltip content=\"视频号账号\" placement=\"top\">\n                  <el-tag size=\"small\" type=\"warning\">{{ platformStats.channels }}</el-tag>\n                </el-tooltip>\n                <el-tooltip content=\"小红书账号\" placement=\"top\">\n                  <el-tag size=\"small\" type=\"info\">{{ platformStats.xiaohongshu }}</el-tag>\n                </el-tooltip>\n              </div>\n            </div>\n          </el-card>\n        </el-col>\n\n        <!-- 素材统计卡片 -->\n        <el-col :span=\"8\">\n          <el-card class=\"stat-card\">\n            <div class=\"stat-card-content\">\n              <div class=\"stat-icon content-icon\">\n                <el-icon><Document /></el-icon>\n              </div>\n              <div class=\"stat-info\">\n                <div class=\"stat-value\">{{ contentStats.total }}</div>\n                <div class=\"stat-label\">素材总数</div>\n              </div>\n            </div>\n            <div class=\"stat-footer\">\n              <div class=\"stat-detail\">\n                <span>视频: {{ contentStats.videos }}</span>\n                <span>图片: {{ contentStats.images }}</span>\n                <span>其他: {{ contentStats.others }}</span>\n              </div>\n            </div>\n          </el-card>\n        </el-col>\n      </el-row>\n\n      <!-- 快捷操作区域 -->\n      <div class=\"quick-actions\">\n        <h2>快捷操作</h2>\n        <el-row :gutter=\"20\">\n          <el-col :span=\"6\">\n            <el-card class=\"action-card\" @click=\"navigateTo('/account-management')\">\n              <div class=\"action-icon\">\n                <el-icon><UserFilled /></el-icon>\n              </div>\n              <div class=\"action-title\">账号管理</div>\n              <div class=\"action-desc\">管理所有平台账号</div>\n            </el-card>\n          </el-col>\n          <el-col :span=\"6\">\n            <el-card class=\"action-card\" @click=\"navigateTo('/material-management')\">\n              <div class=\"action-icon\">\n                <el-icon><Upload /></el-icon>\n              </div>\n              <div class=\"action-title\">素材管理</div>\n              <div class=\"action-desc\">上传和管理视频素材</div>\n            </el-card>\n          </el-col>\n          <el-col :span=\"6\">\n            <el-card class=\"action-card\" @click=\"navigateTo('/publish-center')\">\n              <div class=\"action-icon\">\n                <el-icon><Timer /></el-icon>\n              </div>\n              <div class=\"action-title\">发布中心</div>\n              <div class=\"action-desc\">发布内容到各平台</div>\n            </el-card>\n          </el-col>\n          <el-col :span=\"6\">\n            <el-card class=\"action-card\" @click=\"navigateTo('/about')\">\n              <div class=\"action-icon\">\n                <el-icon><DataAnalysis /></el-icon>\n              </div>\n              <div class=\"action-title\">关于系统</div>\n              <div class=\"action-desc\">查看系统信息</div>\n            </el-card>\n          </el-col>\n        </el-row>\n      </div>\n\n      <!-- 素材列表 -->\n      <div class=\"recent-tasks\">\n        <div class=\"section-header\">\n          <h2>最近上传素材</h2>\n          <el-button text @click=\"navigateTo('/material-management')\">查看全部</el-button>\n        </div>\n\n        <el-table :data=\"recentMaterials\" style=\"width: 100%\" v-loading=\"loading\">\n          <el-table-column prop=\"filename\" label=\"文件名\" width=\"300\" />\n          <el-table-column prop=\"filesize\" label=\"文件大小\" width=\"120\">\n            <template #default=\"scope\">\n              {{ scope.row.filesize }} MB\n            </template>\n          </el-table-column>\n          <el-table-column prop=\"upload_time\" label=\"上传时间\" width=\"200\" />\n          <el-table-column label=\"类型\" width=\"100\">\n            <template #default=\"scope\">\n              <el-tag\n                :type=\"getFileTypeTag(scope.row.filename)\"\n                effect=\"plain\"\n                size=\"small\"\n              >\n                {{ getFileType(scope.row.filename) }}\n              </el-tag>\n            </template>\n          </el-table-column>\n        </el-table>\n\n        <el-empty v-if=\"!loading && recentMaterials.length === 0\" description=\"暂无素材数据\" />\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, reactive, computed, onMounted } from 'vue'\nimport { useRouter } from 'vue-router'\nimport {\n  User, UserFilled, Platform, Document,\n  Upload, Timer, DataAnalysis\n} from '@element-plus/icons-vue'\nimport { accountApi } from '@/api/account'\nimport { materialApi } from '@/api/material'\nimport { useAccountStore } from '@/stores/account'\nimport { useAppStore } from '@/stores/app'\n\nconst router = useRouter()\nconst accountStore = useAccountStore()\nconst appStore = useAppStore()\nconst loading = ref(false)\n\n// 账号统计数据 - 从真实数据计算\nconst accountStats = computed(() => {\n  const accounts = accountStore.accounts\n  const normal = accounts.filter(a => a.status === '正常').length\n  const abnormal = accounts.filter(a => a.status !== '正常' && a.status !== '验证中').length\n  return {\n    total: accounts.length,\n    normal,\n    abnormal\n  }\n})\n\n// 平台统计数据 - 从真实数据计算\nconst platformStats = computed(() => {\n  const accounts = accountStore.accounts\n  const kuaishou = accounts.filter(a => a.platform === '快手').length\n  const douyin = accounts.filter(a => a.platform === '抖音').length\n  const channels = accounts.filter(a => a.platform === '视频号').length\n  const xiaohongshu = accounts.filter(a => a.platform === '小红书').length\n  // 统计有账号的平台数量\n  const total = [kuaishou, douyin, channels, xiaohongshu].filter(n => n > 0).length\n  return { total, kuaishou, douyin, channels, xiaohongshu }\n})\n\n// 素材统计数据 - 从真实数据计算\nconst videoExtensions = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']\nconst imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']\n\nconst contentStats = computed(() => {\n  const materials = appStore.materials\n  const videos = materials.filter(m => videoExtensions.some(ext => m.filename.toLowerCase().endsWith(ext))).length\n  const images = materials.filter(m => imageExtensions.some(ext => m.filename.toLowerCase().endsWith(ext))).length\n  return {\n    total: materials.length,\n    videos,\n    images,\n    others: materials.length - videos - images\n  }\n})\n\n// 最近上传的素材（最多显示5条）\nconst recentMaterials = computed(() => {\n  return [...appStore.materials]\n    .sort((a, b) => new Date(b.upload_time) - new Date(a.upload_time))\n    .slice(0, 5)\n})\n\n// 获取文件类型\nconst getFileType = (filename) => {\n  if (videoExtensions.some(ext => filename.toLowerCase().endsWith(ext))) return '视频'\n  if (imageExtensions.some(ext => filename.toLowerCase().endsWith(ext))) return '图片'\n  return '其他'\n}\n\n// 获取文件类型标签颜色\nconst getFileTypeTag = (filename) => {\n  const type = getFileType(filename)\n  return { '视频': 'success', '图片': 'warning', '其他': 'info' }[type] || 'info'\n}\n\n// 导航到指定路由\nconst navigateTo = (path) => {\n  router.push(path)\n}\n\n// 加载数据\nconst fetchDashboardData = async () => {\n  loading.value = true\n  try {\n    // 并行获取账号和素材数据\n    const [accountRes, materialRes] = await Promise.allSettled([\n      accountApi.getAccounts(),\n      materialApi.getAllMaterials()\n    ])\n\n    if (accountRes.status === 'fulfilled' && accountRes.value.code === 200) {\n      accountStore.setAccounts(accountRes.value.data)\n    }\n    if (materialRes.status === 'fulfilled' && materialRes.value.code === 200) {\n      appStore.setMaterials(materialRes.value.data)\n    }\n  } catch (error) {\n    console.error('获取仪表盘数据失败:', error)\n  } finally {\n    loading.value = false\n  }\n}\n\nonMounted(() => {\n  fetchDashboardData()\n})\n</script>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/variables.scss' as *;\n\n.dashboard {\n  .page-header {\n    margin-bottom: 20px;\n\n    h1 {\n      font-size: 24px;\n      color: $text-primary;\n      margin: 0;\n    }\n  }\n\n  .dashboard-content {\n    .stat-card {\n      height: 140px;\n      margin-bottom: 20px;\n\n      .stat-card-content {\n        display: flex;\n        align-items: center;\n        margin-bottom: 15px;\n\n        .stat-icon {\n          width: 60px;\n          height: 60px;\n          border-radius: 50%;\n          background-color: rgba($primary-color, 0.1);\n          display: flex;\n          justify-content: center;\n          align-items: center;\n          margin-right: 15px;\n\n          .el-icon {\n            font-size: 30px;\n            color: $primary-color;\n          }\n\n          &.platform-icon {\n            background-color: rgba($success-color, 0.1);\n\n            .el-icon {\n              color: $success-color;\n            }\n          }\n\n          &.content-icon {\n            background-color: rgba($info-color, 0.1);\n\n            .el-icon {\n              color: $info-color;\n            }\n          }\n        }\n\n        .stat-info {\n          .stat-value {\n            font-size: 24px;\n            font-weight: bold;\n            color: $text-primary;\n            line-height: 1.2;\n          }\n\n          .stat-label {\n            font-size: 14px;\n            color: $text-secondary;\n          }\n        }\n      }\n\n      .stat-footer {\n        border-top: 1px solid $border-lighter;\n        padding-top: 10px;\n\n        .stat-detail {\n          display: flex;\n          justify-content: space-between;\n          color: $text-secondary;\n          font-size: 13px;\n\n          .el-tag {\n            margin-right: 5px;\n          }\n        }\n      }\n    }\n\n    .quick-actions {\n      margin: 20px 0 30px;\n\n      h2 {\n        font-size: 18px;\n        margin-bottom: 15px;\n        color: $text-primary;\n      }\n\n      .action-card {\n        height: 160px;\n        display: flex;\n        flex-direction: column;\n        align-items: center;\n        justify-content: center;\n        cursor: pointer;\n        transition: all 0.3s;\n\n        &:hover {\n          transform: translateY(-5px);\n          box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);\n        }\n\n        .action-icon {\n          width: 50px;\n          height: 50px;\n          border-radius: 50%;\n          background-color: rgba($primary-color, 0.1);\n          display: flex;\n          justify-content: center;\n          align-items: center;\n          margin-bottom: 15px;\n\n          .el-icon {\n            font-size: 24px;\n            color: $primary-color;\n          }\n        }\n\n        .action-title {\n          font-size: 16px;\n          font-weight: bold;\n          color: $text-primary;\n          margin-bottom: 5px;\n        }\n\n        .action-desc {\n          font-size: 13px;\n          color: $text-secondary;\n          text-align: center;\n        }\n      }\n    }\n\n    .recent-tasks {\n      margin-top: 30px;\n\n      .section-header {\n        display: flex;\n        justify-content: space-between;\n        align-items: center;\n        margin-bottom: 15px;\n\n        h2 {\n          font-size: 18px;\n          color: $text-primary;\n          margin: 0;\n        }\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "sau_frontend/src/views/MaterialManagement.vue",
    "content": "<template>\n  <div class=\"material-management\">\n    <div class=\"page-header\">\n      <h1>素材管理</h1>\n    </div>\n    \n    <div class=\"material-list-container\">\n      <div class=\"material-search\">\n        <el-input\n          v-model=\"searchKeyword\"\n          placeholder=\"输入文件名搜索\"\n          prefix-icon=\"Search\"\n          clearable\n          @clear=\"handleSearch\"\n          @input=\"handleSearch\"\n        />\n        <div class=\"action-buttons\">\n          <el-button type=\"primary\" @click=\"handleUploadMaterial\">上传素材</el-button>\n          <el-button type=\"info\" @click=\"fetchMaterials\" :loading=\"false\">\n            <el-icon :class=\"{ 'is-loading': isRefreshing }\"><Refresh /></el-icon>\n            <span v-if=\"isRefreshing\">刷新中</span>\n          </el-button>\n        </div>\n      </div>\n      \n      <div v-if=\"filteredMaterials.length > 0\" class=\"material-list\">\n        <el-table :data=\"filteredMaterials\" style=\"width: 100%\">\n          <el-table-column prop=\"uuid\" label=\"UUID\" width=\"180\" />\n          <el-table-column prop=\"filename\" label=\"文件名\" width=\"300\" />\n          <el-table-column prop=\"filesize\" label=\"文件大小\" width=\"120\">\n            <template #default=\"scope\">\n              {{ scope.row.filesize }} MB\n            </template>\n          </el-table-column>\n          <el-table-column prop=\"upload_time\" label=\"上传时间\" width=\"180\" />\n          <el-table-column label=\"操作\">\n            <template #default=\"scope\">\n              <el-button size=\"small\" @click=\"handlePreview(scope.row)\">预览</el-button>\n              <el-button size=\"small\" type=\"danger\" @click=\"handleDelete(scope.row)\">删除</el-button>\n            </template>\n          </el-table-column>\n        </el-table>\n      </div>\n      \n      <div v-else class=\"empty-data\">\n        <el-empty description=\"暂无素材数据\" />\n      </div>\n    </div>\n    \n    <!-- 上传对话框 -->\n    <el-dialog\n      v-model=\"uploadDialogVisible\"\n      title=\"上传素材\"\n      width=\"40%\"\n      @close=\"handleUploadDialogClose\"\n    >\n      <div class=\"upload-form\">\n        <el-form label-width=\"80px\">\n          <el-form-item label=\"文件名称:\">\n            <el-input\n              v-model=\"customFilename\"\n              placeholder=\"选填 (仅单个文件时生效)\"\n              :disabled=\"customFilenameDisabled\"\n              clearable\n            />\n          </el-form-item>\n          <el-form-item label=\"选择文件\">\n            <el-upload\n              class=\"upload-demo\"\n              drag\n              multiple\n              :auto-upload=\"false\"\n              :on-change=\"handleFileChange\"\n              :on-remove=\"handleFileRemove\"\n              :file-list=\"fileList\"\n            >\n              <el-icon class=\"el-icon--upload\"><Upload /></el-icon>\n              <div class=\"el-upload__text\">\n                将文件拖到此处，或<em>点击上传</em>\n              </div>\n              <template #tip>\n                <div class=\"el-upload__tip\">\n                  支持视频、图片等格式文件，可一次选择多个文件\n                </div>\n              </template>\n            </el-upload>\n          </el-form-item>\n          <el-form-item label=\"上传列表\" v-if=\"fileList.length > 0\">\n            <div class=\"upload-file-list\">\n              <div v-for=\"file in fileList\" :key=\"file.uid\" class=\"upload-file-item\">\n                <span class=\"file-name\">{{ file.name }}</span>\n                <el-progress\n                  :percentage=\"uploadProgress[file.uid]?.percentage || 0\"\n                  :text-inside=\"true\"\n                  :stroke-width=\"20\"\n                  style=\"width: 100%; margin-top: 5px;\"\n                >\n                  <span>{{ uploadProgress[file.uid]?.speed || '' }}</span>\n                </el-progress>\n              </div>\n            </div>\n          </el-form-item>\n        </el-form>\n      </div>\n      <template #footer>\n        <div class=\"dialog-footer\">\n          <el-button @click=\"uploadDialogVisible = false\">取消</el-button>\n          <el-button type=\"primary\" @click=\"submitUpload\" :loading=\"isUploading\">\n            {{ isUploading ? '上传中' : '确认上传' }}\n          </el-button>\n        </div>\n      </template>\n    </el-dialog>\n    \n    <!-- 预览对话框 -->\n    <el-dialog\n      v-model=\"previewDialogVisible\"\n      title=\"素材预览\"\n      width=\"50%\"\n      :top=\"'10vh'\"\n    >\n      <div class=\"preview-container\" v-if=\"currentMaterial\">\n        <div v-if=\"isVideoFile(currentMaterial.filename)\" class=\"video-preview\">\n          <video controls style=\"max-width: 100%; max-height: 60vh;\">\n            <source :src=\"getPreviewUrl(currentMaterial.file_path)\" type=\"video/mp4\">\n            您的浏览器不支持视频播放\n          </video>\n        </div>\n        <div v-else-if=\"isImageFile(currentMaterial.filename)\" class=\"image-preview\">\n          <img :src=\"getPreviewUrl(currentMaterial.file_path)\" style=\"max-width: 100%; max-height: 60vh;\" />\n        </div>\n        <div v-else class=\"file-info\">\n          <p>文件名: {{ currentMaterial.filename }}</p>\n          <p>文件大小: {{ currentMaterial.filesize }} MB</p>\n          <p>上传时间: {{ currentMaterial.upload_time }}</p>\n          <el-button type=\"primary\" @click=\"downloadFile(currentMaterial)\">下载文件</el-button>\n        </div>\n      </div>\n    </el-dialog>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, watch } from 'vue'\nimport { Refresh, Upload } from '@element-plus/icons-vue'\nimport { ElMessage, ElMessageBox } from 'element-plus'\nimport { materialApi } from '@/api/material'\nimport { useAppStore } from '@/stores/app'\n\n// 获取应用状态管理\nconst appStore = useAppStore()\n\n// 搜索和状态控制\nconst searchKeyword = ref('')\nconst isRefreshing = ref(false)\nconst isUploading = ref(false)\n\n// 对话框控制\nconst uploadDialogVisible = ref(false)\nconst previewDialogVisible = ref(false)\nconst currentMaterial = ref(null)\n\n// 文件上传\nconst fileList = ref([])\nconst customFilename = ref('')\nconst customFilenameDisabled = computed(() => fileList.value.length > 1)\nconst uploadProgress = ref({}); // { [uid]: { percentage: 0, speed: '' } }\n\n\nwatch(fileList, (newList) => {\n  if (newList.length <= 1) {\n    // If you want to clear the custom name when going back to single file, uncomment below\n    // customFilename.value = ''\n  }\n});\n\n\n// 获取素材列表\nconst fetchMaterials = async () => {\n  isRefreshing.value = true\n  try {\n    const response = await materialApi.getAllMaterials()\n    \n    if (response.code === 200) {\n      appStore.setMaterials(response.data)\n      ElMessage.success('刷新成功')\n    } else {\n      ElMessage.error('获取素材列表失败')\n    }\n  } catch (error) {\n    console.error('获取素材列表出错:', error)\n    ElMessage.error('获取素材列表失败')\n  } finally {\n    isRefreshing.value = false\n  }\n}\n\n// 过滤素材\nconst filteredMaterials = computed(() => {\n  if (!searchKeyword.value) return appStore.materials\n  \n  const keyword = searchKeyword.value.toLowerCase()\n  return appStore.materials.filter(material => \n    material.filename.toLowerCase().includes(keyword)\n  )\n})\n\n// 搜索处理\nconst handleSearch = () => {\n  // 搜索逻辑已通过计算属性实现\n}\n\n// 上传素材\nconst handleUploadMaterial = () => {\n  // 清空变量\n  fileList.value = []\n  customFilename.value = ''\n  uploadProgress.value = {};\n  uploadDialogVisible.value = true\n}\n\n// 关闭上传对话框时清空变量\nconst handleUploadDialogClose = () => {\n  fileList.value = []\n  customFilename.value = ''\n  uploadProgress.value = {};\n}\n\n// 文件选择变更\nconst handleFileChange = (file, uploadFileList) => {\n  fileList.value = uploadFileList;\n  const newProgress = {};\n  for (const f of uploadFileList) {\n    newProgress[f.uid] = { percentage: 0, speed: '' };\n  }\n  uploadProgress.value = newProgress;\n}\n\nconst handleFileRemove = (file, uploadFileList) => {\n  fileList.value = uploadFileList;\n  const newProgress = { ...uploadProgress.value };\n  delete newProgress[file.uid];\n  uploadProgress.value = newProgress;\n}\n\n// 提交上传\nconst submitUpload = async () => {\n  if (fileList.value.length === 0) {\n    ElMessage.warning('请选择要上传的文件')\n    return\n  }\n  \n  isUploading.value = true\n  \n  for (const file of fileList.value) {\n    try {\n      // 确保文件对象存在\n      if (!file || !file.raw) {\n        ElMessage.warning(`文件 ${file.name} 对象无效，已跳过`)\n        continue\n      }\n      \n      const formData = new FormData()\n      formData.append('file', file.raw)\n      \n      // 只有当只有一个文件时，自定义文件名才生效\n      if (fileList.value.length === 1 && customFilename.value.trim()) {\n        formData.append('filename', customFilename.value.trim())\n      }\n      \n      let lastLoaded = 0;\n      let lastTime = Date.now();\n\n      const response = await materialApi.uploadMaterial(formData, (progressEvent) => {\n        const progressData = uploadProgress.value[file.uid];\n        if (!progressData) return;\n\n        const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)\n        progressData.percentage = progress;\n\n        const currentTime = Date.now();\n        const timeDiff = (currentTime - lastTime) / 1000; // in seconds\n        const loadedDiff = progressEvent.loaded - lastLoaded;\n\n        if (timeDiff > 0.5) { // Update speed every 0.5 seconds\n          const speed = loadedDiff / timeDiff; // bytes per second\n          if (speed > 1024 * 1024) {\n            progressData.speed = (speed / (1024 * 1024)).toFixed(2) + ' MB/s';\n          } else {\n            progressData.speed = (speed / 1024).toFixed(2) + ' KB/s';\n          }\n          lastLoaded = progressEvent.loaded;\n          lastTime = currentTime;\n        }\n      })\n      \n      if (response.code === 200) {\n        ElMessage.success(`文件 ${file.name} 上传成功`)\n        const progressData = uploadProgress.value[file.uid];\n        if(progressData) progressData.speed = '完成';\n      } else {\n        ElMessage.error(`文件 ${file.name} 上传失败: ${response.msg || '未知错误'}`)\n      }\n    } catch (error) {\n      console.error(`上传文件 ${file.name} 出错:`, error)\n      ElMessage.error(`文件 ${file.name} 上传失败: ${error.message || '未知错误'}`)\n    }\n  }\n  \n  isUploading.value = false\n  // Keep dialog open to show results\n  // uploadDialogVisible.value = false \n  await fetchMaterials()\n}\n\n// 预览素材\nconst handlePreview = async (material) => {\n  currentMaterial.value = null\n  previewDialogVisible.value = true\n  ElMessage.info('加载中...')\n  try {\n    // 等待一小段时间以确保对话框已打开\n    await new Promise(resolve => setTimeout(resolve, 100))\n    currentMaterial.value = material\n  } catch (error) {\n    console.error('预览素材出错:', error)\n    ElMessage.error('预览加载失败')\n    previewDialogVisible.value = false\n  }\n}\n\n// 删除素材\nconst handleDelete = (material) => {\n  ElMessageBox.confirm(\n    `确定要删除素材 ${material.filename} 吗？`,\n    '警告',\n    {\n      confirmButtonText: '确定',\n      cancelButtonText: '取消',\n      type: 'warning',\n    }\n  )\n    .then(async () => {\n      try {\n        const response = await materialApi.deleteMaterial(material.id)\n        \n        if (response.code === 200) {\n          appStore.removeMaterial(material.id)\n          ElMessage.success('删除成功')\n        } else {\n          ElMessage.error(response.msg || '删除失败')\n        }\n      } catch (error) {\n        console.error('删除素材出错:', error)\n        ElMessage.error('删除失败')\n      }\n    })\n    .catch(() => {\n      // 取消删除\n    })\n}\n\n// 获取预览URL\nconst getPreviewUrl = (filePath) => {\n  const filename = filePath.split('/').pop()\n  return materialApi.getMaterialPreviewUrl(filename)\n}\n\n// 下载文件\nconst downloadFile = (material) => {\n  const url = materialApi.downloadMaterial(material.file_path)\n  window.open(url, '_blank')\n}\n\n// 判断文件类型\nconst isVideoFile = (filename) => {\n  const videoExtensions = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']\n  return videoExtensions.some(ext => filename.toLowerCase().endsWith(ext))\n}\n\nconst isImageFile = (filename) => {\n  const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']\n  return imageExtensions.some(ext => filename.toLowerCase().endsWith(ext))\n}\n\n// 组件挂载时获取素材列表\nonMounted(() => {\n  // 只有store中没有数据时才获取\n  if (appStore.materials.length === 0) {\n    fetchMaterials()\n  }\n})\n</script>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/variables.scss' as *;\n\n@keyframes rotate {\n  from {\n    transform: rotate(0deg);\n  }\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n.material-management {\n  \n  .page-header {\n    margin-bottom: 20px;\n    \n    h1 {\n      font-size: 24px;\n      font-weight: 500;\n      color: $text-primary;\n      margin: 0;\n    }\n  }\n  \n  .material-list-container {\n    background-color: #fff;\n    border-radius: 4px;\n    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);\n    padding: 20px;\n    \n    .material-search {\n      display: flex;\n      justify-content: space-between;\n      align-items: center;\n      margin-bottom: 20px;\n      \n      .el-input {\n        width: 300px;\n      }\n      \n      .action-buttons {\n        display: flex;\n        gap: 10px;\n        \n        .is-loading {\n          animation: rotate 1s linear infinite;\n        }\n      }\n    }\n    \n    .material-list {\n      margin-top: 20px;\n    }\n    \n    .empty-data {\n      padding: 40px 0;\n    }\n  }\n  \n  .material-upload {\n    width: 100%;\n  }\n  \n  .preview-container {\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    flex-direction: column;\n    padding: 0 20px;\n    \n    .file-info {\n      text-align: center;\n      margin-top: 20px;\n    }\n  }\n}\n\n.upload-form {\n  padding: 0 20px;\n  \n  .form-tip {\n    font-size: 12px;\n    color: #909399;\n    margin-top: 5px;\n  }\n  \n  .upload-demo {\n    width: 100%;\n  }\n}\n\n.dialog-footer {\n  padding: 0 20px;\n  display: flex;\n  justify-content: flex-end;\n}\n\n.upload-file-list {\n  width: 100%;\n}\n\n.upload-file-item {\n  border: 1px solid #dcdfe6;\n  border-radius: 4px;\n  padding: 10px;\n  margin-bottom: 10px;\n}\n\n.upload-file-item .file-name {\n  font-size: 14px;\n  color: #606266;\n  margin-bottom: 5px;\n  display: block;\n}\n\n/* 覆盖Element Plus对话框样式 */\n:deep(.el-dialog__body) {\n  padding: 20px 0;\n}\n\n:deep(.el-dialog__header) {\n  padding-left: 20px;\n  padding-right: 20px;\n  margin-right: 0;\n}\n\n:deep(.el-dialog__footer) {\n  padding-top: 10px;\n  padding-bottom: 15px;\n}\n\n/* 修改上传进度条样式 */\n:deep(.el-progress__text) {\n  color: #303133 !important; /* 深灰色字体，确保在各种背景上都可见 */\n  font-size: 12px;\n}\n\n:deep(.el-progress--line) {\n  margin-bottom: 10px;\n}\n\n.upload-file-item {\n  border: 1px solid #dcdfe6;\n  border-radius: 6px; /* 增加圆角 */\n  padding: 12px; /* 增加内边距 */\n  margin-bottom: 12px; /* 增加外边距 */\n  background-color: #fafafa; /* 轻微背景色 */\n  transition: box-shadow 0.3s; /* 添加过渡效果 */\n}\n\n.upload-file-item:hover {\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); /* 悬停效果 */\n}\n\n.upload-file-item .file-name {\n  font-size: 14px;\n  color: #303133; /* 深灰色字体 */\n  margin-bottom: 8px; /* 增加底部间距 */\n  display: block;\n  font-weight: 500;\n}\n</style>\n"
  },
  {
    "path": "sau_frontend/src/views/PublishCenter.vue",
    "content": "<template>\n  <div class=\"publish-center\">\n    <!-- Tab管理区域 -->\n    <div class=\"tab-management\">\n      <div class=\"tab-header\">\n        <div class=\"tab-list\">\n          <div \n            v-for=\"tab in tabs\" \n            :key=\"tab.name\"\n            :class=\"['tab-item', { active: activeTab === tab.name }]\"\n            @click=\"activeTab = tab.name\"\n          >\n            <span>{{ tab.label }}</span>\n            <el-icon \n              v-if=\"tabs.length > 1\"\n              class=\"close-icon\" \n              @click.stop=\"removeTab(tab.name)\"\n            >\n              <Close />\n            </el-icon>\n          </div>\n        </div>\n        <div class=\"tab-actions\">\n          <el-button \n            type=\"primary\" \n            size=\"small\" \n            @click=\"addTab\"\n            class=\"add-tab-btn\"\n          >\n            <el-icon><Plus /></el-icon>\n            添加Tab\n          </el-button>\n          <el-button \n            type=\"success\" \n            size=\"small\" \n            @click=\"batchPublish\"\n            :loading=\"batchPublishing\"\n            class=\"batch-publish-btn\"\n          >\n            批量发布\n          </el-button>\n        </div>\n      </div>\n    </div>\n\n    <!-- 内容区域 -->\n    <div class=\"publish-content\">\n      <div class=\"tab-content-wrapper\">\n        <div \n          v-for=\"tab in tabs\" \n          :key=\"tab.name\"\n          v-show=\"activeTab === tab.name\"\n          class=\"tab-content\"\n        >\n          <!-- 发布状态提示 -->\n          <div v-if=\"tab.publishStatus\" class=\"publish-status\">\n            <el-alert\n              :title=\"tab.publishStatus.message\"\n              :type=\"tab.publishStatus.type\"\n              :closable=\"false\"\n              show-icon\n            />\n          </div>\n\n          <!-- 视频上传区域 -->\n          <div class=\"upload-section\">\n            <h3>视频</h3>\n            <div class=\"upload-options\">\n              <el-button type=\"primary\" @click=\"showUploadOptions(tab)\" class=\"upload-btn\">\n                <el-icon><Upload /></el-icon>\n                上传视频\n              </el-button>\n            </div>\n            \n            <!-- 已上传文件列表 -->\n            <div v-if=\"tab.fileList.length > 0\" class=\"uploaded-files\">\n              <h4>已上传文件：</h4>\n              <div class=\"file-list\">\n                <div v-for=\"(file, index) in tab.fileList\" :key=\"index\" class=\"file-item\">\n                  <el-link :href=\"file.url\" target=\"_blank\" type=\"primary\">{{ file.name }}</el-link>\n                  <span class=\"file-size\">{{ (file.size / 1024 / 1024).toFixed(2) }}MB</span>\n                  <el-button type=\"danger\" size=\"small\" @click=\"removeFile(tab, index)\">删除</el-button>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <!-- 上传选项弹窗 -->\n          <el-dialog\n            v-model=\"uploadOptionsVisible\"\n            title=\"选择上传方式\"\n            width=\"400px\"\n            class=\"upload-options-dialog\"\n          >\n            <div class=\"upload-options-content\">\n              <el-button type=\"primary\" @click=\"selectLocalUpload\" class=\"option-btn\">\n                <el-icon><Upload /></el-icon>\n                本地上传\n              </el-button>\n              <el-button type=\"success\" @click=\"selectMaterialLibrary\" class=\"option-btn\">\n                <el-icon><Folder /></el-icon>\n                素材库\n              </el-button>\n            </div>\n          </el-dialog>\n\n          <!-- 本地上传弹窗 -->\n          <el-dialog\n            v-model=\"localUploadVisible\"\n            title=\"本地上传\"\n            width=\"600px\"\n            class=\"local-upload-dialog\"\n          >\n            <el-upload\n              class=\"video-upload\"\n              drag\n              :auto-upload=\"true\"\n              :action=\"`${apiBaseUrl}/upload`\"\n              :on-success=\"(response, file) => handleUploadSuccess(response, file, currentUploadTab)\"\n              :on-error=\"handleUploadError\"\n              multiple\n              accept=\"video/*\"\n              :headers=\"authHeaders\"\n            >\n              <el-icon class=\"el-icon--upload\"><Upload /></el-icon>\n              <div class=\"el-upload__text\">\n                将视频文件拖到此处，或<em>点击上传</em>\n              </div>\n              <template #tip>\n                <div class=\"el-upload__tip\">\n                  支持MP4、AVI等视频格式，可上传多个文件\n                </div>\n              </template>\n            </el-upload>\n          </el-dialog>\n\n          <!-- 批量发布进度对话框 -->\n          <el-dialog\n            v-model=\"batchPublishDialogVisible\"\n            title=\"批量发布进度\"\n            width=\"500px\"\n            :close-on-click-modal=\"false\"\n            :close-on-press-escape=\"false\"\n            :show-close=\"false\"\n          >\n            <div class=\"publish-progress\">\n              <el-progress \n                :percentage=\"publishProgress\"\n                :status=\"publishProgress === 100 ? 'success' : ''\"\n              />\n              <div v-if=\"currentPublishingTab\" class=\"current-publishing\">\n                正在发布：{{ currentPublishingTab.label }}\n              </div>\n              \n              <!-- 发布结果列表 -->\n              <div class=\"publish-results\" v-if=\"publishResults.length > 0\">\n                <div \n                  v-for=\"(result, index) in publishResults\" \n                  :key=\"index\"\n                  :class=\"['result-item', result.status]\"\n                >\n                  <el-icon v-if=\"result.status === 'success'\"><Check /></el-icon>\n                  <el-icon v-else-if=\"result.status === 'error'\"><Close /></el-icon>\n                  <el-icon v-else><InfoFilled /></el-icon>\n                  <span class=\"label\">{{ result.label }}</span>\n                  <span class=\"message\">{{ result.message }}</span>\n                </div>\n              </div>\n            </div>\n            \n            <template #footer>\n              <div class=\"dialog-footer\">\n                <el-button \n                  @click=\"cancelBatchPublish\" \n                  :disabled=\"publishProgress === 100\"\n                >\n                  取消发布\n                </el-button>\n                <el-button \n                  type=\"primary\" \n                  @click=\"batchPublishDialogVisible = false\"\n                  v-if=\"publishProgress === 100\"\n                >\n                  关闭\n                </el-button>\n              </div>\n            </template>\n          </el-dialog>\n\n          <!-- 素材库选择弹窗 -->\n          <el-dialog\n            v-model=\"materialLibraryVisible\"\n            title=\"选择素材\"\n            width=\"800px\"\n            class=\"material-library-dialog\"\n          >\n            <div class=\"material-library-content\">\n              <el-checkbox-group v-model=\"selectedMaterials\">\n                <div class=\"material-list\">\n                  <div\n                    v-for=\"material in materials\"\n                    :key=\"material.id\"\n                    class=\"material-item\"\n                  >\n                    <el-checkbox :label=\"material.id\" class=\"material-checkbox\">\n                      <div class=\"material-info\">\n                        <div class=\"material-name\">{{ material.filename }}</div>\n                        <div class=\"material-details\">\n                          <span class=\"file-size\">{{ material.filesize }}MB</span>\n                          <span class=\"upload-time\">{{ material.upload_time }}</span>\n                        </div>\n                      </div>\n                    </el-checkbox>\n                  </div>\n                </div>\n              </el-checkbox-group>\n            </div>\n            <template #footer>\n              <div class=\"dialog-footer\">\n                <el-button @click=\"materialLibraryVisible = false\">取消</el-button>\n                <el-button type=\"primary\" @click=\"confirmMaterialSelection\">确定</el-button>\n              </div>\n            </template>\n          </el-dialog>\n\n          <!-- 账号选择 -->\n          <div class=\"account-section\">\n            <h3>账号</h3>\n            <div class=\"account-display\">\n              <div class=\"selected-accounts\">\n                <el-tag\n                  v-for=\"(account, index) in tab.selectedAccounts\"\n                  :key=\"index\"\n                  closable\n                  @close=\"removeAccount(tab, index)\"\n                  class=\"account-tag\"\n                >\n                  {{ getAccountDisplayName(account) }}\n                </el-tag>\n              </div>\n              <el-button \n                type=\"primary\" \n                plain \n                @click=\"openAccountDialog(tab)\"\n                class=\"select-account-btn\"\n              >\n                选择账号\n              </el-button>\n            </div>\n          </div>\n\n          <!-- 账号选择弹窗 -->\n          <el-dialog\n            v-model=\"accountDialogVisible\"\n            title=\"选择账号\"\n            width=\"600px\"\n            class=\"account-dialog\"\n          >\n            <div class=\"account-dialog-content\">\n              <el-checkbox-group v-model=\"tempSelectedAccounts\">\n                <div class=\"account-list\">\n                  <el-checkbox\n                    v-for=\"account in availableAccounts\"\n                    :key=\"account.id\"\n                    :label=\"account.id\"\n                    class=\"account-item\"\n                  >\n                    <div class=\"account-info\">\n                      <span class=\"account-name\">{{ account.name }}</span>                      \n                    </div>\n                  </el-checkbox>\n                </div>\n              </el-checkbox-group>\n            </div>\n\n            <template #footer>\n              <div class=\"dialog-footer\">\n                <el-button @click=\"accountDialogVisible = false\">取消</el-button>\n                <el-button type=\"primary\" @click=\"confirmAccountSelection\">确定</el-button>\n              </div>\n            </template>\n          </el-dialog>\n\n          <!-- 平台选择 -->\n          <div class=\"platform-section\">\n            <h3>平台</h3>\n            <el-radio-group v-model=\"tab.selectedPlatform\" class=\"platform-radios\">\n              <el-radio \n                v-for=\"platform in platforms\" \n                :key=\"platform.key\"\n                :label=\"platform.key\"\n                class=\"platform-radio\"\n              >\n                {{ platform.name }}\n              </el-radio>\n            </el-radio-group>\n          </div>\n\n          <!-- 原创声明 -->\n          <div class=\"original-section\">\n            <el-checkbox\n              v-model=\"tab.isOriginal\"\n              label=\"声明原创\"\n              class=\"original-checkbox\"\n            />\n          </div>\n\n          <!-- 草稿选项 (仅在视频号可见) -->\n          <div v-if=\"tab.selectedPlatform === 2\" class=\"draft-section\">\n            <el-checkbox\n              v-model=\"tab.isDraft\"\n              label=\"视频号仅保存草稿(用手机发布)\"\n              class=\"draft-checkbox\"\n            />\n          </div>\n\n          <!-- 标签 (仅在抖音可见) -->\n          <div v-if=\"tab.selectedPlatform === 3\" class=\"product-section\">\n            <h3>商品链接</h3>\n            <el-input\n              v-model=\"tab.productTitle\"\n              type=\"text\"\n              :rows=\"1\"\n              placeholder=\"请输入商品名称\"\n              maxlength=\"200\"\n              class=\"product-name-input\"\n            />\n            <el-input\n              v-model=\"tab.productLink\"\n              type=\"text\"\n              :rows=\"1\"\n              placeholder=\"请输入商品链接\"\n              maxlength=\"200\"\n              class=\"product-link-input\"\n            />\n          </div>\n\n          <!-- 标题输入 -->\n          <div class=\"title-section\">\n            <h3>标题</h3>\n            <el-input\n              v-model=\"tab.title\"\n              type=\"textarea\"\n              :rows=\"3\"\n              placeholder=\"请输入标题\"\n              maxlength=\"100\"\n              show-word-limit\n              class=\"title-input\"\n            />\n          </div>\n\n          <!-- 话题输入 -->\n          <div class=\"topic-section\">\n            <h3>话题</h3>\n            <div class=\"topic-display\">\n              <div class=\"selected-topics\">\n                <el-tag\n                  v-for=\"(topic, index) in tab.selectedTopics\"\n                  :key=\"index\"\n                  closable\n                  @close=\"removeTopic(tab, index)\"\n                  class=\"topic-tag\"\n                >\n                  #{{ topic }}\n                </el-tag>\n              </div>\n              <el-button \n                type=\"primary\" \n                plain \n                @click=\"openTopicDialog(tab)\"\n                class=\"select-topic-btn\"\n              >\n                添加话题\n              </el-button>\n            </div>\n          </div>\n\n          <!-- 添加话题弹窗 -->\n          <el-dialog\n            v-model=\"topicDialogVisible\"\n            title=\"添加话题\"\n            width=\"600px\"\n            class=\"topic-dialog\"\n          >\n            <div class=\"topic-dialog-content\">\n              <!-- 自定义话题输入 -->\n              <div class=\"custom-topic-input\">\n                <el-input\n                  v-model=\"customTopic\"\n                  placeholder=\"输入自定义话题\"\n                  class=\"custom-input\"\n                >\n                  <template #prepend>#</template>\n                </el-input>\n                <el-button type=\"primary\" @click=\"addCustomTopic\">添加</el-button>\n              </div>\n\n              <!-- 推荐话题 -->\n              <div class=\"recommended-topics\">\n                <h4>推荐话题</h4>\n                <div class=\"topic-grid\">\n                  <el-button\n                    v-for=\"topic in recommendedTopics\"\n                    :key=\"topic\"\n                    :type=\"currentTab?.selectedTopics?.includes(topic) ? 'primary' : 'default'\"\n                    @click=\"toggleRecommendedTopic(topic)\"\n                    class=\"topic-btn\"\n                  >\n                    {{ topic }}\n                  </el-button>\n                </div>\n              </div>\n            </div>\n\n            <template #footer>\n              <div class=\"dialog-footer\">\n                <el-button @click=\"topicDialogVisible = false\">取消</el-button>\n                <el-button type=\"primary\" @click=\"confirmTopicSelection\">确定</el-button>\n              </div>\n            </template>\n          </el-dialog>\n\n          <!-- 定时发布 -->\n          <div class=\"schedule-section\">\n            <h3>定时发布</h3>\n            <div class=\"schedule-controls\">\n              <el-switch\n                v-model=\"tab.scheduleEnabled\"\n                active-text=\"定时发布\"\n                inactive-text=\"立即发布\"\n              />\n              <div v-if=\"tab.scheduleEnabled\" class=\"schedule-settings\">\n                <div class=\"schedule-item\">\n                  <span class=\"label\">每天发布视频数：</span>\n                  <el-select v-model=\"tab.videosPerDay\" placeholder=\"选择发布数量\">\n                    <el-option\n                      v-for=\"num in 55\"\n                      :key=\"num\"\n                      :label=\"num\"\n                      :value=\"num\"\n                    />\n                  </el-select>\n                </div>\n                <div class=\"schedule-item\">\n                  <span class=\"label\">每天发布时间：</span>\n                  <el-time-select\n                    v-for=\"(time, index) in tab.dailyTimes\"\n                    :key=\"index\"\n                    v-model=\"tab.dailyTimes[index]\"\n                    start=\"00:00\"\n                    step=\"00:30\"\n                    end=\"23:30\"\n                    placeholder=\"选择时间\"\n                  />\n                  <el-button\n                    v-if=\"tab.dailyTimes.length < tab.videosPerDay\"\n                    type=\"primary\"\n                    size=\"small\"\n                    @click=\"tab.dailyTimes.push('10:00')\"\n                  >\n                    添加时间\n                  </el-button>\n                </div>\n                <div class=\"schedule-item\">\n                  <span class=\"label\">开始天数：</span>\n                  <el-select v-model=\"tab.startDays\" placeholder=\"选择开始天数\">\n                    <el-option :label=\"'明天'\" :value=\"0\" />\n                    <el-option :label=\"'后天'\" :value=\"1\" />\n                  </el-select>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <!-- 操作按钮 -->\n          <div class=\"action-buttons\">\n            <el-button size=\"small\" @click=\"cancelPublish(tab)\">取消</el-button>\n            <el-button\n              size=\"small\"\n              type=\"primary\"\n              @click=\"confirmPublish(tab)\"\n              :loading=\"tab.publishing || false\"\n            >\n              {{ tab.publishing ? '发布中...' : '发布' }}\n            </el-button>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, reactive, computed } from 'vue'\nimport { Upload, Plus, Close, Folder } from '@element-plus/icons-vue'\nimport { ElMessage } from 'element-plus'\nimport { useAccountStore } from '@/stores/account'\nimport { useAppStore } from '@/stores/app'\nimport { materialApi } from '@/api/material'\nimport { http } from '@/utils/request'\n\n// API base URL\nconst apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5409'\n\n// Authorization headers\nconst authHeaders = computed(() => ({\n  'Authorization': `Bearer ${localStorage.getItem('token') || ''}`\n}))\n\n// 当前激活的tab\nconst activeTab = ref('tab1')\n\n// tab计数器\nlet tabCounter = 1\n\n// 获取应用状态管理\nconst appStore = useAppStore()\n\n// 上传相关状态\nconst uploadOptionsVisible = ref(false)\nconst localUploadVisible = ref(false)\nconst materialLibraryVisible = ref(false)\nconst currentUploadTab = ref(null)\nconst selectedMaterials = ref([])\nconst materials = computed(() => appStore.materials)\n\n// 批量发布相关状态\nconst batchPublishing = ref(false)\nconst batchPublishMessage = ref('')\nconst batchPublishType = ref('info')\n\n// 平台列表 - 对应后端type字段\nconst platforms = [\n  { key: 3, name: '抖音' },\n  { key: 4, name: '快手' },\n  { key: 2, name: '视频号' },\n  { key: 1, name: '小红书' }\n]\n\nconst defaultTabInit = {\n  name: 'tab1',\n  label: '发布1',\n  fileList: [], // 后端返回的文件名列表\n  displayFileList: [], // 用于显示的文件列表\n  selectedAccounts: [], // 选中的账号ID列表\n  selectedPlatform: 1, // 选中的平台（单选）\n  title: '',\n  productLink: '', // 商品链接\n  productTitle: '', // 商品名称\n  selectedTopics: [], // 话题列表（不带#号）\n  scheduleEnabled: false, // 定时发布开关\n  videosPerDay: 1, // 每天发布视频数量\n  dailyTimes: ['10:00'], // 每天发布时间点列表\n  startDays: 0, // 从今天开始计算的发布天数，0表示明天，1表示后天\n  publishStatus: null, // 发布状态，包含message和type\n  publishing: false, // 发布状态，用于控制按钮loading效果\n  isDraft: false, // 是否保存为草稿，仅视频号平台可见\n  isOriginal: false // 是否标记为原创\n}\n\n// helper to create a fresh deep-copied tab from defaultTabInit\nconst makeNewTab = () => {\n  // prefer structuredClone when available (newer browsers/node), fallback to JSON\n  try {\n    return typeof structuredClone === 'function' ? structuredClone(defaultTabInit) : JSON.parse(JSON.stringify(defaultTabInit))\n  } catch (e) {\n    return JSON.parse(JSON.stringify(defaultTabInit))\n  }\n}\n\n// tab页数据 - 默认只有一个tab (use deep copy to avoid shared refs)\nconst tabs = reactive([\n  makeNewTab()\n])\n\n// 账号相关状态\nconst accountDialogVisible = ref(false)\nconst tempSelectedAccounts = ref([])\nconst currentTab = ref(null)\n\n// 获取账号状态管理\nconst accountStore = useAccountStore()\n\n// 根据选择的平台获取可用账号列表\nconst availableAccounts = computed(() => {\n  const platformMap = {\n    3: '抖音',\n    2: '视频号',\n    1: '小红书',\n    4: '快手'\n  }\n  const currentPlatform = currentTab.value ? platformMap[currentTab.value.selectedPlatform] : null\n  return currentPlatform ? accountStore.accounts.filter(acc => acc.platform === currentPlatform) : []\n})\n\n// 话题相关状态\nconst topicDialogVisible = ref(false)\nconst customTopic = ref('')\n\n// 推荐话题列表\nconst recommendedTopics = [\n  '游戏', '电影', '音乐', '美食', '旅行', '文化',\n  '科技', '生活', '娱乐', '体育', '教育', '艺术',\n  '健康', '时尚', '美妆', '摄影', '宠物', '汽车'\n]\n\n// 添加新tab\nconst addTab = () => {\n  tabCounter++\n  const newTab = makeNewTab()\n  newTab.name = `tab${tabCounter}`\n  newTab.label = `发布${tabCounter}`\n  tabs.push(newTab)\n  activeTab.value = newTab.name\n}\n\n// 删除tab\nconst removeTab = (tabName) => {\n  const index = tabs.findIndex(tab => tab.name === tabName)\n  if (index > -1) {\n    tabs.splice(index, 1)\n    // 如果删除的是当前激活的tab，切换到第一个tab\n    if (activeTab.value === tabName && tabs.length > 0) {\n      activeTab.value = tabs[0].name\n    }\n  }\n}\n\n// 处理文件上传成功\nconst handleUploadSuccess = (response, file, tab) => {\n  if (response.code === 200) {\n    // 获取文件路径\n    const filePath = response.data.path || response.data\n    // 从路径中提取文件名\n    const filename = filePath.split('/').pop()\n    \n    // 保存文件信息到fileList，包含文件路径和其他信息\n    const fileInfo = {\n      name: file.name,\n      url: materialApi.getMaterialPreviewUrl(filename), // 使用getMaterialPreviewUrl生成预览URL\n      path: filePath,\n      size: file.size,\n      type: file.type\n    }\n    \n    // 添加到文件列表\n    tab.fileList.push(fileInfo)\n    \n    // 更新显示列表\n    tab.displayFileList = [...tab.fileList.map(item => ({\n      name: item.name,\n      url: item.url\n    }))]\n    \n    ElMessage.success('文件上传成功')\n  } else {\n    ElMessage.error(response.msg || '上传失败')\n  }\n}\n\n// 处理文件上传失败\nconst handleUploadError = (error) => {\n  ElMessage.error('文件上传失败')\n}\n\n// 删除已上传文件\nconst removeFile = (tab, index) => {\n  // 从文件列表中删除\n  tab.fileList.splice(index, 1)\n  \n  // 更新显示列表\n  tab.displayFileList = [...tab.fileList.map(item => ({\n    name: item.name,\n    url: item.url\n  }))]\n  \n  ElMessage.success('文件删除成功')\n}\n\n// 话题相关方法\n// 打开添加话题弹窗\nconst openTopicDialog = (tab) => {\n  currentTab.value = tab\n  topicDialogVisible.value = true\n}\n\n// 添加自定义话题\nconst addCustomTopic = () => {\n  if (!customTopic.value.trim()) {\n    ElMessage.warning('请输入话题内容')\n    return\n  }\n  if (currentTab.value && !currentTab.value.selectedTopics.includes(customTopic.value.trim())) {\n    currentTab.value.selectedTopics.push(customTopic.value.trim())\n    customTopic.value = ''\n    ElMessage.success('话题添加成功')\n  } else {\n    ElMessage.warning('话题已存在')\n  }\n}\n\n// 切换推荐话题\nconst toggleRecommendedTopic = (topic) => {\n  if (!currentTab.value) return\n  \n  const index = currentTab.value.selectedTopics.indexOf(topic)\n  if (index > -1) {\n    currentTab.value.selectedTopics.splice(index, 1)\n  } else {\n    currentTab.value.selectedTopics.push(topic)\n  }\n}\n\n// 删除话题\nconst removeTopic = (tab, index) => {\n  tab.selectedTopics.splice(index, 1)\n}\n\n// 确认添加话题\nconst confirmTopicSelection = () => {\n  topicDialogVisible.value = false\n  customTopic.value = ''\n  currentTab.value = null\n  ElMessage.success('添加话题完成')\n}\n\n// 账号选择相关方法\n// 打开账号选择弹窗\nconst openAccountDialog = (tab) => {\n  currentTab.value = tab\n  tempSelectedAccounts.value = [...tab.selectedAccounts]\n  accountDialogVisible.value = true\n}\n\n// 确认账号选择\nconst confirmAccountSelection = () => {\n  if (currentTab.value) {\n    currentTab.value.selectedAccounts = [...tempSelectedAccounts.value]\n  }\n  accountDialogVisible.value = false\n  currentTab.value = null\n  ElMessage.success('账号选择完成')\n}\n\n// 删除选中的账号\nconst removeAccount = (tab, index) => {\n  tab.selectedAccounts.splice(index, 1)\n}\n\n// 获取账号显示名称\nconst getAccountDisplayName = (accountId) => {\n  const account = accountStore.accounts.find(acc => acc.id === accountId)\n  return account ? account.name : accountId\n}\n\n// 取消发布\nconst cancelPublish = (tab) => {\n  ElMessage.info('已取消发布')\n}\n\n// 确认发布\nconst confirmPublish = async (tab) => {\n  // 防止重复点击\n  if (tab.publishing) {\n    throw new Error('正在发布中，请稍候...')\n  }\n\n  tab.publishing = true // 设置发布状态为进行中\n\n  // 数据验证\n  if (tab.fileList.length === 0) {\n    ElMessage.error('请先上传视频文件')\n    tab.publishing = false\n    throw new Error('请先上传视频文件')\n  }\n  if (!tab.title.trim()) {\n    ElMessage.error('请输入标题')\n    tab.publishing = false\n    throw new Error('请输入标题')\n  }\n  if (!tab.selectedPlatform) {\n    ElMessage.error('请选择发布平台')\n    tab.publishing = false\n    throw new Error('请选择发布平台')\n  }\n  if (tab.selectedAccounts.length === 0) {\n    ElMessage.error('请选择发布账号')\n    tab.publishing = false\n    throw new Error('请选择发布账号')\n  }\n\n  // 构造发布数据，符合后端API格式\n  const publishData = {\n    type: tab.selectedPlatform,\n    title: tab.title,\n    tags: tab.selectedTopics, // 不带#号的话题列表\n    fileList: tab.fileList.map(file => file.path), // 只发送文件路径\n    accountList: tab.selectedAccounts.map(accountId => {\n      const account = accountStore.accounts.find(acc => acc.id === accountId)\n      return account ? account.filePath : accountId\n    }), // 发送账号的文件路径\n    enableTimer: tab.scheduleEnabled ? 1 : 0,\n    videosPerDay: tab.scheduleEnabled ? tab.videosPerDay || 1 : 1,\n    dailyTimes: tab.scheduleEnabled ? tab.dailyTimes || ['10:00'] : ['10:00'],\n    startDays: tab.scheduleEnabled ? tab.startDays || 0 : 0,\n    category: tab.isOriginal ? 1 : 0, // 1表示原创，0表示非原创\n    productLink: tab.productLink.trim() || '',\n    productTitle: tab.productTitle.trim() || '',\n    isDraft: tab.isDraft\n  }\n\n  // 调用后端发布API（使用统一的http封装）\n  try {\n    const data = await http.post('/postVideo', publishData)\n    tab.publishStatus = {\n      message: '发布成功',\n      type: 'success'\n    }\n    // 清空当前tab的数据\n    tab.fileList = []\n    tab.displayFileList = []\n    tab.title = ''\n    tab.selectedTopics = []\n    tab.selectedAccounts = []\n    tab.scheduleEnabled = false\n  } catch (error) {\n    console.error('发布错误:', error)\n    tab.publishStatus = {\n      message: `发布失败：${error.message || '请检查网络连接'}`,\n      type: 'error'\n    }\n    throw error\n  } finally {\n    tab.publishing = false\n  }\n}\n\n// 显示上传选项\nconst showUploadOptions = (tab) => {\n  currentUploadTab.value = tab\n  uploadOptionsVisible.value = true\n}\n\n// 选择本地上传\nconst selectLocalUpload = () => {\n  uploadOptionsVisible.value = false\n  localUploadVisible.value = true\n}\n\n// 选择素材库\nconst selectMaterialLibrary = async () => {\n  uploadOptionsVisible.value = false\n  \n  // 如果素材库为空，先获取素材数据\n  if (materials.value.length === 0) {\n    try {\n      const response = await materialApi.getAllMaterials()\n      if (response.code === 200) {\n        appStore.setMaterials(response.data)\n      } else {\n        ElMessage.error('获取素材列表失败')\n        return\n      }\n    } catch (error) {\n      console.error('获取素材列表出错:', error)\n      ElMessage.error('获取素材列表失败')\n      return\n    }\n  }\n  \n  selectedMaterials.value = []\n  materialLibraryVisible.value = true\n}\n\n// 确认素材选择\nconst confirmMaterialSelection = () => {\n  if (selectedMaterials.value.length === 0) {\n    ElMessage.warning('请选择至少一个素材')\n    return\n  }\n  \n  if (currentUploadTab.value) {\n    // 将选中的素材添加到当前tab的文件列表\n    selectedMaterials.value.forEach(materialId => {\n      const material = materials.value.find(m => m.id === materialId)\n      if (material) {\n        const fileInfo = {\n          name: material.filename,\n          url: materialApi.getMaterialPreviewUrl(material.file_path.split('/').pop()),\n          path: material.file_path,\n          size: material.filesize * 1024 * 1024, // 转换为字节\n          type: 'video/mp4'\n        }\n        \n        // 检查是否已存在相同文件\n        const exists = currentUploadTab.value.fileList.some(file => file.path === fileInfo.path)\n        if (!exists) {\n          currentUploadTab.value.fileList.push(fileInfo)\n        }\n      }\n    })\n    \n    // 更新显示列表\n    currentUploadTab.value.displayFileList = [...currentUploadTab.value.fileList.map(item => ({\n      name: item.name,\n      url: item.url\n    }))]\n  }\n  \n  const addedCount = selectedMaterials.value.length\n  materialLibraryVisible.value = false\n  selectedMaterials.value = []\n  currentUploadTab.value = null\n  ElMessage.success(`已添加 ${addedCount} 个素材`)\n}\n\n// 批量发布对话框状态\nconst batchPublishDialogVisible = ref(false)\nconst currentPublishingTab = ref(null)\nconst publishProgress = ref(0)\nconst publishResults = ref([])\nconst isCancelled = ref(false)\n\n// 取消批量发布\nconst cancelBatchPublish = () => {\n  isCancelled.value = true\n  ElMessage.info('正在取消发布...')\n}\n\n// 批量发布方法\nconst batchPublish = async () => {\n  if (batchPublishing.value) return\n  \n  batchPublishing.value = true\n  currentPublishingTab.value = null\n  publishProgress.value = 0\n  publishResults.value = []\n  isCancelled.value = false\n  batchPublishDialogVisible.value = true\n  \n  try {\n    for (let i = 0; i < tabs.length; i++) {\n      if (isCancelled.value) {\n        publishResults.value.push({\n          label: tabs[i].label,\n          status: 'cancelled',\n          message: '已取消'\n        })\n        continue\n      }\n\n      const tab = tabs[i]\n      currentPublishingTab.value = tab\n      publishProgress.value = Math.floor((i / tabs.length) * 100)\n      \n      try {\n        await confirmPublish(tab)\n        publishResults.value.push({\n          label: tab.label,\n          status: 'success',\n          message: '发布成功'\n        })\n      } catch (error) {\n        publishResults.value.push({\n          label: tab.label,\n          status: 'error',\n          message: error.message\n        })\n        // 不立即返回，继续显示发布结果\n      }\n    }\n    \n    publishProgress.value = 100\n    \n    // 统计发布结果\n    const successCount = publishResults.value.filter(r => r.status === 'success').length\n    const failCount = publishResults.value.filter(r => r.status === 'error').length\n    const cancelCount = publishResults.value.filter(r => r.status === 'cancelled').length\n    \n    if (isCancelled.value) {\n      ElMessage.warning(`发布已取消：${successCount}个成功，${failCount}个失败，${cancelCount}个未执行`)\n    } else if (failCount > 0) {\n      ElMessage.error(`发布完成：${successCount}个成功，${failCount}个失败`)\n    } else {\n      ElMessage.success('所有Tab发布成功')\n      setTimeout(() => {\n        batchPublishDialogVisible.value = false\n      }, 1000)\n    }\n    \n  } catch (error) {\n    console.error('批量发布出错:', error)\n    ElMessage.error('批量发布出错，请重试')\n  } finally {\n    batchPublishing.value = false\n    isCancelled.value = false\n  }\n}\n</script>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/variables.scss' as *;\n\n.publish-center {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n  \n  // Tab管理区域\n  .tab-management {\n    background-color: #fff;\n    border-radius: 4px;\n    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);\n    margin-bottom: 20px;\n    padding: 15px 20px;\n    \n    .tab-header {\n      display: flex;\n      align-items: flex-start;\n      gap: 15px;\n      \n      .tab-list {\n        display: flex;\n        flex-wrap: wrap;\n        gap: 10px;\n        flex: 1;\n        min-width: 0;\n        \n        .tab-item {\n           display: flex;\n           align-items: center;\n           gap: 6px;\n           padding: 6px 12px;\n           background-color: #f5f7fa;\n           border: 1px solid #dcdfe6;\n           border-radius: 4px;\n           cursor: pointer;\n           transition: all 0.3s;\n           font-size: 14px;\n           height: 32px;\n           \n           &:hover {\n             background-color: #ecf5ff;\n             border-color: #b3d8ff;\n           }\n           \n           &.active {\n             background-color: #409eff;\n             border-color: #409eff;\n             color: #fff;\n             \n             .close-icon {\n               color: #fff;\n               \n               &:hover {\n                 background-color: rgba(255, 255, 255, 0.2);\n               }\n             }\n           }\n           \n           .close-icon {\n             padding: 2px;\n             border-radius: 2px;\n             cursor: pointer;\n             transition: background-color 0.3s;\n             font-size: 12px;\n             \n             &:hover {\n               background-color: rgba(0, 0, 0, 0.1);\n             }\n           }\n         }\n       }\n       \n      .tab-actions {\n        display: flex;\n        gap: 10px;\n        flex-shrink: 0;\n        \n        .add-tab-btn,\n        .batch-publish-btn {\n          display: flex;\n          align-items: center;\n          gap: 4px;\n          height: 32px;\n          padding: 6px 12px;\n          font-size: 14px;\n          white-space: nowrap;\n        }\n      }\n    }\n  }\n  \n  // 批量发布进度对话框样式\n  .publish-progress {\n    padding: 20px;\n    \n    .current-publishing {\n      margin: 15px 0;\n      text-align: center;\n      color: #606266;\n    }\n\n    .publish-results {\n      margin-top: 20px;\n      border-top: 1px solid #EBEEF5;\n      padding-top: 15px;\n      max-height: 300px;\n      overflow-y: auto;\n\n      .result-item {\n        display: flex;\n        align-items: center;\n        padding: 8px 0;\n        color: #606266;\n\n        .el-icon {\n          margin-right: 8px;\n        }\n\n        .label {\n          margin-right: 10px;\n          font-weight: 500;\n        }\n\n        .message {\n          color: #909399;\n        }\n\n        &.success {\n          color: #67C23A;\n        }\n\n        &.error {\n          color: #F56C6C;\n        }\n\n        &.cancelled {\n          color: #909399;\n        }\n      }\n    }\n  }\n\n  .dialog-footer {\n    text-align: right;\n  }\n  \n  // 内容区域\n  .publish-content {\n    flex: 1;\n    background-color: #fff;\n    border-radius: 4px;\n    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);\n    padding: 20px;\n    \n    .tab-content-wrapper {\n      display: flex;\n      justify-content: center;\n      \n      .tab-content {\n        width: 100%;\n        max-width: 800px;\n        \n        h3 {\n          font-size: 16px;\n          font-weight: 500;\n          color: $text-primary;\n          margin: 0 0 10px 0;\n        }\n        \n        .upload-section,\n        .account-section,\n        .platform-section,\n        .title-section,\n        .product-section,\n        .topic-section,\n        .schedule-section {\n          margin-bottom: 30px;\n        }\n\n        .product-section {\n          .product-name-input,\n          .product-link-input {\n            margin-bottom: 5px;\n          }\n        }\n        \n        .video-upload {\n          width: 100%;\n          \n          :deep(.el-upload-dragger) {\n            width: 100%;\n            height: 180px;\n          }\n        }\n        \n        .account-input {\n          max-width: 400px;\n        }\n        \n        .platform-buttons {\n          display: flex;\n          gap: 10px;\n          flex-wrap: wrap;\n          \n          .platform-btn {\n            min-width: 80px;\n          }\n        }\n        \n        .title-input {\n          max-width: 600px;\n        }\n        \n        .topic-display {\n          display: flex;\n          flex-direction: column;\n          gap: 12px;\n          \n          .selected-topics {\n            display: flex;\n            flex-wrap: wrap;\n            gap: 8px;\n            min-height: 32px;\n            \n            .topic-tag {\n              font-size: 14px;\n            }\n          }\n          \n          .select-topic-btn {\n            align-self: flex-start;\n          }\n        }\n        \n        .schedule-controls {\n          display: flex;\n          flex-direction: column;\n          gap: 15px;\n\n          .schedule-settings {\n            margin-top: 15px;\n            padding: 15px;\n            background-color: #f5f7fa;\n            border-radius: 4px;\n\n            .schedule-item {\n              display: flex;\n              align-items: center;\n              margin-bottom: 15px;\n\n              &:last-child {\n                margin-bottom: 0;\n              }\n\n              .label {\n                min-width: 120px;\n                margin-right: 10px;\n              }\n\n              .el-time-select {\n                margin-right: 10px;\n              }\n\n              .el-button {\n                margin-left: 10px;\n              }\n            }\n          }\n        }\n        \n        .action-buttons {\n          display: flex;\n          justify-content: flex-end;\n          gap: 10px;\n          margin-top: 30px;\n          padding-top: 20px;\n          border-top: 1px solid #ebeef5;\n        }\n\n        .draft-section {\n          margin: 20px 0;\n\n          .draft-checkbox {\n            display: block;\n            margin: 10px 0;\n          }\n        }\n\n        .original-section {\n          margin: 10px 0 20px;\n\n          .original-checkbox {\n            display: block;\n            margin: 10px 0;\n          }\n        }\n      }\n    }\n  }\n\n  // 已上传文件列表样式\n  .uploaded-files {\n    margin-top: 20px;\n    \n    h4 {\n      font-size: 16px;\n      font-weight: 500;\n      margin-bottom: 12px;\n      color: #303133;\n    }\n    \n    .file-list {\n      display: flex;\n      flex-direction: column;\n      gap: 10px;\n      \n      .file-item {\n        display: flex;\n        align-items: center;\n        padding: 10px 15px;\n        background-color: #f5f7fa;\n        border-radius: 4px;\n        \n        .el-link {\n          margin-right: 10px;\n          max-width: 300px;\n          overflow: hidden;\n          text-overflow: ellipsis;\n          white-space: nowrap;\n        }\n        \n        .file-size {\n          color: #909399;\n          font-size: 13px;\n          margin-right: auto;\n        }\n      }\n    }\n  }\n  \n  // 添加话题弹窗样式\n  .topic-dialog {\n    .topic-dialog-content {\n      .custom-topic-input {\n        display: flex;\n        gap: 12px;\n        margin-bottom: 24px;\n        \n        .custom-input {\n          flex: 1;\n        }\n      }\n      \n      .recommended-topics {\n        h4 {\n          margin: 0 0 16px 0;\n          font-size: 16px;\n          font-weight: 500;\n          color: #303133;\n        }\n        \n        .topic-grid {\n          display: grid;\n          grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));\n          gap: 12px;\n          \n          .topic-btn {\n            height: 36px;\n            font-size: 14px;\n            border-radius: 6px;\n            min-width: 100px;\n            padding: 0 12px;\n            white-space: nowrap;\n            text-align: center;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            \n            &.el-button--primary {\n              background-color: #409eff;\n              border-color: #409eff;\n              color: white;\n            }\n          }\n        }\n      }\n    }\n    \n    .dialog-footer {\n      display: flex;\n      justify-content: flex-end;\n      gap: 12px;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "sau_frontend/vite.config.js",
    "content": "import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nimport { resolve } from 'path'\n\n// https://vite.dev/config/\nexport default defineConfig({\n  plugins: [vue()],\n  resolve: {\n    alias: {\n      '@': resolve(__dirname, 'src'),\n    },\n  },\n  css: {\n    preprocessorOptions: {\n      scss: {\n        // 移除自动导入，改用@use语法\n      }\n    }\n  },\n  server: {\n    port: 5173,\n    open: true,\n    proxy: {\n      '/api': {\n        target: 'http://localhost:5409',\n        changeOrigin: true,\n        rewrite: (path) => path.replace(/^\\/api/, '')\n      }\n    }\n  },\n  build: {\n    outDir: 'dist',\n    sourcemap: false,\n    chunkSizeWarningLimit: 1600,\n    rollupOptions: {\n      output: {\n        manualChunks: {\n          vue: ['vue', 'vue-router', 'pinia'],\n          elementPlus: ['element-plus'],\n          utils: ['axios']\n        }\n      }\n    }\n  }\n})\n"
  },
  {
    "path": "start-win.bat",
    "content": "@echo off\nTITLE One-Click Starter for social-auto-upload\n\nECHO ==================================================\nECHO  Starting social-auto-upload Servers...\nECHO ==================================================\nECHO.\n\nECHO [1/2] Starting Python Backend Server in a new window...\nREM The START command launches a new process.\nREM The first quoted string \"SAU Backend\" is the title of the new window.\nREM cmd /k runs the command and keeps the window open to show logs.\nSTART \"SAU Backend\" cmd /k \"python sau_backend.py\"\n\nECHO [2/2] Starting Vue.js Frontend Server in another new window...\nSTART \"SAU Frontend\" cmd /k \"cd sau_frontend && npm run dev -- --host 0.0.0.0\"\n\nECHO.\nECHO ==================================================\nECHO  Done.\nECHO  Two new windows have been opened for the backend\nECHO  and frontend servers. You can monitor logs there.\nECHO ==================================================\nECHO.\n\nECHO This window will close in 10 seconds...\ntimeout /t 10 /nobreak > nul\n"
  },
  {
    "path": "uploader/__init__.py",
    "content": "from pathlib import Path\n\nfrom conf import BASE_DIR\n\nPath(BASE_DIR / \"cookies\").mkdir(exist_ok=True)"
  },
  {
    "path": "uploader/baijiahao_uploader/__init__.py",
    "content": ""
  },
  {
    "path": "uploader/baijiahao_uploader/main.py",
    "content": "# -*- coding: utf-8 -*-\nimport random\nfrom datetime import datetime\n\nfrom playwright.async_api import Playwright, async_playwright, Page\nimport os\nimport time\nimport asyncio\n\nfrom conf import LOCAL_CHROME_PATH, LOCAL_CHROME_HEADLESS\nfrom utils.base_social_media import set_init_script\nfrom utils.log import baijiahao_logger\nfrom utils.network import async_retry\n\n\nasync def baijiahao_cookie_gen(account_file):\n    async with async_playwright() as playwright:\n        options = {\n            'args': [\n                '--lang en-GB'\n            ],\n            'headless': LOCAL_CHROME_HEADLESS,  # Set headless option here\n        }\n        # Make sure to run headed.\n        browser = await playwright.chromium.launch(**options)\n        # Setup context however you like.\n        context = await browser.new_context()  # Pass any options\n        context = await set_init_script(context)\n        # Pause the page, and start recording manually.\n        page = await context.new_page()\n        await page.goto(\"https://baijiahao.baidu.com/builder/theme/bjh/login\")\n        await page.pause()\n        # 点击调试器的继续，保存cookie\n        await context.storage_state(path=account_file)\n        baijiahao_logger.success(\"cookie saved\")\n\n\nasync def cookie_auth(account_file):\n    async with async_playwright() as playwright:\n        browser = await playwright.chromium.launch(headless=LOCAL_CHROME_HEADLESS)\n        context = await browser.new_context(storage_state=account_file)\n        context = await set_init_script(context)\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://baijiahao.baidu.com/builder/rc/home\")\n        await page.wait_for_timeout(timeout=5000)\n\n        if await page.get_by_text('注册/登录百家号').count():\n            baijiahao_logger.error(\"等待5秒 cookie 失效\")\n            return False\n        else:\n            baijiahao_logger.success(\"[+] cookie 有效\")\n            return True\n\n\nasync def baijiahao_setup(account_file, handle=False):\n    if not os.path.exists(account_file) or not await cookie_auth(account_file):\n        if not handle:\n            return False\n        baijiahao_logger.error(\"cookie文件不存在或已失效，即将自动打开浏览器，请扫码登录，登陆后会自动生成cookie文件\")\n        await baijiahao_cookie_gen(account_file)\n    return True\n\nclass BaiJiaHaoVideo(object):\n    def __init__(self, title, file_path, tags, publish_date: datetime, account_file, proxy_setting=None):\n        self.title = title  # 视频标题\n        self.file_path = file_path\n        self.tags = tags\n        self.publish_date = publish_date\n        self.account_file = account_file\n        self.date_format = '%Y年%m月%d日 %H:%M'\n        self.local_executable_path = LOCAL_CHROME_PATH\n        self.headless = LOCAL_CHROME_HEADLESS\n        self.proxy_setting = proxy_setting\n\n    async def set_schedule_time(self, page, publish_date):\n        \"\"\"\n        todo 时间选择，日后在处理 百家号的时间选择不准确，目前是随机\n        \"\"\"\n        publish_date_day = f\"{publish_date.month}月{publish_date.day}日\" if publish_date.day >9  else f\"{publish_date.month}月0{publish_date.day}日\"\n        publish_date_hour = f\"{publish_date.hour}点\"\n        publish_date_min = f\"{publish_date.minute}分\"\n        await page.wait_for_selector('div.select-wrap', timeout=5000)\n        for _ in range(3):\n            try:\n                await page.locator('div.select-wrap').nth(0).click()\n                await page.wait_for_selector('div.rc-virtual-list  div.cheetah-select-item', timeout=5000)\n                break\n            except:\n                await page.locator('div.select-wrap').nth(0).click()\n        # page.locator(f'div.rc-virtual-list-holder-inner >> text={publish_date_day}').click()\n        await page.wait_for_timeout(2000)\n        await page.locator(f'div.rc-virtual-list  div.cheetah-select-item >> text={publish_date_day}').click()\n        await page.wait_for_timeout(2000)\n\n        # 改为随机点击一个 hour\n        for _ in range(3):\n            try:\n                await page.locator('div.select-wrap').nth(1).click()\n                await page.wait_for_selector('div.rc-virtual-list div.rc-virtual-list-holder-inner:visible', timeout=5000)\n                break\n            except:\n                await page.locator('div.select-wrap').nth(1).click()\n        await page.wait_for_timeout(2000)\n        current_choice_hour = await page.locator('div.rc-virtual-list:visible div.cheetah-select-item-option').count()\n        await page.wait_for_timeout(2000)\n        await page.locator('div.rc-virtual-list:visible div.cheetah-select-item-option').nth(\n            random.randint(1, current_choice_hour-3)).click()\n        # 2024.08.05 current_choice_hour的获取可能有问题，页面有7，这里获取了10，暂时硬编码至6\n\n        await page.wait_for_timeout(2000)\n        await page.locator(\"button >> text=定时发布\").click()\n\n\n    async def handle_upload_error(self, page):\n        # 日后实现，目前没遇到\n        return\n        print(\"视频出错了，重新上传中\")\n\n    async def upload(self, playwright: Playwright) -> None:\n        # 使用 Chromium 浏览器启动一个浏览器实例\n        browser = await playwright.chromium.launch(headless=self.headless, executable_path=self.local_executable_path, proxy=self.proxy_setting)\n        # 创建一个浏览器上下文，使用指定的 cookie 文件\n        context = await browser.new_context(storage_state=f\"{self.account_file}\", user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.4324.150 Safari/537.36')\n        # context = await set_init_script(context)\n        await context.grant_permissions(['geolocation'])\n\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://baijiahao.baidu.com/builder/rc/edit?type=videoV2\", timeout=60000)\n        baijiahao_logger.info(f\"正在上传-------{self.title}.mp4\")\n        # 等待页面跳转到指定的 URL，没进入，则自动等待到超时\n        baijiahao_logger.info('正在打开主页...')\n        await page.wait_for_url(\"https://baijiahao.baidu.com/builder/rc/edit?type=videoV2\", timeout=60000)\n\n        # 点击 \"上传视频\" 按钮\n        await page.locator(\"div[class^='video-main-container'] input\").set_input_files(self.file_path)\n\n        # 等待页面跳转到指定的 URL\n        while True:\n            # 判断是是否进入视频发布页面，没进入，则自动等待到超时\n            try:\n                await page.wait_for_selector(\"div#formMain:visible\")\n                break\n            except:\n                baijiahao_logger.info(\"正在等待进入视频发布页面...\")\n                await asyncio.sleep(0.1)\n\n        # 填充标题和话题\n        # 这里为了避免页面变化，故使用相对位置定位：作品标题父级右侧第一个元素的input子元素\n        await asyncio.sleep(1)\n        baijiahao_logger.info(\"正在填充标题和话题...\")\n        await self.add_title_tags(page)\n\n        upload_status = await self.uploading_video(page)\n        if not upload_status:\n            baijiahao_logger.error(f\"发现上传出错了... 文件:{self.file_path}\")\n            raise\n\n        # 判断视频封面图是否生成成功\n        while True:\n            baijiahao_logger.info(\"正在确认封面完成, 准备去点击定时/发布...\")\n            if await page.locator(\"div.cheetah-spin-container img\").count():\n                baijiahao_logger.info(\"封面已完成，点击定时/发布...\")\n                break\n            else:\n                baijiahao_logger.info(\"等待封面生成...\")\n                await asyncio.sleep(3)\n\n        await self.publish_video(page, self.publish_date)\n        await page.wait_for_timeout(2000)\n        if await page.locator('div.passMod_dialog-container >> text=百度安全验证:visible').count():\n            baijiahao_logger.error(\"出现验证，退出\")\n            raise Exception(\"出现验证，退出\")\n        await page.wait_for_url(\"https://baijiahao.baidu.com/builder/rc/clue**\", timeout=5000)\n        baijiahao_logger.success(\"视频发布成功\")\n\n        await context.storage_state(path=self.account_file)  # 保存cookie\n        baijiahao_logger.info('cookie更新完毕！')\n        await asyncio.sleep(2)  # 这里延迟是为了方便眼睛直观的观看\n        # 关闭浏览器上下文和浏览器实例\n        await context.close()\n        await browser.close()\n\n\n    @async_retry(timeout=300)  # 例如，最多重试3次，超时时间为180秒\n    async def uploading_video(self, page):\n        while True:\n            upload_failed = await page.locator('div .cover-overlay:has-text(\"上传失败\")').count()\n            if upload_failed:\n                baijiahao_logger.error(\"发现上传出错了...\")\n                # await self.handle_upload_error(page)  # 假设这是处理上传错误的函数\n                return False\n\n            uploading = await page.locator('div .cover-overlay:has-text(\"上传中\")').count()\n            if uploading:\n                baijiahao_logger.info(\"正在上传视频中...\")\n                await asyncio.sleep(2)  # 等待2秒再次检查\n                continue\n\n            # 检查上传是否成功\n            if not uploading and not upload_failed:\n                baijiahao_logger.success(\"视频上传完毕\")\n                return True\n\n    async def set_schedule_publish(self, page, publish_date):\n        while True:\n            schedule_element = page.locator(\"div.op-btn-outter-content >> text=定时发布\").locator(\"..\").locator(\n                'button')\n            try:\n                await schedule_element.click()\n                await page.wait_for_selector('div.select-wrap:visible', timeout=3000)\n                await page.wait_for_timeout(timeout=2000)\n                baijiahao_logger.info(\"开始点击发布定时...\")\n                await self.set_schedule_time(page, publish_date)\n                break\n            except Exception as e:\n                baijiahao_logger.error(f\"定时发布失败: {e}\")\n                raise  # 重新抛出异常，让重试装饰器捕获\n\n    @async_retry(timeout=300)  # 例如，最多重试3次，超时时间为180秒\n    async def publish_video(self, page: Page, publish_date):\n        if publish_date != 0:\n            # 定时发布\n            await self.set_schedule_publish(page, publish_date)\n        else:\n            # 立即发布\n            await self.direct_publish(page)\n\n    async def direct_publish(self, page):\n        try:\n            publish_button = page.locator(\"button >> text=发布\")\n            if await publish_button.count():\n                await publish_button.click()\n        except Exception as e:\n            baijiahao_logger.error(f\"直接发布视频失败: {e}\")\n            raise  # 重新抛出异常，让重试装饰器捕获\n\n    async def add_title_tags(self, page):\n        title_container = page.get_by_placeholder('添加标题获得更多推荐')\n        if len(self.title) <= 8:\n            self.title += \" 你不知道的\"\n        await title_container.fill(self.title[:30])\n\n    async def main(self):\n        async with async_playwright() as playwright:\n            await self.upload(playwright)\n\n\n\n    # 使用 AI成片 功能\n    async def ai2video(self, playwright: Playwright) -> None:\n        # 使用 Chromium 浏览器启动一个浏览器实例\n        browser = await playwright.chromium.launch(headless=self.headless, executable_path=self.local_executable_path, proxy=self.proxy_setting)\n        # 创建一个浏览器上下文，使用指定的 cookie 文件\n        context = await browser.new_context(\n            viewport={\"width\": 1600, \"height\": 900},\n            storage_state=f\"{self.account_file}\",\n            user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.4324.150 Safari/537.36'\n        )\n        # context = await set_init_script(context)\n        await context.grant_permissions(['geolocation'])\n\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://aigc.baidu.com/make\", timeout=60000)\n        # 等待页面跳转到指定的 URL，没进入，则自动等待到超时\n        baijiahao_logger.info('正在打开主页...')\n        await page.wait_for_url(\"https://aigc.baidu.com/make\", timeout=60000)\n\n        # 点击\"全网\"标签\n        await page.locator('div.rounded-lg.border:has-text(\"全网\")').click()\n        await asyncio.sleep(1)  # 这里延迟是为了方便眼睛直观的观看\n\n        # 点击 \"上传视频\" 按钮\n        # await page.locator(\"div[class^='video-main-container'] input\").set_input_files(self.file_path)\n\n        # region 操作处\n\n        # 生成日期时间键名（格式：ai2video_YYYYMMDDHHMM）\n        now = datetime.now()\n        datetime_str = now.strftime(\"%Y%m%d%H%M\")\n        processed_key = \"ai2video_processed_titles\"\n        batch_key = f\"ai2video_{datetime_str}\"\n\n        # 初始化LocalStorage\n        await page.evaluate(f\"\"\"\n                   if (!localStorage.getItem(\"{processed_key}\")) {{\n                       localStorage.setItem(\"{processed_key}\", JSON.stringify([]));                   \n                   }}\n                   if (!localStorage.getItem(\"{batch_key}\")) {{\n                       localStorage.setItem(\"{batch_key}\", JSON.stringify([]));                   \n                   }}\n               \"\"\")\n\n        # 定位新闻列表容器（转义特殊CSS字符）\n        container_selector = '.overflow-auto.flex-grow.h-0.saas-scrollbar.mt\\-\\[-4px\\].pl\\-\\[24px\\].pr\\-\\[10px\\].pb\\-\\[18px\\]'\n        news_items = await page.locator(container_selector).locator('div.py\\-\\[6px\\].group.cursor-pointer').all()\n\n        for item in news_items:\n            try:\n                # 获取新闻标题\n                title_elem = item.locator('div.flex.text-gray-darker.items-center.relative.pr\\-\\[56px\\] > span')\n                title = await title_elem.text_content()\n                if not title:\n                    continue\n\n                # 检查是否已处理过\n                is_processed = await page.evaluate(\n                    f\"\"\"title => {{\n                               const processedList = JSON.parse(localStorage.getItem(\"{processed_key}\") || \"[]\");\n                               return processedList.includes(title);\n                           }}\"\"\",\n                    title\n                )\n\n                if is_processed:\n                    print(f\"[跳过] {title}\")\n                    continue\n\n                # 悬停显示按钮（根据HTML结构，按钮在悬停时显示）\n                await item.hover()\n\n                # 点击生成文案按钮\n                button = item.locator('button:has-text(\"生成文案\")')\n                await button.click()\n                print(f\"[点击] {title}\")\n\n                # 等待30秒\n                # await page.wait_for_timeout(30000)\n                print(f\"[等待完成] {title}\")\n                \n                # 监听\"一键成片\"按钮\n                print(f\"[开始监听] 一键成片按钮\")\n                should_exit_while_loop = False  # 添加标志变量\n                while True:\n                    # 定位\"一键成片\"按钮\n                    one_key_button = page.locator(\"button:has-text('一键成片')\")\n                    \n                    # 检查按钮是否存在\n                    if await one_key_button.count() > 0:\n                        # 检查按钮是否有disabled属性\n                        is_disabled = await one_key_button.get_attribute(\"disabled\")\n                        \n                        if is_disabled is None:\n                            # 按钮不再被禁用，点击它\n                            print(f\"[发现可点击按钮] 一键成片\")\n                            await one_key_button.click()  # 先点击一键成片按钮\n                            \n                            # 等待可能出现的\"温馨提示\"窗口\n                            print(f\"[检查] 是否出现温馨提示窗口\")\n                            await page.wait_for_timeout(2000)  # 等待2秒，让窗口有时间显示\n                            \n                            try:\n                                # 检查是否存在\"温馨提示\"窗口，设置较短的超时时间\n                                tip_window = page.locator(\"div:has-text('温馨提示') >> visible=true\")\n                                if await tip_window.count() > 0:\n                                    print(f\"[发现] 温馨提示窗口\")\n                                    \n                                    # 定位并点击\"知道了\"按钮，设置较短的超时时间\n                                    know_button = page.locator(\"button:has-text('知道了')\")\n                                    if await know_button.count() > 0:\n                                        try:\n                                            # 设置较短的超时时间进行点击\n                                            await know_button.click(timeout=5000)\n                                            print(f\"[已点击] 知道了按钮\")\n                                        except Exception as e:\n                                            print(f\"[警告] 点击知道了按钮时出错: {str(e)}\")\n                                    else:\n                                        print(f\"[警告] 未找到知道了按钮\")\n                                else:\n                                    print(f\"[信息] 未出现温馨提示窗口，继续执行\")\n                            except Exception as e:\n                                print(f\"[警告] 处理温馨提示窗口时出错: {str(e)}\")\n                                # 继续执行，不要因为这个错误中断流程\n                                \n                            # 记录到LocalStorage前打印日志\n                            print(f\"[开始记录] 准备将标题 '{title}' 记录到LocalStorage\")\n                            \n                            # 记录到LocalStorage\n                            await page.evaluate(\n                                f\"\"\"\n                                        (title, processedKey, batchKey) => {{\n                                            // 更新已处理列表\n                                            const processedList = JSON.parse(localStorage.getItem(processedKey) || \"[]\");\n                                            if (!processedList.includes(title)) {{\n                                                processedList.push(title);\n                                                localStorage.setItem(processedKey, JSON.stringify(processedList));\n                                            }}\n\n                                            // 更新当前批次记录\n                                            const batchList = JSON.parse(localStorage.getItem(batchKey) || \"[]\");\n                                            if (!batchList.includes(title)) {{\n                                                batchList.push(title);\n                                                localStorage.setItem(batchKey, JSON.stringify(batchList));\n                                            }}\n                                        }}\n                                        \"\"\",\n                                title, processed_key, batch_key\n                            )\n                            \n                            # 记录完成后打印日志\n                            print(f\"[记录完成] 标题 '{title}' 已成功记录到LocalStorage\")\n\n                            print(f\"[记录完成] {title}\")\n                            \n                            # 监听新打开的标签页\n                            print(f\"[监听] 等待新标签页打开\")\n                            # 获取当前所有页面\n                            current_pages = context.pages\n                            current_page_count = len(current_pages)\n                            \n                            # 等待新标签页打开（最多等待10秒）\n                            new_page = None\n                            max_wait_time = 10  # 最大等待时间（秒）\n                            start_time = time.time()\n                            \n                            while time.time() - start_time < max_wait_time:\n                                # 获取最新的页面列表\n                                pages = context.pages\n                                # 如果页面数量增加，说明新标签页已打开\n                                if len(pages) > current_page_count:\n                                    # 获取最新打开的页面（通常是列表中的最后一个）\n                                    new_page = pages[-1]\n                                    print(f\"[发现] 新标签页已打开\")\n                                    break\n                                # 短暂等待后再次检查\n                                await asyncio.sleep(0.5)\n                            \n                            # 如果找到新标签页，获取其标题和URL并保存\n                            if new_page:\n                                # 等待页面加载完成\n                                try:\n                                    await new_page.wait_for_load_state(\"domcontentloaded\", timeout=5000)\n                                    # 获取页面标题和URL\n                                    page_title = await new_page.title()\n                                    page_url = new_page.url\n                                    \n                                    print(f\"[获取] 标题: {page_title}\")\n                                    print(f\"[获取] URL: {page_url}\")\n                                    \n                                    # 将标题和URL保存到url.txt文件\n                                    with open(\"url.txt\", \"a\", encoding=\"utf-8\") as f:\n                                        f.write(f\"{page_title}\\n{page_url}\\n\\n\")\n                                    \n                                    print(f\"[保存] 标题和URL已保存到url.txt\")\n                                    \n                                    # 等待5秒后关闭新标签页\n                                    print(f\"[等待] 5秒后将关闭新标签页\")\n                                    await asyncio.sleep(5)\n                                    await new_page.close()\n                                    print(f\"[关闭] 新标签页已关闭\")\n                                except Exception as e:\n                                    print(f\"[错误] 处理新标签页时出错: {str(e)}\")\n                                    try:\n                                        # 尝试关闭页面，即使出错\n                                        await new_page.close()\n                                        print(f\"[关闭] 新标签页已关闭（出错后）\")\n                                    except:\n                                        pass\n                            else:\n                                print(f\"[警告] 未检测到新标签页打开\")\n                            \n                            # 跳出整个while循环\n                            print(f\"[操作] 跳出所有循环，不再处理其他新闻\")\n                            should_exit_while_loop = True  # 设置标志变量\n                            break  # 跳出while循环\n                    \n                    # 检查是否需要跳出while循环\n                    if should_exit_while_loop:\n                        break\n                        \n                    # 每秒检查一次按钮状态\n                    await page.wait_for_timeout(1000)\n                \n                # 检查是否需要跳出for循环\n                if should_exit_while_loop:\n                    print(f\"[操作] 跳出for循环，完全结束处理\")\n                    break  # 跳出for循环\n            except Exception as e:\n                print(f\"处理新闻时出错: {str(e)}\")\n                continue\n\n\n        # endregion 操作处\n\n        print(f\"[循环完成] 准备关闭浏览器\")\n\n        # 暂停 1000s\n        await asyncio.sleep(1000)  # 这里延迟是为了方便眼睛直观的观看\n\n        # 退出前保存 storage 信息\n        await context.storage_state(path=self.account_file)  # 保存cookie\n        baijiahao_logger.info('cookie更新完毕！')\n        await asyncio.sleep(2)  # 这里延迟是为了方便眼睛直观的观看\n        # 关闭浏览器上下文和浏览器实例\n        await context.close()\n        await browser.close()\n\n\n    async def mainAi(self):\n        async with async_playwright() as playwright:\n            await self.ai2video(playwright)\n"
  },
  {
    "path": "uploader/bilibili_uploader/__init__.py",
    "content": "from pathlib import Path\n\nfrom conf import BASE_DIR\n\nPath(BASE_DIR / \"cookies\" / \"bilibili_uploader\").mkdir(exist_ok=True)"
  },
  {
    "path": "uploader/bilibili_uploader/main.py",
    "content": "import json\nimport pathlib\nimport random\nfrom biliup.plugins.bili_webup import BiliBili, Data\n\nfrom utils.log import bilibili_logger\n\n\ndef extract_keys_from_json(data):\n    \"\"\"Extract specified keys from the provided JSON data.\"\"\"\n    keys_to_extract = [\"SESSDATA\", \"bili_jct\", \"DedeUserID__ckMd5\", \"DedeUserID\", \"access_token\"]\n    extracted_data = {}\n\n    # Extracting cookie data\n    for cookie in data['cookie_info']['cookies']:\n        if cookie['name'] in keys_to_extract:\n            extracted_data[cookie['name']] = cookie['value']\n\n    # Extracting access_token\n    if \"access_token\" in data['token_info']:\n        extracted_data['access_token'] = data['token_info']['access_token']\n\n    return extracted_data\n\n\ndef read_cookie_json_file(filepath: pathlib.Path):\n    with open(filepath, 'r', encoding='utf-8') as file:\n        content = json.load(file)\n        return content\n\n\ndef random_emoji():\n    emoji_list = [\"🍏\", \"🍎\", \"🍊\", \"🍋\", \"🍌\", \"🍉\", \"🍇\", \"🍓\", \"🍈\", \"🍒\", \"🍑\", \"🍍\", \"🥭\", \"🥥\", \"🥝\",\n                  \"🍅\", \"🍆\", \"🥑\", \"🥦\", \"🥒\", \"🥬\", \"🌶\", \"🌽\", \"🥕\", \"🥔\", \"🍠\", \"🥐\", \"🍞\", \"🥖\", \"🥨\", \"🥯\", \"🧀\", \"🥚\", \"🍳\", \"🥞\",\n                  \"🥓\", \"🥩\", \"🍗\", \"🍖\", \"🌭\", \"🍔\", \"🍟\", \"🍕\", \"🥪\", \"🥙\", \"🌮\", \"🌯\", \"🥗\", \"🥘\", \"🥫\", \"🍝\", \"🍜\", \"🍲\", \"🍛\", \"🍣\",\n                  \"🍱\", \"🥟\", \"🍤\", \"🍙\", \"🍚\", \"🍘\", \"🍥\", \"🥮\", \"🥠\", \"🍢\", \"🍡\", \"🍧\", \"🍨\", \"🍦\", \"🥧\", \"🍰\", \"🎂\", \"🍮\", \"🍭\", \"🍬\",\n                  \"🍫\", \"🍿\", \"🧂\", \"🍩\", \"🍪\", \"🌰\", \"🥜\", \"🍯\", \"🥛\", \"🍼\", \"☕️\", \"🍵\", \"🥤\", \"🍶\", \"🍻\", \"🥂\", \"🍷\", \"🥃\", \"🍸\", \"🍹\",\n                  \"🍾\", \"🥄\", \"🍴\", \"🍽\", \"🥣\", \"🥡\", \"🥢\"]\n    return random.choice(emoji_list)\n\n\nclass BilibiliUploader(object):\n    def __init__(self, cookie_data, file: pathlib.Path, title, desc, tid, tags, dtime):\n        self.upload_thread_num = 3\n        self.copyright = 1\n        self.lines = 'AUTO'\n        self.cookie_data = cookie_data\n        self.file = file\n        self.title = title\n        self.desc = desc\n        self.tid = tid\n        self.tags = tags\n        self.dtime = dtime\n        self._init_data()\n\n    def _init_data(self):\n        self.data = Data()\n        self.data.copyright = self.copyright\n        self.data.title = self.title\n        self.data.desc = self.desc\n        self.data.tid = self.tid\n        self.data.set_tag(self.tags)\n        self.data.dtime = self.dtime\n\n    def upload(self):\n        with BiliBili(self.data) as bili:\n            bili.login_by_cookies(self.cookie_data)\n            bili.access_token = self.cookie_data.get('access_token')\n            video_part = bili.upload_file(str(self.file), lines=self.lines,\n                                          tasks=self.upload_thread_num)  # 上传视频，默认线路AUTO自动选择，线程数量3。\n            video_part['title'] = self.title\n            self.data.append(video_part)\n            ret = bili.submit()  # 提交视频\n            if ret.get('code') == 0:\n                bilibili_logger.success(f'[+] {self.file.name}上传 成功')\n                return True\n            else:\n                bilibili_logger.error(f'[-] {self.file.name}上传 失败, error messge: {ret.get(\"message\")}')\n                return False\n"
  },
  {
    "path": "uploader/douyin_uploader/__init__.py",
    "content": "from pathlib import Path\n\nfrom conf import BASE_DIR\n\nPath(BASE_DIR / \"cookies\" / \"douyin_uploader\").mkdir(exist_ok=True)"
  },
  {
    "path": "uploader/douyin_uploader/main.py",
    "content": "# -*- coding: utf-8 -*-\nfrom datetime import datetime\n\nfrom playwright.async_api import Playwright, async_playwright, Page\nimport os\nimport asyncio\n\nfrom conf import LOCAL_CHROME_PATH, LOCAL_CHROME_HEADLESS\nfrom utils.base_social_media import set_init_script\nfrom utils.log import douyin_logger\n\n\nasync def cookie_auth(account_file):\n    async with async_playwright() as playwright:\n        browser = await playwright.chromium.launch(headless=LOCAL_CHROME_HEADLESS)\n        context = await browser.new_context(storage_state=account_file)\n        context = await set_init_script(context)\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://creator.douyin.com/creator-micro/content/upload\")\n        try:\n            await page.wait_for_url(\"https://creator.douyin.com/creator-micro/content/upload\", timeout=5000)\n        except:\n            print(\"[+] 等待5秒 cookie 失效\")\n            await context.close()\n            await browser.close()\n            return False\n        # 2024.06.17 抖音创作者中心改版\n        if await page.get_by_text('手机号登录').count() or await page.get_by_text('扫码登录').count():\n            print(\"[+] 等待5秒 cookie 失效\")\n            return False\n        else:\n            print(\"[+] cookie 有效\")\n            return True\n\n\nasync def douyin_setup(account_file, handle=False):\n    if not os.path.exists(account_file) or not await cookie_auth(account_file):\n        if not handle:\n            # Todo alert message\n            return False\n        douyin_logger.info('[+] cookie文件不存在或已失效，即将自动打开浏览器，请扫码登录，登陆后会自动生成cookie文件')\n        await douyin_cookie_gen(account_file)\n    return True\n\n\nasync def douyin_cookie_gen(account_file):\n    async with async_playwright() as playwright:\n        options = {\n            'headless': LOCAL_CHROME_HEADLESS\n        }\n        # Make sure to run headed.\n        browser = await playwright.chromium.launch(**options)\n        # Setup context however you like.\n        context = await browser.new_context()  # Pass any options\n        context = await set_init_script(context)\n        # Pause the page, and start recording manually.\n        page = await context.new_page()\n        await page.goto(\"https://creator.douyin.com/\")\n        await page.pause()\n        # 点击调试器的继续，保存cookie\n        await context.storage_state(path=account_file)\n\n\nclass DouYinVideo(object):\n    def __init__(self, title, file_path, tags, publish_date: datetime, account_file, thumbnail_path=None, productLink='', productTitle=''):\n        self.title = title  # 视频标题\n        self.file_path = file_path\n        self.tags = tags\n        self.publish_date = publish_date\n        self.account_file = account_file\n        self.date_format = '%Y年%m月%d日 %H:%M'\n        self.local_executable_path = LOCAL_CHROME_PATH\n        self.headless = LOCAL_CHROME_HEADLESS\n        self.thumbnail_path = thumbnail_path\n        self.productLink = productLink\n        self.productTitle = productTitle\n\n    async def set_schedule_time_douyin(self, page, publish_date):\n        # 选择包含特定文本内容的 label 元素\n        label_element = page.locator(\"[class^='radio']:has-text('定时发布')\")\n        # 在选中的 label 元素下点击 checkbox\n        await label_element.click()\n        await asyncio.sleep(1)\n        publish_date_hour = publish_date.strftime(\"%Y-%m-%d %H:%M\")\n\n        await asyncio.sleep(1)\n        await page.locator('.semi-input[placeholder=\"日期和时间\"]').click()\n        await page.keyboard.press(\"Control+KeyA\")\n        await page.keyboard.type(str(publish_date_hour))\n        await page.keyboard.press(\"Enter\")\n\n        await asyncio.sleep(1)\n\n    async def handle_upload_error(self, page):\n        douyin_logger.info('视频出错了，重新上传中')\n        await page.locator('div.progress-div [class^=\"upload-btn-input\"]').set_input_files(self.file_path)\n\n    async def upload(self, playwright: Playwright) -> None:\n        # 使用 Chromium 浏览器启动一个浏览器实例\n        if self.local_executable_path:\n            browser = await playwright.chromium.launch(headless=self.headless, executable_path=self.local_executable_path)\n        else:\n            browser = await playwright.chromium.launch(headless=self.headless)\n        # 创建一个浏览器上下文，使用指定的 cookie 文件\n        context = await browser.new_context(storage_state=f\"{self.account_file}\")\n        context = await set_init_script(context)\n\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://creator.douyin.com/creator-micro/content/upload\")\n        douyin_logger.info(f'[+]正在上传-------{self.title}.mp4')\n        # 等待页面跳转到指定的 URL，没进入，则自动等待到超时\n        douyin_logger.info(f'[-] 正在打开主页...')\n        await page.wait_for_url(\"https://creator.douyin.com/creator-micro/content/upload\")\n        # 点击 \"上传视频\" 按钮\n        await page.locator(\"div[class^='container'] input\").set_input_files(self.file_path)\n\n        # 等待页面跳转到指定的 URL 2025.01.08修改在原有基础上兼容两种页面\n        while True:\n            try:\n                # 尝试等待第一个 URL\n                await page.wait_for_url(\n                    \"https://creator.douyin.com/creator-micro/content/publish?enter_from=publish_page\", timeout=3000)\n                douyin_logger.info(\"[+] 成功进入version_1发布页面!\")\n                break  # 成功进入页面后跳出循环\n            except Exception:\n                try:\n                    # 如果第一个 URL 超时，再尝试等待第二个 URL\n                    await page.wait_for_url(\n                        \"https://creator.douyin.com/creator-micro/content/post/video?enter_from=publish_page\",\n                        timeout=3000)\n                    douyin_logger.info(\"[+] 成功进入version_2发布页面!\")\n\n                    break  # 成功进入页面后跳出循环\n                except:\n                    print(\"  [-] 超时未进入视频发布页面，重新尝试...\")\n                    await asyncio.sleep(0.5)  # 等待 0.5 秒后重新尝试\n        # 填充标题和话题\n        # 检查是否存在包含输入框的元素\n        # 这里为了避免页面变化，故使用相对位置定位：作品标题父级右侧第一个元素的input子元素\n        await asyncio.sleep(1)\n        douyin_logger.info(f'  [-] 正在填充标题和话题...')\n        title_container = page.get_by_text('作品标题').locator(\"..\").locator(\"xpath=following-sibling::div[1]\").locator(\"input\")\n        if await title_container.count():\n            await title_container.fill(self.title[:30])\n        else:\n            titlecontainer = page.locator(\".notranslate\")\n            await titlecontainer.click()\n            await page.keyboard.press(\"Backspace\")\n            await page.keyboard.press(\"Control+KeyA\")\n            await page.keyboard.press(\"Delete\")\n            await page.keyboard.type(self.title)\n            await page.keyboard.press(\"Enter\")\n        css_selector = \".zone-container\"\n        for index, tag in enumerate(self.tags, start=1):\n            await page.type(css_selector, \"#\" + tag)\n            await page.press(css_selector, \"Space\")\n        douyin_logger.info(f'总共添加{len(self.tags)}个话题')\n        while True:\n            # 判断重新上传按钮是否存在，如果不存在，代表视频正在上传，则等待\n            try:\n                #  新版：定位重新上传\n                number = await page.locator('[class^=\"long-card\"] div:has-text(\"重新上传\")').count()\n                if number > 0:\n                    douyin_logger.success(\"  [-]视频上传完毕\")\n                    break\n                else:\n                    douyin_logger.info(\"  [-] 正在上传视频中...\")\n                    await asyncio.sleep(2)\n\n                    if await page.locator('div.progress-div > div:has-text(\"上传失败\")').count():\n                        douyin_logger.error(\"  [-] 发现上传出错了... 准备重试\")\n                        await self.handle_upload_error(page)\n            except:\n                douyin_logger.info(\"  [-] 正在上传视频中...\")\n                await asyncio.sleep(2)\n\n        if self.productLink and self.productTitle:\n            douyin_logger.info(f'  [-] 正在设置商品链接...')\n            await self.set_product_link(page, self.productLink, self.productTitle)\n            douyin_logger.info(f'  [+] 完成设置商品链接...')\n        \n        #上传视频封面\n        await self.set_thumbnail(page, self.thumbnail_path)\n\n        # 更换可见元素\n        await self.set_location(page, \"\")\n\n\n        # 頭條/西瓜\n        third_part_element = '[class^=\"info\"] > [class^=\"first-part\"] div div.semi-switch'\n        # 定位是否有第三方平台\n        if await page.locator(third_part_element).count():\n            # 检测是否是已选中状态\n            if 'semi-switch-checked' not in await page.eval_on_selector(third_part_element, 'div => div.className'):\n                await page.locator(third_part_element).locator('input.semi-switch-native-control').click()\n\n        if self.publish_date != 0:\n            await self.set_schedule_time_douyin(page, self.publish_date)\n\n        # 判断视频是否发布成功\n        while True:\n            # 判断视频是否发布成功\n            try:\n                publish_button = page.get_by_role('button', name=\"发布\", exact=True)\n                if await publish_button.count():\n                    await publish_button.click()\n                await page.wait_for_url(\"https://creator.douyin.com/creator-micro/content/manage**\",\n                                        timeout=3000)  # 如果自动跳转到作品页面，则代表发布成功\n                douyin_logger.success(\"  [-]视频发布成功\")\n                break\n            except:\n                # 尝试处理封面问题\n                await self.handle_auto_video_cover(page)\n                douyin_logger.info(\"  [-] 视频正在发布中...\")\n                await page.screenshot(full_page=True)\n                await asyncio.sleep(0.5)\n\n        await context.storage_state(path=self.account_file)  # 保存cookie\n        douyin_logger.success('  [-]cookie更新完毕！')\n        await asyncio.sleep(2)  # 这里延迟是为了方便眼睛直观的观看\n        # 关闭浏览器上下文和浏览器实例\n        await context.close()\n        await browser.close()\n\n    async def handle_auto_video_cover(self, page):\n        \"\"\"\n        处理必须设置封面的情况，点击推荐封面的第一个\n        \"\"\"\n        # 1. 判断是否出现 \"请设置封面后再发布\" 的提示\n        # 必须确保提示是可见的 (is_visible)，因为 DOM 中可能存在隐藏的历史提示\n        if await page.get_by_text(\"请设置封面后再发布\").first.is_visible():\n            print(\"  [-] 检测到需要设置封面提示...\")\n\n            # 2. 定位“智能推荐封面”区域下的第一个封面\n            # 使用 class^= 前缀匹配，避免 hash 变化导致失效\n            recommend_cover = page.locator('[class^=\"recommendCover-\"]').first\n\n            if await recommend_cover.count():\n                print(\"  [-] 正在选择第一个推荐封面...\")\n                try:\n                    await recommend_cover.click()\n                    await asyncio.sleep(1)  # 等待选中生效\n\n                    # 3. 处理可能的确认弹窗 \"是否确认应用此封面？\"\n                    # 并不一定每次都会出现，健壮性判断：如果出现弹窗，则点击确定\n                    confirm_text = \"是否确认应用此封面？\"\n                    if await page.get_by_text(confirm_text).first.is_visible():\n                        print(f\"  [-] 检测到确认弹窗: {confirm_text}\")\n                        # 直接点击“确定”按钮，不依赖脆弱的 CSS 类名\n                        await page.get_by_role(\"button\", name=\"确定\").click()\n                        print(\"  [-] 已点击确认应用封面\")\n                        await asyncio.sleep(1)\n\n                    print(\"  [-] 已完成封面选择流程\")\n                    return True\n                except Exception as e:\n                    print(f\"  [-] 选择封面失败: {e}\")\n\n        return False\n\n    async def set_thumbnail(self, page: Page, thumbnail_path: str):\n        if thumbnail_path:\n            douyin_logger.info('  [-] 正在设置视频封面...')\n            await page.click('text=\"选择封面\"')\n            await page.wait_for_selector(\"div.dy-creator-content-modal\")\n            await page.click('text=\"设置竖封面\"')\n            await page.wait_for_timeout(2000)  # 等待2秒\n            # 定位到上传区域并点击\n            await page.locator(\"div[class^='semi-upload upload'] >> input.semi-upload-hidden-input\").set_input_files(thumbnail_path)\n            await page.wait_for_timeout(2000)  # 等待2秒\n            await page.locator(\"div#tooltip-container button:visible:has-text('完成')\").click()\n            # finish_confirm_element = page.locator(\"div[class^='confirmBtn'] >> div:has-text('完成')\")\n            # if await finish_confirm_element.count():\n            #     await finish_confirm_element.click()\n            # await page.locator(\"div[class^='footer'] button:has-text('完成')\").click()\n            douyin_logger.info('  [+] 视频封面设置完成！')\n            # 等待封面设置对话框关闭\n            await page.wait_for_selector(\"div.extractFooter\", state='detached')\n            \n\n    async def set_location(self, page: Page, location: str = \"\"):\n        if not location:\n            return\n        # todo supoort location later\n        # await page.get_by_text('添加标签').locator(\"..\").locator(\"..\").locator(\"xpath=following-sibling::div\").locator(\n        #     \"div.semi-select-single\").nth(0).click()\n        await page.locator('div.semi-select span:has-text(\"输入地理位置\")').click()\n        await page.keyboard.press(\"Backspace\")\n        await page.wait_for_timeout(2000)\n        await page.keyboard.type(location)\n        await page.wait_for_selector('div[role=\"listbox\"] [role=\"option\"]', timeout=5000)\n        await page.locator('div[role=\"listbox\"] [role=\"option\"]').first.click()\n\n    async def handle_product_dialog(self, page: Page, product_title: str):\n        \"\"\"处理商品编辑弹窗\"\"\"\n\n        await page.wait_for_timeout(2000)\n        await page.wait_for_selector('input[placeholder=\"请输入商品短标题\"]', timeout=10000)\n        short_title_input = page.locator('input[placeholder=\"请输入商品短标题\"]')\n        if not await short_title_input.count():\n            douyin_logger.error(\"[-] 未找到商品短标题输入框\")\n            return False\n        product_title = product_title[:10]\n        await short_title_input.fill(product_title)\n        # 等待一下让界面响应\n        await page.wait_for_timeout(1000)\n\n        finish_button = page.locator('button:has-text(\"完成编辑\")')\n        if 'disabled' not in await finish_button.get_attribute('class'):\n            await finish_button.click()\n            douyin_logger.debug(\"[+] 成功点击'完成编辑'按钮\")\n            \n            # 等待对话框关闭\n            await page.wait_for_selector('.semi-modal-content', state='hidden', timeout=5000)\n            return True\n        else:\n            douyin_logger.error(\"[-] '完成编辑'按钮处于禁用状态，尝试直接关闭对话框\")\n            # 如果按钮禁用，尝试点击取消或关闭按钮\n            cancel_button = page.locator('button:has-text(\"取消\")')\n            if await cancel_button.count():\n                await cancel_button.click()\n            else:\n                # 点击右上角的关闭按钮\n                close_button = page.locator('.semi-modal-close')\n                await close_button.click()\n            \n            await page.wait_for_selector('.semi-modal-content', state='hidden', timeout=5000)\n            return False\n        \n    async def set_product_link(self, page: Page, product_link: str, product_title: str):\n        \"\"\"设置商品链接功能\"\"\"\n        await page.wait_for_timeout(2000)  # 等待2秒\n        try:\n            # 定位\"添加标签\"文本，然后向上导航到容器，再找到下拉框\n            await page.wait_for_selector('text=添加标签', timeout=10000)\n            dropdown = page.get_by_text('添加标签').locator(\"..\").locator(\"..\").locator(\"..\").locator(\".semi-select\").first\n            if not await dropdown.count():\n                douyin_logger.error(\"[-] 未找到标签下拉框\")\n                return False\n            douyin_logger.debug(\"[-] 找到标签下拉框，准备选择'购物车'\")\n            await dropdown.click()\n            ## 等待下拉选项出现\n            await page.wait_for_selector('[role=\"listbox\"]', timeout=5000)\n            ## 选择\"购物车\"选项\n            await page.locator('[role=\"option\"]:has-text(\"购物车\")').click()\n            douyin_logger.debug(\"[+] 成功选择'购物车'\")\n            \n            # 输入商品链接\n            ## 等待商品链接输入框出现\n            await page.wait_for_selector('input[placeholder=\"粘贴商品链接\"]', timeout=5000)\n            # 输入\n            input_field = page.locator('input[placeholder=\"粘贴商品链接\"]')\n            await input_field.fill(product_link)\n            douyin_logger.debug(f\"[+] 已输入商品链接: {product_link}\")\n            \n            # 点击\"添加链接\"按钮\n            add_button = page.locator('span:has-text(\"添加链接\")')\n            ## 检查按钮是否可用（没有disable类）\n            button_class = await add_button.get_attribute('class')\n            if 'disable' in button_class:\n                douyin_logger.error(\"[-] '添加链接'按钮不可用\")\n                return False\n            await add_button.click()\n            douyin_logger.debug(\"[+] 成功点击'添加链接'按钮\")\n            ## 如果链接不可用\n            await page.wait_for_timeout(2000)\n            error_modal = page.locator('text=未搜索到对应商品')\n            if await error_modal.count():\n                confirm_button = page.locator('button:has-text(\"确定\")')\n                await confirm_button.click()\n                # await page.wait_for_selector('.semi-modal-content', state='hidden', timeout=5000)\n                douyin_logger.error(\"[-] 商品链接无效\")\n                return False\n\n            # 填写商品短标题\n            if not await self.handle_product_dialog(page, product_title):\n                return False\n            \n            # 等待链接添加完成\n            douyin_logger.debug(\"[+] 成功设置商品链接\")\n            return True\n        except Exception as e:\n            douyin_logger.error(f\"[-] 设置商品链接时出错: {str(e)}\")\n            return False\n\n    async def main(self):\n        async with async_playwright() as playwright:\n            await self.upload(playwright)\n\n\n"
  },
  {
    "path": "uploader/ks_uploader/__init__.py",
    "content": "from pathlib import Path\n\nfrom conf import BASE_DIR\n\nPath(BASE_DIR / \"cookies\" / \"ks_uploader\").mkdir(exist_ok=True)"
  },
  {
    "path": "uploader/ks_uploader/main.py",
    "content": "# -*- coding: utf-8 -*-\nfrom datetime import datetime\n\nfrom playwright.async_api import Playwright, async_playwright\nimport os\nimport asyncio\n\nfrom conf import LOCAL_CHROME_PATH, LOCAL_CHROME_HEADLESS\nfrom utils.base_social_media import set_init_script\nfrom utils.files_times import get_absolute_path\nfrom utils.log import kuaishou_logger\n\n\nasync def cookie_auth(account_file):\n    async with async_playwright() as playwright:\n        browser = await playwright.chromium.launch(headless=LOCAL_CHROME_HEADLESS)\n        context = await browser.new_context(storage_state=account_file)\n        context = await set_init_script(context)\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://cp.kuaishou.com/article/publish/video\")\n        try:\n            await page.wait_for_selector(\"div.names div.container div.name:text('机构服务')\", timeout=5000)  # 等待5秒\n\n            kuaishou_logger.info(\"[+] 等待5秒 cookie 失效\")\n            return False\n        except:\n            kuaishou_logger.success(\"[+] cookie 有效\")\n            return True\n\n\nasync def ks_setup(account_file, handle=False):\n    account_file = get_absolute_path(account_file, \"ks_uploader\")\n    if not os.path.exists(account_file) or not await cookie_auth(account_file):\n        if not handle:\n            return False\n        kuaishou_logger.info('[+] cookie文件不存在或已失效，即将自动打开浏览器，请扫码登录，登陆后会自动生成cookie文件')\n        await get_ks_cookie(account_file)\n    return True\n\n\nasync def get_ks_cookie(account_file):\n    async with async_playwright() as playwright:\n        options = {\n            'args': [\n                '--lang en-GB'\n            ],\n            'headless': LOCAL_CHROME_HEADLESS,  # Set headless option here\n        }\n        # Make sure to run headed.\n        browser = await playwright.chromium.launch(**options)\n        # Setup context however you like.\n        context = await browser.new_context()  # Pass any options\n        context = await set_init_script(context)\n        # Pause the page, and start recording manually.\n        page = await context.new_page()\n        await page.goto(\"https://cp.kuaishou.com\")\n        await page.pause()\n        # 点击调试器的继续，保存cookie\n        await context.storage_state(path=account_file)\n\n\nclass KSVideo(object):\n    def __init__(self, title, file_path, tags, publish_date: datetime, account_file):\n        self.title = title  # 视频标题\n        self.file_path = file_path\n        self.tags = tags\n        self.publish_date = publish_date\n        self.account_file = account_file\n        self.date_format = '%Y-%m-%d %H:%M'\n        self.local_executable_path = LOCAL_CHROME_PATH\n        self.headless = LOCAL_CHROME_HEADLESS\n\n    async def handle_upload_error(self, page):\n        kuaishou_logger.error(\"视频出错了，重新上传中\")\n        await page.locator('div.progress-div [class^=\"upload-btn-input\"]').set_input_files(self.file_path)\n\n    async def upload(self, playwright: Playwright) -> None:\n        # 使用 Chromium 浏览器启动一个浏览器实例\n        print(self.local_executable_path)\n        if self.local_executable_path:\n            browser = await playwright.chromium.launch(\n                headless=self.headless,\n                executable_path=self.local_executable_path,\n            )\n        else:\n            browser = await playwright.chromium.launch(\n                headless=self.headless\n            )  # 创建一个浏览器上下文，使用指定的 cookie 文件\n        context = await browser.new_context(storage_state=f\"{self.account_file}\")\n        context = await set_init_script(context)\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://cp.kuaishou.com/article/publish/video\")\n        kuaishou_logger.info('正在上传-------{}.mp4'.format(self.title))\n        # 等待页面跳转到指定的 URL，没进入，则自动等待到超时\n        kuaishou_logger.info('正在打开主页...')\n        await page.wait_for_url(\"https://cp.kuaishou.com/article/publish/video\")\n        # 点击 \"上传视频\" 按钮\n        upload_button = page.locator(\"button[class^='_upload-btn']\")\n        await upload_button.wait_for(state='visible')  # 确保按钮可见\n\n        async with page.expect_file_chooser() as fc_info:\n            await upload_button.click()\n        file_chooser = await fc_info.value\n        await file_chooser.set_files(self.file_path)\n\n        await asyncio.sleep(2)\n\n        # if not await page.get_by_text(\"封面编辑\").count():\n        #     raise Exception(\"似乎没有跳转到到编辑页面\")\n\n        await asyncio.sleep(1)\n\n        # 等待按钮可交互\n        new_feature_button = page.locator('button[type=\"button\"] span:text(\"我知道了\")')\n        if await new_feature_button.count() > 0:\n            await new_feature_button.click()\n\n        kuaishou_logger.info(\"正在填充标题和话题...\")\n        await page.get_by_text(\"描述\").locator(\"xpath=following-sibling::div\").click()\n        kuaishou_logger.info(\"clear existing title\")\n        await page.keyboard.press(\"Backspace\")\n        await page.keyboard.press(\"Control+KeyA\")\n        await page.keyboard.press(\"Delete\")\n        kuaishou_logger.info(\"filling new  title\")\n        await page.keyboard.type(self.title)\n        await page.keyboard.press(\"Enter\")\n\n        # 快手只能添加3个话题\n        for index, tag in enumerate(self.tags[:3], start=1):\n            kuaishou_logger.info(\"正在添加第%s个话题\" % index)\n            await page.keyboard.type(f\"#{tag} \")\n            await asyncio.sleep(2)\n\n        max_retries = 60  # 设置最大重试次数,最大等待时间为 2 分钟\n        retry_count = 0\n\n        while retry_count < max_retries:\n            try:\n                # 获取包含 '上传中' 文本的元素数量\n                number = await page.locator(\"text=上传中\").count()\n\n                if number == 0:\n                    kuaishou_logger.success(\"视频上传完毕\")\n                    break\n                else:\n                    if retry_count % 5 == 0:\n                        kuaishou_logger.info(\"正在上传视频中...\")\n                    await asyncio.sleep(2)\n            except Exception as e:\n                kuaishou_logger.error(f\"检查上传状态时发生错误: {e}\")\n                await asyncio.sleep(2)  # 等待 2 秒后重试\n            retry_count += 1\n\n        if retry_count == max_retries:\n            kuaishou_logger.warning(\"超过最大重试次数，视频上传可能未完成。\")\n\n        # 定时任务\n        if self.publish_date != 0:\n            await self.set_schedule_time(page, self.publish_date)\n\n        # 判断视频是否发布成功\n        while True:\n            try:\n                publish_button = page.get_by_text(\"发布\", exact=True)\n                if await publish_button.count() > 0:\n                    await publish_button.click()\n\n                await asyncio.sleep(1)\n                confirm_button = page.get_by_text(\"确认发布\")\n                if await confirm_button.count() > 0:\n                    await confirm_button.click()\n\n                # 等待页面跳转，确认发布成功\n                await page.wait_for_url(\n                    \"https://cp.kuaishou.com/article/manage/video?status=2&from=publish\",\n                    timeout=5000,\n                )\n                kuaishou_logger.success(\"视频发布成功\")\n                break\n            except Exception as e:\n                kuaishou_logger.info(f\"视频正在发布中... 错误: {e}\")\n                await page.screenshot(full_page=True)\n                await asyncio.sleep(1)\n\n        await context.storage_state(path=self.account_file)  # 保存cookie\n        kuaishou_logger.info('cookie更新完毕！')\n        await asyncio.sleep(2)  # 这里延迟是为了方便眼睛直观的观看\n        # 关闭浏览器上下文和浏览器实例\n        await context.close()\n        await browser.close()\n\n    async def main(self):\n        async with async_playwright() as playwright:\n            await self.upload(playwright)\n\n    async def set_schedule_time(self, page, publish_date):\n        kuaishou_logger.info(\"click schedule\")\n        publish_date_hour = publish_date.strftime(\"%Y-%m-%d %H:%M:%S\")\n        await page.locator(\"label:text('发布时间')\").locator('xpath=following-sibling::div').locator(\n            '.ant-radio-input').nth(1).click()\n        await asyncio.sleep(1)\n\n        await page.locator('div.ant-picker-input input[placeholder=\"选择日期时间\"]').click()\n        await asyncio.sleep(1)\n\n        await page.keyboard.press(\"Control+KeyA\")\n        await page.keyboard.type(str(publish_date_hour))\n        await page.keyboard.press(\"Enter\")\n        await asyncio.sleep(1)\n"
  },
  {
    "path": "uploader/tencent_uploader/__init__.py",
    "content": "from pathlib import Path\n\nfrom conf import BASE_DIR\n\nPath(BASE_DIR / \"cookies\" / \"tencent_uploader\").mkdir(exist_ok=True)"
  },
  {
    "path": "uploader/tencent_uploader/main.py",
    "content": "# -*- coding: utf-8 -*-\nfrom datetime import datetime\n\nfrom playwright.async_api import Playwright, async_playwright\nimport os\nimport asyncio\n\nfrom conf import LOCAL_CHROME_PATH, LOCAL_CHROME_HEADLESS\nfrom utils.base_social_media import set_init_script\nfrom utils.files_times import get_absolute_path\nfrom utils.log import tencent_logger\n\n\ndef format_str_for_short_title(origin_title: str) -> str:\n    # 定义允许的特殊字符\n    allowed_special_chars = \"《》“”:+?%°\"\n\n    # 移除不允许的特殊字符\n    filtered_chars = [char if char.isalnum() or char in allowed_special_chars else ' ' if char == ',' else '' for\n                      char in origin_title]\n    formatted_string = ''.join(filtered_chars)\n\n    # 调整字符串长度\n    if len(formatted_string) > 16:\n        # 截断字符串\n        formatted_string = formatted_string[:16]\n    elif len(formatted_string) < 6:\n        # 使用空格来填充字符串\n        formatted_string += ' ' * (6 - len(formatted_string))\n\n    return formatted_string\n\n\nasync def cookie_auth(account_file):\n    async with async_playwright() as playwright:\n        browser = await playwright.chromium.launch(headless=LOCAL_CHROME_HEADLESS)\n        context = await browser.new_context(storage_state=account_file)\n        context = await set_init_script(context)\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://channels.weixin.qq.com/platform/post/create\")\n        try:\n            await page.wait_for_selector('div.title-name:has-text(\"微信小店\")', timeout=5000)  # 等待5秒\n            tencent_logger.error(\"[+] 等待5秒 cookie 失效\")\n            return False\n        except:\n            tencent_logger.success(\"[+] cookie 有效\")\n            return True\n\n\nasync def get_tencent_cookie(account_file):\n    async with async_playwright() as playwright:\n        options = {\n            'args': [\n                '--lang en-GB'\n            ],\n            'headless': LOCAL_CHROME_HEADLESS,  # Set headless option here\n        }\n        # Make sure to run headed.\n        browser = await playwright.chromium.launch(**options)\n        # Setup context however you like.\n        context = await browser.new_context()  # Pass any options\n        # Pause the page, and start recording manually.\n        context = await set_init_script(context)\n        page = await context.new_page()\n        await page.goto(\"https://channels.weixin.qq.com\")\n        await page.pause()\n        # 点击调试器的继续，保存cookie\n        await context.storage_state(path=account_file)\n\n\nasync def weixin_setup(account_file, handle=False):\n    account_file = get_absolute_path(account_file, \"tencent_uploader\")\n    if not os.path.exists(account_file) or not await cookie_auth(account_file):\n        if not handle:\n            # Todo alert message\n            return False\n        tencent_logger.info('[+] cookie文件不存在或已失效，即将自动打开浏览器，请扫码登录，登陆后会自动生成cookie文件')\n        await get_tencent_cookie(account_file)\n    return True\n\n\nclass TencentVideo(object):\n    def __init__(self, title, file_path, tags, publish_date: datetime, account_file, category=None, is_draft=False):\n        self.title = title  # 视频标题\n        self.file_path = file_path\n        self.tags = tags\n        self.publish_date = publish_date\n        self.account_file = account_file\n        self.category = category\n        self.headless = LOCAL_CHROME_HEADLESS\n        self.is_draft = is_draft  # 是否保存为草稿\n        self.local_executable_path = LOCAL_CHROME_PATH or None\n\n    async def set_schedule_time_tencent(self, page, publish_date):\n        label_element = page.locator(\"label\").filter(has_text=\"定时\").nth(1)\n        await label_element.click()\n\n        await page.click('input[placeholder=\"请选择发表时间\"]')\n\n        str_month = str(publish_date.month) if publish_date.month > 9 else \"0\" + str(publish_date.month)\n        current_month = str_month + \"月\"\n        # 获取当前的月份\n        page_month = await page.inner_text('span.weui-desktop-picker__panel__label:has-text(\"月\")')\n\n        # 检查当前月份是否与目标月份相同\n        if page_month != current_month:\n            await page.click('button.weui-desktop-btn__icon__right')\n\n        # 获取页面元素\n        elements = await page.query_selector_all('table.weui-desktop-picker__table a')\n\n        # 遍历元素并点击匹配的元素\n        for element in elements:\n            if 'weui-desktop-picker__disabled' in await element.evaluate('el => el.className'):\n                continue\n            text = await element.inner_text()\n            if text.strip() == str(publish_date.day):\n                await element.click()\n                break\n\n        # 输入小时部分（假设选择11小时）\n        await page.click('input[placeholder=\"请选择时间\"]')\n        await page.keyboard.press(\"Control+KeyA\")\n        await page.keyboard.type(str(publish_date.hour))\n\n        # 选择标题栏（令定时时间生效）\n        await page.locator(\"div.input-editor\").click()\n\n    async def handle_upload_error(self, page):\n        tencent_logger.info(\"视频出错了，重新上传中\")\n        await page.locator('div.media-status-content div.tag-inner:has-text(\"删除\")').click()\n        await page.get_by_role('button', name=\"删除\", exact=True).click()\n        file_input = page.locator('input[type=\"file\"]')\n        await file_input.set_input_files(self.file_path)\n\n    async def upload(self, playwright: Playwright) -> None:\n        # 使用 Chromium (这里使用系统内浏览器，用chromium 会造成h264错误\n        browser = await playwright.chromium.launch(headless=self.headless, executable_path=self.local_executable_path)\n        # 创建一个浏览器上下文，使用指定的 cookie 文件\n        context = await browser.new_context(storage_state=f\"{self.account_file}\")\n        context = await set_init_script(context)\n\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://channels.weixin.qq.com/platform/post/create\")\n        tencent_logger.info(f'[+]正在上传-------{self.title}.mp4')\n        # 等待页面跳转到指定的 URL，没进入，则自动等待到超时\n        await page.wait_for_url(\"https://channels.weixin.qq.com/platform/post/create\")\n        # await page.wait_for_selector('input[type=\"file\"]', timeout=10000)\n        file_input = page.locator('input[type=\"file\"]')\n        await file_input.set_input_files(self.file_path)\n        # 填充标题和话题\n        await self.add_title_tags(page)\n        # 添加商品\n        # await self.add_product(page)\n        # 合集功能\n        await self.add_collection(page)\n        # 原创选择\n        await self.add_original(page)\n        # 检测上传状态\n        await self.detect_upload_status(page)\n        if self.publish_date != 0:\n            await self.set_schedule_time_tencent(page, self.publish_date)\n        # 添加短标题\n        await self.add_short_title(page)\n\n        await self.click_publish(page)\n\n        await context.storage_state(path=f\"{self.account_file}\")  # 保存cookie\n        tencent_logger.success('  [-]cookie更新完毕！')\n        await asyncio.sleep(2)  # 这里延迟是为了方便眼睛直观的观看\n        # 关闭浏览器上下文和浏览器实例\n        await context.close()\n        await browser.close()\n\n    async def add_short_title(self, page):\n        short_title_element = page.get_by_text(\"短标题\", exact=True).locator(\"..\").locator(\n            \"xpath=following-sibling::div\").locator(\n            'span input[type=\"text\"]')\n        if await short_title_element.count():\n            short_title = format_str_for_short_title(self.title)\n            await short_title_element.fill(short_title)\n\n    async def click_publish(self, page):\n        while True:\n            try:\n                if self.is_draft:\n                    # 点击\"保存草稿\"按钮\n                    draft_button = page.locator('div.form-btns button:has-text(\"保存草稿\")')\n                    if await draft_button.count():\n                        await draft_button.click()\n                    # 等待跳转到草稿箱页面或确认保存成功\n                    await page.wait_for_url(\"**/post/list**\", timeout=5000)  # 使用通配符匹配包含post/list的URL\n                    tencent_logger.success(\"  [-]视频草稿保存成功\")\n                else:\n                    # 点击\"发表\"按钮\n                    publish_button = page.locator('div.form-btns button:has-text(\"发表\")')\n                    if await publish_button.count():\n                        await publish_button.click()\n                    await page.wait_for_url(\"https://channels.weixin.qq.com/platform/post/list\", timeout=5000)\n                    tencent_logger.success(\"  [-]视频发布成功\")\n                break\n            except Exception as e:\n                current_url = page.url\n                if self.is_draft:\n                    # 检查是否在草稿相关的页面\n                    if \"post/list\" in current_url or \"draft\" in current_url:\n                        tencent_logger.success(\"  [-]视频草稿保存成功\")\n                        break\n                else:\n                    # 检查是否在发布列表页面\n                    if \"https://channels.weixin.qq.com/platform/post/list\" in current_url:\n                        tencent_logger.success(\"  [-]视频发布成功\")\n                        break\n                tencent_logger.exception(f\"  [-] Exception: {e}\")\n                tencent_logger.info(\"  [-] 视频正在发布中...\")\n                await asyncio.sleep(0.5)\n\n    async def detect_upload_status(self, page):\n        while True:\n            # 匹配删除按钮，代表视频上传完毕，如果不存在，代表视频正在上传，则等待\n            try:\n                # 匹配删除按钮，代表视频上传完毕\n                if \"weui-desktop-btn_disabled\" not in await page.get_by_role(\"button\", name=\"发表\").get_attribute(\n                        'class'):\n                    tencent_logger.info(\"  [-]视频上传完毕\")\n                    break\n                else:\n                    tencent_logger.info(\"  [-] 正在上传视频中...\")\n                    await asyncio.sleep(2)\n                    # 出错了视频出错\n                    if await page.locator('div.status-msg.error').count() and await page.locator(\n                            'div.media-status-content div.tag-inner:has-text(\"删除\")').count():\n                        tencent_logger.error(\"  [-] 发现上传出错了...准备重试\")\n                        await self.handle_upload_error(page)\n            except:\n                tencent_logger.info(\"  [-] 正在上传视频中...\")\n                await asyncio.sleep(2)\n\n    async def add_title_tags(self, page):\n        await page.locator(\"div.input-editor\").click()\n        await page.keyboard.type(self.title)\n        await page.keyboard.press(\"Enter\")\n        for index, tag in enumerate(self.tags, start=1):\n            await page.keyboard.type(\"#\" + tag)\n            await page.keyboard.press(\"Space\")\n        tencent_logger.info(f\"成功添加hashtag: {len(self.tags)}\")\n\n    async def add_collection(self, page):\n        collection_elements = page.get_by_text(\"添加到合集\").locator(\"xpath=following-sibling::div\").locator(\n            '.option-list-wrap > div')\n        if await collection_elements.count() > 1:\n            await page.get_by_text(\"添加到合集\").locator(\"xpath=following-sibling::div\").click()\n            await collection_elements.first.click()\n\n    async def add_original(self, page):\n        if await page.get_by_label(\"视频为原创\").count():\n            await page.get_by_label(\"视频为原创\").check()\n        # 检查 \"我已阅读并同意 《视频号原创声明使用条款》\" 元素是否存在\n        label_locator = await page.locator('label:has-text(\"我已阅读并同意 《视频号原创声明使用条款》\")').is_visible()\n        if label_locator:\n            await page.get_by_label(\"我已阅读并同意 《视频号原创声明使用条款》\").check()\n            await page.get_by_role(\"button\", name=\"声明原创\").click()\n        # 2023年11月20日 wechat更新: 可能新账号或者改版账号，出现新的选择页面\n        if await page.locator('div.label span:has-text(\"声明原创\")').count() and self.category:\n            # 因处罚无法勾选原创，故先判断是否可用\n            if not await page.locator('div.declare-original-checkbox input.ant-checkbox-input').is_disabled():\n                await page.locator('div.declare-original-checkbox input.ant-checkbox-input').click()\n                if not await page.locator(\n                        'div.declare-original-dialog label.ant-checkbox-wrapper.ant-checkbox-wrapper-checked:visible').count():\n                    await page.locator('div.declare-original-dialog input.ant-checkbox-input:visible').click()\n            if await page.locator('div.original-type-form > div.form-label:has-text(\"原创类型\"):visible').count():\n                await page.locator('div.form-content:visible').click()  # 下拉菜单\n                await page.locator(\n                    f'div.form-content:visible ul.weui-desktop-dropdown__list li.weui-desktop-dropdown__list-ele:has-text(\"{self.category}\")').first.click()\n                await page.wait_for_timeout(1000)\n            if await page.locator('button:has-text(\"声明原创\"):visible').count():\n                await page.locator('button:has-text(\"声明原创\"):visible').click()\n\n    async def main(self):\n        async with async_playwright() as playwright:\n            await self.upload(playwright)\n"
  },
  {
    "path": "uploader/tk_uploader/__init__.py",
    "content": "from pathlib import Path\n\nfrom conf import BASE_DIR\n\nPath(BASE_DIR / \"cookies\" / \"tk_uploader\").mkdir(exist_ok=True)"
  },
  {
    "path": "uploader/tk_uploader/main.py",
    "content": "# -*- coding: utf-8 -*-\nimport re\nfrom datetime import datetime\n\nfrom playwright.async_api import Playwright, async_playwright\nimport os\nimport asyncio\nfrom uploader.tk_uploader.tk_config import Tk_Locator\nfrom utils.base_social_media import set_init_script\nfrom utils.files_times import get_absolute_path\nfrom utils.log import tiktok_logger\nfrom conf import LOCAL_CHROME_HEADLESS\n\n\nasync def cookie_auth(account_file):\n    async with async_playwright() as playwright:\n        browser = await playwright.firefox.launch(headless=LOCAL_CHROME_HEADLESS)\n        context = await browser.new_context(storage_state=account_file)\n        context = await set_init_script(context)\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://www.tiktok.com/tiktokstudio/upload?lang=en\")\n        await page.wait_for_load_state('networkidle')\n        try:\n            # 选择所有的 select 元素\n            select_elements = await page.query_selector_all('select')\n            for element in select_elements:\n                class_name = await element.get_attribute('class')\n                # 使用正则表达式匹配特定模式的 class 名称\n                if re.match(r'tiktok-.*-SelectFormContainer.*', class_name):\n                    tiktok_logger.error(\"[+] cookie expired\")\n                    return False\n            tiktok_logger.success(\"[+] cookie valid\")\n            return True\n        except:\n            tiktok_logger.success(\"[+] cookie valid\")\n            return True\n\n\nasync def tiktok_setup(account_file, handle=False):\n    account_file = get_absolute_path(account_file, \"tk_uploader\")\n    if not os.path.exists(account_file) or not await cookie_auth(account_file):\n        if not handle:\n            return False\n        tiktok_logger.info('[+] cookie file is not existed or expired. Now open the browser auto. Please login with your way(gmail phone, whatever, the cookie file will generated after login')\n        await get_tiktok_cookie(account_file)\n    return True\n\n\nasync def get_tiktok_cookie(account_file):\n    async with async_playwright() as playwright:\n        options = {\n            'args': [\n                '--lang en-GB',\n            ],\n            'headless': LOCAL_CHROME_HEADLESS,  # Set headless option here\n        }\n        # Make sure to run headed.\n        browser = await playwright.firefox.launch(**options)\n        # Setup context however you like.\n        context = await browser.new_context()  # Pass any options\n        context = await set_init_script(context)\n        # Pause the page, and start recording manually.\n        page = await context.new_page()\n        await page.goto(\"https://www.tiktok.com/login?lang=en\")\n        await page.pause()\n        # 点击调试器的继续，保存cookie\n        await context.storage_state(path=account_file)\n\n\nclass TiktokVideo(object):\n    def __init__(self, title, file_path, tags, publish_date, account_file):\n        self.title = title\n        self.file_path = file_path\n        self.tags = tags\n        self.publish_date = publish_date\n        self.account_file = account_file\n        self.headless = LOCAL_CHROME_HEADLESS\n        self.locator_base = None\n\n\n    async def set_schedule_time(self, page, publish_date):\n        schedule_input_element = self.locator_base.get_by_label('Schedule')\n        await schedule_input_element.wait_for(state='visible')  # 确保按钮可见\n\n        await schedule_input_element.click()\n        scheduled_picker = self.locator_base.locator('div.scheduled-picker')\n        await scheduled_picker.locator('div.TUXInputBox').nth(1).click()\n\n        calendar_month = await self.locator_base.locator('div.calendar-wrapper span.month-title').inner_text()\n\n        n_calendar_month = datetime.strptime(calendar_month, '%B').month\n\n        schedule_month = publish_date.month\n\n        if n_calendar_month != schedule_month:\n            if n_calendar_month < schedule_month:\n                arrow = self.locator_base.locator('div.calendar-wrapper span.arrow').nth(-1)\n            else:\n                arrow = self.locator_base.locator('div.calendar-wrapper span.arrow').nth(0)\n            await arrow.click()\n\n        # day set\n        valid_days_locator = self.locator_base.locator(\n            'div.calendar-wrapper span.day.valid')\n        valid_days = await valid_days_locator.count()\n        for i in range(valid_days):\n            day_element = valid_days_locator.nth(i)\n            text = await day_element.inner_text()\n            if text.strip() == str(publish_date.day):\n                await day_element.click()\n                break\n        # time set\n        await scheduled_picker.locator('div.TUXInputBox').nth(0).click()\n\n        hour_str = publish_date.strftime(\"%H\")\n        correct_minute = int(publish_date.minute / 5)\n        minute_str = f\"{correct_minute:02d}\"\n\n        hour_selector = f\"span.tiktok-timepicker-left:has-text('{hour_str}')\"\n        minute_selector = f\"span.tiktok-timepicker-right:has-text('{minute_str}')\"\n\n        # pick hour first\n        await self.locator_base.locator(hour_selector).click()\n        # click time button again\n        # 等待某个特定的元素出现或状态变化，表明UI已更新\n        await page.wait_for_timeout(1000)  # 等待500毫秒\n        await scheduled_picker.locator('div.TUXInputBox').nth(0).click()\n        # pick minutes after\n        await self.locator_base.locator(minute_selector).click()\n\n        # click title to remove the focus.\n        await self.locator_base.locator(\"h1:has-text('Upload video')\").click()\n\n    async def handle_upload_error(self, page):\n        tiktok_logger.info(\"video upload error retrying.\")\n        select_file_button = self.locator_base.locator('button[aria-label=\"Select file\"]')\n        async with page.expect_file_chooser() as fc_info:\n            await select_file_button.click()\n        file_chooser = await fc_info.value\n        await file_chooser.set_files(self.file_path)\n\n    async def upload(self, playwright: Playwright) -> None:\n        browser = await playwright.firefox.launch(headless=self.headless)\n        context = await browser.new_context(storage_state=f\"{self.account_file}\")\n        context = await set_init_script(context)\n        page = await context.new_page()\n\n        await page.goto(\"https://www.tiktok.com/creator-center/upload\")\n        tiktok_logger.info(f'[+]Uploading-------{self.title}.mp4')\n\n        await page.wait_for_url(\"https://www.tiktok.com/tiktokstudio/upload\", timeout=10000)\n\n        try:\n            await page.wait_for_selector('iframe[data-tt=\"Upload_index_iframe\"], div.upload-container', timeout=10000)\n            tiktok_logger.info(\"Either iframe or div appeared.\")\n        except Exception as e:\n            tiktok_logger.error(\"Neither iframe nor div appeared within the timeout.\")\n\n        await self.choose_base_locator(page)\n\n        upload_button = self.locator_base.locator(\n            'button:has-text(\"Select video\"):visible')\n        await upload_button.wait_for(state='visible')  # 确保按钮可见\n\n        async with page.expect_file_chooser() as fc_info:\n            await upload_button.click()\n        file_chooser = await fc_info.value\n        await file_chooser.set_files(self.file_path)\n\n        await self.add_title_tags(page)\n        # detact upload status\n        await self.detect_upload_status(page)\n        if self.publish_date != 0:\n            await self.set_schedule_time(page, self.publish_date)\n\n        await self.click_publish(page)\n\n        await context.storage_state(path=f\"{self.account_file}\")  # save cookie\n        tiktok_logger.info('  [-] update cookie！')\n        await asyncio.sleep(2)  # close delay for look the video status\n        # close all\n        await context.close()\n        await browser.close()\n\n    async def add_title_tags(self, page):\n\n        editor_locator = self.locator_base.locator('div.public-DraftEditor-content')\n        await editor_locator.click()\n\n        await page.keyboard.press(\"End\")\n\n        await page.keyboard.press(\"Control+A\")\n\n        await page.keyboard.press(\"Delete\")\n\n        await page.keyboard.press(\"End\")\n\n        await page.wait_for_timeout(1000)  # 等待1秒\n\n        await page.keyboard.insert_text(self.title)\n        await page.wait_for_timeout(1000)  # 等待1秒\n        await page.keyboard.press(\"End\")\n\n        await page.keyboard.press(\"Enter\")\n\n        # tag part\n        for index, tag in enumerate(self.tags, start=1):\n            tiktok_logger.info(\"Setting the %s tag\" % index)\n            await page.keyboard.press(\"End\")\n            await page.wait_for_timeout(1000)  # 等待1秒\n            await page.keyboard.insert_text(\"#\" + tag + \" \")\n            await page.keyboard.press(\"Space\")\n            await page.wait_for_timeout(1000)  # 等待1秒\n\n            await page.keyboard.press(\"Backspace\")\n            await page.keyboard.press(\"End\")\n\n    async def click_publish(self, page):\n        success_flag_div = '#\\\\:r9\\\\:'\n        while True:\n            try:\n                publish_button = self.locator_base.locator('div.btn-post')\n                if await publish_button.count():\n                    await publish_button.click()\n\n                await self.locator_base.locator(success_flag_div).wait_for(state=\"visible\", timeout=3000)\n                tiktok_logger.success(\"  [-] video published success\")\n                break\n            except Exception as e:\n                if await self.locator_base.locator(success_flag_div).count():\n                    tiktok_logger.success(\"  [-]video published success\")\n                    break\n                else:\n                    tiktok_logger.exception(f\"  [-] Exception: {e}\")\n                    tiktok_logger.info(\"  [-] video publishing\")\n                    await page.screenshot(full_page=True)\n                    await asyncio.sleep(0.5)\n\n    async def detect_upload_status(self, page):\n        while True:\n            try:\n                if await self.locator_base.locator('div.btn-post > button').get_attribute(\"disabled\") is None:\n                    tiktok_logger.info(\"  [-]video uploaded.\")\n                    break\n                else:\n                    tiktok_logger.info(\"  [-] video uploading...\")\n                    await asyncio.sleep(2)\n                    if await self.locator_base.locator('button[aria-label=\"Select file\"]').count():\n                        tiktok_logger.info(\"  [-] found some error while uploading now retry...\")\n                        await self.handle_upload_error(page)\n            except:\n                tiktok_logger.info(\"  [-] video uploading...\")\n                await asyncio.sleep(2)\n\n    async def choose_base_locator(self, page):\n        # await page.wait_for_selector('div.upload-container')\n        if await page.locator('iframe[data-tt=\"Upload_index_iframe\"]').count():\n            self.locator_base = self.locator_base\n        else:\n            self.locator_base = page.locator(Tk_Locator.default) \n\n    async def main(self):\n        async with async_playwright() as playwright:\n            await self.upload(playwright)\n\n"
  },
  {
    "path": "uploader/tk_uploader/main_chrome.py",
    "content": "# -*- coding: utf-8 -*-\nimport re\nfrom datetime import datetime\n\nfrom playwright.async_api import Playwright, async_playwright\nimport os\nimport asyncio\n\nfrom conf import LOCAL_CHROME_PATH, LOCAL_CHROME_HEADLESS\nfrom uploader.tk_uploader.tk_config import Tk_Locator\nfrom utils.base_social_media import set_init_script\nfrom utils.files_times import get_absolute_path\nfrom utils.log import tiktok_logger\n\n\nasync def cookie_auth(account_file):\n    async with async_playwright() as playwright:\n        browser = await playwright.chromium.launch(headless=LOCAL_CHROME_HEADLESS)\n        context = await browser.new_context(storage_state=account_file)\n        context = await set_init_script(context)\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://www.tiktok.com/tiktokstudio/upload?lang=en\")\n        await page.wait_for_load_state('networkidle')\n        try:\n            # 选择所有的 select 元素\n            select_elements = await page.query_selector_all('select')\n            for element in select_elements:\n                class_name = await element.get_attribute('class')\n                # 使用正则表达式匹配特定模式的 class 名称\n                if re.match(r'tiktok-.*-SelectFormContainer.*', class_name):\n                    tiktok_logger.error(\"[+] cookie expired\")\n                    return False\n            tiktok_logger.success(\"[+] cookie valid\")\n            return True\n        except:\n            tiktok_logger.success(\"[+] cookie valid\")\n            return True\n\n\nasync def tiktok_setup(account_file, handle=False):\n    account_file = get_absolute_path(account_file, \"tk_uploader\")\n    if not os.path.exists(account_file) or not await cookie_auth(account_file):\n        if not handle:\n            return False\n        tiktok_logger.info('[+] cookie file is not existed or expired. Now open the browser auto. Please login with your way(gmail phone, whatever, the cookie file will generated after login')\n        await get_tiktok_cookie(account_file)\n    return True\n\n\nasync def get_tiktok_cookie(account_file):\n    async with async_playwright() as playwright:\n        options = {\n            'args': [\n                '--lang en-GB',\n            ],\n            'headless': LOCAL_CHROME_HEADLESS,  # Set headless option here\n        }\n        # Make sure to run headed.\n        browser = await playwright.chromium.launch(**options)\n        # Setup context however you like.\n        context = await browser.new_context()  # Pass any options\n        context = await set_init_script(context)\n        # Pause the page, and start recording manually.\n        page = await context.new_page()\n        await page.goto(\"https://www.tiktok.com/login?lang=en\")\n        await page.pause()\n        # 点击调试器的继续，保存cookie\n        await context.storage_state(path=account_file)\n\n\nclass TiktokVideo(object):\n    def __init__(self, title, file_path, tags, publish_date, account_file, thumbnail_path=None):\n        self.title = title\n        self.file_path = file_path\n        self.tags = tags\n        self.publish_date = publish_date\n        self.thumbnail_path = thumbnail_path\n        self.account_file = account_file\n        self.local_executable_path = LOCAL_CHROME_PATH\n        self.headless = LOCAL_CHROME_HEADLESS\n        self.locator_base = None\n\n    async def set_schedule_time(self, page, publish_date):\n        schedule_input_element = self.locator_base.get_by_label('Schedule')\n        await schedule_input_element.wait_for(state='visible')  # 确保按钮可见\n\n        await schedule_input_element.click(force=True)\n        if await self.locator_base.locator('div.TUXButton-content >> text=Allow').count():\n            await self.locator_base.locator('div.TUXButton-content >> text=Allow').click()\n\n        scheduled_picker = self.locator_base.locator('div.scheduled-picker')\n        await scheduled_picker.locator('div.TUXInputBox').nth(1).click()\n\n        calendar_month = await self.locator_base.locator(\n            'div.calendar-wrapper span.month-title').inner_text()\n\n        n_calendar_month = datetime.strptime(calendar_month, '%B').month\n\n        schedule_month = publish_date.month\n\n        if n_calendar_month != schedule_month:\n            if n_calendar_month < schedule_month:\n                arrow = self.locator_base.locator('div.calendar-wrapper span.arrow').nth(-1)\n            else:\n                arrow = self.locator_base.locator('div.calendar-wrapper span.arrow').nth(0)\n            await arrow.click()\n\n        # day set\n        valid_days_locator = self.locator_base.locator(\n            'div.calendar-wrapper span.day.valid')\n        valid_days = await valid_days_locator.count()\n        for i in range(valid_days):\n            day_element = valid_days_locator.nth(i)\n            text = await day_element.inner_text()\n            if text.strip() == str(publish_date.day):\n                await day_element.click()\n                break\n        # time set\n        await scheduled_picker.locator('div.TUXInputBox').nth(0).click()\n\n        hour_str = publish_date.strftime(\"%H\")\n        correct_minute = int(publish_date.minute / 5)\n        minute_str = f\"{correct_minute:02d}\"\n\n        hour_selector = f\"span.tiktok-timepicker-left:has-text('{hour_str}')\"\n        minute_selector = f\"span.tiktok-timepicker-right:has-text('{minute_str}')\"\n\n        # pick hour first\n        await page.wait_for_timeout(1000)  # 等待500毫秒\n        await self.locator_base.locator(hour_selector).click()\n        # click time button again\n        await page.wait_for_timeout(1000)  # 等待500毫秒\n        # pick minutes after\n        await self.locator_base.locator(minute_selector).click()\n\n        # click title to remove the focus.\n        # await self.locator_base.locator(\"h1:has-text('Upload video')\").click()\n\n    async def handle_upload_error(self, page):\n        tiktok_logger.info(\"video upload error retrying.\")\n        select_file_button = self.locator_base.locator('button[aria-label=\"Select file\"]')\n        async with page.expect_file_chooser() as fc_info:\n            await select_file_button.click()\n        file_chooser = await fc_info.value\n        await file_chooser.set_files(self.file_path)\n\n    async def upload(self, playwright: Playwright) -> None:\n        browser = await playwright.chromium.launch(headless=self.headless, executable_path=self.local_executable_path)\n        context = await browser.new_context(storage_state=f\"{self.account_file}\")\n        # context = await set_init_script(context)\n        page = await context.new_page()\n\n        # change language to eng first\n        await self.change_language(page)\n        await page.goto(\"https://www.tiktok.com/tiktokstudio/upload\")\n        tiktok_logger.info(f'[+]Uploading-------{self.title}.mp4')\n\n        await page.wait_for_url(\"https://www.tiktok.com/tiktokstudio/upload\", timeout=10000)\n\n        try:\n            await page.wait_for_selector('iframe[data-tt=\"Upload_index_iframe\"], div.upload-container', timeout=10000)\n            tiktok_logger.info(\"Either iframe or div appeared.\")\n        except Exception as e:\n            tiktok_logger.error(\"Neither iframe nor div appeared within the timeout.\")\n\n        await self.choose_base_locator(page)\n\n        upload_button = self.locator_base.locator(\n            'button:has-text(\"Select video\"):visible')\n        await upload_button.wait_for(state='visible')  # 确保按钮可见\n\n        async with page.expect_file_chooser() as fc_info:\n            await upload_button.click()\n        file_chooser = await fc_info.value\n        await file_chooser.set_files(self.file_path)\n\n        await self.add_title_tags(page)\n        # detect upload status\n        await self.detect_upload_status(page)\n        if self.thumbnail_path:\n            tiktok_logger.info(f'[+] Uploading thumbnail file {self.title}.png')\n            await self.upload_thumbnails(page)\n\n        if self.publish_date != 0:\n            await self.set_schedule_time(page, self.publish_date)\n\n        await self.click_publish(page)\n        tiktok_logger.success(f\"video_id: {await self.get_last_video_id(page)}\")\n\n        await context.storage_state(path=f\"{self.account_file}\")  # save cookie\n        tiktok_logger.info('  [-] update cookie！')\n        await asyncio.sleep(2)  # close delay for look the video status\n        # close all\n        await context.close()\n        await browser.close()\n\n    async def add_title_tags(self, page):\n\n        editor_locator = self.locator_base.locator('div.public-DraftEditor-content')\n        await editor_locator.click()\n\n        await page.keyboard.press(\"End\")\n\n        await page.keyboard.press(\"Control+A\")\n\n        await page.keyboard.press(\"Delete\")\n\n        await page.keyboard.press(\"End\")\n\n        await page.wait_for_timeout(1000)  # 等待1秒\n\n        await page.keyboard.insert_text(self.title)\n        await page.wait_for_timeout(1000)  # 等待1秒\n        await page.keyboard.press(\"End\")\n\n        await page.keyboard.press(\"Enter\")\n\n        # tag part\n        for index, tag in enumerate(self.tags, start=1):\n            tiktok_logger.info(\"Setting the %s tag\" % index)\n            await page.keyboard.press(\"End\")\n            await page.wait_for_timeout(1000)  # 等待1秒\n            await page.keyboard.insert_text(\"#\" + tag + \" \")\n            await page.keyboard.press(\"Space\")\n            await page.wait_for_timeout(1000)  # 等待1秒\n\n            await page.keyboard.press(\"Backspace\")\n            await page.keyboard.press(\"End\")\n\n    async def upload_thumbnails(self, page):\n        await self.locator_base.locator(\".cover-container\").click()\n        await self.locator_base.locator(\".cover-edit-container >> text=Upload cover\").click()\n        async with page.expect_file_chooser() as fc_info:\n            await self.locator_base.locator(\".upload-image-upload-area\").click()\n            file_chooser = await fc_info.value\n            await file_chooser.set_files(self.thumbnail_path)\n        await self.locator_base.locator('div.cover-edit-panel:not(.hide-panel)').get_by_role(\n            \"button\", name=\"Confirm\").click()\n        await page.wait_for_timeout(3000)  # wait 3s, fix it later\n\n    async def change_language(self, page):\n        # set the language to english\n        await page.goto(\"https://www.tiktok.com\")\n        await page.wait_for_load_state('domcontentloaded')\n        await page.wait_for_selector('[data-e2e=\"nav-more-menu\"]')\n        # 已经设置为英文, 省略这个步骤\n        if await page.locator('[data-e2e=\"nav-more-menu\"]').text_content() == \"More\":\n            return\n\n        await page.locator('[data-e2e=\"nav-more-menu\"]').click()\n        await page.locator('[data-e2e=\"language-select\"]').click()\n        await page.locator('#creator-tools-selection-menu-header >> text=English (US)').click()\n\n    async def click_publish(self, page):\n        success_flag_div = 'div.common-modal-confirm-modal'\n        while True:\n            try:\n                publish_button = self.locator_base.locator('div.button-group button').nth(0)\n                if await publish_button.count():\n                    await publish_button.click()\n\n                await page.wait_for_url(\"https://www.tiktok.com/tiktokstudio/content\",  timeout=3000)\n                tiktok_logger.success(\"  [-] video published success\")\n                break\n            except Exception as e:\n                tiktok_logger.exception(f\"  [-] Exception: {e}\")\n                tiktok_logger.info(\"  [-] video publishing\")\n                await asyncio.sleep(0.5)\n\n    async def get_last_video_id(self, page):\n        await page.wait_for_selector('div[data-tt=\"components_PostTable_Container\"]')\n        video_list_locator = self.locator_base.locator('div[data-tt=\"components_PostTable_Container\"] div[data-tt=\"components_PostInfoCell_Container\"] a')\n        if await video_list_locator.count():\n            first_video_obj = await video_list_locator.nth(0).get_attribute('href')\n            video_id = re.search(r'video/(\\d+)', first_video_obj).group(1) if first_video_obj else None\n            return video_id\n\n\n    async def detect_upload_status(self, page):\n        while True:\n            try:\n                # if await self.locator_base.locator('div.btn-post > button').get_attribute(\"disabled\") is None:\n                if await self.locator_base.locator(\n                        'div.button-group > button >> text=Post').get_attribute(\"disabled\") is None:\n                    tiktok_logger.info(\"  [-]video uploaded.\")\n                    break\n                else:\n                    tiktok_logger.info(\"  [-] video uploading...\")\n                    await asyncio.sleep(2)\n                    if await self.locator_base.locator(\n                            'button[aria-label=\"Select file\"]').count():\n                        tiktok_logger.info(\"  [-] found some error while uploading now retry...\")\n                        await self.handle_upload_error(page)\n            except:\n                tiktok_logger.info(\"  [-] video uploading...\")\n                await asyncio.sleep(2)\n\n    async def choose_base_locator(self, page):\n        # await page.wait_for_selector('div.upload-container')\n        if await page.locator('iframe[data-tt=\"Upload_index_iframe\"]').count():\n            self.locator_base = page.frame_locator(Tk_Locator.tk_iframe)\n        else:\n            self.locator_base = page.locator(Tk_Locator.default) \n\n    async def main(self):\n        async with async_playwright() as playwright:\n            await self.upload(playwright)\n"
  },
  {
    "path": "uploader/tk_uploader/tk_config.py",
    "content": "\nclass Tk_Locator(object):\n    tk_iframe = '[data-tt=\"Upload_index_iframe\"]'\n    default = 'body'\n"
  },
  {
    "path": "uploader/xhs_uploader/__init__.py",
    "content": ""
  },
  {
    "path": "uploader/xhs_uploader/accounts.ini",
    "content": "[account1]\ncookies = changeme\n"
  },
  {
    "path": "uploader/xhs_uploader/main.py",
    "content": "import configparser\nimport json\nimport pathlib\nfrom time import sleep\n\nimport requests\nfrom playwright.sync_api import sync_playwright\n\nfrom conf import BASE_DIR, XHS_SERVER, LOCAL_CHROME_HEADLESS\n\nconfig = configparser.RawConfigParser()\nconfig.read('accounts.ini')\n\n\ndef sign_local(uri, data=None, a1=\"\", web_session=\"\"):\n    for _ in range(10):\n        try:\n            with sync_playwright() as playwright:\n                stealth_js_path = pathlib.Path(BASE_DIR / \"utils/stealth.min.js\")\n                chromium = playwright.chromium\n\n                # 如果一直失败可尝试设置成 False 让其打开浏览器，适当添加 sleep 可查看浏览器状态\n                browser = chromium.launch(headless=LOCAL_CHROME_HEADLESS)\n\n                browser_context = browser.new_context()\n                browser_context.add_init_script(path=stealth_js_path)\n                context_page = browser_context.new_page()\n                context_page.goto(\"https://www.xiaohongshu.com\")\n                browser_context.add_cookies([\n                    {'name': 'a1', 'value': a1, 'domain': \".xiaohongshu.com\", 'path': \"/\"}]\n                )\n                context_page.reload()\n                # 这个地方设置完浏览器 cookie 之后，如果这儿不 sleep 一下签名获取就失败了，如果经常失败请设置长一点试试\n                sleep(2)\n                encrypt_params = context_page.evaluate(\"([url, data]) => window._webmsxyw(url, data)\", [uri, data])\n                return {\n                    \"x-s\": encrypt_params[\"X-s\"],\n                    \"x-t\": str(encrypt_params[\"X-t\"])\n                }\n        except Exception:\n            # 这儿有时会出现 window._webmsxyw is not a function 或未知跳转错误，因此加一个失败重试趴\n            pass\n    raise Exception(\"重试了这么多次还是无法签名成功，寄寄寄\")\n\n\ndef sign(uri, data=None, a1=\"\", web_session=\"\"):\n    # 填写自己的 flask 签名服务端口地址\n    res = requests.post(f\"{XHS_SERVER}/sign\",\n                        json={\"uri\": uri, \"data\": data, \"a1\": a1, \"web_session\": web_session})\n    signs = res.json()\n    return {\n        \"x-s\": signs[\"x-s\"],\n        \"x-t\": signs[\"x-t\"]\n    }\n\n\ndef beauty_print(data: dict):\n    print(json.dumps(data, ensure_ascii=False, indent=2))\n"
  },
  {
    "path": "uploader/xhs_uploader/xhs_login_qrcode.py",
    "content": "import datetime\nimport json\nimport qrcode\nfrom time import sleep\n\nfrom xhs import XhsClient\n\nfrom uploader.xhs_uploader.main import sign\n\n# pip install qrcode\nif __name__ == '__main__':\n    xhs_client = XhsClient(sign=sign, timeout=60)\n    print(datetime.datetime.now())\n    qr_res = xhs_client.get_qrcode()\n    qr_id = qr_res[\"qr_id\"]\n    qr_code = qr_res[\"code\"]\n\n    qr = qrcode.QRCode(version=1, error_correction=qrcode.ERROR_CORRECT_L,\n                       box_size=50,\n                       border=1)\n    qr.add_data(qr_res[\"url\"])\n    qr.make()\n    qr.print_ascii()\n\n    while True:\n        check_qrcode = xhs_client.check_qrcode(qr_id, qr_code)\n        print(check_qrcode)\n        sleep(1)\n        if check_qrcode[\"code_status\"] == 2:\n            print(json.dumps(check_qrcode[\"login_info\"], indent=4))\n            print(\"当前 cookie：\" + xhs_client.cookie)\n            break\n\n    print(json.dumps(xhs_client.get_self_info(), indent=4))"
  },
  {
    "path": "uploader/xiaohongshu_uploader/__init__.py",
    "content": "from pathlib import Path\n\nfrom conf import BASE_DIR\n\nPath(BASE_DIR / \"cookies\" / \"xiaohongshu_uploader\").mkdir(exist_ok=True)"
  },
  {
    "path": "uploader/xiaohongshu_uploader/main.py",
    "content": "# -*- coding: utf-8 -*-\nfrom datetime import datetime\n\nfrom playwright.async_api import Playwright, async_playwright, Page\nimport os\nimport asyncio\n\nfrom conf import LOCAL_CHROME_PATH, LOCAL_CHROME_HEADLESS\nfrom utils.base_social_media import set_init_script\nfrom utils.log import xiaohongshu_logger\n\n\nasync def cookie_auth(account_file):\n    async with async_playwright() as playwright:\n        browser = await playwright.chromium.launch(headless=LOCAL_CHROME_HEADLESS)\n        context = await browser.new_context(storage_state=account_file)\n        context = await set_init_script(context)\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://creator.xiaohongshu.com/creator-micro/content/upload\")\n        try:\n            await page.wait_for_url(\"https://creator.xiaohongshu.com/creator-micro/content/upload\", timeout=5000)\n        except:\n            print(\"[+] 等待5秒 cookie 失效\")\n            await context.close()\n            await browser.close()\n            return False\n        # 2024.06.17 抖音创作者中心改版\n        if await page.get_by_text('手机号登录').count() or await page.get_by_text('扫码登录').count():\n            print(\"[+] 等待5秒 cookie 失效\")\n            return False\n        else:\n            print(\"[+] cookie 有效\")\n            return True\n\n\nasync def xiaohongshu_setup(account_file, handle=False):\n    if not os.path.exists(account_file) or not await cookie_auth(account_file):\n        if not handle:\n            # Todo alert message\n            return False\n        xiaohongshu_logger.info('[+] cookie文件不存在或已失效，即将自动打开浏览器，请扫码登录，登陆后会自动生成cookie文件')\n        await xiaohongshu_cookie_gen(account_file)\n    return True\n\n\nasync def xiaohongshu_cookie_gen(account_file):\n    async with async_playwright() as playwright:\n        options = {\n            'headless': LOCAL_CHROME_HEADLESS\n        }\n        # Make sure to run headed.\n        browser = await playwright.chromium.launch(**options)\n        # Setup context however you like.\n        context = await browser.new_context()  # Pass any options\n        context = await set_init_script(context)\n        # Pause the page, and start recording manually.\n        page = await context.new_page()\n        await page.goto(\"https://creator.xiaohongshu.com/\")\n        await page.pause()\n        # 点击调试器的继续，保存cookie\n        await context.storage_state(path=account_file)\n\n\nclass XiaoHongShuVideo(object):\n    def __init__(self, title, file_path, tags, publish_date: datetime, account_file, thumbnail_path=None):\n        self.title = title  # 视频标题\n        self.file_path = file_path\n        self.tags = tags\n        self.publish_date = publish_date\n        self.account_file = account_file\n        self.date_format = '%Y年%m月%d日 %H:%M'\n        self.local_executable_path = LOCAL_CHROME_PATH\n        self.headless = LOCAL_CHROME_HEADLESS\n        self.thumbnail_path = thumbnail_path\n\n    async def set_schedule_time_xiaohongshu(self, page, publish_date):\n        print(\"  [-] 正在设置定时发布时间...\")\n        print(f\"publish_date: {publish_date}\")\n\n        # 使用文本内容定位元素\n        # element = await page.wait_for_selector(\n        #     'label:has-text(\"定时发布\")',\n        #     timeout=5000  # 5秒超时时间\n        # )\n        # await element.click()\n\n        # # 选择包含特定文本内容的 label 元素\n        label_element = page.locator(\"label:has-text('定时发布')\")\n        # # 在选中的 label 元素下点击 checkbox\n        await label_element.click()\n        await asyncio.sleep(1)\n        publish_date_hour = publish_date.strftime(\"%Y-%m-%d %H:%M\")\n        print(f\"publish_date_hour: {publish_date_hour}\")\n\n        await asyncio.sleep(1)\n        await page.locator('.el-input__inner[placeholder=\"选择日期和时间\"]').click()\n        await page.keyboard.press(\"Control+KeyA\")\n        await page.keyboard.type(str(publish_date_hour))\n        await page.keyboard.press(\"Enter\")\n\n        await asyncio.sleep(1)\n\n    async def handle_upload_error(self, page):\n        xiaohongshu_logger.info('视频出错了，重新上传中')\n        await page.locator('div.progress-div [class^=\"upload-btn-input\"]').set_input_files(self.file_path)\n\n    async def upload(self, playwright: Playwright) -> None:\n        # 使用 Chromium 浏览器启动一个浏览器实例\n        if self.local_executable_path:\n            browser = await playwright.chromium.launch(headless=self.headless, executable_path=self.local_executable_path)\n        else:\n            browser = await playwright.chromium.launch(headless=self.headless)\n        # 创建一个浏览器上下文，使用指定的 cookie 文件\n        context = await browser.new_context(\n            viewport={\"width\": 1600, \"height\": 900},\n            storage_state=f\"{self.account_file}\"\n        )\n        context = await set_init_script(context)\n\n        # 创建一个新的页面\n        page = await context.new_page()\n        # 访问指定的 URL\n        await page.goto(\"https://creator.xiaohongshu.com/publish/publish?from=homepage&target=video\")\n        xiaohongshu_logger.info(f'[+]正在上传-------{self.title}.mp4')\n        # 等待页面跳转到指定的 URL，没进入，则自动等待到超时\n        xiaohongshu_logger.info(f'[-] 正在打开主页...')\n        await page.wait_for_url(\"https://creator.xiaohongshu.com/publish/publish?from=homepage&target=video\")\n        # 点击 \"上传视频\" 按钮\n        await page.locator(\"div[class^='upload-content'] input[class='upload-input']\").set_input_files(self.file_path)\n\n        # 等待页面跳转到指定的 URL 2025.01.08修改在原有基础上兼容两种页面\n        while True:\n            try:\n                # 等待upload-input元素出现\n                upload_input = await page.wait_for_selector('input.upload-input', timeout=3000)\n                # 获取下一个兄弟元素\n                preview_new = await upload_input.query_selector(\n                    'xpath=following-sibling::div[contains(@class, \"preview-new\")]')\n                if preview_new:\n                    # 在preview-new元素中查找包含\"上传成功\"的stage元素\n                    stage_elements = await preview_new.query_selector_all('div.stage')\n                    upload_success = False\n                    for stage in stage_elements:\n                        text_content = await page.evaluate('(element) => element.textContent', stage)\n                        if '上传成功' in text_content:\n                            upload_success = True\n                            break\n                    if upload_success:\n                        xiaohongshu_logger.info(\"[+] 检测到上传成功标识!\")\n                        break  # 成功检测到上传成功后跳出循环\n                    else:\n                        print(\"  [-] 未找到上传成功标识，继续等待...\")\n                else:\n                    print(\"  [-] 未找到预览元素，继续等待...\")\n                    await asyncio.sleep(1)\n            except Exception as e:\n                print(f\"  [-] 检测过程出错: {str(e)}，重新尝试...\")\n                await asyncio.sleep(0.5)  # 等待0.5秒后重新尝试\n\n        # 填充标题和话题\n        # 检查是否存在包含输入框的元素\n        # 这里为了避免页面变化，故使用相对位置定位：作品标题父级右侧第一个元素的input子元素\n        await asyncio.sleep(1)\n        xiaohongshu_logger.info(f'  [-] 正在填充标题和话题...')\n        title_container = page.locator('div.plugin.title-container').locator('input.d-text')\n        if await title_container.count():\n            await title_container.fill(self.title[:30])\n        else:\n            titlecontainer = page.locator(\".notranslate\")\n            await titlecontainer.click()\n            await page.keyboard.press(\"Backspace\")\n            await page.keyboard.press(\"Control+KeyA\")\n            await page.keyboard.press(\"Delete\")\n            await page.keyboard.type(self.title)\n            await page.keyboard.press(\"Enter\")\n        css_selector = \".ql-editor\" # 不能加上 .ql-blank 属性，这样只能获取第一次非空状态\n        for index, tag in enumerate(self.tags, start=1):\n            await page.type(css_selector, \"#\" + tag)\n            await page.press(css_selector, \"Space\")\n        xiaohongshu_logger.info(f'总共添加{len(self.tags)}个话题')\n\n        # while True:\n        #     # 判断重新上传按钮是否存在，如果不存在，代表视频正在上传，则等待\n        #     try:\n        #         #  新版：定位重新上传\n        #         number = await page.locator('[class^=\"long-card\"] div:has-text(\"重新上传\")').count()\n        #         if number > 0:\n        #             xiaohongshu_logger.success(\"  [-]视频上传完毕\")\n        #             break\n        #         else:\n        #             xiaohongshu_logger.info(\"  [-] 正在上传视频中...\")\n        #             await asyncio.sleep(2)\n\n        #             if await page.locator('div.progress-div > div:has-text(\"上传失败\")').count():\n        #                 xiaohongshu_logger.error(\"  [-] 发现上传出错了... 准备重试\")\n        #                 await self.handle_upload_error(page)\n        #     except:\n        #         xiaohongshu_logger.info(\"  [-] 正在上传视频中...\")\n        #         await asyncio.sleep(2)\n        \n        # 上传视频封面\n        # await self.set_thumbnail(page, self.thumbnail_path)\n\n        # 更换可见元素\n        # await self.set_location(page, \"青岛市\")\n\n        # # 頭條/西瓜\n        # third_part_element = '[class^=\"info\"] > [class^=\"first-part\"] div div.semi-switch'\n        # # 定位是否有第三方平台\n        # if await page.locator(third_part_element).count():\n        #     # 检测是否是已选中状态\n        #     if 'semi-switch-checked' not in await page.eval_on_selector(third_part_element, 'div => div.className'):\n        #         await page.locator(third_part_element).locator('input.semi-switch-native-control').click()\n\n        if self.publish_date != 0:\n            await self.set_schedule_time_xiaohongshu(page, self.publish_date)\n\n        # 判断视频是否发布成功\n        while True:\n            try:\n                # 等待包含\"定时发布\"文本的button元素出现并点击\n                if self.publish_date != 0:\n                    await page.locator('button:has-text(\"定时发布\")').click()\n                else:\n                    await page.locator('button:has-text(\"发布\")').click()\n                await page.wait_for_url(\n                    \"https://creator.xiaohongshu.com/publish/success?**\",\n                    timeout=3000\n                )  # 如果自动跳转到作品页面，则代表发布成功\n                xiaohongshu_logger.success(\"  [-]视频发布成功\")\n                break\n            except:\n                xiaohongshu_logger.info(\"  [-] 视频正在发布中...\")\n                await page.screenshot(full_page=True)\n                await asyncio.sleep(0.5)\n\n        await context.storage_state(path=self.account_file)  # 保存cookie\n        xiaohongshu_logger.success('  [-]cookie更新完毕！')\n        await asyncio.sleep(2)  # 这里延迟是为了方便眼睛直观的观看\n        # 关闭浏览器上下文和浏览器实例\n        await context.close()\n        await browser.close()\n    \n    async def set_thumbnail(self, page: Page, thumbnail_path: str):\n        if thumbnail_path:\n            await page.click('text=\"选择封面\"')\n            await page.wait_for_selector(\"div.semi-modal-content:visible\")\n            await page.click('text=\"设置竖封面\"')\n            await page.wait_for_timeout(2000)  # 等待2秒\n            # 定位到上传区域并点击\n            await page.locator(\"div[class^='semi-upload upload'] >> input.semi-upload-hidden-input\").set_input_files(thumbnail_path)\n            await page.wait_for_timeout(2000)  # 等待2秒\n            await page.locator(\"div[class^='extractFooter'] button:visible:has-text('完成')\").click()\n            # finish_confirm_element = page.locator(\"div[class^='confirmBtn'] >> div:has-text('完成')\")\n            # if await finish_confirm_element.count():\n            #     await finish_confirm_element.click()\n            # await page.locator(\"div[class^='footer'] button:has-text('完成')\").click()\n\n    async def set_location(self, page: Page, location: str = \"青岛市\"):\n        print(f\"开始设置位置: {location}\")\n        \n        # 点击地点输入框\n        print(\"等待地点输入框加载...\")\n        loc_ele = await page.wait_for_selector('div.d-text.d-select-placeholder.d-text-ellipsis.d-text-nowrap')\n        print(f\"已定位到地点输入框: {loc_ele}\")\n        await loc_ele.click()\n        print(\"点击地点输入框完成\")\n        \n        # 输入位置名称\n        print(f\"等待1秒后输入位置名称: {location}\")\n        await page.wait_for_timeout(1000)\n        await page.keyboard.type(location)\n        print(f\"位置名称输入完成: {location}\")\n        \n        # 等待下拉列表加载\n        print(\"等待下拉列表加载...\")\n        dropdown_selector = 'div.d-popover.d-popover-default.d-dropdown.--size-min-width-large'\n        await page.wait_for_timeout(3000)\n        try:\n            await page.wait_for_selector(dropdown_selector, timeout=3000)\n            print(\"下拉列表已加载\")\n        except:\n            print(\"下拉列表未按预期显示，可能结构已变化\")\n        \n        # 增加等待时间以确保内容加载完成\n        print(\"额外等待1秒确保内容渲染完成...\")\n        await page.wait_for_timeout(1000)\n        \n        # 尝试更灵活的XPath选择器\n        print(\"尝试使用更灵活的XPath选择器...\")\n        flexible_xpath = (\n            f'//div[contains(@class, \"d-popover\") and contains(@class, \"d-dropdown\")]'\n            f'//div[contains(@class, \"d-options-wrapper\")]'\n            f'//div[contains(@class, \"d-grid\") and contains(@class, \"d-options\")]'\n            f'//div[contains(@class, \"name\") and text()=\"{location}\"]'\n        )\n        await page.wait_for_timeout(3000)\n        \n        # 尝试定位元素\n        print(f\"尝试定位包含'{location}'的选项...\")\n        try:\n            # 先尝试使用更灵活的选择器\n            location_option = await page.wait_for_selector(\n                flexible_xpath,\n                timeout=3000\n            )\n            \n            if location_option:\n                print(f\"使用灵活选择器定位成功: {location_option}\")\n            else:\n                # 如果灵活选择器失败，再尝试原选择器\n                print(\"灵活选择器未找到元素，尝试原始选择器...\")\n                location_option = await page.wait_for_selector(\n                    f'//div[contains(@class, \"d-popover\") and contains(@class, \"d-dropdown\")]'\n                    f'//div[contains(@class, \"d-options-wrapper\")]'\n                    f'//div[contains(@class, \"d-grid\") and contains(@class, \"d-options\")]'\n                    f'/div[1]//div[contains(@class, \"name\") and text()=\"{location}\"]',\n                    timeout=2000\n                )\n            \n            # 滚动到元素并点击\n            print(\"滚动到目标选项...\")\n            await location_option.scroll_into_view_if_needed()\n            print(\"元素已滚动到视图内\")\n            \n            # 增加元素可见性检查\n            is_visible = await location_option.is_visible()\n            print(f\"目标选项是否可见: {is_visible}\")\n            \n            # 点击元素\n            print(\"准备点击目标选项...\")\n            await location_option.click()\n            print(f\"成功选择位置: {location}\")\n            return True\n            \n        except Exception as e:\n            print(f\"定位位置失败: {e}\")\n            \n            # 打印更多调试信息\n            print(\"尝试获取下拉列表中的所有选项...\")\n            try:\n                all_options = await page.query_selector_all(\n                    '//div[contains(@class, \"d-popover\") and contains(@class, \"d-dropdown\")]'\n                    '//div[contains(@class, \"d-options-wrapper\")]'\n                    '//div[contains(@class, \"d-grid\") and contains(@class, \"d-options\")]'\n                    '/div'\n                )\n                print(f\"找到 {len(all_options)} 个选项\")\n                \n                # 打印前3个选项的文本内容\n                for i, option in enumerate(all_options[:3]):\n                    option_text = await option.inner_text()\n                    print(f\"选项 {i+1}: {option_text.strip()[:50]}...\")\n                    \n            except Exception as e:\n                print(f\"获取选项列表失败: {e}\")\n                \n            # 截图保存（取消注释使用）\n            # await page.screenshot(path=f\"location_error_{location}.png\")\n            return False\n\n    async def main(self):\n        async with async_playwright() as playwright:\n            await self.upload(playwright)\n\n\n"
  },
  {
    "path": "utils/__init__.py",
    "content": ""
  },
  {
    "path": "utils/base_social_media.py",
    "content": "from pathlib import Path\nfrom typing import List\n\nfrom conf import BASE_DIR\n\nSOCIAL_MEDIA_DOUYIN = \"douyin\"\nSOCIAL_MEDIA_TENCENT = \"tencent\"\nSOCIAL_MEDIA_TIKTOK = \"tiktok\"\nSOCIAL_MEDIA_BILIBILI = \"bilibili\"\nSOCIAL_MEDIA_KUAISHOU = \"kuaishou\"\n\n\ndef get_supported_social_media() -> List[str]:\n    return [SOCIAL_MEDIA_DOUYIN, SOCIAL_MEDIA_TENCENT, SOCIAL_MEDIA_TIKTOK, SOCIAL_MEDIA_KUAISHOU]\n\n\ndef get_cli_action() -> List[str]:\n    return [\"upload\", \"login\", \"watch\"]\n\n\nasync def set_init_script(context):\n    stealth_js_path = Path(BASE_DIR / \"utils/stealth.min.js\")\n    await context.add_init_script(path=stealth_js_path)\n    return context\n"
  },
  {
    "path": "utils/browser_hook.py",
    "content": "from conf import LOCAL_CHROME_HEADLESS, LOCAL_CHROME_PATH\n\ndef get_browser_options():\n    options = {\n        'headless': LOCAL_CHROME_HEADLESS,\n        'args': [\n            '--disable-blink-features=AutomationControlled',\n            '--lang=zh-CN',\n            '--disable-infobars',\n            '--start-maximized',\n            '--no-sandbox',\n            '--disable-web-security'\n        ]\n    }\n    if LOCAL_CHROME_PATH:\n        options['executable_path'] = LOCAL_CHROME_PATH\n    return options\n"
  },
  {
    "path": "utils/constant.py",
    "content": "import enum\n\n\nclass TencentZoneTypes(enum.Enum):\n    LIFESTYLE = '生活'\n    CUTE_KIDS = '萌娃'\n    MUSIC = '音乐'\n    KNOWLEDGE = '知识'\n    EMOTION = '情感'\n    TRAVEL_SCENERY = '旅行风景'\n    FASHION = '时尚'\n    FOOD = '美食'\n    LIFE_HACKS = '生活技巧'\n    DANCE = '舞蹈'\n    MOVIES_TV_SHOWS = '影视综艺'\n    SPORTS = '运动'\n    FUNNY = '搞笑'\n    CELEBRITIES = '明星名人'\n    NEWS_INFO = '新闻资讯'\n    GAMING = '游戏'\n    AUTOMOTIVE = '车'\n    ANIME = '二次元'\n    TALENT = '才艺'\n    CUTE_PETS = '萌宠'\n    INDUSTRY_MACHINERY_CONSTRUCTION = '机械'\n    ANIMALS = '动物'\n    PARENTING = '育儿'\n    TECHNOLOGY = '科技'\n\nclass VideoZoneTypes(enum.Enum):\n    \"\"\"\n    所有分区枚举\n\n    - MAINPAGE: 主页\n    - ANIME: 番剧\n        - ANIME_SERIAL: 连载中番剧\n        - ANIME_FINISH: 已完结番剧\n        - ANIME_INFORMATION: 资讯\n        - ANIME_OFFICAL: 官方延伸\n    - MOVIE: 电影\n    - GUOCHUANG: 国创\n        - GUOCHUANG_CHINESE: 国产动画\n        - GUOCHUANG_ORIGINAL: 国产原创相关\n        - GUOCHUANG_PUPPETRY: 布袋戏\n        - GUOCHUANG_MOTIONCOMIC: 动态漫·广播剧\n        - GUOCHUANG_INFORMATION: 资讯\n    - TELEPLAY: 电视剧\n    - DOCUMENTARY: 纪录片\n    - DOUGA: 动画\n        - DOUGA_MAD: MAD·AMV\n        - DOUGA_MMD: MMD·3D\n        - DOUGA_VOICE: 短片·手书·配音\n        - DOUGA_GARAGE_KIT: 手办·模玩\n        - DOUGA_TOKUSATSU: 特摄\n        - DOUGA_ACGNTALKS: 动漫杂谈\n        - DOUGA_OTHER: 综合\n    - GAME: 游戏\n        - GAME_STAND_ALONE: 单机游戏\n        - GAME_ESPORTS: 电子竞技\n        - GAME_MOBILE: 手机游戏\n        - GAME_ONLINE: 网络游戏\n        - GAME_BOARD: 桌游棋牌\n        - GAME_GMV: GMV\n        - GAME_MUSIC: 音游\n        - GAME_MUGEN: Mugen\n    - KICHIKU: 鬼畜\n        - KICHIKU_GUIDE: 鬼畜调教\n        - KICHIKU_MAD: 音MAD\n        - KICHIKU_MANUAL_VOCALOID: 人力VOCALOID\n        - KICHIKU_THEATRE: 鬼畜剧场\n        - KICHIKU_COURSE: 教程演示\n    - MUSIC: 音乐\n        - MUSIC_ORIGINAL: 原创音乐\n        - MUSIC_COVER: 翻唱\n        - MUSIC_PERFORM: 演奏\n        - MUSIC_VOCALOID: VOCALOID·UTAU\n        - MUSIC_LIVE: 音乐现场\n        - MUSIC_MV: MV\n        - MUSIC_COMMENTARY: 乐评盘点\n        - MUSIC_TUTORIAL: 音乐教学\n        - MUSIC_OTHER: 音乐综合\n    - DANCE: 舞蹈\n        - DANCE_OTAKU: 宅舞\n        - DANCE_HIPHOP: 街舞\n        - DANCE_STAR: 明星舞蹈\n        - DANCE_CHINA: 中国舞\n        - DANCE_THREE_D: 舞蹈综合\n        - DANCE_DEMO: 舞蹈教程\n    - CINEPHILE: 影视\n        - CINEPHILE_CINECISM: 影视杂谈\n        - CINEPHILE_MONTAGE: 影视剪辑\n        - CINEPHILE_SHORTFILM: 小剧场\n        - CINEPHILE_TRAILER_INFO: 预告·资讯\n    - ENT: 娱乐\n        - ENT_VARIETY: 综艺\n        - ENT_TALKER: 娱乐杂谈\n        - ENT_FANS: 粉丝创作\n        - ENT_CELEBRITY: 明星综合\n    - KNOWLEDGE: 知识\n        - KNOWLEDGE_SCIENCE: 科学科普\n        - KNOWLEDGE_SOCIAL_SCIENCE: 社科·法律·心理\n        - KNOWLEDGE_HUMANITY_HISTORY: 人文历史\n        - KNOWLEDGE_BUSINESS: 财经商业\n        - KNOWLEDGE_CAMPUS: 校园学习\n        - KNOWLEDGE_CAREER: 职业职场\n        - KNOWLEDGE_DESIGN: 设计·创意\n        - KNOWLEDGE_SKILL: 野生技能协会\n    - TECH: 科技\n        - TECH_DIGITAL: 数码\n        - TECH_APPLICATION: 软件应用\n        - TECH_COMPUTER_TECH: 计算机技术\n        - TECH_INDUSTRY: 科工机械\n    - INFORMATION: 资讯\n        - INFORMATION_HOTSPOT: 热点\n        - INFORMATION_GLOBAL: 环球\n        - INFORMATION_SOCIAL: 社会\n        - INFORMATION_MULTIPLE: 综合\n    - FOOD: 美食\n        - FOOD_MAKE: 美食制作\n        - FOOD_DETECTIVE: 美食侦探\n        - FOOD_MEASUREMENT: 美食测评\n        - FOOD_RURAL: 田园美食\n        - FOOD_RECORD: 美食记录\n    - LIFE: 生活\n        - LIFE_FUNNY: 搞笑\n        - LIFE_TRAVEL: 出行\n        - LIFE_RURALLIFE: 三农\n        - LIFE_HOME: 家居房产\n        - LIFE_HANDMAKE: 手工\n        - LIFE_PAINTING: 绘画\n        - LIFE_DAILY: 日常\n    - CAR: 汽车\n        - CAR_RACING: 赛车\n        - CAR_MODIFIEDVEHICLE: 改装玩车\n        - CAR_NEWENERGYVEHICLE: 新能源车\n        - CAR_TOURINGCAR: 房车\n        - CAR_MOTORCYCLE: 摩托车\n        - CAR_STRATEGY: 购车攻略\n        - CAR_LIFE: 汽车生活\n    - FASHION: 时尚\n        - FASHION_MAKEUP: 美妆护肤\n        - FASHION_COS: 仿妆cos\n        - FASHION_CLOTHING: 穿搭\n        - FASHION_TREND: 时尚潮流\n    - SPORTS: 运动\n        - SPORTS_BASKETBALL: 篮球\n        - SPORTS_FOOTBALL: 足球\n        - SPORTS_AEROBICS: 健身\n        - SPORTS_ATHLETIC: 竞技体育\n        - SPORTS_CULTURE: 运动文化\n        - SPORTS_COMPREHENSIVE: 运动综合\n    - ANIMAL: 动物圈\n        - ANIMAL_CAT: 喵星人\n        - ANIMAL_DOG: 汪星人\n        - ANIMAL_PANDA: 大熊猫\n        - ANIMAL_WILD_ANIMAL: 野生动物\n        - ANIMAL_REPTILES: 爬宠\n        - ANIMAL_COMPOSITE: 动物综合\n    - VLOG: VLOG\n    \"\"\"\n\n    MAINPAGE = 0\n\n    ANIME = 13\n    ANIME_SERIAL = 33\n    ANIME_FINISH = 32\n    ANIME_INFORMATION = 51\n    ANIME_OFFICAL = 152\n\n    MOVIE = 23\n\n    GUOCHUANG = 167\n    GUOCHUANG_CHINESE = 153\n    GUOCHUANG_ORIGINAL = 168\n    GUOCHUANG_PUPPETRY = 169\n    GUOCHUANG_MOTIONCOMIC = 195\n    GUOCHUANG_INFORMATION = 170\n\n    TELEPLAY = 11\n\n    DOCUMENTARY = 177\n\n    DOUGA = 1\n    DOUGA_MAD = 24\n    DOUGA_MMD = 25\n    DOUGA_VOICE = 47\n    DOUGA_GARAGE_KIT = 210\n    DOUGA_TOKUSATSU = 86\n    DOUGA_ACGNTALKS = 253\n    DOUGA_OTHER = 27\n\n    GAME = 4\n    GAME_STAND_ALONE = 17\n    GAME_ESPORTS = 171\n    GAME_MOBILE = 172\n    GAME_ONLINE = 65\n    GAME_BOARD = 173\n    GAME_GMV = 121\n    GAME_MUSIC = 136\n    GAME_MUGEN = 19\n\n    KICHIKU = 119\n    KICHIKU_GUIDE = 22\n    KICHIKU_MAD = 26\n    KICHIKU_MANUAL_VOCALOID = 126\n    KICHIKU_THEATRE = 216\n    KICHIKU_COURSE = 127\n\n    MUSIC = 3\n    MUSIC_ORIGINAL = 28\n    MUSIC_COVER = 31\n    MUSIC_PERFORM = 59\n    MUSIC_VOCALOID = 30\n    MUSIC_LIVE = 29\n    MUSIC_MV = 193\n    MUSIC_COMMENTARY = 243\n    MUSIC_TUTORIAL = 244\n    MUSIC_OTHER = 130\n\n    DANCE = 129\n    DANCE_OTAKU = 20\n    DANCE_HIPHOP = 198\n    DANCE_STAR = 199\n    DANCE_CHINA = 200\n    DANCE_THREE_D = 154\n    DANCE_DEMO = 156\n\n    CINEPHILE = 181\n    CINEPHILE_CINECISM = 182\n    CINEPHILE_MONTAGE = 183\n    CINEPHILE_SHORTFILM = 85\n    CINEPHILE_TRAILER_INFO = 184\n\n    ENT = 5\n    ENT_VARIETY = 71\n    ENT_TALKER = 241\n    ENT_FANS = 242\n    ENT_CELEBRITY = 137\n\n    KNOWLEDGE = 36\n    KNOWLEDGE_SCIENCE = 201\n    KNOWLEDGE_SOCIAL_SCIENCE = 124\n    KNOWLEDGE_HUMANITY_HISTORY = 228\n    KNOWLEDGE_BUSINESS = 207\n    KNOWLEDGE_CAMPUS = 208\n    KNOWLEDGE_CAREER = 209\n    KNOWLEDGE_DESIGN = 229\n    KNOWLEDGE_SKILL = 122\n\n    TECH = 188\n    TECH_DIGITAL = 95\n    TECH_APPLICATION = 230\n    TECH_COMPUTER_TECH = 231\n    TECH_INDUSTRY = 232\n\n    INFORMATION = 202\n    INFORMATION_HOTSPOT = 203\n    INFORMATION_GLOBAL = 204\n    INFORMATION_SOCIAL = 205\n    INFORMATION_MULTIPLE = 206\n\n    FOOD = 211\n    FOOD_MAKE = 76\n    FOOD_DETECTIVE = 212\n    FOOD_MEASUREMENT = 213\n    FOOD_RURAL = 214\n    FOOD_RECORD = 215\n\n    LIFE = 160\n    LIFE_FUNNY = 138\n    LIFE_TRAVEL = 250\n    LIFE_RURALLIFE = 251\n    LIFE_HOME = 239\n    LIFE_HANDMAKE = 161\n    LIFE_PAINTING = 162\n    LIFE_DAILY = 21\n\n    CAR = 223\n    CAR_RACING = 245\n    CAR_MODIFIEDVEHICLE = 246\n    CAR_NEWENERGYVEHICLE = 247\n    CAR_TOURINGCAR = 248\n    CAR_MOTORCYCLE = 240\n    CAR_STRATEGY = 227\n    CAR_LIFE = 176\n\n    FASHION = 155\n    FASHION_MAKEUP = 157\n    FASHION_COS = 252\n    FASHION_CLOTHING = 158\n    FASHION_TREND = 159\n\n    SPORTS = 234\n    SPORTS_BASKETBALL = 235\n    SPORTS_FOOTBALL = 249\n    SPORTS_AEROBICS = 164\n    SPORTS_ATHLETIC = 236\n    SPORTS_CULTURE = 237\n    SPORTS_COMPREHENSIVE = 238\n\n    ANIMAL = 217\n    ANIMAL_CAT = 218\n    ANIMAL_DOG = 219\n    ANIMAL_PANDA = 220\n    ANIMAL_WILD_ANIMAL = 221\n    ANIMAL_REPTILES = 222\n    ANIMAL_COMPOSITE = 75\n\n    VLOG = 19\n"
  },
  {
    "path": "utils/files_times.py",
    "content": "from datetime import timedelta\n\nfrom datetime import datetime\nfrom pathlib import Path\n\nfrom conf import BASE_DIR\n\n\ndef get_absolute_path(relative_path: str, base_dir: str = None) -> str:\n    # Convert the relative path to an absolute path\n    absolute_path = Path(BASE_DIR) / base_dir / relative_path\n    return str(absolute_path)\n\n\ndef get_title_and_hashtags(filename):\n    \"\"\"\n  获取视频标题和 hashtag\n\n  Args:\n    filename: 视频文件名\n\n  Returns:\n    视频标题和 hashtag 列表\n  \"\"\"\n\n    # 获取视频标题和 hashtag txt 文件名\n    txt_filename = filename.replace(\".mp4\", \".txt\")\n\n    # 读取 txt 文件\n    with open(txt_filename, \"r\", encoding=\"utf-8\") as f:\n        content = f.read()\n\n    # 获取标题和 hashtag\n    splite_str = content.strip().split(\"\\n\")\n    title = splite_str[0]\n    hashtags = splite_str[1].replace(\"#\", \"\").split(\" \")\n\n    return title, hashtags\n\n\ndef generate_schedule_time_next_day(total_videos, videos_per_day = 1, daily_times=None, timestamps=False, start_days=0):\n    \"\"\"\n    Generate a schedule for video uploads, starting from the next day.\n\n    Args:\n    - total_videos: Total number of videos to be uploaded.\n    - videos_per_day: Number of videos to be uploaded each day.\n    - daily_times: Optional list of specific times of the day to publish the videos.\n    - timestamps: Boolean to decide whether to return timestamps or datetime objects.\n    - start_days: Start from after start_days.\n\n    Returns:\n    - A list of scheduling times for the videos, either as timestamps or datetime objects.\n    \"\"\"\n    if videos_per_day <= 0:\n        raise ValueError(\"videos_per_day should be a positive integer\")\n\n    if daily_times is None:\n        # Default times to publish videos if not provided\n        daily_times = [6, 11, 14, 16, 22]\n\n    if videos_per_day > len(daily_times):\n        raise ValueError(\"videos_per_day should not exceed the length of daily_times\")\n\n    # Generate timestamps\n    schedule = []\n    current_time = datetime.now()\n\n    for video in range(total_videos):\n        day = video // videos_per_day + start_days + 1  # +1 to start from the next day\n        daily_video_index = video % videos_per_day\n\n        # Calculate the time for the current video\n        hour = daily_times[daily_video_index]\n        time_offset = timedelta(days=day, hours=hour - current_time.hour, minutes=-current_time.minute,\n                                seconds=-current_time.second, microseconds=-current_time.microsecond)\n        timestamp = current_time + time_offset\n\n        schedule.append(timestamp)\n\n    if timestamps:\n        schedule = [int(time.timestamp()) for time in schedule]\n    return schedule\n"
  },
  {
    "path": "utils/log.py",
    "content": "from pathlib import Path\nfrom sys import stdout\nfrom loguru import logger\n\nfrom conf import BASE_DIR\n\n\ndef log_formatter(record: dict) -> str:\n    \"\"\"\n    Formatter for log records.\n    :param dict record: Log object containing log metadata & message.\n    :returns: str\n    \"\"\"\n    colors = {\n        \"TRACE\": \"#cfe2f3\",\n        \"INFO\": \"#9cbfdd\",\n        \"DEBUG\": \"#8598ea\",\n        \"WARNING\": \"#dcad5a\",\n        \"SUCCESS\": \"#3dd08d\",\n        \"ERROR\": \"#ae2c2c\"\n    }\n    color = colors.get(record[\"level\"].name, \"#b3cfe7\")\n    return f\"<fg #70acde>{{time:YYYY-MM-DD HH:mm:ss}}</fg #70acde> | <fg {color}>{{level}}</fg {color}>: <light-white>{{message}}</light-white>\\n\"\n\n\ndef create_logger(log_name: str, file_path: str):\n    \"\"\"\n    Create custom logger for different business modules.\n    :param str log_name: name of log\n    :param str file_path: Optional path to log file\n    :returns: Configured logger\n    \"\"\"\n    def filter_record(record):\n        return record[\"extra\"].get(\"business_name\") == log_name\n\n    Path(BASE_DIR / file_path).parent.mkdir(exist_ok=True)\n    logger.add(Path(BASE_DIR / file_path), filter=filter_record, level=\"INFO\", rotation=\"10 MB\", retention=\"10 days\", backtrace=True, diagnose=True)\n    return logger.bind(business_name=log_name)\n\n\n# Remove all existing handlers\nlogger.remove()\n# Add a standard console handler\nlogger.add(stdout, colorize=True, format=log_formatter)\n\ndouyin_logger = create_logger('douyin', 'logs/douyin.log')\ntencent_logger = create_logger('tencent', 'logs/tencent.log')\nxhs_logger = create_logger('xhs', 'logs/xhs.log')\ntiktok_logger = create_logger('tiktok', 'logs/tiktok.log')\nbilibili_logger = create_logger('bilibili', 'logs/bilibili.log')\nkuaishou_logger = create_logger('kuaishou', 'logs/kuaishou.log')\nbaijiahao_logger = create_logger('baijiahao', 'logs/baijiahao.log')\nxiaohongshu_logger = create_logger('xiaohongshu', 'logs/xiaohongshu.log')\n"
  },
  {
    "path": "utils/network.py",
    "content": "import asyncio\nimport time\nfrom functools import wraps\n\n\ndef async_retry(timeout=60, max_retries=None):\n    def decorator(func):\n        @wraps(func)\n        async def wrapper(*args, **kwargs):\n            start_time = time.time()\n            attempts = 0\n            while True:\n                try:\n                    return await func(*args, **kwargs)\n                except Exception as e:\n                    attempts += 1\n                    if max_retries is not None and attempts >= max_retries:\n                        print(f\"Reached maximum retries of {max_retries}.\")\n                        raise Exception(f\"Failed after {max_retries} retries.\") from e\n                    if time.time() - start_time > timeout:\n                        print(f\"Function timeout after {timeout} seconds.\")\n                        raise TimeoutError(f\"Function execution exceeded {timeout} seconds timeout.\") from e\n                    print(f\"Attempt {attempts} failed: {e}. Retrying...\")\n                    await asyncio.sleep(1)  # Sleep to avoid tight loop or provide backoff logic here\n\n        return wrapper\n\n    return decorator"
  },
  {
    "path": "videos/demo.txt",
    "content": "男子为了心爱之人每天坚守❤️‍🩹\n#坚持不懈 #爱情执着 #奋斗使者 #短视频"
  }
]