mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-16 15:39:47 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a0ffd9533 | |||
| 69355b4ee1 | |||
| 23b7487321 | |||
| ad8781c1c5 | |||
| 86a3d6521b | |||
| e99ce744cd |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.81.0"
|
"version": "1.84.0"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"./skills/baoyu-danger-gemini-web",
|
"./skills/baoyu-danger-gemini-web",
|
||||||
"./skills/baoyu-danger-x-to-markdown",
|
"./skills/baoyu-danger-x-to-markdown",
|
||||||
"./skills/baoyu-format-markdown",
|
"./skills/baoyu-format-markdown",
|
||||||
"./skills/baoyu-image-gen",
|
"./skills/baoyu-imagine",
|
||||||
"./skills/baoyu-infographic",
|
"./skills/baoyu-infographic",
|
||||||
"./skills/baoyu-markdown-to-html",
|
"./skills/baoyu-markdown-to-html",
|
||||||
"./skills/baoyu-post-to-weibo",
|
"./skills/baoyu-post-to-weibo",
|
||||||
|
|||||||
@@ -2,6 +2,31 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.84.0 - 2026-03-25
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Rename `baoyu-image-gen` skill to `baoyu-imagine` — shorter command name, all references updated across docs, configs, and dependent skills
|
||||||
|
|
||||||
|
## 1.83.0 - 2026-03-25
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-image-gen`: add MiniMax provider (`image-01` / `image-01-live`) with subject_reference for character/portrait consistency, custom sizes, and aspect ratio support
|
||||||
|
|
||||||
|
## 1.82.0 - 2026-03-24
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-url-to-markdown`: add browser fallback strategy — headless first, automatic retry in visible Chrome on technical failure; new `--browser auto|headless|headed` flag with `--headless`/`--headed` shortcuts
|
||||||
|
- `baoyu-url-to-markdown`: add content cleaner module for HTML preprocessing before extraction (remove ads, base64 images, scripts, styles)
|
||||||
|
- `baoyu-url-to-markdown`: support base64 data URI images in media localizer alongside remote URLs
|
||||||
|
- `baoyu-url-to-markdown`: capture final URL from browser to track redirects for output path generation
|
||||||
|
- `baoyu-url-to-markdown`: add agent quality gate documentation for post-capture content validation
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- `baoyu-url-to-markdown`: upgrade defuddle ^0.12.0 → ^0.14.0
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `baoyu-url-to-markdown`: add unit tests for content-cleaner, html-to-markdown, legacy-converter, media-localizer
|
||||||
|
|
||||||
## 1.81.0 - 2026-03-24
|
## 1.81.0 - 2026-03-24
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -2,6 +2,31 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.84.0 - 2026-03-25
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- 将 `baoyu-image-gen` 技能重命名为 `baoyu-imagine` — 更简短的命令名,所有文档、配置和依赖技能中的引用已同步更新
|
||||||
|
|
||||||
|
## 1.83.0 - 2026-03-25
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-image-gen`:新增 MiniMax 服务商(`image-01` / `image-01-live`),支持 subject_reference 角色/肖像一致性、自定义尺寸和宽高比
|
||||||
|
|
||||||
|
## 1.82.0 - 2026-03-24
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-url-to-markdown`:新增浏览器回退策略 — 默认无头模式优先,技术故障时自动重试有头 Chrome;新增 `--browser auto|headless|headed` 参数及 `--headless`/`--headed` 快捷方式
|
||||||
|
- `baoyu-url-to-markdown`:新增内容清理模块,提取前预处理 HTML(移除广告、base64 图片、脚本、样式)
|
||||||
|
- `baoyu-url-to-markdown`:媒体本地化支持 base64 data URI 图片
|
||||||
|
- `baoyu-url-to-markdown`:从浏览器捕获最终 URL 以跟踪重定向,用于输出路径生成
|
||||||
|
- `baoyu-url-to-markdown`:新增 Agent 质量门控文档,规范捕获后的内容验证流程
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
- `baoyu-url-to-markdown`:升级 defuddle ^0.12.0 → ^0.14.0
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
- `baoyu-url-to-markdown`:新增 content-cleaner、html-to-markdown、legacy-converter、media-localizer 单元测试
|
||||||
|
|
||||||
## 1.81.0 - 2026-03-24
|
## 1.81.0 - 2026-03-24
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.81.0**.
|
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.84.0**.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ Execute: `${BUN_X} skills/<skill>/scripts/main.ts [options]`
|
|||||||
|
|
||||||
- **Bun**: TypeScript runtime (`bun` preferred, fallback `npx -y bun`)
|
- **Bun**: TypeScript runtime (`bun` preferred, fallback `npx -y bun`)
|
||||||
- **Chrome**: Required for CDP-based skills (gemini-web, post-to-x/wechat/weibo, url-to-markdown). All CDP skills share a single profile, override via `BAOYU_CHROME_PROFILE_DIR` env var. Platform paths: [docs/chrome-profile.md](docs/chrome-profile.md)
|
- **Chrome**: Required for CDP-based skills (gemini-web, post-to-x/wechat/weibo, url-to-markdown). All CDP skills share a single profile, override via `BAOYU_CHROME_PROFILE_DIR` env var. Platform paths: [docs/chrome-profile.md](docs/chrome-profile.md)
|
||||||
- **Image generation APIs**: `baoyu-image-gen` requires API key (OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, or Replicate) configured in EXTEND.md
|
- **Image generation APIs**: `baoyu-imagine` requires API key (OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, or Replicate) configured in EXTEND.md
|
||||||
- **Gemini Web auth**: Browser cookies (first run opens Chrome for login, `--login` to refresh)
|
- **Gemini Web auth**: Browser cookies (first run opens Chrome for login, `--login` to refresh)
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
@@ -46,7 +46,7 @@ Execute: `${BUN_X} skills/<skill>/scripts/main.ts [options]`
|
|||||||
| Rule | Description |
|
| Rule | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| **Load project skills first** | Project skills override system/user-level skills with same name |
|
| **Load project skills first** | Project skills override system/user-level skills with same name |
|
||||||
| **Default image generation** | Use `skills/baoyu-image-gen/SKILL.md` unless user specifies otherwise |
|
| **Default image generation** | Use `skills/baoyu-imagine/SKILL.md` unless user specifies otherwise |
|
||||||
|
|
||||||
Priority: project `skills/` → `$HOME/.baoyu-skills/` → system-level.
|
Priority: project `skills/` → `$HOME/.baoyu-skills/` → system-level.
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ This repository now supports publishing each `skills/baoyu-*` directory as an in
|
|||||||
ClawHub installs skills individually, not as one marketplace bundle. After publishing, users can install specific skills such as:
|
ClawHub installs skills individually, not as one marketplace bundle. After publishing, users can install specific skills such as:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawhub install baoyu-image-gen
|
clawhub install baoyu-imagine
|
||||||
clawhub install baoyu-markdown-to-html
|
clawhub install baoyu-markdown-to-html
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -661,43 +661,58 @@ Post content to Weibo (微博). Supports regular posts with text, images, and vi
|
|||||||
|
|
||||||
AI-powered generation backends.
|
AI-powered generation backends.
|
||||||
|
|
||||||
#### baoyu-image-gen
|
#### baoyu-imagine
|
||||||
|
|
||||||
AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), Jimeng (即梦), Seedream (豆包), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
|
AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), MiniMax, Jimeng (即梦), Seedream (豆包), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, custom sizes, batch generation, and quality presets.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Basic generation (auto-detect provider)
|
# Basic generation (auto-detect provider)
|
||||||
/baoyu-image-gen --prompt "A cute cat" --image cat.png
|
/baoyu-imagine --prompt "A cute cat" --image cat.png
|
||||||
|
|
||||||
# With aspect ratio
|
# With aspect ratio
|
||||||
/baoyu-image-gen --prompt "A landscape" --image landscape.png --ar 16:9
|
/baoyu-imagine --prompt "A landscape" --image landscape.png --ar 16:9
|
||||||
|
|
||||||
# High quality (2k)
|
# High quality (2k)
|
||||||
/baoyu-image-gen --prompt "A banner" --image banner.png --quality 2k
|
/baoyu-imagine --prompt "A banner" --image banner.png --quality 2k
|
||||||
|
|
||||||
# Specific provider
|
# Specific provider
|
||||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openai
|
/baoyu-imagine --prompt "A cat" --image cat.png --provider openai
|
||||||
|
|
||||||
# Azure OpenAI (model = deployment name)
|
# Azure OpenAI (model = deployment name)
|
||||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider azure --model gpt-image-1.5
|
/baoyu-imagine --prompt "A cat" --image cat.png --provider azure --model gpt-image-1.5
|
||||||
|
|
||||||
# OpenRouter
|
# OpenRouter
|
||||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider openrouter
|
/baoyu-imagine --prompt "A cat" --image cat.png --provider openrouter
|
||||||
|
|
||||||
|
# OpenRouter with reference images
|
||||||
|
/baoyu-imagine --prompt "Make it blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
|
||||||
|
|
||||||
# DashScope (Aliyun Tongyi Wanxiang)
|
# DashScope (Aliyun Tongyi Wanxiang)
|
||||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
||||||
|
|
||||||
|
# DashScope with custom size
|
||||||
|
/baoyu-imagine --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
|
||||||
|
|
||||||
|
# MiniMax
|
||||||
|
/baoyu-imagine --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
|
||||||
|
|
||||||
|
# MiniMax with subject reference
|
||||||
|
/baoyu-imagine --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
|
||||||
|
|
||||||
# Replicate
|
# Replicate
|
||||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider replicate
|
/baoyu-imagine --prompt "A cat" --image cat.png --provider replicate
|
||||||
|
|
||||||
# Jimeng (即梦)
|
# Jimeng (即梦)
|
||||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||||
|
|
||||||
# Seedream (豆包)
|
# Seedream (豆包)
|
||||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
|
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||||
|
|
||||||
# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, or Seedream 5.0/4.5/4.0)
|
# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 5.0/4.5/4.0)
|
||||||
/baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
|
/baoyu-imagine --prompt "Make it blue" --image out.png --ref source.png
|
||||||
|
|
||||||
|
# Batch mode
|
||||||
|
/baoyu-imagine --batchfile batch.json --jobs 4 --json
|
||||||
```
|
```
|
||||||
|
|
||||||
**Options**:
|
**Options**:
|
||||||
@@ -706,44 +721,73 @@ AI SDK-based image generation using OpenAI, Azure OpenAI, Google, OpenRouter, Da
|
|||||||
| `--prompt`, `-p` | Prompt text |
|
| `--prompt`, `-p` | Prompt text |
|
||||||
| `--promptfiles` | Read prompt from files (concatenated) |
|
| `--promptfiles` | Read prompt from files (concatenated) |
|
||||||
| `--image` | Output image path (required) |
|
| `--image` | Output image path (required) |
|
||||||
| `--provider` | `google`, `openai`, `openrouter`, `dashscope`, `jimeng`, `seedream` or `replicate` (default: auto-detect; prefers google) |
|
| `--batchfile` | JSON batch file for multi-image generation |
|
||||||
| `--model`, `-m` | Model ID |
|
| `--jobs` | Worker count for batch mode |
|
||||||
|
| `--provider` | `google`, `openai`, `azure`, `openrouter`, `dashscope`, `minimax`, `jimeng`, `seedream`, or `replicate` |
|
||||||
|
| `--model`, `-m` | Model ID or deployment name. Azure uses deployment name; OpenRouter uses full model IDs; MiniMax uses `image-01` / `image-01-live` |
|
||||||
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||||
| `--size` | Size (e.g., `1024x1024`) |
|
| `--size` | Size (e.g., `1024x1024`) |
|
||||||
| `--quality` | `normal` or `2k` (default: `2k`) |
|
| `--quality` | `normal` or `2k` (default: `2k`) |
|
||||||
| `--ref` | Reference images (Google, OpenAI, OpenRouter, Replicate, or Seedream 5.0/4.5/4.0) |
|
| `--imageSize` | `1K`, `2K`, or `4K` for Google/OpenRouter |
|
||||||
|
| `--ref` | Reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 5.0/4.5/4.0) |
|
||||||
|
| `--n` | Number of images per request |
|
||||||
|
| `--json` | JSON output |
|
||||||
|
|
||||||
**Environment Variables** (see [Environment Configuration](#environment-configuration) for setup):
|
**Environment Variables** (see [Environment Configuration](#environment-configuration) for setup):
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `OPENAI_API_KEY` | OpenAI API key | - |
|
| `OPENAI_API_KEY` | OpenAI API key | - |
|
||||||
|
| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key | - |
|
||||||
| `OPENROUTER_API_KEY` | OpenRouter API key | - |
|
| `OPENROUTER_API_KEY` | OpenRouter API key | - |
|
||||||
| `GOOGLE_API_KEY` | Google API key | - |
|
| `GOOGLE_API_KEY` | Google API key | - |
|
||||||
|
| `GEMINI_API_KEY` | Alias for `GOOGLE_API_KEY` | - |
|
||||||
| `DASHSCOPE_API_KEY` | DashScope API key (Aliyun) | - |
|
| `DASHSCOPE_API_KEY` | DashScope API key (Aliyun) | - |
|
||||||
|
| `MINIMAX_API_KEY` | MiniMax API key | - |
|
||||||
| `REPLICATE_API_TOKEN` | Replicate API token | - |
|
| `REPLICATE_API_TOKEN` | Replicate API token | - |
|
||||||
| `JIMENG_ACCESS_KEY_ID` | Jimeng Volcengine access key | - |
|
| `JIMENG_ACCESS_KEY_ID` | Jimeng Volcengine access key | - |
|
||||||
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng Volcengine secret key | - |
|
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng Volcengine secret key | - |
|
||||||
| `ARK_API_KEY` | Seedream Volcengine ARK API key | - |
|
| `ARK_API_KEY` | Seedream Volcengine ARK API key | - |
|
||||||
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
|
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
|
||||||
|
| `AZURE_OPENAI_DEPLOYMENT` | Azure default deployment name | - |
|
||||||
|
| `AZURE_OPENAI_IMAGE_MODEL` | Backward-compatible Azure deployment/model alias | `gpt-image-1.5` |
|
||||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image-preview` |
|
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image-preview` |
|
||||||
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
|
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
|
||||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `qwen-image-2.0-pro` |
|
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `qwen-image-2.0-pro` |
|
||||||
|
| `MINIMAX_IMAGE_MODEL` | MiniMax model | `image-01` |
|
||||||
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-pro` |
|
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-pro` |
|
||||||
| `JIMENG_IMAGE_MODEL` | Jimeng model | `jimeng_t2i_v40` |
|
| `JIMENG_IMAGE_MODEL` | Jimeng model | `jimeng_t2i_v40` |
|
||||||
| `SEEDREAM_IMAGE_MODEL` | Seedream model | `doubao-seedream-5-0-260128` |
|
| `SEEDREAM_IMAGE_MODEL` | Seedream model | `doubao-seedream-5-0-260128` |
|
||||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | - |
|
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | - |
|
||||||
|
| `OPENAI_IMAGE_USE_CHAT` | Use `/chat/completions` for OpenAI image generation | `false` |
|
||||||
|
| `AZURE_OPENAI_BASE_URL` | Azure resource or deployment endpoint | - |
|
||||||
|
| `AZURE_API_VERSION` | Azure image API version | `2025-04-01-preview` |
|
||||||
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint | `https://openrouter.ai/api/v1` |
|
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint | `https://openrouter.ai/api/v1` |
|
||||||
|
| `OPENROUTER_HTTP_REFERER` | Optional app/site URL for OpenRouter attribution | - |
|
||||||
|
| `OPENROUTER_TITLE` | Optional app name for OpenRouter attribution | - |
|
||||||
| `GOOGLE_BASE_URL` | Custom Google endpoint | - |
|
| `GOOGLE_BASE_URL` | Custom Google endpoint | - |
|
||||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - |
|
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - |
|
||||||
|
| `MINIMAX_BASE_URL` | Custom MiniMax endpoint | `https://api.minimax.io` |
|
||||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint | - |
|
| `REPLICATE_BASE_URL` | Custom Replicate endpoint | - |
|
||||||
| `JIMENG_BASE_URL` | Custom Jimeng endpoint | `https://visual.volcengineapi.com` |
|
| `JIMENG_BASE_URL` | Custom Jimeng endpoint | `https://visual.volcengineapi.com` |
|
||||||
| `JIMENG_REGION` | Jimeng region | `cn-north-1` |
|
| `JIMENG_REGION` | Jimeng region | `cn-north-1` |
|
||||||
| `SEEDREAM_BASE_URL` | Custom Seedream endpoint | `https://ark.cn-beijing.volces.com/api/v3` |
|
| `SEEDREAM_BASE_URL` | Custom Seedream endpoint | `https://ark.cn-beijing.volces.com/api/v3` |
|
||||||
|
| `BAOYU_IMAGE_GEN_MAX_WORKERS` | Override batch worker cap | `10` |
|
||||||
|
| `BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY` | Override provider concurrency | provider-specific |
|
||||||
|
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | Override provider request start gap | provider-specific |
|
||||||
|
|
||||||
|
**Provider Notes**:
|
||||||
|
- Azure OpenAI: `--model` means Azure deployment name, not the underlying model family.
|
||||||
|
- DashScope: `qwen-image-2.0-pro` is the recommended default for custom `--size`, `21:9`, and strong Chinese/English text rendering.
|
||||||
|
- MiniMax: `image-01` supports documented custom `width` / `height`; `image-01-live` is lower latency and works best with `--ar`.
|
||||||
|
- MiniMax reference images are sent as `subject_reference`; the current API is specialized toward character / portrait consistency.
|
||||||
|
- Jimeng does not support reference images.
|
||||||
|
- Seedream reference images are supported by Seedream 5.0 / 4.5 / 4.0, not Seedream 3.0.
|
||||||
|
|
||||||
**Provider Auto-Selection**:
|
**Provider Auto-Selection**:
|
||||||
1. If `--provider` specified → use it
|
1. If `--provider` is specified → use it
|
||||||
2. If only one API key available → use that provider
|
2. If `--ref` is provided and no provider is specified → try Google, then OpenAI, Azure, OpenRouter, Replicate, Seedream, and finally MiniMax
|
||||||
3. If multiple available → default to Google
|
3. If only one API key is available → use that provider
|
||||||
|
4. If multiple providers are available → default to Google
|
||||||
|
|
||||||
#### baoyu-danger-gemini-web
|
#### baoyu-danger-gemini-web
|
||||||
|
|
||||||
@@ -1001,7 +1045,7 @@ Custom style descriptions are also accepted, e.g., `--style "poetic and lyrical"
|
|||||||
Some skills require API keys or custom configuration. Environment variables can be set in `.env` files:
|
Some skills require API keys or custom configuration. Environment variables can be set in `.env` files:
|
||||||
|
|
||||||
**Load Priority** (higher priority overrides lower):
|
**Load Priority** (higher priority overrides lower):
|
||||||
1. CLI environment variables (e.g., `OPENAI_API_KEY=xxx /baoyu-image-gen ...`)
|
1. CLI environment variables (e.g., `OPENAI_API_KEY=xxx /baoyu-imagine ...`)
|
||||||
2. `process.env` (system environment)
|
2. `process.env` (system environment)
|
||||||
3. `<cwd>/.baoyu-skills/.env` (project-level)
|
3. `<cwd>/.baoyu-skills/.env` (project-level)
|
||||||
4. `~/.baoyu-skills/.env` (user-level)
|
4. `~/.baoyu-skills/.env` (user-level)
|
||||||
@@ -1018,11 +1062,20 @@ cat > ~/.baoyu-skills/.env << 'EOF'
|
|||||||
OPENAI_API_KEY=sk-xxx
|
OPENAI_API_KEY=sk-xxx
|
||||||
OPENAI_IMAGE_MODEL=gpt-image-1.5
|
OPENAI_IMAGE_MODEL=gpt-image-1.5
|
||||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
# OPENAI_IMAGE_USE_CHAT=false
|
||||||
|
|
||||||
|
# Azure OpenAI
|
||||||
|
AZURE_OPENAI_API_KEY=xxx
|
||||||
|
AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com
|
||||||
|
AZURE_OPENAI_DEPLOYMENT=gpt-image-1.5
|
||||||
|
# AZURE_API_VERSION=2025-04-01-preview
|
||||||
|
|
||||||
# OpenRouter
|
# OpenRouter
|
||||||
OPENROUTER_API_KEY=sk-or-xxx
|
OPENROUTER_API_KEY=sk-or-xxx
|
||||||
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image-preview
|
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image-preview
|
||||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
# OPENROUTER_HTTP_REFERER=https://your-app.example.com
|
||||||
|
# OPENROUTER_TITLE=Your App Name
|
||||||
|
|
||||||
# Google
|
# Google
|
||||||
GOOGLE_API_KEY=xxx
|
GOOGLE_API_KEY=xxx
|
||||||
@@ -1034,6 +1087,11 @@ DASHSCOPE_API_KEY=sk-xxx
|
|||||||
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
|
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
|
||||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||||
|
|
||||||
|
# MiniMax
|
||||||
|
MINIMAX_API_KEY=xxx
|
||||||
|
MINIMAX_IMAGE_MODEL=image-01
|
||||||
|
# MINIMAX_BASE_URL=https://api.minimax.io
|
||||||
|
|
||||||
# Replicate
|
# Replicate
|
||||||
REPLICATE_API_TOKEN=r8_xxx
|
REPLICATE_API_TOKEN=r8_xxx
|
||||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||||
|
|||||||
+79
-21
@@ -32,7 +32,7 @@ npx skills add jimliu/baoyu-skills
|
|||||||
ClawHub 按“单个 skill”安装,不是把整个 marketplace 一次性装进去。发布后,用户可以按需安装:
|
ClawHub 按“单个 skill”安装,不是把整个 marketplace 一次性装进去。发布后,用户可以按需安装:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawhub install baoyu-image-gen
|
clawhub install baoyu-imagine
|
||||||
clawhub install baoyu-markdown-to-html
|
clawhub install baoyu-markdown-to-html
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -661,43 +661,58 @@ accounts:
|
|||||||
|
|
||||||
AI 驱动的生成后端。
|
AI 驱动的生成后端。
|
||||||
|
|
||||||
#### baoyu-image-gen
|
#### baoyu-imagine
|
||||||
|
|
||||||
基于 AI SDK 的图像生成,支持 OpenAI、Azure OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比和质量预设。
|
基于 AI SDK 的图像生成,支持 OpenAI、Azure OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、MiniMax、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比、自定义尺寸、批量生成和质量预设。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 基础生成(自动检测服务商)
|
# 基础生成(自动检测服务商)
|
||||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png
|
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png
|
||||||
|
|
||||||
# 指定宽高比
|
# 指定宽高比
|
||||||
/baoyu-image-gen --prompt "风景图" --image landscape.png --ar 16:9
|
/baoyu-imagine --prompt "风景图" --image landscape.png --ar 16:9
|
||||||
|
|
||||||
# 高质量(2k 分辨率)
|
# 高质量(2k 分辨率)
|
||||||
/baoyu-image-gen --prompt "横幅图" --image banner.png --quality 2k
|
/baoyu-imagine --prompt "横幅图" --image banner.png --quality 2k
|
||||||
|
|
||||||
# 指定服务商
|
# 指定服务商
|
||||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openai
|
/baoyu-imagine --prompt "一只猫" --image cat.png --provider openai
|
||||||
|
|
||||||
# Azure OpenAI(model 为部署名称)
|
# Azure OpenAI(model 为部署名称)
|
||||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider azure --model gpt-image-1.5
|
/baoyu-imagine --prompt "一只猫" --image cat.png --provider azure --model gpt-image-1.5
|
||||||
|
|
||||||
# OpenRouter
|
# OpenRouter
|
||||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider openrouter
|
/baoyu-imagine --prompt "一只猫" --image cat.png --provider openrouter
|
||||||
|
|
||||||
|
# OpenRouter + 参考图
|
||||||
|
/baoyu-imagine --prompt "把它变成蓝色" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
|
||||||
|
|
||||||
# DashScope(阿里通义万相)
|
# DashScope(阿里通义万相)
|
||||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider dashscope
|
||||||
|
|
||||||
|
# DashScope 自定义尺寸
|
||||||
|
/baoyu-imagine --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image banner.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
|
||||||
|
|
||||||
|
# MiniMax
|
||||||
|
/baoyu-imagine --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
|
||||||
|
|
||||||
|
# MiniMax + 角色参考图
|
||||||
|
/baoyu-imagine --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
|
||||||
|
|
||||||
# Replicate
|
# Replicate
|
||||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider replicate
|
/baoyu-imagine --prompt "一只猫" --image cat.png --provider replicate
|
||||||
|
|
||||||
# 即梦(Jimeng)
|
# 即梦(Jimeng)
|
||||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||||
|
|
||||||
# 豆包(Seedream)
|
# 豆包(Seedream)
|
||||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
|
/baoyu-imagine --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||||
|
|
||||||
# 带参考图(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate 或 Seedream 5.0/4.5/4.0)
|
# 带参考图(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate、MiniMax 或 Seedream 5.0/4.5/4.0)
|
||||||
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
|
/baoyu-imagine --prompt "把它变成蓝色" --image out.png --ref source.png
|
||||||
|
|
||||||
|
# 批量模式
|
||||||
|
/baoyu-imagine --batchfile batch.json --jobs 4 --json
|
||||||
```
|
```
|
||||||
|
|
||||||
**选项**:
|
**选项**:
|
||||||
@@ -706,44 +721,73 @@ AI 驱动的生成后端。
|
|||||||
| `--prompt`, `-p` | 提示词文本 |
|
| `--prompt`, `-p` | 提示词文本 |
|
||||||
| `--promptfiles` | 从文件读取提示词(多文件拼接) |
|
| `--promptfiles` | 从文件读取提示词(多文件拼接) |
|
||||||
| `--image` | 输出图片路径(必需) |
|
| `--image` | 输出图片路径(必需) |
|
||||||
| `--provider` | `google`、`openai`、`openrouter`、`dashscope`、`jimeng`、`seedream` 或 `replicate`(默认:自动检测,优先 google) |
|
| `--batchfile` | 多图批量生成的 JSON 文件 |
|
||||||
| `--model`, `-m` | 模型 ID |
|
| `--jobs` | 批量模式的并发 worker 数 |
|
||||||
|
| `--provider` | `google`、`openai`、`azure`、`openrouter`、`dashscope`、`minimax`、`jimeng`、`seedream` 或 `replicate` |
|
||||||
|
| `--model`, `-m` | 模型 ID 或部署名。Azure 使用部署名;OpenRouter 使用完整模型 ID;MiniMax 使用 `image-01` / `image-01-live` |
|
||||||
| `--ar` | 宽高比(如 `16:9`、`1:1`、`4:3`) |
|
| `--ar` | 宽高比(如 `16:9`、`1:1`、`4:3`) |
|
||||||
| `--size` | 尺寸(如 `1024x1024`) |
|
| `--size` | 尺寸(如 `1024x1024`) |
|
||||||
| `--quality` | `normal` 或 `2k`(默认:`2k`) |
|
| `--quality` | `normal` 或 `2k`(默认:`2k`) |
|
||||||
| `--ref` | 参考图片(Google、OpenAI、OpenRouter、Replicate 或 Seedream 5.0/4.5/4.0) |
|
| `--imageSize` | Google/OpenRouter 使用的 `1K`、`2K`、`4K` |
|
||||||
|
| `--ref` | 参考图片(Google、OpenAI、Azure OpenAI、OpenRouter、Replicate、MiniMax 或 Seedream 5.0/4.5/4.0) |
|
||||||
|
| `--n` | 单次请求生成图片数量 |
|
||||||
|
| `--json` | 输出 JSON 结果 |
|
||||||
|
|
||||||
**环境变量**(配置方法见[环境配置](#环境配置)):
|
**环境变量**(配置方法见[环境配置](#环境配置)):
|
||||||
| 变量 | 说明 | 默认值 |
|
| 变量 | 说明 | 默认值 |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| `OPENAI_API_KEY` | OpenAI API 密钥 | - |
|
| `OPENAI_API_KEY` | OpenAI API 密钥 | - |
|
||||||
|
| `AZURE_OPENAI_API_KEY` | Azure OpenAI API 密钥 | - |
|
||||||
| `OPENROUTER_API_KEY` | OpenRouter API 密钥 | - |
|
| `OPENROUTER_API_KEY` | OpenRouter API 密钥 | - |
|
||||||
| `GOOGLE_API_KEY` | Google API 密钥 | - |
|
| `GOOGLE_API_KEY` | Google API 密钥 | - |
|
||||||
|
| `GEMINI_API_KEY` | `GOOGLE_API_KEY` 的别名 | - |
|
||||||
| `DASHSCOPE_API_KEY` | DashScope API 密钥(阿里云) | - |
|
| `DASHSCOPE_API_KEY` | DashScope API 密钥(阿里云) | - |
|
||||||
|
| `MINIMAX_API_KEY` | MiniMax API 密钥 | - |
|
||||||
| `REPLICATE_API_TOKEN` | Replicate API Token | - |
|
| `REPLICATE_API_TOKEN` | Replicate API Token | - |
|
||||||
| `JIMENG_ACCESS_KEY_ID` | 即梦火山引擎 Access Key | - |
|
| `JIMENG_ACCESS_KEY_ID` | 即梦火山引擎 Access Key | - |
|
||||||
| `JIMENG_SECRET_ACCESS_KEY` | 即梦火山引擎 Secret Key | - |
|
| `JIMENG_SECRET_ACCESS_KEY` | 即梦火山引擎 Secret Key | - |
|
||||||
| `ARK_API_KEY` | 豆包火山引擎 ARK API 密钥 | - |
|
| `ARK_API_KEY` | 豆包火山引擎 ARK API 密钥 | - |
|
||||||
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` |
|
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` |
|
||||||
|
| `AZURE_OPENAI_DEPLOYMENT` | Azure 默认部署名 | - |
|
||||||
|
| `AZURE_OPENAI_IMAGE_MODEL` | 兼容旧配置的 Azure 部署/模型别名 | `gpt-image-1.5` |
|
||||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image-preview` |
|
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image-preview` |
|
||||||
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
|
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
|
||||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `qwen-image-2.0-pro` |
|
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `qwen-image-2.0-pro` |
|
||||||
|
| `MINIMAX_IMAGE_MODEL` | MiniMax 模型 | `image-01` |
|
||||||
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-pro` |
|
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-pro` |
|
||||||
| `JIMENG_IMAGE_MODEL` | 即梦模型 | `jimeng_t2i_v40` |
|
| `JIMENG_IMAGE_MODEL` | 即梦模型 | `jimeng_t2i_v40` |
|
||||||
| `SEEDREAM_IMAGE_MODEL` | 豆包模型 | `doubao-seedream-5-0-260128` |
|
| `SEEDREAM_IMAGE_MODEL` | 豆包模型 | `doubao-seedream-5-0-260128` |
|
||||||
| `OPENAI_BASE_URL` | 自定义 OpenAI 端点 | - |
|
| `OPENAI_BASE_URL` | 自定义 OpenAI 端点 | - |
|
||||||
|
| `OPENAI_IMAGE_USE_CHAT` | OpenAI 改走 `/chat/completions` | `false` |
|
||||||
|
| `AZURE_OPENAI_BASE_URL` | Azure 资源或部署端点 | - |
|
||||||
|
| `AZURE_API_VERSION` | Azure 图像 API 版本 | `2025-04-01-preview` |
|
||||||
| `OPENROUTER_BASE_URL` | 自定义 OpenRouter 端点 | `https://openrouter.ai/api/v1` |
|
| `OPENROUTER_BASE_URL` | 自定义 OpenRouter 端点 | `https://openrouter.ai/api/v1` |
|
||||||
|
| `OPENROUTER_HTTP_REFERER` | OpenRouter 归因用站点 URL | - |
|
||||||
|
| `OPENROUTER_TITLE` | OpenRouter 归因用应用名 | - |
|
||||||
| `GOOGLE_BASE_URL` | 自定义 Google 端点 | - |
|
| `GOOGLE_BASE_URL` | 自定义 Google 端点 | - |
|
||||||
| `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - |
|
| `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - |
|
||||||
|
| `MINIMAX_BASE_URL` | 自定义 MiniMax 端点 | `https://api.minimax.io` |
|
||||||
| `REPLICATE_BASE_URL` | 自定义 Replicate 端点 | - |
|
| `REPLICATE_BASE_URL` | 自定义 Replicate 端点 | - |
|
||||||
| `JIMENG_BASE_URL` | 自定义即梦端点 | `https://visual.volcengineapi.com` |
|
| `JIMENG_BASE_URL` | 自定义即梦端点 | `https://visual.volcengineapi.com` |
|
||||||
| `JIMENG_REGION` | 即梦区域 | `cn-north-1` |
|
| `JIMENG_REGION` | 即梦区域 | `cn-north-1` |
|
||||||
| `SEEDREAM_BASE_URL` | 自定义豆包端点 | `https://ark.cn-beijing.volces.com/api/v3` |
|
| `SEEDREAM_BASE_URL` | 自定义豆包端点 | `https://ark.cn-beijing.volces.com/api/v3` |
|
||||||
|
| `BAOYU_IMAGE_GEN_MAX_WORKERS` | 批量模式最大 worker 数 | `10` |
|
||||||
|
| `BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY` | 覆盖 provider 并发数 | provider 默认值 |
|
||||||
|
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | 覆盖 provider 请求启动间隔 | provider 默认值 |
|
||||||
|
|
||||||
|
**Provider 说明**:
|
||||||
|
- Azure OpenAI:`--model` 表示 Azure deployment name,不是底层模型家族名。
|
||||||
|
- DashScope:`qwen-image-2.0-pro` 是自定义 `--size`、`21:9` 和中英文排版的推荐默认模型。
|
||||||
|
- MiniMax:`image-01` 支持官方文档里的自定义 `width` / `height`;`image-01-live` 更偏低延迟,适合配合 `--ar` 使用。
|
||||||
|
- MiniMax 参考图会走 `subject_reference`,当前能力更偏角色 / 人像一致性。
|
||||||
|
- 即梦不支持参考图。
|
||||||
|
- 豆包参考图能力仅适用于 Seedream 5.0 / 4.5 / 4.0,不适用于 Seedream 3.0。
|
||||||
|
|
||||||
**服务商自动选择**:
|
**服务商自动选择**:
|
||||||
1. 如果指定了 `--provider` → 使用指定的
|
1. 如果指定了 `--provider` → 使用指定的
|
||||||
2. 如果只有一个 API 密钥 → 使用对应服务商
|
2. 如果传了 `--ref` 且未指定 provider → 依次尝试 Google、OpenAI、Azure、OpenRouter、Replicate、Seedream,最后是 MiniMax
|
||||||
3. 如果多个可用 → 默认使用 Google
|
3. 如果只有一个 API 密钥 → 使用对应服务商
|
||||||
|
4. 如果多个可用 → 默认使用 Google
|
||||||
|
|
||||||
#### baoyu-danger-gemini-web
|
#### baoyu-danger-gemini-web
|
||||||
|
|
||||||
@@ -1001,7 +1045,7 @@ AI 驱动的生成后端。
|
|||||||
部分技能需要 API 密钥或自定义配置。环境变量可以在 `.env` 文件中设置:
|
部分技能需要 API 密钥或自定义配置。环境变量可以在 `.env` 文件中设置:
|
||||||
|
|
||||||
**加载优先级**(高优先级覆盖低优先级):
|
**加载优先级**(高优先级覆盖低优先级):
|
||||||
1. 命令行环境变量(如 `OPENAI_API_KEY=xxx /baoyu-image-gen ...`)
|
1. 命令行环境变量(如 `OPENAI_API_KEY=xxx /baoyu-imagine ...`)
|
||||||
2. `process.env`(系统环境变量)
|
2. `process.env`(系统环境变量)
|
||||||
3. `<cwd>/.baoyu-skills/.env`(项目级)
|
3. `<cwd>/.baoyu-skills/.env`(项目级)
|
||||||
4. `~/.baoyu-skills/.env`(用户级)
|
4. `~/.baoyu-skills/.env`(用户级)
|
||||||
@@ -1018,11 +1062,20 @@ cat > ~/.baoyu-skills/.env << 'EOF'
|
|||||||
OPENAI_API_KEY=sk-xxx
|
OPENAI_API_KEY=sk-xxx
|
||||||
OPENAI_IMAGE_MODEL=gpt-image-1.5
|
OPENAI_IMAGE_MODEL=gpt-image-1.5
|
||||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
# OPENAI_IMAGE_USE_CHAT=false
|
||||||
|
|
||||||
|
# Azure OpenAI
|
||||||
|
AZURE_OPENAI_API_KEY=xxx
|
||||||
|
AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com
|
||||||
|
AZURE_OPENAI_DEPLOYMENT=gpt-image-1.5
|
||||||
|
# AZURE_API_VERSION=2025-04-01-preview
|
||||||
|
|
||||||
# OpenRouter
|
# OpenRouter
|
||||||
OPENROUTER_API_KEY=sk-or-xxx
|
OPENROUTER_API_KEY=sk-or-xxx
|
||||||
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image-preview
|
OPENROUTER_IMAGE_MODEL=google/gemini-3.1-flash-image-preview
|
||||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
# OPENROUTER_HTTP_REFERER=https://your-app.example.com
|
||||||
|
# OPENROUTER_TITLE=你的应用名
|
||||||
|
|
||||||
# Google
|
# Google
|
||||||
GOOGLE_API_KEY=xxx
|
GOOGLE_API_KEY=xxx
|
||||||
@@ -1034,6 +1087,11 @@ DASHSCOPE_API_KEY=sk-xxx
|
|||||||
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
|
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
|
||||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||||
|
|
||||||
|
# MiniMax
|
||||||
|
MINIMAX_API_KEY=xxx
|
||||||
|
MINIMAX_IMAGE_MODEL=image-01
|
||||||
|
# MINIMAX_BASE_URL=https://api.minimax.io
|
||||||
|
|
||||||
# Replicate
|
# Replicate
|
||||||
REPLICATE_API_TOKEN=r8_xxx
|
REPLICATE_API_TOKEN=r8_xxx
|
||||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Skills that require image generation MUST delegate to available image generation
|
|||||||
|
|
||||||
## Skill Selection
|
## Skill Selection
|
||||||
|
|
||||||
**Default**: `skills/baoyu-image-gen/SKILL.md` (unless user specifies otherwise).
|
**Default**: `skills/baoyu-imagine/SKILL.md` (unless user specifies otherwise).
|
||||||
|
|
||||||
1. Read skill's SKILL.md for parameters and capabilities
|
1. Read skill's SKILL.md for parameters and capabilities
|
||||||
2. If user requests different skill, check `skills/` for alternatives
|
2. If user requests different skill, check `skills/` for alternatives
|
||||||
@@ -16,7 +16,7 @@ Skills that require image generation MUST delegate to available image generation
|
|||||||
### Step N: Generate Images
|
### Step N: Generate Images
|
||||||
|
|
||||||
**Skill Selection**:
|
**Skill Selection**:
|
||||||
1. Check available skills (`baoyu-image-gen` default, or `baoyu-danger-gemini-web`)
|
1. Check available skills (`baoyu-imagine` default, or `baoyu-danger-gemini-web`)
|
||||||
2. Read selected skill's SKILL.md for parameters
|
2. Read selected skill's SKILL.md for parameters
|
||||||
3. If multiple skills available, ask user to choose
|
3. If multiple skills available, ask user to choose
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ Skills that require image generation MUST delegate to available image generation
|
|||||||
4. On failure, auto-retry once before reporting error
|
4. On failure, auto-retry once before reporting error
|
||||||
```
|
```
|
||||||
|
|
||||||
**Batch Parallel** (`baoyu-image-gen` only): concurrent workers with per-provider throttling via `batch.max_workers` in EXTEND.md.
|
**Batch Parallel** (`baoyu-imagine` only): concurrent workers with per-provider throttling via `batch.max_workers` in EXTEND.md.
|
||||||
|
|
||||||
## Output Path Convention
|
## Output Path Convention
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ Full template: [references/workflow.md](references/workflow.md#step-4-generate-o
|
|||||||
|
|
||||||
⛔ **BLOCKING: Prompt files MUST be saved before ANY image generation.**
|
⛔ **BLOCKING: Prompt files MUST be saved before ANY image generation.**
|
||||||
|
|
||||||
**Execution strategy**: When multiple illustrations have saved prompt files and the task is now plain generation, prefer `baoyu-image-gen` batch mode (`build-batch.ts` → `--batchfile`) over spawning subagents. Use subagents only when each image still needs separate prompt iteration or creative exploration.
|
**Execution strategy**: When multiple illustrations have saved prompt files and the task is now plain generation, prefer `baoyu-imagine` batch mode (`build-batch.ts` → `--batchfile`) over spawning subagents. Use subagents only when each image still needs separate prompt iteration or creative exploration.
|
||||||
|
|
||||||
1. For each illustration, create a prompt file per [references/prompt-construction.md](references/prompt-construction.md)
|
1. For each illustration, create a prompt file per [references/prompt-construction.md](references/prompt-construction.md)
|
||||||
2. Save to `prompts/NN-{type}-{slug}.md` with YAML frontmatter
|
2. Save to `prompts/NN-{type}-{slug}.md` with YAML frontmatter
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ Prompt Files:
|
|||||||
**DO NOT** pass ad-hoc inline text to `--prompt` without first saving prompt files. The generation command should either use `--promptfiles prompts/NN-{type}-{slug}.md` or read the saved file content for `--prompt`.
|
**DO NOT** pass ad-hoc inline text to `--prompt` without first saving prompt files. The generation command should either use `--promptfiles prompts/NN-{type}-{slug}.md` or read the saved file content for `--prompt`.
|
||||||
|
|
||||||
**Execution choice**:
|
**Execution choice**:
|
||||||
- If multiple illustrations already have saved prompt files and the task is now plain generation, prefer `baoyu-image-gen` batch mode (`build-batch.ts` -> `main.ts --batchfile`)
|
- If multiple illustrations already have saved prompt files and the task is now plain generation, prefer `baoyu-imagine` batch mode (`build-batch.ts` -> `main.ts --batchfile`)
|
||||||
- Use subagents only when each illustration still needs separate prompt rewriting, style exploration, or other per-image reasoning before generation
|
- Use subagents only when each illustration still needs separate prompt rewriting, style exploration, or other per-image reasoning before generation
|
||||||
|
|
||||||
**CRITICAL - References in Frontmatter**:
|
**CRITICAL - References in Frontmatter**:
|
||||||
@@ -352,7 +352,7 @@ Check available skills. If multiple, ask user.
|
|||||||
|
|
||||||
| Skill Supports `--ref` | Action |
|
| Skill Supports `--ref` | Action |
|
||||||
|------------------------|--------|
|
|------------------------|--------|
|
||||||
| Yes (e.g., baoyu-image-gen with Google) | Pass reference images via `--ref` |
|
| Yes (e.g., baoyu-imagine with Google) | Pass reference images via `--ref` |
|
||||||
| No | Convert to text description, append to prompt |
|
| No | Convert to text description, append to prompt |
|
||||||
|
|
||||||
**Verification**: Before generating, confirm reference processing:
|
**Verification**: Before generating, confirm reference processing:
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ Options:
|
|||||||
--prompts <path> Path to prompts directory
|
--prompts <path> Path to prompts directory
|
||||||
--output <path> Path to output batch.json
|
--output <path> Path to output batch.json
|
||||||
--images-dir <path> Directory for generated images
|
--images-dir <path> Directory for generated images
|
||||||
--provider <name> Provider for baoyu-image-gen batch tasks (default: replicate)
|
--provider <name> Provider for baoyu-imagine batch tasks (default: replicate)
|
||||||
--model <id> Model for baoyu-image-gen batch tasks (default: google/nano-banana-pro)
|
--model <id> Model for baoyu-imagine batch tasks (default: google/nano-banana-pro)
|
||||||
--ar <ratio> Aspect ratio for all tasks (default: 16:9)
|
--ar <ratio> Aspect ratio for all tasks (default: 16:9)
|
||||||
--quality <level> Quality for all tasks (default: 2k)
|
--quality <level> Quality for all tasks (default: 2k)
|
||||||
--jobs <count> Recommended worker count metadata (optional)
|
--jobs <count> Recommended worker count metadata (optional)
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ Analyze → [Check Existing?] → [Confirm: Style + Reviews] → Storyboard →
|
|||||||
|
|
||||||
**7.1 Generate character sheet first**:
|
**7.1 Generate character sheet first**:
|
||||||
- **Backup rule**: If `characters/characters.png` exists, rename to `characters/characters-backup-YYYYMMDD-HHMMSS.png`
|
- **Backup rule**: If `characters/characters.png` exists, rename to `characters/characters-backup-YYYYMMDD-HHMMSS.png`
|
||||||
- Invoke an installed image generation skill such as `baoyu-image-gen`
|
- Invoke an installed image generation skill such as `baoyu-imagine`
|
||||||
- Read that skill's `SKILL.md` and follow its documented interface rather than calling its scripts directly
|
- Read that skill's `SKILL.md` and follow its documented interface rather than calling its scripts directly
|
||||||
- Use `characters/characters.md` as the prompt-file input
|
- Use `characters/characters.md` as the prompt-file input
|
||||||
- Save output to `characters/characters.png`
|
- Save output to `characters/characters.png`
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ With confirmed prompts from Step 5/6:
|
|||||||
| Supports `--ref` | **Strategy A** | Pass `characters/characters.png` with EVERY page |
|
| Supports `--ref` | **Strategy A** | Pass `characters/characters.png` with EVERY page |
|
||||||
| Does NOT support `--ref` | **Strategy B** | Prepend character descriptions to EVERY prompt |
|
| Does NOT support `--ref` | **Strategy B** | Prepend character descriptions to EVERY prompt |
|
||||||
|
|
||||||
**Strategy A: Using `--ref` parameter** (e.g., baoyu-image-gen)
|
**Strategy A: Using `--ref` parameter** (e.g., baoyu-imagine)
|
||||||
|
|
||||||
- Read the chosen image generation skill's `SKILL.md`
|
- Read the chosen image generation skill's `SKILL.md`
|
||||||
- Invoke that installed skill via its documented interface, not by calling its scripts directly
|
- Invoke that installed skill via its documented interface, not by calling its scripts directly
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-image-gen
|
name: baoyu-imagine
|
||||||
description: AI image generation with OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
description: AI image generation with OpenAI, Azure OpenAI, Google, OpenRouter, DashScope, MiniMax, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
||||||
version: 1.56.3
|
version: 1.56.4
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-gen
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-imagine
|
||||||
requires:
|
requires:
|
||||||
anyBins:
|
anyBins:
|
||||||
- bun
|
- bun
|
||||||
@@ -13,7 +13,7 @@ metadata:
|
|||||||
|
|
||||||
# Image Generation (AI SDK)
|
# Image Generation (AI SDK)
|
||||||
|
|
||||||
Official API-based image generation. Supports OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), Jimeng (即梦), Seedream (豆包) and Replicate providers.
|
Official API-based image generation. Supports OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), MiniMax, Jimeng (即梦), Seedream (豆包) and Replicate providers.
|
||||||
|
|
||||||
## Script Directory
|
## Script Directory
|
||||||
|
|
||||||
@@ -30,17 +30,17 @@ Check EXTEND.md existence (priority: project → user):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS, Linux, WSL, Git Bash
|
# macOS, Linux, WSL, Git Bash
|
||||||
test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project"
|
test -f .baoyu-skills/baoyu-imagine/EXTEND.md && echo "project"
|
||||||
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "xdg"
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-imagine/EXTEND.md" && echo "xdg"
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
test -f "$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md" && echo "user"
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# PowerShell (Windows)
|
# PowerShell (Windows)
|
||||||
if (Test-Path .baoyu-skills/baoyu-image-gen/EXTEND.md) { "project" }
|
if (Test-Path .baoyu-skills/baoyu-imagine/EXTEND.md) { "project" }
|
||||||
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
if (Test-Path "$xdg/baoyu-skills/baoyu-image-gen/EXTEND.md") { "xdg" }
|
if (Test-Path "$xdg/baoyu-skills/baoyu-imagine/EXTEND.md") { "xdg" }
|
||||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md") { "user" }
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md") { "user" }
|
||||||
```
|
```
|
||||||
|
|
||||||
| Result | Action |
|
| Result | Action |
|
||||||
@@ -52,8 +52,8 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md") { "user" }
|
|||||||
|
|
||||||
| Path | Location |
|
| Path | Location |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory |
|
| `.baoyu-skills/baoyu-imagine/EXTEND.md` | Project directory |
|
||||||
| `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | User home |
|
| `$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md` | User home |
|
||||||
|
|
||||||
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models | Batch worker cap | Provider-specific batch limits
|
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models | Batch worker cap | Provider-specific batch limits
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k
|
|||||||
# From prompt files
|
# From prompt files
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||||
|
|
||||||
# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, or Seedream 4.0/4.5/5.0)
|
# With reference images (Google, OpenAI, Azure OpenAI, OpenRouter, Replicate, MiniMax, or Seedream 4.0/4.5/5.0)
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
|
||||||
|
|
||||||
# With reference images (explicit provider/model)
|
# With reference images (explicit provider/model)
|
||||||
@@ -101,6 +101,15 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9
|
|||||||
# DashScope legacy Qwen fixed-size model
|
# DashScope legacy Qwen fixed-size model
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张电影感海报" --image out.png --provider dashscope --model qwen-image-max --size 1664x928
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张电影感海报" --image out.png --provider dashscope --model qwen-image-max --size 1664x928
|
||||||
|
|
||||||
|
# MiniMax
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A fashion editorial portrait by a bright studio window" --image out.jpg --provider minimax
|
||||||
|
|
||||||
|
# MiniMax with subject reference (best for character/portrait consistency)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A girl stands by the library window, cinematic lighting" --image out.jpg --provider minimax --model image-01 --ref portrait.png --ar 16:9
|
||||||
|
|
||||||
|
# MiniMax with custom size (documented for image-01)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cinematic poster" --image out.jpg --provider minimax --model image-01 --size 1536x1024
|
||||||
|
|
||||||
# Replicate (google/nano-banana-pro)
|
# Replicate (google/nano-banana-pro)
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||||
|
|
||||||
@@ -150,13 +159,13 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
|||||||
| `--image <path>` | Output image path (required in single-image mode) |
|
| `--image <path>` | Output image path (required in single-image mode) |
|
||||||
| `--batchfile <path>` | JSON batch file for multi-image generation |
|
| `--batchfile <path>` | JSON batch file for multi-image generation |
|
||||||
| `--jobs <count>` | Worker count for batch mode (default: auto, max from config, built-in default 10) |
|
| `--jobs <count>` | Worker count for batch mode (default: auto, max from config, built-in default 10) |
|
||||||
| `--provider google\|openai\|azure\|openrouter\|dashscope\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
|
| `--provider google\|openai\|azure\|openrouter\|dashscope\|minimax\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
|
||||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; Azure: deployment name such as `gpt-image-1.5` or `image-prod`; OpenRouter: `google/gemini-3.1-flash-image-preview`; DashScope: `qwen-image-2.0-pro`) |
|
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; Azure: deployment name such as `gpt-image-1.5` or `image-prod`; OpenRouter: `google/gemini-3.1-flash-image-preview`; DashScope: `qwen-image-2.0-pro`; MiniMax: `image-01`) |
|
||||||
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||||
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||||
| `--quality normal\|2k` | Quality preset (default: `2k`) |
|
| `--quality normal\|2k` | Quality preset (default: `2k`) |
|
||||||
| `--imageSize 1K\|2K\|4K` | Image size for Google/OpenRouter (default: from quality) |
|
| `--imageSize 1K\|2K\|4K` | Image size for Google/OpenRouter (default: from quality) |
|
||||||
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate, and Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, or removed SeedEdit 3.0 |
|
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, Azure OpenAI edits (PNG/JPG only), OpenRouter multimodal models, Replicate, MiniMax subject-reference, and Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, or removed SeedEdit 3.0 |
|
||||||
| `--n <count>` | Number of images |
|
| `--n <count>` | Number of images |
|
||||||
| `--json` | JSON output |
|
| `--json` | JSON output |
|
||||||
|
|
||||||
@@ -169,6 +178,7 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
|||||||
| `OPENROUTER_API_KEY` | OpenRouter API key |
|
| `OPENROUTER_API_KEY` | OpenRouter API key |
|
||||||
| `GOOGLE_API_KEY` | Google API key |
|
| `GOOGLE_API_KEY` | Google API key |
|
||||||
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
|
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
|
||||||
|
| `MINIMAX_API_KEY` | MiniMax API key |
|
||||||
| `REPLICATE_API_TOKEN` | Replicate API token |
|
| `REPLICATE_API_TOKEN` | Replicate API token |
|
||||||
| `JIMENG_ACCESS_KEY_ID` | Jimeng (即梦) Volcengine access key |
|
| `JIMENG_ACCESS_KEY_ID` | Jimeng (即梦) Volcengine access key |
|
||||||
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine secret key |
|
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine secret key |
|
||||||
@@ -179,6 +189,7 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
|||||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) |
|
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) |
|
||||||
| `GOOGLE_IMAGE_MODEL` | Google model override |
|
| `GOOGLE_IMAGE_MODEL` | Google model override |
|
||||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: `qwen-image-2.0-pro`) |
|
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: `qwen-image-2.0-pro`) |
|
||||||
|
| `MINIMAX_IMAGE_MODEL` | MiniMax model override (default: `image-01`) |
|
||||||
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
|
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
|
||||||
| `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) |
|
| `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) |
|
||||||
| `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) |
|
| `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) |
|
||||||
@@ -190,6 +201,7 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
|||||||
| `OPENROUTER_TITLE` | Optional app name for OpenRouter attribution |
|
| `OPENROUTER_TITLE` | Optional app name for OpenRouter attribution |
|
||||||
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
||||||
|
| `MINIMAX_BASE_URL` | Custom MiniMax endpoint (default: `https://api.minimax.io`) |
|
||||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
||||||
| `JIMENG_BASE_URL` | Custom Jimeng endpoint (default: `https://visual.volcengineapi.com`) |
|
| `JIMENG_BASE_URL` | Custom Jimeng endpoint (default: `https://visual.volcengineapi.com`) |
|
||||||
| `JIMENG_REGION` | Jimeng region (default: `cn-north-1`) |
|
| `JIMENG_REGION` | Jimeng region (default: `cn-north-1`) |
|
||||||
@@ -240,7 +252,7 @@ When translating CLI args into DashScope behavior:
|
|||||||
- `--size` wins over `--ar`
|
- `--size` wins over `--ar`
|
||||||
- For `qwen-image-2.0*`, prefer explicit `--size`; otherwise infer from `--ar` and use the official recommended resolutions below
|
- For `qwen-image-2.0*`, prefer explicit `--size`; otherwise infer from `--ar` and use the official recommended resolutions below
|
||||||
- For `qwen-image-max/plus/image`, only use the five official fixed sizes; if the requested ratio is not covered, switch to `qwen-image-2.0-pro`
|
- For `qwen-image-max/plus/image`, only use the five official fixed sizes; if the requested ratio is not covered, switch to `qwen-image-2.0-pro`
|
||||||
- `--quality` is a baoyu-image-gen compatibility preset, not a native DashScope API field. Mapping `normal` / `2k` onto the `qwen-image-2.0*` table below is an implementation inference, not an official API guarantee
|
- `--quality` is a baoyu-imagine compatibility preset, not a native DashScope API field. Mapping `normal` / `2k` onto the `qwen-image-2.0*` table below is an implementation inference, not an official API guarantee
|
||||||
|
|
||||||
Recommended `qwen-image-2.0*` sizes for common aspect ratios:
|
Recommended `qwen-image-2.0*` sizes for common aspect ratios:
|
||||||
|
|
||||||
@@ -255,7 +267,7 @@ Recommended `qwen-image-2.0*` sizes for common aspect ratios:
|
|||||||
| `16:9` | `1280*720` | `1920*1080` |
|
| `16:9` | `1280*720` | `1920*1080` |
|
||||||
| `21:9` | `1344*576` | `2048*872` |
|
| `21:9` | `1344*576` | `2048*872` |
|
||||||
|
|
||||||
DashScope official APIs also expose `negative_prompt`, `prompt_extend`, and `watermark`, but `baoyu-image-gen` does not expose them as dedicated CLI flags today.
|
DashScope official APIs also expose `negative_prompt`, `prompt_extend`, and `watermark`, but `baoyu-imagine` does not expose them as dedicated CLI flags today.
|
||||||
|
|
||||||
Official references:
|
Official references:
|
||||||
|
|
||||||
@@ -263,6 +275,34 @@ Official references:
|
|||||||
- [Text-to-image guide](https://help.aliyun.com/zh/model-studio/text-to-image)
|
- [Text-to-image guide](https://help.aliyun.com/zh/model-studio/text-to-image)
|
||||||
- [Qwen-Image Edit API](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api)
|
- [Qwen-Image Edit API](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api)
|
||||||
|
|
||||||
|
### MiniMax Models
|
||||||
|
|
||||||
|
Use `--model image-01` or set `default_model.minimax` / `MINIMAX_IMAGE_MODEL` when the user wants MiniMax image generation.
|
||||||
|
|
||||||
|
Official MiniMax image model options currently documented in the API reference:
|
||||||
|
|
||||||
|
- `image-01` (recommended default)
|
||||||
|
- Supports text-to-image and subject-reference image generation
|
||||||
|
- Supports official `aspect_ratio` values: `1:1`, `16:9`, `4:3`, `3:2`, `2:3`, `3:4`, `9:16`, `21:9`
|
||||||
|
- Supports documented custom `width` / `height` output sizes when using `--size <WxH>`
|
||||||
|
- `width` and `height` must both be between `512` and `2048`, and both must be divisible by `8`
|
||||||
|
- `image-01-live`
|
||||||
|
- Lower-latency variant
|
||||||
|
- Use `--ar` for sizing; MiniMax documents custom `width` / `height` as only effective for `image-01`
|
||||||
|
|
||||||
|
MiniMax subject reference notes:
|
||||||
|
|
||||||
|
- `--ref` files are sent as MiniMax `subject_reference`
|
||||||
|
- MiniMax docs currently describe `subject_reference[].type` as `character`
|
||||||
|
- Official docs say `image_file` supports public URLs or Base64 Data URLs; `baoyu-imagine` sends local refs as Data URLs
|
||||||
|
- Official docs recommend front-facing portrait references in JPG/JPEG/PNG under 10MB
|
||||||
|
|
||||||
|
Official references:
|
||||||
|
|
||||||
|
- [MiniMax Image Generation Guide](https://platform.minimax.io/docs/guides/image-generation)
|
||||||
|
- [MiniMax Text-to-Image API](https://platform.minimax.io/docs/api-reference/image-generation-t2i)
|
||||||
|
- [MiniMax Image-to-Image API](https://platform.minimax.io/docs/api-reference/image-generation-i2i)
|
||||||
|
|
||||||
### OpenRouter Models
|
### OpenRouter Models
|
||||||
|
|
||||||
Use full OpenRouter model IDs, e.g.:
|
Use full OpenRouter model IDs, e.g.:
|
||||||
@@ -297,8 +337,8 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider r
|
|||||||
|
|
||||||
## Provider Selection
|
## Provider Selection
|
||||||
|
|
||||||
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then OpenRouter, then Replicate (Jimeng and Seedream do not support reference images)
|
1. `--ref` provided + no `--provider` → auto-select Google first, then OpenAI, then Azure, then OpenRouter, then Replicate, then Seedream, then MiniMax (MiniMax subject reference is more specialized toward character/portrait consistency)
|
||||||
2. `--provider` specified → use it (if `--ref`, must be `google`, `openai`, `openrouter`, or `replicate`)
|
2. `--provider` specified → use it (if `--ref`, must be `google`, `openai`, `azure`, `openrouter`, `replicate`, `seedream`, or `minimax`)
|
||||||
3. Only one API key available → use that provider
|
3. Only one API key available → use that provider
|
||||||
4. Multiple available → default to Google
|
4. Multiple available → default to Google
|
||||||
|
|
||||||
@@ -319,6 +359,7 @@ Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
|||||||
- OpenAI: maps to closest supported size
|
- OpenAI: maps to closest supported size
|
||||||
- OpenRouter: sends `imageGenerationOptions.aspect_ratio`; if only `--size <WxH>` is given, aspect ratio is inferred automatically
|
- OpenRouter: sends `imageGenerationOptions.aspect_ratio`; if only `--size <WxH>` is given, aspect ratio is inferred automatically
|
||||||
- Replicate: passes `aspect_ratio` to model; when `--ref` is provided without `--ar`, defaults to `match_input_image`
|
- Replicate: passes `aspect_ratio` to model; when `--ref` is provided without `--ar`, defaults to `match_input_image`
|
||||||
|
- MiniMax: sends official `aspect_ratio` values directly; if `--size <WxH>` is given without `--ar`, `width` / `height` are sent for `image-01`
|
||||||
|
|
||||||
## Generation Mode
|
## Generation Mode
|
||||||
|
|
||||||
+41
-5
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: first-time-setup
|
name: first-time-setup
|
||||||
description: First-time setup and default model selection flow for baoyu-image-gen
|
description: First-time setup and default model selection flow for baoyu-imagine
|
||||||
---
|
---
|
||||||
|
|
||||||
# First-Time Setup
|
# First-Time Setup
|
||||||
@@ -53,6 +53,8 @@ options:
|
|||||||
description: "Router for Gemini/FLUX/OpenAI-compatible image models"
|
description: "Router for Gemini/FLUX/OpenAI-compatible image models"
|
||||||
- label: "DashScope"
|
- label: "DashScope"
|
||||||
description: "Alibaba Cloud - Qwen-Image, strong Chinese/English text rendering"
|
description: "Alibaba Cloud - Qwen-Image, strong Chinese/English text rendering"
|
||||||
|
- label: "MiniMax"
|
||||||
|
description: "MiniMax image generation with subject-reference character workflows"
|
||||||
- label: "Replicate"
|
- label: "Replicate"
|
||||||
description: "Community models - nano-banana-pro, flexible model selection"
|
description: "Community models - nano-banana-pro, flexible model selection"
|
||||||
```
|
```
|
||||||
@@ -103,6 +105,20 @@ options:
|
|||||||
description: "Previous GPT Image deployment name"
|
description: "Previous GPT Image deployment name"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Question 2d: Default MiniMax Model
|
||||||
|
|
||||||
|
Only show if user selected MiniMax.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "MiniMax Model"
|
||||||
|
question: "Default MiniMax image generation model?"
|
||||||
|
options:
|
||||||
|
- label: "image-01 (Recommended)"
|
||||||
|
description: "Best default, supports aspect ratios and custom width/height"
|
||||||
|
- label: "image-01-live"
|
||||||
|
description: "Faster variant, use aspect ratio instead of custom size"
|
||||||
|
```
|
||||||
|
|
||||||
### Question 3: Default Quality
|
### Question 3: Default Quality
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -131,8 +147,8 @@ options:
|
|||||||
|
|
||||||
| Choice | Path | Scope |
|
| Choice | Path | Scope |
|
||||||
|--------|------|-------|
|
|--------|------|-------|
|
||||||
| Project | `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Current project |
|
| Project | `.baoyu-skills/baoyu-imagine/EXTEND.md` | Current project |
|
||||||
| User | `$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md` | All projects |
|
| User | `$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md` | All projects |
|
||||||
|
|
||||||
### EXTEND.md Template
|
### EXTEND.md Template
|
||||||
|
|
||||||
@@ -149,6 +165,7 @@ default_model:
|
|||||||
azure: [selected azure deployment or null]
|
azure: [selected azure deployment or null]
|
||||||
openrouter: [selected openrouter model or null]
|
openrouter: [selected openrouter model or null]
|
||||||
dashscope: null
|
dashscope: null
|
||||||
|
minimax: [selected minimax model or null]
|
||||||
replicate: null
|
replicate: null
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
@@ -197,7 +214,7 @@ options:
|
|||||||
|
|
||||||
Notes for Azure setup:
|
Notes for Azure setup:
|
||||||
|
|
||||||
- In `baoyu-image-gen`, Azure `--model` / `default_model.azure` should be the Azure deployment name, not just the underlying model family.
|
- In `baoyu-imagine`, Azure `--model` / `default_model.azure` should be the Azure deployment name, not just the underlying model family.
|
||||||
- If the deployment name is custom, save that exact deployment name in `default_model.azure`.
|
- If the deployment name is custom, save that exact deployment name in `default_model.azure`.
|
||||||
|
|
||||||
### OpenRouter Model Selection
|
### OpenRouter Model Selection
|
||||||
@@ -238,7 +255,7 @@ Notes for DashScope setup:
|
|||||||
|
|
||||||
- Prefer `qwen-image-2.0-pro` when the user needs custom `--size`, uncommon ratios like `21:9`, or strong Chinese/English text rendering.
|
- Prefer `qwen-image-2.0-pro` when the user needs custom `--size`, uncommon ratios like `21:9`, or strong Chinese/English text rendering.
|
||||||
- `qwen-image-max` / `qwen-image-plus` / `qwen-image` only support five fixed sizes: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`.
|
- `qwen-image-max` / `qwen-image-plus` / `qwen-image` only support five fixed sizes: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`.
|
||||||
- In `baoyu-image-gen`, `quality` is a compatibility preset. It is not a native DashScope parameter.
|
- In `baoyu-imagine`, `quality` is a compatibility preset. It is not a native DashScope parameter.
|
||||||
|
|
||||||
### Replicate Model Selection
|
### Replicate Model Selection
|
||||||
|
|
||||||
@@ -252,6 +269,24 @@ options:
|
|||||||
description: "Google's base image model on Replicate"
|
description: "Google's base image model on Replicate"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### MiniMax Model Selection
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "MiniMax Model"
|
||||||
|
question: "Choose a default MiniMax image generation model?"
|
||||||
|
options:
|
||||||
|
- label: "image-01 (Recommended)"
|
||||||
|
description: "Best general-purpose MiniMax image model with custom width/height support"
|
||||||
|
- label: "image-01-live"
|
||||||
|
description: "Lower-latency MiniMax image model using aspect ratios"
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes for MiniMax setup:
|
||||||
|
|
||||||
|
- `image-01` is the safest default. It supports official `aspect_ratio` values and documented custom `width` / `height` output sizes.
|
||||||
|
- `image-01-live` is useful when the user prefers faster generation and can work with aspect-ratio-based sizing.
|
||||||
|
- MiniMax subject reference currently uses `subject_reference[].type = character`; docs recommend front-facing portrait references in JPG/JPEG/PNG under 10MB.
|
||||||
|
|
||||||
### Update EXTEND.md
|
### Update EXTEND.md
|
||||||
|
|
||||||
After user selects a model:
|
After user selects a model:
|
||||||
@@ -267,6 +302,7 @@ default_model:
|
|||||||
azure: [value or null]
|
azure: [value or null]
|
||||||
openrouter: [value or null]
|
openrouter: [value or null]
|
||||||
dashscope: [value or null]
|
dashscope: [value or null]
|
||||||
|
minimax: [value or null]
|
||||||
replicate: [value or null]
|
replicate: [value or null]
|
||||||
```
|
```
|
||||||
|
|
||||||
+11
-2
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: preferences-schema
|
name: preferences-schema
|
||||||
description: EXTEND.md YAML schema for baoyu-image-gen user preferences
|
description: EXTEND.md YAML schema for baoyu-imagine user preferences
|
||||||
---
|
---
|
||||||
|
|
||||||
# Preferences Schema
|
# Preferences Schema
|
||||||
@@ -11,7 +11,7 @@ description: EXTEND.md YAML schema for baoyu-image-gen user preferences
|
|||||||
---
|
---
|
||||||
version: 1
|
version: 1
|
||||||
|
|
||||||
default_provider: null # google|openai|azure|openrouter|dashscope|replicate|null (null = auto-detect)
|
default_provider: null # google|openai|azure|openrouter|dashscope|minimax|replicate|null (null = auto-detect)
|
||||||
|
|
||||||
default_quality: null # normal|2k|null (null = use default: 2k)
|
default_quality: null # normal|2k|null (null = use default: 2k)
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ default_model:
|
|||||||
azure: null # Azure deployment name, e.g., "gpt-image-1.5" or "image-prod"
|
azure: null # Azure deployment name, e.g., "gpt-image-1.5" or "image-prod"
|
||||||
openrouter: null # e.g., "google/gemini-3.1-flash-image-preview"
|
openrouter: null # e.g., "google/gemini-3.1-flash-image-preview"
|
||||||
dashscope: null # e.g., "qwen-image-2.0-pro"
|
dashscope: null # e.g., "qwen-image-2.0-pro"
|
||||||
|
minimax: null # e.g., "image-01"
|
||||||
replicate: null # e.g., "google/nano-banana-pro"
|
replicate: null # e.g., "google/nano-banana-pro"
|
||||||
|
|
||||||
batch:
|
batch:
|
||||||
@@ -48,6 +49,9 @@ batch:
|
|||||||
dashscope:
|
dashscope:
|
||||||
concurrency: 3
|
concurrency: 3
|
||||||
start_interval_ms: 1100
|
start_interval_ms: 1100
|
||||||
|
minimax:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -65,6 +69,7 @@ batch:
|
|||||||
| `default_model.azure` | string\|null | null | Azure default deployment name |
|
| `default_model.azure` | string\|null | null | Azure default deployment name |
|
||||||
| `default_model.openrouter` | string\|null | null | OpenRouter default model |
|
| `default_model.openrouter` | string\|null | null | OpenRouter default model |
|
||||||
| `default_model.dashscope` | string\|null | null | DashScope default model |
|
| `default_model.dashscope` | string\|null | null | DashScope default model |
|
||||||
|
| `default_model.minimax` | string\|null | null | MiniMax default model |
|
||||||
| `default_model.replicate` | string\|null | null | Replicate default model |
|
| `default_model.replicate` | string\|null | null | Replicate default model |
|
||||||
| `batch.max_workers` | int\|null | 10 | Batch worker cap |
|
| `batch.max_workers` | int\|null | 10 | Batch worker cap |
|
||||||
| `batch.provider_limits.<provider>.concurrency` | int\|null | provider default | Max simultaneous requests per provider |
|
| `batch.provider_limits.<provider>.concurrency` | int\|null | provider default | Max simultaneous requests per provider |
|
||||||
@@ -95,6 +100,7 @@ default_model:
|
|||||||
azure: "gpt-image-1.5"
|
azure: "gpt-image-1.5"
|
||||||
openrouter: "google/gemini-3.1-flash-image-preview"
|
openrouter: "google/gemini-3.1-flash-image-preview"
|
||||||
dashscope: "qwen-image-2.0-pro"
|
dashscope: "qwen-image-2.0-pro"
|
||||||
|
minimax: "image-01"
|
||||||
replicate: "google/nano-banana-pro"
|
replicate: "google/nano-banana-pro"
|
||||||
batch:
|
batch:
|
||||||
max_workers: 10
|
max_workers: 10
|
||||||
@@ -108,5 +114,8 @@ batch:
|
|||||||
openrouter:
|
openrouter:
|
||||||
concurrency: 3
|
concurrency: 3
|
||||||
start_interval_ms: 1100
|
start_interval_ms: 1100
|
||||||
|
minimax:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
+43
-2
@@ -69,7 +69,7 @@ async function makeTempDir(prefix: string): Promise<string> {
|
|||||||
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
test("parseArgs parses the main image-gen CLI flags", () => {
|
test("parseArgs parses the main baoyu-imagine CLI flags", () => {
|
||||||
const args = parseArgs([
|
const args = parseArgs([
|
||||||
"--promptfiles",
|
"--promptfiles",
|
||||||
"prompts/system.md",
|
"prompts/system.md",
|
||||||
@@ -124,6 +124,7 @@ default_model:
|
|||||||
google: gemini-3-pro-image-preview
|
google: gemini-3-pro-image-preview
|
||||||
openai: gpt-image-1.5
|
openai: gpt-image-1.5
|
||||||
azure: image-prod
|
azure: image-prod
|
||||||
|
minimax: image-01
|
||||||
batch:
|
batch:
|
||||||
max_workers: 8
|
max_workers: 8
|
||||||
provider_limits:
|
provider_limits:
|
||||||
@@ -132,6 +133,9 @@ batch:
|
|||||||
start_interval_ms: 900
|
start_interval_ms: 900
|
||||||
openai:
|
openai:
|
||||||
concurrency: 4
|
concurrency: 4
|
||||||
|
minimax:
|
||||||
|
concurrency: 2
|
||||||
|
start_interval_ms: 1400
|
||||||
azure:
|
azure:
|
||||||
concurrency: 1
|
concurrency: 1
|
||||||
start_interval_ms: 1500
|
start_interval_ms: 1500
|
||||||
@@ -147,6 +151,7 @@ batch:
|
|||||||
assert.equal(config.default_model?.google, "gemini-3-pro-image-preview");
|
assert.equal(config.default_model?.google, "gemini-3-pro-image-preview");
|
||||||
assert.equal(config.default_model?.openai, "gpt-image-1.5");
|
assert.equal(config.default_model?.openai, "gpt-image-1.5");
|
||||||
assert.equal(config.default_model?.azure, "image-prod");
|
assert.equal(config.default_model?.azure, "image-prod");
|
||||||
|
assert.equal(config.default_model?.minimax, "image-01");
|
||||||
assert.equal(config.batch?.max_workers, 8);
|
assert.equal(config.batch?.max_workers, 8);
|
||||||
assert.deepEqual(config.batch?.provider_limits?.google, {
|
assert.deepEqual(config.batch?.provider_limits?.google, {
|
||||||
concurrency: 2,
|
concurrency: 2,
|
||||||
@@ -155,6 +160,10 @@ batch:
|
|||||||
assert.deepEqual(config.batch?.provider_limits?.openai, {
|
assert.deepEqual(config.batch?.provider_limits?.openai, {
|
||||||
concurrency: 4,
|
concurrency: 4,
|
||||||
});
|
});
|
||||||
|
assert.deepEqual(config.batch?.provider_limits?.minimax, {
|
||||||
|
concurrency: 2,
|
||||||
|
start_interval_ms: 1400,
|
||||||
|
});
|
||||||
assert.deepEqual(config.batch?.provider_limits?.azure, {
|
assert.deepEqual(config.batch?.provider_limits?.azure, {
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
start_interval_ms: 1500,
|
start_interval_ms: 1500,
|
||||||
@@ -200,6 +209,7 @@ test("detectProvider rejects non-ref-capable providers and prefers Google first
|
|||||||
OPENAI_API_KEY: "openai-key",
|
OPENAI_API_KEY: "openai-key",
|
||||||
OPENROUTER_API_KEY: null,
|
OPENROUTER_API_KEY: null,
|
||||||
DASHSCOPE_API_KEY: null,
|
DASHSCOPE_API_KEY: null,
|
||||||
|
MINIMAX_API_KEY: null,
|
||||||
REPLICATE_API_TOKEN: null,
|
REPLICATE_API_TOKEN: null,
|
||||||
JIMENG_ACCESS_KEY_ID: null,
|
JIMENG_ACCESS_KEY_ID: null,
|
||||||
JIMENG_SECRET_ACCESS_KEY: null,
|
JIMENG_SECRET_ACCESS_KEY: null,
|
||||||
@@ -216,6 +226,7 @@ test("detectProvider selects an available ref-capable provider for reference-ima
|
|||||||
AZURE_OPENAI_BASE_URL: null,
|
AZURE_OPENAI_BASE_URL: null,
|
||||||
OPENROUTER_API_KEY: null,
|
OPENROUTER_API_KEY: null,
|
||||||
DASHSCOPE_API_KEY: null,
|
DASHSCOPE_API_KEY: null,
|
||||||
|
MINIMAX_API_KEY: null,
|
||||||
REPLICATE_API_TOKEN: null,
|
REPLICATE_API_TOKEN: null,
|
||||||
JIMENG_ACCESS_KEY_ID: null,
|
JIMENG_ACCESS_KEY_ID: null,
|
||||||
JIMENG_SECRET_ACCESS_KEY: null,
|
JIMENG_SECRET_ACCESS_KEY: null,
|
||||||
@@ -235,6 +246,7 @@ test("detectProvider selects Azure when only Azure credentials are configured",
|
|||||||
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com",
|
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com",
|
||||||
OPENROUTER_API_KEY: null,
|
OPENROUTER_API_KEY: null,
|
||||||
DASHSCOPE_API_KEY: null,
|
DASHSCOPE_API_KEY: null,
|
||||||
|
MINIMAX_API_KEY: null,
|
||||||
REPLICATE_API_TOKEN: null,
|
REPLICATE_API_TOKEN: null,
|
||||||
JIMENG_ACCESS_KEY_ID: null,
|
JIMENG_ACCESS_KEY_ID: null,
|
||||||
JIMENG_SECRET_ACCESS_KEY: null,
|
JIMENG_SECRET_ACCESS_KEY: null,
|
||||||
@@ -254,6 +266,7 @@ test("detectProvider infers Seedream from model id and allows Seedream reference
|
|||||||
OPENAI_API_KEY: null,
|
OPENAI_API_KEY: null,
|
||||||
OPENROUTER_API_KEY: null,
|
OPENROUTER_API_KEY: null,
|
||||||
DASHSCOPE_API_KEY: null,
|
DASHSCOPE_API_KEY: null,
|
||||||
|
MINIMAX_API_KEY: null,
|
||||||
REPLICATE_API_TOKEN: null,
|
REPLICATE_API_TOKEN: null,
|
||||||
JIMENG_ACCESS_KEY_ID: null,
|
JIMENG_ACCESS_KEY_ID: null,
|
||||||
JIMENG_SECRET_ACCESS_KEY: null,
|
JIMENG_SECRET_ACCESS_KEY: null,
|
||||||
@@ -281,6 +294,26 @@ test("detectProvider infers Seedream from model id and allows Seedream reference
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("detectProvider selects MiniMax when only MiniMax credentials are configured or the model id matches", (t) => {
|
||||||
|
useEnv(t, {
|
||||||
|
GOOGLE_API_KEY: null,
|
||||||
|
OPENAI_API_KEY: null,
|
||||||
|
AZURE_OPENAI_API_KEY: null,
|
||||||
|
AZURE_OPENAI_BASE_URL: null,
|
||||||
|
OPENROUTER_API_KEY: null,
|
||||||
|
DASHSCOPE_API_KEY: null,
|
||||||
|
MINIMAX_API_KEY: "minimax-key",
|
||||||
|
REPLICATE_API_TOKEN: null,
|
||||||
|
JIMENG_ACCESS_KEY_ID: null,
|
||||||
|
JIMENG_SECRET_ACCESS_KEY: null,
|
||||||
|
ARK_API_KEY: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(detectProvider(makeArgs()), "minimax");
|
||||||
|
assert.equal(detectProvider(makeArgs({ referenceImages: ["ref.png"] })), "minimax");
|
||||||
|
assert.equal(detectProvider(makeArgs({ model: "image-01-live" })), "minimax");
|
||||||
|
});
|
||||||
|
|
||||||
test("batch worker and provider-rate-limit configuration prefer env over EXTEND config", (t) => {
|
test("batch worker and provider-rate-limit configuration prefer env over EXTEND config", (t) => {
|
||||||
useEnv(t, {
|
useEnv(t, {
|
||||||
BAOYU_IMAGE_GEN_MAX_WORKERS: "12",
|
BAOYU_IMAGE_GEN_MAX_WORKERS: "12",
|
||||||
@@ -296,6 +329,10 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
|
|||||||
concurrency: 2,
|
concurrency: 2,
|
||||||
start_interval_ms: 900,
|
start_interval_ms: 900,
|
||||||
},
|
},
|
||||||
|
minimax: {
|
||||||
|
concurrency: 1,
|
||||||
|
start_interval_ms: 1500,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -305,10 +342,14 @@ test("batch worker and provider-rate-limit configuration prefer env over EXTEND
|
|||||||
concurrency: 5,
|
concurrency: 5,
|
||||||
startIntervalMs: 450,
|
startIntervalMs: 450,
|
||||||
});
|
});
|
||||||
|
assert.deepEqual(getConfiguredProviderRateLimits(extendConfig).minimax, {
|
||||||
|
concurrency: 1,
|
||||||
|
startIntervalMs: 1500,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t) => {
|
test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t) => {
|
||||||
const root = await makeTempDir("baoyu-image-gen-batch-");
|
const root = await makeTempDir("baoyu-imagine-batch-");
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
const batchFile = path.join(root, "jobs", "batch.json");
|
const batchFile = path.join(root, "jobs", "batch.json");
|
||||||
@@ -58,6 +58,7 @@ const DEFAULT_PROVIDER_RATE_LIMITS: Record<Provider, ProviderRateLimit> = {
|
|||||||
openai: { concurrency: 3, startIntervalMs: 1100 },
|
openai: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
openrouter: { concurrency: 3, startIntervalMs: 1100 },
|
openrouter: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
dashscope: { concurrency: 3, startIntervalMs: 1100 },
|
dashscope: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
|
minimax: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
jimeng: { concurrency: 3, startIntervalMs: 1100 },
|
jimeng: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
seedream: { concurrency: 3, startIntervalMs: 1100 },
|
seedream: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
azure: { concurrency: 3, startIntervalMs: 1100 },
|
azure: { concurrency: 3, startIntervalMs: 1100 },
|
||||||
@@ -75,13 +76,13 @@ Options:
|
|||||||
--image <path> Output image path (required in single-image mode)
|
--image <path> Output image path (required in single-image mode)
|
||||||
--batchfile <path> JSON batch file for multi-image generation
|
--batchfile <path> JSON batch file for multi-image generation
|
||||||
--jobs <count> Worker count for batch mode (default: auto, max from config, built-in default 10)
|
--jobs <count> Worker count for batch mode (default: auto, max from config, built-in default 10)
|
||||||
--provider google|openai|openrouter|dashscope|replicate|jimeng|seedream|azure Force provider (auto-detect by default)
|
--provider google|openai|openrouter|dashscope|minimax|replicate|jimeng|seedream|azure Force provider (auto-detect by default)
|
||||||
-m, --model <id> Model ID
|
-m, --model <id> Model ID
|
||||||
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
||||||
--size <WxH> Size (e.g., 1024x1024)
|
--size <WxH> Size (e.g., 1024x1024)
|
||||||
--quality normal|2k Quality preset (default: 2k)
|
--quality normal|2k Quality preset (default: 2k)
|
||||||
--imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality)
|
--imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality)
|
||||||
--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate, or Seedream 4.0/4.5/5.0)
|
--ref <files...> Reference images (Google, OpenAI, Azure, OpenRouter, Replicate, MiniMax, or Seedream 4.0/4.5/5.0)
|
||||||
--n <count> Number of images for the current task (default: 1)
|
--n <count> Number of images for the current task (default: 1)
|
||||||
--json JSON output
|
--json JSON output
|
||||||
-h, --help Show help
|
-h, --help Show help
|
||||||
@@ -112,6 +113,7 @@ Environment variables:
|
|||||||
GOOGLE_API_KEY Google API key
|
GOOGLE_API_KEY Google API key
|
||||||
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
GEMINI_API_KEY Gemini API key (alias for GOOGLE_API_KEY)
|
||||||
DASHSCOPE_API_KEY DashScope API key
|
DASHSCOPE_API_KEY DashScope API key
|
||||||
|
MINIMAX_API_KEY MiniMax API key
|
||||||
REPLICATE_API_TOKEN Replicate API token
|
REPLICATE_API_TOKEN Replicate API token
|
||||||
JIMENG_ACCESS_KEY_ID Jimeng Access Key ID
|
JIMENG_ACCESS_KEY_ID Jimeng Access Key ID
|
||||||
JIMENG_SECRET_ACCESS_KEY Jimeng Secret Access Key
|
JIMENG_SECRET_ACCESS_KEY Jimeng Secret Access Key
|
||||||
@@ -120,6 +122,7 @@ Environment variables:
|
|||||||
OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image-preview)
|
OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image-preview)
|
||||||
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
||||||
DASHSCOPE_IMAGE_MODEL Default DashScope model (qwen-image-2.0-pro)
|
DASHSCOPE_IMAGE_MODEL Default DashScope model (qwen-image-2.0-pro)
|
||||||
|
MINIMAX_IMAGE_MODEL Default MiniMax model (image-01)
|
||||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
||||||
JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)
|
JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)
|
||||||
SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)
|
SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)
|
||||||
@@ -130,6 +133,7 @@ Environment variables:
|
|||||||
OPENROUTER_TITLE Optional app name for OpenRouter attribution
|
OPENROUTER_TITLE Optional app name for OpenRouter attribution
|
||||||
GOOGLE_BASE_URL Custom Google endpoint
|
GOOGLE_BASE_URL Custom Google endpoint
|
||||||
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
DASHSCOPE_BASE_URL Custom DashScope endpoint
|
||||||
|
MINIMAX_BASE_URL Custom MiniMax endpoint
|
||||||
REPLICATE_BASE_URL Custom Replicate endpoint
|
REPLICATE_BASE_URL Custom Replicate endpoint
|
||||||
JIMENG_BASE_URL Custom Jimeng endpoint
|
JIMENG_BASE_URL Custom Jimeng endpoint
|
||||||
AZURE_OPENAI_API_KEY Azure OpenAI API key
|
AZURE_OPENAI_API_KEY Azure OpenAI API key
|
||||||
@@ -235,6 +239,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
v !== "openai" &&
|
v !== "openai" &&
|
||||||
v !== "openrouter" &&
|
v !== "openrouter" &&
|
||||||
v !== "dashscope" &&
|
v !== "dashscope" &&
|
||||||
|
v !== "minimax" &&
|
||||||
v !== "replicate" &&
|
v !== "replicate" &&
|
||||||
v !== "jimeng" &&
|
v !== "jimeng" &&
|
||||||
v !== "seedream" &&
|
v !== "seedream" &&
|
||||||
@@ -390,6 +395,7 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
openai: null,
|
openai: null,
|
||||||
openrouter: null,
|
openrouter: null,
|
||||||
dashscope: null,
|
dashscope: null,
|
||||||
|
minimax: null,
|
||||||
replicate: null,
|
replicate: null,
|
||||||
jimeng: null,
|
jimeng: null,
|
||||||
seedream: null,
|
seedream: null,
|
||||||
@@ -417,6 +423,7 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
key === "openai" ||
|
key === "openai" ||
|
||||||
key === "openrouter" ||
|
key === "openrouter" ||
|
||||||
key === "dashscope" ||
|
key === "dashscope" ||
|
||||||
|
key === "minimax" ||
|
||||||
key === "replicate" ||
|
key === "replicate" ||
|
||||||
key === "jimeng" ||
|
key === "jimeng" ||
|
||||||
key === "seedream" ||
|
key === "seedream" ||
|
||||||
@@ -434,6 +441,7 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
key === "openai" ||
|
key === "openai" ||
|
||||||
key === "openrouter" ||
|
key === "openrouter" ||
|
||||||
key === "dashscope" ||
|
key === "dashscope" ||
|
||||||
|
key === "minimax" ||
|
||||||
key === "replicate" ||
|
key === "replicate" ||
|
||||||
key === "jimeng" ||
|
key === "jimeng" ||
|
||||||
key === "seedream" ||
|
key === "seedream" ||
|
||||||
@@ -468,8 +476,8 @@ async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
|
|||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
|
|
||||||
const paths = [
|
const paths = [
|
||||||
path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),
|
||||||
path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
path.join(home, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
@@ -528,12 +536,13 @@ export function getConfiguredProviderRateLimits(
|
|||||||
openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai },
|
openai: { ...DEFAULT_PROVIDER_RATE_LIMITS.openai },
|
||||||
openrouter: { ...DEFAULT_PROVIDER_RATE_LIMITS.openrouter },
|
openrouter: { ...DEFAULT_PROVIDER_RATE_LIMITS.openrouter },
|
||||||
dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },
|
dashscope: { ...DEFAULT_PROVIDER_RATE_LIMITS.dashscope },
|
||||||
|
minimax: { ...DEFAULT_PROVIDER_RATE_LIMITS.minimax },
|
||||||
jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },
|
jimeng: { ...DEFAULT_PROVIDER_RATE_LIMITS.jimeng },
|
||||||
seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream },
|
seedream: { ...DEFAULT_PROVIDER_RATE_LIMITS.seedream },
|
||||||
azure: { ...DEFAULT_PROVIDER_RATE_LIMITS.azure },
|
azure: { ...DEFAULT_PROVIDER_RATE_LIMITS.azure },
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream", "azure"] as Provider[]) {
|
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "minimax", "jimeng", "seedream", "azure"] as Provider[]) {
|
||||||
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
|
const envPrefix = `BAOYU_IMAGE_GEN_${provider.toUpperCase()}`;
|
||||||
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
|
const extendLimit = extendConfig.batch?.provider_limits?.[provider];
|
||||||
configured[provider] = {
|
configured[provider] = {
|
||||||
@@ -582,7 +591,9 @@ export function normalizeOutputImagePath(p: string, defaultExtension = ".png"):
|
|||||||
|
|
||||||
function inferProviderFromModel(model: string | null): Provider | null {
|
function inferProviderFromModel(model: string | null): Provider | null {
|
||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
if (model.includes("seedream") || model.includes("seededit")) return "seedream";
|
const normalized = model.trim();
|
||||||
|
if (normalized.includes("seedream") || normalized.includes("seededit")) return "seedream";
|
||||||
|
if (normalized === "image-01" || normalized === "image-01-live") return "minimax";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,10 +606,11 @@ export function detectProvider(args: CliArgs): Provider {
|
|||||||
args.provider !== "azure" &&
|
args.provider !== "azure" &&
|
||||||
args.provider !== "openrouter" &&
|
args.provider !== "openrouter" &&
|
||||||
args.provider !== "replicate" &&
|
args.provider !== "replicate" &&
|
||||||
args.provider !== "seedream"
|
args.provider !== "seedream" &&
|
||||||
|
args.provider !== "minimax"
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider azure (Azure OpenAI), --provider openrouter (OpenRouter multimodal), --provider replicate, or --provider seedream for supported Seedream models."
|
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider azure (Azure OpenAI), --provider openrouter (OpenRouter multimodal), --provider replicate, --provider seedream for supported Seedream models, or --provider minimax for MiniMax subject-reference workflows."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,6 +621,7 @@ export function detectProvider(args: CliArgs): Provider {
|
|||||||
const hasOpenai = !!process.env.OPENAI_API_KEY;
|
const hasOpenai = !!process.env.OPENAI_API_KEY;
|
||||||
const hasOpenrouter = !!process.env.OPENROUTER_API_KEY;
|
const hasOpenrouter = !!process.env.OPENROUTER_API_KEY;
|
||||||
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
|
const hasDashscope = !!process.env.DASHSCOPE_API_KEY;
|
||||||
|
const hasMinimax = !!process.env.MINIMAX_API_KEY;
|
||||||
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
|
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
|
||||||
const hasJimeng = !!(process.env.JIMENG_ACCESS_KEY_ID && process.env.JIMENG_SECRET_ACCESS_KEY);
|
const hasJimeng = !!(process.env.JIMENG_ACCESS_KEY_ID && process.env.JIMENG_SECRET_ACCESS_KEY);
|
||||||
const hasSeedream = !!process.env.ARK_API_KEY;
|
const hasSeedream = !!process.env.ARK_API_KEY;
|
||||||
@@ -621,6 +634,13 @@ export function detectProvider(args: CliArgs): Provider {
|
|||||||
return "seedream";
|
return "seedream";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (modelProvider === "minimax") {
|
||||||
|
if (!hasMinimax) {
|
||||||
|
throw new Error("Model looks like a MiniMax image model, but MINIMAX_API_KEY is not set.");
|
||||||
|
}
|
||||||
|
return "minimax";
|
||||||
|
}
|
||||||
|
|
||||||
if (args.referenceImages.length > 0) {
|
if (args.referenceImages.length > 0) {
|
||||||
if (hasGoogle) return "google";
|
if (hasGoogle) return "google";
|
||||||
if (hasOpenai) return "openai";
|
if (hasOpenai) return "openai";
|
||||||
@@ -628,8 +648,9 @@ export function detectProvider(args: CliArgs): Provider {
|
|||||||
if (hasOpenrouter) return "openrouter";
|
if (hasOpenrouter) return "openrouter";
|
||||||
if (hasReplicate) return "replicate";
|
if (hasReplicate) return "replicate";
|
||||||
if (hasSeedream) return "seedream";
|
if (hasSeedream) return "seedream";
|
||||||
|
if (hasMinimax) return "minimax";
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Reference images require Google, OpenAI, Azure, OpenRouter, Replicate, or supported Seedream models. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, or ARK_API_KEY, or remove --ref."
|
"Reference images require Google, OpenAI, Azure, OpenRouter, Replicate, supported Seedream models, or MiniMax. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, ARK_API_KEY, or MINIMAX_API_KEY, or remove --ref."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,6 +660,7 @@ export function detectProvider(args: CliArgs): Provider {
|
|||||||
hasAzure && "azure",
|
hasAzure && "azure",
|
||||||
hasOpenrouter && "openrouter",
|
hasOpenrouter && "openrouter",
|
||||||
hasDashscope && "dashscope",
|
hasDashscope && "dashscope",
|
||||||
|
hasMinimax && "minimax",
|
||||||
hasReplicate && "replicate",
|
hasReplicate && "replicate",
|
||||||
hasJimeng && "jimeng",
|
hasJimeng && "jimeng",
|
||||||
hasSeedream && "seedream",
|
hasSeedream && "seedream",
|
||||||
@@ -648,7 +670,7 @@ export function detectProvider(args: CliArgs): Provider {
|
|||||||
if (available.length > 1) return available[0]!;
|
if (available.length > 1) return available[0]!;
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" +
|
"No API key found. Set GOOGLE_API_KEY, GEMINI_API_KEY, OPENAI_API_KEY, AZURE_OPENAI_API_KEY+AZURE_OPENAI_BASE_URL, OPENROUTER_API_KEY, DASHSCOPE_API_KEY, MINIMAX_API_KEY, REPLICATE_API_TOKEN, JIMENG keys, or ARK_API_KEY.\n" +
|
||||||
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
|
"Create ~/.baoyu-skills/.env or <cwd>/.baoyu-skills/.env with your keys."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -687,6 +709,7 @@ export function isRetryableGenerationError(error: unknown): boolean {
|
|||||||
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||||
if (provider === "google") return (await import("./providers/google")) as ProviderModule;
|
if (provider === "google") return (await import("./providers/google")) as ProviderModule;
|
||||||
if (provider === "dashscope") return (await import("./providers/dashscope")) as ProviderModule;
|
if (provider === "dashscope") return (await import("./providers/dashscope")) as ProviderModule;
|
||||||
|
if (provider === "minimax") return (await import("./providers/minimax")) as ProviderModule;
|
||||||
if (provider === "replicate") return (await import("./providers/replicate")) as ProviderModule;
|
if (provider === "replicate") return (await import("./providers/replicate")) as ProviderModule;
|
||||||
if (provider === "openrouter") return (await import("./providers/openrouter")) as ProviderModule;
|
if (provider === "openrouter") return (await import("./providers/openrouter")) as ProviderModule;
|
||||||
if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule;
|
if (provider === "jimeng") return (await import("./providers/jimeng")) as ProviderModule;
|
||||||
@@ -717,6 +740,7 @@ function getModelForProvider(
|
|||||||
return extendConfig.default_model.openrouter;
|
return extendConfig.default_model.openrouter;
|
||||||
}
|
}
|
||||||
if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope;
|
if (provider === "dashscope" && extendConfig.default_model.dashscope) return extendConfig.default_model.dashscope;
|
||||||
|
if (provider === "minimax" && extendConfig.default_model.minimax) return extendConfig.default_model.minimax;
|
||||||
if (provider === "replicate" && extendConfig.default_model.replicate) return extendConfig.default_model.replicate;
|
if (provider === "replicate" && extendConfig.default_model.replicate) return extendConfig.default_model.replicate;
|
||||||
if (provider === "jimeng" && extendConfig.default_model.jimeng) return extendConfig.default_model.jimeng;
|
if (provider === "jimeng" && extendConfig.default_model.jimeng) return extendConfig.default_model.jimeng;
|
||||||
if (provider === "seedream" && extendConfig.default_model.seedream) return extendConfig.default_model.seedream;
|
if (provider === "seedream" && extendConfig.default_model.seedream) return extendConfig.default_model.seedream;
|
||||||
+1
-1
@@ -136,7 +136,7 @@ test("Azure image generation routes model to deployment and sends mapped quality
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Azure image edits include quality in multipart requests", async (t) => {
|
test("Azure image edits include quality in multipart requests", async (t) => {
|
||||||
const root = await makeTempDir("baoyu-image-gen-azure-");
|
const root = await makeTempDir("baoyu-imagine-azure-");
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
const pngPath = path.join(root, "ref.png");
|
const pngPath = path.join(root, "ref.png");
|
||||||
+1
-1
@@ -421,7 +421,7 @@ export async function generateImage(
|
|||||||
|
|
||||||
if (args.referenceImages.length > 0) {
|
if (args.referenceImages.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
|
"Reference images are not supported with DashScope provider in baoyu-imagine. Use --provider google with a Gemini multimodal model."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
|
import {
|
||||||
|
buildMinimaxUrl,
|
||||||
|
buildRequestBody,
|
||||||
|
buildSubjectReference,
|
||||||
|
extractImageFromResponse,
|
||||||
|
parsePixelSize,
|
||||||
|
validateArgs,
|
||||||
|
} from "./minimax.ts";
|
||||||
|
|
||||||
|
function useEnv(
|
||||||
|
t: TestContext,
|
||||||
|
values: Record<string, string | null>,
|
||||||
|
): void {
|
||||||
|
const previous = new Map<string, string | undefined>();
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
previous.set(key, process.env[key]);
|
||||||
|
if (value == null) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.after(() => {
|
||||||
|
for (const [key, value] of previous.entries()) {
|
||||||
|
if (value == null) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
|
return {
|
||||||
|
prompt: null,
|
||||||
|
promptFiles: [],
|
||||||
|
imagePath: null,
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
aspectRatio: null,
|
||||||
|
size: null,
|
||||||
|
quality: null,
|
||||||
|
imageSize: null,
|
||||||
|
referenceImages: [],
|
||||||
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
|
json: false,
|
||||||
|
help: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("MiniMax URL builder normalizes /v1 suffixes", (t) => {
|
||||||
|
useEnv(t, { MINIMAX_BASE_URL: "https://api.minimax.io" });
|
||||||
|
assert.equal(buildMinimaxUrl(), "https://api.minimax.io/v1/image_generation");
|
||||||
|
|
||||||
|
process.env.MINIMAX_BASE_URL = "https://proxy.example.com/custom/v1/";
|
||||||
|
assert.equal(buildMinimaxUrl(), "https://proxy.example.com/custom/v1/image_generation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MiniMax size parsing and validation follow documented constraints", () => {
|
||||||
|
assert.deepEqual(parsePixelSize("1536x1024"), { width: 1536, height: 1024 });
|
||||||
|
assert.deepEqual(parsePixelSize("1536*1024"), { width: 1536, height: 1024 });
|
||||||
|
assert.equal(parsePixelSize("wide"), null);
|
||||||
|
|
||||||
|
validateArgs("image-01", makeArgs({ size: "1536x1024", n: 9 }));
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs("image-01-live", makeArgs({ size: "1536x1024" })),
|
||||||
|
/only supported with model image-01/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs("image-01", makeArgs({ size: "1537x1024" })),
|
||||||
|
/divisible by 8/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs("image-01", makeArgs({ aspectRatio: "2.35:1" })),
|
||||||
|
/aspect_ratio must be one of/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs("image-01", makeArgs({ n: 10 })),
|
||||||
|
/at most 9 images/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MiniMax request body maps aspect ratio, size, n, and subject references", async (t) => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "minimax-test-"));
|
||||||
|
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const refPath = path.join(dir, "portrait.png");
|
||||||
|
await fs.writeFile(refPath, Buffer.from("portrait"));
|
||||||
|
|
||||||
|
const ratioBody = await buildRequestBody(
|
||||||
|
"A portrait by the window",
|
||||||
|
"image-01",
|
||||||
|
makeArgs({ aspectRatio: "16:9", n: 2, referenceImages: [refPath] }),
|
||||||
|
);
|
||||||
|
assert.equal(ratioBody.aspect_ratio, "16:9");
|
||||||
|
assert.equal(ratioBody.n, 2);
|
||||||
|
assert.equal(ratioBody.response_format, "base64");
|
||||||
|
assert.match(ratioBody.subject_reference?.[0]?.image_file || "", /^data:image\/png;base64,/);
|
||||||
|
|
||||||
|
const sizeBody = await buildRequestBody(
|
||||||
|
"A portrait by the window",
|
||||||
|
"image-01",
|
||||||
|
makeArgs({ size: "1536x1024" }),
|
||||||
|
);
|
||||||
|
assert.equal(sizeBody.width, 1536);
|
||||||
|
assert.equal(sizeBody.height, 1024);
|
||||||
|
assert.equal(sizeBody.aspect_ratio, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MiniMax subject references require supported file types", async (t) => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "minimax-ref-"));
|
||||||
|
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const good = path.join(dir, "portrait.jpg");
|
||||||
|
const bad = path.join(dir, "portrait.webp");
|
||||||
|
await fs.writeFile(good, Buffer.from("portrait"));
|
||||||
|
await fs.writeFile(bad, Buffer.from("portrait"));
|
||||||
|
|
||||||
|
const subjectReference = await buildSubjectReference([good]);
|
||||||
|
assert.equal(subjectReference?.[0]?.type, "character");
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => buildSubjectReference([bad]),
|
||||||
|
/only supports JPG, JPEG, or PNG/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MiniMax response extraction supports base64 and URL payloads", async (t) => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromBase64 = await extractImageFromResponse({
|
||||||
|
data: {
|
||||||
|
image_base64: [Buffer.from("hello").toString("base64")],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(Buffer.from(fromBase64).toString("utf8"), "hello");
|
||||||
|
|
||||||
|
globalThis.fetch = async () =>
|
||||||
|
new Response(Uint8Array.from([1, 2, 3]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "image/jpeg" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromUrl = await extractImageFromResponse({
|
||||||
|
data: {
|
||||||
|
image_urls: ["https://example.com/output.jpg"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual([...fromUrl], [1, 2, 3]);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => extractImageFromResponse({ base_resp: { status_code: 1001, status_msg: "blocked" } }),
|
||||||
|
/blocked/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = "image-01";
|
||||||
|
const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const SUPPORTED_ASPECT_RATIOS = new Set(["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]);
|
||||||
|
|
||||||
|
type MinimaxSubjectReference = {
|
||||||
|
type: "character";
|
||||||
|
image_file: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MinimaxRequestBody = {
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
response_format: "base64";
|
||||||
|
aspect_ratio?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
n?: number;
|
||||||
|
subject_reference?: MinimaxSubjectReference[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MinimaxResponse = {
|
||||||
|
id?: string;
|
||||||
|
data?: {
|
||||||
|
image_urls?: string[];
|
||||||
|
image_base64?: string[];
|
||||||
|
};
|
||||||
|
base_resp?: {
|
||||||
|
status_code?: number;
|
||||||
|
status_msg?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDefaultModel(): string {
|
||||||
|
return process.env.MINIMAX_IMAGE_MODEL || DEFAULT_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiKey(): string | null {
|
||||||
|
return process.env.MINIMAX_API_KEY || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMinimaxUrl(): string {
|
||||||
|
const base = (process.env.MINIMAX_BASE_URL || "https://api.minimax.io").replace(/\/+$/g, "");
|
||||||
|
return base.endsWith("/v1") ? `${base}/image_generation` : `${base}/v1/image_generation`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMimeType(filename: string): "image/jpeg" | "image/png" {
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||||
|
if (ext === ".png") return "image/png";
|
||||||
|
throw new Error(
|
||||||
|
`MiniMax subject_reference only supports JPG, JPEG, or PNG files: ${filename}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePixelSize(size: string): { width: number; height: number } | null {
|
||||||
|
const match = size.trim().match(/^(\d+)\s*[xX*]\s*(\d+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const width = parseInt(match[1]!, 10);
|
||||||
|
const height = parseInt(match[2]!, 10);
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePixelSize(width: number, height: number): void {
|
||||||
|
if (width < 512 || width > 2048 || height < 512 || height > 2048) {
|
||||||
|
throw new Error("MiniMax custom size must keep width and height between 512 and 2048.");
|
||||||
|
}
|
||||||
|
if (width % 8 !== 0 || height % 8 !== 0) {
|
||||||
|
throw new Error("MiniMax custom size requires width and height divisible by 8.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArgs(model: string, args: CliArgs): void {
|
||||||
|
if (args.n > 9) {
|
||||||
|
throw new Error("MiniMax supports at most 9 images per request.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.aspectRatio && !SUPPORTED_ASPECT_RATIOS.has(args.aspectRatio)) {
|
||||||
|
throw new Error(
|
||||||
|
`MiniMax aspect_ratio must be one of: ${Array.from(SUPPORTED_ASPECT_RATIOS).join(", ")}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.size && !args.aspectRatio) {
|
||||||
|
if (model !== "image-01") {
|
||||||
|
throw new Error("MiniMax custom --size is only supported with model image-01. Use --model image-01 or pass --ar instead.");
|
||||||
|
}
|
||||||
|
const parsed = parsePixelSize(args.size);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error("MiniMax --size must be in WxH format, for example 1536x1024.");
|
||||||
|
}
|
||||||
|
validatePixelSize(parsed.width, parsed.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildSubjectReference(
|
||||||
|
referenceImages: string[],
|
||||||
|
): Promise<MinimaxSubjectReference[] | undefined> {
|
||||||
|
if (referenceImages.length === 0) return undefined;
|
||||||
|
|
||||||
|
const subjectReference: MinimaxSubjectReference[] = [];
|
||||||
|
for (const refPath of referenceImages) {
|
||||||
|
const bytes = await readFile(refPath);
|
||||||
|
if (bytes.length > MAX_REFERENCE_IMAGE_BYTES) {
|
||||||
|
throw new Error(`MiniMax subject_reference images must be smaller than 10MB: ${refPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
subjectReference.push({
|
||||||
|
type: "character",
|
||||||
|
image_file: `data:${getMimeType(refPath)};base64,${bytes.toString("base64")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return subjectReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildRequestBody(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs,
|
||||||
|
): Promise<MinimaxRequestBody> {
|
||||||
|
validateArgs(model, args);
|
||||||
|
|
||||||
|
const body: MinimaxRequestBody = {
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
response_format: "base64",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (args.aspectRatio) {
|
||||||
|
body.aspect_ratio = args.aspectRatio;
|
||||||
|
} else if (args.size) {
|
||||||
|
const parsed = parsePixelSize(args.size);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error("MiniMax --size must be in WxH format, for example 1536x1024.");
|
||||||
|
}
|
||||||
|
body.width = parsed.width;
|
||||||
|
body.height = parsed.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.n > 1) {
|
||||||
|
body.n = args.n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subjectReference = await buildSubjectReference(args.referenceImages);
|
||||||
|
if (subjectReference) {
|
||||||
|
body.subject_reference = subjectReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadImage(url: string): Promise<Uint8Array> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download image from MiniMax: ${response.status}`);
|
||||||
|
}
|
||||||
|
return new Uint8Array(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractImageFromResponse(result: MinimaxResponse): Promise<Uint8Array> {
|
||||||
|
const baseResp = result.base_resp;
|
||||||
|
if (baseResp && baseResp.status_code !== undefined && baseResp.status_code !== 0) {
|
||||||
|
throw new Error(baseResp.status_msg || `MiniMax API returned status_code=${baseResp.status_code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Image = result.data?.image_base64?.[0];
|
||||||
|
if (base64Image) {
|
||||||
|
return Uint8Array.from(Buffer.from(base64Image, "base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = result.data?.image_urls?.[0];
|
||||||
|
if (url) {
|
||||||
|
return downloadImage(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("No image data in MiniMax response");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultOutputExtension(): ".jpg" {
|
||||||
|
return ".jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateImage(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("MINIMAX_API_KEY is required. Get one from https://platform.minimax.io/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await buildRequestBody(prompt, model, args);
|
||||||
|
const response = await fetch(buildMinimaxUrl(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.text();
|
||||||
|
throw new Error(`MiniMax API error (${response.status}): ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as MinimaxResponse;
|
||||||
|
return extractImageFromResponse(result);
|
||||||
|
}
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
export type Provider = "google" | "openai" | "openrouter" | "dashscope" | "replicate" | "jimeng" | "seedream" | "azure";
|
export type Provider =
|
||||||
|
| "google"
|
||||||
|
| "openai"
|
||||||
|
| "openrouter"
|
||||||
|
| "dashscope"
|
||||||
|
| "minimax"
|
||||||
|
| "replicate"
|
||||||
|
| "jimeng"
|
||||||
|
| "seedream"
|
||||||
|
| "azure";
|
||||||
export type Quality = "normal" | "2k";
|
export type Quality = "normal" | "2k";
|
||||||
|
|
||||||
export type CliArgs = {
|
export type CliArgs = {
|
||||||
@@ -52,6 +61,7 @@ export type ExtendConfig = {
|
|||||||
openai: string | null;
|
openai: string | null;
|
||||||
openrouter: string | null;
|
openrouter: string | null;
|
||||||
dashscope: string | null;
|
dashscope: string | null;
|
||||||
|
minimax: string | null;
|
||||||
replicate: string | null;
|
replicate: string | null;
|
||||||
jimeng: string | null;
|
jimeng: string | null;
|
||||||
seedream: string | null;
|
seedream: string | null;
|
||||||
@@ -118,6 +118,7 @@ Full reference: [references/config/first-time-setup.md](references/config/first-
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Chrome CDP for full JavaScript rendering
|
- Chrome CDP for full JavaScript rendering
|
||||||
|
- Browser strategy fallback: default headless first, then visible Chrome on technical failure
|
||||||
- URL-specific parser layer for sites that need custom HTML rules before generic extraction
|
- URL-specific parser layer for sites that need custom HTML rules before generic extraction
|
||||||
- Two capture modes: auto or wait-for-user
|
- Two capture modes: auto or wait-for-user
|
||||||
- Save rendered HTML as a sibling `-captured.html` file
|
- Save rendered HTML as a sibling `-captured.html` file
|
||||||
@@ -137,6 +138,12 @@ Full reference: [references/config/first-time-setup.md](references/config/first-
|
|||||||
# Auto mode (default) - capture when page loads
|
# Auto mode (default) - capture when page loads
|
||||||
${BUN_X} {baseDir}/scripts/main.ts <url>
|
${BUN_X} {baseDir}/scripts/main.ts <url>
|
||||||
|
|
||||||
|
# Force headless only
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts <url> --browser headless
|
||||||
|
|
||||||
|
# Force visible browser
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts <url> --browser headed
|
||||||
|
|
||||||
# Wait mode - wait for user signal before capture
|
# Wait mode - wait for user signal before capture
|
||||||
${BUN_X} {baseDir}/scripts/main.ts <url> --wait
|
${BUN_X} {baseDir}/scripts/main.ts <url> --wait
|
||||||
|
|
||||||
@@ -158,6 +165,9 @@ ${BUN_X} {baseDir}/scripts/main.ts <url> --download-media
|
|||||||
| `-o <path>` | Output file path — must be a **file** path, not directory (default: auto-generated) |
|
| `-o <path>` | Output file path — must be a **file** path, not directory (default: auto-generated) |
|
||||||
| `--output-dir <dir>` | Base output directory — auto-generates `{dir}/{domain}/{slug}.md` (default: `./url-to-markdown/`) |
|
| `--output-dir <dir>` | Base output directory — auto-generates `{dir}/{domain}/{slug}.md` (default: `./url-to-markdown/`) |
|
||||||
| `--wait` | Wait for user signal before capturing |
|
| `--wait` | Wait for user signal before capturing |
|
||||||
|
| `--browser <mode>` | Browser strategy: `auto` (default), `headless`, or `headed` |
|
||||||
|
| `--headless` | Shortcut for `--browser headless` |
|
||||||
|
| `--headed` | Shortcut for `--browser headed` |
|
||||||
| `--timeout <ms>` | Page load timeout (default: 30000) |
|
| `--timeout <ms>` | Page load timeout (default: 30000) |
|
||||||
| `--download-media` | Download image/video assets to local `imgs/` and `videos/`, and rewrite markdown links to local relative paths |
|
| `--download-media` | Download image/video assets to local `imgs/` and `videos/`, and rewrite markdown links to local relative paths |
|
||||||
|
|
||||||
@@ -165,7 +175,7 @@ ${BUN_X} {baseDir}/scripts/main.ts <url> --download-media
|
|||||||
|
|
||||||
| Mode | Behavior | Use When |
|
| Mode | Behavior | Use When |
|
||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
| Auto (default) | Capture on network idle | Public pages, static content |
|
| Auto (default) | Try headless first, then retry in visible Chrome if needed | Public pages, static content, unknown pages |
|
||||||
| Wait (`--wait`) | User signals when ready | Login-required, lazy loading, paywalls |
|
| Wait (`--wait`) | User signals when ready | Login-required, lazy loading, paywalls |
|
||||||
|
|
||||||
**Wait mode workflow**:
|
**Wait mode workflow**:
|
||||||
@@ -173,6 +183,43 @@ ${BUN_X} {baseDir}/scripts/main.ts <url> --download-media
|
|||||||
2. Ask user to confirm page is ready
|
2. Ask user to confirm page is ready
|
||||||
3. Send newline to stdin to trigger capture
|
3. Send newline to stdin to trigger capture
|
||||||
|
|
||||||
|
**Default browser fallback**:
|
||||||
|
1. Auto mode starts with headless Chrome and captures on network idle
|
||||||
|
2. If headless capture fails technically, retry with visible Chrome
|
||||||
|
3. If a shared Chrome session for this profile already exists, reuse it instead of launching a new browser
|
||||||
|
4. The script does not hard-code login or paywall detection; the agent must inspect the captured markdown or HTML and decide whether to rerun with `--browser headed --wait`
|
||||||
|
|
||||||
|
## Agent Quality Gate
|
||||||
|
|
||||||
|
**CRITICAL**: The agent must treat headless capture as provisional. Some sites render differently in headless mode and can silently return an error shell, partially hydrated page, or low-quality extraction **without** causing the CLI to fail.
|
||||||
|
|
||||||
|
After every run that used `--browser auto` or `--browser headless`, the agent **MUST** inspect the saved markdown first, and inspect the saved `-captured.html` when the markdown looks suspicious.
|
||||||
|
|
||||||
|
### Quality checks the agent must perform
|
||||||
|
|
||||||
|
1. Confirm the markdown title matches the target page, not a generic site shell
|
||||||
|
2. Confirm the body contains the expected article or page content, not just navigation, footer, or a generic error
|
||||||
|
3. Watch for obvious failure signs such as:
|
||||||
|
- `Application error`
|
||||||
|
- `This page could not be found`
|
||||||
|
- login, signup, subscribe, or verification shells
|
||||||
|
- extremely short markdown for a page that should be long-form
|
||||||
|
- raw framework payloads or mostly boilerplate content
|
||||||
|
4. If the result is low quality, incomplete, or clearly wrong, do **not** accept the run as successful just because the CLI exited with code 0
|
||||||
|
|
||||||
|
### Recovery workflow the agent must follow
|
||||||
|
|
||||||
|
1. First run with default `auto` unless there is already a clear reason to use wait mode
|
||||||
|
2. Review markdown quality immediately after the run
|
||||||
|
3. If the content is low quality, rerun locally with visible Chrome:
|
||||||
|
- `--browser headed` for ordinary rendering issues
|
||||||
|
- `--browser headed --wait` when the page may need login, anti-bot interaction, cookie acceptance, or extra hydration time
|
||||||
|
4. If `--wait` is used, tell the user exactly what to do:
|
||||||
|
- if login is required, ask them to sign in
|
||||||
|
- if the page needs time to hydrate, ask them to wait until the full content is visible
|
||||||
|
- once ready, ask them to press Enter so capture can continue
|
||||||
|
5. Only fall back to hosted `defuddle.md` after the local browser strategies have failed or are clearly lower fidelity
|
||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
|
|
||||||
Each run saves two files side by side:
|
Each run saves two files side by side:
|
||||||
@@ -211,8 +258,9 @@ Conversion order:
|
|||||||
2. If no specialized parser matches, try Defuddle
|
2. If no specialized parser matches, try Defuddle
|
||||||
3. For rich pages such as YouTube, prefer Defuddle's extractor-specific output (including transcripts when available) instead of replacing it with the legacy pipeline
|
3. For rich pages such as YouTube, prefer Defuddle's extractor-specific output (including transcripts when available) instead of replacing it with the legacy pipeline
|
||||||
4. If Defuddle throws, cannot load, returns obviously incomplete markdown, or captures lower-quality content than the legacy pipeline, automatically fall back to the pre-Defuddle extractor
|
4. If Defuddle throws, cannot load, returns obviously incomplete markdown, or captures lower-quality content than the legacy pipeline, automatically fall back to the pre-Defuddle extractor
|
||||||
5. If the entire local browser capture flow fails before markdown can be produced, try the hosted `https://defuddle.md/<url>` API and save its markdown output directly
|
5. If the agent determines the captured result is a login screen, verification screen, or paywall shell, rerun locally with `--browser headed --wait` and ask the user to complete access before capture
|
||||||
6. The legacy fallback path uses the older Readability/selector/Next.js-data based HTML-to-Markdown implementation recovered from git history
|
6. If the entire local browser capture flow still fails before markdown can be produced, try the hosted `https://defuddle.md/<url>` API and save its markdown output directly
|
||||||
|
7. The legacy fallback path uses the older Readability/selector/Next.js-data based HTML-to-Markdown implementation recovered from git history
|
||||||
|
|
||||||
CLI output will show:
|
CLI output will show:
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||||
"defuddle": "^0.12.0",
|
"defuddle": "^0.14.0",
|
||||||
"jsdom": "^24.1.3",
|
"jsdom": "^24.1.3",
|
||||||
"linkedom": "^0.18.12",
|
"linkedom": "^0.18.12",
|
||||||
"turndown": "^7.2.2",
|
"turndown": "^7.2.2",
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||||
|
|
||||||
"defuddle": ["defuddle@0.12.0", "", { "dependencies": { "commander": "^12.1.0" }, "optionalDependencies": { "mathml-to-latex": "^1.5.0", "temml": "^0.13.1", "turndown": "^7.2.0" }, "peerDependencies": { "jsdom": "^24.0.0" }, "bin": { "defuddle": "dist/cli.js" } }, "sha512-Y/WgyGKBxwxFir+hWNth4nmWDDDb8BzQi3qASS2NWYPXsKU42Ku49/3M5yFYefnRef9prynnmasfnXjk99EWgA=="],
|
"defuddle": ["defuddle@0.14.0", "", { "dependencies": { "commander": "^12.1.0" }, "optionalDependencies": { "linkedom": "^0.18.12", "mathml-to-latex": "^1.5.0", "temml": "^0.13.1", "turndown": "^7.2.0" }, "bin": { "defuddle": "dist/cli.js" } }, "sha512-btavZGd1WgiVqrVM62WGRXMUi/aU7ckTZiq0xXWLZMHvzIqNZjwIFQEDRx8MarD7fIgsB90NXZ9xHJkKtapt2Q=="],
|
||||||
|
|
||||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { cleanContent } from "./content-cleaner.js";
|
||||||
|
|
||||||
|
const SAMPLE_HTML = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Example Story</title>
|
||||||
|
<style>.cookie-banner { position: fixed; }</style>
|
||||||
|
<script>window.__noise = true;</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- comment that should be removed -->
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<a href="/home">Home</a>
|
||||||
|
<a href="/topics">Topics</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<div class="cookie-banner">Accept cookies</div>
|
||||||
|
<aside>Sidebar links</aside>
|
||||||
|
<main>
|
||||||
|
<article class="content">
|
||||||
|
<h1>Actual Story Title</h1>
|
||||||
|
<p>
|
||||||
|
This is the first paragraph of the real story body, and it is intentionally long enough
|
||||||
|
to survive the cleaner's main-content heuristics without being mistaken for navigation.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This is the second paragraph with more useful detail, a
|
||||||
|
<a href="/read-more">supporting link</a>, and a normal image.
|
||||||
|
</p>
|
||||||
|
<img src="/images/cover.jpg" alt="Cover">
|
||||||
|
<img src="data:image/png;base64,AAAA" alt="Inline data">
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
<footer>Footer boilerplate</footer>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
test("cleanContent keeps the article body and removes obvious boilerplate", () => {
|
||||||
|
const cleaned = cleanContent(SAMPLE_HTML, "https://example.com/posts/story");
|
||||||
|
|
||||||
|
assert.match(cleaned, /Actual Story Title/);
|
||||||
|
assert.match(cleaned, /https:\/\/example\.com\/read-more/);
|
||||||
|
assert.match(cleaned, /https:\/\/example\.com\/images\/cover\.jpg/);
|
||||||
|
|
||||||
|
assert.doesNotMatch(cleaned, /Accept cookies/);
|
||||||
|
assert.doesNotMatch(cleaned, /Sidebar links/);
|
||||||
|
assert.doesNotMatch(cleaned, /Footer boilerplate/);
|
||||||
|
assert.doesNotMatch(cleaned, /window\.__noise/);
|
||||||
|
assert.doesNotMatch(cleaned, /comment that should be removed/);
|
||||||
|
assert.doesNotMatch(cleaned, /data:image\/png;base64/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
|
||||||
|
export interface CleaningOptions {
|
||||||
|
removeAds?: boolean;
|
||||||
|
removeBase64Images?: boolean;
|
||||||
|
onlyMainContent?: boolean;
|
||||||
|
includeTags?: string[];
|
||||||
|
excludeTags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALWAYS_REMOVE_SELECTORS = [
|
||||||
|
"script",
|
||||||
|
"style",
|
||||||
|
"noscript",
|
||||||
|
"link[rel='stylesheet']",
|
||||||
|
"[hidden]",
|
||||||
|
"[aria-hidden='true']",
|
||||||
|
"[style*='display: none']",
|
||||||
|
"[style*='display:none']",
|
||||||
|
"[style*='visibility: hidden']",
|
||||||
|
"[style*='visibility:hidden']",
|
||||||
|
"svg[aria-hidden='true']",
|
||||||
|
"svg.icon",
|
||||||
|
"svg[class*='icon']",
|
||||||
|
"template",
|
||||||
|
"meta",
|
||||||
|
"iframe",
|
||||||
|
"canvas",
|
||||||
|
"object",
|
||||||
|
"embed",
|
||||||
|
"form",
|
||||||
|
"input",
|
||||||
|
"select",
|
||||||
|
"textarea",
|
||||||
|
"button",
|
||||||
|
];
|
||||||
|
|
||||||
|
const OVERLAY_SELECTORS = [
|
||||||
|
"[class*='modal']",
|
||||||
|
"[class*='popup']",
|
||||||
|
"[class*='overlay']",
|
||||||
|
"[class*='dialog']",
|
||||||
|
"[role='dialog']",
|
||||||
|
"[role='alertdialog']",
|
||||||
|
"[class*='cookie']",
|
||||||
|
"[class*='consent']",
|
||||||
|
"[class*='gdpr']",
|
||||||
|
"[class*='privacy-banner']",
|
||||||
|
"[class*='notification-bar']",
|
||||||
|
"[id*='cookie']",
|
||||||
|
"[id*='consent']",
|
||||||
|
"[id*='gdpr']",
|
||||||
|
"[style*='position: fixed']",
|
||||||
|
"[style*='position:fixed']",
|
||||||
|
"[style*='position: sticky']",
|
||||||
|
"[style*='position:sticky']",
|
||||||
|
];
|
||||||
|
|
||||||
|
const NAVIGATION_SELECTORS = [
|
||||||
|
"header",
|
||||||
|
"footer",
|
||||||
|
"nav",
|
||||||
|
"aside",
|
||||||
|
".header",
|
||||||
|
".top",
|
||||||
|
".navbar",
|
||||||
|
"#header",
|
||||||
|
".footer",
|
||||||
|
".bottom",
|
||||||
|
"#footer",
|
||||||
|
".sidebar",
|
||||||
|
".side",
|
||||||
|
".aside",
|
||||||
|
"#sidebar",
|
||||||
|
".modal",
|
||||||
|
".popup",
|
||||||
|
"#modal",
|
||||||
|
".overlay",
|
||||||
|
".ad",
|
||||||
|
".ads",
|
||||||
|
".advert",
|
||||||
|
"#ad",
|
||||||
|
".lang-selector",
|
||||||
|
".language",
|
||||||
|
"#language-selector",
|
||||||
|
".social",
|
||||||
|
".social-media",
|
||||||
|
".social-links",
|
||||||
|
"#social",
|
||||||
|
".menu",
|
||||||
|
".navigation",
|
||||||
|
"#nav",
|
||||||
|
".breadcrumbs",
|
||||||
|
"#breadcrumbs",
|
||||||
|
".share",
|
||||||
|
"#share",
|
||||||
|
".widget",
|
||||||
|
"#widget",
|
||||||
|
".cookie",
|
||||||
|
"#cookie",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FORCE_INCLUDE_SELECTORS = [
|
||||||
|
"#main",
|
||||||
|
"#content",
|
||||||
|
"#main-content",
|
||||||
|
"#article",
|
||||||
|
"#post",
|
||||||
|
"#page-content",
|
||||||
|
"main",
|
||||||
|
"article",
|
||||||
|
"[role='main']",
|
||||||
|
".main-content",
|
||||||
|
".content",
|
||||||
|
".post-content",
|
||||||
|
".article-content",
|
||||||
|
".entry-content",
|
||||||
|
".page-content",
|
||||||
|
".article-body",
|
||||||
|
".post-body",
|
||||||
|
".story-content",
|
||||||
|
".blog-content",
|
||||||
|
];
|
||||||
|
|
||||||
|
const AD_SELECTORS = [
|
||||||
|
"ins.adsbygoogle",
|
||||||
|
".google-ad",
|
||||||
|
".adsense",
|
||||||
|
"[data-ad]",
|
||||||
|
"[data-ads]",
|
||||||
|
"[data-ad-slot]",
|
||||||
|
"[data-ad-client]",
|
||||||
|
".ad-container",
|
||||||
|
".ad-wrapper",
|
||||||
|
".advertisement",
|
||||||
|
".sponsored-content",
|
||||||
|
"img[width='1'][height='1']",
|
||||||
|
"img[src*='pixel']",
|
||||||
|
"img[src*='tracking']",
|
||||||
|
"img[src*='analytics']",
|
||||||
|
];
|
||||||
|
|
||||||
|
function getLinkDensity(element: Element): number {
|
||||||
|
const text = element.textContent || "";
|
||||||
|
const textLength = text.trim().length;
|
||||||
|
if (textLength === 0) return 1;
|
||||||
|
|
||||||
|
let linkLength = 0;
|
||||||
|
element.querySelectorAll("a").forEach((link: Element) => {
|
||||||
|
linkLength += (link.textContent || "").trim().length;
|
||||||
|
});
|
||||||
|
|
||||||
|
return linkLength / textLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContentScore(element: Element): number {
|
||||||
|
let score = 0;
|
||||||
|
const text = element.textContent || "";
|
||||||
|
const textLength = text.trim().length;
|
||||||
|
|
||||||
|
score += Math.min(textLength / 100, 50);
|
||||||
|
score += element.querySelectorAll("p").length * 3;
|
||||||
|
score += element.querySelectorAll("h1, h2, h3, h4, h5, h6").length * 2;
|
||||||
|
score += element.querySelectorAll("img").length;
|
||||||
|
|
||||||
|
score -= element.querySelectorAll("a").length * 0.5;
|
||||||
|
score -= element.querySelectorAll("li").length * 0.2;
|
||||||
|
|
||||||
|
const linkDensity = getLinkDensity(element);
|
||||||
|
if (linkDensity > 0.5) score -= 30;
|
||||||
|
else if (linkDensity > 0.3) score -= 15;
|
||||||
|
|
||||||
|
const className = typeof element.className === "string" ? element.className : "";
|
||||||
|
const classAndId = `${className} ${element.id || ""}`;
|
||||||
|
if (/article|content|post|body|main|entry/i.test(classAndId)) score += 25;
|
||||||
|
if (/comment|sidebar|footer|nav|menu|header|widget|ad/i.test(classAndId)) score -= 25;
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeNavigation(element: Element): boolean {
|
||||||
|
const linkDensity = getLinkDensity(element);
|
||||||
|
if (linkDensity > 0.5) return true;
|
||||||
|
|
||||||
|
const listItems = element.querySelectorAll("li");
|
||||||
|
const links = element.querySelectorAll("a");
|
||||||
|
return listItems.length > 5 && links.length > listItems.length * 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeElements(document: Document, selectors: string[]): void {
|
||||||
|
for (const selector of selectors) {
|
||||||
|
try {
|
||||||
|
document.querySelectorAll(selector).forEach((element: Element) => element.remove());
|
||||||
|
} catch {
|
||||||
|
// Ignore unsupported selectors from linkedom/jsdom differences.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWithProtection(
|
||||||
|
document: Document,
|
||||||
|
selectorsToRemove: string[],
|
||||||
|
protectedSelectors: string[]
|
||||||
|
): void {
|
||||||
|
for (const selector of selectorsToRemove) {
|
||||||
|
try {
|
||||||
|
document.querySelectorAll(selector).forEach((element: Element) => {
|
||||||
|
const isProtected = protectedSelectors.some((protectedSelector) => {
|
||||||
|
try {
|
||||||
|
return element.matches(protectedSelector);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (isProtected) return;
|
||||||
|
|
||||||
|
const containsProtected = protectedSelectors.some((protectedSelector) => {
|
||||||
|
try {
|
||||||
|
return element.querySelector(protectedSelector) !== null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (containsProtected) return;
|
||||||
|
|
||||||
|
element.remove();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore unsupported selectors from linkedom/jsdom differences.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMainContent(document: Document): Element | null {
|
||||||
|
const isValidContent = (element: Element | null): element is Element => {
|
||||||
|
if (!element) return false;
|
||||||
|
const text = element.textContent || "";
|
||||||
|
if (text.trim().length < 100) return false;
|
||||||
|
return !looksLikeNavigation(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = document.querySelector("main");
|
||||||
|
if (isValidContent(main) && getLinkDensity(main) < 0.4) return main;
|
||||||
|
|
||||||
|
const roleMain = document.querySelector('[role="main"]');
|
||||||
|
if (isValidContent(roleMain) && getLinkDensity(roleMain) < 0.4) return roleMain;
|
||||||
|
|
||||||
|
const articles = document.querySelectorAll("article");
|
||||||
|
if (articles.length === 1 && isValidContent(articles[0] ?? null)) {
|
||||||
|
return articles[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentSelectors = [
|
||||||
|
"#content",
|
||||||
|
"#main-content",
|
||||||
|
"#main",
|
||||||
|
".content",
|
||||||
|
".main-content",
|
||||||
|
".post-content",
|
||||||
|
".article-content",
|
||||||
|
".entry-content",
|
||||||
|
".page-content",
|
||||||
|
".article-body",
|
||||||
|
".post-body",
|
||||||
|
".story-content",
|
||||||
|
".blog-content",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of contentSelectors) {
|
||||||
|
try {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (isValidContent(element) && getLinkDensity(element) < 0.4) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid selectors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: Array<{ element: Element; score: number }> = [];
|
||||||
|
const containers = document.querySelectorAll("div, section, article");
|
||||||
|
containers.forEach((element: Element) => {
|
||||||
|
const text = element.textContent || "";
|
||||||
|
if (text.trim().length < 200) return;
|
||||||
|
|
||||||
|
const score = getContentScore(element);
|
||||||
|
if (score > 0) {
|
||||||
|
candidates.push({ element, score });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
candidates.sort((left, right) => right.score - left.score);
|
||||||
|
if ((candidates[0]?.score ?? 0) > 20) {
|
||||||
|
return candidates[0]?.element ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBase64ImagesFromDocument(document: Document): void {
|
||||||
|
document.querySelectorAll("img[src^='data:']").forEach((element: Element) => {
|
||||||
|
element.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[style*='data:image']").forEach((element: Element) => {
|
||||||
|
const style = element.getAttribute("style");
|
||||||
|
if (!style) return;
|
||||||
|
|
||||||
|
const cleanedStyle = style.replace(
|
||||||
|
/background(-image)?:\s*url\([^)]*data:image[^)]*\)[^;]*;?/gi,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cleanedStyle.trim()) {
|
||||||
|
element.setAttribute("style", cleanedStyle);
|
||||||
|
} else {
|
||||||
|
element.removeAttribute("style");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("source[src^='data:'], source[srcset*='data:']").forEach((element: Element) => {
|
||||||
|
element.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAbsoluteUrl(value: string, baseUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
return new URL(value, baseUrl).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertRelativeUrls(document: Document, baseUrl: string): void {
|
||||||
|
document.querySelectorAll("[src]").forEach((element: Element) => {
|
||||||
|
const src = element.getAttribute("src");
|
||||||
|
if (!src || src.startsWith("http") || src.startsWith("//") || src.startsWith("data:")) return;
|
||||||
|
|
||||||
|
const absolute = makeAbsoluteUrl(src, baseUrl);
|
||||||
|
if (absolute) element.setAttribute("src", absolute);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[href]").forEach((element: Element) => {
|
||||||
|
const href = element.getAttribute("href");
|
||||||
|
if (
|
||||||
|
!href ||
|
||||||
|
href.startsWith("http") ||
|
||||||
|
href.startsWith("//") ||
|
||||||
|
href.startsWith("#") ||
|
||||||
|
href.startsWith("mailto:") ||
|
||||||
|
href.startsWith("tel:") ||
|
||||||
|
href.startsWith("javascript:")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolute = makeAbsoluteUrl(href, baseUrl);
|
||||||
|
if (absolute) element.setAttribute("href", absolute);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanHtml(html: string, baseUrl: string, options: CleaningOptions = {}): string {
|
||||||
|
const {
|
||||||
|
removeAds = true,
|
||||||
|
removeBase64Images = true,
|
||||||
|
onlyMainContent = true,
|
||||||
|
includeTags,
|
||||||
|
excludeTags,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
|
||||||
|
removeElements(document, ALWAYS_REMOVE_SELECTORS);
|
||||||
|
removeElements(document, OVERLAY_SELECTORS);
|
||||||
|
|
||||||
|
if (removeAds) {
|
||||||
|
removeElements(document, AD_SELECTORS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (excludeTags?.length) {
|
||||||
|
removeElements(document, excludeTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlyMainContent) {
|
||||||
|
removeWithProtection(document, NAVIGATION_SELECTORS, FORCE_INCLUDE_SELECTORS);
|
||||||
|
|
||||||
|
const mainContent = findMainContent(document);
|
||||||
|
if (mainContent && document.body) {
|
||||||
|
const clone = mainContent.cloneNode(true) as Element;
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
document.body.appendChild(clone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeTags?.length && document.body) {
|
||||||
|
const matchedElements: Element[] = [];
|
||||||
|
|
||||||
|
for (const selector of includeTags) {
|
||||||
|
try {
|
||||||
|
document.querySelectorAll(selector).forEach((element: Element) => {
|
||||||
|
matchedElements.push(element.cloneNode(true) as Element);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid selectors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedElements.length > 0) {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
matchedElements.forEach((element) => document.body?.appendChild(element));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removeBase64Images) {
|
||||||
|
removeBase64ImagesFromDocument(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
const walker = document.createTreeWalker(document, 128);
|
||||||
|
const comments: Node[] = [];
|
||||||
|
while (walker.nextNode()) {
|
||||||
|
comments.push(walker.currentNode);
|
||||||
|
}
|
||||||
|
comments.forEach((comment) => comment.parentNode?.removeChild(comment));
|
||||||
|
|
||||||
|
convertRelativeUrls(document, baseUrl);
|
||||||
|
|
||||||
|
return document.documentElement?.outerHTML || html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanContent(html: string, baseUrl: string, options: CleaningOptions = {}): string {
|
||||||
|
return cleanHtml(html, baseUrl, options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { extractContent } from "./html-to-markdown.js";
|
||||||
|
|
||||||
|
const EMBEDDED_IMAGE_HTML = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<article>
|
||||||
|
<h1>Embedded Image Story</h1>
|
||||||
|
<p>
|
||||||
|
This paragraph is intentionally long enough to satisfy the extractor thresholds so the
|
||||||
|
resulting markdown keeps the main article body and the embedded image reference.
|
||||||
|
</p>
|
||||||
|
<img src="data:image/png;base64,AAAA" alt="inline">
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
test("extractContent preserves base64 images when requested for media download", async () => {
|
||||||
|
const result = await extractContent(EMBEDDED_IMAGE_HTML, "https://example.com/embedded", {
|
||||||
|
preserveBase64Images: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(result.markdown, /!\[inline\]\(data:image\/png;base64,AAAA\)/);
|
||||||
|
});
|
||||||
@@ -13,10 +13,15 @@ import {
|
|||||||
shouldCompareWithLegacy,
|
shouldCompareWithLegacy,
|
||||||
} from "./legacy-converter.js";
|
} from "./legacy-converter.js";
|
||||||
import { tryUrlRuleParsers } from "./parsers/index.js";
|
import { tryUrlRuleParsers } from "./parsers/index.js";
|
||||||
|
import { cleanContent } from "./content-cleaner.js";
|
||||||
|
|
||||||
export type { ConversionResult, PageMetadata };
|
export type { ConversionResult, PageMetadata };
|
||||||
export { createMarkdownDocument, formatMetadataYaml };
|
export { createMarkdownDocument, formatMetadataYaml };
|
||||||
|
|
||||||
|
export interface ExtractContentOptions {
|
||||||
|
preserveBase64Images?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const absolutizeUrlsScript = String.raw`
|
export const absolutizeUrlsScript = String.raw`
|
||||||
(function() {
|
(function() {
|
||||||
const baseUrl = document.baseURI || location.href;
|
const baseUrl = document.baseURI || location.href;
|
||||||
@@ -85,7 +90,10 @@ export const absolutizeUrlsScript = String.raw`
|
|||||||
absAttr(htmlClone, "video[poster]", "poster");
|
absAttr(htmlClone, "video[poster]", "poster");
|
||||||
absSrcset(htmlClone, "img[srcset], source[srcset]");
|
absSrcset(htmlClone, "img[srcset], source[srcset]");
|
||||||
|
|
||||||
return { html: "<!doctype html>\n" + htmlClone.outerHTML };
|
return {
|
||||||
|
html: "<!doctype html>\n" + htmlClone.outerHTML,
|
||||||
|
finalUrl: location.href,
|
||||||
|
};
|
||||||
})()
|
})()
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -102,7 +110,11 @@ function shouldPreferDefuddle(result: ConversionResult): boolean {
|
|||||||
return /^##?\s+transcript\b/im.test(result.markdown);
|
return /^##?\s+transcript\b/im.test(result.markdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function extractContent(html: string, url: string): Promise<ConversionResult> {
|
export async function extractContent(
|
||||||
|
html: string,
|
||||||
|
url: string,
|
||||||
|
options: ExtractContentOptions = {}
|
||||||
|
): Promise<ConversionResult> {
|
||||||
const capturedAt = new Date().toISOString();
|
const capturedAt = new Date().toISOString();
|
||||||
const baseMetadata = extractMetadataFromHtml(html, url, capturedAt);
|
const baseMetadata = extractMetadataFromHtml(html, url, capturedAt);
|
||||||
|
|
||||||
@@ -111,14 +123,23 @@ export async function extractContent(html: string, url: string): Promise<Convers
|
|||||||
return specializedResult;
|
return specializedResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defuddleResult = await tryDefuddleConversion(html, url, baseMetadata);
|
let cleanedHtml = html;
|
||||||
|
try {
|
||||||
|
cleanedHtml = cleanContent(html, url, {
|
||||||
|
removeBase64Images: !options.preserveBase64Images,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
cleanedHtml = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defuddleResult = await tryDefuddleConversion(cleanedHtml, url, baseMetadata);
|
||||||
if (defuddleResult.ok) {
|
if (defuddleResult.ok) {
|
||||||
if (shouldPreferDefuddle(defuddleResult.result)) {
|
if (shouldPreferDefuddle(defuddleResult.result)) {
|
||||||
return defuddleResult.result;
|
return { ...defuddleResult.result, rawHtml: html };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldCompareWithLegacy(defuddleResult.result.markdown)) {
|
if (shouldCompareWithLegacy(defuddleResult.result.markdown)) {
|
||||||
const legacyResult = convertWithLegacyExtractor(html, baseMetadata);
|
const legacyResult = convertWithLegacyExtractor(html, baseMetadata, cleanedHtml);
|
||||||
const legacyScore = scoreMarkdownQuality(legacyResult.markdown);
|
const legacyScore = scoreMarkdownQuality(legacyResult.markdown);
|
||||||
const defuddleScore = scoreMarkdownQuality(defuddleResult.result.markdown);
|
const defuddleScore = scoreMarkdownQuality(defuddleResult.result.markdown);
|
||||||
|
|
||||||
@@ -130,10 +151,10 @@ export async function extractContent(html: string, url: string): Promise<Convers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return defuddleResult.result;
|
return { ...defuddleResult.result, rawHtml: html };
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackResult = convertWithLegacyExtractor(html, baseMetadata);
|
const fallbackResult = convertWithLegacyExtractor(html, baseMetadata, cleanedHtml);
|
||||||
return {
|
return {
|
||||||
...fallbackResult,
|
...fallbackResult,
|
||||||
fallbackReason: defuddleResult.reason,
|
fallbackReason: defuddleResult.reason,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { cleanContent } from "./content-cleaner.js";
|
||||||
|
import { convertWithLegacyExtractor } from "./legacy-converter.js";
|
||||||
|
import { extractMetadataFromHtml } from "./markdown-conversion-shared.js";
|
||||||
|
|
||||||
|
const CAPTURED_AT = "2026-03-24T03:00:00.000Z";
|
||||||
|
|
||||||
|
const NEXT_DATA_HTML = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Hydrated Story</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="cookie-banner">Accept cookies</div>
|
||||||
|
<main>
|
||||||
|
<p>Short teaser text that should not win over the structured article payload.</p>
|
||||||
|
</main>
|
||||||
|
<script id="__NEXT_DATA__" type="application/json">
|
||||||
|
{
|
||||||
|
"props": {
|
||||||
|
"pageProps": {
|
||||||
|
"article": {
|
||||||
|
"title": "Hydrated Story",
|
||||||
|
"description": "A structured article payload from Next.js",
|
||||||
|
"body": "<p>The full article lives in __NEXT_DATA__ and should still be extracted even when the cleaned HTML removes scripts before the selector and readability passes run.</p><p>A second paragraph keeps the content comfortably above the minimum extraction threshold and proves the legacy extractor still has access to the original structured payload.</p>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
test("legacy extractor still uses original __NEXT_DATA__ after HTML cleaning", () => {
|
||||||
|
const url = "https://example.com/posts/hydrated-story";
|
||||||
|
const baseMetadata = extractMetadataFromHtml(NEXT_DATA_HTML, url, CAPTURED_AT);
|
||||||
|
const cleanedHtml = cleanContent(NEXT_DATA_HTML, url);
|
||||||
|
|
||||||
|
const result = convertWithLegacyExtractor(NEXT_DATA_HTML, baseMetadata, cleanedHtml);
|
||||||
|
|
||||||
|
assert.equal(result.conversionMethod, "legacy:next-data");
|
||||||
|
assert.match(result.markdown, /The full article lives in .*NEXT.*DATA/);
|
||||||
|
assert.match(result.markdown, /A second paragraph keeps the content comfortably above the minimum extraction threshold/);
|
||||||
|
assert.doesNotMatch(result.markdown, /Short teaser text that should not win/);
|
||||||
|
assert.equal(result.rawHtml, NEXT_DATA_HTML);
|
||||||
|
});
|
||||||
@@ -336,29 +336,32 @@ function tryNextDataExtraction(document: Document): ExtractionCandidate | null {
|
|||||||
|
|
||||||
function buildReadabilityCandidate(
|
function buildReadabilityCandidate(
|
||||||
article: ReturnType<Readability["parse"]>,
|
article: ReturnType<Readability["parse"]>,
|
||||||
document: Document,
|
referenceDocument: Document,
|
||||||
method: string
|
method: string
|
||||||
): ExtractionCandidate | null {
|
): ExtractionCandidate | null {
|
||||||
const textContent = article?.textContent?.trim() ?? "";
|
const textContent = article?.textContent?.trim() ?? "";
|
||||||
if (textContent.length < MIN_CONTENT_LENGTH) return null;
|
if (textContent.length < MIN_CONTENT_LENGTH) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: pickString(article?.title, extractTitle(document)),
|
title: pickString(article?.title, extractTitle(referenceDocument)),
|
||||||
byline: pickString((article as { byline?: string } | null)?.byline),
|
byline: pickString((article as { byline?: string } | null)?.byline),
|
||||||
excerpt: pickString(article?.excerpt, generateExcerpt(null, textContent)),
|
excerpt: pickString(article?.excerpt, generateExcerpt(null, textContent)),
|
||||||
published: pickString((article as { publishedTime?: string } | null)?.publishedTime, extractPublishedTime(document)),
|
published: pickString(
|
||||||
|
(article as { publishedTime?: string } | null)?.publishedTime,
|
||||||
|
extractPublishedTime(referenceDocument)
|
||||||
|
),
|
||||||
html: article?.content ? sanitizeHtml(article.content) : null,
|
html: article?.content ? sanitizeHtml(article.content) : null,
|
||||||
textContent,
|
textContent,
|
||||||
method,
|
method,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryReadability(document: Document): ExtractionCandidate | null {
|
function tryReadability(document: Document, referenceDocument: Document = document): ExtractionCandidate | null {
|
||||||
try {
|
try {
|
||||||
const strictClone = document.cloneNode(true) as Document;
|
const strictClone = document.cloneNode(true) as Document;
|
||||||
const strictResult = buildReadabilityCandidate(
|
const strictResult = buildReadabilityCandidate(
|
||||||
new Readability(strictClone).parse(),
|
new Readability(strictClone).parse(),
|
||||||
document,
|
referenceDocument,
|
||||||
"readability"
|
"readability"
|
||||||
);
|
);
|
||||||
if (strictResult) return strictResult;
|
if (strictResult) return strictResult;
|
||||||
@@ -366,7 +369,7 @@ function tryReadability(document: Document): ExtractionCandidate | null {
|
|||||||
const relaxedClone = document.cloneNode(true) as Document;
|
const relaxedClone = document.cloneNode(true) as Document;
|
||||||
return buildReadabilityCandidate(
|
return buildReadabilityCandidate(
|
||||||
new Readability(relaxedClone, { charThreshold: 120 }).parse(),
|
new Readability(relaxedClone, { charThreshold: 120 }).parse(),
|
||||||
document,
|
referenceDocument,
|
||||||
"readability-relaxed"
|
"readability-relaxed"
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -471,14 +474,15 @@ function pickBestCandidate(candidates: ExtractionCandidate[]): ExtractionCandida
|
|||||||
return ranked[0];
|
return ranked[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractFromHtml(html: string): ExtractionCandidate | null {
|
function extractFromHtml(html: string, cleanedHtml: string = html): ExtractionCandidate | null {
|
||||||
const document = parseDocument(html);
|
const originalDocument = parseDocument(html);
|
||||||
|
const cleanedDocument = parseDocument(cleanedHtml);
|
||||||
|
|
||||||
const readabilityCandidate = tryReadability(document);
|
const readabilityCandidate = tryReadability(cleanedDocument, originalDocument);
|
||||||
const nextDataCandidate = tryNextDataExtraction(document);
|
const nextDataCandidate = tryNextDataExtraction(originalDocument);
|
||||||
const jsonLdCandidate = tryJsonLdExtraction(document);
|
const jsonLdCandidate = tryJsonLdExtraction(originalDocument);
|
||||||
const selectorCandidate = trySelectorExtraction(document);
|
const selectorCandidate = trySelectorExtraction(cleanedDocument);
|
||||||
const bodyCandidate = tryBodyExtraction(document);
|
const bodyCandidate = tryBodyExtraction(cleanedDocument);
|
||||||
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
readabilityCandidate,
|
readabilityCandidate,
|
||||||
@@ -493,8 +497,8 @@ function extractFromHtml(html: string): ExtractionCandidate | null {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...winner,
|
...winner,
|
||||||
title: winner.title ?? extractTitle(document),
|
title: winner.title ?? extractTitle(originalDocument),
|
||||||
published: winner.published ?? extractPublishedTime(document),
|
published: winner.published ?? extractPublishedTime(originalDocument),
|
||||||
excerpt: winner.excerpt ?? generateExcerpt(null, winner.textContent),
|
excerpt: winner.excerpt ?? generateExcerpt(null, winner.textContent),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -610,12 +614,16 @@ export function shouldCompareWithLegacy(markdown: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertWithLegacyExtractor(html: string, baseMetadata: PageMetadata): ConversionResult {
|
export function convertWithLegacyExtractor(
|
||||||
const extracted = extractFromHtml(html);
|
html: string,
|
||||||
|
baseMetadata: PageMetadata,
|
||||||
|
cleanedHtml: string = html
|
||||||
|
): ConversionResult {
|
||||||
|
const extracted = extractFromHtml(html, cleanedHtml);
|
||||||
|
|
||||||
let markdown = extracted?.html ? convertHtmlFragmentToMarkdown(extracted.html) : "";
|
let markdown = extracted?.html ? convertHtmlFragmentToMarkdown(extracted.html) : "";
|
||||||
if (!markdown.trim()) {
|
if (!markdown.trim()) {
|
||||||
markdown = extracted?.textContent?.trim() || fallbackPlainText(html);
|
markdown = extracted?.textContent?.trim() || fallbackPlainText(cleanedHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -29,10 +29,33 @@ interface Args {
|
|||||||
wait: boolean;
|
wait: boolean;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
downloadMedia: boolean;
|
downloadMedia: boolean;
|
||||||
|
browserMode: BrowserMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BrowserMode = "auto" | "headless" | "headed";
|
||||||
|
|
||||||
|
interface CaptureAttemptOptions {
|
||||||
|
headless: boolean;
|
||||||
|
wait: boolean;
|
||||||
|
existingPort?: number;
|
||||||
|
waitPrompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CaptureSnapshot {
|
||||||
|
html: string;
|
||||||
|
finalUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BROWSER_MODES = new Set<BrowserMode>(["auto", "headless", "headed"]);
|
||||||
|
|
||||||
function parseArgs(argv: string[]): Args {
|
function parseArgs(argv: string[]): Args {
|
||||||
const args: Args = { url: "", wait: false, timeout: DEFAULT_TIMEOUT_MS, downloadMedia: false };
|
const args: Args = {
|
||||||
|
url: "",
|
||||||
|
wait: false,
|
||||||
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
downloadMedia: false,
|
||||||
|
browserMode: "auto",
|
||||||
|
};
|
||||||
for (let i = 2; i < argv.length; i++) {
|
for (let i = 2; i < argv.length; i++) {
|
||||||
const arg = argv[i];
|
const arg = argv[i];
|
||||||
if (arg === "--wait" || arg === "-w") {
|
if (arg === "--wait" || arg === "-w") {
|
||||||
@@ -45,6 +68,12 @@ function parseArgs(argv: string[]): Args {
|
|||||||
args.outputDir = argv[++i];
|
args.outputDir = argv[++i];
|
||||||
} else if (arg === "--download-media") {
|
} else if (arg === "--download-media") {
|
||||||
args.downloadMedia = true;
|
args.downloadMedia = true;
|
||||||
|
} else if (arg === "--browser") {
|
||||||
|
args.browserMode = (argv[++i] as BrowserMode | undefined) ?? "auto";
|
||||||
|
} else if (arg === "--headless") {
|
||||||
|
args.browserMode = "headless";
|
||||||
|
} else if (arg === "--headed" || arg === "--noheadless" || arg === "--no-headless") {
|
||||||
|
args.browserMode = "headed";
|
||||||
} else if (!arg.startsWith("-") && !args.url) {
|
} else if (!arg.startsWith("-") && !args.url) {
|
||||||
args.url = arg;
|
args.url = arg;
|
||||||
}
|
}
|
||||||
@@ -194,21 +223,28 @@ async function generateOutputPath(url: string, title: string, outputDir?: string
|
|||||||
return path.join(dataDir, domain, timestampSlug, `${timestampSlug}.md`);
|
return path.join(dataDir, domain, timestampSlug, `${timestampSlug}.md`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForUserSignal(): Promise<void> {
|
function defaultWaitPrompt(): string {
|
||||||
console.log("Page opened. Press Enter when ready to capture...");
|
return "A browser window has been opened. If the page requires login or verification, complete it first, then press Enter to capture.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForUserSignal(prompt: string): Promise<void> {
|
||||||
|
console.log(prompt);
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
rl.once("line", () => { rl.close(); resolve(); });
|
rl.once("line", () => { rl.close(); resolve(); });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function captureUrl(args: Args): Promise<ConversionResult> {
|
async function captureUrlOnce(args: Args, options: CaptureAttemptOptions): Promise<ConversionResult> {
|
||||||
const existingPort = await findExistingChromePort();
|
const reusing = options.existingPort !== undefined;
|
||||||
const reusing = existingPort !== null;
|
const port = options.existingPort ?? await getFreePort();
|
||||||
const port = existingPort ?? await getFreePort();
|
const chrome = reusing ? null : await launchChrome(args.url, port, options.headless);
|
||||||
const chrome = reusing ? null : await launchChrome(args.url, port, false);
|
|
||||||
|
|
||||||
if (reusing) console.log(`Reusing existing Chrome on port ${port}`);
|
if (reusing) {
|
||||||
|
console.log(`Reusing existing Chrome on port ${port}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Launching Chrome (${options.headless ? "headless" : "headed"})...`);
|
||||||
|
}
|
||||||
|
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
let targetId: string | null = null;
|
let targetId: string | null = null;
|
||||||
@@ -235,8 +271,8 @@ async function captureUrl(args: Args): Promise<ConversionResult> {
|
|||||||
await cdp.send("Page.enable", {}, { sessionId });
|
await cdp.send("Page.enable", {}, { sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.wait) {
|
if (options.wait) {
|
||||||
await waitForUserSignal();
|
await waitForUserSignal(options.waitPrompt ?? defaultWaitPrompt());
|
||||||
} else {
|
} else {
|
||||||
console.log("Waiting for page to load...");
|
console.log("Waiting for page to load...");
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
@@ -251,11 +287,12 @@ async function captureUrl(args: Args): Promise<ConversionResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log("Capturing page content...");
|
console.log("Capturing page content...");
|
||||||
const { html } = await evaluateScript<{ html: string }>(
|
const snapshot = await evaluateScript<CaptureSnapshot>(
|
||||||
cdp, sessionId, absolutizeUrlsScript, args.timeout
|
cdp, sessionId, absolutizeUrlsScript, args.timeout
|
||||||
);
|
);
|
||||||
|
return await extractContent(snapshot.html, snapshot.finalUrl || args.url, {
|
||||||
return await extractContent(html, args.url);
|
preserveBase64Images: args.downloadMedia,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (reusing) {
|
if (reusing) {
|
||||||
if (cdp && targetId) {
|
if (cdp && targetId) {
|
||||||
@@ -272,10 +309,67 @@ async function captureUrl(args: Args): Promise<ConversionResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runHeadedFlow(
|
||||||
|
args: Args,
|
||||||
|
options: { existingPort?: number; wait: boolean; waitPrompt?: string }
|
||||||
|
): Promise<ConversionResult> {
|
||||||
|
return await captureUrlOnce(args, {
|
||||||
|
headless: false,
|
||||||
|
wait: options.wait,
|
||||||
|
existingPort: options.existingPort,
|
||||||
|
waitPrompt: options.waitPrompt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureUrl(args: Args): Promise<ConversionResult> {
|
||||||
|
const existingPort = await findExistingChromePort();
|
||||||
|
if (existingPort !== null) {
|
||||||
|
console.log("Found an existing Chrome session for this profile. Reusing it instead of launching a new browser.");
|
||||||
|
return await runHeadedFlow(args, {
|
||||||
|
existingPort,
|
||||||
|
wait: args.wait,
|
||||||
|
waitPrompt: args.wait ? defaultWaitPrompt() : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.browserMode === "headless") {
|
||||||
|
return await captureUrlOnce(args, { headless: true, wait: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.browserMode === "headed") {
|
||||||
|
return await runHeadedFlow(args, {
|
||||||
|
wait: args.wait,
|
||||||
|
waitPrompt: args.wait ? defaultWaitPrompt() : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.wait) {
|
||||||
|
return await runHeadedFlow(args, {
|
||||||
|
wait: true,
|
||||||
|
waitPrompt: defaultWaitPrompt(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await captureUrlOnce(args, { headless: true, wait: false });
|
||||||
|
} catch (error) {
|
||||||
|
const headlessMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn(`Headless capture failed: ${headlessMessage}`);
|
||||||
|
console.log("Retrying with a visible browser window...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await runHeadedFlow(args, { wait: false });
|
||||||
|
} catch (headedError) {
|
||||||
|
const headedMessage = headedError instanceof Error ? headedError.message : String(headedError);
|
||||||
|
throw new Error(`Headless capture failed (${headlessMessage}); headed retry failed (${headedMessage})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv);
|
const args = parseArgs(process.argv);
|
||||||
if (!args.url) {
|
if (!args.url) {
|
||||||
console.error("Usage: bun main.ts <url> [-o output.md] [--output-dir dir] [--wait] [--timeout ms] [--download-media]");
|
console.error("Usage: bun main.ts <url> [-o output.md] [--output-dir dir] [--wait] [--browser auto|headless|headed] [--timeout ms] [--download-media]");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +380,16 @@ async function main(): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!BROWSER_MODES.has(args.browserMode)) {
|
||||||
|
console.error(`Invalid --browser mode: ${args.browserMode}. Expected auto, headless, or headed.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.wait && args.browserMode === "headless") {
|
||||||
|
console.error("Error: --wait requires a visible browser. Use --browser auto or --browser headed.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (args.output) {
|
if (args.output) {
|
||||||
const stat = await import("node:fs").then(fs => fs.statSync(args.output!, { throwIfNoEntry: false }));
|
const stat = await import("node:fs").then(fs => fs.statSync(args.output!, { throwIfNoEntry: false }));
|
||||||
if (stat?.isDirectory()) {
|
if (stat?.isDirectory()) {
|
||||||
@@ -296,6 +400,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
console.log(`Fetching: ${args.url}`);
|
console.log(`Fetching: ${args.url}`);
|
||||||
console.log(`Mode: ${args.wait ? "wait" : "auto"}`);
|
console.log(`Mode: ${args.wait ? "wait" : "auto"}`);
|
||||||
|
console.log(`Browser: ${args.browserMode}`);
|
||||||
|
|
||||||
let outputPath: string;
|
let outputPath: string;
|
||||||
let htmlSnapshotPath: string | null = null;
|
let htmlSnapshotPath: string | null = null;
|
||||||
@@ -306,7 +411,7 @@ async function main(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const result = await captureUrl(args);
|
const result = await captureUrl(args);
|
||||||
document = createMarkdownDocument(result);
|
document = createMarkdownDocument(result);
|
||||||
outputPath = args.output || await generateOutputPath(args.url, result.metadata.title, args.outputDir, document);
|
outputPath = args.output || await generateOutputPath(result.metadata.url || args.url, result.metadata.title, args.outputDir, document);
|
||||||
const outputDir = path.dirname(outputPath);
|
const outputDir = path.dirname(outputPath);
|
||||||
htmlSnapshotPath = deriveHtmlSnapshotPath(outputPath);
|
htmlSnapshotPath = deriveHtmlSnapshotPath(outputPath);
|
||||||
await mkdir(outputDir, { recursive: true });
|
await mkdir(outputDir, { recursive: true });
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, readFile, readdir } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { localizeMarkdownMedia } from "./media-localizer.js";
|
||||||
|
|
||||||
|
const PNG_1X1_BASE64 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0ioAAAAASUVORK5CYII=";
|
||||||
|
|
||||||
|
test("localizeMarkdownMedia saves embedded base64 images into imgs directory", async () => {
|
||||||
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "url-to-markdown-media-"));
|
||||||
|
const dataUri = `data:image/png;base64,${PNG_1X1_BASE64}`;
|
||||||
|
const markdown = [
|
||||||
|
"---",
|
||||||
|
`coverImage: "${dataUri}"`,
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"# Embedded Image",
|
||||||
|
"",
|
||||||
|
``,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const result = await localizeMarkdownMedia(markdown, {
|
||||||
|
markdownPath: path.join(tempDir, "post.md"),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.downloadedImages, 1);
|
||||||
|
assert.equal(result.downloadedVideos, 0);
|
||||||
|
assert.match(result.markdown, /coverImage: "imgs\/img-001\.png"/);
|
||||||
|
assert.match(result.markdown, /!\[inline\]\(imgs\/img-001\.png\)/);
|
||||||
|
|
||||||
|
const files = await readdir(path.join(tempDir, "imgs"));
|
||||||
|
assert.deepEqual(files, ["img-001.png"]);
|
||||||
|
|
||||||
|
const bytes = await readFile(path.join(tempDir, "imgs", "img-001.png"));
|
||||||
|
assert.equal(bytes.length, Buffer.from(PNG_1X1_BASE64, "base64").length);
|
||||||
|
});
|
||||||
@@ -3,10 +3,12 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|||||||
|
|
||||||
type MediaKind = "image" | "video";
|
type MediaKind = "image" | "video";
|
||||||
type MediaHint = "image" | "unknown";
|
type MediaHint = "image" | "unknown";
|
||||||
|
type MediaSource = "remote" | "data";
|
||||||
|
|
||||||
type MarkdownLinkCandidate = {
|
type MarkdownLinkCandidate = {
|
||||||
url: string;
|
url: string;
|
||||||
hint: MediaHint;
|
hint: MediaHint;
|
||||||
|
source: MediaSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LocalizeMarkdownMediaOptions = {
|
export type LocalizeMarkdownMediaOptions = {
|
||||||
@@ -22,8 +24,9 @@ export type LocalizeMarkdownMediaResult = {
|
|||||||
videoDir: string | null;
|
videoDir: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MARKDOWN_LINK_RE = /(!?\[[^\]\n]*\])\((<)?(https?:\/\/[^)\s>]+)(>)?\)/g;
|
const MARKDOWN_LINK_RE =
|
||||||
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")(https?:\/\/[^"]+)(")/m;
|
/(!?\[[^\]\n]*\])\((<)?((?:https?:\/\/[^)\s>]+)|(?:data:[^)>\s]+))(>)?\)/g;
|
||||||
|
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")((?:https?:\/\/[^"]+)|(?:data:[^"]+))(")/m;
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set([
|
const IMAGE_EXTENSIONS = new Set([
|
||||||
"jpg",
|
"jpg",
|
||||||
@@ -86,6 +89,10 @@ function resolveExtensionFromUrl(rawUrl: string): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveExtensionFromContentType(contentType: string): string | undefined {
|
||||||
|
return normalizeExtension(MIME_EXTENSION_MAP[contentType]);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveKindFromContentType(contentType: string): MediaKind | undefined {
|
function resolveKindFromContentType(contentType: string): MediaKind | undefined {
|
||||||
if (!contentType) return undefined;
|
if (!contentType) return undefined;
|
||||||
if (contentType.startsWith("image/")) return "image";
|
if (contentType.startsWith("image/")) return "image";
|
||||||
@@ -124,7 +131,7 @@ function resolveOutputExtension(
|
|||||||
extension: string | undefined,
|
extension: string | undefined,
|
||||||
kind: MediaKind
|
kind: MediaKind
|
||||||
): string {
|
): string {
|
||||||
const extFromMime = normalizeExtension(MIME_EXTENSION_MAP[contentType]);
|
const extFromMime = resolveExtensionFromContentType(contentType);
|
||||||
if (extFromMime) return extFromMime;
|
if (extFromMime) return extFromMime;
|
||||||
|
|
||||||
const normalizedExt = normalizeExtension(extension);
|
const normalizedExt = normalizeExtension(extension);
|
||||||
@@ -150,6 +157,10 @@ function sanitizeFileSegment(input: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveFileStem(rawUrl: string, extension: string): string {
|
function resolveFileStem(rawUrl: string, extension: string): string {
|
||||||
|
if (isDataUri(rawUrl)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(rawUrl);
|
const parsed = new URL(rawUrl);
|
||||||
const base = path.posix.basename(parsed.pathname);
|
const base = path.posix.basename(parsed.pathname);
|
||||||
@@ -172,6 +183,26 @@ function buildFileName(kind: MediaKind, index: number, sourceUrl: string, extens
|
|||||||
return `${prefix}-${serial}${suffix}.${extension}`;
|
return `${prefix}-${serial}${suffix}.${extension}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDataUri(value: string): boolean {
|
||||||
|
return value.startsWith("data:");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBase64DataUri(rawUrl: string): { contentType: string; bytes: Buffer } | null {
|
||||||
|
const match = rawUrl.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i);
|
||||||
|
if (!match?.[1] || !match[2]) return null;
|
||||||
|
|
||||||
|
const contentType = normalizeContentType(match[1]);
|
||||||
|
if (!contentType) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = Buffer.from(match[2].replace(/\s+/g, ""), "base64");
|
||||||
|
if (bytes.length === 0) return null;
|
||||||
|
return { contentType, bytes };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
|
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
|
||||||
const candidates: MarkdownLinkCandidate[] = [];
|
const candidates: MarkdownLinkCandidate[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -181,7 +212,11 @@ function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[
|
|||||||
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
|
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
|
||||||
if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
|
if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
|
||||||
seen.add(coverMatch[2]);
|
seen.add(coverMatch[2]);
|
||||||
candidates.push({ url: coverMatch[2], hint: "image" });
|
candidates.push({
|
||||||
|
url: coverMatch[2],
|
||||||
|
hint: "image",
|
||||||
|
source: isDataUri(coverMatch[2]) ? "data" : "remote",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +230,7 @@ function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[
|
|||||||
candidates.push({
|
candidates.push({
|
||||||
url: rawUrl,
|
url: rawUrl,
|
||||||
hint: label.startsWith("![") ? "image" : "unknown",
|
hint: label.startsWith("![") ? "image" : "unknown",
|
||||||
|
source: isDataUri(rawUrl) ? "data" : "remote",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,24 +280,45 @@ export async function localizeMarkdownMedia(
|
|||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(candidate.url, {
|
let sourceUrl = candidate.url;
|
||||||
method: "GET",
|
let contentType = "";
|
||||||
redirect: "follow",
|
let extension: string | undefined;
|
||||||
headers: {
|
let kind: MediaKind | undefined;
|
||||||
"user-agent": DOWNLOAD_USER_AGENT,
|
let bytes: Buffer | null = null;
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (candidate.source === "data") {
|
||||||
log(`[url-to-markdown] Skip media (${response.status}): ${candidate.url}`);
|
const parsed = parseBase64DataUri(candidate.url);
|
||||||
continue;
|
if (!parsed) {
|
||||||
|
log("[url-to-markdown] Skip embedded media: unsupported or invalid data URI");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType = parsed.contentType;
|
||||||
|
extension = resolveExtensionFromContentType(contentType);
|
||||||
|
kind = resolveMediaKind(sourceUrl, contentType, extension, candidate.hint);
|
||||||
|
bytes = parsed.bytes;
|
||||||
|
} else {
|
||||||
|
const response = await fetch(candidate.url, {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "follow",
|
||||||
|
headers: {
|
||||||
|
"user-agent": DOWNLOAD_USER_AGENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
log(`[url-to-markdown] Skip media (${response.status}): ${candidate.url}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceUrl = response.url || candidate.url;
|
||||||
|
contentType = normalizeContentType(response.headers.get("content-type"));
|
||||||
|
extension = resolveExtensionFromUrl(sourceUrl) ?? resolveExtensionFromUrl(candidate.url);
|
||||||
|
kind = resolveMediaKind(sourceUrl, contentType, extension, candidate.hint);
|
||||||
|
bytes = Buffer.from(await response.arrayBuffer());
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceUrl = response.url || candidate.url;
|
if (!kind || !bytes) {
|
||||||
const contentType = normalizeContentType(response.headers.get("content-type"));
|
|
||||||
const extension = resolveExtensionFromUrl(sourceUrl) ?? resolveExtensionFromUrl(candidate.url);
|
|
||||||
const kind = resolveMediaKind(sourceUrl, contentType, extension, candidate.hint);
|
|
||||||
if (!kind) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +331,6 @@ export async function localizeMarkdownMedia(
|
|||||||
const fileName = buildFileName(kind, nextIndex, sourceUrl, outputExtension);
|
const fileName = buildFileName(kind, nextIndex, sourceUrl, outputExtension);
|
||||||
const absolutePath = path.join(targetDir, fileName);
|
const absolutePath = path.join(targetDir, fileName);
|
||||||
const relativePath = path.posix.join(dirName, fileName);
|
const relativePath = path.posix.join(dirName, fileName);
|
||||||
const bytes = Buffer.from(await response.arrayBuffer());
|
|
||||||
await writeFile(absolutePath, bytes);
|
await writeFile(absolutePath, bytes);
|
||||||
replacements.set(candidate.url, relativePath);
|
replacements.set(candidate.url, relativePath);
|
||||||
|
|
||||||
@@ -305,6 +361,7 @@ export function countRemoteMedia(markdown: string): { images: number; videos: nu
|
|||||||
let images = 0;
|
let images = 0;
|
||||||
let videos = 0;
|
let videos = 0;
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
|
if (c.source !== "remote") continue;
|
||||||
const ext = resolveExtensionFromUrl(c.url);
|
const ext = resolveExtensionFromUrl(c.url);
|
||||||
const kind = resolveKindFromExtension(ext);
|
const kind = resolveKindFromExtension(ext);
|
||||||
if (kind === "video") {
|
if (kind === "video") {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||||
"defuddle": "^0.12.0",
|
"defuddle": "^0.14.0",
|
||||||
"jsdom": "^24.1.3",
|
"jsdom": "^24.1.3",
|
||||||
"linkedom": "^0.18.12",
|
"linkedom": "^0.18.12",
|
||||||
"turndown": "^7.2.2",
|
"turndown": "^7.2.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user