Skill: standed/auto-midjourney (version 0.1.2)

--- name: auto-midjourney description: Automate Midjourney Alpha web image generation from Claude using the authenticated https://alpha.midjourney.com session. Use this skill whenever the user wants to create Midjourney images, submit MJ prompts, default to Midjourney v8, optimize Midjourney prompt syntax, or poll/download results from the Midjourney web app instead of Discord. Also use it when the user shares Alpha web request samples, cookies, channel IDs, or asks to reverse-engineer the Midjourney website workflow. compatibility: - python3 - network access --- # Auto Midjourney Use the user's own Midjourney Alpha web session to submit `imagine` jobs and optionally poll for results. This skill is intended for conservative, user-triggered assistance rather than unattended bulk automation. ## What this skill does - Submits prompts to `https://alpha.midjourney.com/api/submit-jobs` - Defaults to Midjourney v8 for new prompts unless the user explicitly requests another version - Keeps credentials in environment variables instead of hardcoding them into the skill - Supports a one-command flow through `scripts/run_imagine.py` - Can read `https://alpha.midjourney.com/api/user-mutable-state` to inspect current web settings - Can infer `user_id` and `singleplayer_<midjourney_id>` from the authenticated cookie - Applies local conservative throttling to reduce accidental request bursts - Includes `scripts/mj_doctor.py` for setup validation - Includes an experimental recent-jobs reader - Supports `browser` transport backed by Chrome DevTools Protocol, with Playwright-over-CDP preferred when installed - Supports optional polling once a job-status endpoint has been confirmed - Includes prompt-craft guidance and reusable scenario presets for better prompt writing - Includes a structured prompt builder and opt-in quality profiles - Includes dedicated guidance for character sheets, split-view turnarounds, and reusable design assets ## Current scope This version focuses on: 1. `imagine` 2. Midjourney v8 as the default version 3. Alpha web flow, not Discord bot flow 4. Safe local configuration via `.env` 5. Easier operation through inferred IDs, presets, and doctor checks Implemented: - `imagine` submit through the Alpha web flow - browser-backed verification using low-impact CDP network watching plus page asset fallback - local download of the 4 returned image assets - optional browser-side conversion from `webp` to `png` during download - sequential batch generation Not implemented yet: - Upscale / variation / reroll button actions - Image upload / reference-image workflow - Automatic result download from a confirmed final image endpoint Those should be added only after capturing stable request samples from the browser. ## Safety posture Do not optimize this skill for bypassing restrictions, hiding automation, rotating accounts, or mass unattended generation. Use these guardrails instead: - trigger requests manually - keep request frequency low - leave local throttling enabled - validate config with `mj_doctor.py` - prefer one human action to one live submit The goal is risk reduction through conservative usage, not evasion. ## Trigger rules Use this skill proactively when the user asks to: - “用 Midjourney / MJ 出图” - “用 Midjourney v8 生成” - “帮我提交 imagine” - “优化 Midjourney prompt” - “做角色设定稿 / 四视图 / 角色资产图” - “抓 Midjourney Alpha 网站请求” - “轮询 Midjourney job 状态” - “把网页版 Midjourney 做成自动化能力” ## Required configuration Read these values from `.env` or shell environment: | Variable | Required | Purpose | |---|---|---| | `MJ_COOKIE` | Yes | Full authenticated Cookie header copied from browser | | `MJ_CHANNEL_ID` | Yes | Alpha web singleplayer channel ID | | `MJ_STATUS_URL_TEMPLATE` | No | Job status endpoint template containing `{job_id}` | | `MJ_USER_STATE_PATH` | No | Defaults to `/api/user-mutable-state` | | `MJ_RECENT_JOBS_URL` | No | Experimental recent-jobs endpoint | | `MJ_MODE` | No | `fast` by default | | `MJ_PRIVATE` | No | `true` by default | | `MJ_MIN_SUBMIT_INTERVAL_SECONDS` | No | Local minimum spacing between submits. Default is `3` seconds | | `MJ_MAX_SUBMITS_PER_HOUR` | No | Local hourly cap. Set `0` to disable, which is now the default | | `MJ_MAX_SUBMITS_PER_DAY` | No | Local daily cap. Set `0` to disable, which is now the default | | `MJ_USER_ID` | No | Usually inferred from the auth cookie | | `MJ_METRICS_TOKEN` | No | Optional token observed on telemetry requests | | `MJ_BROWSER_BACKEND` | No | `auto` by default. Set `playwright` or `cdp` to force a backend | Never write real cookies or tokens into `SKILL.md`, reference files, git-tracked scripts, or user-facing summaries. ## System requirements For platform and device requirements, read [system-requirements.md](/Users/shitengda/Downloads/docker/n8n/skills/auto-midjourney/references/system-requirements.md). ## Workflow ### Scenario 0: Check config first Run: ```bash python3 scripts/mj_doctor.py --fetch-user-state --transport browser ``` This shows: - whether the cookie exists - inferred `midjourney_id` - inferred `channel_id` - current server-side speed and visibility - current local safe-limit settings ### Scenario 1: Submit one prompt Run: ```bash python3 scripts/run_imagine.py "1 girl --ar 16:9" --transport browser ``` Default behavior: - Appends `--v 8` if the prompt does not already specify a version - Appends `--raw` by default unless the user disables it - Uses `MJ_MODE` and `MJ_PRIVATE` from the environment - Can sync server-side defaults before submitting - Records the submit locally and enforces conservative pacing - Prints structured JSON with the request payload, submission response, and extracted `job_id` For simplest live use: ```bash python3 scripts/run_imagine.py "cinematic portrait of a fox astronaut" --transport browser --sync-user-state --wait-page-assets --download --convert-to png ``` When `--wait-page-assets` is enabled, the browser transport now prefers watching Midjourney's existing in-page network traffic for the submitted `job_id`. It falls back to page asset probing only if the low-impact watcher does not yield 4 images. ### Scenario 2: Submit and wait If a working status endpoint has been captured and stored in `MJ_STATUS_URL_TEMPLATE`, run: ```bash python3 scripts/run_imagine.py "cinematic portrait of a fox astronaut --ar 16:9" --wait ``` ### Scenario 3: Low-risk debugging Use dry-run first when changing payload structure: ```bash python3 scripts/run_imagine.py "robot barista in tokyo alley" --dry-run ``` This validates prompt normalization and payload generation without sending a live request. ### Scenario 3b: Use a preset Run: ```bash python3 scripts/run_imagine.py "silver perfume bottle on black glass" --preset product --sync-user-state ``` Preset definitions live in `config/presets.example.json`. When the user wants better prompt wording, templates, or parameter tradeoffs, read [prompt-craft.md](/Users/shitengda/Downloads/docker/n8n/skills/auto-midjourney/references/prompt-craft.md). ### Scenario 3c: Build a prompt from a template Run: ```bash python3 scripts/mj_prompt_helper.py --template product --subject "premium silver perfume bottle" --camera "front three-quarter angle" --surface "black glass surface" --lighting "controlled softbox rim light" --background "dark charcoal background" --mood "minimal luxury beauty campaign" --preset product_clean_square --quality-profile final_v8_q4 --json ``` This produces a V8-friendly prompt string and a ready-to-run `run_imagine.py` command. ### Scenario 4: Read current web settings Run: ```bash python3 scripts/get_user_state.py --transport browser ``` This reads the same `user-mutable-state` endpoint the web app uses and returns values such as: - `settings.speed` - `settings.visibility` - `abilities` - saved `macros` ## Command reference ### Submit only ```bash python3 scripts/submit_job.py "minimalist glass monolith --ar 16:9 --v 8" ``` ### Poll one job ```bash python3 scripts/poll_job.py "<job_id>" ``` ### Inspect current server-side settings ```bash python3 scripts/get_user_state.py --transport browser ``` ### Validate config and inferred identity ```bash python3 scripts/mj_doctor.py --fetch-user-state --transport browser ``` ### Experimental recent jobs lookup ```bash python3 scripts/list_recent_jobs.py --amount 10 ``` ### Submit, verify, and download locally ```bash python3 scripts/run_imagine.py "fashion editorial, silver fabric, studio light --ar 3:4" --transport browser --sync-user-state --wait-page-assets --download --convert-to png ``` ### Batch generate and store PNGs ```bash python3 scripts/batch_generate.py config/prompts.example.txt --transport browser --sync-user-state --convert-to png ``` Use conservative fixed spacing and batch cooldowns when needed: ```bash python3 scripts/batch_generate.py config/prompts.example.txt --transport browser --sync-user-state --convert-to png --batch-size 5 --submit-interval-seconds 120 --batch-cooldown-seconds 600 ``` Apply an opt-in quality profile to the whole batch: ```bash python3 scripts/batch_generate.py config/prompts.example.txt --transport browser --sync-user-state --quality-profile final_v8_q4 --convert-to png ``` ### Convert existing WEBP downloads to PNG ```bash python3 scripts/convert_downloads.py outputs ``` Or write converted files into a separate directory: ```bash python3 scripts/convert_downloads.py outputs --output-dir outputs/png-converted ``` If you want to rebuild PNGs from a saved result manifest instead of local files: ```bash python3 scripts/convert_downloads.py outputs/live-test/recent-job.json --output-dir outputs/png-rebuilt ``` This waits for the submitted `job_id` to appear in the browser page resource list, verifies that 4 Midjourney CDN image URLs exist, and downloads the returned image files into `outputs/<job_id>/`. ### Batch generate sequentially Create a text file with one prompt per line, then run: ```bash python3 scripts/batch_generate.py prompts.txt --transport browser --sync-user-state --download-dir outputs/batch ``` This submits prompts one by one, waits for each `job_id` to produce page resource URLs, and downloads the returned image files before moving to the next prompt. ### Full flow ```bash python3 scripts/run_imagine.py "fashion editorial, silver fabric, studio light --ar 3:4" --sync-user-state --wait-recent-jobs --download ``` ## Prompt defaults When the user does not specify MJ flags: - Default to `--v 8` - Default to `--raw` - Preserve any explicit aspect ratio or style flags already present - Do not auto-add `--hd` - Treat `--q 4` as an opt-in final-pass override rather than a default for the current V8-focused flow Do not silently override explicit user flags. ## Output format When you use this skill, report back in this structure: ```text Prompt: <final prompt sent> Job ID: <job_id or "not returned"> Mode: <fast/relax/etc> Visibility: <private/public> Status: <submitted / polled / failed / dry-run> Notes: <missing status endpoint, saved JSON path, or next step> ``` ## Simpler usage model For day-to-day use, prefer this sequence: 1. `python3 scripts/mj_doctor.py --fetch-user-state --transport browser` 2. `python3 scripts/run_imagine.py "<prompt>" --transport browser --sync-user-state` 3. `python3 scripts/run_imagine.py "<prompt>" --transport browser --sync-user-state --wait-page-assets --download` This reduces manual mistakes and keeps you in a low-frequency workflow. On this machine, `browser` transport is the preferred live path because raw HTTP requests are blocked by Cloudflare. ## Success criteria for a usable generation Treat a generation as verified only when all of these are true: 1. submit response contains a `job_id` 2. page resource entries contain 4 CDN image URLs for the same `job_id` 3. the image files are downloaded successfully to local disk Normal Midjourney `imagine` behavior is a 4-image grid. Depending on the endpoint response, you may get one grid image file rather than four separately cropped files. This skill currently verifies and downloads the returned image assets as provided by Midjourney. ## Extension path After the user captures more browser requests, extend in this order: 1. Confirm job-status endpoint and final-image fields 2. Add result downloader 3. Add button-action support for upscale / variation 4. Add reference image upload flow 5. Add prompt-template helpers for common MJ styles ## Confirmed non-status endpoints - `POST https://proxima.midjourney.com/` is currently treated as telemetry ingestion - `POST /api/v1/traces` is currently treated as tracing/observability data - `GET /api/user-mutable-state` is useful for reading current speed and visibility defaults Do not mistake telemetry endpoints for job-status APIs. ## GitHub-informed but experimental path The skill includes an experimental `recent-jobs` reader based on public GitHub reverse-engineering notes. Treat it as best-effort support rather than a stable API contract.

path: README.md
content: # Auto Midjourney

Browser-backed Midjourney Alpha skill for authenticated web usage.

![Auto Midjourney Hook](https://raw.githubusercontent.com/Standed/auto-midjourney-skill/main/assets/marketing/wechat-hook-banner.jpg)

想系统学习 AI 视频、Midjourney、智能体工作流和相关自动化，欢迎联系西羊石 AI 视频团队。

微信号：`741375926`

如果图片未显示，也可以直接添加上面的微信号交流合作。

This skill focuses on:

- submitting `imagine` jobs against `https://alpha.midjourney.com`
- Midjourney V8-first prompting
- browser-session result collection and PNG download
- conservative batch generation
- character-sheet and split-view prompt workflows

What is included:

- `SKILL.md` with the main workflow
- Python and Node helper scripts for submit, batch generation, prompt building, and browser-backed result retrieval
- prompt presets and quality profiles
- prompt-craft guidance for product shots, portraits, environments, and character sheets

What is not included:

- `.env`
- browser profile data
- outputs
- local state files

Recommended use cases:

- Midjourney Alpha web automation for one-off user-triggered generation
- batch prompt testing with conservative pacing
- character asset creation, split-view turnarounds, and design sheets
- local PNG downloads for downstream creative workflows


path: config/presets.example.json
content: {
  "portrait": {
    "suffix": "--ar 3:4 --s 150 --raw --v 8",
    "notes": "Editorial and character portraits. Keeps quality at the model default."
  },
  "landscape": {
    "suffix": "--ar 16:9 --s 100 --raw --v 8",
    "notes": "Wide scenes, environment concepts, travel-style images. Keeps quality at the model default."
  },
  "product": {
    "suffix": "--ar 1:1 --s 50 --raw --v 8",
    "notes": "Cleaner product and packshot style outputs. Keeps quality at the model default."
  },
  "portrait_clean": {
    "suffix": "--ar 4:5 --s 75 --raw --v 8",
    "notes": "Literal portrait preset with less stylization for controllable faces and wardrobe."
  },
  "portrait_editorial": {
    "suffix": "--ar 4:5 --s 200 --raw --v 8",
    "notes": "More fashion/editorial energy while staying in RAW mode."
  },
  "product_clean_square": {
    "suffix": "--ar 1:1 --s 50 --raw --v 8 --no text, watermark, logo clutter",
    "notes": "E-commerce friendly square framing with negative terms for common clutter."
  },
  "product_editorial": {
    "suffix": "--ar 4:5 --s 125 --raw --v 8",
    "notes": "More premium ad-like product look for packaging, cosmetics, and hero shots."
  },
  "environment_wide": {
    "suffix": "--ar 16:9 --s 100 --raw --v 8",
    "notes": "General environment and cinematic scene preset."
  },
  "social_vertical": {
    "suffix": "--ar 9:16 --s 100 --raw --v 8",
    "notes": "Vertical mobile-first framing for reels, shorts, and storyboards."
  },
  "character_sheet_wide": {
    "suffix": "--ar 21:9 --s 75 --raw --v 8 --no duplicate body, extra limbs, extra characters, busy background",
    "notes": "Wide character asset sheet preset for split-view layouts, turnarounds, and design-sheet style outputs."
  },
  "concept_variety": {
    "suffix": "--ar 16:9 --s 100 --c 20 --raw --v 8",
    "notes": "Adds moderate variety across the 4-image set when exploring directions."
  },
  "pattern_tile": {
    "suffix": "--ar 1:1 --tile --s 100 --raw --v 8",
    "notes": "Seamless repeat-style pattern exploration."
  }
}


path: config/prompts.character-assets-20.txt
content: ## Lin - front
Lin, female cyberpunk memory archivist, pale skin, deep tired but determined eyes, sleek high-collared dark grey futuristic uniform, subtle cybernetic neural interface scar behind her right ear, soft bioluminescent blue data reflections in her eyes, full body front view character asset, centered single character, clean neutral studio backdrop with faint dystopian solarpunk mood, photorealistic, intricate details, Unreal Engine 5 render style, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Lin - three quarter
Lin, female cyberpunk memory archivist, pale skin, deep tired but determined eyes, sleek high-collared dark grey futuristic uniform, subtle cybernetic neural interface scar behind her right ear, soft bioluminescent blue data reflections in her eyes, full body three-quarter view character asset, centered single character, clean neutral studio backdrop with faint dystopian solarpunk mood, photorealistic, intricate details, Unreal Engine 5 render style, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Lin - side
Lin, female cyberpunk memory archivist, pale skin, deep tired but determined eyes, sleek high-collared dark grey futuristic uniform, subtle cybernetic neural interface scar behind her right ear, soft bioluminescent blue data reflections in her eyes, full body side profile character asset, centered single character, clean neutral studio backdrop with faint dystopian solarpunk mood, photorealistic, intricate details, Unreal Engine 5 render style, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Lin - back
Lin, female cyberpunk memory archivist, pale skin, sleek high-collared dark grey futuristic uniform, subtle cybernetic neural interface scar behind her right ear, soft bioluminescent blue accents, full body back view character asset, centered single character, clean neutral studio backdrop with faint dystopian solarpunk mood, photorealistic, intricate details, Unreal Engine 5 render style, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Dr. Vance - front
Dr. Vance, charismatic elegant futuristic scientist, male antagonist, perfectly groomed silver hair, seamless pristine white and gold tailored trench coat, gentle but chillingly emotionless expression, full body front view character asset, centered single character, clean sterile ultra-modern laboratory inspired backdrop kept minimal, dramatic sharp shadow lighting, volumetric light, hyper-detailed cinematic photography, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Dr. Vance - three quarter
Dr. Vance, charismatic elegant futuristic scientist, male antagonist, perfectly groomed silver hair, seamless pristine white and gold tailored trench coat, gentle but chillingly emotionless expression, full body three-quarter view character asset, centered single character, clean sterile ultra-modern laboratory inspired backdrop kept minimal, dramatic sharp shadow lighting, volumetric light, hyper-detailed cinematic photography, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Dr. Vance - side
Dr. Vance, charismatic elegant futuristic scientist, male antagonist, perfectly groomed silver hair, seamless pristine white and gold tailored trench coat, gentle but chillingly emotionless expression, full body side profile character asset, centered single character, clean sterile ultra-modern laboratory inspired backdrop kept minimal, dramatic sharp shadow lighting, volumetric light, hyper-detailed cinematic photography, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Dr. Vance - back
Dr. Vance, charismatic elegant futuristic scientist, male antagonist, perfectly groomed silver hair, seamless pristine white and gold tailored trench coat, full body back view character asset, centered single character, clean sterile ultra-modern laboratory inspired backdrop kept minimal, dramatic sharp shadow lighting, volumetric light, hyper-detailed cinematic photography, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Kael - front
Kael, rugged cyberpunk scavenger, cynical but loyal expression, messy hair, slight stubble, visibly worn retro-mechanical left prosthetic arm, patched leather jacket, holding a small glowing antique pocket watch, full body front view character asset, centered single character, neutral studio backdrop with subtle cold neon rain mood, Cyberpunk 2077 style, highly detailed textures, dramatic lighting, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Kael - three quarter
Kael, rugged cyberpunk scavenger, cynical but loyal expression, messy hair, slight stubble, visibly worn retro-mechanical left prosthetic arm, patched leather jacket, holding a small glowing antique pocket watch, full body three-quarter view character asset, centered single character, neutral studio backdrop with subtle cold neon rain mood, Cyberpunk 2077 style, highly detailed textures, dramatic lighting, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Kael - side
Kael, rugged cyberpunk scavenger, cynical but loyal expression, messy hair, slight stubble, visibly worn retro-mechanical left prosthetic arm, patched leather jacket, holding a small glowing antique pocket watch, full body side profile character asset, centered single character, neutral studio backdrop with subtle cold neon rain mood, Cyberpunk 2077 style, highly detailed textures, dramatic lighting, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Kael - back
Kael, rugged cyberpunk scavenger, messy hair, slight stubble, visibly worn retro-mechanical left prosthetic arm, patched leather jacket, full body back view character asset, centered single character, neutral studio backdrop with subtle cold neon rain mood, Cyberpunk 2077 style, highly detailed textures, dramatic lighting, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Elara - front
Elara, female memory architect, dreamy ethereal look, avant-garde clothing made of translucent holographic fabrics, hair with subtle glowing neon gradient from pink to cyan, manipulating floating digital memory orbs, full body front view character asset, centered single character, clean ambient studio backdrop with synthwave glow, vibrant colors, highly aesthetic digital painting masterpiece, detailed face, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Elara - three quarter
Elara, female memory architect, dreamy ethereal look, avant-garde clothing made of translucent holographic fabrics, hair with subtle glowing neon gradient from pink to cyan, manipulating floating digital memory orbs, full body three-quarter view character asset, centered single character, clean ambient studio backdrop with synthwave glow, vibrant colors, highly aesthetic digital painting masterpiece, detailed face, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Elara - side
Elara, female memory architect, dreamy ethereal look, avant-garde clothing made of translucent holographic fabrics, hair with subtle glowing neon gradient from pink to cyan, manipulating floating digital memory orbs, full body side profile character asset, centered single character, clean ambient studio backdrop with synthwave glow, vibrant colors, highly aesthetic digital painting masterpiece, detailed face, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Elara - back
Elara, female memory architect, dreamy ethereal look, avant-garde clothing made of translucent holographic fabrics, hair with subtle glowing neon gradient from pink to cyan, full body back view character asset, centered single character, clean ambient studio backdrop with synthwave glow, vibrant colors, highly aesthetic digital painting masterpiece, detailed costume silhouette, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Silas - front
Silas, cybernetic enforcer, imposing tall figure, matte black futuristic tactical armor, upper face fully covered by sleek horizontal red glowing visor, faint white heat smoke from armor joints, full body front view character asset, centered single character, dark rainy dystopian alley mood simplified into clean backdrop, high contrast ominous atmosphere, cinematic sci-fi concept art, extremely detailed, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Silas - three quarter
Silas, cybernetic enforcer, imposing tall figure, matte black futuristic tactical armor, upper face fully covered by sleek horizontal red glowing visor, faint white heat smoke from armor joints, full body three-quarter view character asset, centered single character, dark rainy dystopian alley mood simplified into clean backdrop, high contrast ominous atmosphere, cinematic sci-fi concept art, extremely detailed, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Silas - side
Silas, cybernetic enforcer, imposing tall figure, matte black futuristic tactical armor, upper face fully covered by sleek horizontal red glowing visor, faint white heat smoke from armor joints, full body side profile character asset, centered single character, dark rainy dystopian alley mood simplified into clean backdrop, high contrast ominous atmosphere, cinematic sci-fi concept art, extremely detailed, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw

## Silas - back
Silas, cybernetic enforcer, imposing tall figure, matte black futuristic tactical armor, upper face fully covered by sleek horizontal red glowing visor, faint white heat smoke from armor joints, full body back view character asset, centered single character, dark rainy dystopian alley mood simplified into clean backdrop, high contrast ominous atmosphere, cinematic sci-fi concept art, extremely detailed, character design asset, no duplicate body, no extra limbs --ar 21:9 --v 8.0 --raw


path: config/prompts.example.txt
content: # One prompt per line
cinematic portrait of a fox astronaut --ar 16:9
silver perfume bottle on black glass
cyberpunk tokyo rainy alley at night --ar 16:9


path: config/prompts.sop-cover-22.txt
content: # 22 wide prompts for SOP tutorials,公众号头图, and workflow articles
clean modern workspace with laptop, notebook, coffee and soft daylight, premium productivity article hero image --ar 16:9
business team reviewing a digital workflow dashboard in a bright meeting room, polished corporate editorial photography --ar 16:9
creator desk with microphone, camera, tablet and warm desk lamp, elegant content creation setup --ar 16:9
AI automation concept, glowing workflow nodes connected across a sleek desktop environment, cinematic but practical tech illustration --ar 16:9
minimal operations control room with large screens showing task pipelines, refined technology brand visual --ar 16:9
marketing strategist working on a content calendar in a stylish studio office, clean commercial photography --ar 16:9
step-by-step process visualization with floating cards, arrows and subtle glassmorphism, premium editorial infographic style --ar 16:9
customer service team collaborating around a clean CRM dashboard, natural expressions, modern office environment --ar 16:9
entrepreneur writing SOP notes on a glass wall while teammates discuss behind, startup documentary editorial style --ar 16:9
laptop showing an analytics dashboard beside a smartphone and printed report, sharp business lifestyle photography --ar 16:9
polished tutorial cover image, hands typing on keyboard with AI chat and workflow tools on screen, refined realistic lighting --ar 16:9
clean e-commerce operations scene with stacked parcels, laptop dashboard and soft warehouse background, commercial brand image --ar 16:9
social media manager planning article topics with sticky notes and tablet, bright creative studio, magazine style --ar 16:9
premium online course cover image, focused young professional learning at night with soft monitor glow, realistic cinematic look --ar 16:9
team brainstorming around a whiteboard filled with content strategy blocks, calm neutral palette, editorial photo --ar 16:9
beautiful library-inspired study desk with laptop, books and warm golden lamp, ideal knowledge workflow cover image --ar 16:9
future-of-work concept, elegant office interior with holographic task boards and clean realistic architecture --ar 16:9
founder recording a screen tutorial in a minimal home studio, soft daylight, premium creator economy visual --ar 16:9
clean financial operations dashboard scene, calculator, reports and laptop on marble desk, upscale business photography --ar 16:9
editorial cover image for AI productivity guide, calm blue-gray workspace with layered screens and organized notes --ar 16:9
high-end article hero for process optimization, abstract flowing lines becoming structured task cards, modern brand design aesthetic --ar 16:9
content publishing workflow cover image, writer, designer and analyst collaborating around one large screen, realistic magazine-style photography --ar 16:9


path: config/prompts.user-test-22.txt
content: # 22 realistic prompts for end-to-end batch testing
editorial portrait of a young woman in soft morning window light, natural skin texture, quiet luxury styling --ar 4:5
confident male founder portrait in a minimalist concrete office, clean fashion editorial look --ar 4:5
asian female barista smiling in a sunlit cafe, documentary photography feel --ar 4:5
skincare serum bottle on wet black stone, luxury beauty campaign lighting --ar 1:1
silver perfume bottle on reflective black glass, premium product photography, crisp edges --ar 1:1
matte ceramic coffee cup on warm oak table, nordic lifestyle commercial photo --ar 4:5
modern living room with curved sofa, travertine table, soft afternoon sunlight, interior magazine style --ar 16:9
boutique hotel bedroom with linen sheets, walnut wood, warm ambient lighting, calm travel editorial --ar 16:9
rainy tokyo alley at night with neon reflections, cinematic street photography --ar 16:9
sunrise over terraced rice fields in yunnan, misty atmosphere, travel photography --ar 16:9
close-up bowl of tonkotsu ramen with steam, rich food photography, dark restaurant background --ar 4:5
artisan strawberry shortcake on a clean dessert plate, bright bakery commercial style --ar 4:5
athletic running shoes on a floating pedestal, dynamic sports brand advertising image --ar 1:1
minimal tech desk setup with silver laptop, mechanical keyboard, soft side light, creator workspace --ar 16:9
streetwear fashion campaign, young woman leaning against a red vending machine in seoul, bold colors --ar 4:5
gentle anime girl with long dark hair and green eyes, clean line art, soft daylight, tasteful character illustration --ar 3:4
two best friends walking by the seaside at golden hour, candid lifestyle photography --ar 16:9
cozy bookstore corner with stacked books, green reading lamp, rainy day atmosphere --ar 16:9
futuristic electric car in a white studio, premium automotive advertising render --ar 16:9
small jewelry brand hero image, gold ring with diamond on soft beige paper backdrop --ar 1:1
healthy salad bowl with salmon, avocado and citrus, clean overhead food photography --ar 4:5
cinematic fox astronaut standing on a moon base, detailed suit reflections, dramatic sci-fi scene --ar 16:9


path: config/quality-profiles.example.json
content: {
  "default_v8": {
    "suffix": "",
    "notes": "V8 Alpha default quality path. Best for ideation and prompt debugging."
  },
  "final_v8_q4": {
    "suffix": "--q 4",
    "notes": "Higher-cost final-pass render for V8 Alpha after composition is already working."
  },
  "legacy_hd_q4_experimental": {
    "suffix": "--hd --q 4",
    "notes": "Legacy/high-definition experimental override. Expensive and not recommended for batch ideation."
  }
}


path: evals/evals.json
content: {
  "skill_name": "auto-midjourney",
  "evals": [
    {
      "id": 1,
      "prompt": "帮我用 Midjourney v8 生成一张 16:9 的赛博朋克东京雨夜街景，默认用网页端流程提交。",
      "expected_output": "Uses the auto-midjourney skill, normalizes the prompt for MJ v8, and submits or dry-runs through the Alpha web scripts.",
      "files": []
    },
    {
      "id": 2,
      "prompt": "我已经抓到了 Midjourney Alpha 的 cookie 和 channelId，帮我提交一个 imagine prompt：editorial fashion portrait, soft light。",
      "expected_output": "Uses the provided env configuration path, submits an imagine request, and returns the job_id.",
      "files": []
    },
    {
      "id": 3,
      "prompt": "这是一个 Midjourney job_id，帮我轮询状态并告诉我结果图链接。",
      "expected_output": "Uses poll_job.py when a status template exists, otherwise clearly reports the missing endpoint configuration.",
      "files": []
    },
    {
      "id": 4,
      "prompt": "帮我看看我当前 Midjourney Alpha 网页端默认是 fast 还是 relax，图片是否是私密模式。",
      "expected_output": "Uses get_user_state.py or the user-mutable-state endpoint and reports speed plus visibility.",
      "files": []
    },
    {
      "id": 5,
      "prompt": "帮我先自检 Midjourney 配置，再用最稳妥的方式提交一个 v8 prompt，尽量少填参数。",
      "expected_output": "Uses mj_doctor.py first, then run_imagine.py with sync-user-state and conservative throttling.",
      "files": []
    },
    {
      "id": 6,
      "prompt": "帮我提交一个 Midjourney prompt，确认它真的生成成功，并把结果下载到本地。",
      "expected_output": "Uses run_imagine.py with wait-recent-jobs and download so success is only reported after the job appears in recent-jobs and image files are saved locally.",
      "files": []
    },
    {
      "id": 7,
      "prompt": "我有一批 Midjourney prompts，帮我顺序批量生成，并确保每条都落到本地。",
      "expected_output": "Uses batch_generate.py with sequential processing, local throttling, recent-jobs verification, and downloads.",
      "files": []
    }
  ]
}


path: package-lock.json
content: {
  "name": "auto-midjourney",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "auto-midjourney",
      "version": "1.0.0",
      "license": "ISC",
      "dependencies": {
        "@msgpack/msgpack": "^3.1.3",
        "playwright-core": "^1.58.2"
      }
    },
    "node_modules/@msgpack/msgpack": {
      "version": "3.1.3",
      "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">= 18"
      }
    },
    "node_modules/playwright-core": {
      "version": "1.58.2",
      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "bin": {
        "playwright-core": "cli.js"
      },
      "engines": {
        "node": ">=18"
      }
    }
  }
}


path: package.json
content: {
  "name": "auto-midjourney",
  "version": "1.0.0",
  "description": "Midjourney Alpha browser helpers with Playwright-over-CDP support",
  "main": "scripts/mj_playwright_bridge.mjs",
  "scripts": {
    "check:playwright-bridge": "node --check scripts/mj_playwright_bridge.mjs"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@msgpack/msgpack": "^3.1.3",
    "playwright-core": "^1.58.2"
  }
}


path: references/api-notes.md
content: # Midjourney Alpha API Notes

This skill is based on the browser flow observed on `https://alpha.midjourney.com/`.

## Confirmed request

- Method: `POST`
- URL: `https://alpha.midjourney.com/api/submit-jobs`
- Content type: `application/json`
- Important headers:
  - `cookie: <authenticated session cookie>`
  - `origin: https://alpha.midjourney.com`
  - `referer: https://alpha.midjourney.com/explore?tab=hot`
  - `x-csrf-protection: 1`
  - `x-mj-traceparent: <trace id>`

## Confirmed payload shape

```json
{
  "f": {
    "mode": "fast",
    "private": true
  },
  "channelId": "singleplayer_<uuid>",
  "metadata": {
    "isMobile": null,
    "imagePrompts": 0,
    "imageReferences": 0,
    "characterReferences": 0,
    "depthReferences": 0,
    "lightboxOpen": null
  },
  "t": "imagine",
  "prompt": "1 girl --ar 16:9 --raw --v 8"
}
```

## Confirmed response shape

```json
{
  "success": [
    {
      "job_id": "<uuid>",
      "prompt": "1 girl --ar 16:9 --raw --v 8.0",
      "is_queued": false,
      "softban": false,
      "event_type": "diffusion",
      "job_type": "v8_diffusion",
      "flags": {
        "mode": "fast",
        "visibility": "stealth"
      },
      "meta": {
        "height": 816,
        "width": 1456,
        "batch_size": 4,
        "parent_id": null,
        "parent_grid": null
      }
    }
  ],
  "failure": []
}
```

## Unknowns to capture next

1. Final job-status endpoint
2. Result image URLs and field names
3. Upscale / variation button-action endpoints
4. Reference-image upload endpoints

## Experimental recent-jobs endpoint

Public GitHub reverse-engineering notes indicate a recent-jobs endpoint at:

```text
https://www.midjourney.com/api/app/recent-jobs/
```

Typical query parameters seen in community tooling include:

- `userId`
- `page`
- `amount`
- `orderBy`
- optional `jobStatus`
- optional `jobType`

This skill exposes that path as an experimental helper. It may change without notice.

## Confirmed user-state endpoint

- Method: `GET`
- URL: `https://alpha.midjourney.com/api/user-mutable-state`
- Purpose: returns current web settings and user flags

Example fields observed:

```json
{
  "settings": {
    "speed": "fast",
    "visibility": "stealth"
  },
  "macros": {
    "Richard": "https://cdn.discordapp.com/attachments/..."
  },
  "abilities": 31457284
}
```

This is useful for reading defaults, but it is not a job result endpoint.

## Observed telemetry endpoints

### Proxima metrics

- Method: `POST`
- URL: `https://proxima.midjourney.com/`
- Observed header: `x-metrics-token`
- Observed purpose: event ingestion for web analytics / metrics

Example event types:

- `WEB_GENERATE`

The current skill does not need this endpoint to submit `imagine` jobs.

### Trace ingestion

- Method: `POST`
- URL: `https://alpha.midjourney.com/api/v1/traces`
- Observed purpose: lifecycle tracing for client observability

Observed trace attributes included:

- `job.job_id`
- `job.model_version`
- `job.submission_type`
- `job.speed`

This can confirm that a job existed, but it is still not a replacement for a true job-status or result endpoint.

## Capture checklist

When extending this skill, collect one HAR or request sample for each new action:

1. `imagine` submit
2. job status poll
3. final image open/download
4. upscale
5. variation

Strip live cookies before storing samples in the repo.


path: references/operating-model.md
content: # Operating Model

Use this skill as a personal assistant for Midjourney Alpha web, not as a bulk automation framework.

## Recommended workflow

1. Run `python3 scripts/mj_doctor.py --fetch-user-state`
2. Confirm inferred `user_id` and `channel_id`
3. Build or refine the prompt with `python3 scripts/mj_prompt_helper.py ...` when the user needs template-based help
4. Submit one prompt with `python3 scripts/run_imagine.py "<prompt>" --sync-user-state`
5. Keep the built-in spacing enabled, but tune it pragmatically for real usage
6. Use `python3 scripts/run_imagine.py "<prompt>" --sync-user-state --wait-page-assets --download --convert-to png` when you need a verified local result
7. Use `python3 scripts/list_recent_jobs.py --amount 10` to inspect recent jobs

The preferred browser verification path now watches the existing Midjourney tab's network responses for matching `job_id` image URLs. This is quieter than forcing repeated page refreshes or navigation.

## Why this model is safer

- fewer manual header mistakes
- fewer duplicate submits
- avoids bursty request patterns
- easier to keep one human action mapped to one live submit

## Do not do this

- long unattended loops
- multi-account rotation
- burst retries to “push through” failures
- scraping or background polling at high frequency

## Local throttling defaults

The current defaults are intentionally light-weight:

- minimum interval: 3 seconds
- hourly cap: disabled by default
- daily cap: disabled by default

These are local tool limits, not official Midjourney limits. If you want stricter guardrails, set the hourly and daily caps explicitly in `.env`.

## Batch workflow

When you need multiple prompts, use `scripts/batch_generate.py` with a plain text prompts file. It can submit a small batch first, then wait for that batch to resolve, and can pause between batches with `--batch-size` plus `--batch-cooldown-seconds`.


path: references/prompt-craft.md
content: # Prompt Craft

Use this file when the user wants better Midjourney prompts, parameter guidance, reusable templates, or scenario-specific presets.

## Parameter policy

Use these as the default parameter set for this skill unless the user explicitly wants otherwise:

- `--v 8` because this skill targets the Alpha web V8 flow
- `--raw` when the user wants more literal prompt following
- `--ar <ratio>` to fit the intended output surface
- `--s <value>` to control stylization
- `--c <value>` only when the user wants more exploration/variation across the 4-image set
- `--no <thing>` for common exclusions such as text, watermark, extra fingers, clutter, frame

Do not auto-add `--hd` or `--q 4` by default.

Reason:

- Midjourney's official quality documentation now explicitly mentions V8 Alpha quality values, and the current documented V8 Alpha quality values are `1` (default) and `4`.
- The same official quality documentation notes that combining `--q 4` with `--hd` in V8 Alpha currently costs `16x` more GPU time.
- Midjourney's official legacy-features documentation also lists `--hd` under legacy/high-definition behavior, so it should be treated as a special-purpose override, not a baseline preset.

Inference:

- For the V8 Alpha-focused flow in this skill, `--q 2` should not be recommended as the standard quality bump because the current official V8 Alpha docs list `1` and `4`.
- Treat `--q 4` as an opt-in final-pass override, not a baseline prompt suffix.

## Practical defaults

Use these rough bands for `--s`:

- `--s 50` to `--s 100`: product shots, UI-style renders, packshots, cleaner realism
- `--s 100` to `--s 175`: balanced default for environments and general scenes
- `--s 175` to `--s 250`: editorial, concept art, fashion, more stylized images

Use these rough bands for `--c`:

- `--c 0` to `--c 10`: tighter consistency across the 4 results
- `--c 15` to `--c 30`: broader exploration when ideating directions

## Prompt structure templates

### Portrait

Template:

`[subject], [age / style cues], [pose or framing], [lens / camera feel], [lighting], [background], [mood], [wardrobe / material details]`

Example:

`editorial portrait of a young woman, half body, 85mm lens, soft window light, muted concrete studio background, quiet luxury mood, silk blouse, natural skin texture`

Recommended suffix:

`--ar 4:5 --s 100 --raw --v 8`

### Product

Template:

`[product], [material / finish], [camera angle], [surface], [lighting setup], [background], [brand adjectives], [exclusions if needed]`

Example:

`premium silver perfume bottle, brushed aluminum cap, front three-quarter angle, black glass surface, controlled softbox rim light, dark charcoal background, minimal luxury beauty campaign`

Recommended suffix:

`--ar 1:1 --s 50 --raw --v 8 --no text, watermark, logo clutter`

### Environment

Template:

`[location], [time of day / weather], [camera framing], [hero details], [materials / signage], [mood], [color palette]`

Example:

`cyberpunk tokyo alley, rainy midnight, wide cinematic frame, glowing ramen shop signs, wet asphalt reflections, dense cables and small vending machines, neon teal and amber palette`

Recommended suffix:

`--ar 16:9 --s 100 --raw --v 8`

### Character / Anime

Template:

`[character type], [hair / outfit], [pose], [expression], [setting], [lighting], [render style], [key props]`

Example:

`anime schoolgirl detective, black braided hair, sailor uniform with subtle tactical details, calm direct gaze, clean gray backdrop, soft key light, refined cel shading, small notebook in hand`

Recommended suffix:

`--ar 3:4 --s 125 --raw --v 8`

### Character Sheet / Turnaround

Use this pattern when the user wants character assets, model sheets, turnarounds, split-view layouts, or reusable design references.

Goal:

- one clearly defined character
- explicit view directions
- neutral background
- reduced scene clutter
- wording that nudges Midjourney toward sheet-style composition instead of a narrative shot

Recommended structure:

`Split-view composition: [panel layout]. [close-up section if needed]. [full-body view directions]. [character identity]. [costume]. [props]. [background]. [render style]. [asset-sheet constraints]`

Best practices:

- Say `split-view composition` or `character design sheet` early.
- Explicitly state `front, side, back` or `front, three-quarter, side, back`.
- Use `centered single character`.
- Use `pure white background` or `clean neutral studio backdrop`.
- Add `no duplicate body, no extra limbs, no extra characters`.
- Keep the costume and signature props in every view description.
- Prefer `--raw` and a lower-to-moderate `--s` for asset work.

Useful aspect ratios:

- `--ar 21:9` for wide horizontal sheets that still leave room for a full character
- `--ar 7:3` for more aggressive split layouts with a face panel plus turnaround views
- `--ar 16:9` when the user wants a safer wide format with fewer layout failures

Suggested parameter band:

- `--s 50` to `--s 100`
- `--raw`
- optional `--no duplicate body, extra limbs, extra characters, busy background`

Example:

`Split-view composition: left third is an extreme close-up portrait of Lin, a female cyberpunk memory archivist with pale skin, tired but determined eyes, subtle neural interface scar behind her right ear. right two-thirds shows full-body turnaround views front, side, and back. sleek high-collared dark grey futuristic uniform, soft blue data reflections in the eyes, centered single character, pure white background, high-quality 3D production render, Unreal Engine 5 style, character design sheet, no duplicate body, no extra limbs, no extra characters --ar 21:9 --s 75 --raw --v 8`

## When to consider higher quality

For the current V8 Alpha flow, consider testing `--q 4` only when all of these are true:

- composition is already close
- the user wants final output rather than ideation speed
- the user accepts slower generation and possible higher usage
- the current V8 Alpha workflow is actually the target

Do not recommend `--q 4` or `--hd` for:

- early ideation batches
- prompt debugging
- large prompt sweeps
- cheap exploratory batches

Use plain defaults for ideation first:

- `--q 1` implicit default
- `--raw`
- a deliberate `--ar`
- moderate `--s`
- optional `--c`

## Recommended workflow in this skill

1. Start with a clean scenario preset from `config/presets.example.json`
2. Improve the natural-language prompt body first
3. Use `--ar`, `--raw`, `--s`, and optional `--c` before reaching for quality overrides
4. Only test higher `--q` settings after the baseline composition is already working

## Character asset workflow

For character libraries and reusable art assets:

1. First generate dedicated sheet-style prompts instead of story prompts.
2. Lock the ratio and sheet layout wording.
3. Generate the base character sheet batch.
4. Only after the base look is approved, create story or poster shots from the chosen design direction.


path: references/system-requirements.md
content: # System Requirements

Use this file when the user asks what devices or operating systems are supported.

## Current support status

- macOS: supported and validated in this skill
- Windows: browser/CDP path is designed to work, but not validated in this repo yet
- Linux: browser/CDP path is designed to work, but not validated in this repo yet

## Required software

- Python 3
- Node.js
- `npm install` once in this skill directory to restore `playwright-core` when needed
- Google Chrome or Chromium with DevTools remote debugging available
- A logged-in Midjourney Alpha web session

## Important implementation detail

This skill does not need a powerful GPU on the user's machine because Midjourney rendering happens in Midjourney's cloud.

The local machine is responsible for:

- launching Chrome
- reusing the authenticated browser session
- watching page resources for completed images
- downloading or converting result files

## Browser transport notes

- The preferred live path is browser/CDP transport rather than raw HTTP
- When `playwright-core` is installed, the skill can attach to the live Chrome session through Playwright over CDP
- Use `MJ_BROWSER_BACKEND=playwright` to force Playwright or `MJ_BROWSER_BACKEND=cdp` to force the legacy CDP scripts
- On macOS, the skill can still fall back to AppleScript if CDP fetch hits trouble
- On Windows and Linux, the intended path is CDP only

## When to set `MJ_CHROME_APP`

Set `MJ_CHROME_APP` explicitly when:

- Chrome is installed in a non-default location
- the system has multiple Chrome-family browsers
- auto-detection does not find the correct executable


path: scripts/batch_generate.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import random
import re
import sys
import time
from pathlib import Path
from typing import Any

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from mj_browser import (
    browser_fetch_imagine_feed,
    browser_fetch_json,
    wait_for_imagine_feed_jobs,
    wait_for_job_cdn_assets,
    wait_for_job_page_assets,
    wait_for_prompt_card_assets,
    watch_job_results,
)
from mj_alpha import (
    DEFAULT_METADATA,
    apply_preset,
    append_negative_terms,
    download_image,
    enrich_config_from_cookie,
    enforce_safe_limits,
    extract_job_ids,
    get_user_state,
    load_config,
    normalize_prompt,
    print_json,
    record_submit,
    require_config,
    resolve_named_suffix,
    save_json,
    submit_imagine,
    summarize_job_images,
    wait_for_recent_job,
)


SAFE_WAIT_RE = re.compile(r"wait at least ([0-9]+(?:\.[0-9]+)?) more seconds", re.IGNORECASE)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Generate Midjourney jobs in batches with submit-first waiting.")
    parser.add_argument("prompts_file", help="Text file with one prompt per line")
    parser.add_argument("--dotenv", default=".env", help="Path to .env file")
    parser.add_argument("--transport", choices=["auto", "browser", "http"], default="browser", help="Request transport")
    parser.add_argument("--preset", help="Preset name from presets file")
    parser.add_argument("--presets-file", default="config/presets.example.json", help="Path to presets JSON")
    parser.add_argument("--quality-profile", help="Quality profile name from config/quality-profiles.example.json")
    parser.add_argument("--quality-profiles-file", default="config/quality-profiles.example.json", help="Path to quality profiles JSON")
    parser.add_argument("--negative", help="Append a --no clause when prompts do not already define one")
    parser.add_argument("--default-version", default="8", help="Default version when prompt has no version flag")
    parser.add_argument("--no-raw", action="store_true", help="Do not auto-append --raw")
    parser.add_argument("--sync-user-state", action="store_true", help="Read /api/user-mutable-state before the batch starts")
    parser.add_argument("--batch-size", type=int, default=10, help="Prompts per batch before waiting for that batch to finish")
    parser.add_argument("--batch-cooldown-seconds", type=float, default=0.0, help="Extra cooldown after each completed batch")
    parser.add_argument("--batch-cooldown-min-seconds", type=float, help="Minimum cooldown between completed batches")
    parser.add_argument("--batch-cooldown-max-seconds", type=float, help="Maximum cooldown between completed batches")
    parser.add_argument("--wait-mode", choices=["batch", "serial"], default="batch", help="Wait for one batch at a time or fully serialise each prompt")
    parser.add_argument("--submit-interval-seconds", type=float, help="Deprecated alias: fixed delay between submits")
    parser.add_argument("--submit-delay-min-seconds", type=float, default=1.0, help="Minimum delay between prompt submissions")
    parser.add_argument("--submit-delay-max-seconds", type=float, default=2.0, help="Maximum delay between prompt submissions")
    parser.add_argument("--recent-attempts", type=int, default=40, help="Max page asset polling attempts per job or batch")
    parser.add_argument("--recent-amount", type=int, default=25, help="Recent-jobs page size while waiting in HTTP mode")
    parser.add_argument("--download-dir", default="outputs/batch", help="Base directory for downloaded images")
    parser.add_argument(
        "--convert-to",
        choices=["original", "png"],
        default="original",
        help="Download images as their original format or browser-convert them to PNG",
    )
    parser.add_argument("--output", help="Write batch JSON result to file")
    return parser


def read_prompts(path: str | Path) -> list[str]:
    prompts: list[str] = []
    for raw_line in Path(path).read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        prompts.append(line)
    return prompts


def resolve_preset_suffix(presets_file: str, preset_name: str | None) -> str | None:
    return resolve_named_suffix(presets_file, preset_name, label="preset")


def extract_safe_wait_seconds(message: str) -> float | None:
    match = SAFE_WAIT_RE.search(message)
    if not match:
        return None
    return float(match.group(1))


def wait_for_safe_window(config) -> dict[str, Any]:
    while True:
        try:
            return enforce_safe_limits(config)
        except RuntimeError as exc:
            wait_seconds = extract_safe_wait_seconds(str(exc))
            if wait_seconds is None:
                raise
            time.sleep(max(0.5, wait_seconds + 0.25))


def save_partial_output(args, results: list[dict[str, Any]], synced_user_state, config) -> None:
    if not args.output:
        return
    output = {
        "count": len(results),
        "batch_size": args.batch_size,
        "batch_cooldown_seconds": args.batch_cooldown_seconds,
        "wait_mode": args.wait_mode,
        "submit_delay_min_seconds": args.submit_delay_min_seconds,
        "submit_delay_max_seconds": args.submit_delay_max_seconds,
        "min_submit_interval_seconds": config.min_submit_interval_seconds,
        "preset": args.preset,
        "quality_profile": args.quality_profile,
        "negative": args.negative,
        "sync_user_state": bool(synced_user_state),
        "user_state": synced_user_state,
        "results": results,
    }
    save_json(args.output, output)


def maybe_sleep_between_submits(args, index: int, prompt_count: int, is_batch_boundary: bool) -> None:
    if index >= prompt_count or is_batch_boundary:
        return
    low = max(0.0, float(args.submit_delay_min_seconds))
    high = max(low, float(args.submit_delay_max_seconds))
    if high <= 0:
        return
    time.sleep(random.uniform(low, high))


def maybe_sleep_between_batches(args, index: int, prompt_count: int) -> None:
    if index >= prompt_count:
        return
    if args.batch_cooldown_min_seconds is not None or args.batch_cooldown_max_seconds is not None:
        low = float(args.batch_cooldown_min_seconds if args.batch_cooldown_min_seconds is not None else 0.0)
        high = float(args.batch_cooldown_max_seconds if args.batch_cooldown_max_seconds is not None else low)
        if high < low:
            raise SystemExit("--batch-cooldown-max-seconds must be >= --batch-cooldown-min-seconds")
        if high > 0:
            time.sleep(random.uniform(max(0.0, low), max(0.0, high)))
        return
    if args.batch_cooldown_seconds > 0:
        time.sleep(args.batch_cooldown_seconds)


def submit_prompt(config, args, final_prompt: str) -> tuple[dict[str, Any], str]:
    if args.transport in {"auto", "browser"}:
        payload = {
            "f": {"mode": config.mode, "private": config.private},
            "channelId": config.channel_id,
            "metadata": DEFAULT_METADATA,
            "t": "imagine",
            "prompt": final_prompt,
        }
        try:
            response = browser_fetch_json(
                config.submit_url,
                method="POST",
                headers={
                    "accept": "*/*",
                    "content-type": "application/json",
                    "x-csrf-protection": config.csrf_protection,
                },
                payload=payload,
                timeout_seconds=config.timeout_seconds,
            )
            submit_result = {
                "dry_run": False,
                "transport": "browser",
                "submit_url": config.submit_url,
                "payload": payload,
                "response": response,
                "job_ids": extract_job_ids(response),
            }
        except Exception:
            if args.transport == "browser":
                raise
            submit_result = submit_imagine(config, final_prompt)
            submit_result["transport"] = "http"
    else:
        submit_result = submit_imagine(config, final_prompt)
        submit_result["transport"] = "http"

    job_ids = extract_job_ids(submit_result)
    if not job_ids:
        raise SystemExit("Submit response did not contain a job_id.")
    return submit_result, job_ids[0]


def build_pending_item(
    *,
    index: int,
    batch_index: int,
    original_prompt: str,
    final_prompt: str,
    safe_limits: dict[str, Any],
    submit_result: dict[str, Any],
    job_id: str,
) -> dict[str, Any]:
    return {
        "index": index,
        "batch_index": batch_index,
        "original_prompt": original_prompt,
        "final_prompt": final_prompt,
        "safe_limits": safe_limits,
        "submit": submit_result,
        "job_id": job_id,
    }


def wait_for_pending_items(config, args, pending_items: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
    if not pending_items:
        return {}

    if args.transport in {"auto", "browser"}:
        watch_timeout = min(
            max(10.0, float(config.poll_interval_seconds) * 3.0),
            max(10.0, float(config.timeout_seconds)),
        )
        try:
            watched = watch_job_results(
                [item["job_id"] for item in pending_items],
                page_url="https://alpha.midjourney.com/imagine",
                min_count=4,
                timeout_seconds=watch_timeout,
            )
            watched_jobs = watched.get("jobs", {}) if isinstance(watched, dict) else {}
            if watched_jobs:
                if all(len(list((watched_jobs.get(item["job_id"]) or {}).get("result_urls") or [])) >= 4 for item in pending_items):
                    return watched_jobs
        except Exception:
            if args.transport == "browser":
                raise
        feed_wait = wait_for_imagine_feed_jobs(
            [item["job_id"] for item in pending_items],
            user_id=config.user_id or "",
            csrf_protection=config.csrf_protection,
            attempts=args.recent_attempts,
            interval_seconds=config.poll_interval_seconds,
            page_size=max(1000, len(pending_items) * 8),
            page_url="https://alpha.midjourney.com/imagine",
            timeout_seconds=config.timeout_seconds,
        )
        jobs: dict[str, dict[str, Any]] = {}
        for item in pending_items:
            feed_row = (feed_wait.get("jobs", {}) or {}).get(item["job_id"]) or None
            attempts = max(2, min(6, args.recent_attempts // 4 or 2))
            page_result = wait_for_job_cdn_assets(
                item["job_id"],
                attempts=attempts,
                interval_seconds=max(1.0, config.poll_interval_seconds),
                timeout_seconds=config.timeout_seconds,
            )
            if len(list(page_result.get("result_urls") or [])) < 4:
                page_result = wait_for_job_page_assets(
                    item["job_id"],
                    attempts=attempts,
                    interval_seconds=max(1.0, config.poll_interval_seconds),
                    timeout_seconds=config.timeout_seconds,
                )
            if feed_row:
                page_result["imagine_feed_job"] = feed_row
            page_result["imagine_feed_check"] = feed_wait
            jobs[item["job_id"]] = page_result
        return jobs

    results: dict[str, dict[str, Any]] = {}
    for item in pending_items:
        recent_result = wait_for_recent_job(
            config,
            item["job_id"],
            attempts=args.recent_attempts,
            amount=args.recent_amount,
        )
        results[item["job_id"]] = recent_result
    return results


def finalize_pending_items(
    config,
    args,
    pending_items: list[dict[str, Any]],
    results: list[dict[str, Any]],
    synced_user_state,
) -> None:
    if not pending_items:
        return

    waited = wait_for_pending_items(config, args, pending_items)
    for item in pending_items:
        job_id = item["job_id"]
        recent_result = waited.get(job_id, {})

        if args.transport in {"auto", "browser"}:
            image_paths = list(recent_result.get("result_urls") or [])
            if not image_paths:
                fallback_result = wait_for_prompt_card_assets(
                    item["final_prompt"],
                    page_url="https://alpha.midjourney.com/imagine",
                    attempts=max(20, args.recent_attempts),
                    interval_seconds=config.poll_interval_seconds,
                    timeout_seconds=config.timeout_seconds,
                )
                if fallback_result.get("result_urls"):
                    recent_result = fallback_result
                    image_paths = list(fallback_result.get("result_urls") or [])
            job_summary = {
                "job_id": job_id,
                "image_count": len(image_paths),
                "image_paths": image_paths,
                "source": recent_result.get("source", "page_assets"),
            }
        else:
            job = recent_result.get("job")
            if not job:
                raise SystemExit(f"Job {job_id} was not found in recent-jobs for prompt #{item['index']}.")
            job_summary = summarize_job_images(job)
            image_paths = list(job.get("image_paths") or [])

        if not image_paths:
            raise SystemExit(f"Job {job_id} completed without image paths for prompt #{item['index']}.")

        job_output_dir = Path(args.download_dir) / job_id
        downloads = [
            download_image(
                config,
                image_url,
                job_output_dir,
                filename_stem=f"{job_id}_{image_index}",
                convert_to=args.convert_to,
            )
            for image_index, image_url in enumerate(image_paths, start=1)
        ]

        results.append(
            {
                "index": item["index"],
                "batch_index": item["batch_index"],
                "original_prompt": item["original_prompt"],
                "final_prompt": item["final_prompt"],
                "safe_limits": item["safe_limits"],
                "job_id": job_id,
                "submit": item["submit"],
                "recent_job_summary": job_summary,
                "recent_job_check": recent_result,
                "downloads": downloads,
            }
        )
        save_partial_output(args, results, synced_user_state, config)


def main() -> int:
    args = build_parser().parse_args()
    prompts = read_prompts(args.prompts_file)
    if not prompts:
        raise SystemExit("No prompts found in prompts file.")

    if args.submit_interval_seconds is not None:
        fixed = max(0.0, float(args.submit_interval_seconds))
        args.submit_delay_min_seconds = fixed
        args.submit_delay_max_seconds = fixed
    if args.submit_delay_max_seconds < args.submit_delay_min_seconds:
        raise SystemExit("--submit-delay-max-seconds must be >= --submit-delay-min-seconds")
    if (
        args.batch_cooldown_min_seconds is not None
        and args.batch_cooldown_max_seconds is not None
        and args.batch_cooldown_max_seconds < args.batch_cooldown_min_seconds
    ):
        raise SystemExit("--batch-cooldown-max-seconds must be >= --batch-cooldown-min-seconds")
    if args.batch_size < 0:
        raise SystemExit("--batch-size must be >= 0")

    config = enrich_config_from_cookie(load_config(args.dotenv))
    require_config(config)
    config.min_submit_interval_seconds = max(0.0, float(args.submit_delay_min_seconds))

    synced_user_state = None
    if args.sync_user_state:
        if args.transport in {"auto", "browser"}:
            try:
                synced_user_state = browser_fetch_json(
                    config.user_state_url,
                    headers={
                        "accept": "*/*",
                        "content-type": "application/json",
                        "x-csrf-protection": config.csrf_protection,
                    },
                    timeout_seconds=config.timeout_seconds,
                )
            except Exception:
                if args.transport == "browser":
                    raise
                synced_user_state = get_user_state(config)
        else:
            synced_user_state = get_user_state(config)
        settings = synced_user_state.get("settings", {}) if isinstance(synced_user_state, dict) else {}
        if isinstance(settings.get("speed"), str):
            config.mode = settings["speed"]
        if isinstance(settings.get("visibility"), str):
            config.private = settings["visibility"].lower() in {"stealth", "private"}

    preset_suffix = resolve_preset_suffix(args.presets_file, args.preset)
    quality_suffix = resolve_named_suffix(args.quality_profiles_file, args.quality_profile, label="quality profile")
    results: list[dict[str, Any]] = []
    pending_items: list[dict[str, Any]] = []
    prompt_count = len(prompts)
    effective_batch_size = args.batch_size or prompt_count

    for index, original_prompt in enumerate(prompts, start=1):
        prompt = original_prompt
        if preset_suffix:
            prompt = apply_preset(prompt, preset_suffix)
        if quality_suffix:
            prompt = apply_preset(prompt, quality_suffix)
        prompt = append_negative_terms(prompt, args.negative)
        final_prompt = normalize_prompt(
            prompt,
            default_version=args.default_version,
            add_raw=not args.no_raw,
        )

        safe_limits = wait_for_safe_window(config)
        submit_result, job_id = submit_prompt(config, args, final_prompt)

        record_submit(
            config,
            prompt=final_prompt,
            mode=str(submit_result.get("payload", {}).get("f", {}).get("mode", config.mode)),
            private=bool(submit_result.get("payload", {}).get("f", {}).get("private", config.private)),
            job_id=job_id,
            dry_run=False,
            submit_url=submit_result.get("submit_url", config.submit_url),
        )

        batch_index = ((index - 1) // effective_batch_size) + 1
        pending_items.append(
            build_pending_item(
                index=index,
                batch_index=batch_index,
                original_prompt=original_prompt,
                final_prompt=final_prompt,
                safe_limits=safe_limits,
                submit_result=submit_result,
                job_id=job_id,
            )
        )

        is_batch_boundary = index % effective_batch_size == 0 or index == prompt_count
        if args.wait_mode == "serial":
            finalize_pending_items(config, args, pending_items, results, synced_user_state)
            pending_items.clear()
        elif is_batch_boundary:
            finalize_pending_items(config, args, pending_items, results, synced_user_state)
            pending_items.clear()

        if is_batch_boundary:
            maybe_sleep_between_batches(args, index, prompt_count)
        elif args.wait_mode != "serial":
            maybe_sleep_between_submits(args, index, prompt_count, is_batch_boundary)

    output = {
        "count": len(results),
        "batch_size": args.batch_size,
        "batch_cooldown_seconds": args.batch_cooldown_seconds,
        "wait_mode": args.wait_mode,
        "submit_delay_min_seconds": args.submit_delay_min_seconds,
        "submit_delay_max_seconds": args.submit_delay_max_seconds,
        "min_submit_interval_seconds": config.min_submit_interval_seconds,
        "preset": args.preset,
        "quality_profile": args.quality_profile,
        "negative": args.negative,
        "sync_user_state": bool(synced_user_state),
        "user_state": synced_user_state,
        "results": results,
    }
    if args.output:
        save_json(args.output, output)
    print_json(output)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/convert_downloads.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import json
import mimetypes
import sys
import urllib.parse
from dataclasses import dataclass
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from mj_alpha import extract_result_urls, print_json, safe_slug
from mj_browser import browser_convert_image_bytes, browser_convert_image_url


@dataclass(frozen=True)
class SourceItem:
    kind: str
    path: Path | None = None
    anchor: Path | None = None
    url: str | None = None
    group_name: str | None = None


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Convert local downloaded image files or MJ JSON manifests to PNG.")
    parser.add_argument(
        "inputs",
        nargs="*",
        default=["outputs"],
        help="Files or directories to scan. Directories scan for matching images and known MJ manifests; JSON files rebuild from stored URLs.",
    )
    parser.add_argument(
        "--glob",
        default="*.webp",
        help="Filename glob to match inside directories. Defaults to *.webp",
    )
    parser.add_argument(
        "--output-dir",
        help="Optional output root. If omitted, PNGs are written next to source files or under a manifest-named folder.",
    )
    parser.add_argument(
        "--overwrite",
        action="store_true",
        help="Overwrite existing PNG files",
    )
    parser.add_argument(
        "--delete-original",
        action="store_true",
        help="Delete the source file after successful PNG conversion",
    )
    parser.add_argument(
        "--page-url",
        default="https://alpha.midjourney.com/imagine",
        help="Browser page used for conversion context",
    )
    return parser


def collect_sources(inputs: list[str], pattern: str) -> list[SourceItem]:
    items: list[SourceItem] = []
    seen_files: set[str] = set()
    seen_urls: set[str] = set()

    def add_file(path: Path, anchor: Path) -> None:
        key = str(path.resolve())
        if key in seen_files:
            return
        seen_files.add(key)
        items.append(SourceItem(kind="file", path=path, anchor=anchor))

    def add_manifest(path: Path) -> None:
        for item in load_manifest_sources(path):
            assert item.url is not None
            if item.url in seen_urls:
                continue
            seen_urls.add(item.url)
            items.append(item)

    for raw_input in inputs:
        path = Path(raw_input)
        if not path.exists():
            raise SystemExit(f"Input path not found: {raw_input}")
        if path.is_dir():
            for file in sorted(path.rglob(pattern)):
                if file.is_file():
                    add_file(file, path)
            for manifest_name in ("recent-job.json", "downloads.json"):
                for manifest in sorted(path.rglob(manifest_name)):
                    if manifest.is_file():
                        add_manifest(manifest)
            continue
        if path.suffix.lower() == ".json":
            add_manifest(path)
            continue
        add_file(path, path.parent)
    if not items:
        raise SystemExit("No matching files or manifest URLs found for conversion.")
    return items


def load_manifest_sources(path: Path) -> list[SourceItem]:
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise SystemExit(f"Invalid JSON manifest: {path}") from exc

    urls = extract_result_urls(data)
    if not urls:
        raise SystemExit(f"No image URLs found in manifest: {path}")
    return [
        SourceItem(
            kind="url",
            path=path,
            url=url,
            group_name=safe_slug(path.stem, fallback="manifest"),
        )
        for url in urls
    ]


def detect_mime_type(path: Path) -> str:
    guessed, _ = mimetypes.guess_type(str(path))
    return guessed or "image/webp"


def looks_like_html(path: Path) -> bool:
    head = path.read_bytes()[:256].lstrip().lower()
    return head.startswith(b"<!doctype html") or head.startswith(b"<html")


def manifest_url_name(url: str) -> str:
    parsed = urllib.parse.urlparse(url)
    stem = Path(parsed.path).stem
    return safe_slug(stem or "image", fallback="image")


def build_output_path(source: SourceItem, output_root: Path | None) -> Path:
    if source.kind == "file":
        assert source.path is not None
        assert source.anchor is not None
        if output_root is None:
            return source.path.with_suffix(".png")
        relative_path = source.path.relative_to(source.anchor)
        return output_root / relative_path.with_suffix(".png")

    assert source.kind == "url"
    assert source.path is not None
    assert source.url is not None
    group_name = source.group_name or "manifest"
    if output_root is None:
        return source.path.parent / group_name / f"{manifest_url_name(source.url)}.png"
    return output_root / group_name / f"{manifest_url_name(source.url)}.png"


def convert_local_file(source: SourceItem, *, page_url: str) -> dict:
    assert source.path is not None
    if looks_like_html(source.path):
        raise RuntimeError(
            "Source file is an HTML challenge page, not a real image. "
            "Use a recent-job.json/downloads.json manifest to rebuild PNGs from the original URLs."
        )
    converted = browser_convert_image_bytes(
        source.path.read_bytes(),
        input_mime_type=detect_mime_type(source.path),
        output_format="png",
        page_url=page_url,
        timeout_seconds=60.0,
    )
    return {
        "bytes": converted["bytes"],
        "width": converted.get("width"),
        "height": converted.get("height"),
        "mime_type": converted.get("mime_type"),
    }


def convert_remote_url(source: SourceItem, *, page_url: str) -> dict:
    assert source.url is not None
    converted = browser_convert_image_url(
        source.url,
        output_format="png",
        page_url=page_url,
        timeout_seconds=60.0,
    )
    return {
        "bytes": converted["bytes"],
        "width": converted.get("width"),
        "height": converted.get("height"),
        "mime_type": converted.get("mime_type"),
    }


def convert_one(source: SourceItem, *, output_root: Path | None, overwrite: bool, delete_original: bool, page_url: str) -> dict:
    output_path = build_output_path(source, output_root)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    if output_path.exists() and not overwrite:
        return {
            "kind": source.kind,
            "source": str(source.path if source.kind == "file" else source.url),
            "output": str(output_path),
            "skipped": True,
            "reason": "output_exists",
        }

    try:
        converted = convert_local_file(source, page_url=page_url) if source.kind == "file" else convert_remote_url(source, page_url=page_url)
        output_path.write_bytes(converted["bytes"])

        if delete_original and source.kind == "file" and source.path is not None and source.path.resolve() != output_path.resolve():
            source.path.unlink()

        return {
            "kind": source.kind,
            "source": str(source.path if source.kind == "file" else source.url),
            "manifest": str(source.path) if source.kind == "url" and source.path is not None else None,
            "output": str(output_path),
            "skipped": False,
            "deleted_original": bool(delete_original and source.kind == "file"),
            "size_bytes": len(converted["bytes"]),
            "width": converted.get("width"),
            "height": converted.get("height"),
            "mime_type": converted.get("mime_type"),
        }
    except Exception as exc:
        return {
            "kind": source.kind,
            "source": str(source.path if source.kind == "file" else source.url),
            "manifest": str(source.path) if source.kind == "url" and source.path is not None else None,
            "output": str(output_path),
            "skipped": True,
            "reason": "conversion_failed",
            "error": str(exc),
        }


def main() -> int:
    args = build_parser().parse_args()
    output_root = Path(args.output_dir) if args.output_dir else None
    sources = collect_sources(args.inputs, args.glob)
    results = [
        convert_one(
            source,
            output_root=output_root,
            overwrite=args.overwrite,
            delete_original=args.delete_original,
            page_url=args.page_url,
        )
        for source in sources
    ]
    converted = [item for item in results if not item.get("skipped")]
    skipped = [item for item in results if item.get("skipped")]
    print_json(
        {
            "count": len(results),
            "converted": len(converted),
            "skipped": len(skipped),
            "results": results,
        }
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/get_user_state.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from mj_browser import browser_fetch_json
from mj_alpha import enrich_config_from_cookie, get_user_state, load_config, print_json, require_config, save_json


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Fetch Midjourney Alpha user-mutable-state.")
    parser.add_argument("--dotenv", default=".env", help="Path to .env file")
    parser.add_argument("--transport", choices=["auto", "browser", "http"], default="auto", help="Request transport")
    parser.add_argument("--output", help="Write JSON result to file")
    return parser


def main() -> int:
    args = build_parser().parse_args()
    config = enrich_config_from_cookie(load_config(args.dotenv))
    require_config(config, need_channel_id=False)

    response = None
    if args.transport in {"auto", "browser"}:
        try:
            response = browser_fetch_json(
                config.user_state_url,
                headers={
                    "accept": "*/*",
                    "content-type": "application/json",
                    "x-csrf-protection": config.csrf_protection,
                },
                timeout_seconds=config.timeout_seconds,
            )
        except Exception:
            if args.transport == "browser":
                raise
    if response is None:
        response = get_user_state(config)

    result = {
        "user_state_url": config.user_state_url,
        "response": response,
    }

    if args.output:
        save_json(args.output, result)
    print_json(result)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/list_recent_jobs.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import sys
from pathlib import Path
from urllib.parse import urlencode

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from mj_browser import browser_fetch_json
from mj_alpha import (
    enrich_config_from_cookie,
    load_config,
    print_json,
    request_recent_jobs,
    require_config,
    save_json,
    summarize_job_images,
)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="List recent Midjourney jobs using the experimental recent-jobs endpoint.")
    parser.add_argument("--dotenv", default=".env", help="Path to .env file")
    parser.add_argument("--transport", choices=["auto", "browser", "http"], default="auto", help="Request transport")
    parser.add_argument("--user-id", help="Override MJ_USER_ID")
    parser.add_argument("--page", type=int, default=1, help="Recent jobs page number")
    parser.add_argument("--amount", type=int, default=25, help="Jobs per page")
    parser.add_argument("--order-by", default="new", help="Order parameter")
    parser.add_argument("--job-status", help="Optional jobStatus filter")
    parser.add_argument("--job-type", help="Optional jobType filter")
    parser.add_argument("--output", help="Write JSON result to file")
    return parser


def main() -> int:
    args = build_parser().parse_args()
    config = enrich_config_from_cookie(load_config(args.dotenv))
    if args.user_id:
        config.user_id = args.user_id
    require_config(config, need_channel_id=False)

    response = None
    resolved_user_id = args.user_id or config.user_id
    if args.transport in {"auto", "browser"}:
        try:
            params = {
                "userId": resolved_user_id,
                "page": str(args.page),
                "amount": str(args.amount),
                "orderBy": args.order_by,
                "dedupe": "true",
                "refreshApi": "0",
            }
            params["jobStatus"] = args.job_status or "completed"
            if args.job_type:
                params["jobType"] = args.job_type
            url = f"{config.recent_jobs_url}?{urlencode(params)}"
            response = browser_fetch_json(
                url,
                page_url="https://www.midjourney.com/",
                headers={"accept": "*/*", "content-type": "application/json"},
                timeout_seconds=config.timeout_seconds,
            )
        except Exception:
            if args.transport == "browser":
                raise
    if response is None:
        response = request_recent_jobs(
            config,
            user_id=args.user_id,
            page=args.page,
            amount=args.amount,
            order_by=args.order_by,
            job_status=args.job_status,
            job_type=args.job_type,
        )
    jobs = response if isinstance(response, list) else []
    result = {
        "recent_jobs_url": config.recent_jobs_url,
        "user_id": config.user_id,
        "count": len(jobs),
        "summary": [summarize_job_images(job) for job in jobs if isinstance(job, dict)],
        "response": response,
    }

    if args.output:
        save_json(args.output, result)
    print_json(result)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/mj_alpha.py
content: #!/usr/bin/env python3

from __future__ import annotations

import json
import os
import random
import re
import ssl
import subprocess
import sys
import time
import urllib.parse
import urllib.error
import urllib.request
import base64
from dataclasses import dataclass
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path
from typing import Any


DEFAULT_METADATA = {
    "isMobile": None,
    "imagePrompts": 0,
    "imageReferences": 0,
    "characterReferences": 0,
    "depthReferences": 0,
    "lightboxOpen": None,
}

IMAGE_URL_RE = re.compile(r"https://[^\s\"']+\.(?:png|jpg|jpeg|webp)(?:\?[^\s\"']*)?$", re.IGNORECASE)


def env_bool(name: str, default: bool) -> bool:
    value = os.getenv(name)
    if value is None:
        return default
    return value.strip().lower() in {"1", "true", "yes", "on"}


def env_float(name: str, default: float) -> float:
    value = os.getenv(name)
    if not value:
        return default
    try:
        return float(value)
    except ValueError as exc:
        raise ValueError(f"{name} must be a number, got: {value}") from exc


def load_dotenv(dotenv_path: str | Path | None = None) -> None:
    path = Path(dotenv_path or ".env")
    if not path.exists():
        return

    for raw_line in path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        key = key.strip()
        value = value.strip().strip("'").strip('"')
        os.environ.setdefault(key, value)


@dataclass
class MJConfig:
    base_url: str
    submit_path: str
    user_state_path: str
    recent_jobs_url: str
    status_url_template: str | None
    cookie: str
    channel_id: str
    user_id: str | None
    mode: str
    private: bool
    timeout_seconds: float
    poll_interval_seconds: float
    min_submit_interval_seconds: float
    max_submits_per_hour: int
    max_submits_per_day: int
    state_dir: str
    referer_path: str
    origin: str
    csrf_protection: str
    user_agent: str
    metrics_token: str | None
    extra_headers: dict[str, str]

    @property
    def submit_url(self) -> str:
        return self.base_url.rstrip("/") + "/" + self.submit_path.lstrip("/")

    @property
    def user_state_url(self) -> str:
        return self.base_url.rstrip("/") + "/" + self.user_state_path.lstrip("/")


def load_config(dotenv_path: str | Path | None = None) -> MJConfig:
    load_dotenv(dotenv_path)

    extra_headers_raw = os.getenv("MJ_EXTRA_HEADERS_JSON", "").strip()
    extra_headers: dict[str, str] = {}
    if extra_headers_raw:
        try:
            parsed = json.loads(extra_headers_raw)
        except json.JSONDecodeError as exc:
            raise ValueError("MJ_EXTRA_HEADERS_JSON must be valid JSON") from exc
        if not isinstance(parsed, dict):
            raise ValueError("MJ_EXTRA_HEADERS_JSON must be a JSON object")
        extra_headers = {str(key): str(value) for key, value in parsed.items()}

    return MJConfig(
        base_url=os.getenv("MJ_BASE_URL", "https://alpha.midjourney.com"),
        submit_path=os.getenv("MJ_SUBMIT_PATH", "/api/submit-jobs"),
        user_state_path=os.getenv("MJ_USER_STATE_PATH", "/api/user-mutable-state"),
        recent_jobs_url=os.getenv("MJ_RECENT_JOBS_URL", "https://www.midjourney.com/api/app/recent-jobs/"),
        status_url_template=os.getenv("MJ_STATUS_URL_TEMPLATE") or None,
        cookie=os.getenv("MJ_COOKIE", ""),
        channel_id=os.getenv("MJ_CHANNEL_ID", ""),
        user_id=os.getenv("MJ_USER_ID") or None,
        mode=os.getenv("MJ_MODE", "fast"),
        private=env_bool("MJ_PRIVATE", True),
        timeout_seconds=env_float("MJ_TIMEOUT_SECONDS", 60.0),
        poll_interval_seconds=env_float("MJ_POLL_INTERVAL_SECONDS", 5.0),
        min_submit_interval_seconds=env_float("MJ_MIN_SUBMIT_INTERVAL_SECONDS", 3.0),
        max_submits_per_hour=int(os.getenv("MJ_MAX_SUBMITS_PER_HOUR", "0")),
        max_submits_per_day=int(os.getenv("MJ_MAX_SUBMITS_PER_DAY", "0")),
        state_dir=os.getenv("MJ_STATE_DIR", "state"),
        referer_path=os.getenv("MJ_REFERER_PATH", "/explore?tab=hot"),
        origin=os.getenv("MJ_ORIGIN", "https://alpha.midjourney.com"),
        csrf_protection=os.getenv("MJ_CSRF_PROTECTION", "1"),
        user_agent=os.getenv(
            "MJ_USER_AGENT",
            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
            "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
        ),
        metrics_token=os.getenv("MJ_METRICS_TOKEN") or None,
        extra_headers=extra_headers,
    )


def enrich_config_from_cookie(config: MJConfig) -> MJConfig:
    claims = extract_auth_claims_from_cookie(config.cookie)
    if claims:
        midjourney_id = claims.get("midjourney_id")
        if not config.user_id and midjourney_id:
            config.user_id = str(midjourney_id)
        if not config.channel_id and midjourney_id:
            config.channel_id = f"singleplayer_{midjourney_id}"
    return config


def require_config(
    config: MJConfig,
    need_status: bool = False,
    need_cookie: bool = True,
    need_channel_id: bool = True,
) -> None:
    missing: list[str] = []
    if need_cookie and not config.cookie:
        missing.append("MJ_COOKIE")
    if need_channel_id and not config.channel_id:
        missing.append("MJ_CHANNEL_ID")
    if need_status and not config.status_url_template:
        missing.append("MJ_STATUS_URL_TEMPLATE")

    if missing:
        raise SystemExit(f"Missing required config: {', '.join(missing)}")


def build_traceparent() -> str:
    trace_id = f"{random.getrandbits(128):032x}"
    parent_id = f"{random.getrandbits(64):016x}"
    return f"00-{trace_id}-{parent_id}-01"


def build_headers(config: MJConfig) -> dict[str, str]:
    headers = {
        "accept": "*/*",
        "content-type": "application/json",
        "cookie": config.cookie,
        "origin": config.origin,
        "referer": config.origin.rstrip("/") + config.referer_path,
        "user-agent": config.user_agent,
        "x-csrf-protection": config.csrf_protection,
        "x-mj-traceparent": build_traceparent(),
    }
    if config.metrics_token:
        headers["x-metrics-token"] = config.metrics_token
    headers.update(config.extra_headers)
    return headers


def post_json(url: str, payload: dict[str, Any], headers: dict[str, str], timeout_seconds: float) -> Any:
    body = json.dumps(payload).encode("utf-8")
    request = urllib.request.Request(url=url, data=body, headers=headers, method="POST")
    return _open_json(request, timeout_seconds)


def get_json(url: str, headers: dict[str, str], timeout_seconds: float) -> Any:
    request = urllib.request.Request(url=url, headers=headers, method="GET")
    return _open_json(request, timeout_seconds)


def _open_json(request: urllib.request.Request, timeout_seconds: float) -> Any:
    try:
        raw = _open_text(request, timeout_seconds)
        if not raw.strip():
            return {}
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            match = re.search(r"<pre[^>]*>(.*?)</pre>", raw, re.IGNORECASE | re.DOTALL)
            if match:
                return json.loads(match.group(1))
            raise
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {exc.code} for {request.full_url}: {body}") from exc
    except (urllib.error.URLError, ssl.SSLError) as exc:
        raise RuntimeError(f"Request failed for {request.full_url}: {exc}") from exc


def _open_text(request: urllib.request.Request, timeout_seconds: float) -> str:
    try:
        with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
            return response.read().decode("utf-8")
    except urllib.error.HTTPError:
        raise
    except (urllib.error.URLError, ssl.SSLError) as exc:
        if not _is_tls_protocol_error(exc):
            raise
        return _curl_request_text(request, timeout_seconds)


def _is_tls_protocol_error(exc: Exception) -> bool:
    message = str(exc).lower()
    return "tlsv1 alert protocol version" in message or "wrong version number" in message


def _curl_request_text(request: urllib.request.Request, timeout_seconds: float) -> str:
    return _curl_request_bytes(request, timeout_seconds).decode("utf-8")


def _curl_request_bytes(request: urllib.request.Request, timeout_seconds: float) -> bytes:
    command = [
        "curl",
        "-sS",
        "-L",
        "--compressed",
        "--max-time",
        str(int(max(1, timeout_seconds))),
        "-X",
        request.get_method(),
        request.full_url,
    ]
    for key, value in request.header_items():
        command.extend(["-H", f"{key}: {value}"])

    data = request.data
    if data:
        command.extend(["--data-binary", "@-"])

    completed = subprocess.run(
        command,
        input=data,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        check=False,
    )
    if completed.returncode != 0:
        stderr = completed.stderr.decode("utf-8", errors="replace").strip()
        raise RuntimeError(f"curl request failed for {request.full_url}: {stderr}")
    return completed.stdout


def normalize_prompt(prompt: str, *, default_version: str = "8", add_raw: bool = True) -> str:
    cleaned = " ".join(prompt.strip().split())
    if not cleaned:
        raise ValueError("Prompt must not be empty")

    if "--v" not in cleaned and "--version" not in cleaned:
        cleaned = f"{cleaned} --v {default_version}"

    if add_raw and "--raw" not in cleaned:
        cleaned = f"{cleaned} --raw"

    return cleaned


def append_negative_terms(prompt: str, negative_terms: str | None) -> str:
    if not negative_terms or not negative_terms.strip():
        return " ".join(prompt.strip().split())
    cleaned_prompt = " ".join(prompt.strip().split())
    cleaned_negative = " ".join(negative_terms.strip().split())
    if not cleaned_negative:
        return cleaned_prompt
    if "--no" in cleaned_prompt:
        return cleaned_prompt
    return f"{cleaned_prompt} --no {cleaned_negative}".strip()


def apply_preset(prompt: str, preset_suffix: str) -> str:
    cleaned_prompt = " ".join(prompt.strip().split())
    cleaned_suffix = " ".join(preset_suffix.strip().split())
    if not cleaned_suffix:
        return cleaned_prompt
    if cleaned_prompt.endswith(cleaned_suffix):
        return cleaned_prompt
    return f"{cleaned_prompt} {cleaned_suffix}".strip()


def resolve_named_suffix(path: str | Path, name: str | None, *, label: str) -> str | None:
    if not name:
        return None
    data = load_json(path, {})
    if not isinstance(data, dict):
        raise SystemExit(f"Invalid {label} file: {path}")
    item = data.get(name)
    if not isinstance(item, dict):
        raise SystemExit(f"{label.title()} not found: {name}")
    suffix = item.get("suffix")
    if not isinstance(suffix, str):
        raise SystemExit(f"{label.title()} {name} does not define a valid suffix")
    return " ".join(suffix.strip().split())


def build_imagine_payload(
    prompt: str,
    *,
    channel_id: str,
    mode: str,
    private: bool,
    metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
    return {
        "f": {
            "mode": mode,
            "private": private,
        },
        "channelId": channel_id,
        "metadata": metadata or DEFAULT_METADATA,
        "t": "imagine",
        "prompt": prompt,
    }


def submit_imagine(
    config: MJConfig,
    prompt: str,
    *,
    mode: str | None = None,
    private: bool | None = None,
    channel_id: str | None = None,
    dry_run: bool = False,
) -> dict[str, Any]:
    payload = build_imagine_payload(
        prompt,
        channel_id=channel_id or config.channel_id,
        mode=mode or config.mode,
        private=config.private if private is None else private,
    )

    if dry_run:
        return {
            "dry_run": True,
            "submit_url": config.submit_url,
            "payload": payload,
            "headers_preview": redact_headers(build_headers(config)),
        }

    response = post_json(
        config.submit_url,
        payload,
        headers=build_headers(config),
        timeout_seconds=config.timeout_seconds,
    )
    return {
        "dry_run": False,
        "submit_url": config.submit_url,
        "payload": payload,
        "response": response,
        "job_ids": extract_job_ids(response),
    }


def resolve_status_url(status_url_template: str | None, job_id: str) -> str | None:
    if not status_url_template:
        return None
    return status_url_template.format(job_id=job_id)


def get_user_state(config: MJConfig) -> dict[str, Any]:
    return get_json(
        config.user_state_url,
        headers=build_headers(config),
        timeout_seconds=config.timeout_seconds,
    )


def request_recent_jobs(
    config: MJConfig,
    *,
    user_id: str | None = None,
    page: int = 1,
    amount: int = 50,
    order_by: str = "new",
    job_status: str | None = None,
    job_type: str | None = None,
    ) -> Any:
    resolved_user_id = user_id or config.user_id
    if not resolved_user_id:
        raise RuntimeError("MJ_USER_ID is not configured and could not be inferred from the auth cookie")

    params = {
        "userId": resolved_user_id,
        "page": str(page),
        "amount": str(amount),
        "orderBy": order_by,
    }
    if job_status:
        params["jobStatus"] = job_status
    if job_type:
        params["jobType"] = job_type

    url = f"{config.recent_jobs_url}?{urllib.parse.urlencode(params)}"
    return get_json(
        url,
        headers=build_headers(config),
        timeout_seconds=config.timeout_seconds,
    )


def find_job_by_id(data: Any, job_id: str) -> dict[str, Any] | None:
    if isinstance(data, list):
        for item in data:
            if isinstance(item, dict) and str(item.get("id", "")) == job_id:
                return item
    elif isinstance(data, dict):
        if str(data.get("id", "")) == job_id:
            return data
        for value in data.values():
            found = find_job_by_id(value, job_id)
            if found:
                return found
    return None


def wait_for_recent_job(
    config: MJConfig,
    job_id: str,
    *,
    page: int = 1,
    amount: int = 25,
    attempts: int = 40,
    interval_seconds: float | None = None,
    job_status: str = "completed",
) -> dict[str, Any]:
    interval = config.poll_interval_seconds if interval_seconds is None else interval_seconds
    history: list[dict[str, Any]] = []

    for attempt in range(1, max(1, attempts) + 1):
        response = request_recent_jobs(
            config,
            page=page,
            amount=amount,
            job_status=job_status,
        )
        found = find_job_by_id(response, job_id)
        history.append(
            {
                "attempt": attempt,
                "timestamp": int(time.time()),
                "match_found": found is not None,
            }
        )
        if found:
            return {
                "job_id": job_id,
                "attempts": attempt,
                "done": True,
                "history": history,
                "job": found,
                "result_urls": list(found.get("image_paths") or []),
            }
        if attempt < attempts:
            time.sleep(interval)

    return {
        "job_id": job_id,
        "attempts": attempts,
        "done": False,
        "history": history,
        "job": None,
        "result_urls": [],
    }


def poll_job(
    config: MJConfig,
    job_id: str,
    *,
    attempts: int = 60,
    interval_seconds: float | None = None,
    once: bool = False,
) -> dict[str, Any]:
    status_url = resolve_status_url(config.status_url_template, job_id)
    if not status_url:
        raise RuntimeError("MJ_STATUS_URL_TEMPLATE is not configured")

    interval = config.poll_interval_seconds if interval_seconds is None else interval_seconds
    headers = build_headers(config)
    history: list[dict[str, Any]] = []

    for attempt in range(1, max(1, attempts) + 1):
        data = get_json(status_url, headers=headers, timeout_seconds=config.timeout_seconds)
        snapshot = {
            "attempt": attempt,
            "timestamp": int(time.time()),
            "data": data,
        }
        history.append(snapshot)

        if once or is_terminal_job_response(data):
            return {
                "job_id": job_id,
                "status_url": status_url,
                "attempts": attempt,
                "done": True,
                "history": history,
                "result_urls": extract_result_urls(data),
            }

        if attempt < attempts:
            time.sleep(interval)

    return {
        "job_id": job_id,
        "status_url": status_url,
        "attempts": attempts,
        "done": False,
        "history": history,
        "result_urls": extract_result_urls(history[-1]["data"]) if history else [],
    }


def is_terminal_job_response(data: Any) -> bool:
    if not isinstance(data, (dict, list)):
        return True

    result_urls = extract_result_urls(data)
    if result_urls:
        return True

    terminal_values = {"complete", "completed", "done", "finished", "failed", "cancelled", "canceled", "error"}
    for value in iter_scalar_values(data):
        lowered = str(value).strip().lower()
        if lowered in terminal_values:
            return True

    progress = find_progress_value(data)
    if progress is not None and progress >= 100:
        return True

    return False


def find_progress_value(data: Any) -> float | None:
    if isinstance(data, dict):
        for key, value in data.items():
            if str(key).lower() in {"progress", "percent", "percentage"}:
                try:
                    return float(value)
                except (TypeError, ValueError):
                    return None
            nested = find_progress_value(value)
            if nested is not None:
                return nested
    elif isinstance(data, list):
        for item in data:
            nested = find_progress_value(item)
            if nested is not None:
                return nested
    return None


def iter_scalar_values(data: Any) -> list[Any]:
    values: list[Any] = []
    if isinstance(data, dict):
        for value in data.values():
            values.extend(iter_scalar_values(value))
    elif isinstance(data, list):
        for item in data:
            values.extend(iter_scalar_values(item))
    else:
        values.append(data)
    return values


def extract_job_ids(data: Any) -> list[str]:
    job_ids: list[str] = []
    walk_and_collect(data, lambda value: isinstance(value, str) and looks_like_job_id(value), job_ids)
    return dedupe(job_ids)


def extract_result_urls(data: Any) -> list[str]:
    urls: list[str] = []
    walk_and_collect(data, lambda value: isinstance(value, str) and IMAGE_URL_RE.match(value) is not None, urls)
    return dedupe(urls)


def fetch_binary(url: str, headers: dict[str, str], timeout_seconds: float) -> tuple[bytes, str | None]:
    request = urllib.request.Request(url=url, headers=headers, method="GET")
    try:
        with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
            return response.read(), response.headers.get_content_type()
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {exc.code} for {request.full_url}: {body}") from exc
    except (urllib.error.URLError, ssl.SSLError) as exc:
        if _is_tls_protocol_error(exc):
            return _curl_request_bytes(request, timeout_seconds), None
        raise RuntimeError(f"Request failed for {request.full_url}: {exc}") from exc


def infer_extension_from_url_or_type(url: str, content_type: str | None = None) -> str:
    parsed = urllib.parse.urlparse(url)
    suffix = Path(parsed.path).suffix.lower()
    if suffix:
        return suffix
    if content_type == "image/png":
        return ".png"
    if content_type == "image/jpeg":
        return ".jpg"
    if content_type == "image/webp":
        return ".webp"
    return ".bin"


def safe_slug(value: str, fallback: str = "image") -> str:
    cleaned = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip()).strip("-_.")
    return cleaned[:80] or fallback


def convert_image_bytes_to_png(data: bytes) -> dict[str, Any]:
    from PIL import Image

    with Image.open(BytesIO(data)) as image:
        width, height = image.size
        converted = BytesIO()
        image.save(converted, format="PNG")
        return {
            "bytes": converted.getvalue(),
            "mime_type": "image/png",
            "width": width,
            "height": height,
        }


def download_image(
    config: MJConfig,
    image_url: str,
    output_dir: str | Path,
    *,
    filename_stem: str,
    convert_to: str = "original",
) -> dict[str, Any]:
    path = Path(output_dir)
    path.mkdir(parents=True, exist_ok=True)
    if convert_to.lower() == "png":
        from mj_browser import browser_convert_image_url, browser_fetch_binary_url

        converted: dict[str, Any] | None = None
        last_error: Exception | None = None
        for attempt in range(1, 4):
            try:
                converted = browser_convert_image_url(
                    image_url,
                    output_format="png",
                    page_url="https://alpha.midjourney.com/imagine",
                    timeout_seconds=max(config.timeout_seconds, 60.0),
                )
                break
            except Exception as exc:
                last_error = exc
                try:
                    fetched = browser_fetch_binary_url(
                        image_url,
                        page_url="https://alpha.midjourney.com/imagine",
                        timeout_seconds=max(config.timeout_seconds, 60.0),
                    )
                    converted = convert_image_bytes_to_png(fetched["bytes"])
                    break
                except Exception as fetch_exc:
                    last_error = fetch_exc
                    if attempt < 3:
                        time.sleep(float(attempt))
        if converted is None:
            raise last_error or RuntimeError(f"Failed to download image as PNG: {image_url}")
        output_path = path / f"{safe_slug(filename_stem)}.png"
        output_path.write_bytes(converted["bytes"])
        return {
            "url": image_url,
            "content_type": converted.get("mime_type"),
            "size_bytes": len(converted["bytes"]),
            "path": str(output_path),
            "converted_to": "png",
            "width": converted.get("width"),
            "height": converted.get("height"),
        }

    data, content_type = fetch_binary(
        image_url,
        headers=build_headers(config),
        timeout_seconds=config.timeout_seconds,
    )
    extension = infer_extension_from_url_or_type(image_url, content_type)
    output_path = path / f"{safe_slug(filename_stem)}{extension}"
    output_path.write_bytes(data)
    return {
        "url": image_url,
        "content_type": content_type,
        "size_bytes": len(data),
        "path": str(output_path),
        "converted_to": "original",
    }


def summarize_job_images(job: dict[str, Any]) -> dict[str, Any]:
    image_paths = list(job.get("image_paths") or [])
    batch_size = None
    for key in ("batch_size",):
        if key in job:
            batch_size = job.get(key)
    if batch_size is None and isinstance(job.get("meta"), dict):
        batch_size = job["meta"].get("batch_size")
    if batch_size is None:
        batch_size = 4 if image_paths else None

    return {
        "job_id": job.get("id"),
        "prompt": job.get("prompt") or job.get("full_command"),
        "job_type": job.get("job_type"),
        "image_count": len(image_paths),
        "batch_size": batch_size,
        "image_paths": image_paths,
        "enqueue_time": job.get("enqueue_time"),
    }


def looks_like_job_id(value: str) -> bool:
    return re.fullmatch(
        r"[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",
        value,
        re.IGNORECASE,
    ) is not None


def walk_and_collect(data: Any, predicate: Any, sink: list[str]) -> None:
    if isinstance(data, dict):
        for value in data.values():
            walk_and_collect(value, predicate, sink)
        return
    if isinstance(data, list):
        for item in data:
            walk_and_collect(item, predicate, sink)
        return
    if predicate(data):
        sink.append(str(data))


def dedupe(values: list[str]) -> list[str]:
    ordered: list[str] = []
    seen: set[str] = set()
    for value in values:
        if value not in seen:
            ordered.append(value)
            seen.add(value)
    return ordered


def redact_headers(headers: dict[str, str]) -> dict[str, str]:
    redacted = dict(headers)
    for key in list(redacted):
        lowered = key.lower()
        if lowered == "cookie" or "token" in lowered or "authorization" in lowered or lowered.startswith("x-auth"):
            redacted[key] = "<redacted>"
    return redacted


def save_json(path: str | Path, data: Any) -> None:
    output_path = Path(path)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def print_json(data: Any) -> None:
    json.dump(data, sys.stdout, ensure_ascii=False, indent=2)
    sys.stdout.write("\n")


def load_json(path: str | Path, default: Any) -> Any:
    file_path = Path(path)
    if not file_path.exists():
        return default
    try:
        return json.loads(file_path.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return default


def state_dir(config: MJConfig) -> Path:
    path = Path(config.state_dir)
    path.mkdir(parents=True, exist_ok=True)
    return path


def usage_log_path(config: MJConfig) -> Path:
    return state_dir(config) / "usage-log.json"


def load_usage_log(config: MJConfig) -> list[dict[str, Any]]:
    data = load_json(usage_log_path(config), [])
    if not isinstance(data, list):
        return []
    return [item for item in data if isinstance(item, dict)]


def save_usage_log(config: MJConfig, entries: list[dict[str, Any]]) -> None:
    save_json(usage_log_path(config), entries)


def enforce_safe_limits(config: MJConfig, *, now_ts: float | None = None) -> dict[str, Any]:
    now = now_ts or time.time()
    entries = load_usage_log(config)
    one_hour_ago = now - 3600
    one_day_ago = now - 86400

    recent_hour = [entry for entry in entries if float(entry.get("timestamp", 0)) >= one_hour_ago]
    recent_day = [entry for entry in entries if float(entry.get("timestamp", 0)) >= one_day_ago]
    last_submit = max((float(entry.get("timestamp", 0)) for entry in entries), default=0.0)
    seconds_since_last = now - last_submit if last_submit else None

    if seconds_since_last is not None and seconds_since_last < config.min_submit_interval_seconds:
        wait_for = round(config.min_submit_interval_seconds - seconds_since_last, 1)
        raise RuntimeError(
            f"Safe limit blocked this submit: wait at least {wait_for} more seconds "
            f"before sending the next Midjourney request."
        )

    if config.max_submits_per_hour > 0 and len(recent_hour) >= config.max_submits_per_hour:
        raise RuntimeError(
            f"Safe limit blocked this submit: hourly cap reached "
            f"({len(recent_hour)}/{config.max_submits_per_hour})."
        )

    if config.max_submits_per_day > 0 and len(recent_day) >= config.max_submits_per_day:
        raise RuntimeError(
            f"Safe limit blocked this submit: daily cap reached "
            f"({len(recent_day)}/{config.max_submits_per_day})."
        )

    return {
        "last_submit_at": int(last_submit) if last_submit else None,
        "submits_last_hour": len(recent_hour),
        "submits_last_day": len(recent_day),
        "min_submit_interval_seconds": config.min_submit_interval_seconds,
        "max_submits_per_hour": config.max_submits_per_hour,
        "max_submits_per_day": config.max_submits_per_day,
    }


def record_submit(
    config: MJConfig,
    *,
    prompt: str,
    mode: str,
    private: bool,
    job_id: str | None,
    dry_run: bool,
    submit_url: str,
) -> dict[str, Any]:
    now = time.time()
    entry = {
        "timestamp": now,
        "iso_utc": datetime.fromtimestamp(now, tz=timezone.utc).isoformat(),
        "prompt": prompt,
        "mode": mode,
        "private": private,
        "job_id": job_id,
        "dry_run": dry_run,
        "submit_url": submit_url,
    }
    entries = load_usage_log(config)
    entries.append(entry)
    entries = [item for item in entries if float(item.get("timestamp", 0)) >= now - (7 * 86400)]
    save_usage_log(config, entries)
    return entry


def decode_jwt_without_verification(token: str) -> dict[str, Any] | None:
    parts = token.split(".")
    if len(parts) < 2:
        return None
    payload = parts[1]
    padding = "=" * (-len(payload) % 4)
    try:
        decoded = base64.urlsafe_b64decode(payload + padding)
        parsed = json.loads(decoded.decode("utf-8"))
    except Exception:
        return None
    if not isinstance(parsed, dict):
        return None
    return parsed


def extract_auth_claims_from_cookie(cookie_header: str) -> dict[str, Any] | None:
    if not cookie_header:
        return None
    for part in cookie_header.split(";"):
        segment = part.strip()
        if not segment or "=" not in segment:
            continue
        key, value = segment.split("=", 1)
        if key.strip() == "__Host-Midjourney.AuthUserTokenV3_i":
            return decode_jwt_without_verification(value.strip())
    return None


path: scripts/mj_browser.py
content: #!/usr/bin/env python3

from __future__ import annotations

import json
import os
import platform
import shutil
import subprocess
import time
import urllib.parse
import urllib.request
import base64
import re
from pathlib import Path
from typing import Any


MIDJOURNEY_TAB_URL = "https://alpha.midjourney.com/imagine"
SCRIPT_DIR = Path(__file__).resolve().parent
CDP_PORT = int(os.getenv("MJ_CDP_PORT", "9222"))
CDP_PROFILE_DIR = os.getenv("MJ_CDP_PROFILE_DIR", str(SCRIPT_DIR.parent / ".chrome-cdp-profile"))
CHROME_APP = os.getenv("MJ_CHROME_APP", "").strip()
CHROME_PROXY = os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY") or ""
JOB_TILE_RE = re.compile(r"/(?P<tile>\d+_\d+)(?:_[^/?]+)?\.(?:png|jpe?g|webp)(?:\?|$)", re.IGNORECASE)
BROWSER_BACKEND = os.getenv("MJ_BROWSER_BACKEND", "auto").strip().lower()


def _json_version_url() -> str:
    return f"http://127.0.0.1:{CDP_PORT}/json/version"


def _check_cdp_ready() -> bool:
    try:
        with urllib.request.urlopen(_json_version_url(), timeout=1) as response:
            return response.status == 200
    except Exception:
        return False


def _chrome_launch_args(url: str) -> list[str]:
    args = [
        f"--remote-debugging-port={CDP_PORT}",
        f"--user-data-dir={CDP_PROFILE_DIR}",
        "--new-window",
    ]
    if CHROME_PROXY:
        args.append(f"--proxy-server={CHROME_PROXY}")
    args.append(url)
    return args


def _dedupe_strings(values: list[str]) -> list[str]:
    seen: set[str] = set()
    ordered: list[str] = []
    for value in values:
        if not value or value in seen:
            continue
        seen.add(value)
        ordered.append(value)
    return ordered


def _chrome_binary_candidates() -> list[str]:
    system = platform.system()
    configured = CHROME_APP
    if system == "Darwin":
        return _dedupe_strings(
            [
                configured,
                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
                "/Applications/Google Chrome.app",
            ]
        )
    if system == "Windows":
        return _dedupe_strings(
            [
                configured,
                r"C:\Program Files\Google\Chrome\Application\chrome.exe",
                r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
                "chrome.exe",
            ]
        )
    return _dedupe_strings(
        [
            configured,
            shutil.which("google-chrome") or "",
            shutil.which("chromium") or "",
            shutil.which("chromium-browser") or "",
            shutil.which("chrome") or "",
        ]
    )


def _launch_chrome(command: list[str], *, use_open: bool) -> tuple[bool, str]:
    try:
        if use_open:
            completed = subprocess.run(
                command,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                check=False,
            )
            if completed.returncode != 0:
                return False, completed.stderr.strip() or "open returned non-zero exit status"
            return True, ""

        subprocess.Popen(
            command,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            stdin=subprocess.DEVNULL,
            start_new_session=True,
        )
        return True, ""
    except FileNotFoundError as exc:
        return False, str(exc)
    except Exception as exc:
        return False, str(exc)


def _launch_debug_chrome(url: str) -> None:
    system = platform.system()
    launch_args = _chrome_launch_args(url)
    errors: list[str] = []
    for candidate in _chrome_binary_candidates():
        use_open = system == "Darwin" and candidate.endswith(".app")
        if use_open:
            command = ["open", "-na", candidate, "--args", *launch_args]
        else:
            command = [candidate, *launch_args]
        ok, error = _launch_chrome(command, use_open=use_open)
        if ok:
            return
        errors.append(f"{candidate}: {error}")

    raise RuntimeError(
        "Failed to launch Chrome debug session. Set MJ_CHROME_APP to your Chrome executable or app path. "
        + " | ".join(errors)
    )


def ensure_debug_chrome(url: str = MIDJOURNEY_TAB_URL) -> None:
    if _check_cdp_ready():
        return

    Path(CDP_PROFILE_DIR).mkdir(parents=True, exist_ok=True)
    _launch_debug_chrome(url)

    deadline = time.time() + 15
    while time.time() < deadline:
        if _check_cdp_ready():
            return
        time.sleep(0.5)
    raise RuntimeError("Chrome DevTools endpoint did not become ready")


def _run_cdp_fetch(
    url: str,
    *,
    page_url: str,
    method: str,
    headers: dict[str, str] | None,
    payload: dict[str, Any] | None,
    timeout_seconds: float,
) -> Any:
    ensure_debug_chrome()
    command = [
        "node",
        str(SCRIPT_DIR / "mj_cdp_fetch.mjs"),
        "--debug-port",
        str(CDP_PORT),
        "--page-url",
        page_url,
        "--request-url",
        url,
        "--method",
        method,
        "--headers-json",
        json.dumps(headers or {}),
        "--timeout-seconds",
        str(timeout_seconds),
    ]
    cookie = os.getenv("MJ_COOKIE", "")
    if cookie:
        command.extend(["--cookie-header", cookie])
    if payload is not None:
        command.extend(["--payload-json", json.dumps(payload)])

    completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
    if completed.returncode != 0:
        raise RuntimeError(completed.stderr.strip() or "CDP fetch failed")
    try:
        return json.loads(completed.stdout)
    except json.JSONDecodeError as exc:
        raise RuntimeError(f"CDP fetch returned invalid JSON: {completed.stdout[:500]}") from exc


def _playwright_available() -> bool:
    package_json = SCRIPT_DIR.parent / "package.json"
    node_modules = SCRIPT_DIR.parent / "node_modules" / "playwright-core"
    return package_json.exists() and node_modules.exists()


def _should_use_playwright() -> bool:
    if BROWSER_BACKEND == "playwright":
        return True
    if BROWSER_BACKEND == "cdp":
        return False
    return _playwright_available()


def _run_playwright_bridge(
    mode: str,
    *,
    page_url: str,
    timeout_seconds: float,
    request_url: str | None = None,
    method: str | None = None,
    headers: dict[str, str] | None = None,
    payload: dict[str, Any] | None = None,
    expression: str | None = None,
    force_navigate: bool = False,
) -> Any:
    ensure_debug_chrome(page_url)
    command = [
        "node",
        str(SCRIPT_DIR / "mj_playwright_bridge.mjs"),
        "--mode",
        mode,
        "--debug-port",
        str(CDP_PORT),
        "--page-url",
        page_url,
        "--timeout-seconds",
        str(timeout_seconds),
        "--force-navigate",
        "true" if force_navigate else "false",
    ]
    if request_url:
        command.extend(["--request-url", request_url])
    if method:
        command.extend(["--method", method])
    if headers is not None:
        command.extend(["--headers-json", json.dumps(headers)])
    if payload is not None:
        command.extend(["--payload-json", json.dumps(payload)])
    if expression is not None:
        command.extend(["--expression", expression])

    completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
    if completed.returncode != 0:
        raise RuntimeError(completed.stderr.strip() or f"Playwright {mode} failed")
    if mode == "fetch":
        try:
            return json.loads(completed.stdout)
        except json.JSONDecodeError as exc:
            raise RuntimeError(f"Playwright fetch returned invalid JSON: {completed.stdout[:500]}") from exc
    try:
        return json.loads(completed.stdout)
    except json.JSONDecodeError as exc:
        raise RuntimeError(f"Playwright eval returned invalid JSON: {completed.stdout[:500]}") from exc


def browser_eval_json(
    expression: str,
    *,
    page_url: str = MIDJOURNEY_TAB_URL,
    timeout_seconds: float = 30.0,
    force_navigate: bool = False,
) -> Any:
    if _should_use_playwright():
        try:
            return _run_playwright_bridge(
                "eval",
                page_url=page_url,
                timeout_seconds=timeout_seconds,
                expression=expression,
                force_navigate=force_navigate,
            )
        except Exception:
            if BROWSER_BACKEND == "playwright":
                raise

    ensure_debug_chrome(page_url)
    command = [
        "node",
        str(SCRIPT_DIR / "mj_cdp_eval.mjs"),
        "--debug-port",
        str(CDP_PORT),
        "--page-url",
        page_url,
        "--expression",
        expression,
        "--timeout-seconds",
        str(timeout_seconds),
        "--force-navigate",
        "true" if force_navigate else "false",
    ]
    cookie = os.getenv("MJ_COOKIE", "")
    if cookie:
        command.extend(["--cookie-header", cookie])
    completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
    if completed.returncode != 0:
        raise RuntimeError(completed.stderr.strip() or "CDP eval failed")
    try:
        return json.loads(completed.stdout)
    except json.JSONDecodeError as exc:
        raise RuntimeError(f"CDP eval returned invalid JSON: {completed.stdout[:500]}") from exc


def _run_osascript(script: str) -> str:
    completed = subprocess.run(
        ["osascript", "-e", script],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        check=False,
    )
    if completed.returncode != 0:
        raise RuntimeError(completed.stderr.strip() or "osascript failed")
    return completed.stdout.strip()


def ensure_midjourney_tab(url: str = MIDJOURNEY_TAB_URL) -> None:
    script = f'''
tell application "Google Chrome"
    activate
    set foundTab to false
    repeat with wi from 1 to count of windows
        set w to window wi
        repeat with ti from 1 to count of tabs of w
            set t to tab ti of w
            if URL of t contains "alpha.midjourney.com" then
                set active tab index of w to ti
                set index of w to 1
                set foundTab to true
                exit repeat
            end if
        end repeat
        if foundTab then exit repeat
    end repeat
    if not foundTab then
        if (count of windows) = 0 then
            make new window
        end if
        tell window 1
            set newTab to make new tab with properties {{URL:"{url}"}}
            set active tab index to (index of newTab)
        end tell
    end if
end tell
'''
    _run_osascript(script)


def execute_js(js_code: str) -> str:
    normalized = " ".join(line.strip() for line in js_code.splitlines() if line.strip())
    escaped = normalized.replace("\\", "\\\\").replace('"', '\\"')
    script = f'''
tell application "Google Chrome"
    tell active tab of front window to execute javascript "{escaped}"
end tell
'''
    return _run_osascript(script)


def _run_applescript_fetch(
    url: str,
    *,
    page_url: str,
    method: str,
    headers: dict[str, str] | None,
    payload: dict[str, Any] | None,
    timeout_seconds: float,
) -> Any:
    ensure_midjourney_tab(page_url)
    token = f"codex_{int(time.time() * 1000)}"
    request_init = {
        "method": method,
        "headers": headers or {},
        "credentials": "include",
    }
    if payload is not None:
        request_init["body"] = json.dumps(payload)

    js = f"""
(() => {{
  const [REDACTED_SECRET])};
  window.__codexFetchState = window.__codexFetchState || {{}};
  window.__codexFetchState[token] = {{ status: "pending" }};
  fetch({json.dumps(url)}, {json.dumps(request_init)})
    .then(async (resp) => {{
      const text = await resp.text();
      window.__codexFetchState[token] = {{ status: "done", ok: resp.ok, statusCode: resp.status, text }};
    }})
    .catch((err) => {{
      window.__codexFetchState[token] = {{ status: "error", error: String(err) }};
    }});
  return token;
}})();
"""
    execute_js(js)

    deadline = time.time() + timeout_seconds
    while time.time() < deadline:
        state_raw = execute_js(
            f'JSON.stringify((window.__codexFetchState && window.__codexFetchState[{json.dumps(token)}]) || null)'
        )
        if not state_raw or state_raw == "null":
            time.sleep(0.5)
            continue
        state = json.loads(state_raw)
        if state.get("status") == "pending":
            time.sleep(0.5)
            continue
        if state.get("status") == "error":
            raise RuntimeError(state.get("error", "browser fetch failed"))
        text = state.get("text", "")
        if not state.get("ok", False):
            raise RuntimeError(f"browser fetch HTTP {state.get('statusCode')}: {text[:500]}")
        return json.loads(text)

    raise RuntimeError(f"browser fetch timed out after {timeout_seconds} seconds")


def browser_fetch_json(
    url: str,
    *,
    page_url: str = MIDJOURNEY_TAB_URL,
    method: str = "GET",
    headers: dict[str, str] | None = None,
    payload: dict[str, Any] | None = None,
    timeout_seconds: float = 60.0,
) -> Any:
    if _should_use_playwright():
        try:
            return _run_playwright_bridge(
                "fetch",
                page_url=page_url,
                timeout_seconds=timeout_seconds,
                request_url=url,
                method=method,
                headers=headers or {},
                payload=payload,
            )
        except Exception as playwright_error:
            if BROWSER_BACKEND == "playwright":
                raise RuntimeError(f"browser transport failed via Playwright ({playwright_error})") from playwright_error
    try:
        return _run_cdp_fetch(
            url,
            page_url=page_url,
            method=method,
            headers=headers,
            payload=payload,
            timeout_seconds=timeout_seconds,
        )
    except Exception as cdp_error:
        if platform.system() != "Darwin":
            raise RuntimeError(f"browser transport failed via CDP ({cdp_error})") from cdp_error
        try:
            return _run_applescript_fetch(
                url,
                page_url=page_url,
                method=method,
                headers=headers,
                payload=payload,
                timeout_seconds=timeout_seconds,
            )
        except Exception as applescript_error:
            raise RuntimeError(
                f"browser transport failed via CDP ({cdp_error}) and AppleScript ({applescript_error})"
            ) from applescript_error


def browser_fetch_imagine_feed(
    *,
    user_id: str,
    csrf_protection: str,
    page_size: int = 1000,
    cursor: str | None = None,
    page_url: str = MIDJOURNEY_TAB_URL,
    timeout_seconds: float = 60.0,
) -> dict[str, Any]:
    params = {
        "user_id": user_id,
        "page_size": str(page_size),
    }
    if cursor:
        params["cursor"] = cursor
    url = f"https://alpha.midjourney.com/api/imagine?{urllib.parse.urlencode(params)}"
    response = browser_fetch_json(
        url,
        page_url=page_url,
        headers={
            "accept": "*/*",
            "content-type": "application/json",
            "x-csrf-protection": csrf_protection,
        },
        timeout_seconds=timeout_seconds,
    )
    if not isinstance(response, dict):
        raise RuntimeError(f"Unexpected imagine feed response type: {type(response).__name__}")
    return response


def wait_for_imagine_feed_jobs(
    job_ids: list[str],
    *,
    user_id: str,
    csrf_protection: str,
    attempts: int = 20,
    interval_seconds: float = 3.0,
    page_size: int = 1000,
    page_url: str = MIDJOURNEY_TAB_URL,
    timeout_seconds: float = 60.0,
) -> dict[str, Any]:
    wanted_job_ids = [job_id.strip() for job_id in job_ids if job_id and job_id.strip()]
    history: list[dict[str, Any]] = []
    last_response: dict[str, Any] | None = None
    found_rows: dict[str, dict[str, Any]] = {}

    for attempt in range(1, max(1, attempts) + 1):
        response = browser_fetch_imagine_feed(
            user_id=user_id,
            csrf_protection=csrf_protection,
            page_size=page_size,
            page_url=page_url,
            timeout_seconds=timeout_seconds,
        )
        last_response = response
        rows = response.get("data", []) if isinstance(response, dict) else []
        if isinstance(rows, list):
            for row in rows:
                if not isinstance(row, dict):
                    continue
                job_id = str(row.get("id", "")).strip()
                if job_id in wanted_job_ids:
                    found_rows[job_id] = row
        history.append(
            {
                "attempt": attempt,
                "found_job_ids": sorted(found_rows.keys()),
                "row_count": len(rows) if isinstance(rows, list) else None,
            }
        )
        if all(job_id in found_rows for job_id in wanted_job_ids):
            return {
                "done": True,
                "attempts": attempt,
                "history": history,
                "jobs": found_rows,
                "response": response,
            }
        if attempt < attempts:
            time.sleep(interval_seconds)

    return {
        "done": False,
        "attempts": max(1, attempts),
        "history": history,
        "jobs": found_rows,
        "response": last_response,
    }


def watch_job_results(
    job_ids: list[str],
    *,
    page_url: str = MIDJOURNEY_TAB_URL,
    min_count: int = 4,
    timeout_seconds: float = 90.0,
) -> dict[str, Any]:
    wanted_job_ids = [job_id.strip() for job_id in job_ids if job_id and job_id.strip()]
    if not wanted_job_ids:
        return {
            "done": True,
            "elapsed_seconds": 0.0,
            "min_count": min_count,
            "jobs": {},
        }

    ensure_debug_chrome(page_url)
    command = [
        "node",
        str(SCRIPT_DIR / "mj_cdp_watch_results.mjs"),
        "--debug-port",
        str(CDP_PORT),
        "--page-url",
        page_url,
        "--job-ids-json",
        json.dumps(wanted_job_ids),
        "--timeout-seconds",
        str(timeout_seconds),
        "--min-count",
        str(max(1, min_count)),
    ]
    completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=False)
    if completed.returncode != 0:
        raise RuntimeError(completed.stderr.strip() or "CDP watch failed")
    try:
        return json.loads(completed.stdout)
    except json.JSONDecodeError as exc:
        raise RuntimeError(f"CDP watch returned invalid JSON: {completed.stdout[:500]}") from exc


def build_prompt_card_probe(prompt_text: str) -> str:
    prompt_json = json.dumps(prompt_text.strip().lower())
    return f"""
(() => {{
  const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
  const extractUrls = (root) => {{
    const urls = [];
    const seen = new Set();
    const urlPattern = /https?:\\/\\/[^"' )]+cdn\\.midjourney\\.com[^"' )]+/ig;
    const pushUrl = (raw) => {{
      if (!raw) return;
      const cleaned = raw.replace(/^url\\(["']?/, '').replace(/["']?\\)$/, '');
      if (!cleaned.includes('cdn.midjourney.com') || seen.has(cleaned)) return;
      seen.add(cleaned);
      urls.push(cleaned);
    }};
    const nodes = [root, ...Array.from(root.querySelectorAll('*'))];
    for (const node of nodes) {{
      const styleAttr = (node.getAttribute && node.getAttribute('style')) || '';
      if (styleAttr.includes('cdn.midjourney.com')) {{
        const matches = styleAttr.match(urlPattern) || [];
        for (const match of matches) pushUrl(match);
      }}
      const computed = window.getComputedStyle ? getComputedStyle(node) : null;
      const bg = computed ? (computed.backgroundImage || '') : '';
      if (bg.includes('cdn.midjourney.com')) {{
        const matches = bg.match(urlPattern) || [];
        for (const match of matches) pushUrl(match);
      }}
      if (node.tagName === 'IMG') {{
        pushUrl(node.currentSrc || node.src || '');
      }}
    }}
    return urls;
  }};
  const promptNeedle = {prompt_json};
  const promptBase = normalize(promptNeedle.replace(/--.*$/, ''));
  const allNodes = Array.from(document.querySelectorAll('div,article,section,li,a,span,p,h1,h2,h3,h4'));
  const candidates = [];
  for (const node of allNodes) {{
    const text = normalize(node.innerText || '');
    if (!text) continue;
    if (!(text.includes(promptNeedle) || (promptBase && text.includes(promptBase)))) continue;
    let current = node;
    for (let depth = 0; depth < 6 && current; depth += 1) {{
      const imageUrls = extractUrls(current);
      candidates.push({{
        text: (current.innerText || '').trim().slice(0, 500),
        imageUrls,
        imageCount: imageUrls.length,
        htmlLength: (current.innerHTML || '').length,
        textLength: text.length,
        depth,
      }});
      current = current.parentElement;
    }}
  }}
  candidates.sort((a, b) => b.imageCount - a.imageCount || a.depth - b.depth || a.textLength - b.textLength || b.htmlLength - a.htmlLength);
  return {{
    promptNeedle,
    promptBase,
    candidateCount: candidates.length,
    candidates: candidates.slice(0, 5),
    best: candidates[0] || null,
  }};
}})()
"""


def build_job_asset_probe(job_id: str) -> str:
    job_id_json = json.dumps(job_id.strip())
    return f"""
(() => {{
  const jobId = {job_id_json};
  const urls = performance.getEntriesByType('resource')
    .map((entry) => entry.name)
    .filter((name) => name.includes(jobId) && name.includes('cdn.midjourney.com'))
    .filter((name) => /\\.(png|jpe?g|webp)(\\?|$)/i.test(name));
  return {{
    jobId,
    count: Array.from(new Set(urls)).length,
    urls: Array.from(new Set(urls)),
    title: document.title,
    bodySample: (document.body && document.body.innerText ? document.body.innerText.slice(0, 400) : ''),
  }};
}})()
"""


def build_multi_job_asset_probe(job_ids: list[str]) -> str:
    jobs_json = json.dumps([job_id.strip() for job_id in job_ids if job_id.strip()])
    return f"""
(() => {{
  const jobIds = {jobs_json};
  const resourceUrls = performance.getEntriesByType('resource')
    .map((entry) => entry.name)
    .filter((name) => name.includes('cdn.midjourney.com'))
    .filter((name) => /\\.(png|jpe?g|webp)(\\?|$)/i.test(name));
  const jobs = {{}};
  for (const jobId of jobIds) {{
    const urls = resourceUrls.filter((name) => name.includes(jobId));
    jobs[jobId] = {{
      jobId,
      count: Array.from(new Set(urls)).length,
      urls: Array.from(new Set(urls)),
    }};
  }}
  return {{
    jobIds,
    jobs,
    title: document.title,
    bodySample: (document.body && document.body.innerText ? document.body.innerText.slice(0, 400) : ''),
  }};
}})()
"""


def build_job_page_probe(job_id: str) -> str:
    job_id_json = json.dumps(job_id.strip())
    return f"""
new Promise((resolve) => {{
  const jobId = {job_id_json};
  const start = Date.now();
  const collect = () => {{
    const urls = [];
    const seen = new Set();
    const pushUrl = (raw) => {{
      const cleaned = String(raw || '').replace(/^url\\(["']?/, '').replace(/["']?\\)$/, '');
      if (!cleaned.includes(jobId) || seen.has(cleaned)) return;
      seen.add(cleaned);
      urls.push(cleaned);
    }};
    for (const el of Array.from(document.querySelectorAll('*'))) {{
      const styleAttr = (el.getAttribute && el.getAttribute('style')) || '';
      if (styleAttr.includes(jobId)) {{
        for (const match of styleAttr.match(/https?:\\/\\/[^"' )]+/ig) || []) pushUrl(match);
      }}
      const computed = window.getComputedStyle ? getComputedStyle(el) : null;
      const bg = computed ? (computed.backgroundImage || '') : '';
      if (bg.includes(jobId)) {{
        for (const match of bg.match(/https?:\\/\\/[^"' )]+/ig) || []) pushUrl(match);
      }}
      if (el.tagName === 'IMG') {{
        pushUrl(el.currentSrc || el.src || '');
      }}
    }}
    const body = (document.body && document.body.innerText) || '';
    if (urls.length >= 4 || Date.now() - start > 12000) {{
      resolve({{
        jobId,
        count: urls.length,
        urls,
        href: location.href,
        title: document.title,
        bodySample: body.slice(0, 800),
      }});
      return;
    }}
    setTimeout(collect, 500);
  }};
  collect();
}})()
"""


def build_job_cdn_probe(job_id: str) -> str:
    return f"""
(() => {{
  const jobId = {json.dumps(job_id)};
  const urls = ["0_0", "0_1", "0_2", "0_3"].map((tile) =>
    `https://cdn.midjourney.com/${{jobId}}/${{tile}}_640_N.webp?method=shortest`
  );
  const probe = async (url) => {{
    try {{
      const head = await fetch(url, {{ method: "HEAD", credentials: "omit" }});
      return {{
        ok: head.ok,
        url,
        status: head.status,
        contentType: head.headers.get("content-type"),
      }};
    }} catch (error) {{
      try {{
        const get = await fetch(url, {{ credentials: "omit" }});
        return {{
          ok: get.ok,
          url,
          status: get.status,
          contentType: get.headers.get("content-type"),
        }};
      }} catch (fallbackError) {{
        return {{ ok: false, url, error: String(fallbackError || error) }};
      }}
    }}
  }};
  return Promise.all(urls.map(probe)).then((results) => {{
    const ready = results.filter((item) => item.ok).map((item) => item.url);
    return {{
      jobId,
      count: ready.length,
      urls: ready,
      attempted: urls,
      results,
      href: location.href,
      title: document.title,
    }};
  }});
}})()
"""


def select_preferred_job_urls(urls: list[str]) -> list[str]:
    grouped: dict[str, str] = {}
    extras: list[str] = []

    def score(url: str) -> tuple[int, int]:
        lowered = url.lower()
        return (
            2 if lowered.endswith(".webp") or ".webp?" in lowered else 1 if lowered.endswith(".png") or ".png?" in lowered else 0,
            1 if "_n." in lowered or "_n.webp" in lowered or "_n?" in lowered else 0,
        )

    for url in urls:
        match = JOB_TILE_RE.search(url)
        if not match:
            if url not in extras:
                extras.append(url)
            continue
        tile = match.group("tile")
        current = grouped.get(tile)
        if current is None or score(url) > score(current):
            grouped[tile] = url

    ordered_tiles = sorted(grouped.items(), key=lambda item: tuple(int(part) for part in item[0].split("_")))
    ordered_urls = [url for _, url in ordered_tiles]
    for extra in extras:
        if extra not in ordered_urls:
            ordered_urls.append(extra)
    return ordered_urls


def wait_for_job_assets(
    job_id: str,
    *,
    page_url: str = MIDJOURNEY_TAB_URL,
    min_count: int = 4,
    attempts: int = 30,
    interval_seconds: float = 2.0,
    timeout_seconds: float = 30.0,
) -> dict[str, Any]:
    history: list[dict[str, Any]] = []
    result: dict[str, Any] | None = None
    for attempt in range(1, max(1, attempts) + 1):
        result = browser_eval_json(
            build_job_asset_probe(job_id),
            page_url=page_url,
            timeout_seconds=timeout_seconds,
        )
        count = int(result.get("count", 0)) if isinstance(result, dict) else 0
        history.append({"attempt": attempt, "count": count})
        if count >= max(1, min_count):
            result_urls = select_preferred_job_urls(list(result.get("urls") or []))
            return {
                "job_id": job_id,
                "done": True,
                "attempts": attempt,
                "history": history,
                "result_urls": result_urls,
                "page_probe": result,
                "min_count": min_count,
            }
        if attempt < attempts:
            time.sleep(interval_seconds)

    return {
        "job_id": job_id,
        "done": False,
        "attempts": attempts,
        "history": history,
        "result_urls": select_preferred_job_urls(list(result.get("urls") or [])) if isinstance(result, dict) else [],
        "page_probe": result,
        "min_count": min_count,
    }


def wait_for_job_page_assets(
    job_id: str,
    *,
    attempts: int = 12,
    interval_seconds: float = 3.0,
    timeout_seconds: float = 30.0,
) -> dict[str, Any]:
    page_url = f"https://alpha.midjourney.com/jobs/{job_id}?index=0"
    history: list[dict[str, Any]] = []
    result: dict[str, Any] | None = None
    for attempt in range(1, max(1, attempts) + 1):
        result = browser_eval_json(
            build_job_page_probe(job_id),
            page_url=page_url,
            timeout_seconds=timeout_seconds,
        )
        count = int(result.get("count", 0)) if isinstance(result, dict) else 0
        history.append({"attempt": attempt, "count": count})
        if count >= 4:
            result_urls = select_preferred_job_urls(list(result.get("urls") or []))
            return {
                "job_id": job_id,
                "done": True,
                "attempts": attempt,
                "history": history,
                "result_urls": result_urls,
                "page_probe": result,
                "source": "job_page",
                "min_count": 4,
            }
        if attempt < attempts:
            time.sleep(interval_seconds)

    return {
        "job_id": job_id,
        "done": False,
        "attempts": attempts,
        "history": history,
        "result_urls": select_preferred_job_urls(list(result.get("urls") or [])) if isinstance(result, dict) else [],
        "page_probe": result,
        "source": "job_page",
        "min_count": 4,
    }


def wait_for_job_cdn_assets(
    job_id: str,
    *,
    page_url: str = MIDJOURNEY_TAB_URL,
    attempts: int = 12,
    interval_seconds: float = 3.0,
    timeout_seconds: float = 30.0,
) -> dict[str, Any]:
    history: list[dict[str, Any]] = []
    result: dict[str, Any] | None = None
    for attempt in range(1, max(1, attempts) + 1):
        result = browser_eval_json(
            build_job_cdn_probe(job_id),
            page_url=page_url,
            timeout_seconds=timeout_seconds,
        )
        count = int(result.get("count", 0)) if isinstance(result, dict) else 0
        history.append({"attempt": attempt, "count": count})
        if count >= 4:
            result_urls = select_preferred_job_urls(list(result.get("urls") or []))
            return {
                "job_id": job_id,
                "done": True,
                "attempts": attempt,
                "history": history,
                "result_urls": result_urls,
                "page_probe": result,
                "source": "job_cdn",
                "min_count": 4,
            }
        if attempt < attempts:
            time.sleep(interval_seconds)

    return {
        "job_id": job_id,
        "done": False,
        "attempts": attempts,
        "history": history,
        "result_urls": select_preferred_job_urls(list(result.get("urls") or [])) if isinstance(result, dict) else [],
        "page_probe": result,
        "source": "job_cdn",
        "min_count": 4,
    }


def wait_for_job_assets_batch(
    job_ids: list[str],
    *,
    page_url: str = MIDJOURNEY_TAB_URL,
    min_count: int = 4,
    attempts: int = 30,
    interval_seconds: float = 2.0,
    timeout_seconds: float = 30.0,
) -> dict[str, Any]:
    wanted_job_ids = [job_id.strip() for job_id in job_ids if job_id and job_id.strip()]
    if not wanted_job_ids:
        return {
            "done": True,
            "attempts": 0,
            "history": [],
            "jobs": {},
            "min_count": min_count,
        }

    history: list[dict[str, Any]] = []
    result: dict[str, Any] | None = None
    for attempt in range(1, max(1, attempts) + 1):
        result = browser_eval_json(
            build_multi_job_asset_probe(wanted_job_ids),
            page_url=page_url,
            timeout_seconds=timeout_seconds,
        )
        jobs = result.get("jobs", {}) if isinstance(result, dict) else {}
        counts = {
            job_id: int((jobs.get(job_id) or {}).get("count", 0))
            for job_id in wanted_job_ids
        }
        history.append({"attempt": attempt, "counts": counts})
        if all(count >= max(1, min_count) for count in counts.values()):
            return {
                "done": True,
                "attempts": attempt,
                "history": history,
                "jobs": {
                    job_id: {
                        "job_id": job_id,
                        "done": counts.get(job_id, 0) >= max(1, min_count),
                        "attempts": attempt,
                        "result_urls": select_preferred_job_urls(list((jobs.get(job_id) or {}).get("urls") or [])),
                        "page_probe": jobs.get(job_id) or {},
                        "min_count": min_count,
                    }
                    for job_id in wanted_job_ids
                },
                "page_probe": result,
                "min_count": min_count,
            }
        if attempt < attempts:
            time.sleep(interval_seconds)

    jobs = result.get("jobs", {}) if isinstance(result, dict) else {}
    return {
        "done": False,
        "attempts": attempts,
        "history": history,
        "jobs": {
            job_id: {
                "job_id": job_id,
                "done": int((jobs.get(job_id) or {}).get("count", 0)) >= max(1, min_count),
                "attempts": attempts,
                "result_urls": select_preferred_job_urls(list((jobs.get(job_id) or {}).get("urls") or [])),
                "page_probe": jobs.get(job_id) or {},
                "min_count": min_count,
            }
            for job_id in wanted_job_ids
        },
        "page_probe": result,
        "min_count": min_count,
    }


def wait_for_prompt_card_assets(
    prompt_text: str,
    *,
    page_url: str = MIDJOURNEY_TAB_URL,
    min_count: int = 4,
    attempts: int = 30,
    interval_seconds: float = 2.0,
    timeout_seconds: float = 30.0,
) -> dict[str, Any]:
    history: list[dict[str, Any]] = []
    result: dict[str, Any] | None = None
    for attempt in range(1, max(1, attempts) + 1):
        result = browser_eval_json(
            build_prompt_card_probe(prompt_text),
            page_url=page_url,
            timeout_seconds=timeout_seconds,
        )
        best = result.get("best") or {} if isinstance(result, dict) else {}
        image_urls = list(best.get("imageUrls") or [])
        history.append(
            {
                "attempt": attempt,
                "candidate_count": int((result or {}).get("candidateCount", 0)) if isinstance(result, dict) else 0,
                "image_count": len(image_urls),
            }
        )
        if len(image_urls) >= max(1, min_count):
            return {
                "prompt_text": prompt_text,
                "done": True,
                "attempts": attempt,
                "history": history,
                "result_urls": image_urls,
                "page_probe": result,
                "min_count": min_count,
                "source": "prompt_card",
            }
        if attempt < attempts:
            time.sleep(interval_seconds)

    best = result.get("best") or {} if isinstance(result, dict) else {}
    return {
        "prompt_text": prompt_text,
        "done": False,
        "attempts": attempts,
        "history": history,
        "result_urls": list(best.get("imageUrls") or []),
        "page_probe": result,
        "min_count": min_count,
        "source": "prompt_card",
    }


def browser_convert_image_url(
    image_url: str,
    *,
    output_format: str = "png",
    page_url: str = MIDJOURNEY_TAB_URL,
    timeout_seconds: float = 60.0,
) -> dict[str, Any]:
    format_name = output_format.lower()
    if format_name != "png":
        raise RuntimeError(f"Unsupported browser conversion format: {output_format}")

    expr = f"""
(async () => {{
  try {{
    const resp = await fetch({json.dumps(image_url)});
    const blob = await resp.blob();
    const bmp = await createImageBitmap(blob);
    const canvas = document.createElement('canvas');
    canvas.width = bmp.width;
    canvas.height = bmp.height;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(bmp, 0, 0);
    const dataUrl = canvas.toDataURL('image/png');
    return {{
      ok: true,
      width: bmp.width,
      height: bmp.height,
      dataBase64: dataUrl.split(',')[1],
      mimeType: 'image/png'
    }};
  }} catch (error) {{
    return {{
      ok: false,
      error: String(error)
    }};
  }}
}})()
"""
    result = browser_eval_json(expr, page_url=page_url, timeout_seconds=timeout_seconds)
    if not isinstance(result, dict) or not result.get("ok"):
        raise RuntimeError(f"Browser image conversion failed: {result}")
    data_base64 = result.get("dataBase64")
    if not isinstance(data_base64, str) or not data_base64:
        raise RuntimeError("Browser image conversion returned empty image data")
    return {
        "bytes": base64.b64decode(data_base64),
        "mime_type": result.get("mimeType", "image/png"),
        "width": result.get("width"),
        "height": result.get("height"),
    }


def browser_fetch_binary_url(
    url: str,
    *,
    page_url: str = MIDJOURNEY_TAB_URL,
    timeout_seconds: float = 60.0,
) -> dict[str, Any]:
    expr = f"""
(async () => {{
  try {{
    const resp = await fetch({json.dumps(url)});
    const buffer = await resp.arrayBuffer();
    const bytes = new Uint8Array(buffer);
    let binary = '';
    const chunkSize = 0x8000;
    for (let i = 0; i < bytes.length; i += chunkSize) {{
      binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
    }}
    return {{
      ok: resp.ok,
      status: resp.status,
      mimeType: resp.headers.get('content-type') || '',
      dataBase64: btoa(binary),
      sizeBytes: bytes.length,
    }};
  }} catch (error) {{
    return {{
      ok: false,
      status: 0,
      error: String(error)
    }};
  }}
}})()
"""
    result = browser_eval_json(expr, page_url=page_url, timeout_seconds=timeout_seconds)
    if not isinstance(result, dict) or not result.get("ok"):
        raise RuntimeError(f"Browser binary fetch failed: {result}")
    data_base64 = result.get("dataBase64")
    if not isinstance(data_base64, str) or not data_base64:
        raise RuntimeError("Browser binary fetch returned empty data")
    return {
        "bytes": base64.b64decode(data_base64),
        "mime_type": result.get("mimeType") or None,
        "status": result.get("status"),
        "size_bytes": result.get("sizeBytes"),
    }


def browser_convert_image_bytes(
    image_bytes: bytes,
    *,
    input_mime_type: str = "image/webp",
    output_format: str = "png",
    page_url: str = MIDJOURNEY_TAB_URL,
    timeout_seconds: float = 60.0,
) -> dict[str, Any]:
    format_name = output_format.lower()
    if format_name != "png":
        raise RuntimeError(f"Unsupported browser conversion format: {output_format}")

    input_base64 = base64.b64encode(image_bytes).decode("ascii")
    expr = f"""
(async () => {{
  try {{
    const binary = atob({json.dumps(input_base64)});
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i += 1) {{
      bytes[i] = binary.charCodeAt(i);
    }}
    const blob = new Blob([bytes], {{ type: {json.dumps(input_mime_type)} }});
    const objectUrl = URL.createObjectURL(blob);
    const img = new Image();
    img.src = objectUrl;
    await img.decode();
    const canvas = document.createElement('canvas');
    canvas.width = img.naturalWidth;
    canvas.height = img.naturalHeight;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(img, 0, 0);
    const outputDataUrl = canvas.toDataURL('image/png');
    URL.revokeObjectURL(objectUrl);
    return {{
      ok: true,
      width: img.naturalWidth,
      height: img.naturalHeight,
      dataBase64: outputDataUrl.split(',')[1],
      mimeType: 'image/png'
    }};
  }} catch (error) {{
    return {{
      ok: false,
      error: String(error)
    }};
  }}
}})()
"""
    result = browser_eval_json(expr, page_url=page_url, timeout_seconds=timeout_seconds)
    if not isinstance(result, dict) or not result.get("ok"):
        raise RuntimeError(f"Browser image conversion failed: {result}")
    data_base64 = result.get("dataBase64")
    if not isinstance(data_base64, str) or not data_base64:
        raise RuntimeError("Browser image conversion returned empty image data")
    return {
        "bytes": base64.b64decode(data_base64),
        "mime_type": result.get("mimeType", "image/png"),
        "width": result.get("width"),
        "height": result.get("height"),
    }


path: scripts/mj_cdp_eval.mjs
content: #!/usr/bin/env node

const args = process.argv.slice(2);

function getArg(name, fallback = null) {
  const index = args.indexOf(name);
  if (index === -1) return fallback;
  return args[index + 1] ?? fallback;
}

const debugPort = Number(getArg("--debug-port", "9222"));
const pageUrl = getArg("--page-url");
const expression = getArg("--expression");
const cookieHeader = getArg("--cookie-header", "");
const timeoutSeconds = Number(getArg("--timeout-seconds", "30"));
const forceNavigate = getArg("--force-navigate", "false") === "true";

if (!pageUrl || !expression) {
  console.error("Missing required arguments");
  process.exit(1);
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchJson(url, options = {}) {
  const response = await fetch(url, options);
  const text = await response.text();
  return text ? JSON.parse(text) : {};
}

async function getOrCreatePageTarget() {
  const list = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`);
  let target = list.find(
    (item) =>
      item.type === "page" &&
      String(item.url || "").startsWith(pageUrl),
  );
  if (target) return target;
  if (pageUrl.includes("/jobs/")) {
    target = list.find(
      (item) =>
        item.type === "page" &&
        String(item.url || "").includes("alpha.midjourney.com/jobs/"),
    );
    if (target) return target;
  }
  target = await fetchJson(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(pageUrl)}`, {
    method: "PUT",
  });
  await sleep(1500);
  return target;
}

function parseCookieHeader(cookieHeaderValue) {
  if (!cookieHeaderValue) return [];
  return cookieHeaderValue
    .split(";")
    .map((part) => part.trim())
    .filter(Boolean)
    .map((part) => {
      const eq = part.indexOf("=");
      return {
        name: part.slice(0, eq),
        value: part.slice(eq + 1),
      };
    });
}

class CDPClient {
  constructor(webSocketUrl) {
    this.socket = new WebSocket(webSocketUrl);
    this.nextId = 1;
    this.pending = new Map();
  }

  async open() {
    await new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error("CDP websocket timeout")), 10000);
      this.socket.addEventListener("open", () => {
        clearTimeout(timer);
        resolve();
      });
      this.socket.addEventListener("error", (event) => {
        clearTimeout(timer);
        reject(new Error(String(event.message || "CDP websocket error")));
      });
    });

    this.socket.addEventListener("message", (event) => {
      const message = JSON.parse(event.data);
      if (message.id && this.pending.has(message.id)) {
        const { resolve, reject } = this.pending.get(message.id);
        this.pending.delete(message.id);
        if (message.error) reject(new Error(message.error.message || "CDP error"));
        else resolve(message.result);
      }
    });
  }

  send(methodName, params = {}) {
    const id = this.nextId++;
    const payload = JSON.stringify({ id, method: methodName, params });
    return new Promise((resolve, reject) => {
      this.pending.set(id, { resolve, reject });
      this.socket.send(payload);
    });
  }

  close() {
    this.socket.close();
  }
}

async function assertNoSecurityChallenge(client) {
  const probeResult = await client.send("Runtime.evaluate", {
    expression: `
      (() => ({
        href: location.href,
        title: document.title,
        body: (document.body && document.body.innerText ? document.body.innerText.slice(0, 1000) : "")
      }))()
    `,
    returnByValue: true,
  });
  const probe = probeResult?.result?.value || {};
  const haystack = `${probe.href || ""}\n${probe.title || ""}\n${probe.body || ""}`.toLowerCase();
  const markers = [
    "verify you are human",
    "performing security verification",
    "just a moment",
    "cloudflare",
    "security service to protect against malicious bots",
  ];
  if (markers.some((marker) => haystack.includes(marker))) {
    throw new Error("Cloudflare challenge detected on the active Midjourney page. Resolve it manually in Chrome, then retry.");
  }
}

async function main() {
  const target = await getOrCreatePageTarget();
  const client = new CDPClient(target.webSocketDebuggerUrl);
  await client.open();
  await client.send("Page.enable");
  await client.send("Runtime.enable");
  await client.send("Network.enable");

  const cookies = parseCookieHeader(cookieHeader);
  for (const cookie of cookies) {
    const candidateParams = [];
    if (cookie.name.startsWith("__Host-")) {
      candidateParams.push({
        name: cookie.name,
        value: cookie.value,
        path: "/",
        secure: true,
        url: "https://alpha.midjourney.com/",
      });
      candidateParams.push({
        name: cookie.name,
        value: cookie.value,
        path: "/",
        secure: true,
        url: "https://www.midjourney.com/",
      });
    } else {
      candidateParams.push({
        name: cookie.name,
        value: cookie.value,
        path: "/",
        secure: true,
        domain: ".midjourney.com",
        url: "https://alpha.midjourney.com/",
      });
    }
    for (const params of candidateParams) {
      try {
        await client.send("Network.setCookie", params);
      } catch (_) {
        // Ignore cookie-set failures for non-critical cookies.
      }
    }
  }

  const currentUrlResult = await client.send("Runtime.evaluate", {
    expression: "location.href",
    returnByValue: true,
  });
  const currentUrl = String(currentUrlResult?.result?.value || "");
  if (forceNavigate || !currentUrl.startsWith(pageUrl)) {
    await client.send("Page.navigate", { url: pageUrl });
    await sleep(2000);
  }

  await assertNoSecurityChallenge(client);

  const deadline = Date.now() + timeoutSeconds * 1000;
  let result;
  while (Date.now() < deadline) {
    result = await client.send("Runtime.evaluate", {
      expression,
      awaitPromise: true,
      returnByValue: true,
    });
    if (result?.result) break;
    await sleep(300);
  }

  client.close();

  if (!result?.result) {
    throw new Error("No evaluation result");
  }
  const value = result.result.value;
  process.stdout.write(JSON.stringify(value));
}

main().catch((error) => {
  console.error(error.message || String(error));
  process.exit(1);
});


path: scripts/mj_cdp_fetch.mjs
content: #!/usr/bin/env node

const args = process.argv.slice(2);

function getArg(name, fallback = null) {
  const index = args.indexOf(name);
  if (index === -1) return fallback;
  return args[index + 1] ?? fallback;
}

const debugPort = Number(getArg("--debug-port", "9222"));
const pageUrl = getArg("--page-url");
const requestUrl = getArg("--request-url");
const method = getArg("--method", "GET");
const headers = JSON.parse(getArg("--headers-json", "{}"));
const payloadJson = getArg("--payload-json");
const payload = payloadJson ? JSON.parse(payloadJson) : null;
const cookieHeader = getArg("--cookie-header", "");
const timeoutSeconds = Number(getArg("--timeout-seconds", "60"));

if (!pageUrl || !requestUrl) {
  console.error("Missing required arguments");
  process.exit(1);
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchJson(url, options = {}) {
  const response = await fetch(url, options);
  const text = await response.text();
  return text ? JSON.parse(text) : {};
}

function parseCookieHeader(cookieHeaderValue) {
  if (!cookieHeaderValue) return [];
  return cookieHeaderValue
    .split(";")
    .map((part) => part.trim())
    .filter(Boolean)
    .map((part) => {
      const eq = part.indexOf("=");
      return {
        name: part.slice(0, eq),
        value: part.slice(eq + 1),
      };
    });
}

async function getOrCreatePageTarget() {
  const list = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`);
  let target = list.find(
    (item) =>
      item.type === "page" &&
      String(item.url || "").startsWith(pageUrl),
  );
  if (target) return target;
  target = await fetchJson(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(pageUrl)}`, {
    method: "PUT",
  });
  await sleep(1500);
  return target;
}

class CDPClient {
  constructor(webSocketUrl) {
    this.socket = new WebSocket(webSocketUrl);
    this.nextId = 1;
    this.pending = new Map();
    this.events = new Map();
  }

  async open() {
    await new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error("CDP websocket timeout")), 10000);
      this.socket.addEventListener("open", () => {
        clearTimeout(timer);
        resolve();
      });
      this.socket.addEventListener("error", (event) => {
        clearTimeout(timer);
        reject(new Error(String(event.message || "CDP websocket error")));
      });
    });

    this.socket.addEventListener("message", (event) => {
      const message = JSON.parse(event.data);
      if (message.id && this.pending.has(message.id)) {
        const { resolve, reject } = this.pending.get(message.id);
        this.pending.delete(message.id);
        if (message.error) reject(new Error(message.error.message || "CDP error"));
        else resolve(message.result);
        return;
      }
      if (message.method) {
        const handlers = this.events.get(message.method) || [];
        for (const handler of handlers) handler(message.params || {});
      }
    });
  }

  send(methodName, params = {}) {
    const id = this.nextId++;
    const payload = JSON.stringify({ id, method: methodName, params });
    return new Promise((resolve, reject) => {
      this.pending.set(id, { resolve, reject });
      this.socket.send(payload);
    });
  }

  on(eventName, handler) {
    const handlers = this.events.get(eventName) || [];
    handlers.push(handler);
    this.events.set(eventName, handlers);
  }

  close() {
    this.socket.close();
  }
}

async function assertNoSecurityChallenge(client) {
  const probeResult = await client.send("Runtime.evaluate", {
    expression: `
      (() => ({
        href: location.href,
        title: document.title,
        body: (document.body && document.body.innerText ? document.body.innerText.slice(0, 1000) : "")
      }))()
    `,
    returnByValue: true,
  });
  const probe = probeResult?.result?.value || {};
  const haystack = `${probe.href || ""}\n${probe.title || ""}\n${probe.body || ""}`.toLowerCase();
  const markers = [
    "verify you are human",
    "performing security verification",
    "just a moment",
    "cloudflare",
    "security service to protect against malicious bots",
  ];
  if (markers.some((marker) => haystack.includes(marker))) {
    throw new Error("Cloudflare challenge detected on the active Midjourney page. Resolve it manually in Chrome, then retry.");
  }
}

async function main() {
  const target = await getOrCreatePageTarget();
  const client = new CDPClient(target.webSocketDebuggerUrl);
  await client.open();

  await client.send("Page.enable");
  await client.send("Network.enable");

  const cookies = parseCookieHeader(cookieHeader);
  for (const cookie of cookies) {
    const candidateParams = [];
    if (cookie.name.startsWith("__Host-")) {
      candidateParams.push({
        name: cookie.name,
        value: cookie.value,
        path: "/",
        secure: true,
        url: "https://alpha.midjourney.com/",
      });
      candidateParams.push({
        name: cookie.name,
        value: cookie.value,
        path: "/",
        secure: true,
        url: "https://www.midjourney.com/",
      });
    } else {
      candidateParams.push({
        name: cookie.name,
        value: cookie.value,
        path: "/",
        secure: true,
        domain: ".midjourney.com",
        url: "https://alpha.midjourney.com/",
      });
    }

    for (const params of candidateParams) {
      try {
        await client.send("Network.setCookie", params);
      } catch (_) {
        // Ignore cookie-set failures for non-critical cookies.
      }
    }
  }

  const currentUrlResult = await client.send("Runtime.evaluate", {
    expression: "location.href",
    returnByValue: true,
  });
  const currentUrl = String(currentUrlResult?.result?.value || "");
  if (!currentUrl.startsWith(pageUrl)) {
    let loadSeen = false;
    client.on("Page.loadEventFired", () => {
      loadSeen = true;
    });
    await client.send("Page.navigate", { url: pageUrl });
    const loadDeadline = Date.now() + 15000;
    while (!loadSeen && Date.now() < loadDeadline) {
      await sleep(200);
    }
  }

  await assertNoSecurityChallenge(client);

  const expression = `
    (async () => {
      try {
        const response = await fetch(${JSON.stringify(requestUrl)}, ${JSON.stringify({
          method,
          headers,
          credentials: "include",
          body: payload ? JSON.stringify(payload) : undefined,
        })});
        const text = await response.text();
        return {
          ok: response.ok,
          status: response.status,
          text,
        };
      } catch (error) {
        return {
          ok: false,
          status: 0,
          text: "",
          error: String(error),
        };
      }
    })()
  `;

  const result = await client.send("Runtime.evaluate", {
    expression,
    awaitPromise: true,
    returnByValue: true,
  });
  client.close();

  const value = result.result?.value;
  if (value === undefined || value === null) {
    throw new Error("Empty CDP fetch response");
  }
  const parsed = typeof value === "string" ? JSON.parse(value) : value;
  if (!parsed.ok) {
    throw new Error(`${parsed.error || `HTTP ${parsed.status}`}: ${String(parsed.text).slice(0, 500)}`);
  }
  process.stdout.write(parsed.text);
}

main().catch((error) => {
  console.error(error.message || String(error));
  process.exit(1);
});


path: scripts/mj_cdp_watch_results.mjs
content: #!/usr/bin/env node

const args = process.argv.slice(2);

function getArg(name, fallback = null) {
  const index = args.indexOf(name);
  if (index === -1) return fallback;
  return args[index + 1] ?? fallback;
}

const debugPort = Number(getArg("--debug-port", "9222"));
const pageUrl = getArg("--page-url");
const jobIds = JSON.parse(getArg("--job-ids-json", "[]"));
const timeoutSeconds = Number(getArg("--timeout-seconds", "90"));
const minCount = Number(getArg("--min-count", "4"));

if (!pageUrl || !Array.isArray(jobIds) || jobIds.length === 0) {
  console.error("Missing required arguments");
  process.exit(1);
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchJson(url, options = {}) {
  const response = await fetch(url, options);
  const text = await response.text();
  return text ? JSON.parse(text) : {};
}

async function getExistingPageTarget() {
  const list = await fetchJson(`http://127.0.0.1:${debugPort}/json/list`);
  return (
    list.find(
      (item) =>
        item.type === "page" &&
        String(item.url || "").startsWith(pageUrl),
    ) || null
  );
}

class CDPClient {
  constructor(webSocketUrl) {
    this.socket = new WebSocket(webSocketUrl);
    this.nextId = 1;
    this.pending = new Map();
    this.events = new Map();
  }

  async open() {
    await new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error("CDP websocket timeout")), 10000);
      this.socket.addEventListener("open", () => {
        clearTimeout(timer);
        resolve();
      });
      this.socket.addEventListener("error", (event) => {
        clearTimeout(timer);
        reject(new Error(String(event.message || "CDP websocket error")));
      });
    });

    this.socket.addEventListener("message", (event) => {
      const message = JSON.parse(event.data);
      if (message.id && this.pending.has(message.id)) {
        const { resolve, reject } = this.pending.get(message.id);
        this.pending.delete(message.id);
        if (message.error) reject(new Error(message.error.message || "CDP error"));
        else resolve(message.result);
        return;
      }
      if (message.method) {
        const handlers = this.events.get(message.method) || [];
        for (const handler of handlers) handler(message.params || {});
      }
    });
  }

  send(methodName, params = {}) {
    const id = this.nextId++;
    const payload = JSON.stringify({ id, method: methodName, params });
    return new Promise((resolve, reject) => {
      this.pending.set(id, { resolve, reject });
      this.socket.send(payload);
    });
  }

  on(eventName, handler) {
    const handlers = this.events.get(eventName) || [];
    handlers.push(handler);
    this.events.set(eventName, handlers);
  }

  close() {
    this.socket.close();
  }
}

function buildImageUrlRegex() {
  return /https?:\/\/[^"' )]+cdn\.midjourney\.com[^"' )]+\.(?:png|jpe?g|webp)(?:\?[^"' )]*)?/ig;
}

function normalizeUrl(rawUrl) {
  return String(rawUrl || "")
    .replace(/^url\(["']?/, "")
    .replace(/["']?\)$/, "");
}

function extractUrlsForJobs(input, wantedJobIds) {
  const text = String(input || "");
  const matches = text.match(buildImageUrlRegex()) || [];
  const urlsByJob = new Map();
  for (const jobId of wantedJobIds) {
    urlsByJob.set(jobId, []);
  }
  for (const match of matches) {
    const cleaned = normalizeUrl(match);
    for (const jobId of wantedJobIds) {
      if (!cleaned.includes(jobId)) continue;
      urlsByJob.get(jobId).push(cleaned);
    }
  }
  return urlsByJob;
}

async function assertNoSecurityChallenge(client) {
  const probeResult = await client.send("Runtime.evaluate", {
    expression: `
      (() => ({
        href: location.href,
        title: document.title,
        body: (document.body && document.body.innerText ? document.body.innerText.slice(0, 1000) : "")
      }))()
    `,
    returnByValue: true,
  });
  const probe = probeResult?.result?.value || {};
  const haystack = `${probe.href || ""}\n${probe.title || ""}\n${probe.body || ""}`.toLowerCase();
  const markers = [
    "verify you are human",
    "performing security verification",
    "just a moment",
    "cloudflare",
    "security service to protect against malicious bots",
  ];
  if (markers.some((marker) => haystack.includes(marker))) {
    throw new Error("Cloudflare challenge detected on the active Midjourney page. Resolve it manually in Chrome, then retry.");
  }
  return probe;
}

function makeJobState(jobId) {
  return {
    job_id: jobId,
    count: 0,
    result_urls: [],
    done: false,
    sources: [],
  };
}

function recordJobUrls(state, urls, source) {
  for (const url of urls) {
    if (!url) continue;
    if (!state.result_urls.includes(url)) {
      state.result_urls.push(url);
    }
  }
  state.count = state.result_urls.length;
  state.done = state.count >= Math.max(1, minCount);
  if (source && !state.sources.includes(source)) {
    state.sources.push(source);
  }
}

async function main() {
  const target = await getExistingPageTarget();
  if (!target?.webSocketDebuggerUrl) {
    throw new Error("No existing Midjourney imagine page is open for low-impact result watching.");
  }

  const client = new CDPClient(target.webSocketDebuggerUrl);
  await client.open();
  await client.send("Page.enable");
  await client.send("Runtime.enable");
  await client.send("Network.enable");

  const pageProbe = await assertNoSecurityChallenge(client);
  const jobState = Object.fromEntries(jobIds.map((jobId) => [jobId, makeJobState(jobId)]));
  const interestingRequests = new Map();
  let apiEventCount = 0;
  let imageEventCount = 0;

  const seedResult = await client.send("Runtime.evaluate", {
    expression: `
      (() => performance.getEntriesByType('resource').map((entry) => entry.name))()
    `,
    returnByValue: true,
  });
  const performanceUrls = Array.isArray(seedResult?.result?.value) ? seedResult.result.value : [];
  const seeded = extractUrlsForJobs(performanceUrls.join("\n"), jobIds);
  for (const jobId of jobIds) {
    recordJobUrls(jobState[jobId], seeded.get(jobId) || [], "performance");
  }

  client.on("Network.responseReceived", (params) => {
    const requestId = params.requestId;
    const responseUrl = String(params.response?.url || "");
    if (!responseUrl) return;

    if (
      responseUrl.includes("/api/imagine") ||
      responseUrl.includes("/api/imagine-update") ||
      responseUrl.includes("/api/user-queue") ||
      responseUrl.includes("/api/job-status")
    ) {
      interestingRequests.set(requestId, responseUrl);
      apiEventCount += 1;
    }

    if (!responseUrl.includes("cdn.midjourney.com")) return;
    for (const jobId of jobIds) {
      if (!responseUrl.includes(jobId)) continue;
      recordJobUrls(jobState[jobId], [responseUrl], "network_response");
      imageEventCount += 1;
    }
  });

  client.on("Network.loadingFinished", async (params) => {
    const responseUrl = interestingRequests.get(params.requestId);
    if (!responseUrl) return;
    interestingRequests.delete(params.requestId);
    try {
      const bodyResult = await client.send("Network.getResponseBody", { requestId: params.requestId });
      const bodyText = bodyResult?.base64Encoded
        ? Buffer.from(bodyResult.body || "", "base64").toString("utf8")
        : String(bodyResult?.body || "");
      const extracted = extractUrlsForJobs(`${responseUrl}\n${bodyText}`, jobIds);
      for (const jobId of jobIds) {
        recordJobUrls(jobState[jobId], extracted.get(jobId) || [], "api_response");
      }
    } catch (_) {
      // Response bodies are best-effort only.
    }
  });

  const startedAt = Date.now();
  const deadline = startedAt + timeoutSeconds * 1000;
  while (Date.now() < deadline) {
    if (jobIds.every((jobId) => jobState[jobId].done)) {
      break;
    }
    await sleep(500);
  }

  const completedAt = Date.now();
  client.close();

  process.stdout.write(
    JSON.stringify({
      done: jobIds.every((jobId) => jobState[jobId].done),
      elapsed_seconds: Number(((completedAt - startedAt) / 1000).toFixed(3)),
      min_count: minCount,
      page_probe: pageProbe,
      event_counts: {
        apiEventCount,
        imageEventCount,
        pendingRequests: interestingRequests.size,
      },
      jobs: jobState,
    }),
  );
}

main().catch((error) => {
  console.error(error.message || String(error));
  process.exit(1);
});


path: scripts/mj_doctor.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import os
import platform
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from mj_browser import browser_fetch_json
from mj_alpha import (
    enrich_config_from_cookie,
    extract_auth_claims_from_cookie,
    get_user_state,
    load_config,
    load_usage_log,
    print_json,
)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Validate local Midjourney Alpha config and summarize safe defaults.")
    parser.add_argument("--dotenv", default=".env", help="Path to .env file")
    parser.add_argument("--fetch-user-state", action="store_true", help="Also call /api/user-mutable-state")
    parser.add_argument("--transport", choices=["auto", "browser", "http"], default="auto", help="Request transport")
    return parser


def main() -> int:
    args = build_parser().parse_args()
    config = enrich_config_from_cookie(load_config(args.dotenv))
    claims = extract_auth_claims_from_cookie(config.cookie)
    usage_log = load_usage_log(config)

    result = {
        "config": {
            "has_cookie": bool(config.cookie),
            "channel_id": config.channel_id,
            "user_id": config.user_id,
            "submit_url": config.submit_url,
            "user_state_url": config.user_state_url,
            "recent_jobs_url": config.recent_jobs_url,
            "mode": config.mode,
            "private": config.private,
            "min_submit_interval_seconds": config.min_submit_interval_seconds,
            "max_submits_per_hour": config.max_submits_per_hour,
            "max_submits_per_day": config.max_submits_per_day,
        },
        "runtime": {
            "platform": platform.system(),
            "chrome_app": os.getenv("MJ_CHROME_APP", ""),
            "cdp_port": os.getenv("MJ_CDP_PORT", "9222"),
            "cdp_profile_dir": os.getenv("MJ_CDP_PROFILE_DIR", ".chrome-cdp-profile"),
        },
        "cookie_claims_preview": {
            "midjourney_id": claims.get("midjourney_id") if claims else None,
            "email": claims.get("email") if claims else None,
            "name": claims.get("name") if claims else None,
        },
        "recent_submit_count": len(usage_log),
    }

    if args.fetch_user_state and config.cookie:
        try:
            if args.transport in {"auto", "browser"}:
                try:
                    result["user_state"] = browser_fetch_json(
                        config.user_state_url,
                        headers={
                            "accept": "*/*",
                            "content-type": "application/json",
                            "x-csrf-protection": config.csrf_protection,
                        },
                        timeout_seconds=config.timeout_seconds,
                    )
                except Exception:
                    if args.transport == "browser":
                        raise
                    result["user_state"] = get_user_state(config)
            else:
                result["user_state"] = get_user_state(config)
        except Exception as exc:
            result["user_state_error"] = str(exc)

    print_json(result)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/mj_playwright_bridge.mjs
content: #!/usr/bin/env node

import { chromium } from "playwright-core";

const args = process.argv.slice(2);

function getArg(name, fallback = null) {
  const index = args.indexOf(name);
  if (index === -1) return fallback;
  return args[index + 1] ?? fallback;
}

const mode = getArg("--mode");
const debugPort = Number(getArg("--debug-port", "9222"));
const pageUrl = getArg("--page-url");
const requestUrl = getArg("--request-url");
const method = getArg("--method", "GET");
const headers = JSON.parse(getArg("--headers-json", "{}"));
const payloadJson = getArg("--payload-json");
const payload = payloadJson ? JSON.parse(payloadJson) : null;
const expression = getArg("--expression");
const timeoutSeconds = Number(getArg("--timeout-seconds", "30"));
const forceNavigate = getArg("--force-navigate", "false") === "true";

if (!mode || !pageUrl) {
  console.error("Missing required arguments");
  process.exit(1);
}

async function connectBrowser() {
  return chromium.connectOverCDP(`http://127.0.0.1:${debugPort}`);
}

function pageMatches(page, expectedUrl) {
  const url = String(page.url() || "");
  return url.startsWith(expectedUrl);
}

function pageMatchesFamily(page, expectedUrl) {
  const url = String(page.url() || "");
  if (url.startsWith(expectedUrl)) return true;
  if (expectedUrl.includes("/jobs/")) {
    return url.includes("alpha.midjourney.com/jobs/");
  }
  return false;
}

async function getOrCreatePage(browser, expectedUrl) {
  const contexts = browser.contexts();
  for (const context of contexts) {
    for (const page of context.pages()) {
      if (pageMatches(page, expectedUrl)) return page;
    }
  }
  for (const context of contexts) {
    for (const page of context.pages()) {
      if (pageMatchesFamily(page, expectedUrl)) return page;
    }
  }
  const context = contexts[0] || (await browser.newContext());
  return context.newPage();
}

async function assertNoSecurityChallenge(page) {
  const probe = await page.evaluate(() => ({
    href: location.href,
    title: document.title,
    body: document.body && document.body.innerText ? document.body.innerText.slice(0, 1000) : "",
  }));
  const haystack = `${probe.href || ""}\n${probe.title || ""}\n${probe.body || ""}`.toLowerCase();
  const markers = [
    "verify you are human",
    "performing security verification",
    "just a moment",
    "cloudflare",
    "security service to protect against malicious bots",
  ];
  if (markers.some((marker) => haystack.includes(marker))) {
    throw new Error("Cloudflare challenge detected on the active Midjourney page. Resolve it manually in Chrome, then retry.");
  }
}

async function ensurePageReady(page, expectedUrl) {
  if (forceNavigate || !page.url().startsWith(expectedUrl)) {
    await page.goto(expectedUrl, {
      waitUntil: "domcontentloaded",
      timeout: timeoutSeconds * 1000,
    });
  }
  await assertNoSecurityChallenge(page);
}

async function runFetch(page) {
  if (!requestUrl) {
    throw new Error("--request-url is required for fetch mode");
  }
  const result = await page.evaluate(
    async ({ requestUrl: targetUrl, requestMethod, requestHeaders, requestBody }) => {
      try {
        const response = await fetch(targetUrl, {
          method: requestMethod,
          headers: requestHeaders,
          credentials: "include",
          body: requestBody ? JSON.stringify(requestBody) : undefined,
        });
        const text = await response.text();
        return {
          ok: response.ok,
          status: response.status,
          text,
        };
      } catch (error) {
        return {
          ok: false,
          status: 0,
          text: "",
          error: String(error),
        };
      }
    },
    {
      requestUrl,
      requestMethod: method,
      requestHeaders: headers,
      requestBody: payload,
    },
  );
  if (!result?.ok) {
    throw new Error(`${result?.error || `HTTP ${result?.status}`}: ${String(result?.text || "").slice(0, 500)}`);
  }
  process.stdout.write(result.text);
}

async function runEval(page) {
  if (!expression) {
    throw new Error("--expression is required for eval mode");
  }
  const value = await page.evaluate(({ source }) => {
    // Local bridge for trusted local expressions only.
    return eval(source);
  }, { source: expression });
  process.stdout.write(JSON.stringify(value));
}

async function main() {
  const browser = await connectBrowser();
  try {
    const page = await getOrCreatePage(browser, pageUrl);
    page.setDefaultTimeout(timeoutSeconds * 1000);
    await ensurePageReady(page, pageUrl);
    if (mode === "fetch") {
      await runFetch(page);
      return;
    }
    if (mode === "eval") {
      await runEval(page);
      return;
    }
    throw new Error(`Unsupported mode: ${mode}`);
  } finally {
    await browser.close();
  }
}

main().catch((error) => {
  console.error(error.message || String(error));
  process.exit(1);
});


path: scripts/mj_prompt_helper.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import shlex
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from mj_alpha import (
    apply_preset,
    append_negative_terms,
    normalize_prompt,
    print_json,
    resolve_named_suffix,
)


TEMPLATES: dict[str, list[str]] = {
    "portrait": ["subject", "framing", "camera", "lighting", "background", "mood", "details"],
    "product": ["subject", "material", "camera", "surface", "lighting", "background", "mood", "details"],
    "environment": ["subject", "time_of_day", "camera", "hero_details", "materials", "mood", "palette", "details"],
    "character": ["subject", "wardrobe", "pose", "expression", "background", "lighting", "style", "details"],
    "character_sheet": ["sheet_layout", "closeup", "subject", "wardrobe", "pose", "background", "lighting", "style", "details"],
    "custom": ["subject", "details"],
}


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Build a Midjourney V8-friendly prompt from structured fields.")
    parser.add_argument("--template", choices=sorted(TEMPLATES), default="custom", help="Prompt template")
    parser.add_argument("--subject", required=True, help="Main subject or scene")
    parser.add_argument("--framing", help="Portrait framing or crop style")
    parser.add_argument("--camera", help="Lens, focal length, or camera feel")
    parser.add_argument("--lighting", help="Lighting style")
    parser.add_argument("--background", help="Background or environment")
    parser.add_argument("--mood", help="Mood or art direction")
    parser.add_argument("--details", help="Additional details")
    parser.add_argument("--sheet-layout", dest="sheet_layout", help="Sheet composition or multi-view layout")
    parser.add_argument("--closeup", help="Close-up panel description for split-view sheets")
    parser.add_argument("--material", help="Materials or finishes")
    parser.add_argument("--surface", help="Surface under the subject")
    parser.add_argument("--time-of-day", dest="time_of_day", help="Time of day or weather")
    parser.add_argument("--hero-details", dest="hero_details", help="Hero objects or focal details")
    parser.add_argument("--materials", help="Architectural or scene materials")
    parser.add_argument("--palette", help="Color palette")
    parser.add_argument("--wardrobe", help="Wardrobe or costume")
    parser.add_argument("--pose", help="Pose")
    parser.add_argument("--expression", help="Expression")
    parser.add_argument("--style", help="Rendering or illustration style")
    parser.add_argument("--preset", help="Preset name from config/presets.example.json")
    parser.add_argument("--presets-file", default="config/presets.example.json", help="Path to presets JSON")
    parser.add_argument("--quality-profile", help="Quality profile name from config/quality-profiles.example.json")
    parser.add_argument("--quality-profiles-file", default="config/quality-profiles.example.json", help="Path to quality profiles JSON")
    parser.add_argument("--negative", help="Negative terms appended as --no ... when absent")
    parser.add_argument("--default-version", default="8", help="Default Midjourney version")
    parser.add_argument("--no-raw", action="store_true", help="Do not auto-append --raw")
    parser.add_argument("--json", action="store_true", help="Print structured JSON instead of plain prompt")
    return parser


def build_prompt_from_template(args) -> str:
    parts: list[str] = []
    for field_name in TEMPLATES[args.template]:
        value = getattr(args, field_name, None)
        if isinstance(value, str) and value.strip():
            parts.append(" ".join(value.strip().split()))
    return ", ".join(parts)


def main() -> int:
    args = build_parser().parse_args()
    prompt = build_prompt_from_template(args)
    preset_suffix = resolve_named_suffix(args.presets_file, args.preset, label="preset")
    if preset_suffix:
        prompt = apply_preset(prompt, preset_suffix)
    quality_suffix = resolve_named_suffix(args.quality_profiles_file, args.quality_profile, label="quality profile")
    if quality_suffix:
        prompt = apply_preset(prompt, quality_suffix)
    prompt = append_negative_terms(prompt, args.negative)
    final_prompt = normalize_prompt(prompt, default_version=args.default_version, add_raw=not args.no_raw)

    if args.json:
        print_json(
            {
                "template": args.template,
                "preset": args.preset,
                "quality_profile": args.quality_profile,
                "negative": args.negative,
                "prompt": final_prompt,
                "run_imagine_command": " ".join(
                    [
                        "python3",
                        "scripts/run_imagine.py",
                        shlex.quote(final_prompt),
                        "--transport",
                        "browser",
                        "--sync-user-state",
                    ]
                ),
            }
        )
        return 0

    print(final_prompt)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/poll_job.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from mj_alpha import enrich_config_from_cookie, load_config, poll_job, print_json, require_config, save_json


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Poll one Midjourney Alpha job.")
    parser.add_argument("job_id", help="Midjourney job UUID")
    parser.add_argument("--dotenv", default=".env", help="Path to .env file")
    parser.add_argument("--attempts", type=int, default=60, help="Max poll attempts")
    parser.add_argument("--interval", type=float, help="Override MJ_POLL_INTERVAL_SECONDS")
    parser.add_argument("--once", action="store_true", help="Fetch only one status response")
    parser.add_argument("--output", help="Write JSON result to file")
    return parser


def main() -> int:
    args = build_parser().parse_args()
    config = enrich_config_from_cookie(load_config(args.dotenv))
    require_config(config, need_status=True, need_channel_id=False)

    result = poll_job(
        config,
        args.job_id,
        attempts=args.attempts,
        interval_seconds=args.interval,
        once=args.once,
    )

    if args.output:
        save_json(args.output, result)
    print_json(result)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/run_imagine.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import sys
from pathlib import Path
from urllib.parse import urlencode

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from mj_browser import (
    browser_fetch_json,
    wait_for_imagine_feed_jobs,
    wait_for_job_cdn_assets,
    wait_for_job_page_assets,
    watch_job_results,
)
from mj_alpha import (
    apply_preset,
    append_negative_terms,
    download_image,
    enrich_config_from_cookie,
    enforce_safe_limits,
    extract_job_ids,
    get_user_state,
    load_config,
    normalize_prompt,
    poll_job,
    print_json,
    record_submit,
    require_config,
    resolve_named_suffix,
    save_json,
    submit_imagine,
    summarize_job_images,
    wait_for_recent_job,
)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Submit a Midjourney Alpha imagine job and optionally wait.")
    parser.add_argument("prompt", help="Natural prompt or full MJ prompt")
    parser.add_argument("--dotenv", default=".env", help="Path to .env file")
    parser.add_argument("--transport", choices=["auto", "browser", "http"], default="auto", help="Request transport")
    parser.add_argument("--channel-id", help="Override MJ_CHANNEL_ID")
    parser.add_argument("--mode", help="Override MJ_MODE")
    parser.add_argument("--private", action="store_true", help="Submit as private/stealth")
    parser.add_argument("--public", action="store_true", help="Submit as public")
    parser.add_argument("--wait", action="store_true", help="Poll job status after submit")
    parser.add_argument("--attempts", type=int, default=60, help="Max poll attempts when --wait is enabled")
    parser.add_argument("--interval", type=float, help="Override MJ_POLL_INTERVAL_SECONDS")
    parser.add_argument("--default-version", default="8", help="Default Midjourney version when prompt has no version flag")
    parser.add_argument("--no-raw", action="store_true", help="Do not auto-append --raw")
    parser.add_argument("--preset", help="Preset name from config/presets.example.json or your own JSON file")
    parser.add_argument("--presets-file", default="config/presets.example.json", help="Path to presets JSON")
    parser.add_argument("--quality-profile", help="Quality profile name from config/quality-profiles.example.json")
    parser.add_argument("--quality-profiles-file", default="config/quality-profiles.example.json", help="Path to quality profiles JSON")
    parser.add_argument("--negative", help="Append a --no clause when the prompt does not already define one")
    parser.add_argument("--sync-user-state", action="store_true", help="Read /api/user-mutable-state and use server defaults when flags are not overridden")
    parser.add_argument("--unsafe-no-throttle", action="store_true", help="Disable local conservative rate limits")
    parser.add_argument("--wait-recent-jobs", action="store_true", help="Legacy: wait for the submitted job to appear in recent-jobs")
    parser.add_argument("--wait-page-assets", action="store_true", help="Wait for page resource URLs for this job_id to appear on the Alpha imagine page")
    parser.add_argument("--recent-attempts", type=int, default=40, help="Max recent-jobs polling attempts")
    parser.add_argument("--recent-amount", type=int, default=25, help="Recent-jobs page size while waiting")
    parser.add_argument("--download", action="store_true", help="Download result images to local disk after recent-jobs verification")
    parser.add_argument("--download-dir", default="outputs", help="Base directory for downloaded images")
    parser.add_argument(
        "--convert-to",
        choices=["original", "png"],
        default="original",
        help="Download images as their original format or browser-convert them to PNG",
    )
    parser.add_argument("--dry-run", action="store_true", help="Print normalized payload without submitting")
    parser.add_argument("--output-dir", help="Directory for submit.json and poll.json")
    return parser


def resolve_preset_suffix(presets_file: str, preset_name: str | None) -> str | None:
    return resolve_named_suffix(presets_file, preset_name, label="preset")


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    if args.private and args.public:
        parser.error("Choose either --private or --public, not both")

    private = None
    if args.private:
        private = True
    elif args.public:
        private = False

    prompt = args.prompt
    preset_suffix = resolve_preset_suffix(args.presets_file, args.preset)
    if preset_suffix:
        prompt = apply_preset(prompt, preset_suffix)
    quality_suffix = resolve_named_suffix(args.quality_profiles_file, args.quality_profile, label="quality profile")
    if quality_suffix:
        prompt = apply_preset(prompt, quality_suffix)
    prompt = append_negative_terms(prompt, args.negative)

    normalized_prompt = normalize_prompt(
        prompt,
        default_version=args.default_version,
        add_raw=not args.no_raw,
    )

    config = enrich_config_from_cookie(load_config(args.dotenv))
    if args.channel_id:
        config.channel_id = args.channel_id

    user_state = None
    if args.sync_user_state and not args.dry_run:
        try:
            if args.transport in {"auto", "browser"}:
                try:
                    user_state = browser_fetch_json(
                        config.user_state_url,
                        headers={
                            "accept": "*/*",
                            "content-type": "application/json",
                            "x-csrf-protection": config.csrf_protection,
                        },
                        timeout_seconds=config.timeout_seconds,
                    )
                except Exception:
                    if args.transport == "browser":
                        raise
                    user_state = get_user_state(config)
            else:
                user_state = get_user_state(config)
        except Exception as exc:
            raise SystemExit(f"Failed to sync from user-mutable-state: {exc}")

        settings = user_state.get("settings", {}) if isinstance(user_state, dict) else {}
        if not args.mode and isinstance(settings.get("speed"), str):
            config.mode = settings["speed"]
        if private is None and isinstance(settings.get("visibility"), str):
            private = settings["visibility"].lower() in {"stealth", "private"}

    require_config(
        config,
        need_status=args.wait and not args.dry_run,
        need_cookie=not args.dry_run,
    )

    safe_limits = None
    if not args.dry_run and not args.unsafe_no_throttle:
        safe_limits = enforce_safe_limits(config)

    if args.dry_run:
        submit_result = submit_imagine(
            config,
            normalized_prompt,
            mode=args.mode,
            private=private,
            channel_id=args.channel_id,
            dry_run=True,
        )
    elif args.transport in {"auto", "browser"}:
        mode_to_use = args.mode or config.mode
        private_to_use = config.private if private is None else private
        payload = {
            "f": {"mode": mode_to_use, "private": private_to_use},
            "channelId": args.channel_id or config.channel_id,
            "metadata": {
                "isMobile": None,
                "imagePrompts": 0,
                "imageReferences": 0,
                "characterReferences": 0,
                "depthReferences": 0,
                "lightboxOpen": None,
            },
            "t": "imagine",
            "prompt": normalized_prompt,
        }
        try:
            response = browser_fetch_json(
                config.submit_url,
                method="POST",
                headers={
                    "accept": "*/*",
                    "content-type": "application/json",
                    "x-csrf-protection": config.csrf_protection,
                },
                payload=payload,
                timeout_seconds=config.timeout_seconds,
            )
            submit_result = {
                "dry_run": False,
                "transport": "browser",
                "submit_url": config.submit_url,
                "payload": payload,
                "response": response,
                "job_ids": extract_job_ids(response),
            }
        except Exception:
            if args.transport == "browser":
                raise
            submit_result = submit_imagine(
                config,
                normalized_prompt,
                mode=args.mode,
                private=private,
                channel_id=args.channel_id,
                dry_run=False,
            )
            submit_result["transport"] = "http"
    else:
        submit_result = submit_imagine(
            config,
            normalized_prompt,
            mode=args.mode,
            private=private,
            channel_id=args.channel_id,
            dry_run=False,
        )
        submit_result["transport"] = "http"

    output = {
        "final_prompt": normalized_prompt,
        "preset": args.preset,
        "quality_profile": args.quality_profile,
        "negative": args.negative,
        "safe_mode": not args.unsafe_no_throttle,
        "safe_limits": safe_limits,
        "user_state_synced": bool(user_state),
        "user_state": user_state,
        "submit": submit_result,
    }

    if args.output_dir:
        save_json(Path(args.output_dir) / "submit.json", submit_result)

    job_ids = extract_job_ids(submit_result) if not args.dry_run else []

    if not args.dry_run:
        mode_used = (
            submit_result.get("payload", {})
            .get("f", {})
            .get("mode", config.mode)
        )
        private_used = (
            submit_result.get("payload", {})
            .get("f", {})
            .get("private", config.private if private is None else private)
        )
        output["usage_log_entry"] = record_submit(
            config,
            prompt=normalized_prompt,
            mode=str(mode_used),
            private=bool(private_used),
            job_id=job_ids[0] if job_ids else None,
            dry_run=False,
            submit_url=submit_result.get("submit_url", config.submit_url),
        )

    if (args.wait_recent_jobs or args.wait_page_assets or args.download) and not args.dry_run:
        if not job_ids:
            raise SystemExit("Submit response did not contain a job_id; cannot verify in recent-jobs.")
        if args.transport in {"auto", "browser"} and (args.wait_page_assets or args.download):
            watch_timeout = min(
                max(10.0, float(config.poll_interval_seconds) * 3.0),
                max(10.0, float(config.timeout_seconds)),
            )
            try:
                watch_result = watch_job_results(
                    [job_ids[0]],
                    page_url="https://alpha.midjourney.com/imagine",
                    min_count=4,
                    timeout_seconds=watch_timeout,
                )
                recent_result = (watch_result.get("jobs", {}) or {}).get(job_ids[0]) or {
                    "job_id": job_ids[0],
                    "done": False,
                    "result_urls": [],
                    "source": "network_watch",
                    "watch_result": watch_result,
                }
                recent_result.setdefault("source", "network_watch")
                recent_result.setdefault("watch_result", watch_result)
                if len(list(recent_result.get("result_urls") or [])) < 4:
                    feed_wait = wait_for_imagine_feed_jobs(
                        [job_ids[0]],
                        user_id=config.user_id or "",
                        csrf_protection=config.csrf_protection,
                        attempts=args.recent_attempts,
                        interval_seconds=config.poll_interval_seconds,
                        page_size=1000,
                        page_url="https://alpha.midjourney.com/imagine",
                        timeout_seconds=config.timeout_seconds,
                    )
                    recent_result = wait_for_job_cdn_assets(
                        job_ids[0],
                        attempts=max(2, min(6, args.recent_attempts // 4 or 2)),
                        interval_seconds=max(1.0, config.poll_interval_seconds),
                        timeout_seconds=config.timeout_seconds,
                    )
                    if len(list(recent_result.get("result_urls") or [])) < 4:
                        recent_result = wait_for_job_page_assets(
                            job_ids[0],
                            attempts=max(2, min(6, args.recent_attempts // 4 or 2)),
                            interval_seconds=max(1.0, config.poll_interval_seconds),
                            timeout_seconds=config.timeout_seconds,
                        )
                    recent_result["imagine_feed_check"] = feed_wait
                    recent_result["imagine_feed_job"] = (feed_wait.get("jobs", {}) or {}).get(job_ids[0])
            except Exception:
                if args.transport == "browser":
                    raise
                raise
        elif args.transport in {"auto", "browser"}:
            try:
                history = []
                recent_result = None
                for attempt in range(1, max(1, args.recent_attempts) + 1):
                    params = urlencode(
                        {
                            "userId": config.user_id,
                            "page": 1,
                            "amount": args.recent_amount,
                            "orderBy": "new",
                            "jobStatus": "completed",
                            "dedupe": "true",
                            "refreshApi": "0",
                        }
                    )
                    response = browser_fetch_json(
                        f"{config.recent_jobs_url}?{params}",
                        page_url="https://www.midjourney.com/",
                        headers={"accept": "*/*", "content-type": "application/json"},
                        timeout_seconds=config.timeout_seconds,
                    )
                    from mj_alpha import find_job_by_id
                    job = find_job_by_id(response, job_ids[0])
                    history.append({"attempt": attempt, "timestamp": int(__import__('time').time()), "match_found": job is not None})
                    if job:
                        recent_result = {
                            "job_id": job_ids[0],
                            "attempts": attempt,
                            "done": True,
                            "history": history,
                            "job": job,
                            "result_urls": list(job.get("image_paths") or []),
                        }
                        break
                    if attempt < args.recent_attempts:
                        __import__('time').sleep(config.poll_interval_seconds)
                if recent_result is None:
                    recent_result = {
                        "job_id": job_ids[0],
                        "attempts": args.recent_attempts,
                        "done": False,
                        "history": history,
                        "job": None,
                        "result_urls": [],
                    }
            except Exception:
                if args.transport == "browser":
                    raise
                recent_result = wait_for_recent_job(
                    config,
                    job_ids[0],
                    attempts=args.recent_attempts,
                    amount=args.recent_amount,
                )
        else:
            recent_result = wait_for_recent_job(
                config,
                job_ids[0],
                attempts=args.recent_attempts,
                amount=args.recent_amount,
            )
        output["recent_job_check"] = recent_result
        if args.output_dir:
            save_json(Path(args.output_dir) / "recent-job.json", recent_result)

        job = recent_result.get("job")
        if job:
            output["recent_job_summary"] = summarize_job_images(job)
        elif recent_result.get("result_urls"):
            output["recent_job_summary"] = {
                "job_id": job_ids[0],
                "image_count": len(recent_result.get("result_urls") or []),
                "image_paths": list(recent_result.get("result_urls") or []),
                "source": "page_assets",
            }

        if args.download:
            image_paths = list(recent_result.get("result_urls") or [])
            if not image_paths:
                raise SystemExit("No downloadable result URLs were found; cannot download.")

            job_output_dir = Path(args.download_dir) / (job_ids[0] if job_ids else "unknown-job")
            downloads = []
            for index, image_url in enumerate(image_paths, start=1):
                downloads.append(
                    download_image(
                        config,
                        image_url,
                        job_output_dir,
                        filename_stem=f"{job_ids[0]}_{index}",
                        convert_to=args.convert_to,
                    )
                )
            output["downloads"] = downloads
            if args.output_dir:
                save_json(Path(args.output_dir) / "downloads.json", downloads)

    if args.wait and not args.dry_run:
        if not job_ids:
            raise SystemExit("Submit response did not contain a job_id; cannot poll.")

        poll_result = poll_job(
            config,
            job_ids[0],
            attempts=args.attempts,
            interval_seconds=args.interval,
        )
        output["poll"] = poll_result

        if args.output_dir:
            save_json(Path(args.output_dir) / "poll.json", poll_result)

    print_json(output)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/submit_job.py
content: #!/usr/bin/env python3

from __future__ import annotations

import argparse
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from mj_alpha import enrich_config_from_cookie, load_config, print_json, require_config, save_json, submit_imagine


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Submit one Midjourney Alpha imagine job.")
    parser.add_argument("prompt", help="Full prompt to submit")
    parser.add_argument("--dotenv", default=".env", help="Path to .env file")
    parser.add_argument("--channel-id", help="Override MJ_CHANNEL_ID")
    parser.add_argument("--mode", help="Override MJ_MODE, for example fast")
    parser.add_argument("--private", action="store_true", help="Submit as private/stealth")
    parser.add_argument("--public", action="store_true", help="Submit as public")
    parser.add_argument("--dry-run", action="store_true", help="Print payload without submitting")
    parser.add_argument("--output", help="Write JSON result to file")
    return parser


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    if args.private and args.public:
        parser.error("Choose either --private or --public, not both")

    private = None
    if args.private:
        private = True
    elif args.public:
        private = False

    config = enrich_config_from_cookie(load_config(args.dotenv))
    if args.channel_id:
        config.channel_id = args.channel_id
    require_config(config, need_cookie=not args.dry_run)

    result = submit_imagine(
        config,
        args.prompt,
        mode=args.mode,
        private=private,
        channel_id=args.channel_id,
        dry_run=args.dry_run,
    )

    if args.output:
        save_json(args.output, result)
    print_json(result)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


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