Skill: revolgmphl/suno-skill (version 1.0.1)

--- name: suno description: Suno AI 音乐创作助手 — 自动登录、创建歌曲、下载音频。当用户要求生成音乐、写歌、创作歌曲、用 Suno 生成 AI 音乐时使用。支持自定义歌词、音乐风格、自动解决 hCaptcha 验证码。 metadata: {"openclaw": {"emoji": "🎵", "requires": {"bins": ["google-chrome"]}}} --- # 🎵 Suno AI 音乐创作助手 两大核心能力：**账号登录**（通过 Google OAuth）和 **歌曲创作**（自定义歌词+风格+下载）。 --- ## 零、前置检查 每次操作前必须先执行环境检查： ```bash bash {baseDir}/suno/check_env.sh ``` 返回码：`0` = 正常已登录 → 可直接创建歌曲；`1` = 缺少依赖 → 安装依赖；`2` = 未登录 → 登录流程。 --- ## 一、登录流程 **⚠️ 重要：不要在 skill 代码中硬编码账号密码！必须先询问用户的 Gmail 邮箱和密码。** ### 1.1 询问用户凭据 当需要登录时，**必须先向用户询问**： > 需要登录 Suno.com（通过 Google 账号）。请提供： > 1. **Gmail 邮箱地址** > 2. **Gmail 密码** > > ⚠️ 你的凭据仅用于本次登录，不会被存储或传输到任何第三方。 ### 1.2 执行登录 用户提供邮箱和密码后： ```bash cd {baseDir}/suno python3 suno_login.py --email "<用户邮箱>" --password "<用户密码>" ``` **登录模式说明**： - **macOS/有GUI环境**：会弹出 Chrome 窗口，自动完成 Google 登录 - **Linux 云服务器**：自动使用 Xvfb 虚拟显示（需 `apt install xvfb && pip install PyVirtualDisplay`） - **首次登录必须使用 GUI 模式**（默认行为），后续检查状态可以 headless ### 1.3 检查登录状态 ```bash cd {baseDir}/suno python3 suno_login.py --check-only ``` 退出码 `0` = 已登录，`2` = 未登录。 ### 1.4 强制重新登录 ```bash cd {baseDir}/suno python3 suno_login.py --email "<邮箱>" --password "<密码>" --force-login ``` --- ## 二、创建歌曲 ### 2.1 前置条件 1. 已完成登录（`suno_login.py --check-only` 返回 0） 2. 需要 **Gemini API Key**（用于自动解决 hCaptcha 验证码） ### 2.2 获取 Gemini API Key 如果用户没有 Gemini API Key，引导用户获取： > 创建歌曲时 Suno 会弹出验证码，需要 Gemini API Key 来自动解决。 > 1. 访问 https://aistudio.google.com/app/apikey > 2. 点击 "Create API key" > 3. 复制生成的 Key 获取后保存到环境文件： ```bash mkdir -p ~/.suno echo "GEMINI_API_KEY=<用户的key>" > ~/.suno/.env ``` 或通过环境变量： ```bash export GEMINI_API_KEY="<用户的key>" ``` ### 2.3 hCaptcha 兼容补丁 首次使用前需运行一次（Suno 使用自定义 hCaptcha 域名，需打补丁）： ```bash cd {baseDir}/suno python3 patch_hcaptcha.py ``` ### 2.4 创建歌曲命令 ```bash cd {baseDir}/suno python3 suno_create_song.py \ --lyrics "<歌词内容>" \ --style "<音乐风格标签>" \ --title "<歌曲标题>" \ --output-dir "<下载目录>" ``` 也可以从文件读取歌词： ```bash cd {baseDir}/suno python3 suno_create_song.py \ --lyrics-file "<歌词文件路径>" \ --style "<音乐风格标签>" \ --title "<歌曲标题>" ``` ### 2.5 参数说明 | 参数 | 说明 | 必填 | 默认值 | |------|------|:---:|--------| | `--lyrics` | 歌词内容（与 `--lyrics-file` 二选一） | ✅ | - | | `--lyrics-file` | 歌词文件路径（与 `--lyrics` 二选一） | ✅ | - | | `--style` | 音乐风格标签（英文，逗号分隔） | ❌ | `rock, electric guitar, energetic, male vocals` | | `--title` | 歌曲标题 | ❌ | `My Song` | | `--output-dir` | MP3 下载目录 | ❌ | `{baseDir}/output_mp3` | | `--gemini-key` | Gemini API Key（也可通过环境变量或 ~/.suno/.env） | ❌ | 自动读取 | ### 2.6 音乐风格标签参考 常用风格标签（英文，可自由组合）： - **流派**: rock, pop, jazz, blues, electronic, hip-hop, R&B, classical, folk, metal, country, reggae, latin, indie - **乐器**: electric guitar, acoustic guitar, piano, synthesizer, drums, bass, violin, saxophone, trumpet - **情绪**: energetic, emotional, melancholic, upbeat, dark, dreamy, aggressive, peaceful, romantic - **人声**: male vocals, female vocals, choir, rap, whisper, powerful vocals, falsetto - **语言**: chinese, japanese, korean, english, spanish - **其他**: fast tempo, slow tempo, instrumental, lo-fi, cinematic, epic **示例**： - 摇滚: `rock, electric guitar, energetic, male vocals, chinese` - 抒情: `pop, piano, emotional, female vocals, slow tempo, chinese` - 电子: `electronic, synthesizer, upbeat, fast tempo, dance` - 说唱: `hip-hop, rap, bass, drums, energetic, chinese` --- ## 三、完整使用示例 ### 示例 1：创建中文摇滚歌曲 ```bash # 1. 检查环境 bash {baseDir}/suno/check_env.sh # 2. 如果未登录，先登录（需要用户提供邮箱密码） cd {baseDir}/suno python3 suno_login.py --email "[REDACTED_SECRET]" --password "password123" # 3. 确保 hCaptcha 补丁已应用 python3 patch_hcaptcha.py # 4. 创建歌曲 python3 suno_create_song.py \ --lyrics "窗外的麻雀 在电线杆上多嘴 你说这一句 很有夏天的感觉 手中的铅笔 在纸上来来回回 我用几行字形容你是我的谁" \ --style "rock, electric guitar, energetic, male vocals, chinese" \ --title "七里香摇滚版" ``` ### 示例 2：从文件读取歌词 ```bash cd {baseDir}/suno python3 suno_create_song.py \ --lyrics-file /path/to/lyrics.txt \ --style "pop, piano, emotional, female vocals, chinese" \ --title "我的歌" ``` --- ## 四、安装依赖（仅首次） ### macOS ```bash # 安装 Python 依赖 cd {baseDir}/suno pip3 install -r requirements.txt playwright install # 确保已安装 Google Chrome # 下载地址: https://www.google.com/chrome/ ``` ### Linux 云服务器 ```bash # 系统依赖 sudo apt update && sudo apt install -y xvfb google-chrome-stable fonts-noto-cjk # Python 依赖 cd {baseDir}/suno pip3 install -r requirements.txt pip3 install PyVirtualDisplay playwright install ``` --- ## 五、技术原理 ### 登录方案 - 使用 Playwright + 真实 Chrome 浏览器 (`channel='chrome'`) - `persistent context` 保持浏览器状态（cookies、localStorage） - `headless=False`（GUI 模式）通过 Google 反自动化检测 - Linux 服务器使用 Xvfb 虚拟显示支持 GUI 模式 - 首次登录后 persistent context 自动保持会话 ### 歌曲创建方案 - 浏览器自动化操作 suno.com/create 页面 - hcaptcha-challenger + Gemini API 自动解决 hCaptcha 验证码 - 通过拦截浏览器网络响应捕获新生成的 clip ID - 通过 Suno 内部 API (`studio-api.prod.suno.com`) 轮询歌曲生成状态 - 生成完成后自动下载 MP3 文件 ### 文件结构 ``` suno/ ├── suno_login.py # 登录工具（通过 Google OAuth） ├── suno_create_song.py # 歌曲创建+下载工具 ├── patch_hcaptcha.py # hCaptcha 域名兼容补丁 ├── check_env.sh # 环境检查脚本 ├── requirements.txt # Python 依赖 └── qilixiang_lyrics.txt # 示例歌词（七里香） ``` --- ## 六、注意事项 1. **不要硬编码账号密码** — 每次都需要询问用户 2. Suno 免费账号每天有积分限制，每首歌消耗约 100 积分 3. 歌曲生成通常需要 1-3 分钟 4. 每次创建会生成 2 首不同版本的歌曲 5. 如果遇到 Google 登录被拒（rejected），等待 10-30 分钟后重试 6. Gemini API 免费额度：每分钟 15 次请求，每天 1500 次 7. `headless=True` 模式会被 Google 检测拦截，**登录必须使用 GUI 模式** 8. hCaptcha 可能需要多次尝试，成功率取决于 Gemini 模型的图片识别能力 ## 七、故障排查 ```bash # 检查环境 bash {baseDir}/suno/check_env.sh # 查看登录截图 ls -la /tmp/suno_debug_*.png # 检查 persistent context ls -la ~/.suno/chrome_gui_profile/ # 查看 cookies python3 -c "import json; d=json.load(open('$HOME/.suno/cookies.json')); print(f'{len(d)} cookies')" # 查看 Gemini API Key cat ~/.suno/.env # 查看下载的歌曲 ls -la {baseDir}/output_mp3/ ```

path: suno_create_song.py
content: #!/usr/bin/env python3
"""
Suno 歌曲创建工具 - 使用 hcaptcha-challenger 自动解决验证码

核心流程:
1. 使用已登录的 persistent context 打开 suno.com/create
2. 切换到 Custom 模式，填写歌词/风格/标题
3. 点击 Create → 触发 hCaptcha
4. 使用 hcaptcha-challenger + Gemini API 自动解决 hCaptcha
5. 通过 API 轮询歌曲状态并下载

前置条件:
- 已运行 suno_login.py 完成登录
- 需要 Gemini API Key: https://aistudio.google.com/app/apikey
- pip install hcaptcha-challenger playwright

用法:
    export GEMINI_API_KEY="your_key_here"
    python suno_create_song.py --lyrics "歌词" --style "rock" --title "歌名"
"""
import asyncio
import json
import os
import sys
import time
import re
import argparse
import requests
from playwright.async_api import async_playwright
from hcaptcha_challenger import AgentConfig, AgentV

USER_DATA_DIR = os.path.expanduser("~/.suno/chrome_gui_profile")

# ====== 确保 hcaptcha-challenger 支持 Suno 自定义 hCaptcha 域名 ======
# Suno 使用 hcaptcha-assets-prod.suno.com 而非标准 newassets.hcaptcha.com
# patch_hcaptcha.py 已修改源文件，这里做运行时双保险
try:
    from hcaptcha_challenger.agent.challenger import RoboticArm
    _orig_init = RoboticArm.__init__

    def _patched_init(self, *args, **kwargs):
        _orig_init(self, *args, **kwargs)
        # 替换 XPath 选择器为通用匹配（支持 checkbox-invisible 和 checkbox）
        self._checkbox_selector = "//iframe[contains(@src, '/captcha/v1/') and (contains(@src, 'frame=checkbox') or contains(@src, 'frame=checkbox-invisible'))]"
        self._challenge_selector = "//iframe[contains(@src, '/captcha/v1/') and contains(@src, 'frame=challenge')]"

    RoboticArm.__init__ = _patched_init

    print("   ✅ hCaptcha 域名兼容 patch 已应用", flush=True)
except Exception as e:
    print(f"   ⚠️ hCaptcha patch 跳过: {e}", flush=True)
# ====== Patch 结束 ======
DOWNLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "output_mp3")
SUNO_API_BASE = "https://studio-api.prod.suno.com"


def download_mp3(audio_url, title, clip_id, output_dir):
    """下载 MP3 文件"""
    os.makedirs(output_dir, exist_ok=True)
    safe_title = re.sub(r'[^\w\u4e00-\u9fff\-]', '_', title)
    filename = f"{safe_title}_{clip_id[:8]}.mp3"
    filepath = os.path.join(output_dir, filename)

    print(f"   📥 下载: {filename}", flush=True)
    resp = requests.get(audio_url, stream=True, timeout=120)
    resp.raise_for_status()
    with open(filepath, "wb") as f:
        for chunk in resp.iter_content(chunk_size=8192):
            f.write(chunk)
    size_mb = os.path.getsize(filepath) / 1024 / 1024
    print(f"   ✅ 已保存: {filepath} ({size_mb:.1f} MB)", flush=True)
    return filepath


async def create_song(lyrics: str, style: str, title: str, output_dir: str, gemini_key: str):
    """
    完整的歌曲创建流程（含 hCaptcha 自动解决）
    """
    os.makedirs(output_dir, exist_ok=True)

    # 配置 hcaptcha-challenger
    agent_config = AgentConfig(
        GEMINI_API_KEY=gemini_key,
        EXECUTION_TIMEOUT=180,  # 3 分钟超时
        RESPONSE_TIMEOUT=60,
        RETRY_ON_FAILURE=True,
    )

    async with async_playwright() as p:
        print("\n🚀 启动 Chrome (headless=False)...", flush=True)
        context = await p.chromium.launch_persistent_context(
            USER_DATA_DIR,
            channel="chrome",
            headless=False,
            viewport={"width": 1380, "height": 900},
            args=[
                "--disable-blink-features=AutomationControlled",
                "--no-sandbox",
            ],
            ignore_default_args=["--enable-automation"],
        )
        await context.add_init_script(
            "Object.defineProperty(navigator, 'webdriver', { get: () => undefined });"
        )
        page = context.pages[0] if context.pages else await context.new_page()

        # 记录新生成的 clip（只跟踪 generate API 的响应）
        new_clip_ids = []

        async def on_response(response):
            url = response.url
            method = response.request.method
            # 只关注 generate API 的 POST 响应
            if method == "POST" and "studio-api" in url and "generate" in url:
                try:
                    data = await response.json()
                    clips = data.get("clips", [])
                    if clips:
                        for c in clips:
                            cid = c.get("id")
                            if cid and cid not in new_clip_ids:
                                new_clip_ids.append(cid)
                        print(f"\n   📡 生成任务已提交！{len(clips)} 首歌曲", flush=True)
                        for c in clips:
                            print(f"      ID: {c.get('id')}, Status: {c.get('status')}", flush=True)
                except:
                    pass

        page.on("response", on_response)

        # ========== 步骤 1: 打开创建页面 ==========
        print("\n📌 步骤 1: 打开创建页面...", flush=True)
        await page.goto("https://suno.com/create", wait_until="domcontentloaded", timeout=30000)
        await page.wait_for_timeout(5000)

        if "sign-in" in page.url:
            print("❌ 未登录！请先运行 suno_login.py", flush=True)
            await context.close()
            return None

        print(f"   ✅ 已登录", flush=True)

        # ========== 步骤 2: 切换到 Custom 模式 ==========
        print("📌 步骤 2: 切换到 Custom 模式...", flush=True)
        try:
            await page.locator('button:has-text("Custom")').first.click(timeout=5000)
            await page.wait_for_timeout(1500)
            print("   ✅ 已切换", flush=True)
        except:
            print("   ℹ️ 可能已在 Custom 模式", flush=True)

        # ========== 步骤 3: 填写歌词 ==========
        print("📌 步骤 3: 填写歌词...", flush=True)
        try:
            lyrics_input = page.locator('textarea[placeholder*="Write some lyrics"]').first
            await lyrics_input.click()
            await page.wait_for_timeout(300)
            await lyrics_input.fill(lyrics)
            print(f"   ✅ 已填写歌词 ({len(lyrics)} 字)", flush=True)
        except Exception as e:
            print(f"   ❌ 填写歌词失败: {e}", flush=True)
            await context.close()
            return None

        # ========== 步骤 4: 填写风格标签 ==========
        print("📌 步骤 4: 填写风格标签...", flush=True)
        try:
            # 尝试多种选择器
            style_input = None
            for sel in ['textarea[placeholder*="touhou"]', 'textarea[placeholder*="Style"]']:
                try:
                    el = page.locator(sel).first
                    if await el.is_visible(timeout=2000):
                        style_input = el
                        break
                except:
                    continue
            if not style_input:
                textareas = page.locator("textarea")
                count = await textareas.count()
                if count >= 2:
                    style_input = textareas.nth(1)
            if style_input:
                await style_input.click()
                await page.wait_for_timeout(300)
                await style_input.fill("")
                await page.wait_for_timeout(200)
                await style_input.fill(style)
                print(f"   ✅ 已填写风格: {style}", flush=True)
        except Exception as e:
            print(f"   ⚠️ 填写风格失败: {e}", flush=True)

        # ========== 步骤 5: 填写标题 ==========
        print("📌 步骤 5: 填写标题...", flush=True)
        try:
            # 标题输入框可能被折叠/隐藏，先尝试展开
            try:
                toggle = page.locator('button:has-text("Title"), [data-testid*="title"]').first
                if await toggle.is_visible(timeout=2000):
                    await toggle.click()
                    await page.wait_for_timeout(500)
            except:
                pass
            title_input = page.locator('input[placeholder="Song Title (Optional)"]').first
            # 通过 JS 直接设置值（绕过 visibility 问题）
            await page.evaluate("""(title) => {
                const inputs = document.querySelectorAll('input[placeholder="Song Title (Optional)"]');
                if (inputs.length > 0) {
                    const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
                    nativeInputValueSetter.call(inputs[0], title);
                    inputs[0].dispatchEvent(new Event('input', { bubbles: true }));
                    inputs[0].dispatchEvent(new Event('change', { bubbles: true }));
                }
            }""", title)
            print(f"   ✅ 已填写标题: {title}", flush=True)
        except Exception as e:
            print(f"   ⚠️ 填写标题失败（非关键）: {e}", flush=True)

        await page.wait_for_timeout(1000)

        # ========== 步骤 6: 初始化 hCaptcha 解决器 ==========
        print("📌 步骤 6: 初始化 hCaptcha 解决器...", flush=True)
        agent = AgentV(page=page, agent_config=agent_config)
        print("   ✅ hcaptcha-challenger 已就绪", flush=True)

        # ========== 步骤 7: 点击 Create 按钮 ==========
        print("📌 步骤 7: 点击 Create...", flush=True)
        all_create_btns = page.locator("button").filter(has_text="Create")
        count = await all_create_btns.count()
        print(f"   找到 {count} 个 Create 按钮", flush=True)

        target_btn = None
        for idx in range(count):
            btn = all_create_btns.nth(idx)
            text = (await btn.text_content()).strip()
            box = await btn.bounding_box()
            if box:
                print(f"   [{idx}] '{text[:30]}' at x={box['x']:.0f}, y={box['y']:.0f}, w={box['width']:.0f}", flush=True)
                if box["width"] > 50 and box["y"] > 200:
                    target_btn = btn

        if target_btn:
            await target_btn.click()
            print("   ✅ 已点击 Create", flush=True)
        elif count > 0:
            await all_create_btns.last.click()
            print("   ✅ 已点击最后一个 Create", flush=True)
        else:
            print("   ❌ 没找到 Create 按钮", flush=True)
            await context.close()
            return None

        # ========== 步骤 8: 自动解决 hCaptcha ==========
        print("\n🔒 步骤 8: 等待并解决 hCaptcha...", flush=True)
        print("   （hcaptcha-challenger 将使用 Gemini API 识别图片）", flush=True)

        # 步骤 8a: 等待 hCaptcha checkbox iframe 出现
        print("   🔍 等待 hCaptcha checkbox 出现...", flush=True)
        checkbox_clicked = False
        for wait_i in range(15):  # 最多等 30 秒
            await page.wait_for_timeout(2000)
            # 检查是否有 hCaptcha checkbox iframe
            frames_info = await page.evaluate("""() => {
                return Array.from(document.querySelectorAll('iframe')).map(f => ({
                    src: f.src || '',
                    width: f.offsetWidth,
                    height: f.offsetHeight,
                    visible: f.offsetHeight > 0
                }));
            }""")
            captcha_frames = [f for f in frames_info if '/captcha/v1/' in f.get('src', '') and f.get('visible')]
            if captcha_frames:
                print(f"   ✅ [{(wait_i+1)*2}s] 发现 {len(captcha_frames)} 个 hCaptcha frame", flush=True)
                for cf in captcha_frames:
                    print(f"      {cf['src'][:80]} ({cf['width']}x{cf['height']})", flush=True)

                # 找到 checkbox iframe 并点击
                for frame in page.frames:
                    if '/captcha/v1/' in frame.url and 'frame=checkbox' in frame.url:
                        try:
                            checkbox = frame.locator('#checkbox')
                            if await checkbox.is_visible(timeout=3000):
                                await checkbox.click()
                                checkbox_clicked = True
                                print("   ✅ 已点击 hCaptcha checkbox", flush=True)
                                await page.wait_for_timeout(3000)
                                break
                        except Exception as e:
                            print(f"   ⚠️ 点击 checkbox 失败: {e}", flush=True)
                break
            else:
                # 可能 hCaptcha 不需要（某些情况下 Suno 不弹验证码）
                if new_clip_ids:
                    print(f"   ✅ 无需验证码，generate API 已返回", flush=True)
                    break
                print(f"   ⏳ [{(wait_i+1)*2}s] 等待 hCaptcha...", flush=True)

        if not checkbox_clicked and not new_clip_ids:
            print("   ⚠️ 未检测到 hCaptcha checkbox，尝试继续...", flush=True)
            # 截图诊断
            await page.screenshot(path="/tmp/suno_no_captcha.png")

        # 步骤 8b: 使用 hcaptcha-challenger 解决图片验证
        if checkbox_clicked:
            try:
                signal = await agent.wait_for_challenge()
                print(f"   🔒 hCaptcha 结果: {signal}", flush=True)
                if "SUCCESS" in str(signal):
                    print("   ✅ hCaptcha 已解决！", flush=True)
                else:
                    print(f"   ⚠️ hCaptcha 结果: {signal}（可能需要重试）", flush=True)
            except Exception as e:
                print(f"   ⚠️ hCaptcha 处理异常: {e}", flush=True)
                print("   ℹ️ 继续等待，可能验证码已自动通过...", flush=True)
        elif not new_clip_ids:
            # 没有 checkbox 也没有 clip，尝试直接调用 wait_for_challenge
            try:
                signal = await agent.wait_for_challenge()
                print(f"   🔒 hCaptcha 结果: {signal}", flush=True)
            except Exception as e:
                print(f"   ⚠️ {e}", flush=True)

        # ========== 步骤 9: 等待歌曲生成 ==========
        print("\n⏳ 步骤 9: 等待歌曲生成任务提交...", flush=True)

        # 如果 hCaptcha 通过后 generate API 还没被调用，等一会
        for i in range(12):
            await page.wait_for_timeout(5000)
            elapsed = (i + 1) * 5
            if new_clip_ids:
                print(f"   ✅ [{elapsed}s] 捕获到 {len(new_clip_ids)} 个新 clip!", flush=True)
                break
            print(f"   ⏳ [{elapsed}s] 等待 generate API 响应...", flush=True)

        if not new_clip_ids:
            print("   ❌ 未捕获到新的 clip（generate API 可能未被调用）", flush=True)
            await page.screenshot(path="/tmp/suno_no_new_clips.png")
            await context.close()
            return None

        # ========== 步骤 10: 通过 API 轮询歌曲状态 ==========
        print(f"\n📡 步骤 10: 轮询 clip 状态: {new_clip_ids}", flush=True)

        # 获取 token
        token = await page.evaluate("""async () => {
            if (window.Clerk && window.Clerk.session) {
                return await window.Clerk.session.getToken();
            }
            return null;
        }""")

        if not [REDACTED_SECRET]"   ⚠️ 无法获取 token", flush=True)
            await context.close()
            return None

        headers = {
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "Referer": "https://suno.com/",
            "Origin": "https://suno.com",
        }

        completed = {}
        for attempt in range(36):  # 最多等 3 分钟
            await page.wait_for_timeout(5000)
            elapsed = (attempt + 1) * 5

            # 每 60 秒刷新 token
            if elapsed % 60 == 0:
                new_token = await page.evaluate("""async () => {
                    if (window.Clerk && window.Clerk.session) {
                        return await window.Clerk.session.getToken();
                    }
                    return null;
                }""")
                if new_token:
                    [REDACTED_SECRET]
                    headers["Authorization"] = f"Bearer {token}"

            ids_str = ",".join(new_clip_ids)
            try:
                resp = requests.get(
                    f"{SUNO_API_BASE}/api/feed/?ids={ids_str}",
                    headers=headers,
                    timeout=15,
                )
                if resp.status_code == 200:
                    data = resp.json()
                    items = data if isinstance(data, list) else [data]
                    for item in items:
                        cid = item.get("id")
                        status = item.get("status", "unknown")
                        audio_url = item.get("audio_url", "")

                        if status == "complete" and audio_url and cid not in completed:
                            print(f"   ✅ [{elapsed}s] {cid}: 完成!", flush=True)
                            completed[cid] = item
                        elif status == "error":
                            print(f"   ❌ [{elapsed}s] {cid}: 生成失败", flush=True)
                            err = item.get("metadata", {}).get("error_message", "")
                            if err:
                                print(f"      错误: {err}", flush=True)
                            completed[cid] = item
                        elif cid not in completed:
                            print(f"   ⏳ [{elapsed}s] {cid}: {status}", flush=True)
            except Exception as e:
                print(f"   ⚠️ [{elapsed}s] 查询失败: {e}", flush=True)

            if len(completed) >= len(new_clip_ids):
                break

        # ========== 步骤 11: 下载 ==========
        downloaded = []
        if completed:
            print(f"\n📥 步骤 11: 下载歌曲...", flush=True)
            for cid, clip in completed.items():
                audio_url = clip.get("audio_url", "")
                if audio_url:
                    clip_title = clip.get("title") or title
                    try:
                        filepath = download_mp3(audio_url, clip_title, cid, output_dir)
                        downloaded.append(filepath)
                    except Exception as e:
                        print(f"   ❌ 下载失败: {e}", flush=True)

        await context.close()

        if downloaded:
            print(f"\n{'='*60}", flush=True)
            print(f"🎉 完成！已下载 {len(downloaded)} 首歌曲：", flush=True)
            for f in downloaded:
                print(f"   📁 {f}", flush=True)
            print(f"{'='*60}", flush=True)
        else:
            print("\n❌ 没有歌曲被下载", flush=True)

        return downloaded


def main():
    parser = argparse.ArgumentParser(description="Suno 歌曲创建工具（含 hCaptcha 自动解决）")
    parser.add_argument("--lyrics", type=str, help="歌词内容")
    parser.add_argument("--lyrics-file", type=str, help="歌词文件路径")
    parser.add_argument("--style", type=str, default="rock, electric guitar, energetic, male vocals",
                        help="音乐风格标签")
    parser.add_argument("--title", type=str, default="My Song", help="歌曲标题")
    parser.add_argument("--output-dir", type=str, default=DOWNLOAD_DIR, help="下载目录")
    parser.add_argument("--gemini-key", type=str, default=os.environ.get("GEMINI_API_KEY", ""),
                        help="Gemini API Key（或设置 GEMINI_API_KEY 环境变量）")
    args = parser.parse_args()

    # 读取歌词
    if args.lyrics_file:
        with open(args.lyrics_file, "r") as f:
            lyrics = f.read().strip()
    elif args.lyrics:
        lyrics = args.lyrics
    else:
        print("❌ 请提供 --lyrics 或 --lyrics-file", flush=True)
        sys.exit(1)

    # 检查 Gemini API Key
    gemini_key = args.gemini_key
    if not gemini_key:
        # 尝试从 ~/.suno/.env 读取
        env_file = os.path.expanduser("~/.suno/.env")
        if os.path.exists(env_file):
            with open(env_file) as f:
                for line in f:
                    if line.startswith("GEMINI_API_KEY="):
                        gemini_key = line.strip().split("=", 1)[1]
                        break
    if not gemini_key:
        print("❌ 未设置 Gemini API Key！hCaptcha 无法自动解决", flush=True)
        print("   设置方法 1: export GEMINI_API_KEY='your_key'", flush=True)
        print("   设置方法 2: echo 'GEMINI_API_KEY=your_key' > ~/.suno/.env", flush=True)
        print("   获取地址: https://aistudio.google.com/app/apikey", flush=True)
        sys.exit(1)

    print("=" * 60, flush=True)
    print("🎵 Suno 歌曲创建工具", flush=True)
    print(f"   标题: {args.title}", flush=True)
    print(f"   风格: {args.style}", flush=True)
    print(f"   歌词: {lyrics[:60]}{'...' if len(lyrics)>60 else ''}", flush=True)
    print(f"   Gemini Key: {'已设置' if gemini_key else '未设置'}", flush=True)
    print("=" * 60, flush=True)

    result = asyncio.run(create_song(lyrics, args.style, args.title, args.output_dir, gemini_key))
    sys.exit(0 if result else 1)


if __name__ == "__main__":
    main()


path: requirements.txt
content: playwright>=1.40.0
requests>=2.28.0
hcaptcha-challenger>=0.19.0


path: patch_hcaptcha.py
content: #!/usr/bin/env python3
"""修改 hcaptcha-challenger 源码以支持 Suno 自定义 hCaptcha 域名"""
import hcaptcha_challenger.agent.challenger as mod

CHALLENGER_FILE = mod.__file__
print(f"📂 文件: {CHALLENGER_FILE}")

with open(CHALLENGER_FILE, "r") as f:
    lines = f.readlines()

changes = 0
new_lines = []
for i, line in enumerate(lines):
    ln = i + 1
    new_line = line

    if ln == 303:
        # XPath: starts-with(@src,'https://newassets.hcaptcha.com/captcha/v1/') → contains(@src, '/captcha/v1/')
        new_line = line.replace(
            "starts-with(@src,'https://newassets.hcaptcha.com/captcha/v1/')",
            "contains(@src, '/captcha/v1/')"
        )
    elif ln == 304:
        new_line = line.replace(
            "starts-with(@src,'https://newassets.hcaptcha.com/captcha/v1/')",
            "contains(@src, '/captcha/v1/')"
        )
    elif ln == 330:
        # Python: frame.url.startswith("https://...") → ("/captcha/v1/" in frame.url)
        new_line = line.replace(
            'frame.url.startswith("https://newassets.hcaptcha.com/captcha/v1/")',
            '("/captcha/v1/" in frame.url)'
        )
    elif ln == 357:
        # Python: child_frame.url.startswith("https://...") → ("/captcha/v1/" in child_frame.url)
        new_line = line.replace(
            'child_frame.url.startswith("https://newassets.hcaptcha.com/captcha/v1/")',
            '("/captcha/v1/" in child_frame.url)'
        )

    if new_line != line:
        changes += 1
        print(f"   ✅ L{ln}: {line.strip()[:60]} → {new_line.strip()[:60]}")
    new_lines.append(new_line)

with open(CHALLENGER_FILE, "w") as f:
    f.writelines(new_lines)

print(f"\n共修改 {changes} 处")

# 验证
with open(CHALLENGER_FILE, "r") as f:
    content = f.read()
remaining = content.count("newassets.hcaptcha.com")
if remaining > 0:
    print(f"⚠️ 仍有 {remaining} 处 newassets.hcaptcha.com 引用")
else:
    print("✅ 所有 hcaptcha.com 硬编码已移除")


path: suno_login.py
content: #!/usr/bin/env python3
"""
Suno.com 自动登录脚本
使用 Playwright + 真实 Chrome 浏览器 + persistent context 实现 Suno 的全自动登录。

✅ 已验证成功方案:
    channel='chrome' + launch_persistent_context + headless=False + ignore_default_args
    通过 Google OAuth 完成登录，Google 不会拦截

⚠️ 重要发现:
    - headless=True 模式下 Google 会 reject（即使用了 stealth/nodriver 等反检测）
    - headless=False 模式下 Google 不会 reject（100% 成功率）
    - 首次登录必须使用 headless=False（GUI 模式）
    - 登录成功后 persistent context 会保留会话，后续可用 headless=True 检查状态

用法:
    # 首次登录（必须 GUI 模式，macOS 会弹窗，Linux 需要 Xvfb）
    python suno_login.py --email <Gmail邮箱> --password <Gmail密码>

    # 检查登录状态（headless 即可）
    python suno_login.py --check-only

    # 强制重新登录
    python suno_login.py --email <Gmail邮箱> --password <Gmail密码> --force-login

    # Linux 云服务器（自动使用 Xvfb 虚拟显示）
    python suno_login.py --email <Gmail邮箱> --password <Gmail密码>

前置条件:
    - 系统安装了 Google Chrome 浏览器
    - pip install playwright && playwright install
    - Linux 云服务器还需: apt install -y xvfb && pip install PyVirtualDisplay
"""

import argparse
import json
import os
import sys
import time
import platform
from pathlib import Path
from urllib.parse import urlparse

try:
    from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
except ImportError:
    print("❌ 缺少 playwright 库，请先安装：")
    print("   pip install playwright && playwright install")
    sys.exit(1)


# ========== 配置 ==========
DEFAULT_COOKIE_FILE = os.path.expanduser("~/.suno/cookies.json")
DEFAULT_USER_DATA_DIR = os.path.expanduser("~/.suno/chrome_gui_profile")
SUNO_HOME = "https://suno.com"
SUNO_SIGN_IN = "https://suno.com/sign-in"
SUNO_CREATE = "https://suno.com/create"
DEFAULT_TIMEOUT = 30000


def ensure_dir(filepath: str):
    """确保文件所在目录存在"""
    Path(filepath).parent.mkdir(parents=True, exist_ok=True)


def save_cookies(context, cookie_file: str):
    """保存浏览器 cookies 到本地 JSON 文件"""
    ensure_dir(cookie_file)
    cookies = context.cookies()
    with open(cookie_file, "w") as f:
        json.dump(cookies, f, indent=2)
    print(f"✅ Cookies 已保存到 {cookie_file}（共 {len(cookies)} 条）", flush=True)


def _is_headless_linux():
    """检测是否在无 GUI 的 Linux 环境"""
    if platform.system() != "Linux":
        return False
    return not os.environ.get("DISPLAY")


def _setup_virtual_display():
    """
    在 Linux 无 GUI 环境下创建虚拟显示（Xvfb）
    返回 display 对象（需要在结束时 stop）
    """
    try:
        from pyvirtualdisplay import Display
        display = Display(visible=0, size=(1280, 800))
        display.start()
        print("   ✅ Xvfb 虚拟显示已启动", flush=True)
        return display
    except ImportError:
        print("   ⚠️ 未安装 PyVirtualDisplay，尝试直接运行", flush=True)
        print("   💡 安装方法: apt install -y xvfb && pip install PyVirtualDisplay", flush=True)
        return None


def _launch_context(pw, user_data_dir: str, headless: bool = False):
    """
    启动 Chrome persistent context

    关键参数:
    - channel='chrome': 使用系统安装的真实 Chrome（非 Playwright 自带 Chromium）
    - launch_persistent_context: 保留浏览器状态（cookies/localStorage 等）
    - ignore_default_args: 移除 --enable-automation 标志
    - headless=False: 必须使用 GUI 模式，否则 Google 会 reject
    """
    os.makedirs(user_data_dir, exist_ok=True)

    context = pw.chromium.launch_persistent_context(
        user_data_dir,
        channel="chrome",
        headless=headless,
        viewport={"width": 1280, "height": 800},
        locale="en-US",
        timezone_id="America/New_York",
        args=[
            "--disable-blink-features=AutomationControlled",
            "--no-sandbox",
            "--disable-dev-shm-usage",
            "--disable-automation",
        ],
        ignore_default_args=["--enable-automation"],
    )

    # 注入反检测脚本
    context.add_init_script("""
        Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
        delete navigator.__proto__.webdriver;
        if (!window.chrome) window.chrome = {};
        if (!window.chrome.runtime) window.chrome.runtime = {};
    """)

    return context


def check_login_status(page) -> dict:
    """
    检查是否已登录 Suno，返回状态信息
    """
    result = {"logged_in": False, "username": None, "credits": None}
    try:
        page.goto(SUNO_SIGN_IN, wait_until="domcontentloaded", timeout=DEFAULT_TIMEOUT)
        page.wait_for_timeout(5000)

        url = page.url
        parsed = urlparse(url)
        # 只检查 URL 的 path 部分（忽略 query 参数中的 sign-in 字样）
        if "sign-in" not in parsed.path and "suno.com" in parsed.netloc:
            result["logged_in"] = True
            # 尝试获取用户信息
            try:
                body = page.locator("body").text_content()[:500]
                if "credits" in body:
                    parts = body.split("credits")[0].strip()
                    words = parts.split()
                    for w in reversed(words):
                        if w.isdigit():
                            result["credits"] = int(w)
                            break
                    for w in reversed(words):
                        if not w.isdigit() and len(w) > 1:
                            result["username"] = w
                            break
            except:
                pass
            return result

        return result
    except Exception as e:
        print(f"⚠️ 检查登录状态出错: {e}", flush=True)
        return result


def login_google_oauth(page, email: str, password: str) -> bool:
    """
    通过 Google OAuth 登录 Suno.com（全自动）

    流程:
    1. 打开 suno.com/sign-in
    2. 点击 "Continue with Google"
    3. 跳转到 Google → 输入邮箱 → Next
    4. 输入密码 → Next
    5. 跳转回 suno.com/create → 登录成功
    """
    print(f"🔐 开始通过 Google OAuth 登录 Suno.com", flush=True)
    print(f"   邮箱: {email}", flush=True)

    # 1. Warmup: 先访问 Google 首页建立正常浏览历史
    print("\n📌 步骤 1/6: 建立正常浏览历史...", flush=True)
    try:
        page.goto("https://www.google.com", wait_until="domcontentloaded", timeout=15000)
        page.wait_for_timeout(2000)
    except:
        pass

    # 2. 打开 Suno 登录页
    print("📌 步骤 2/6: 打开 Suno 登录页...", flush=True)
    page.goto(SUNO_SIGN_IN, wait_until="domcontentloaded", timeout=DEFAULT_TIMEOUT)
    page.wait_for_timeout(5000)
    print(f"   URL: {page.url}", flush=True)

    # 检查是否已登录
    parsed_url = urlparse(page.url)
    if "sign-in" not in parsed_url.path and "suno.com" in parsed_url.netloc:
        print("   ✅ 已登录（persistent context 中有有效会话）", flush=True)
        return True

    # 3. 点击 "Continue with Google"
    print("📌 步骤 3/6: 点击 'Continue with Google'...", flush=True)
    try:
        btn = page.locator('button:has-text("Continue with Google")').first
        btn.click(timeout=10000)
        print("   ✅ 已点击", flush=True)
    except Exception as e:
        print(f"   ❌ 未找到 Google 登录按钮: {e}", flush=True)
        page.screenshot(path="/tmp/suno_debug_no_google.png")
        return False

    # 4. 等待 Google OAuth 页面
    print("📌 步骤 4/6: 等待 Google 登录页面...", flush=True)
    try:
        page.wait_for_url("**/accounts.google.com/**", timeout=30000)
        print("   ✅ 已跳转到 Google", flush=True)
    except PlaywrightTimeout:
        print(f"   ❌ 未跳转到 Google: {page.url}", flush=True)
        page.screenshot(path="/tmp/suno_debug_no_redirect.png")
        return False

    page.wait_for_timeout(5000)

    if "rejected" in page.url:
        print("   ❌ Google 拒绝了登录！", flush=True)
        print("   💡 这通常发生在 headless=True 模式下。请用 --no-headless 模式重试", flush=True)
        page.screenshot(path="/tmp/suno_debug_rejected.png")
        return False

    page.screenshot(path="/tmp/suno_debug_google.png")

    # 5. 输入邮箱
    print("📌 步骤 5/6: 输入 Google 邮箱...", flush=True)
    try:
        email_input = page.locator('input[type="email"], input#identifierId').first
        email_input.wait_for(state="visible", timeout=10000)
        email_input.click()
        page.wait_for_timeout(500)
        for char in email:
            page.keyboard.type(char, delay=80 + (ord(char) % 70))
        print(f"   ✅ 已输入邮箱", flush=True)
    except Exception as e:
        print(f"   ❌ 输入邮箱失败: {e}", flush=True)
        page.screenshot(path="/tmp/suno_debug_email.png")
        return False

    page.wait_for_timeout(2000)
    try:
        page.locator('#identifierNext').first.click()
        print("   ✅ 点击 Next", flush=True)
    except:
        page.keyboard.press("Enter")

    page.wait_for_timeout(8000)
    page.screenshot(path="/tmp/suno_debug_after_email.png")

    if "rejected" in page.url:
        print("   ❌ 输入邮箱后被 Google 拒绝！", flush=True)
        page.screenshot(path="/tmp/suno_debug_rejected_email.png")
        return False

    # 6. 输入密码
    print("📌 步骤 6/6: 输入 Google 密码...", flush=True)
    try:
        pwd_input = page.locator('input[type="password"], input[name="Passwd"]').first
        pwd_input.wait_for(state="visible", timeout=15000)
        pwd_input.click()
        page.wait_for_timeout(600)
        for char in [REDACTED_SECRET], delay=60 + (ord(char) % 50))
        print("   ✅ 已输入密码", flush=True)
    except Exception as e:
        print(f"   ❌ 输入密码失败: {e}", flush=True)
        page.screenshot(path="/tmp/suno_debug_password.png")
        return False

    page.wait_for_timeout(2000)
    try:
        page.locator('#passwordNext').first.click()
        print("   ✅ 点击 Next", flush=True)
    except:
        page.keyboard.press("Enter")

    # 等待跳转回 Suno
    print("\n⏳ 等待登录完成...", flush=True)
    for i in range(30):
        page.wait_for_timeout(3000)
        url = page.url
        elapsed = (i + 1) * 3
        print(f"   [{elapsed}s] {url[:100]}", flush=True)

        parsed_u = urlparse(url)
        if "suno.com" in parsed_u.netloc and "sign-in" not in parsed_u.path:
            print(f"\n🎉 登录成功！已跳转到 Suno", flush=True)
            page.wait_for_timeout(3000)
            return True

        if "rejected" in url:
            print("   ❌ Google 拒绝了登录", flush=True)
            page.screenshot(path="/tmp/suno_debug_rejected_final.png")
            return False

        # Google 同意/授权页面
        if "consent" in url:
            try:
                page.locator('button:has-text("Allow"), button:has-text("Continue")').first.click()
                print("   ✅ 已授权", flush=True)
            except:
                pass

        # Google 安全验证
        if "challenge" in url and "pwd" not in url:
            page.screenshot(path="/tmp/suno_debug_challenge.png")
            try:
                body = page.locator("body").text_content()[:300]
                print(f"   ⚠️ Google 要求安全验证: {body[:150]}", flush=True)
            except:
                pass
            print("   💡 提示: 如果 Google 要求手机验证，请先在普通浏览器中登录 Google 并信任此设备", flush=True)

        # Google 选择账号页面
        if "accounts.google.com" in url and "chooser" in url:
            try:
                page.locator(f'[data-email="{email}"]').first.click()
                print(f"   ✅ 已选择账号 {email}", flush=True)
            except:
                pass

    print("\n⏰ 等待超时", flush=True)
    page.screenshot(path="/tmp/suno_debug_timeout.png")
    return False


def main():
    parser = argparse.ArgumentParser(
        description="Suno.com 全自动登录工具（通过 Google OAuth）",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
使用示例:
  # 首次登录（默认 GUI 模式，macOS 弹窗，Linux 用 Xvfb）
  python suno_login.py --email [REDACTED_SECRET] --password mypass

  # 检查登录状态（headless 即可）
  python suno_login.py --check-only

  # 强制重新登录
  python suno_login.py --email [REDACTED_SECRET] --password mypass --force-login

技术方案:
  ✅ Chrome channel + persistent context + headless=False
  ✅ Google OAuth 不会检测到自动化（100% 验证通过）
  ✅ Linux 服务器自动使用 Xvfb 虚拟显示
  ✅ 首次登录后 persistent context 保留会话，后续可 headless
"""
    )
    parser.add_argument("--email", help="Gmail 邮箱地址")
    parser.add_argument("--password", help="Gmail 密码")
    parser.add_argument("--headless", action="store_true",
                        help="强制使用 headless 模式（仅 check-only 或已登录时推荐）")
    parser.add_argument("--cookie-file", default=DEFAULT_COOKIE_FILE,
                        help=f"Cookie 保存路径（默认: {DEFAULT_COOKIE_FILE}）")
    parser.add_argument("--user-data-dir", default=DEFAULT_USER_DATA_DIR,
                        help=f"浏览器配置目录（默认: {DEFAULT_USER_DATA_DIR}）")
    parser.add_argument("--check-only", action="store_true",
                        help="仅检查登录状态（使用 headless 模式）")
    parser.add_argument("--force-login", action="store_true",
                        help="强制重新登录")

    args = parser.parse_args()

    # 确定 headless 模式
    # - check-only: 默认 headless（只是检查状态，不需要 GUI）
    # - 登录: 默认 headless=False（GUI 模式，避免被 Google reject）
    # - --headless: 强制 headless
    if args.check_only:
        headless = True
    elif args.headless:
        headless = True
    else:
        headless = False  # 登录时默认用 GUI 模式

    print("=" * 60, flush=True)
    print("🎵 Suno.com 全自动登录工具", flush=True)
    print("   方案: Chrome + persistent context + Google OAuth", flush=True)
    if headless:
        print("   模式: headless（无头）", flush=True)
    else:
        print("   模式: GUI（图形界面）", flush=True)
    print("=" * 60, flush=True)

    # 强制重新登录：只清除 cookie 文件，保留 persistent context
    # ⚠️ 不要删除 user_data_dir！Google 的 session 信息在里面
    if args.force_login:
        if os.path.exists(args.cookie_file):
            os.remove(args.cookie_file)
            print(f"🗑️ 已清除 cookies: {args.cookie_file}", flush=True)
        print("   ℹ️ 保留浏览器配置（含 Google session），避免被 Google 拦截", flush=True)

    # Linux 无 GUI 环境下启动虚拟显示
    virtual_display = None
    if not headless and _is_headless_linux():
        print("\n🖥️ 检测到 Linux 无 GUI 环境，启动 Xvfb 虚拟显示...", flush=True)
        virtual_display = _setup_virtual_display()

    try:
        with sync_playwright() as pw:
            print(f"\n🌐 启动 Chrome (headless={headless})...", flush=True)
            context = _launch_context(pw, args.user_data_dir, headless=headless)
            page = context.pages[0] if context.pages else context.new_page()

            # 检查模式
            if args.check_only:
                status = check_login_status(page)
                if status["logged_in"]:
                    print(f"\n✅ 已登录 Suno.com", flush=True)
                    if status["username"]:
                        print(f"   用户: {status['username']}", flush=True)
                    if status["credits"]:
                        print(f"   积分: {status['credits']}", flush=True)
                    context.close()
                    sys.exit(0)
                else:
                    print("\n❌ 未登录 Suno.com", flush=True)
                    context.close()
                    sys.exit(2)

            # 检查是否需要登录
            if not args.force_login:
                print("\n🔍 检查现有登录状态...", flush=True)
                status = check_login_status(page)
                if status["logged_in"]:
                    print(f"✅ 已登录！用户: {status.get('username', '未知')}, 积分: {status.get('credits', '未知')}", flush=True)
                    save_cookies(context, args.cookie_file)
                    context.close()
                    sys.exit(0)
                print("   未登录，准备执行登录...\n", flush=True)

            # 执行登录
            if not args.email or not args.[REDACTED_SECRET]"\n❌ 需要 --email 和 --password 参数", flush=True)
                context.close()
                parser.print_help()
                sys.exit(1)

            success = login_google_oauth(page, args.email, args.password)

            if success:
                save_cookies(context, args.cookie_file)
                page.wait_for_timeout(3000)
                status = check_login_status(page)
                print("\n" + "=" * 60, flush=True)
                print("🎉 登录成功！", flush=True)
                if status.get("username"):
                    print(f"   用户: {status['username']}", flush=True)
                if status.get("credits"):
                    print(f"   积分: {status['credits']}", flush=True)
                print(f"   Cookies: {args.cookie_file}", flush=True)
                print(f"   浏览器配置: {args.user_data_dir}", flush=True)
                print("", flush=True)
                print("   后续运行无需重新登录（persistent context 自动保持登录）", flush=True)
                print("   后续可用 --check-only 检查状态（自动 headless 模式）", flush=True)
                print("=" * 60, flush=True)
                context.close()
                sys.exit(0)
            else:
                print("\n" + "=" * 60, flush=True)
                print("❌ 登录失败！可能原因：", flush=True)
                print("   1. Gmail 邮箱或密码不正确", flush=True)
                print("   2. Google 要求手机/两步验证", flush=True)
                print("   3. 系统未安装 Chrome 浏览器", flush=True)
                print("   4. 网络问题", flush=True)
                if headless:
                    print("   5. headless 模式被 Google 检测到 → 请去掉 --headless 重试", flush=True)
                print("", flush=True)
                print("💡 排查: 查看 /tmp/suno_debug_*.png 截图", flush=True)
                print("=" * 60, flush=True)
                context.close()
                sys.exit(1)
    finally:
        # 清理虚拟显示
        if virtual_display:
            virtual_display.stop()
            print("🖥️ Xvfb 虚拟显示已关闭", flush=True)


if __name__ == "__main__":
    main()


path: check_env.sh
content: #!/bin/bash
# Suno 自动登录环境检查脚本
# 模仿小红书 MCP 的 check_env.sh
# 用法: bash suno/check_env.sh

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COOKIE_FILE="${SUNO_COOKIE_FILE:-$HOME/.suno/cookies.json}"
PYTHON="${SUNO_PYTHON:-python3}"

echo "=== 🎵 Suno 自动登录环境检查 ==="
echo ""

echo "=== 1. 检查 Python3 ==="
if command -v "$PYTHON" &>/dev/null; then
    PY_VER=$("$PYTHON" --version 2>&1)
    echo "✅ $PY_VER"
else
    echo "❌ 未找到 Python3，请先安装 Python 3.8+"
    exit 1
fi

echo ""
echo "=== 2. 检查 Playwright ==="
if "$PYTHON" -c "import playwright" 2>/dev/null; then
    echo "✅ Playwright 已安装"
else
    echo "❌ Playwright 未安装"
    echo "   请执行: pip install playwright && playwright install chromium"
    exit 1
fi

echo ""
echo "=== 3. 检查 Chromium 浏览器 ==="
# Playwright 的 chromium 通常安装在特定目录
if "$PYTHON" -c "
from playwright.sync_api import sync_playwright
import sys
try:
    p = sync_playwright().start()
    browser = p.chromium.launch(headless=True, args=['--no-sandbox'])
    browser.close()
    p.stop()
    print('✅ Chromium 浏览器可用')
except Exception as e:
    print(f'❌ Chromium 不可用: {e}')
    sys.exit(1)
" 2>/dev/null; then
    :
else
    echo "❌ Chromium 浏览器不可用"
    echo "   请执行: playwright install chromium"
    exit 1
fi

echo ""
echo "=== 4. 检查 Cookie 文件 ==="
if [ -f "$COOKIE_FILE" ]; then
    COOKIE_COUNT=$(python3 -c "import json; print(len(json.load(open('$COOKIE_FILE'))))" 2>/dev/null || echo "0")
    COOKIE_AGE=$(python3 -c "
import os, time
age = time.time() - os.path.getmtime('$COOKIE_FILE')
days = age / 86400
if days < 1:
    print(f'{age/3600:.1f} 小时前')
else:
    print(f'{days:.1f} 天前')
" 2>/dev/null || echo "未知")
    echo "✅ Cookie 文件存在: $COOKIE_FILE"
    echo "   数量: ${COOKIE_COUNT} 条"
    echo "   保存: ${COOKIE_AGE}"
else
    echo "⚠️ Cookie 文件不存在: $COOKIE_FILE"
    echo "   需要先登录（运行 suno_login.py）"
    exit 2
fi

echo ""
echo "=== 5. 检查登录状态 ==="
"$PYTHON" "$SCRIPT_DIR/suno_login.py" --check-only 2>/dev/null
CHECK_EXIT=$?

if [ $CHECK_EXIT -eq 0 ]; then
    echo ""
    echo "=== ✅ 所有检查通过，可以正常使用 ==="
    exit 0
elif [ $CHECK_EXIT -eq 2 ]; then
    echo "❌ 未登录（Cookie 可能已过期）"
    echo "   请运行: python $SCRIPT_DIR/suno_login.py --email <邮箱> --password <密码>"
    exit 2
else
    echo "❌ 登录状态检查失败"
    echo "   请检查网络连接或重新登录"
    exit 2
fi


path: qilixiang_lyrics.txt
content: 窗外的麻雀 在电线杆上多嘴
你说这一句 很有夏天的感觉
手中的铅笔 在纸上来来回回
我用几行字形容你是我的谁

秋刀鱼的滋味 猫跟你都想了解
初恋的香味就这样被我们寻回
那温暖的阳光 像刚摘的鲜艳草莓
你说你舍不得吃掉这一种感觉

雨下整夜 我的爱溢出就像雨水
院子落叶 跟我的思念厚厚一叠
几句是非 也无法将我的热情冷却
你出现在我诗的每一页


Security scan verdicts — ClawScan: suspicious | static analysis:  | VirusTotal:  | SkillSpector:  ()