漫小白API 文档
下载完整 .md ↓

完整代码与下载

提供 Python 3.10+ 标准库和 Node.js 22+ 两个完整示例,均不依赖第三方 SDK。读取服务端 MXB_API_KEY,只提交一次,保存任务 ID,轮询后下载图片;发生等待超时可继续查询原任务。

下载:Python 示例 · Node.js 示例 · 完整 Markdown。代码也完整包含在下方和“复制全部文档”的 Markdown 中。

视频接入另见 SD / Seedance 完整说明,可下载 Python 视频客户端,支持保存任务和断点续查。

Python 运行方法

bash
# 已在当前进程设置 MXB_API_KEY
python mxb-image.py --prompt "白色桌面上的蓝色陶瓷茶壶"

# 指定模型与尺寸
python mxb-image.py --model gemini-3.1-flash-image-preview-1k \
  --size 1536x864 --prompt "极简茶壶产品摄影,横向构图"

# 单张参考图的 JSON 编辑方式;先确认目标模型/分组支持
python mxb-image.py --reference reference.png --prompt "保留主体,把背景改成浅绿色"

# 使用真实 ID 继续查询,不会创建新任务
python mxb-image.py --task "$MXB_TASK_ID" --output result.png

Node.js 运行方法

bash
# 已设置 MXB_API_KEY;创建任务前清除旧的 MXB_TASK_ID
unset MXB_TASK_ID
export MXB_PROMPT='白色桌面上的蓝色陶瓷茶壶'
node mxb-image.mjs

# 继续查询已有任务:MXB_TASK_ID 填实际返回的任务 ID
export MXB_TASK_ID='填写实际任务ID'
node mxb-image.mjs

Node 可选环境变量:MXB_MODEL、MXB_SIZE、MXB_REFERENCE、MXB_OUTPUT。设置 MXB_TASK_ID 后只查询,不再提交生成。

使用边界

示例默认保存第一张图片,因此固定 n=1。文件后缀不转换实际图片格式;生产应用还应使用图像库验证完整解码与像素,并配置下载大小限制。JSON 参考图示例有 GPT 2.5/Gemini 的既有成功依据,不代表所有 GPT 渠道都支持同一种扩展。

代码已经过本地协议模拟、错误与重试流程检查,接口契约与当前生产实现核对。本文档发布没有重新执行付费生成测试;真实生图依据为已完成的 2026-09-10 测试,不把模拟结果冒充本次真实出图。

Python 完整源码

python
#!/usr/bin/env python3
"""Python 3.10+, standard library only. Submit once; resume with --task."""
import argparse
import base64
import json
import math
import mimetypes
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None  # Never forward API credentials through a redirect.


class APIError(RuntimeError):
    def __init__(self, status, message, retry_after=None):
        super().__init__(f'HTTP {status}: {message}')
        self.status, self.retry_after = status, retry_after


def interval(value, default=3):
    try:
        number = float(value)
        return min(30, max(1, number)) if math.isfinite(number) else default
    except (ValueError, TypeError):
        return default


class ImageClient:
    def __init__(self, key, base_url='https://api.manxiaobai.online'):
        self.key = key
        self.base = base_url.rstrip('/')
        parsed = urllib.parse.urlsplit(self.base)
        if parsed.scheme != 'https' or parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment:
            raise ValueError('base_url must be an HTTPS origin, without /v1 or credentials')
        self.opener = urllib.request.build_opener(NoRedirect())

    def request(self, path, payload=None, timeout=30):
        if not path.startswith('/v1/') or path.startswith('//'):
            raise ValueError('Only fixed /v1/ API paths are accepted')
        body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode('utf-8')
        request = urllib.request.Request(self.base + path, data=body, headers={
            'Authorization': 'Bearer ' + self.key,
            'Content-Type': 'application/json',
        }, method='GET' if payload is None else 'POST')
        try:
            with self.opener.open(request, timeout=timeout) as response:
                result = json.load(response)
        except urllib.error.HTTPError as error:
            raw = error.read(2048).decode('utf-8', errors='replace')
            raise APIError(error.code, raw, error.headers.get('Retry-After')) from error
        if not isinstance(result, dict):
            raise RuntimeError('API response must be a JSON object')
        return result

    def submit(self, model, prompt, size='1024x1024', reference=None):
        body = {'model': model, 'prompt': prompt, 'size': size, 'n': 1}
        path = '/v1/images/generations/async'
        if reference:
            file = Path(reference)
            mime = mimetypes.guess_type(file.name)[0]
            if mime not in ('image/png', 'image/jpeg', 'image/webp', 'image/bmp'):
                raise ValueError('Use a real PNG/JPEG/WebP/BMP reference image')
            raw = file.read_bytes()
            if not raw or len(raw) > 25 * 1024 * 1024:
                raise ValueError('Reference image must be nonempty and at most 25 MiB; channel limits may be lower')
            body['images'] = [{'image_url': f'data:{mime};base64,' + base64.b64encode(raw).decode('ascii')}]
            path = '/v1/images/edits/async'
        # Intentionally exactly one POST. A timeout may mean submission succeeded.
        result = self.request(path, body, timeout=60)
        task_id = result.get('task_id') or result.get('id')
        if not isinstance(task_id, str) or not re.fullmatch(r'[A-Za-z0-9_-]+', task_id):
            raise RuntimeError('No valid task ID returned. Check logs before submitting again.')
        return task_id, result

    def wait(self, task_id, timeout=1800):
        if not re.fullmatch(r'[A-Za-z0-9_-]+', task_id):
            raise ValueError('Invalid task ID')
        deadline, delay = time.monotonic() + timeout, 3
        while time.monotonic() < deadline:
            try:
                task = self.request('/v1/images/tasks/' + task_id)
            except APIError as error:
                if error.status not in (429, 500, 502, 503, 504):
                    raise
                time.sleep(interval(error.retry_after, delay))
                delay = min(30, delay * 1.5)
                continue
            except (urllib.error.URLError, TimeoutError):
                time.sleep(delay)
                delay = min(30, delay * 1.5)
                continue
            status = str(task.get('status') or task.get('state') or '').lower()
            if task.get('submission_uncertain'):
                raise RuntimeError(f'Submission uncertain; check task {task_id} and billing before any new POST')
            if status in ('completed', 'succeeded', 'success', 'done'):
                nested = task.get('result')
                data = task.get('data') or (nested.get('data') if isinstance(nested, dict) else None)
                if not isinstance(data, list) or not data or not isinstance(data[0], dict):
                    raise RuntimeError(f'Task {task_id} completed without valid image data; do not resubmit automatically')
                return data, task
            if status in ('failed', 'failure', 'error', 'cancelled', 'canceled', 'expired'):
                raise RuntimeError(f'Task {task_id} failed: {json.dumps(task.get("error"), ensure_ascii=False)}')
            delay = interval(task.get('poll_after_ms', 3000) / 1000 if isinstance(task.get('poll_after_ms', 3000), (int, float)) else 3)
            time.sleep(delay)
        raise TimeoutError(f'Stopped waiting. Resume with --task {task_id}; this does not cancel the server task.')


def download_image(item, destination):
    if item.get('url'):
        url = str(item['url'])
        if urllib.parse.urlsplit(url).scheme not in ('https', 'http'):
            raise ValueError('Unsupported result URL scheme')
        # Separate unauthenticated request; never send the API Key to image hosts.
        with urllib.request.urlopen(url, timeout=120) as response:
            if not response.headers.get('Content-Type', '').lower().startswith('image/'):
                raise RuntimeError('Download did not return an image Content-Type')
            data = response.read()
    elif item.get('b64_json'):
        data = base64.b64decode(item['b64_json'], validate=True)
    else:
        raise RuntimeError('Image result has neither URL nor Base64')
    if not data:
        raise RuntimeError('Empty image result')
    Path(destination).write_bytes(data)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--model', default='gpt-image-2.5')
    parser.add_argument('--prompt')
    parser.add_argument('--size', default='1024x1024')
    parser.add_argument('--reference')
    parser.add_argument('--task', help='Resume an existing task; never creates another task')
    parser.add_argument('--timeout', type=int, default=1800)
    parser.add_argument('--output', default='result.png')
    args = parser.parse_args()
    key = os.environ.get('MXB_API_KEY')
    if not key:
        parser.error('Set MXB_API_KEY in the server environment')
    if args.timeout <= 0:
        parser.error('--timeout must be positive')
    if not args.task and not args.prompt:
        parser.error('--prompt is required when creating a task')
    client = ImageClient(key)
    task_id = args.task
    if not task_id:
        try:
            task_id, response = client.submit(args.model, args.prompt, args.size, args.reference)
        except Exception:
            print('Submission was not retried. If transmission started, check logs before another POST.', file=sys.stderr)
            raise
        print('Task:', task_id, flush=True)
        Path(f'{task_id}.json').write_text(json.dumps(response, ensure_ascii=False, indent=2), encoding='utf-8')
    data, final = client.wait(task_id, args.timeout)
    Path(f'{task_id}.result.json').write_text(json.dumps(final, ensure_ascii=False, indent=2), encoding='utf-8')
    download_image(data[0], args.output)
    print('Saved:', args.output, '(file extension does not convert the image format)')


if __name__ == '__main__':
    main()

Node.js 完整源码

javascript
// Node.js 22+. No external packages. API requests never follow redirects.
import {readFile, writeFile} from 'node:fs/promises';
import {extname} from 'node:path';
import {pathToFileURL} from 'node:url';

const origin = 'https://api.manxiaobai.online';
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
const delay = value => Number.isFinite(Number(value)) ? Math.max(1000, Math.min(30000, Number(value))) : 3000;
export class APIError extends Error {
  constructor(status, body, retryAfter) {super(`HTTP ${status}: ${body.slice(0, 2048)}`); this.status=status; this.retryAfter=retryAfter;}
}
export class ImageClient {
  constructor(key) {if(!key) throw new Error('Set MXB_API_KEY'); this.key=key;}
  async request(path, body, timeout=30000) {
    if(!path.startsWith('/v1/')) throw new Error('Expected a fixed /v1/ path');
    const response=await fetch(origin+path, {
      method:body===undefined?'GET':'POST', redirect:'error',
      headers:{Authorization:`Bearer ${this.key}`, 'Content-Type':'application/json'},
      body:body===undefined?undefined:JSON.stringify(body), signal:AbortSignal.timeout(timeout)
    });
    if(!response.ok) throw new APIError(response.status,await response.text(),response.headers.get('retry-after'));
    const result=await response.json();
    if(!result||typeof result!=='object'||Array.isArray(result)) throw new Error('Invalid API response object');
    return result;
  }
  async submit({model='gpt-image-2.5',prompt,size='1024x1024',reference}) {
    if(!prompt) throw new Error('Set MXB_PROMPT when creating a task');
    const body={model,prompt,size,n:1}; let path='/v1/images/generations/async';
    if(reference) {
      const types={'.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.bmp':'image/bmp'};
      const mime=types[extname(reference).toLowerCase()];
      if(!mime) throw new Error('Use a real PNG/JPEG/WebP/BMP reference image');
      const bytes=await readFile(reference);
      if(!bytes.length||bytes.length>25*1024*1024) throw new Error('Reference must be nonempty and at most 25 MiB; channel limits may be lower');
      body.images=[{image_url:`data:${mime};base64,${bytes.toString('base64')}`}]; path='/v1/images/edits/async';
    }
    const task=await this.request(path,body,60000); // Exactly one POST, no automatic retry.
    const id=task.task_id||task.id;
    if(typeof id!=='string'||!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error('No valid task ID. Check logs before submitting again.');
    return {id,task};
  }
  async wait(id,timeout=1800000) {
    if(!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error('Invalid task ID');
    const deadline=Date.now()+timeout; let interval=3000;
    while(Date.now()<deadline) {
      let task;
      try {task=await this.request('/v1/images/tasks/'+encodeURIComponent(id));}
      catch(error) {
        if(error instanceof APIError && ![429,500,502,503,504].includes(error.status)) throw error;
        if(!(error instanceof APIError)&& !['TypeError','TimeoutError','AbortError'].includes(error.name)) throw error;
        const wait=error.retryAfter===null||error.retryAfter===undefined?interval:delay(Number(error.retryAfter)*1000);
        await pause(wait); interval=Math.min(30000,interval*1.5); continue;
      }
      const state=String(task.status||task.state||'').toLowerCase();
      if(task.submission_uncertain) throw new Error(`Submission uncertain. Check task ${id} and billing before another POST.`);
      if(['completed','succeeded','success','done'].includes(state)) {
        const data=task.data?.length?task.data:task.result?.data;
        if(!Array.isArray(data)||!data.length||!data[0]||typeof data[0]!=='object') throw new Error(`Task ${id} completed without valid image data`);
        return {data,task};
      }
      if(['failed','failure','error','cancelled','canceled','expired'].includes(state)) throw new Error(`Task ${id} failed: ${JSON.stringify(task.error)}`);
      interval=delay(task.poll_after_ms??3000); await pause(interval);
    }
    throw new Error(`Stopped waiting. Resume with MXB_TASK_ID=${id}; server task is not cancelled.`);
  }
}
export async function downloadImage(item,output) {
  let bytes;
  if(item.url) {
    const url=new URL(item.url); if(!['https:','http:'].includes(url.protocol)) throw new Error('Unsupported image URL');
    const response=await fetch(url,{signal:AbortSignal.timeout(120000)}); // No Authorization header.
    if(!response.ok||!response.headers.get('content-type')?.toLowerCase().startsWith('image/')) throw new Error('Image download failed or returned a non-image');
    bytes=Buffer.from(await response.arrayBuffer());
  } else if(item.b64_json) bytes=Buffer.from(item.b64_json,'base64');
  else throw new Error('No image URL or Base64 data');
  if(!bytes.length) throw new Error('Empty image');
  await writeFile(output,bytes);
}
async function main() {
  const client=new ImageClient(process.env.MXB_API_KEY);
  let id=process.env.MXB_TASK_ID;
  if(!id) {
    let created;
    try {created=await client.submit({model:process.env.MXB_MODEL||'gpt-image-2.5',prompt:process.env.MXB_PROMPT,size:process.env.MXB_SIZE||'1024x1024',reference:process.env.MXB_REFERENCE});}
    catch(error) {console.error('Submission not retried. Check logs before another POST if transmission started.');throw error;}
    id=created.id; console.log('Task:',id);
    await writeFile(`${id}.json`,JSON.stringify(created.task,null,2));
  }
  const {data,task}=await client.wait(id);
  await writeFile(`${id}.result.json`,JSON.stringify(task,null,2));
  const output=process.env.MXB_OUTPUT||'result.png'; await downloadImage(data[0],output);
  console.log('Saved:',output,'(extension does not convert image format)');
}
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href) main().catch(error=>{console.error(error.message);process.exitCode=1;});
漫小白 API 接入文档 · 其他接口 2026-09-16 核对 · SD 章节 2026-10-14 更新
模型权限与费用以当前模型广场及账单为准。反馈文档问题 ↗

复制 Markdown

浏览器未允许自动复制,内容已选中,可按 Ctrl+C / ⌘C 复制。