# MJ 完整客户端示例

<a id="mj-clients"></a>

<a id="python-node-js-完整接入示例"></a>

## Python / Node.js 完整接入示例

在服务端环境变量中配置 `IMAGE2API_API_KEY`，可用 `IMAGE2API_BASE_URL` 覆盖本站根地址。以下脚本实际执行会提交一次收费 Imagine 任务；额外放大示例默认注释。

例如在终端设置 `export IMAGE2API_API_KEY='YOUR_API_KEY'`。Python 示例保存为 `midjourney.py`，安装 requests 后运行 `python midjourney.py`；Node.js 示例保存为 `midjourney.mjs`，使用 Node.js 18+ 运行 `node midjourney.mjs`。

明确拒绝会返回对应 HTTP 状态和错误原因；提交响应头 `X-Midjourney-Submission-Status: rejected` 表示本站已确认未接受生成请求。没有该标记的 502、超时或无效回执不能视为明确拒绝，不应自动重发。查询或下载失败时只重试对应查询或下载。

示例包含提交回执检查、查询退避、终态处理、签名图片下载和原样按钮调用。首次得到任务 ID 后应写入自己的持久存储；示例中的控制台输出不替代持久化。出现 MODAL 时脚本会停止并提示补参，可使用前文 Modal 请求继续。

 **Python：提交 → 轮询 → 下载**  `本站 /mj 原生协议`

```python
"""Python 3；先安装 requests：python -m pip install requests。"""
import os
import time
from pathlib import Path

import requests

BASE_URL = os.environ.get("IMAGE2API_BASE_URL", "https://y.junlistudio.com").rstrip("/")
API_KEY = os.environ["IMAGE2API_API_KEY"]
HEADERS = {"Authorization": f"Bearer {API_KEY}", "Accept": "application/json"}


def submit(operation, body):
    # 提交只发送一次。连接中断、超时、5xx 或无效回执均不得自动重发。
    try:
        response = requests.post(
            f"{BASE_URL}/mj/submit/{operation}", headers=HEADERS, json=body, timeout=90
        )
    except requests.RequestException as exc:
        raise RuntimeError("提交结果不明：保留 state 和时间联系管理员，不要重发") from exc
    reference = response.headers.get("X-Midjourney-Task-Id", "未返回")
    if response.status_code >= 500:
        raise RuntimeError(f"提交结果需核对：HTTP {response.status_code}，参考任务 {reference}")
    if not response.ok:
        raise RuntimeError(f"提交 HTTP {response.status_code}，参考任务 {reference}：{response.text}")
    try:
        receipt = response.json()
        if (isinstance(receipt, dict) and receipt.get("code") in (1, 21, 22)
                and isinstance(receipt.get("result"), str) and receipt["result"]):
            # code 21 仍须检查任务；若为 MODAL，按动作补充参数。
            return receipt["result"]
    except ValueError as exc:
        raise RuntimeError(f"回执无效，不要重发；参考任务 {reference}") from exc
    raise RuntimeError(f"提交未确认接受，请核对回执，不要自动重发：{receipt}")


def wait_for_task(task_id, timeout_seconds=600):
    deadline = time.monotonic() + timeout_seconds
    delay = 5
    while time.monotonic() < deadline:
        try:
            response = requests.get(
                f"{BASE_URL}/mj/task/{task_id}/fetch", headers=HEADERS, timeout=30
            )
            if response.status_code == 429 or response.status_code >= 500:
                time.sleep(min(delay, max(0, deadline - time.monotonic())))
                delay = min(delay * 2, 30)
                continue
            response.raise_for_status()
        except (requests.Timeout, requests.ConnectionError):
            time.sleep(min(delay, max(0, deadline - time.monotonic())))
            delay = min(delay * 2, 30)
            continue
        task = response.json()  # 裸任务对象，不取 data 或 result。
        if not isinstance(task, dict) or task.get("id") != task_id or not task.get("status"):
            raise RuntimeError("查询结果不是预期任务对象，保留任务 ID 后排查")
        status = task["status"]
        print(task_id, status, task.get("progress"))
        if status == "SUCCESS":
            return task
        if status in ("FAILURE", "CANCEL"):
            raise RuntimeError(f"任务 {task_id} {status}: {task.get('failReason')}")
        if status == "MODAL":
            raise RuntimeError(f"任务 {task_id} 等待 Modal：补充参数后调用 /mj/submit/modal")
        delay = 5
        time.sleep(min(delay, max(0, deadline - time.monotonic())))
    raise TimeoutError(f"停止等待，但任务未取消：保存 {task_id}，稍后继续查询，不要重建任务")


def download_image(task, output):
    # 使用完整签名 URL，不向下载站点发送 API Key。过期后重新查询任务。
    response = requests.get(task["imageUrl"], timeout=120)
    response.raise_for_status()
    if not response.headers.get("Content-Type", "").lower().startswith("image/"):
        raise RuntimeError("下载结果不是图片")
    Path(output).write_bytes(response.content)


def submit_button(parent, label):
    button = next((b for b in parent.get("buttons", []) if b.get("label") == label), None)
    if not button:
        raise ValueError(f"本次任务未返回 {label} 按钮")
    return submit("action", {"taskId": parent["id"], "customId": button["customId"]})


if __name__ == "__main__":
    task_id = submit("imagine", {"prompt": "a cat in sunlight --ar 16:9", "state": "python-demo-001"})
    print("请立即保存任务 ID：", task_id)
    task = wait_for_task(task_id)
    download_image(task, "result.png")
    # 放大是额外收费动作，需要时才取消下面注释。
    # child_id = submit_button(task, "U1")
    # print("请立即保存子任务 ID：", child_id)
    # download_image(wait_for_task(child_id), "upscale.png")
```

 **Node.js：提交 → 轮询 → 下载**  `本站 /mj 原生协议`

```javascript
// Node.js 18+，保存为 midjourney.mjs 后执行 node midjourney.mjs。
import { writeFile } from 'node:fs/promises'

const BASE_URL = (process.env.IMAGE2API_BASE_URL || 'https://y.junlistudio.com').replace(/\/$/, '')
const API_KEY = process.env.IMAGE2API_API_KEY
if (!API_KEY) throw new Error('请设置 IMAGE2API_API_KEY')
const headers = { Authorization: `Bearer ${API_KEY}`, Accept: 'application/json' }
const sleep = ms => new Promise(resolve => setTimeout(resolve, Math.max(0, ms)))

async function submit(operation, body) {
  // 不对提交 POST 自动重试，包括连接中断、超时和 5xx。
  let response
  try {
    response = await fetch(`${BASE_URL}/mj/submit/${operation}`, {
      method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' },
      body: JSON.stringify(body), signal: AbortSignal.timeout(90_000),
    })
  } catch (error) {
    throw new Error('提交结果不明：保存 state 和时间联系管理员，不要重发', { cause: error })
  }
  const reference = response.headers.get('X-Midjourney-Task-Id') || '未返回'
  if (!response.ok) throw new Error(`提交 HTTP ${response.status}，参考任务 ${reference}；核对原始响应，不要自动重发：${await response.text()}`)
  let receipt
  try { receipt = await response.json() } catch {
    throw new Error(`回执无效，参考任务 ${reference}；不要重发`)
  }
  if (![1, 21, 22].includes(receipt?.code) || typeof receipt.result !== 'string' || !receipt.result) {
    throw new Error(`提交未确认接受，请核对回执，不要自动重发：${JSON.stringify(receipt)}`)
  }
  return receipt.result
}

async function waitForTask(taskId, timeoutMs = 600_000) {
  const deadline = Date.now() + timeoutMs
  let delay = 5000
  while (Date.now() < deadline) {
    let response
    try {
      response = await fetch(`${BASE_URL}/mj/task/${encodeURIComponent(taskId)}/fetch`, {
        headers, signal: AbortSignal.timeout(30_000),
      })
    } catch {
      await sleep(Math.min(delay, deadline - Date.now()))
      delay = Math.min(delay * 2, 30_000)
      continue
    }
    if (response.status === 429 || response.status >= 500) {
      await response.arrayBuffer() // 消费错误响应后再重试查询。
      await sleep(Math.min(delay, deadline - Date.now()))
      delay = Math.min(delay * 2, 30_000)
      continue
    }
    if (!response.ok) throw new Error(`查询 HTTP ${response.status}: ${await response.text()}`)
    const task = await response.json() // 裸对象，不取 data 或 result。
    if (task?.id !== taskId || !task.status) throw new Error('查询对象无效：保存任务 ID 后排查')
    console.log(taskId, task.status, task.progress)
    if (task.status === 'SUCCESS') return task
    if (['FAILURE', 'CANCEL'].includes(task.status)) throw new Error(`${taskId} ${task.status}: ${task.failReason}`)
    if (task.status === 'MODAL') throw new Error(`${taskId} 等待 Modal：补充参数后调用 /mj/submit/modal`)
    delay = 5000
    await sleep(Math.min(delay, deadline - Date.now()))
  }
  throw new Error(`停止等待，但任务未取消：保存 ${taskId}，稍后继续查询，不要重建任务`)
}

async function downloadImage(task, filename) {
  // 完整签名链接已授权下载，无需 API Key；过期后重新查询任务。
  const response = await fetch(task.imageUrl, { signal: AbortSignal.timeout(120_000) })
  if (!response.ok || !response.headers.get('Content-Type')?.toLowerCase().startsWith('image/')) {
    throw new Error(`下载失败或不是图片：HTTP ${response.status}`)
  }
  await writeFile(filename, Buffer.from(await response.arrayBuffer()))
}

async function submitButton(parent, label) {
  const button = parent.buttons?.find(item => item.label === label)
  if (!button) throw new Error(`本次任务未返回 ${label} 按钮`)
  return submit('action', { taskId: parent.id, customId: button.customId })
}

const taskId = await submit('imagine', { prompt: 'a cat in sunlight --ar 16:9', state: 'node-demo-001' })
console.log('请立即保存任务 ID：', taskId)
const task = await waitForTask(taskId)
await downloadImage(task, 'result.png')
// 放大是额外收费动作，需要时才取消下面注释。
// const childId = await submitButton(task, 'U1')
// console.log('请立即保存子任务 ID：', childId)
// await downloadImage(await waitForTask(childId), 'upscale.png')
```
