mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-21 09:49:48 +08:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cd709b9e7 | |||
| aaf0f188dd | |||
| b6bf8ecd06 | |||
| 7a0ffd9533 | |||
| 69355b4ee1 | |||
| 23b7487321 | |||
| ad8781c1c5 | |||
| 86a3d6521b | |||
| e99ce744cd | |||
| 40f9f05c22 | |||
| 09ce80357f | |||
| 7c995fcc24 | |||
| 151f1ec2a8 | |||
| 12e207dc3f | |||
| 00e74ab071 | |||
| 1653b8544b | |||
| dad8f3a800 | |||
| 35298d7c9d | |||
| f22374ab62 | |||
| d4e80b1bc3 | |||
| a5761dc71a | |||
| a5189dff37 | |||
| 39fe872bf3 | |||
| 52813504f8 | |||
| a4d4108cd1 | |||
| d7e763f1f5 | |||
| 097c09c59b | |||
| e4cd8bfefc | |||
| 3dc5f2e06f | |||
| e5d6c8ec68 | |||
| 6a4b312146 | |||
| 2d6fe533eb | |||
| f53af25e65 | |||
| c7e32b4590 | |||
| 8d973f2bc5 | |||
| ba20cf89f2 | |||
| 1827be9234 | |||
| 93c98dfc3c | |||
| fbd9f9b622 | |||
| b6e293d059 | |||
| bb78aab095 | |||
| 5071a1d0d0 | |||
| e413ade164 | |||
| e52f92b193 | |||
| 603cabaef4 | |||
| 7d12526e90 | |||
| e7f9764a49 | |||
| e55f91b0ea | |||
| fe3b3d9125 | |||
| 105339cf3f | |||
| dcfd9033ae | |||
| eb416d174c | |||
| 83afacb00e | |||
| 0e6bfbcabd | |||
| 7b67ff44b8 | |||
| 79c289ca92 | |||
| fc5ad4b762 | |||
| 747977416d | |||
| e43eec260a | |||
| 96ef6e2251 | |||
| efb7a1917a | |||
| 1af984a64f | |||
| 60363fc2df | |||
| 28ec1053f6 | |||
| 5a6afd576f | |||
| ebc74a10ad | |||
| e79a42fd94 | |||
| ea84f21439 | |||
| 0b9e51d6cc | |||
| 7cc9c92722 | |||
| fc2f0d042a |
@@ -6,48 +6,34 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.70.0"
|
"version": "1.85.0"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "content-skills",
|
"name": "baoyu-skills",
|
||||||
"description": "Content generation and publishing skills",
|
"description": "Content generation, AI backends, and utility tools for daily work efficiency",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skills": [
|
"skills": [
|
||||||
"./skills/baoyu-xhs-images",
|
|
||||||
"./skills/baoyu-post-to-x",
|
|
||||||
"./skills/baoyu-post-to-wechat",
|
|
||||||
"./skills/baoyu-post-to-weibo",
|
|
||||||
"./skills/baoyu-article-illustrator",
|
"./skills/baoyu-article-illustrator",
|
||||||
"./skills/baoyu-cover-image",
|
|
||||||
"./skills/baoyu-slide-deck",
|
|
||||||
"./skills/baoyu-comic",
|
"./skills/baoyu-comic",
|
||||||
"./skills/baoyu-infographic"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ai-generation-skills",
|
|
||||||
"description": "AI-powered generation backends",
|
|
||||||
"source": "./",
|
|
||||||
"strict": true,
|
|
||||||
"skills": [
|
|
||||||
"./skills/baoyu-danger-gemini-web",
|
|
||||||
"./skills/baoyu-image-gen"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "utility-skills",
|
|
||||||
"description": "Utility tools for content processing",
|
|
||||||
"source": "./",
|
|
||||||
"strict": true,
|
|
||||||
"skills": [
|
|
||||||
"./skills/baoyu-danger-x-to-markdown",
|
|
||||||
"./skills/baoyu-compress-image",
|
"./skills/baoyu-compress-image",
|
||||||
"./skills/baoyu-url-to-markdown",
|
"./skills/baoyu-cover-image",
|
||||||
|
"./skills/baoyu-danger-gemini-web",
|
||||||
|
"./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-markdown-to-html",
|
"./skills/baoyu-markdown-to-html",
|
||||||
"./skills/baoyu-translate"
|
"./skills/baoyu-post-to-weibo",
|
||||||
|
"./skills/baoyu-post-to-wechat",
|
||||||
|
"./skills/baoyu-post-to-x",
|
||||||
|
"./skills/baoyu-slide-deck",
|
||||||
|
"./skills/baoyu-translate",
|
||||||
|
"./skills/baoyu-url-to-markdown",
|
||||||
|
"./skills/baoyu-xhs-images",
|
||||||
|
"./skills/baoyu-youtube-transcript"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -166,3 +166,4 @@ posts/
|
|||||||
.clawdhub/
|
.clawdhub/
|
||||||
.release-artifacts/
|
.release-artifacts/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
youtube-transcript/
|
||||||
|
|||||||
+157
@@ -2,6 +2,163 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.85.0 - 2026-03-25
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-imagine`: auto-migrate legacy `baoyu-image-gen` EXTEND.md config path at runtime
|
||||||
|
- Add `baoyu-image-gen` deprecation redirect skill to guide users to install `baoyu-imagine` and remove the old skill
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-youtube-transcript`: add yt-dlp fallback when YouTube blocks direct InnerTube API, with alternate client identity retry and cookie support via `YOUTUBE_TRANSCRIPT_COOKIES_FROM_BROWSER` env var
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
- `baoyu-youtube-transcript`: split monolithic script into typed modules (youtube, transcript, storage, shared, types) and add unit tests
|
||||||
|
|
||||||
|
## 1.80.1 - 2026-03-24
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-image-gen`: use correct `prompt` field name for Jimeng API request
|
||||||
|
|
||||||
|
## 1.80.0 - 2026-03-24
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-image-gen`: add Azure OpenAI as independent image generation provider with flexible endpoint parsing, deployment-name resolution, quality mapping, and reference image validation
|
||||||
|
|
||||||
|
## 1.79.2 - 2026-03-23
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-cover-image`: simplify reference image handling — use `--ref` when model supports it, only create description files for models without reference image support
|
||||||
|
- `baoyu-post-to-weibo`: add no-theme rule for article markdown-to-HTML conversion
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- Fix Node-compatible parser tests and add parser test dependencies
|
||||||
|
|
||||||
|
## 1.79.1 - 2026-03-23
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Consolidate to single plugin to prevent duplicate skill registration (by @TyrealQ)
|
||||||
|
- `baoyu-article-illustrator`: remove opacity parameter from watermark prompt
|
||||||
|
- `baoyu-comic`: fix Doraemon naming spacing and remove opacity from watermark prompt
|
||||||
|
- `baoyu-xhs-images`: remove opacity from watermark prompt and fix CJK spacing
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Update project documentation to reflect single-plugin architecture
|
||||||
|
|
||||||
|
## 1.79.0 - 2026-03-22
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-post-to-wechat`: improve credential loading with multi-source resolution, priority ordering, and diagnostics for skipped incomplete sources
|
||||||
|
|
||||||
|
## 1.78.0 - 2026-03-22
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-url-to-markdown`: add URL-specific parser layer for X/Twitter and archive.ph sites
|
||||||
|
- `baoyu-url-to-markdown`: improved slug generation with stop words removal and subdirectory output structure
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-url-to-markdown`: preserve anchor elements containing media in legacy converter
|
||||||
|
- `baoyu-url-to-markdown`: smarter title deduplication to avoid redundant headings
|
||||||
|
|
||||||
|
## 1.77.0 - 2026-03-22
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-youtube-transcript`: add end times to chapter data (by @jzOcb)
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `sync-clawhub`: skip failed skills instead of aborting
|
||||||
|
|
||||||
|
## 1.76.1 - 2026-03-21
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `baoyu-youtube-transcript`: fix zsh glob issue — always single-quote YouTube URLs when running the script
|
||||||
|
|
||||||
|
## 1.76.0 - 2026-03-21
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-youtube-transcript`: add title heading, description summary, and cover image to markdown output
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-markdown-to-html`: use process.execPath and tsx import in test runner
|
||||||
|
|
||||||
|
## 1.75.0 - 2026-03-21
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-youtube-transcript`: new skill — download YouTube video transcripts/subtitles and cover images with multi-language, chapters, and speaker identification support
|
||||||
|
|
||||||
|
## 1.74.1 - 2026-03-21
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-image-gen`: align OpenRouter image generation with current API, harden image support, and narrow Gemini aspect ratios (by @cwandev)
|
||||||
|
- `baoyu-image-gen`: broaden OpenRouter model detection and aspect ratio validation
|
||||||
|
|
||||||
|
## 1.74.0 - 2026-03-20
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-markdown-to-html`: CLI now supports all rendering options — color, font-family, font-size, code-theme, mac-code-block, line-number, count, legend
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-markdown-to-html`: fix CSS custom property regex to handle quoted values; grace/simple themes now layer default CSS
|
||||||
|
|
||||||
|
## 1.73.3 - 2026-03-20
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-post-to-wechat`: fix placeholder replacement to avoid shorter placeholders matching longer numbered variants
|
||||||
|
|
||||||
|
## 1.73.2 - 2026-03-20
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-post-to-wechat`: fix body image upload to correctly use media/uploadimg API with format and size validation (by @AICreator-Wind)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
- `baoyu-post-to-wechat`: extract image processor module for local format conversion (WebP/BMP/GIF → JPEG/PNG) instead of material API fallback
|
||||||
|
|
||||||
|
## 1.73.1 - 2026-03-18
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
- `baoyu-danger-x-to-markdown`: migrate tests from bun:test to node:test
|
||||||
|
|
||||||
|
## 1.73.0 - 2026-03-18
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-danger-x-to-markdown`: add video media support for X articles with poster image and video link rendering
|
||||||
|
|
||||||
|
## 1.72.0 - 2026-03-18
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-danger-x-to-markdown`: add MARKDOWN entity support for rendering embedded markdown/code blocks in X articles
|
||||||
|
|
||||||
|
## 1.71.0 - 2026-03-17
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-image-gen`: add Seedream reference image support for 5.0/4.5/4.0 models with model-specific size validation
|
||||||
|
|
||||||
## 1.70.0 - 2026-03-17
|
## 1.70.0 - 2026-03-17
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+157
@@ -2,6 +2,163 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.85.0 - 2026-03-25
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-imagine`:运行时自动迁移旧版 `baoyu-image-gen` 的 EXTEND.md 配置路径
|
||||||
|
- 新增 `baoyu-image-gen` 废弃重定向技能,引导用户安装 `baoyu-imagine` 并移除旧技能
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-youtube-transcript`:YouTube 封锁直连 InnerTube API 时自动回退到 yt-dlp,支持备用客户端身份重试及通过 `YOUTUBE_TRANSCRIPT_COOKIES_FROM_BROWSER` 环境变量传递浏览器 Cookie
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
- `baoyu-youtube-transcript`:将单体脚本拆分为类型化模块(youtube、transcript、storage、shared、types)并添加单元测试
|
||||||
|
|
||||||
|
## 1.80.1 - 2026-03-24
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-image-gen`:修正即梦 API 请求中的 `prompt` 字段名
|
||||||
|
|
||||||
|
## 1.80.0 - 2026-03-24
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-image-gen`:新增 Azure OpenAI 作为独立图像生成服务商,支持灵活的端点解析、部署名称推断、质量映射及参考图片格式校验
|
||||||
|
|
||||||
|
## 1.79.2 - 2026-03-23
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-cover-image`:简化参考图片处理流程 — 模型支持 `--ref` 时直接传递,仅在模型不支持参考图时创建描述文件
|
||||||
|
- `baoyu-post-to-weibo`:文章 Markdown 转 HTML 时不传递 --theme 参数
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
- 修复 Node 兼容的解析器测试,添加解析器测试依赖
|
||||||
|
|
||||||
|
## 1.79.1 - 2026-03-23
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- 合并为单一插件,防止 skill 重复注册 (by @TyrealQ)
|
||||||
|
- `baoyu-article-illustrator`:移除水印提示词中的不透明度参数
|
||||||
|
- `baoyu-comic`:修正哆啦 A 梦命名间距,移除水印不透明度参数
|
||||||
|
- `baoyu-xhs-images`:移除水印不透明度参数,修正中英文间距
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- 更新项目文档以反映单一插件架构
|
||||||
|
|
||||||
|
## 1.79.0 - 2026-03-22
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-post-to-wechat`:改进凭据加载机制,支持多来源优先级解析,并提供不完整凭据来源的诊断信息
|
||||||
|
|
||||||
|
## 1.78.0 - 2026-03-22
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-url-to-markdown`:新增 URL 专用解析层,支持 X/Twitter 和 archive.ph 站点的定制化 HTML 提取
|
||||||
|
- `baoyu-url-to-markdown`:改进 slug 生成算法,去除停用词并采用子目录输出结构
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-url-to-markdown`:旧版转换器保留包含媒体元素的锚标签
|
||||||
|
- `baoyu-url-to-markdown`:更智能的标题去重,避免重复添加标题
|
||||||
|
|
||||||
|
## 1.77.0 - 2026-03-22
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-youtube-transcript`:为章节数据添加结束时间 (by @jzOcb)
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `sync-clawhub`:跳过失败的技能而不是中止同步
|
||||||
|
|
||||||
|
## 1.76.1 - 2026-03-21
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- `baoyu-youtube-transcript`:修复 zsh glob 问题 — 运行脚本时始终对 YouTube URL 使用单引号
|
||||||
|
|
||||||
|
## 1.76.0 - 2026-03-21
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-youtube-transcript`:Markdown 输出中新增标题、描述摘要和封面图片
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-markdown-to-html`:测试运行器改用 process.execPath 和 tsx import
|
||||||
|
|
||||||
|
## 1.75.0 - 2026-03-21
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-youtube-transcript`:新技能 — 下载 YouTube 视频字幕/转录文本和封面图片,支持多语言、章节分段和说话人识别
|
||||||
|
|
||||||
|
## 1.74.1 - 2026-03-21
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-image-gen`:对齐 OpenRouter 图像生成与当前 API,增强图像支持,收窄 Gemini 宽高比范围 (by @cwandev)
|
||||||
|
- `baoyu-image-gen`:扩展 OpenRouter 模型检测和宽高比验证
|
||||||
|
|
||||||
|
## 1.74.0 - 2026-03-20
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-markdown-to-html`:CLI 支持全部渲染选项 — color、font-family、font-size、code-theme、mac-code-block、line-number、count、legend
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-markdown-to-html`:修复 CSS 自定义属性正则无法处理带引号值的问题;grace/simple 主题现在会叠加 default 主题 CSS
|
||||||
|
|
||||||
|
## 1.73.3 - 2026-03-20
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-post-to-wechat`:修复占位符替换时短占位符错误匹配更长编号变体的问题
|
||||||
|
|
||||||
|
## 1.73.2 - 2026-03-20
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-post-to-wechat`:修复正文图片上传,正确使用 media/uploadimg 接口并处理格式和大小限制 (by @AICreator-Wind)
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
- `baoyu-post-to-wechat`:提取图片处理模块,本地转换不支持的格式(WebP/BMP/GIF → JPEG/PNG)而非回退到 material 接口
|
||||||
|
|
||||||
|
## 1.73.1 - 2026-03-18
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
- `baoyu-danger-x-to-markdown`:测试从 bun:test 迁移至 node:test
|
||||||
|
|
||||||
|
## 1.73.0 - 2026-03-18
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-danger-x-to-markdown`:支持 X 文章中的视频媒体,渲染封面图和视频链接
|
||||||
|
|
||||||
|
## 1.72.0 - 2026-03-18
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-danger-x-to-markdown`:支持渲染 X 文章中嵌入的 MARKDOWN 实体(代码块等)
|
||||||
|
|
||||||
|
## 1.71.0 - 2026-03-17
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-image-gen`:为 Seedream 5.0/4.5/4.0 模型添加参考图支持,并增加模型特定的尺寸校验
|
||||||
|
|
||||||
## 1.70.0 - 2026-03-17
|
## 1.70.0 - 2026-03-17
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.70.0**.
|
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.84.0**.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Skills organized into three categories in `.claude-plugin/marketplace.json` (defines plugin metadata, version, and skill paths):
|
Skills are exposed through the single `baoyu-skills` plugin in `.claude-plugin/marketplace.json` (which defines plugin metadata, version, and skill paths). The repo docs still group them into three logical areas:
|
||||||
|
|
||||||
| Category | Description |
|
| Group | Description |
|
||||||
|----------|-------------|
|
|-------|-------------|
|
||||||
| `content-skills` | Generate or publish content (images, slides, comics, posts) |
|
| Content Skills | Generate or publish content (images, slides, comics, posts) |
|
||||||
| `ai-generation-skills` | AI generation backends |
|
| AI Generation Skills | AI generation backends |
|
||||||
| `utility-skills` | Content processing (conversion, compression, translation) |
|
| Utility Skills | Content processing (conversion, compression, translation) |
|
||||||
|
|
||||||
Each skill contains `SKILL.md` (YAML front matter + docs), optional `scripts/`, `references/`, `prompts/`.
|
Each skill contains `SKILL.md` (YAML front matter + docs), optional `scripts/`, `references/`, `prompts/`.
|
||||||
|
|
||||||
@@ -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, 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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -52,16 +52,14 @@ Run the following command in Claude Code:
|
|||||||
|
|
||||||
1. Select **Browse and install plugins**
|
1. Select **Browse and install plugins**
|
||||||
2. Select **baoyu-skills**
|
2. Select **baoyu-skills**
|
||||||
3. Select the plugin(s) you want to install
|
3. Select the **baoyu-skills** plugin
|
||||||
4. Select **Install now**
|
4. Select **Install now**
|
||||||
|
|
||||||
**Option 2: Direct Install**
|
**Option 2: Direct Install**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install specific plugin
|
# Install the marketplace's single plugin
|
||||||
/plugin install content-skills@baoyu-skills
|
/plugin install baoyu-skills@baoyu-skills
|
||||||
/plugin install ai-generation-skills@baoyu-skills
|
|
||||||
/plugin install utility-skills@baoyu-skills
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option 3: Ask the Agent**
|
**Option 3: Ask the Agent**
|
||||||
@@ -70,13 +68,13 @@ Simply tell Claude Code:
|
|||||||
|
|
||||||
> Please install Skills from github.com/JimLiu/baoyu-skills
|
> Please install Skills from github.com/JimLiu/baoyu-skills
|
||||||
|
|
||||||
### Available Plugins
|
### Available Plugin
|
||||||
|
|
||||||
| Plugin | Description | Skills |
|
The marketplace now exposes a single plugin so each skill is registered exactly once.
|
||||||
|--------|-------------|--------|
|
|
||||||
| **content-skills** | Content generation and publishing | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
| Plugin | Description | Includes |
|
||||||
| **ai-generation-skills** | AI-powered generation backends | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
|--------|-------------|----------|
|
||||||
| **utility-skills** | Utility tools for content processing | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) |
|
| **baoyu-skills** | Content generation, AI backends, and utility tools for daily work efficiency | All skills in this repository, organized below as Content Skills, AI Generation Skills, and Utility Skills |
|
||||||
|
|
||||||
## Update Skills
|
## Update Skills
|
||||||
|
|
||||||
@@ -663,40 +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, 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)
|
||||||
|
/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, OpenRouter, or Replicate)
|
# 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**:
|
||||||
@@ -705,44 +721,73 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
|||||||
| `--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 or Replicate) |
|
| `--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
|
||||||
|
|
||||||
@@ -766,6 +811,40 @@ Interacts with Gemini Web to generate text and images.
|
|||||||
|
|
||||||
Utility tools for content processing.
|
Utility tools for content processing.
|
||||||
|
|
||||||
|
#### baoyu-youtube-transcript
|
||||||
|
|
||||||
|
Download YouTube video transcripts/subtitles and cover images. Supports multiple languages, translation, chapters, and speaker identification. Caches raw data for fast re-formatting.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default: markdown with timestamps
|
||||||
|
/baoyu-youtube-transcript https://www.youtube.com/watch?v=VIDEO_ID
|
||||||
|
|
||||||
|
# Specify languages (priority order)
|
||||||
|
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --languages zh,en,ja
|
||||||
|
|
||||||
|
# With chapters and speaker identification
|
||||||
|
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --chapters --speakers
|
||||||
|
|
||||||
|
# SRT subtitle format
|
||||||
|
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --format srt
|
||||||
|
|
||||||
|
# List available transcripts
|
||||||
|
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Options**:
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `<url-or-id>` | YouTube URL or video ID | Required |
|
||||||
|
| `--languages <codes>` | Language codes, comma-separated | `en` |
|
||||||
|
| `--format <fmt>` | Output format: `text`, `srt` | `text` |
|
||||||
|
| `--translate <code>` | Translate to specified language | |
|
||||||
|
| `--chapters` | Chapter segmentation from video description | |
|
||||||
|
| `--speakers` | Speaker identification (requires AI post-processing) | |
|
||||||
|
| `--no-timestamps` | Disable timestamps | |
|
||||||
|
| `--list` | List available transcripts | |
|
||||||
|
| `--refresh` | Force re-fetch, ignore cache | |
|
||||||
|
|
||||||
#### baoyu-url-to-markdown
|
#### baoyu-url-to-markdown
|
||||||
|
|
||||||
Fetch any URL via Chrome CDP and convert to clean markdown. Saves rendered HTML snapshot alongside the markdown, and automatically falls back to a legacy extractor when Defuddle fails.
|
Fetch any URL via Chrome CDP and convert to clean markdown. Saves rendered HTML snapshot alongside the markdown, and automatically falls back to a legacy extractor when Defuddle fails.
|
||||||
@@ -966,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)
|
||||||
@@ -983,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
|
||||||
@@ -999,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
|
||||||
|
|||||||
+122
-29
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -52,16 +52,14 @@ clawhub install baoyu-markdown-to-html
|
|||||||
|
|
||||||
1. 选择 **Browse and install plugins**
|
1. 选择 **Browse and install plugins**
|
||||||
2. 选择 **baoyu-skills**
|
2. 选择 **baoyu-skills**
|
||||||
3. 选择要安装的插件
|
3. 选择 **baoyu-skills** 插件
|
||||||
4. 选择 **Install now**
|
4. 选择 **Install now**
|
||||||
|
|
||||||
**方式二:直接安装**
|
**方式二:直接安装**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装指定插件
|
# 安装 marketplace 中唯一的插件
|
||||||
/plugin install content-skills@baoyu-skills
|
/plugin install baoyu-skills@baoyu-skills
|
||||||
/plugin install ai-generation-skills@baoyu-skills
|
|
||||||
/plugin install utility-skills@baoyu-skills
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**方式三:告诉 Agent**
|
**方式三:告诉 Agent**
|
||||||
@@ -72,11 +70,11 @@ clawhub install baoyu-markdown-to-html
|
|||||||
|
|
||||||
### 可用插件
|
### 可用插件
|
||||||
|
|
||||||
| 插件 | 说明 | 包含技能 |
|
现在 marketplace 只暴露一个插件,这样每个 skill 只会注册一次。
|
||||||
|
|
||||||
|
| 插件 | 说明 | 包含内容 |
|
||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| **content-skills** | 内容生成和发布 | [xhs-images](#baoyu-xhs-images), [infographic](#baoyu-infographic), [cover-image](#baoyu-cover-image), [slide-deck](#baoyu-slide-deck), [comic](#baoyu-comic), [article-illustrator](#baoyu-article-illustrator), [post-to-x](#baoyu-post-to-x), [post-to-wechat](#baoyu-post-to-wechat), [post-to-weibo](#baoyu-post-to-weibo) |
|
| **baoyu-skills** | 提供内容生成、AI 后端和日常效率工具技能 | 仓库中的全部 skills,仍按下方的内容技能、AI 生成技能、工具技能三个分类展示 |
|
||||||
| **ai-generation-skills** | AI 生成后端 | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) |
|
|
||||||
| **utility-skills** | 内容处理工具 | [url-to-markdown](#baoyu-url-to-markdown), [danger-x-to-markdown](#baoyu-danger-x-to-markdown), [compress-image](#baoyu-compress-image), [format-markdown](#baoyu-format-markdown), [markdown-to-html](#baoyu-markdown-to-html), [translate](#baoyu-translate) |
|
|
||||||
|
|
||||||
## 更新技能
|
## 更新技能
|
||||||
|
|
||||||
@@ -663,40 +661,58 @@ accounts:
|
|||||||
|
|
||||||
AI 驱动的生成后端。
|
AI 驱动的生成后端。
|
||||||
|
|
||||||
#### baoyu-image-gen
|
#### baoyu-imagine
|
||||||
|
|
||||||
基于 AI SDK 的图像生成,支持 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 为部署名称)
|
||||||
|
/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、OpenRouter 或 Replicate)
|
# 带参考图(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
|
||||||
```
|
```
|
||||||
|
|
||||||
**选项**:
|
**选项**:
|
||||||
@@ -705,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) |
|
| `--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
|
||||||
|
|
||||||
@@ -766,6 +811,40 @@ AI 驱动的生成后端。
|
|||||||
|
|
||||||
内容处理工具。
|
内容处理工具。
|
||||||
|
|
||||||
|
#### baoyu-youtube-transcript
|
||||||
|
|
||||||
|
下载 YouTube 视频字幕/转录文本和封面图片。支持多语言、翻译、章节分段和说话人识别。缓存原始数据以便快速重新格式化。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 默认:带时间戳的 Markdown
|
||||||
|
/baoyu-youtube-transcript https://www.youtube.com/watch?v=VIDEO_ID
|
||||||
|
|
||||||
|
# 指定语言(按优先级排列)
|
||||||
|
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --languages zh,en,ja
|
||||||
|
|
||||||
|
# 章节分段 + 说话人识别
|
||||||
|
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --chapters --speakers
|
||||||
|
|
||||||
|
# SRT 字幕格式
|
||||||
|
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --format srt
|
||||||
|
|
||||||
|
# 列出可用字幕
|
||||||
|
/baoyu-youtube-transcript https://youtu.be/VIDEO_ID --list
|
||||||
|
```
|
||||||
|
|
||||||
|
**选项**:
|
||||||
|
| 选项 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `<url-or-id>` | YouTube URL 或视频 ID | 必填 |
|
||||||
|
| `--languages <codes>` | 语言代码,逗号分隔 | `en` |
|
||||||
|
| `--format <fmt>` | 输出格式:`text`、`srt` | `text` |
|
||||||
|
| `--translate <code>` | 翻译为指定语言 | |
|
||||||
|
| `--chapters` | 根据视频描述进行章节分段 | |
|
||||||
|
| `--speakers` | 说话人识别(需 AI 后处理) | |
|
||||||
|
| `--no-timestamps` | 禁用时间戳 | |
|
||||||
|
| `--list` | 列出可用字幕 | |
|
||||||
|
| `--refresh` | 强制重新获取,忽略缓存 | |
|
||||||
|
|
||||||
#### baoyu-url-to-markdown
|
#### baoyu-url-to-markdown
|
||||||
|
|
||||||
通过 Chrome CDP 抓取任意 URL 并转换为 Markdown。同时保存渲染后的 HTML 快照,Defuddle 失败时自动回退到旧版提取器。
|
通过 Chrome CDP 抓取任意 URL 并转换为 Markdown。同时保存渲染后的 HTML 快照,Defuddle 失败时自动回退到旧版提取器。
|
||||||
@@ -966,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`(用户级)
|
||||||
@@ -983,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
|
||||||
@@ -999,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
|
||||||
|
|||||||
+11
-9
@@ -34,20 +34,22 @@ metadata:
|
|||||||
1. Create `skills/baoyu-<name>/SKILL.md` with YAML front matter
|
1. Create `skills/baoyu-<name>/SKILL.md` with YAML front matter
|
||||||
2. Add TypeScript in `skills/baoyu-<name>/scripts/` (if applicable)
|
2. Add TypeScript in `skills/baoyu-<name>/scripts/` (if applicable)
|
||||||
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
|
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
|
||||||
4. Register in `marketplace.json` under appropriate category
|
4. Register the skill in `.claude-plugin/marketplace.json` under the `baoyu-skills` plugin entry
|
||||||
5. Add Script Directory section to SKILL.md if skill has scripts
|
5. Add Script Directory section to SKILL.md if skill has scripts
|
||||||
6. Add openclaw metadata to frontmatter
|
6. Add openclaw metadata to frontmatter
|
||||||
|
|
||||||
## Category Selection
|
## Skill Grouping
|
||||||
|
|
||||||
| If your skill... | Use category |
|
All skills are registered under the single `baoyu-skills` plugin. Use these logical groups when deciding where the skill should appear in the docs:
|
||||||
|------------------|--------------|
|
|
||||||
| Generates visual content (images, slides, comics) | `content-skills` |
|
|
||||||
| Publishes to platforms (X, WeChat, Weibo) | `content-skills` |
|
|
||||||
| Provides AI generation backend | `ai-generation-skills` |
|
|
||||||
| Converts or processes content | `utility-skills` |
|
|
||||||
|
|
||||||
New category: add plugin object to `marketplace.json` with `name`, `description`, `skills[]`.
|
| If your skill... | Use group |
|
||||||
|
|------------------|-----------|
|
||||||
|
| Generates visual content (images, slides, comics) | Content Skills |
|
||||||
|
| Publishes to platforms (X, WeChat, Weibo) | Content Skills |
|
||||||
|
| Provides AI generation backend | AI Generation Skills |
|
||||||
|
| Converts or processes content | Utility Skills |
|
||||||
|
|
||||||
|
If you add a new logical group, update the docs that present grouped skills, but keep the skill registered under the single `baoyu-skills` plugin entry.
|
||||||
|
|
||||||
## Writing Descriptions
|
## Writing Descriptions
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Generated
+105
-1
@@ -9,7 +9,11 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsx": "^4.20.5"
|
"@mozilla/readability": "^0.6.0",
|
||||||
|
"linkedom": "^0.18.12",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
|
"turndown": "^7.2.2",
|
||||||
|
"turndown-plugin-gfm": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -454,6 +458,23 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mixmark-io/domino": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mozilla/readability": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/debug": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -615,6 +636,13 @@
|
|||||||
"url": "https://github.com/sponsors/fb55"
|
"url": "https://github.com/sponsors/fb55"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cssom": {
|
||||||
|
"version": "0.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
|
||||||
|
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -896,6 +924,13 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-escaper": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/htmlparser2": {
|
"node_modules/htmlparser2": {
|
||||||
"version": "9.1.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
|
||||||
@@ -984,6 +1019,51 @@
|
|||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkedom": {
|
||||||
|
"version": "0.18.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz",
|
||||||
|
"integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"cssom": "^0.5.0",
|
||||||
|
"html-escaper": "^3.0.3",
|
||||||
|
"htmlparser2": "^10.0.0",
|
||||||
|
"uhyphen": "^0.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"canvas": ">= 2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"canvas": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linkedom/node_modules/htmlparser2": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"entities": "^7.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/longest-streak": {
|
"node_modules/longest-streak": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||||
@@ -1768,6 +1848,30 @@
|
|||||||
"fsevents": "~2.3.3"
|
"fsevents": "~2.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/turndown": {
|
||||||
|
"version": "7.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
|
||||||
|
"integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mixmark-io/domino": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/turndown-plugin-gfm": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/uhyphen": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.24.0",
|
"version": "6.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
"test:coverage": "node --import tsx --experimental-test-coverage --test"
|
"test:coverage": "node --import tsx --experimental-test-coverage --test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@mozilla/readability": "^0.6.0",
|
||||||
|
"linkedom": "^0.18.12",
|
||||||
|
"turndown": "^7.2.2",
|
||||||
|
"turndown-plugin-gfm": "^1.0.2",
|
||||||
"tsx": "^4.20.5"
|
"tsx": "^4.20.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
|
|||||||
import {
|
import {
|
||||||
buildMarkdownDocumentMeta,
|
buildMarkdownDocumentMeta,
|
||||||
formatTimestamp,
|
formatTimestamp,
|
||||||
|
renderMarkdownDocument,
|
||||||
resolveColorToken,
|
resolveColorToken,
|
||||||
resolveFontFamilyToken,
|
resolveFontFamilyToken,
|
||||||
resolveMarkdownStyle,
|
resolveMarkdownStyle,
|
||||||
resolveRenderOptions,
|
resolveRenderOptions,
|
||||||
} from "./document.ts";
|
} from "./document.ts";
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInlineStyle(html: string, tagName: string, text: string): string {
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
|
||||||
|
);
|
||||||
|
const match = html.match(pattern);
|
||||||
|
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
|
||||||
|
return match![1]!;
|
||||||
|
}
|
||||||
|
|
||||||
function useCwd(t: TestContext, cwd: string): void {
|
function useCwd(t: TestContext, cwd: string): void {
|
||||||
const previous = process.cwd();
|
const previous = process.cwd();
|
||||||
process.chdir(cwd);
|
process.chdir(cwd);
|
||||||
@@ -138,3 +152,23 @@ keep_title: true
|
|||||||
assert.equal(explicit.fontSize, "18px");
|
assert.equal(explicit.fontSize, "18px");
|
||||||
assert.equal(explicit.keepTitle, false);
|
assert.equal(explicit.keepTitle, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
|
||||||
|
const { html } = await renderMarkdownDocument(
|
||||||
|
`## Section\n\nParagraph with **bold** text.`,
|
||||||
|
{ keepTitle: true, theme: "grace" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const h2Style = findInlineStyle(html, "h2", "Section");
|
||||||
|
assert.match(h2Style, /background: #92617E/);
|
||||||
|
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
|
||||||
|
|
||||||
|
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
|
||||||
|
assert.ok(pMatch, "Expected inline style on <p> tag");
|
||||||
|
assert.match(pMatch![1]!, /color:/);
|
||||||
|
|
||||||
|
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
|
||||||
|
const strongMatch = html.match(strongPattern);
|
||||||
|
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
|
||||||
|
assert.match(strongMatch![1]!, /font-weight:/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ test("normalizeCssText and normalizeInlineCss replace variables and strip declar
|
|||||||
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
|
||||||
|
const normalizedHtml = normalizeInlineCss(
|
||||||
|
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
|
||||||
|
DEFAULT_STYLE,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
|
||||||
|
assert.doesNotMatch(normalizedHtml, /Courier New/);
|
||||||
|
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
|
||||||
|
});
|
||||||
|
|
||||||
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
||||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
|
|||||||
.replace(/var\(--md-accent-color\)/g, style.accentColor)
|
.replace(/var\(--md-accent-color\)/g, style.accentColor)
|
||||||
.replace(/var\(--md-container-bg\)/g, style.containerBg)
|
.replace(/var\(--md-container-bg\)/g, style.containerBg)
|
||||||
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
||||||
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
.replace(/--md-font-family:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
|
.replace(/--md-font-size:\s*[^;]+;?/g, "")
|
||||||
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
|
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
|
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
|
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
|
||||||
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
.replace(/--foreground:\s*[^;]+;?/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
|
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
|
|||||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
||||||
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
||||||
|
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
|
||||||
|
|
||||||
function stripOutputScope(cssContent: string): string {
|
function stripOutputScope(cssContent: string): string {
|
||||||
let css = cssContent;
|
let css = cssContent;
|
||||||
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
|
|||||||
themeCss: string;
|
themeCss: string;
|
||||||
} {
|
} {
|
||||||
const basePath = path.join(THEME_DIR, "base.css");
|
const basePath = path.join(THEME_DIR, "base.css");
|
||||||
|
const defaultThemePath = path.join(THEME_DIR, "default.css");
|
||||||
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
||||||
|
|
||||||
if (!fs.existsSync(basePath)) {
|
if (!fs.existsSync(basePath)) {
|
||||||
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
|
|||||||
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
|
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const layeredThemeCss: string[] = [];
|
||||||
|
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
|
||||||
|
if (!fs.existsSync(defaultThemePath)) {
|
||||||
|
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
|
||||||
|
}
|
||||||
|
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
|
||||||
|
}
|
||||||
|
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseCss: fs.readFileSync(basePath, "utf-8"),
|
baseCss: fs.readFileSync(basePath, "utf-8"),
|
||||||
themeCss: fs.readFileSync(themePath, "utf-8"),
|
themeCss: layeredThemeCss.join("\n"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ async function main() {
|
|||||||
.map((tag) => tag.trim())
|
.map((tag) => tag.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
let succeeded = 0;
|
||||||
|
const failed = [];
|
||||||
|
|
||||||
for (const candidate of actionable) {
|
for (const candidate of actionable) {
|
||||||
const version =
|
const version =
|
||||||
candidate.status === "new"
|
candidate.status === "new"
|
||||||
@@ -158,6 +161,7 @@ async function main() {
|
|||||||
: bumpSemver(candidate.latestVersion, options.bump);
|
: bumpSemver(candidate.latestVersion, options.bump);
|
||||||
|
|
||||||
console.log(`Publishing ${candidate.slug}@${version}`);
|
console.log(`Publishing ${candidate.slug}@${version}`);
|
||||||
|
try {
|
||||||
const files = await listTextFiles(candidate.folder);
|
const files = await listTextFiles(candidate.folder);
|
||||||
await publishSkill({
|
await publishSkill({
|
||||||
registry,
|
registry,
|
||||||
@@ -168,10 +172,19 @@ async function main() {
|
|||||||
changelog: options.changelog,
|
changelog: options.changelog,
|
||||||
tags,
|
tags,
|
||||||
});
|
});
|
||||||
|
succeeded++;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`SKIPPED ${candidate.slug}: ${msg}`);
|
||||||
|
failed.push(candidate.slug);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log(`Uploaded ${actionable.length} skill(s).`);
|
console.log(`Uploaded ${succeeded}/${actionable.length} skill(s).`);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
console.log(`Failed (${failed.length}): ${failed.join(", ")}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -280,5 +280,5 @@ TEXTURE: Halftone transitions between sides
|
|||||||
If watermark enabled in preferences, append:
|
If watermark enabled in preferences, append:
|
||||||
|
|
||||||
```
|
```
|
||||||
Include a subtle watermark "[content]" positioned at [position] with approximately [opacity*100]% visibility.
|
Include a subtle watermark "[content]" positioned at [position].
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -359,8 +359,7 @@ Art: [art style] | Tone: [tone] | Layout: [layout type]
|
|||||||
**Watermark Application** (if enabled in preferences):
|
**Watermark Application** (if enabled in preferences):
|
||||||
Add to each prompt:
|
Add to each prompt:
|
||||||
```
|
```
|
||||||
Include a subtle watermark "[content]" positioned at [position]
|
Include a subtle watermark "[content]" positioned at [position]. The watermark should
|
||||||
with approximately [opacity*100]% visibility. The watermark should
|
|
||||||
be legible but not distracting from the comic panels and storytelling.
|
be legible but not distracting from the comic panels and storytelling.
|
||||||
Ensure watermark does not overlap speech bubbles or key action.
|
Ensure watermark does not overlap speech bubbles or key action.
|
||||||
```
|
```
|
||||||
@@ -434,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
|
||||||
@@ -452,8 +451,8 @@ When skill does NOT support reference images, create combined prompt files:
|
|||||||
|
|
||||||
## Character Reference (maintain consistency)
|
## Character Reference (maintain consistency)
|
||||||
[Copy relevant sections from characters/characters.md here]
|
[Copy relevant sections from characters/characters.md here]
|
||||||
- 大雄: Japanese boy, round glasses, yellow shirt, navy shorts...
|
- 大雄:Japanese boy, round glasses, yellow shirt, navy shorts...
|
||||||
- 哆啦A梦: Round blue robot cat, white belly, red nose, golden bell...
|
- 哆啦 A 梦:Round blue robot cat, white belly, red nose, golden bell...
|
||||||
|
|
||||||
## Page Content
|
## Page Content
|
||||||
[Original page prompt here]
|
[Original page prompt here]
|
||||||
|
|||||||
@@ -162,15 +162,14 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md") { "user" }
|
|||||||
5. **Detect language**: Compare source, user input, EXTEND.md preference
|
5. **Detect language**: Compare source, user input, EXTEND.md preference
|
||||||
6. **Determine output directory**: Per File Structure rules
|
6. **Determine output directory**: Per File Structure rules
|
||||||
|
|
||||||
**⚠️ People in Reference Images — MUST follow all 3 rules:**
|
**⚠️ People in Reference Images:**
|
||||||
|
|
||||||
If reference images contain **people** who should appear in the cover:
|
If reference images contain **people** who should appear in the cover:
|
||||||
|
|
||||||
1. **`usage: direct`** — MUST set in refs description file. NEVER use `style` or `palette` when people need to appear
|
- **Model supports `--ref`** (default): Copy image to `refs/`, pass via `--ref` at generation. No description file needed — the model sees the face directly.
|
||||||
2. **Per-character description** — MUST describe each person's distinctive features (hair, glasses, skin tone, clothing) in `refs/ref-NN-{slug}.md`. Vague descriptions like "a man" will fail
|
- **Model does NOT support `--ref`** (Jimeng, Seedream 3.0): Create `refs/ref-NN-{slug}.md` with per-character description (hair, glasses, skin tone, clothing). Embed as MUST/REQUIRED instructions in prompt text.
|
||||||
3. **`--ref` flag** — MUST pass reference image via `--ref` in Step 4 so the model sees actual faces
|
|
||||||
|
|
||||||
See [reference-images.md § Character Analysis](references/workflow/reference-images.md) for description format.
|
See [reference-images.md](references/workflow/reference-images.md) for full decision table.
|
||||||
|
|
||||||
### Step 2: Confirm Options ⚠️
|
### Step 2: Confirm Options ⚠️
|
||||||
|
|
||||||
|
|||||||
@@ -16,17 +16,24 @@ Guide for processing user-provided reference images in cover generation.
|
|||||||
|
|
||||||
**If user provides file path**:
|
**If user provides file path**:
|
||||||
1. Copy to `refs/ref-NN-{slug}.{ext}` (NN = 01, 02, ...)
|
1. Copy to `refs/ref-NN-{slug}.{ext}` (NN = 01, 02, ...)
|
||||||
2. Create description: `refs/ref-NN-{slug}.md`
|
2. **Only** create description file `refs/ref-NN-{slug}.md` when model does NOT support `--ref` (see below)
|
||||||
3. Verify files exist before proceeding
|
3. Verify image file exists before proceeding
|
||||||
|
|
||||||
**Description File Format**:
|
**When to create description file**:
|
||||||
|
|
||||||
|
| Situation | Action |
|
||||||
|
|-----------|--------|
|
||||||
|
| Model supports `--ref` (Google, OpenAI, OpenRouter, Replicate, Seedream 4.0+) | Copy image only. **No description file needed.** Pass via `--ref` at generation. |
|
||||||
|
| Model does NOT support `--ref` (Jimeng, Seedream 3.0) | Copy image + create description file. Embed description in prompt text. |
|
||||||
|
|
||||||
|
**Description File Format** (only when needed):
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
ref_id: NN
|
ref_id: NN
|
||||||
filename: ref-NN-{slug}.{ext}
|
filename: ref-NN-{slug}.{ext}
|
||||||
usage: direct | style | palette
|
usage: direct | style | palette
|
||||||
---
|
---
|
||||||
[User's description or auto-generated description]
|
[Character or style description to embed in prompt]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Usage | When to Use |
|
| Usage | When to Use |
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { formatArticleMarkdown } from "./markdown.js";
|
||||||
|
|
||||||
|
test("formatArticleMarkdown renders MARKDOWN entities from atomic blocks", () => {
|
||||||
|
const article = {
|
||||||
|
title: "Atomic Markdown Example",
|
||||||
|
content_state: {
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: "unstyled",
|
||||||
|
text: "Before the snippet.",
|
||||||
|
entityRanges: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "atomic",
|
||||||
|
text: " ",
|
||||||
|
entityRanges: [{ key: 0, offset: 0, length: 1 }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "unstyled",
|
||||||
|
text: "After the snippet.",
|
||||||
|
entityRanges: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
entityMap: {
|
||||||
|
"0": {
|
||||||
|
key: "5",
|
||||||
|
value: {
|
||||||
|
type: "MARKDOWN",
|
||||||
|
mutability: "Mutable",
|
||||||
|
data: {
|
||||||
|
markdown: "```python\nprint('hello from x article')\n```\n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { markdown } = formatArticleMarkdown(article);
|
||||||
|
|
||||||
|
assert.ok(markdown.includes("Before the snippet."));
|
||||||
|
assert.ok(markdown.includes("```python\nprint('hello from x article')\n```"));
|
||||||
|
assert.ok(markdown.includes("After the snippet."));
|
||||||
|
assert.strictEqual(markdown, `# Atomic Markdown Example
|
||||||
|
|
||||||
|
Before the snippet.
|
||||||
|
|
||||||
|
\`\`\`python
|
||||||
|
print('hello from x article')
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
After the snippet.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatArticleMarkdown renders article video media as poster plus video link", () => {
|
||||||
|
const posterUrl = "https://pbs.twimg.com/amplify_video_thumb/123/img/poster.jpg";
|
||||||
|
const videoUrl = "https://video.twimg.com/amplify_video/123/vid/avc1/720x720/demo.mp4?tag=21";
|
||||||
|
const article = {
|
||||||
|
title: "Video Example",
|
||||||
|
content_state: {
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: "unstyled",
|
||||||
|
text: "Intro text.",
|
||||||
|
entityRanges: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "atomic",
|
||||||
|
text: " ",
|
||||||
|
entityRanges: [{ key: 0, offset: 0, length: 1 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
entityMap: {
|
||||||
|
"0": {
|
||||||
|
key: "0",
|
||||||
|
value: {
|
||||||
|
type: "MEDIA",
|
||||||
|
mutability: "Immutable",
|
||||||
|
data: {
|
||||||
|
caption: "Demo reel",
|
||||||
|
mediaItems: [{ mediaId: "vid-1" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
media_entities: [
|
||||||
|
{
|
||||||
|
media_id: "vid-1",
|
||||||
|
media_info: {
|
||||||
|
__typename: "ApiVideo",
|
||||||
|
preview_image: {
|
||||||
|
original_img_url: posterUrl,
|
||||||
|
},
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
content_type: "video/mp4",
|
||||||
|
bit_rate: 256000,
|
||||||
|
url: videoUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { markdown } = formatArticleMarkdown(article);
|
||||||
|
|
||||||
|
assert.ok(markdown.includes("Intro text."));
|
||||||
|
assert.ok(markdown.includes(``));
|
||||||
|
assert.ok(markdown.includes(`[video](${videoUrl})`));
|
||||||
|
assert.ok(!markdown.includes(``));
|
||||||
|
assert.ok(!markdown.includes("## Media"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatArticleMarkdown renders unused article videos in trailing media section", () => {
|
||||||
|
const posterUrl = "https://pbs.twimg.com/amplify_video_thumb/456/img/poster.jpg";
|
||||||
|
const videoUrl = "https://video.twimg.com/amplify_video/456/vid/avc1/1080x1080/demo.mp4?tag=21";
|
||||||
|
const article = {
|
||||||
|
title: "Trailing Media Example",
|
||||||
|
plain_text: "Body text.",
|
||||||
|
media_entities: [
|
||||||
|
{
|
||||||
|
media_id: "vid-2",
|
||||||
|
media_info: {
|
||||||
|
__typename: "ApiVideo",
|
||||||
|
preview_image: {
|
||||||
|
original_img_url: posterUrl,
|
||||||
|
},
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
content_type: "video/mp4",
|
||||||
|
bit_rate: 832000,
|
||||||
|
url: videoUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { markdown, coverUrl } = formatArticleMarkdown(article);
|
||||||
|
|
||||||
|
assert.strictEqual(coverUrl, null);
|
||||||
|
assert.ok(markdown.includes("## Media"));
|
||||||
|
assert.ok(markdown.includes(``));
|
||||||
|
assert.ok(markdown.includes(`[video](${videoUrl})`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatArticleMarkdown keeps coverUrl as preview image for video cover media", () => {
|
||||||
|
const posterUrl = "https://pbs.twimg.com/amplify_video_thumb/789/img/poster.jpg";
|
||||||
|
const videoUrl = "https://video.twimg.com/amplify_video/789/vid/avc1/720x720/demo.mp4?tag=21";
|
||||||
|
const article = {
|
||||||
|
title: "Video Cover Example",
|
||||||
|
plain_text: "Body text.",
|
||||||
|
cover_media: {
|
||||||
|
media_info: {
|
||||||
|
__typename: "ApiVideo",
|
||||||
|
preview_image: {
|
||||||
|
original_img_url: posterUrl,
|
||||||
|
},
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
content_type: "video/mp4",
|
||||||
|
bit_rate: 1280000,
|
||||||
|
url: videoUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { coverUrl } = formatArticleMarkdown(article);
|
||||||
|
|
||||||
|
assert.strictEqual(coverUrl, posterUrl);
|
||||||
|
});
|
||||||
@@ -18,6 +18,17 @@ export type FormatArticleOptions = {
|
|||||||
referencedTweets?: Map<string, ReferencedTweetInfo>;
|
referencedTweets?: Map<string, ReferencedTweetInfo>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ResolvedMediaAsset =
|
||||||
|
| {
|
||||||
|
kind: "image";
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "video";
|
||||||
|
url: string;
|
||||||
|
posterUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
function coerceArticleEntity(value: unknown): ArticleEntity | null {
|
function coerceArticleEntity(value: unknown): ArticleEntity | null {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const candidate = value as ArticleEntity;
|
const candidate = value as ArticleEntity;
|
||||||
@@ -109,58 +120,127 @@ function resolveEntityEntry(
|
|||||||
return entityMap[String(entityKey)];
|
return entityMap[String(entityKey)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMediaUrl(info?: ArticleMediaInfo): string | undefined {
|
function resolveVideoUrl(info?: ArticleMediaInfo): string | undefined {
|
||||||
if (!info) return undefined;
|
if (!info) return undefined;
|
||||||
if (info.original_img_url) return info.original_img_url;
|
|
||||||
if (info.preview_image?.original_img_url) return info.preview_image.original_img_url;
|
|
||||||
const variants = info.variants ?? [];
|
const variants = info.variants ?? [];
|
||||||
const mp4 = variants
|
const mp4 = variants
|
||||||
.filter((variant) => variant?.content_type?.includes("video"))
|
.filter((variant) => variant?.content_type?.includes("video"))
|
||||||
.sort((a, b) => (b.bit_rate ?? 0) - (a.bit_rate ?? 0))[0];
|
.sort((a, b) => (b.bit_rate ?? 0) - (a.bit_rate ?? 0))[0];
|
||||||
return mp4?.url ?? variants[0]?.url;
|
return mp4?.url ?? variants.find((variant) => typeof variant?.url === "string")?.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMediaById(article: ArticleEntity): Map<string, string> {
|
function resolveMediaAsset(info?: ArticleMediaInfo): ResolvedMediaAsset | undefined {
|
||||||
const map = new Map<string, string>();
|
if (!info) return undefined;
|
||||||
|
|
||||||
|
const posterUrl = info.preview_image?.original_img_url ?? info.original_img_url;
|
||||||
|
const videoUrl = resolveVideoUrl(info);
|
||||||
|
if (videoUrl) {
|
||||||
|
return {
|
||||||
|
kind: "video",
|
||||||
|
url: videoUrl,
|
||||||
|
posterUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = info.original_img_url ?? info.preview_image?.original_img_url;
|
||||||
|
if (imageUrl) {
|
||||||
|
return {
|
||||||
|
kind: "image",
|
||||||
|
url: imageUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFallbackMediaAsset(rawUrl?: string): ResolvedMediaAsset | undefined {
|
||||||
|
if (!rawUrl) return undefined;
|
||||||
|
|
||||||
|
if (/^https:\/\/video\.twimg\.com\//i.test(rawUrl) || /\.(mp4|m4v|mov|webm)(?:$|[?#])/i.test(rawUrl)) {
|
||||||
|
return {
|
||||||
|
kind: "video",
|
||||||
|
url: rawUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "image",
|
||||||
|
url: rawUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCoverUrl(info?: ArticleMediaInfo): string | undefined {
|
||||||
|
if (!info) return undefined;
|
||||||
|
return info.original_img_url ?? info.preview_image?.original_img_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMediaIdentity(asset: ResolvedMediaAsset): string {
|
||||||
|
return asset.kind === "video"
|
||||||
|
? `video:${asset.url}:${asset.posterUrl ?? ""}`
|
||||||
|
: `image:${asset.url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMediaLines(
|
||||||
|
asset: ResolvedMediaAsset,
|
||||||
|
altText: string,
|
||||||
|
usedUrls: Set<string>
|
||||||
|
): string[] {
|
||||||
|
if (asset.kind === "video") {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (asset.posterUrl && !usedUrls.has(asset.posterUrl)) {
|
||||||
|
usedUrls.add(asset.posterUrl);
|
||||||
|
lines.push(``);
|
||||||
|
}
|
||||||
|
if (!usedUrls.has(asset.url)) {
|
||||||
|
usedUrls.add(asset.url);
|
||||||
|
lines.push(`[video](${asset.url})`);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usedUrls.has(asset.url)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
usedUrls.add(asset.url);
|
||||||
|
return [``];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMediaById(article: ArticleEntity): Map<string, ResolvedMediaAsset> {
|
||||||
|
const map = new Map<string, ResolvedMediaAsset>();
|
||||||
for (const entity of article.media_entities ?? []) {
|
for (const entity of article.media_entities ?? []) {
|
||||||
if (!entity?.media_id) continue;
|
if (!entity?.media_id) continue;
|
||||||
const url = resolveMediaUrl(entity.media_info);
|
const asset = resolveMediaAsset(entity.media_info);
|
||||||
if (url) {
|
if (asset) {
|
||||||
map.set(entity.media_id, url);
|
map.set(entity.media_id, asset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectMediaUrls(
|
function collectMediaAssets(article: ArticleEntity): ResolvedMediaAsset[] {
|
||||||
article: ArticleEntity,
|
const assets: ResolvedMediaAsset[] = [];
|
||||||
usedUrls: Set<string>,
|
const seen = new Set<string>();
|
||||||
excludeUrl?: string
|
const addAsset = (asset?: ResolvedMediaAsset) => {
|
||||||
): string[] {
|
if (!asset) return;
|
||||||
const urls: string[] = [];
|
const identity = buildMediaIdentity(asset);
|
||||||
const addUrl = (url?: string) => {
|
if (seen.has(identity)) return;
|
||||||
if (!url) return;
|
seen.add(identity);
|
||||||
if (excludeUrl && url === excludeUrl) {
|
assets.push(asset);
|
||||||
usedUrls.add(url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (usedUrls.has(url)) return;
|
|
||||||
usedUrls.add(url);
|
|
||||||
urls.push(url);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const entity of article.media_entities ?? []) {
|
for (const entity of article.media_entities ?? []) {
|
||||||
addUrl(resolveMediaUrl(entity?.media_info));
|
addAsset(resolveMediaAsset(entity?.media_info));
|
||||||
}
|
}
|
||||||
|
|
||||||
return urls;
|
return assets;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveEntityMediaLines(
|
function resolveEntityMediaLines(
|
||||||
entityKey: number | undefined,
|
entityKey: number | undefined,
|
||||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
entityLookup: EntityLookup,
|
entityLookup: EntityLookup,
|
||||||
mediaById: Map<string, string>,
|
mediaById: Map<string, ResolvedMediaAsset>,
|
||||||
usedUrls: Set<string>
|
usedUrls: Set<string>
|
||||||
): string[] {
|
): string[] {
|
||||||
if (entityKey === undefined) return [];
|
if (entityKey === undefined) return [];
|
||||||
@@ -182,17 +262,16 @@ function resolveEntityMediaLines(
|
|||||||
: typeof item?.media_id === "string"
|
: typeof item?.media_id === "string"
|
||||||
? item.media_id
|
? item.media_id
|
||||||
: undefined;
|
: undefined;
|
||||||
const url = mediaId ? mediaById.get(mediaId) : undefined;
|
const asset = mediaId ? mediaById.get(mediaId) : undefined;
|
||||||
if (url && !usedUrls.has(url)) {
|
if (asset) {
|
||||||
usedUrls.add(url);
|
lines.push(...renderMediaLines(asset, altText, usedUrls));
|
||||||
lines.push(``);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackUrl = typeof value.data?.url === "string" ? value.data.url : undefined;
|
const fallbackUrl = typeof value.data?.url === "string" ? value.data.url : undefined;
|
||||||
if (fallbackUrl && !usedUrls.has(fallbackUrl)) {
|
const fallbackAsset = resolveFallbackMediaAsset(fallbackUrl);
|
||||||
usedUrls.add(fallbackUrl);
|
if (fallbackAsset) {
|
||||||
lines.push(``);
|
lines.push(...renderMediaLines(fallbackAsset, altText, usedUrls));
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
@@ -237,6 +316,22 @@ function resolveEntityTweetLines(
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveEntityMarkdownLines(
|
||||||
|
entityKey: number | undefined,
|
||||||
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
|
entityLookup: EntityLookup
|
||||||
|
): string[] {
|
||||||
|
if (entityKey === undefined) return [];
|
||||||
|
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
|
||||||
|
const value = entry?.value;
|
||||||
|
if (!value || value.type !== "MARKDOWN") return [];
|
||||||
|
|
||||||
|
const markdown = typeof value.data?.markdown === "string" ? value.data.markdown : "";
|
||||||
|
const normalized = markdown.replace(/\r\n/g, "\n").trimEnd();
|
||||||
|
if (!normalized) return [];
|
||||||
|
return normalized.split("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function buildMediaLinkMap(
|
function buildMediaLinkMap(
|
||||||
entityMap: ArticleContentState["entityMap"] | undefined
|
entityMap: ArticleContentState["entityMap"] | undefined
|
||||||
): Map<number, string> {
|
): Map<number, string> {
|
||||||
@@ -330,7 +425,7 @@ function renderContentBlocks(
|
|||||||
blocks: ArticleBlock[],
|
blocks: ArticleBlock[],
|
||||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
entityLookup: EntityLookup,
|
entityLookup: EntityLookup,
|
||||||
mediaById: Map<string, string>,
|
mediaById: Map<string, ResolvedMediaAsset>,
|
||||||
usedUrls: Set<string>,
|
usedUrls: Set<string>,
|
||||||
mediaLinkMap: Map<number, string>,
|
mediaLinkMap: Map<number, string>,
|
||||||
referencedTweets?: Map<string, ReferencedTweetInfo>
|
referencedTweets?: Map<string, ReferencedTweetInfo>
|
||||||
@@ -397,6 +492,16 @@ function renderContentBlocks(
|
|||||||
return [...new Set(linkLines)];
|
return [...new Set(linkLines)];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const collectMarkdownLines = (block: ArticleBlock): string[] => {
|
||||||
|
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
|
||||||
|
const markdownLines: string[] = [];
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (typeof range?.key !== "number") continue;
|
||||||
|
markdownLines.push(...resolveEntityMarkdownLines(range.key, entityMap, entityLookup));
|
||||||
|
}
|
||||||
|
return markdownLines;
|
||||||
|
};
|
||||||
|
|
||||||
const pushTrailingMedia = (mediaLines: string[]) => {
|
const pushTrailingMedia = (mediaLines: string[]) => {
|
||||||
if (mediaLines.length > 0) {
|
if (mediaLines.length > 0) {
|
||||||
pushBlock(mediaLines, "media");
|
pushBlock(mediaLines, "media");
|
||||||
@@ -441,6 +546,11 @@ function renderContentBlocks(
|
|||||||
pushBlock(tweetLines, "quote");
|
pushBlock(tweetLines, "quote");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markdownLines = collectMarkdownLines(block);
|
||||||
|
if (markdownLines.length > 0) {
|
||||||
|
pushBlock(markdownLines, "text");
|
||||||
|
}
|
||||||
|
|
||||||
const mediaLines = collectMediaLines(block);
|
const mediaLines = collectMediaLines(block);
|
||||||
if (mediaLines.length > 0) {
|
if (mediaLines.length > 0) {
|
||||||
pushBlock(mediaLines, "media");
|
pushBlock(mediaLines, "media");
|
||||||
@@ -571,7 +681,7 @@ export function formatArticleMarkdown(
|
|||||||
lines.push(`# ${title}`);
|
lines.push(`# ${title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info) ?? null;
|
const coverUrl = resolveCoverUrl(candidate.cover_media?.media_info) ?? null;
|
||||||
if (coverUrl) {
|
if (coverUrl) {
|
||||||
usedUrls.add(coverUrl);
|
usedUrls.add(coverUrl);
|
||||||
}
|
}
|
||||||
@@ -602,12 +712,13 @@ export function formatArticleMarkdown(
|
|||||||
lines.push(candidate.preview_text.trim());
|
lines.push(candidate.preview_text.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl ?? undefined);
|
const trailingMediaLines: string[] = [];
|
||||||
if (mediaUrls.length > 0) {
|
for (const asset of collectMediaAssets(candidate)) {
|
||||||
lines.push("", "## Media", "");
|
trailingMediaLines.push(...renderMediaLines(asset, "", usedUrls));
|
||||||
for (const url of mediaUrls) {
|
|
||||||
lines.push(``);
|
|
||||||
}
|
}
|
||||||
|
if (trailingMediaLines.length > 0) {
|
||||||
|
lines.push("", "## Media", "");
|
||||||
|
lines.push(...trailingMediaLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { markdown: lines.join("\n").trimEnd(), coverUrl };
|
return { markdown: lines.join("\n").trimEnd(), coverUrl };
|
||||||
|
|||||||
@@ -202,6 +202,13 @@ function toHighResUrl(rawUrl: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPlausibleMediaUrl(rawUrl: string): boolean {
|
||||||
|
const ext = resolveExtensionFromUrl(rawUrl);
|
||||||
|
if (ext && (IMAGE_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext))) return true;
|
||||||
|
if (resolveKindFromHostname(rawUrl) !== undefined) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
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>();
|
||||||
@@ -221,10 +228,12 @@ function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[
|
|||||||
const label = match[1] ?? "";
|
const label = match[1] ?? "";
|
||||||
const rawUrl = match[3] ?? "";
|
const rawUrl = match[3] ?? "";
|
||||||
if (!rawUrl || seen.has(rawUrl)) continue;
|
if (!rawUrl || seen.has(rawUrl)) continue;
|
||||||
|
const isImage = label.startsWith("![");
|
||||||
|
if (!isImage && !isPlausibleMediaUrl(rawUrl)) continue;
|
||||||
seen.add(rawUrl);
|
seen.add(rawUrl);
|
||||||
candidates.push({
|
candidates.push({
|
||||||
url: rawUrl,
|
url: rawUrl,
|
||||||
hint: label.startsWith("![") ? "image" : "unknown",
|
hint: isImage ? "image" : "unknown",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type ArticleEntityMapEntry = {
|
|||||||
mutability?: string;
|
mutability?: string;
|
||||||
data?: {
|
data?: {
|
||||||
caption?: string;
|
caption?: string;
|
||||||
|
markdown?: string;
|
||||||
mediaItems?: ArticleEntityMapMediaItem[];
|
mediaItems?: ArticleEntityMapMediaItem[];
|
||||||
url?: string;
|
url?: string;
|
||||||
tweetId?: string;
|
tweetId?: string;
|
||||||
|
|||||||
@@ -1,355 +1,19 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-image-gen
|
name: baoyu-image-gen
|
||||||
description: AI image generation with 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: "[DEPRECATED] This skill has been renamed to baoyu-imagine. Triggers on any reference to 'baoyu-image-gen' so users can be guided to upgrade. Use this whenever you detect the old skill name in user prompts, EXTEND.md, or installed skill lists."
|
||||||
version: 1.56.2
|
|
||||||
metadata:
|
|
||||||
openclaw:
|
|
||||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-gen
|
|
||||||
requires:
|
|
||||||
anyBins:
|
|
||||||
- bun
|
|
||||||
- npx
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Image Generation (AI SDK)
|
# baoyu-image-gen is now baoyu-imagine
|
||||||
|
|
||||||
Official API-based image generation. Supports OpenAI, Google, OpenRouter, DashScope (阿里通义万象), Jimeng (即梦), Seedream (豆包) and Replicate providers.
|
This skill has been renamed to **baoyu-imagine** with new features and providers.
|
||||||
|
|
||||||
## Script Directory
|
## What to do
|
||||||
|
|
||||||
**Agent Execution**:
|
When this skill is triggered, inform the user that `baoyu-image-gen` has been renamed to `baoyu-imagine` with new features and providers, and ask for their confirmation to:
|
||||||
1. `{baseDir}` = this SKILL.md file's directory
|
|
||||||
2. Script path = `{baseDir}/scripts/main.ts`
|
|
||||||
3. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun
|
|
||||||
|
|
||||||
## Step 0: Load Preferences ⛔ BLOCKING
|
1. **Install the new skill** from: `https://github.com/JimLiu/baoyu-skills/blob/main/skills/baoyu-imagine/SKILL.md`
|
||||||
|
2. **Remove the old `baoyu-image-gen` skill**
|
||||||
|
|
||||||
**CRITICAL**: This step MUST complete BEFORE any image generation. Do NOT skip or defer.
|
No config changes are needed — `baoyu-imagine` reads the same EXTEND.md settings (API keys, default provider, etc.).
|
||||||
|
|
||||||
Check EXTEND.md existence (priority: project → user):
|
Once the user confirms, help them complete the installation and removal using whatever mechanism the current environment supports. If the user also has an image generation request, proceed with `baoyu-imagine` after migration.
|
||||||
|
|
||||||
```bash
|
|
||||||
# macOS, Linux, WSL, Git Bash
|
|
||||||
test -f .baoyu-skills/baoyu-image-gen/EXTEND.md && echo "project"
|
|
||||||
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "xdg"
|
|
||||||
test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
|
||||||
```
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# PowerShell (Windows)
|
|
||||||
if (Test-Path .baoyu-skills/baoyu-image-gen/EXTEND.md) { "project" }
|
|
||||||
$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 "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md") { "user" }
|
|
||||||
```
|
|
||||||
|
|
||||||
| Result | Action |
|
|
||||||
|--------|--------|
|
|
||||||
| Found | Load, parse, apply settings. If `default_model.[provider]` is null → ask model only (Flow 2) |
|
|
||||||
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save EXTEND.md → Then continue |
|
|
||||||
|
|
||||||
**CRITICAL**: If not found, complete the full setup (provider + model + quality + save location) using AskUserQuestion BEFORE generating any images. Generation is BLOCKED until EXTEND.md is created.
|
|
||||||
|
|
||||||
| Path | Location |
|
|
||||||
|------|----------|
|
|
||||||
| `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory |
|
|
||||||
| `$HOME/.baoyu-skills/baoyu-image-gen/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
|
|
||||||
|
|
||||||
Schema: `references/config/preferences-schema.md`
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Basic
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image cat.png
|
|
||||||
|
|
||||||
# With aspect ratio
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A landscape" --image out.png --ar 16:9
|
|
||||||
|
|
||||||
# High quality
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k
|
|
||||||
|
|
||||||
# From prompt files
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
|
||||||
|
|
||||||
# With reference images (Google, OpenAI, OpenRouter, or Replicate)
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
|
|
||||||
|
|
||||||
# With reference images (explicit provider/model)
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png
|
|
||||||
|
|
||||||
# OpenRouter (recommended default model)
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openrouter
|
|
||||||
|
|
||||||
# OpenRouter with reference images
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
|
|
||||||
|
|
||||||
# Specific provider
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
|
|
||||||
|
|
||||||
# DashScope (阿里通义万象)
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
|
|
||||||
|
|
||||||
# DashScope Qwen-Image 2.0 Pro (recommended for custom sizes and text rendering)
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image out.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
|
|
||||||
|
|
||||||
# DashScope legacy Qwen fixed-size model
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张电影感海报" --image out.png --provider dashscope --model qwen-image-max --size 1664x928
|
|
||||||
|
|
||||||
# Replicate (google/nano-banana-pro)
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
|
||||||
|
|
||||||
# Replicate with specific model
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
|
||||||
|
|
||||||
# Batch mode with saved prompt files
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json
|
|
||||||
|
|
||||||
# Batch mode with explicit worker count
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4 --json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Batch File Format
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"jobs": 4,
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"id": "hero",
|
|
||||||
"promptFiles": ["prompts/hero.md"],
|
|
||||||
"image": "out/hero.png",
|
|
||||||
"provider": "replicate",
|
|
||||||
"model": "google/nano-banana-pro",
|
|
||||||
"ar": "16:9",
|
|
||||||
"quality": "2k"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "diagram",
|
|
||||||
"promptFiles": ["prompts/diagram.md"],
|
|
||||||
"image": "out/diagram.png",
|
|
||||||
"ref": ["references/original.png"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch file's directory. `jobs` is optional (overridden by CLI `--jobs`). Top-level array format (without `jobs` wrapper) is also accepted.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
| Option | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `--prompt <text>`, `-p` | Prompt text |
|
|
||||||
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
|
||||||
| `--image <path>` | Output image path (required in single-image mode) |
|
|
||||||
| `--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) |
|
|
||||||
| `--provider google\|openai\|openrouter\|dashscope\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
|
|
||||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; OpenRouter: `google/gemini-3.1-flash-image-preview`; DashScope: `qwen-image-2.0-pro`) |
|
|
||||||
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
|
||||||
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
|
||||||
| `--quality normal\|2k` | Quality preset (default: `2k`) |
|
|
||||||
| `--imageSize 1K\|2K\|4K` | Image size for Google/OpenRouter (default: from quality) |
|
|
||||||
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, OpenRouter multimodal models, and Replicate. Not supported by Jimeng or Seedream |
|
|
||||||
| `--n <count>` | Number of images |
|
|
||||||
| `--json` | JSON output |
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `OPENAI_API_KEY` | OpenAI API key |
|
|
||||||
| `OPENROUTER_API_KEY` | OpenRouter API key |
|
|
||||||
| `GOOGLE_API_KEY` | Google API key |
|
|
||||||
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
|
|
||||||
| `REPLICATE_API_TOKEN` | Replicate API token |
|
|
||||||
| `JIMENG_ACCESS_KEY_ID` | Jimeng (即梦) Volcengine access key |
|
|
||||||
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine secret key |
|
|
||||||
| `ARK_API_KEY` | Seedream (豆包) Volcengine ARK API key |
|
|
||||||
| `OPENAI_IMAGE_MODEL` | OpenAI model override |
|
|
||||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) |
|
|
||||||
| `GOOGLE_IMAGE_MODEL` | Google model override |
|
|
||||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: `qwen-image-2.0-pro`) |
|
|
||||||
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
|
|
||||||
| `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) |
|
|
||||||
| `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) |
|
|
||||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint |
|
|
||||||
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint (default: `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 |
|
|
||||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
|
||||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
|
||||||
| `JIMENG_BASE_URL` | Custom Jimeng endpoint (default: `https://visual.volcengineapi.com`) |
|
|
||||||
| `JIMENG_REGION` | Jimeng region (default: `cn-north-1`) |
|
|
||||||
| `SEEDREAM_BASE_URL` | Custom Seedream endpoint (default: `https://ark.cn-beijing.volces.com/api/v3`) |
|
|
||||||
| `BAOYU_IMAGE_GEN_MAX_WORKERS` | Override batch worker cap |
|
|
||||||
| `BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY` | Override provider concurrency, e.g. `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY` |
|
|
||||||
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | Override provider start gap, e.g. `BAOYU_IMAGE_GEN_REPLICATE_START_INTERVAL_MS` |
|
|
||||||
|
|
||||||
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
|
||||||
|
|
||||||
## Model Resolution
|
|
||||||
|
|
||||||
Model priority (highest → lowest), applies to all providers:
|
|
||||||
|
|
||||||
1. CLI flag: `--model <id>`
|
|
||||||
2. EXTEND.md: `default_model.[provider]`
|
|
||||||
3. Env var: `<PROVIDER>_IMAGE_MODEL` (e.g., `GOOGLE_IMAGE_MODEL`)
|
|
||||||
4. Built-in default
|
|
||||||
|
|
||||||
**EXTEND.md overrides env vars**. If both EXTEND.md `default_model.google: "gemini-3-pro-image-preview"` and env var `GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image-preview` exist, EXTEND.md wins.
|
|
||||||
|
|
||||||
**Agent MUST display model info** before each generation:
|
|
||||||
- Show: `Using [provider] / [model]`
|
|
||||||
- Show switch hint: `Switch model: --model <id> | EXTEND.md default_model.[provider] | env <PROVIDER>_IMAGE_MODEL`
|
|
||||||
|
|
||||||
### DashScope Models
|
|
||||||
|
|
||||||
Use `--model qwen-image-2.0-pro` or set `default_model.dashscope` / `DASHSCOPE_IMAGE_MODEL` when the user wants official Qwen-Image behavior.
|
|
||||||
|
|
||||||
Official DashScope model families:
|
|
||||||
|
|
||||||
- `qwen-image-2.0-pro`, `qwen-image-2.0-pro-2026-03-03`, `qwen-image-2.0`, `qwen-image-2.0-2026-03-03`
|
|
||||||
- Free-form `size` in `宽*高` format
|
|
||||||
- Total pixels must stay between `512*512` and `2048*2048`
|
|
||||||
- Default size is approximately `1024*1024`
|
|
||||||
- Best choice for custom ratios such as `21:9` and text-heavy Chinese/English layouts
|
|
||||||
- `qwen-image-max`, `qwen-image-max-2025-12-30`, `qwen-image-plus`, `qwen-image-plus-2026-01-09`, `qwen-image`
|
|
||||||
- Fixed sizes only: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`
|
|
||||||
- Default size is `1664*928`
|
|
||||||
- `qwen-image` currently has the same capability as `qwen-image-plus`
|
|
||||||
- Legacy DashScope models such as `z-image-turbo`, `z-image-ultra`, `wanx-v1`
|
|
||||||
- Keep using them only when the user explicitly asks for legacy behavior or compatibility
|
|
||||||
|
|
||||||
When translating CLI args into DashScope behavior:
|
|
||||||
|
|
||||||
- `--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-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
|
|
||||||
|
|
||||||
Recommended `qwen-image-2.0*` sizes for common aspect ratios:
|
|
||||||
|
|
||||||
| Ratio | `normal` | `2k` |
|
|
||||||
|-------|----------|------|
|
|
||||||
| `1:1` | `1024*1024` | `1536*1536` |
|
|
||||||
| `2:3` | `768*1152` | `1024*1536` |
|
|
||||||
| `3:2` | `1152*768` | `1536*1024` |
|
|
||||||
| `3:4` | `960*1280` | `1080*1440` |
|
|
||||||
| `4:3` | `1280*960` | `1440*1080` |
|
|
||||||
| `9:16` | `720*1280` | `1080*1920` |
|
|
||||||
| `16:9` | `1280*720` | `1920*1080` |
|
|
||||||
| `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.
|
|
||||||
|
|
||||||
Official references:
|
|
||||||
|
|
||||||
- [Qwen-Image API](https://help.aliyun.com/zh/model-studio/qwen-image-api)
|
|
||||||
- [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)
|
|
||||||
|
|
||||||
### OpenRouter Models
|
|
||||||
|
|
||||||
Use full OpenRouter model IDs, e.g.:
|
|
||||||
|
|
||||||
- `google/gemini-3.1-flash-image-preview` (recommended, supports image output and reference-image workflows)
|
|
||||||
- `google/gemini-2.5-flash-image-preview`
|
|
||||||
- `black-forest-labs/flux.2-pro`
|
|
||||||
- Other OpenRouter image-capable model IDs
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- OpenRouter image generation uses `/chat/completions`, not the OpenAI `/images` endpoints
|
|
||||||
- If `--ref` is used, choose a multimodal model that supports image input and image output
|
|
||||||
- `--imageSize` maps to OpenRouter `imageGenerationOptions.size`; `--size <WxH>` is converted to the nearest OpenRouter size and inferred aspect ratio when possible
|
|
||||||
|
|
||||||
### Replicate Models
|
|
||||||
|
|
||||||
Supported model formats:
|
|
||||||
|
|
||||||
- `owner/name` (recommended for official models), e.g. `google/nano-banana-pro`
|
|
||||||
- `owner/name:version` (community models by version), e.g. `stability-ai/sdxl:<version>`
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Use Replicate default model
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
|
||||||
|
|
||||||
# Override model explicitly
|
|
||||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
2. `--provider` specified → use it (if `--ref`, must be `google`, `openai`, `openrouter`, or `replicate`)
|
|
||||||
3. Only one API key available → use that provider
|
|
||||||
4. Multiple available → default to Google
|
|
||||||
|
|
||||||
## Quality Presets
|
|
||||||
|
|
||||||
| Preset | Google imageSize | OpenAI Size | OpenRouter size | Replicate resolution | Use Case |
|
|
||||||
|--------|------------------|-------------|-----------------|----------------------|----------|
|
|
||||||
| `normal` | 1K | 1024px | 1K | 1K | Quick previews |
|
|
||||||
| `2k` (default) | 2K | 2048px | 2K | 2K | Covers, illustrations, infographics |
|
|
||||||
|
|
||||||
**Google/OpenRouter imageSize**: Can be overridden with `--imageSize 1K|2K|4K`
|
|
||||||
|
|
||||||
## Aspect Ratios
|
|
||||||
|
|
||||||
Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
|
||||||
|
|
||||||
- Google multimodal: uses `imageConfig.aspectRatio`
|
|
||||||
- OpenAI: maps to closest supported size
|
|
||||||
- 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`
|
|
||||||
|
|
||||||
## Generation Mode
|
|
||||||
|
|
||||||
**Default**: Sequential generation.
|
|
||||||
|
|
||||||
**Batch Parallel Generation**: When `--batchfile` contains 2 or more pending tasks, the script automatically enables parallel generation.
|
|
||||||
|
|
||||||
| Mode | When to Use |
|
|
||||||
|------|-------------|
|
|
||||||
| Sequential (default) | Normal usage, single images, small batches |
|
|
||||||
| Parallel batch | Batch mode with 2+ tasks |
|
|
||||||
|
|
||||||
Execution choice:
|
|
||||||
|
|
||||||
| Situation | Preferred approach | Why |
|
|
||||||
|-----------|--------------------|-----|
|
|
||||||
| One image, or 1-2 simple images | Sequential | Lower coordination overhead and easier debugging |
|
|
||||||
| Multiple images already have saved prompt files | Batch (`--batchfile`) | Reuses finalized prompts, applies shared throttling/retries, and gives predictable throughput |
|
|
||||||
| Each image still needs separate reasoning, prompt writing, or style exploration | Subagents | The work is still exploratory, so each image may need independent analysis before generation |
|
|
||||||
| Output comes from `baoyu-article-illustrator` with `outline.md` + `prompts/` | Batch (`build-batch.ts` -> `--batchfile`) | That workflow already produces prompt files, so direct batch execution is the intended path |
|
|
||||||
|
|
||||||
Rule of thumb:
|
|
||||||
|
|
||||||
- Prefer batch over subagents once prompt files are already saved and the task is "generate all of these"
|
|
||||||
- Use subagents only when generation is coupled with per-image thinking, rewriting, or divergent creative exploration
|
|
||||||
|
|
||||||
Parallel behavior:
|
|
||||||
|
|
||||||
- Default worker count is automatic, capped by config, built-in default 10
|
|
||||||
- Provider-specific throttling is applied only in batch mode, and the built-in defaults are tuned for faster throughput while still avoiding obvious RPM bursts
|
|
||||||
- You can override worker count with `--jobs <count>`
|
|
||||||
- Each image retries automatically up to 3 attempts
|
|
||||||
- Final output includes success count, failure count, and per-image failure reasons
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- Missing API key → error with setup instructions
|
|
||||||
- Generation failure → auto-retry up to 3 attempts per image
|
|
||||||
- Invalid aspect ratio → warning, proceed with default
|
|
||||||
- Reference images with unsupported provider/model → error with fix hint
|
|
||||||
|
|
||||||
## Extension Support
|
|
||||||
|
|
||||||
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.
|
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import type { CliArgs } from "../types";
|
|
||||||
|
|
||||||
export function getDefaultModel(): string {
|
|
||||||
return process.env.SEEDREAM_IMAGE_MODEL || "doubao-seedream-5-0-260128";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getApiKey(): string | null {
|
|
||||||
return process.env.ARK_API_KEY || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBaseUrl(): string {
|
|
||||||
return process.env.SEEDREAM_BASE_URL || "https://ark.cn-beijing.volces.com/api/v3";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert aspect ratio to Seedream size format
|
|
||||||
* Seedream API accepts: "2k" (default), "3k", or WIDTHxHEIGHT format
|
|
||||||
* Note: API uses lowercase "2k"/"3k", not "2K"/"3K"
|
|
||||||
*/
|
|
||||||
function getSeedreamSize(ar: string | null, quality: CliArgs["quality"], imageSize?: string | null): string {
|
|
||||||
// If explicit size is provided
|
|
||||||
if (imageSize) {
|
|
||||||
const upper = imageSize.toUpperCase();
|
|
||||||
if (upper === "2K" || upper === "3K") {
|
|
||||||
return upper.toLowerCase(); // API expects "2k" or "3k"
|
|
||||||
}
|
|
||||||
// For widthxheight format, pass through as-is
|
|
||||||
if (imageSize.includes("x")) {
|
|
||||||
return imageSize;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to 2k (smallest option supported by API)
|
|
||||||
return "2k";
|
|
||||||
}
|
|
||||||
|
|
||||||
type SeedreamImageResponse = {
|
|
||||||
model: string;
|
|
||||||
created: number;
|
|
||||||
data: Array<{
|
|
||||||
url: string;
|
|
||||||
size: string;
|
|
||||||
}>;
|
|
||||||
usage: {
|
|
||||||
generated_images: number;
|
|
||||||
output_tokens: number;
|
|
||||||
total_tokens: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function generateImage(
|
|
||||||
prompt: string,
|
|
||||||
model: string,
|
|
||||||
args: CliArgs
|
|
||||||
): Promise<Uint8Array> {
|
|
||||||
const apiKey = getApiKey();
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error(
|
|
||||||
"ARK_API_KEY is required. " +
|
|
||||||
"Get your API key from https://console.volcengine.com/ark"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = getBaseUrl();
|
|
||||||
const size = getSeedreamSize(args.aspectRatio, args.quality, args.imageSize);
|
|
||||||
|
|
||||||
console.error(`Calling Seedream API (${model}) with size: ${size}`);
|
|
||||||
|
|
||||||
const requestBody = {
|
|
||||||
model,
|
|
||||||
prompt,
|
|
||||||
size,
|
|
||||||
output_format: "png",
|
|
||||||
watermark: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}/images/generations`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const err = await response.text();
|
|
||||||
throw new Error(`Seedream API error (${response.status}): ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = (await response.json()) as SeedreamImageResponse;
|
|
||||||
|
|
||||||
if (!result.data || result.data.length === 0) {
|
|
||||||
throw new Error("No image data in Seedream response");
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageUrl = result.data[0].url;
|
|
||||||
if (!imageUrl) {
|
|
||||||
throw new Error("No image URL in Seedream response");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download image from URL
|
|
||||||
console.error(`Downloading image from ${imageUrl}...`);
|
|
||||||
const imgResponse = await fetch(imageUrl);
|
|
||||||
if (!imgResponse.ok) {
|
|
||||||
throw new Error(`Failed to download image from ${imageUrl}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await imgResponse.arrayBuffer();
|
|
||||||
return new Uint8Array(buffer);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
---
|
||||||
|
name: baoyu-imagine
|
||||||
|
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.4
|
||||||
|
metadata:
|
||||||
|
openclaw:
|
||||||
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-imagine
|
||||||
|
requires:
|
||||||
|
anyBins:
|
||||||
|
- bun
|
||||||
|
- npx
|
||||||
|
---
|
||||||
|
|
||||||
|
# Image Generation (AI SDK)
|
||||||
|
|
||||||
|
Official API-based image generation. Supports OpenAI, Azure OpenAI, Google, OpenRouter, DashScope (阿里通义万象), MiniMax, Jimeng (即梦), Seedream (豆包) and Replicate providers.
|
||||||
|
|
||||||
|
## Script Directory
|
||||||
|
|
||||||
|
**Agent Execution**:
|
||||||
|
1. `{baseDir}` = this SKILL.md file's directory
|
||||||
|
2. Script path = `{baseDir}/scripts/main.ts`
|
||||||
|
3. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun
|
||||||
|
|
||||||
|
## Step 0: Load Preferences ⛔ BLOCKING
|
||||||
|
|
||||||
|
**CRITICAL**: This step MUST complete BEFORE any image generation. Do NOT skip or defer.
|
||||||
|
|
||||||
|
Check EXTEND.md existence (priority: project → user):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS, Linux, WSL, Git Bash
|
||||||
|
test -f .baoyu-skills/baoyu-imagine/EXTEND.md && echo "project"
|
||||||
|
test -f "${XDG_CONFIG_HOME:-$HOME/.config}/baoyu-skills/baoyu-imagine/EXTEND.md" && echo "xdg"
|
||||||
|
test -f "$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md" && echo "user"
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# PowerShell (Windows)
|
||||||
|
if (Test-Path .baoyu-skills/baoyu-imagine/EXTEND.md) { "project" }
|
||||||
|
$xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { "$HOME/.config" }
|
||||||
|
if (Test-Path "$xdg/baoyu-skills/baoyu-imagine/EXTEND.md") { "xdg" }
|
||||||
|
if (Test-Path "$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md") { "user" }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Result | Action |
|
||||||
|
|--------|--------|
|
||||||
|
| Found | Load, parse, apply settings. If `default_model.[provider]` is null → ask model only (Flow 2) |
|
||||||
|
| Not found | ⛔ Run first-time setup ([references/config/first-time-setup.md](references/config/first-time-setup.md)) → Save EXTEND.md → Then continue |
|
||||||
|
|
||||||
|
**CRITICAL**: If not found, complete the full setup (provider + model + quality + save location) using AskUserQuestion BEFORE generating any images. Generation is BLOCKED until EXTEND.md is created.
|
||||||
|
|
||||||
|
| Path | Location |
|
||||||
|
|------|----------|
|
||||||
|
| `.baoyu-skills/baoyu-imagine/EXTEND.md` | Project directory |
|
||||||
|
| `$HOME/.baoyu-skills/baoyu-imagine/EXTEND.md` | User home |
|
||||||
|
|
||||||
|
Legacy compatibility: if `.baoyu-skills/baoyu-image-gen/EXTEND.md` exists and the new path does not, runtime renames it to `baoyu-imagine`. If both files exist, runtime leaves them unchanged and uses the new path.
|
||||||
|
|
||||||
|
**EXTEND.md Supports**: Default provider | Default quality | Default aspect ratio | Default image size | Default models | Batch worker cap | Provider-specific batch limits
|
||||||
|
|
||||||
|
Schema: `references/config/preferences-schema.md`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image cat.png
|
||||||
|
|
||||||
|
# With aspect ratio
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A landscape" --image out.png --ar 16:9
|
||||||
|
|
||||||
|
# High quality
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k
|
||||||
|
|
||||||
|
# From prompt files
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# With reference images (explicit provider/model)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png
|
||||||
|
|
||||||
|
# Azure OpenAI (model means deployment name)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider azure --model gpt-image-1.5
|
||||||
|
|
||||||
|
# OpenRouter (recommended default model)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openrouter
|
||||||
|
|
||||||
|
# OpenRouter with reference images
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider openrouter --model google/gemini-3.1-flash-image-preview --ref source.png
|
||||||
|
|
||||||
|
# Specific provider
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
|
||||||
|
|
||||||
|
# DashScope (阿里通义万象)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
|
||||||
|
|
||||||
|
# DashScope Qwen-Image 2.0 Pro (recommended for custom sizes and text rendering)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image out.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
|
||||||
|
|
||||||
|
# DashScope legacy Qwen fixed-size model
|
||||||
|
${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)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||||
|
|
||||||
|
# Replicate with specific model
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
||||||
|
|
||||||
|
# Batch mode with saved prompt files
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json
|
||||||
|
|
||||||
|
# Batch mode with explicit worker count
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --batchfile batch.json --jobs 4 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch File Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jobs": 4,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "hero",
|
||||||
|
"promptFiles": ["prompts/hero.md"],
|
||||||
|
"image": "out/hero.png",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model": "google/nano-banana-pro",
|
||||||
|
"ar": "16:9",
|
||||||
|
"quality": "2k"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "diagram",
|
||||||
|
"promptFiles": ["prompts/diagram.md"],
|
||||||
|
"image": "out/diagram.png",
|
||||||
|
"ref": ["references/original.png"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch file's directory. `jobs` is optional (overridden by CLI `--jobs`). Top-level array format (without `jobs` wrapper) is also accepted.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `--prompt <text>`, `-p` | Prompt text |
|
||||||
|
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
||||||
|
| `--image <path>` | Output image path (required in single-image mode) |
|
||||||
|
| `--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) |
|
||||||
|
| `--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`; MiniMax: `image-01`) |
|
||||||
|
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||||
|
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||||
|
| `--quality normal\|2k` | Quality preset (default: `2k`) |
|
||||||
|
| `--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, 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 |
|
||||||
|
| `--json` | JSON output |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `OPENAI_API_KEY` | OpenAI API key |
|
||||||
|
| `AZURE_OPENAI_API_KEY` | Azure OpenAI API key |
|
||||||
|
| `OPENROUTER_API_KEY` | OpenRouter API key |
|
||||||
|
| `GOOGLE_API_KEY` | Google API key |
|
||||||
|
| `DASHSCOPE_API_KEY` | DashScope API key (阿里云) |
|
||||||
|
| `MINIMAX_API_KEY` | MiniMax API key |
|
||||||
|
| `REPLICATE_API_TOKEN` | Replicate API token |
|
||||||
|
| `JIMENG_ACCESS_KEY_ID` | Jimeng (即梦) Volcengine access key |
|
||||||
|
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng (即梦) Volcengine secret key |
|
||||||
|
| `ARK_API_KEY` | Seedream (豆包) Volcengine ARK API key |
|
||||||
|
| `OPENAI_IMAGE_MODEL` | OpenAI model override |
|
||||||
|
| `AZURE_OPENAI_DEPLOYMENT` | Azure default deployment name |
|
||||||
|
| `AZURE_OPENAI_IMAGE_MODEL` | Backward-compatible alias for Azure default deployment/model name |
|
||||||
|
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) |
|
||||||
|
| `GOOGLE_IMAGE_MODEL` | Google model override |
|
||||||
|
| `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) |
|
||||||
|
| `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) |
|
||||||
|
| `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) |
|
||||||
|
| `OPENAI_BASE_URL` | Custom OpenAI endpoint |
|
||||||
|
| `AZURE_OPENAI_BASE_URL` | Azure resource endpoint or deployment endpoint |
|
||||||
|
| `AZURE_API_VERSION` | Azure image API version (default: `2025-04-01-preview`) |
|
||||||
|
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint (default: `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 |
|
||||||
|
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint |
|
||||||
|
| `MINIMAX_BASE_URL` | Custom MiniMax endpoint (default: `https://api.minimax.io`) |
|
||||||
|
| `REPLICATE_BASE_URL` | Custom Replicate endpoint |
|
||||||
|
| `JIMENG_BASE_URL` | Custom Jimeng endpoint (default: `https://visual.volcengineapi.com`) |
|
||||||
|
| `JIMENG_REGION` | Jimeng region (default: `cn-north-1`) |
|
||||||
|
| `SEEDREAM_BASE_URL` | Custom Seedream endpoint (default: `https://ark.cn-beijing.volces.com/api/v3`) |
|
||||||
|
| `BAOYU_IMAGE_GEN_MAX_WORKERS` | Override batch worker cap |
|
||||||
|
| `BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY` | Override provider concurrency, e.g. `BAOYU_IMAGE_GEN_REPLICATE_CONCURRENCY` |
|
||||||
|
| `BAOYU_IMAGE_GEN_<PROVIDER>_START_INTERVAL_MS` | Override provider start gap, e.g. `BAOYU_IMAGE_GEN_REPLICATE_START_INTERVAL_MS` |
|
||||||
|
|
||||||
|
**Load Priority**: CLI args > EXTEND.md > env vars > `<cwd>/.baoyu-skills/.env` > `~/.baoyu-skills/.env`
|
||||||
|
|
||||||
|
## Model Resolution
|
||||||
|
|
||||||
|
Model priority (highest → lowest), applies to all providers:
|
||||||
|
|
||||||
|
1. CLI flag: `--model <id>`
|
||||||
|
2. EXTEND.md: `default_model.[provider]`
|
||||||
|
3. Env var: `<PROVIDER>_IMAGE_MODEL` (e.g., `GOOGLE_IMAGE_MODEL`)
|
||||||
|
4. Built-in default
|
||||||
|
|
||||||
|
For Azure, `--model` / `default_model.azure` should be the Azure deployment name. `AZURE_OPENAI_DEPLOYMENT` is the preferred env var, and `AZURE_OPENAI_IMAGE_MODEL` remains as a backward-compatible alias.
|
||||||
|
|
||||||
|
**EXTEND.md overrides env vars**. If both EXTEND.md `default_model.google: "gemini-3-pro-image-preview"` and env var `GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image-preview` exist, EXTEND.md wins.
|
||||||
|
|
||||||
|
**Agent MUST display model info** before each generation:
|
||||||
|
- Show: `Using [provider] / [model]`
|
||||||
|
- Show switch hint: `Switch model: --model <id> | EXTEND.md default_model.[provider] | env <PROVIDER>_IMAGE_MODEL`
|
||||||
|
|
||||||
|
### DashScope Models
|
||||||
|
|
||||||
|
Use `--model qwen-image-2.0-pro` or set `default_model.dashscope` / `DASHSCOPE_IMAGE_MODEL` when the user wants official Qwen-Image behavior.
|
||||||
|
|
||||||
|
Official DashScope model families:
|
||||||
|
|
||||||
|
- `qwen-image-2.0-pro`, `qwen-image-2.0-pro-2026-03-03`, `qwen-image-2.0`, `qwen-image-2.0-2026-03-03`
|
||||||
|
- Free-form `size` in `宽*高` format
|
||||||
|
- Total pixels must stay between `512*512` and `2048*2048`
|
||||||
|
- Default size is approximately `1024*1024`
|
||||||
|
- Best choice for custom ratios such as `21:9` and text-heavy Chinese/English layouts
|
||||||
|
- `qwen-image-max`, `qwen-image-max-2025-12-30`, `qwen-image-plus`, `qwen-image-plus-2026-01-09`, `qwen-image`
|
||||||
|
- Fixed sizes only: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`
|
||||||
|
- Default size is `1664*928`
|
||||||
|
- `qwen-image` currently has the same capability as `qwen-image-plus`
|
||||||
|
- Legacy DashScope models such as `z-image-turbo`, `z-image-ultra`, `wanx-v1`
|
||||||
|
- Keep using them only when the user explicitly asks for legacy behavior or compatibility
|
||||||
|
|
||||||
|
When translating CLI args into DashScope behavior:
|
||||||
|
|
||||||
|
- `--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-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-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:
|
||||||
|
|
||||||
|
| Ratio | `normal` | `2k` |
|
||||||
|
|-------|----------|------|
|
||||||
|
| `1:1` | `1024*1024` | `1536*1536` |
|
||||||
|
| `2:3` | `768*1152` | `1024*1536` |
|
||||||
|
| `3:2` | `1152*768` | `1536*1024` |
|
||||||
|
| `3:4` | `960*1280` | `1080*1440` |
|
||||||
|
| `4:3` | `1280*960` | `1440*1080` |
|
||||||
|
| `9:16` | `720*1280` | `1080*1920` |
|
||||||
|
| `16:9` | `1280*720` | `1920*1080` |
|
||||||
|
| `21:9` | `1344*576` | `2048*872` |
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- [Qwen-Image API](https://help.aliyun.com/zh/model-studio/qwen-image-api)
|
||||||
|
- [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)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
Use full OpenRouter model IDs, e.g.:
|
||||||
|
|
||||||
|
- `google/gemini-3.1-flash-image-preview` (recommended, supports image output and reference-image workflows)
|
||||||
|
- `google/gemini-2.5-flash-image-preview`
|
||||||
|
- `black-forest-labs/flux.2-pro`
|
||||||
|
- Other OpenRouter image-capable model IDs
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- OpenRouter image generation uses `/chat/completions`, not the OpenAI `/images` endpoints
|
||||||
|
- If `--ref` is used, choose a multimodal model that supports image input and image output
|
||||||
|
- `--imageSize` maps to OpenRouter `imageGenerationOptions.size`; `--size <WxH>` is converted to the nearest OpenRouter size and inferred aspect ratio when possible
|
||||||
|
|
||||||
|
### Replicate Models
|
||||||
|
|
||||||
|
Supported model formats:
|
||||||
|
|
||||||
|
- `owner/name` (recommended for official models), e.g. `google/nano-banana-pro`
|
||||||
|
- `owner/name:version` (community models by version), e.g. `stability-ai/sdxl:<version>`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use Replicate default model
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||||
|
|
||||||
|
# Override model explicitly
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate --model google/nano-banana
|
||||||
|
```
|
||||||
|
|
||||||
|
## Provider Selection
|
||||||
|
|
||||||
|
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`, `azure`, `openrouter`, `replicate`, `seedream`, or `minimax`)
|
||||||
|
3. Only one API key available → use that provider
|
||||||
|
4. Multiple available → default to Google
|
||||||
|
|
||||||
|
## Quality Presets
|
||||||
|
|
||||||
|
| Preset | Google imageSize | OpenAI Size | OpenRouter size | Replicate resolution | Use Case |
|
||||||
|
|--------|------------------|-------------|-----------------|----------------------|----------|
|
||||||
|
| `normal` | 1K | 1024px | 1K | 1K | Quick previews |
|
||||||
|
| `2k` (default) | 2K | 2048px | 2K | 2K | Covers, illustrations, infographics |
|
||||||
|
|
||||||
|
**Google/OpenRouter imageSize**: Can be overridden with `--imageSize 1K|2K|4K`
|
||||||
|
|
||||||
|
## Aspect Ratios
|
||||||
|
|
||||||
|
Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
||||||
|
|
||||||
|
- Google multimodal: uses `imageConfig.aspectRatio`
|
||||||
|
- OpenAI: maps to closest supported size
|
||||||
|
- 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`
|
||||||
|
- MiniMax: sends official `aspect_ratio` values directly; if `--size <WxH>` is given without `--ar`, `width` / `height` are sent for `image-01`
|
||||||
|
|
||||||
|
## Generation Mode
|
||||||
|
|
||||||
|
**Default**: Sequential generation.
|
||||||
|
|
||||||
|
**Batch Parallel Generation**: When `--batchfile` contains 2 or more pending tasks, the script automatically enables parallel generation.
|
||||||
|
|
||||||
|
| Mode | When to Use |
|
||||||
|
|------|-------------|
|
||||||
|
| Sequential (default) | Normal usage, single images, small batches |
|
||||||
|
| Parallel batch | Batch mode with 2+ tasks |
|
||||||
|
|
||||||
|
Execution choice:
|
||||||
|
|
||||||
|
| Situation | Preferred approach | Why |
|
||||||
|
|-----------|--------------------|-----|
|
||||||
|
| One image, or 1-2 simple images | Sequential | Lower coordination overhead and easier debugging |
|
||||||
|
| Multiple images already have saved prompt files | Batch (`--batchfile`) | Reuses finalized prompts, applies shared throttling/retries, and gives predictable throughput |
|
||||||
|
| Each image still needs separate reasoning, prompt writing, or style exploration | Subagents | The work is still exploratory, so each image may need independent analysis before generation |
|
||||||
|
| Output comes from `baoyu-article-illustrator` with `outline.md` + `prompts/` | Batch (`build-batch.ts` -> `--batchfile`) | That workflow already produces prompt files, so direct batch execution is the intended path |
|
||||||
|
|
||||||
|
Rule of thumb:
|
||||||
|
|
||||||
|
- Prefer batch over subagents once prompt files are already saved and the task is "generate all of these"
|
||||||
|
- Use subagents only when generation is coupled with per-image thinking, rewriting, or divergent creative exploration
|
||||||
|
|
||||||
|
Parallel behavior:
|
||||||
|
|
||||||
|
- Default worker count is automatic, capped by config, built-in default 10
|
||||||
|
- Provider-specific throttling is applied only in batch mode, and the built-in defaults are tuned for faster throughput while still avoiding obvious RPM bursts
|
||||||
|
- You can override worker count with `--jobs <count>`
|
||||||
|
- Each image retries automatically up to 3 attempts
|
||||||
|
- Final output includes success count, failure count, and per-image failure reasons
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Missing API key → error with setup instructions
|
||||||
|
- Generation failure → auto-retry up to 3 attempts per image
|
||||||
|
- Invalid aspect ratio → warning, proceed with default
|
||||||
|
- Reference images with unsupported provider/model → error with fix hint
|
||||||
|
|
||||||
|
## Extension Support
|
||||||
|
|
||||||
|
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.
|
||||||
+75
-4
@@ -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
|
||||||
@@ -47,10 +47,14 @@ options:
|
|||||||
description: "Gemini multimodal - high quality, reference images, flexible sizes"
|
description: "Gemini multimodal - high quality, reference images, flexible sizes"
|
||||||
- label: "OpenAI"
|
- label: "OpenAI"
|
||||||
description: "GPT Image - consistent quality, reliable output"
|
description: "GPT Image - consistent quality, reliable output"
|
||||||
|
- label: "Azure OpenAI"
|
||||||
|
description: "Azure-hosted GPT Image deployments with resource-specific routing"
|
||||||
- label: "OpenRouter"
|
- label: "OpenRouter"
|
||||||
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"
|
||||||
```
|
```
|
||||||
@@ -87,6 +91,34 @@ options:
|
|||||||
description: "Strong text-to-image quality through OpenRouter"
|
description: "Strong text-to-image quality through OpenRouter"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Question 2c: Default Azure Deployment
|
||||||
|
|
||||||
|
Only show if user selected Azure OpenAI.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Azure Deploy"
|
||||||
|
question: "Default Azure image deployment name?"
|
||||||
|
options:
|
||||||
|
- label: "gpt-image-1.5 (Recommended)"
|
||||||
|
description: "Best default if your Azure deployment uses the same name"
|
||||||
|
- label: "gpt-image-1"
|
||||||
|
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
|
||||||
@@ -115,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
|
||||||
|
|
||||||
@@ -130,8 +162,10 @@ default_image_size: null
|
|||||||
default_model:
|
default_model:
|
||||||
google: [selected google model or null]
|
google: [selected google model or null]
|
||||||
openai: null
|
openai: 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
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
@@ -166,6 +200,23 @@ options:
|
|||||||
description: "Previous generation GPT Image model"
|
description: "Previous generation GPT Image model"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Azure Deployment Selection
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
header: "Azure Deploy"
|
||||||
|
question: "Choose a default Azure image deployment name?"
|
||||||
|
options:
|
||||||
|
- label: "gpt-image-1.5 (Recommended)"
|
||||||
|
description: "Use when your Azure deployment name matches the GPT-image-1.5 model"
|
||||||
|
- label: "gpt-image-1"
|
||||||
|
description: "Use when your Azure deployment name matches GPT-image-1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes for Azure setup:
|
||||||
|
|
||||||
|
- 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`.
|
||||||
|
|
||||||
### OpenRouter Model Selection
|
### OpenRouter Model Selection
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -204,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
|
||||||
|
|
||||||
@@ -218,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:
|
||||||
@@ -230,8 +299,10 @@ After user selects a model:
|
|||||||
default_model:
|
default_model:
|
||||||
google: [value or null]
|
google: [value or null]
|
||||||
openai: [value or null]
|
openai: [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]
|
||||||
```
|
```
|
||||||
|
|
||||||
+20
-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|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)
|
||||||
|
|
||||||
@@ -22,8 +22,10 @@ default_image_size: null # 1K|2K|4K|null (Google/OpenRouter, overrides qualit
|
|||||||
default_model:
|
default_model:
|
||||||
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
||||||
openai: null # e.g., "gpt-image-1.5", "gpt-image-1"
|
openai: null # e.g., "gpt-image-1.5", "gpt-image-1"
|
||||||
|
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:
|
||||||
@@ -38,12 +40,18 @@ batch:
|
|||||||
openai:
|
openai:
|
||||||
concurrency: 3
|
concurrency: 3
|
||||||
start_interval_ms: 1100
|
start_interval_ms: 1100
|
||||||
|
azure:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
openrouter:
|
openrouter:
|
||||||
concurrency: 3
|
concurrency: 3
|
||||||
start_interval_ms: 1100
|
start_interval_ms: 1100
|
||||||
dashscope:
|
dashscope:
|
||||||
concurrency: 3
|
concurrency: 3
|
||||||
start_interval_ms: 1100
|
start_interval_ms: 1100
|
||||||
|
minimax:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -58,8 +66,10 @@ batch:
|
|||||||
| `default_image_size` | string\|null | null | Google/OpenRouter image size (overrides quality) |
|
| `default_image_size` | string\|null | null | Google/OpenRouter image size (overrides quality) |
|
||||||
| `default_model.google` | string\|null | null | Google default model |
|
| `default_model.google` | string\|null | null | Google default model |
|
||||||
| `default_model.openai` | string\|null | null | OpenAI default model |
|
| `default_model.openai` | string\|null | null | OpenAI default model |
|
||||||
|
| `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 |
|
||||||
@@ -87,8 +97,10 @@ default_image_size: 2K
|
|||||||
default_model:
|
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: "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
|
||||||
@@ -96,8 +108,14 @@ batch:
|
|||||||
replicate:
|
replicate:
|
||||||
concurrency: 5
|
concurrency: 5
|
||||||
start_interval_ms: 700
|
start_interval_ms: 700
|
||||||
|
azure:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
openrouter:
|
openrouter:
|
||||||
concurrency: 3
|
concurrency: 3
|
||||||
start_interval_ms: 1100
|
start_interval_ms: 1100
|
||||||
|
minimax:
|
||||||
|
concurrency: 3
|
||||||
|
start_interval_ms: 1100
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
+165
-2
@@ -13,6 +13,7 @@ import {
|
|||||||
getWorkerCount,
|
getWorkerCount,
|
||||||
isRetryableGenerationError,
|
isRetryableGenerationError,
|
||||||
loadBatchTasks,
|
loadBatchTasks,
|
||||||
|
loadExtendConfig,
|
||||||
mergeConfig,
|
mergeConfig,
|
||||||
normalizeOutputImagePath,
|
normalizeOutputImagePath,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
@@ -69,7 +70,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",
|
||||||
@@ -123,6 +124,8 @@ default_image_size: 2K
|
|||||||
default_model:
|
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
|
||||||
|
minimax: image-01
|
||||||
batch:
|
batch:
|
||||||
max_workers: 8
|
max_workers: 8
|
||||||
provider_limits:
|
provider_limits:
|
||||||
@@ -131,6 +134,12 @@ batch:
|
|||||||
start_interval_ms: 900
|
start_interval_ms: 900
|
||||||
openai:
|
openai:
|
||||||
concurrency: 4
|
concurrency: 4
|
||||||
|
minimax:
|
||||||
|
concurrency: 2
|
||||||
|
start_interval_ms: 1400
|
||||||
|
azure:
|
||||||
|
concurrency: 1
|
||||||
|
start_interval_ms: 1500
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const config = parseSimpleYaml(yaml);
|
const config = parseSimpleYaml(yaml);
|
||||||
@@ -142,6 +151,8 @@ batch:
|
|||||||
assert.equal(config.default_image_size, "2K");
|
assert.equal(config.default_image_size, "2K");
|
||||||
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?.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,
|
||||||
@@ -150,6 +161,69 @@ 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, {
|
||||||
|
concurrency: 1,
|
||||||
|
start_interval_ms: 1500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadExtendConfig renames legacy EXTEND.md when the new path is missing", async () => {
|
||||||
|
const root = await makeTempDir("baoyu-imagine-extend-");
|
||||||
|
const cwd = path.join(root, "project");
|
||||||
|
const home = path.join(root, "home");
|
||||||
|
const legacyPath = path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md");
|
||||||
|
const currentPath = path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md");
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
|
||||||
|
await fs.mkdir(home, { recursive: true });
|
||||||
|
await fs.writeFile(legacyPath, `---
|
||||||
|
default_provider: google
|
||||||
|
default_quality: 2k
|
||||||
|
---
|
||||||
|
`);
|
||||||
|
|
||||||
|
const config = await loadExtendConfig(cwd, home);
|
||||||
|
|
||||||
|
assert.equal(config.default_provider, "google");
|
||||||
|
assert.equal(config.default_quality, "2k");
|
||||||
|
await fs.access(currentPath);
|
||||||
|
await assert.rejects(() => fs.access(legacyPath));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadExtendConfig leaves legacy EXTEND.md untouched when both paths exist", async () => {
|
||||||
|
const root = await makeTempDir("baoyu-imagine-extend-dual-");
|
||||||
|
const cwd = path.join(root, "project");
|
||||||
|
const home = path.join(root, "home");
|
||||||
|
const legacyPath = path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md");
|
||||||
|
const currentPath = path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md");
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
|
||||||
|
await fs.mkdir(path.dirname(currentPath), { recursive: true });
|
||||||
|
await fs.mkdir(home, { recursive: true });
|
||||||
|
await fs.writeFile(legacyPath, `---
|
||||||
|
default_provider: google
|
||||||
|
---
|
||||||
|
`);
|
||||||
|
await fs.writeFile(currentPath, `---
|
||||||
|
default_provider: openai
|
||||||
|
---
|
||||||
|
`);
|
||||||
|
|
||||||
|
const config = await loadExtendConfig(cwd, home);
|
||||||
|
|
||||||
|
assert.equal(config.default_provider, "openai");
|
||||||
|
assert.equal(await fs.readFile(legacyPath, "utf8"), `---
|
||||||
|
default_provider: google
|
||||||
|
---
|
||||||
|
`);
|
||||||
|
assert.equal(await fs.readFile(currentPath, "utf8"), `---
|
||||||
|
default_provider: openai
|
||||||
|
---
|
||||||
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mergeConfig only fills values missing from CLI args", () => {
|
test("mergeConfig only fills values missing from CLI args", () => {
|
||||||
@@ -191,6 +265,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,
|
||||||
@@ -203,8 +278,11 @@ test("detectProvider selects an available ref-capable provider for reference-ima
|
|||||||
useEnv(t, {
|
useEnv(t, {
|
||||||
GOOGLE_API_KEY: null,
|
GOOGLE_API_KEY: null,
|
||||||
OPENAI_API_KEY: "openai-key",
|
OPENAI_API_KEY: "openai-key",
|
||||||
|
AZURE_OPENAI_API_KEY: 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,
|
||||||
@@ -216,6 +294,82 @@ test("detectProvider selects an available ref-capable provider for reference-ima
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("detectProvider selects Azure when only Azure credentials are configured", (t) => {
|
||||||
|
useEnv(t, {
|
||||||
|
GOOGLE_API_KEY: null,
|
||||||
|
OPENAI_API_KEY: null,
|
||||||
|
AZURE_OPENAI_API_KEY: "azure-key",
|
||||||
|
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com",
|
||||||
|
OPENROUTER_API_KEY: null,
|
||||||
|
DASHSCOPE_API_KEY: null,
|
||||||
|
MINIMAX_API_KEY: null,
|
||||||
|
REPLICATE_API_TOKEN: null,
|
||||||
|
JIMENG_ACCESS_KEY_ID: null,
|
||||||
|
JIMENG_SECRET_ACCESS_KEY: null,
|
||||||
|
ARK_API_KEY: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(detectProvider(makeArgs()), "azure");
|
||||||
|
assert.equal(
|
||||||
|
detectProvider(makeArgs({ referenceImages: ["ref.png"] })),
|
||||||
|
"azure",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectProvider infers Seedream from model id and allows Seedream reference-image workflows", (t) => {
|
||||||
|
useEnv(t, {
|
||||||
|
GOOGLE_API_KEY: null,
|
||||||
|
OPENAI_API_KEY: null,
|
||||||
|
OPENROUTER_API_KEY: null,
|
||||||
|
DASHSCOPE_API_KEY: null,
|
||||||
|
MINIMAX_API_KEY: null,
|
||||||
|
REPLICATE_API_TOKEN: null,
|
||||||
|
JIMENG_ACCESS_KEY_ID: null,
|
||||||
|
JIMENG_SECRET_ACCESS_KEY: null,
|
||||||
|
ARK_API_KEY: "ark-key",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
detectProvider(
|
||||||
|
makeArgs({
|
||||||
|
model: "doubao-seedream-4-5-251128",
|
||||||
|
referenceImages: ["ref.png"],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
"seedream",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
detectProvider(
|
||||||
|
makeArgs({
|
||||||
|
provider: "seedream",
|
||||||
|
referenceImages: ["ref.png"],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
"seedream",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
@@ -231,6 +385,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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -240,10 +398,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");
|
||||||
@@ -294,6 +456,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
|||||||
|
|
||||||
test("path normalization, worker count, and retry classification follow expected rules", () => {
|
test("path normalization, worker count, and retry classification follow expected rules", () => {
|
||||||
assert.match(normalizeOutputImagePath("out/sample"), /out[\\/]+sample\.png$/);
|
assert.match(normalizeOutputImagePath("out/sample"), /out[\\/]+sample\.png$/);
|
||||||
|
assert.match(normalizeOutputImagePath("out/sample", ".jpg"), /out[\\/]+sample\.jpg$/);
|
||||||
assert.match(normalizeOutputImagePath("out/sample.webp"), /out[\\/]+sample\.webp$/);
|
assert.match(normalizeOutputImagePath("out/sample.webp"), /out[\\/]+sample\.webp$/);
|
||||||
|
|
||||||
assert.equal(getWorkerCount(8, null, 3), 3);
|
assert.equal(getWorkerCount(8, null, 3), 3);
|
||||||
@@ -2,7 +2,7 @@ import path from "node:path";
|
|||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||||
import type {
|
import type {
|
||||||
BatchFile,
|
BatchFile,
|
||||||
BatchTaskInput,
|
BatchTaskInput,
|
||||||
@@ -14,6 +14,8 @@ import type {
|
|||||||
type ProviderModule = {
|
type ProviderModule = {
|
||||||
getDefaultModel: () => string;
|
getDefaultModel: () => string;
|
||||||
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
|
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
|
||||||
|
validateArgs?: (model: string, args: CliArgs) => void;
|
||||||
|
getDefaultOutputExtension?: (model: string, args: CliArgs) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PreparedTask = {
|
type PreparedTask = {
|
||||||
@@ -56,8 +58,10 @@ 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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
function printUsage(): void {
|
function printUsage(): void {
|
||||||
@@ -72,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 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 multimodal, OpenAI GPT Image edits, OpenRouter multimodal, or Replicate)
|
--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
|
||||||
@@ -109,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
|
||||||
@@ -117,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)
|
||||||
@@ -127,8 +133,14 @@ 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_BASE_URL Azure OpenAI resource or deployment endpoint
|
||||||
|
AZURE_OPENAI_DEPLOYMENT Default Azure deployment name
|
||||||
|
AZURE_API_VERSION Azure API version (default: 2025-04-01-preview)
|
||||||
|
AZURE_OPENAI_IMAGE_MODEL Backward-compatible Azure deployment/model alias (defaults to gpt-image-1.5)
|
||||||
SEEDREAM_BASE_URL Custom Seedream endpoint
|
SEEDREAM_BASE_URL Custom Seedream endpoint
|
||||||
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
|
BAOYU_IMAGE_GEN_MAX_WORKERS Override batch worker cap
|
||||||
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency
|
BAOYU_IMAGE_GEN_<PROVIDER>_CONCURRENCY Override provider concurrency
|
||||||
@@ -227,9 +239,11 @@ 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" &&
|
||||||
|
v !== "azure"
|
||||||
) {
|
) {
|
||||||
throw new Error(`Invalid provider: ${v}`);
|
throw new Error(`Invalid provider: ${v}`);
|
||||||
}
|
}
|
||||||
@@ -381,9 +395,11 @@ 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,
|
||||||
|
azure: null,
|
||||||
};
|
};
|
||||||
currentKey = "default_model";
|
currentKey = "default_model";
|
||||||
currentProvider = null;
|
currentProvider = null;
|
||||||
@@ -407,9 +423,11 @@ 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" ||
|
||||||
|
key === "azure"
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
config.batch ??= {};
|
config.batch ??= {};
|
||||||
@@ -423,9 +441,11 @@ 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" ||
|
||||||
|
key === "azure"
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
const cleaned = value.replace(/['"]/g, "");
|
const cleaned = value.replace(/['"]/g, "");
|
||||||
@@ -451,14 +471,49 @@ export function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadExtendConfig(): Promise<Partial<ExtendConfig>> {
|
type ExtendConfigPathPair = {
|
||||||
const home = homedir();
|
current: string;
|
||||||
const cwd = process.cwd();
|
legacy: string;
|
||||||
|
};
|
||||||
|
|
||||||
const paths = [
|
function getExtendConfigPathPairs(cwd: string, home: string): ExtendConfigPathPair[] {
|
||||||
path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
return [
|
||||||
path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
{
|
||||||
|
current: path.join(cwd, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),
|
||||||
|
legacy: path.join(cwd, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
current: path.join(home, ".baoyu-skills", "baoyu-imagine", "EXTEND.md"),
|
||||||
|
legacy: path.join(home, ".baoyu-skills", "baoyu-image-gen", "EXTEND.md"),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateLegacyExtendConfig(cwd: string, home: string): Promise<void> {
|
||||||
|
for (const { current, legacy } of getExtendConfigPathPairs(cwd, home)) {
|
||||||
|
const [hasCurrent, hasLegacy] = await Promise.all([exists(current), exists(legacy)]);
|
||||||
|
if (hasCurrent || !hasLegacy) continue;
|
||||||
|
await mkdir(path.dirname(current), { recursive: true });
|
||||||
|
await rename(legacy, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadExtendConfig(
|
||||||
|
cwd = process.cwd(),
|
||||||
|
home = homedir(),
|
||||||
|
): Promise<Partial<ExtendConfig>> {
|
||||||
|
await migrateLegacyExtendConfig(cwd, home);
|
||||||
|
|
||||||
|
const paths = getExtendConfigPathPairs(cwd, home).map(({ current }) => current);
|
||||||
|
|
||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
try {
|
try {
|
||||||
@@ -516,11 +571,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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream"] 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] = {
|
||||||
@@ -560,11 +617,19 @@ async function readPromptFromStdin(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeOutputImagePath(p: string): string {
|
export function normalizeOutputImagePath(p: string, defaultExtension = ".png"): string {
|
||||||
const full = path.resolve(p);
|
const full = path.resolve(p);
|
||||||
const ext = path.extname(full);
|
const ext = path.extname(full);
|
||||||
if (ext) return full;
|
if (ext) return full;
|
||||||
return `${full}.png`;
|
return `${full}${defaultExtension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferProviderFromModel(model: string | null): Provider | null {
|
||||||
|
if (!model) return null;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectProvider(args: CliArgs): Provider {
|
export function detectProvider(args: CliArgs): Provider {
|
||||||
@@ -573,39 +638,64 @@ export function detectProvider(args: CliArgs): Provider {
|
|||||||
args.provider &&
|
args.provider &&
|
||||||
args.provider !== "google" &&
|
args.provider !== "google" &&
|
||||||
args.provider !== "openai" &&
|
args.provider !== "openai" &&
|
||||||
|
args.provider !== "azure" &&
|
||||||
args.provider !== "openrouter" &&
|
args.provider !== "openrouter" &&
|
||||||
args.provider !== "replicate"
|
args.provider !== "replicate" &&
|
||||||
|
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 openrouter (OpenRouter multimodal), or --provider replicate."
|
"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."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.provider) return args.provider;
|
if (args.provider) return args.provider;
|
||||||
|
|
||||||
const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
|
const hasGoogle = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
|
||||||
|
const hasAzure = !!(process.env.AZURE_OPENAI_API_KEY && process.env.AZURE_OPENAI_BASE_URL);
|
||||||
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;
|
||||||
|
const modelProvider = inferProviderFromModel(args.model);
|
||||||
|
|
||||||
|
if (modelProvider === "seedream") {
|
||||||
|
if (!hasSeedream) {
|
||||||
|
throw new Error("Model looks like a Volcengine ARK image model, but ARK_API_KEY is not set.");
|
||||||
|
}
|
||||||
|
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";
|
||||||
|
if (hasAzure) return "azure";
|
||||||
if (hasOpenrouter) return "openrouter";
|
if (hasOpenrouter) return "openrouter";
|
||||||
if (hasReplicate) return "replicate";
|
if (hasReplicate) return "replicate";
|
||||||
|
if (hasSeedream) return "seedream";
|
||||||
|
if (hasMinimax) return "minimax";
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Reference images require Google, OpenAI, OpenRouter, or Replicate. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, or REPLICATE_API_TOKEN, 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."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = [
|
const available = [
|
||||||
hasGoogle && "google",
|
hasGoogle && "google",
|
||||||
hasOpenai && "openai",
|
hasOpenai && "openai",
|
||||||
|
hasAzure && "azure",
|
||||||
hasOpenrouter && "openrouter",
|
hasOpenrouter && "openrouter",
|
||||||
hasDashscope && "dashscope",
|
hasDashscope && "dashscope",
|
||||||
|
hasMinimax && "minimax",
|
||||||
hasReplicate && "replicate",
|
hasReplicate && "replicate",
|
||||||
hasJimeng && "jimeng",
|
hasJimeng && "jimeng",
|
||||||
hasSeedream && "seedream",
|
hasSeedream && "seedream",
|
||||||
@@ -615,7 +705,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, 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."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -654,10 +744,12 @@ 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;
|
||||||
if (provider === "seedream") return (await import("./providers/seedream")) as ProviderModule;
|
if (provider === "seedream") return (await import("./providers/seedream")) as ProviderModule;
|
||||||
|
if (provider === "azure") return (await import("./providers/azure")) as ProviderModule;
|
||||||
return (await import("./providers/openai")) as ProviderModule;
|
return (await import("./providers/openai")) as ProviderModule;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,9 +775,11 @@ 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;
|
||||||
|
if (provider === "azure" && extendConfig.default_model.azure) return extendConfig.default_model.azure;
|
||||||
}
|
}
|
||||||
return providerModule.getDefaultModel();
|
return providerModule.getDefaultModel();
|
||||||
}
|
}
|
||||||
@@ -701,6 +795,8 @@ async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConf
|
|||||||
const provider = detectProvider(args);
|
const provider = detectProvider(args);
|
||||||
const providerModule = await loadProviderModule(provider);
|
const providerModule = await loadProviderModule(provider);
|
||||||
const model = getModelForProvider(provider, args.model, extendConfig, providerModule);
|
const model = getModelForProvider(provider, args.model, extendConfig, providerModule);
|
||||||
|
providerModule.validateArgs?.(model, args);
|
||||||
|
const defaultOutputExtension = providerModule.getDefaultOutputExtension?.(model, args) ?? ".png";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: "single",
|
id: "single",
|
||||||
@@ -708,7 +804,7 @@ async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConf
|
|||||||
args,
|
args,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
outputPath: normalizeOutputImagePath(args.imagePath),
|
outputPath: normalizeOutputImagePath(args.imagePath, defaultOutputExtension),
|
||||||
providerModule,
|
providerModule,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -784,13 +880,15 @@ async function prepareBatchTasks(
|
|||||||
const provider = detectProvider(taskArgs);
|
const provider = detectProvider(taskArgs);
|
||||||
const providerModule = await loadProviderModule(provider);
|
const providerModule = await loadProviderModule(provider);
|
||||||
const model = getModelForProvider(provider, taskArgs.model, extendConfig, providerModule);
|
const model = getModelForProvider(provider, taskArgs.model, extendConfig, providerModule);
|
||||||
|
providerModule.validateArgs?.(model, taskArgs);
|
||||||
|
const defaultOutputExtension = providerModule.getDefaultOutputExtension?.(model, taskArgs) ?? ".png";
|
||||||
prepared.push({
|
prepared.push({
|
||||||
id: task.id || `task-${String(i + 1).padStart(2, "0")}`,
|
id: task.id || `task-${String(i + 1).padStart(2, "0")}`,
|
||||||
prompt,
|
prompt,
|
||||||
args: taskArgs,
|
args: taskArgs,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
outputPath: normalizeOutputImagePath(taskArgs.imagePath),
|
outputPath: normalizeOutputImagePath(taskArgs.imagePath, defaultOutputExtension),
|
||||||
providerModule,
|
providerModule,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -901,7 +999,7 @@ async function runBatchTasks(
|
|||||||
const acquireProvider = createProviderGate(providerRateLimits);
|
const acquireProvider = createProviderGate(providerRateLimits);
|
||||||
const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);
|
const workerCount = getWorkerCount(tasks.length, jobs, maxWorkers);
|
||||||
console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`);
|
console.error(`Batch mode: ${tasks.length} tasks, ${workerCount} workers, parallel mode enabled.`);
|
||||||
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream"] as Provider[]) {
|
for (const provider of ["replicate", "google", "openai", "openrouter", "dashscope", "jimeng", "seedream", "azure"] as Provider[]) {
|
||||||
const limit = providerRateLimits[provider];
|
const limit = providerRateLimits[provider];
|
||||||
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
|
console.error(`- ${provider}: concurrency=${limit.concurrency}, startIntervalMs=${limit.startIntervalMs}`);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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 {
|
||||||
|
generateImage,
|
||||||
|
getDefaultModel,
|
||||||
|
parseAzureBaseURL,
|
||||||
|
validateArgs,
|
||||||
|
} from "./azure.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeTempDir(prefix: string): Promise<string> {
|
||||||
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Azure endpoint parsing and default deployment selection follow env precedence", (t) => {
|
||||||
|
assert.deepEqual(parseAzureBaseURL("https://example.openai.azure.com"), {
|
||||||
|
resourceBaseURL: "https://example.openai.azure.com/openai",
|
||||||
|
deployment: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
parseAzureBaseURL("https://example.openai.azure.com/openai/deployments/from-url"),
|
||||||
|
{
|
||||||
|
resourceBaseURL: "https://example.openai.azure.com/openai",
|
||||||
|
deployment: "from-url",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEnv(t, {
|
||||||
|
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com/openai/deployments/from-url",
|
||||||
|
AZURE_OPENAI_DEPLOYMENT: "explicit-deploy",
|
||||||
|
AZURE_OPENAI_IMAGE_MODEL: "env-fallback",
|
||||||
|
});
|
||||||
|
assert.equal(getDefaultModel(), "explicit-deploy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Azure validateArgs rejects unsupported edit input formats before the API call", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
validateArgs("demo-deployment", makeArgs({ referenceImages: ["hero.png", "photo.jpeg"] })),
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs("demo-deployment", makeArgs({ referenceImages: ["hero.webp"] })),
|
||||||
|
/PNG or JPG\/JPEG/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Azure image generation routes model to deployment and sends mapped quality", async (t) => {
|
||||||
|
useEnv(t, {
|
||||||
|
AZURE_OPENAI_API_KEY: "azure-key",
|
||||||
|
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com/openai/deployments/default-deploy",
|
||||||
|
AZURE_API_VERSION: null,
|
||||||
|
AZURE_OPENAI_DEPLOYMENT: null,
|
||||||
|
AZURE_OPENAI_IMAGE_MODEL: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls: Array<{ url: string; body: string }> = [];
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
calls.push({
|
||||||
|
url: String(input),
|
||||||
|
body: String(init?.body ?? ""),
|
||||||
|
});
|
||||||
|
return Response.json({
|
||||||
|
data: [{ b64_json: Buffer.from("azure-image").toString("base64") }],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const bytes = await generateImage(
|
||||||
|
"A calm lake at sunset",
|
||||||
|
"custom-deploy",
|
||||||
|
makeArgs({ quality: "normal" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(Buffer.from(bytes).toString("utf8"), "azure-image");
|
||||||
|
assert.equal(
|
||||||
|
calls[0]?.url,
|
||||||
|
"https://example.openai.azure.com/openai/deployments/custom-deploy/images/generations?api-version=2025-04-01-preview",
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = JSON.parse(calls[0]!.body) as Record<string, string>;
|
||||||
|
assert.equal(body.quality, "medium");
|
||||||
|
assert.equal(body.size, "1024x1024");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Azure image edits include quality in multipart requests", async (t) => {
|
||||||
|
const root = await makeTempDir("baoyu-imagine-azure-");
|
||||||
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const pngPath = path.join(root, "ref.png");
|
||||||
|
const jpgPath = path.join(root, "ref.jpg");
|
||||||
|
await fs.writeFile(pngPath, "png-bytes");
|
||||||
|
await fs.writeFile(jpgPath, "jpg-bytes");
|
||||||
|
|
||||||
|
useEnv(t, {
|
||||||
|
AZURE_OPENAI_API_KEY: "azure-key",
|
||||||
|
AZURE_OPENAI_BASE_URL: "https://example.openai.azure.com",
|
||||||
|
AZURE_API_VERSION: "2025-04-01-preview",
|
||||||
|
AZURE_OPENAI_DEPLOYMENT: null,
|
||||||
|
AZURE_OPENAI_IMAGE_MODEL: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls: Array<{ url: string; form: FormData }> = [];
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
calls.push({
|
||||||
|
url: String(input),
|
||||||
|
form: init?.body as FormData,
|
||||||
|
});
|
||||||
|
return Response.json({
|
||||||
|
data: [{ b64_json: Buffer.from("edited-image").toString("base64") }],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const bytes = await generateImage(
|
||||||
|
"Add warm lighting",
|
||||||
|
"edit-deploy",
|
||||||
|
makeArgs({
|
||||||
|
quality: "2k",
|
||||||
|
referenceImages: [pngPath, jpgPath],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(Buffer.from(bytes).toString("utf8"), "edited-image");
|
||||||
|
assert.equal(
|
||||||
|
calls[0]?.url,
|
||||||
|
"https://example.openai.azure.com/openai/deployments/edit-deploy/images/edits?api-version=2025-04-01-preview",
|
||||||
|
);
|
||||||
|
assert.equal(calls[0]?.form.get("quality"), "high");
|
||||||
|
assert.equal(calls[0]?.form.get("size"), "1024x1024");
|
||||||
|
assert.equal(calls[0]?.form.getAll("image[]").length, 2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import type { CliArgs } from "../types";
|
||||||
|
import { getOpenAISize, extractImageFromResponse } from "./openai.ts";
|
||||||
|
|
||||||
|
type OpenAIImageResponse = { data: Array<{ url?: string; b64_json?: string }> };
|
||||||
|
type AzureEndpoint = {
|
||||||
|
resourceBaseURL: string;
|
||||||
|
deployment: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_AZURE_API_VERSION = "2025-04-01-preview";
|
||||||
|
const AZURE_EDIT_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]);
|
||||||
|
|
||||||
|
export function parseAzureBaseURL(url: string): AzureEndpoint {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const trimmedPath = parsed.pathname.replace(/\/+$/, "");
|
||||||
|
const deploymentMatch = trimmedPath.match(/^(.*?)(?:\/openai)?\/deployments\/([^/]+)$/);
|
||||||
|
|
||||||
|
if (deploymentMatch) {
|
||||||
|
parsed.pathname = `${deploymentMatch[1] || ""}/openai`;
|
||||||
|
return {
|
||||||
|
resourceBaseURL: parsed.toString().replace(/\/+$/, ""),
|
||||||
|
deployment: decodeURIComponent(deploymentMatch[2]!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.pathname = trimmedPath.endsWith("/openai") ? trimmedPath : `${trimmedPath}/openai`;
|
||||||
|
return {
|
||||||
|
resourceBaseURL: parsed.toString().replace(/\/+$/, ""),
|
||||||
|
deployment: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultModel(): string {
|
||||||
|
const explicitDeployment = process.env.AZURE_OPENAI_DEPLOYMENT?.trim();
|
||||||
|
if (explicitDeployment) return explicitDeployment;
|
||||||
|
|
||||||
|
const baseURL = process.env.AZURE_OPENAI_BASE_URL;
|
||||||
|
if (baseURL) {
|
||||||
|
try {
|
||||||
|
const { deployment } = parseAzureBaseURL(baseURL);
|
||||||
|
if (deployment) return deployment;
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid URLs here so the required-env check can raise the user-facing error later.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.env.AZURE_OPENAI_IMAGE_MODEL || "gpt-image-1.5";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEndpoint(): AzureEndpoint {
|
||||||
|
const url = process.env.AZURE_OPENAI_BASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(
|
||||||
|
"AZURE_OPENAI_BASE_URL is required. Set it to your Azure resource or deployment endpoint, e.g.: https://your-resource.openai.azure.com or https://your-resource.openai.azure.com/openai/deployments/your-deployment"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parseAzureBaseURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiKey(): string {
|
||||||
|
const key = process.env.AZURE_OPENAI_API_KEY;
|
||||||
|
if (!key) {
|
||||||
|
throw new Error(
|
||||||
|
"AZURE_OPENAI_API_KEY is required. Get it from Azure Portal → your OpenAI resource → Keys and Endpoint."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiVersion(): string {
|
||||||
|
return process.env.AZURE_API_VERSION || DEFAULT_AZURE_API_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeployment(model: string): string {
|
||||||
|
const deployment = model.trim();
|
||||||
|
if (!deployment) {
|
||||||
|
throw new Error(
|
||||||
|
"Azure deployment name is required. Use --model <deployment>, AZURE_OPENAI_DEPLOYMENT, AZURE_OPENAI_IMAGE_MODEL, or embed the deployment in AZURE_OPENAI_BASE_URL."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return deployment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildURL(deployment: string, pathSuffix: string): string {
|
||||||
|
const { resourceBaseURL } = getEndpoint();
|
||||||
|
return `${resourceBaseURL}/deployments/${encodeURIComponent(deployment)}${pathSuffix}?api-version=${getApiVersion()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(): Record<string, string> {
|
||||||
|
return { "api-key": getApiKey() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAzureQuality(quality: CliArgs["quality"]): "medium" | "high" {
|
||||||
|
return quality === "2k" ? "high" : "medium";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArgs(_model: string, args: CliArgs): void {
|
||||||
|
for (const refPath of args.referenceImages) {
|
||||||
|
const ext = path.extname(refPath).toLowerCase();
|
||||||
|
if (!AZURE_EDIT_IMAGE_EXTENSIONS.has(ext)) {
|
||||||
|
throw new Error(
|
||||||
|
`Azure OpenAI reference images must be PNG or JPG/JPEG. Unsupported file: ${refPath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateImage(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const deployment = getDeployment(model);
|
||||||
|
const size = args.size || getOpenAISize(model, args.aspectRatio, args.quality);
|
||||||
|
|
||||||
|
if (args.referenceImages.length > 0) {
|
||||||
|
return generateWithAzureEdits(prompt, deployment, size, args.referenceImages, args.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateWithAzureGenerations(prompt, deployment, size, args.quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateWithAzureGenerations(
|
||||||
|
prompt: string,
|
||||||
|
deployment: string,
|
||||||
|
size: string,
|
||||||
|
quality: CliArgs["quality"]
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const body: Record<string, any> = {
|
||||||
|
prompt,
|
||||||
|
size,
|
||||||
|
n: 1,
|
||||||
|
quality: getAzureQuality(quality),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(buildURL(deployment, "/images/generations"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...authHeaders(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Azure OpenAI API error: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await res.json()) as OpenAIImageResponse;
|
||||||
|
return extractImageFromResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateWithAzureEdits(
|
||||||
|
prompt: string,
|
||||||
|
deployment: string,
|
||||||
|
size: string,
|
||||||
|
referenceImages: string[],
|
||||||
|
quality: CliArgs["quality"]
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("prompt", prompt);
|
||||||
|
form.append("size", size);
|
||||||
|
form.append("n", "1");
|
||||||
|
form.append("quality", getAzureQuality(quality));
|
||||||
|
|
||||||
|
for (const refPath of referenceImages) {
|
||||||
|
const bytes = await readFile(refPath);
|
||||||
|
const filename = path.basename(refPath);
|
||||||
|
const mimeType = path.extname(filename).toLowerCase() === ".png" ? "image/png" : "image/jpeg";
|
||||||
|
const blob = new Blob([bytes], { type: mimeType });
|
||||||
|
form.append("image[]", blob, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(buildURL(deployment, "/images/edits"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...authHeaders(),
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Azure OpenAI edits API error: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await res.json()) as OpenAIImageResponse;
|
||||||
|
return extractImageFromResponse(result);
|
||||||
|
}
|
||||||
+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,114 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
|
import { generateImage } from "./jimeng.ts";
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Jimeng submit request uses prompt field expected by current API", async (t) => {
|
||||||
|
useEnv(t, {
|
||||||
|
JIMENG_ACCESS_KEY_ID: "test-access-key",
|
||||||
|
JIMENG_SECRET_ACCESS_KEY: "test-secret-key",
|
||||||
|
JIMENG_BASE_URL: null,
|
||||||
|
JIMENG_REGION: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls: Array<{
|
||||||
|
input: string;
|
||||||
|
init?: RequestInit;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
calls.push({
|
||||||
|
input: String(input),
|
||||||
|
init,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (calls.length === 1) {
|
||||||
|
return Response.json({
|
||||||
|
code: 10000,
|
||||||
|
data: {
|
||||||
|
task_id: "task-123",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
code: 10000,
|
||||||
|
data: {
|
||||||
|
status: "done",
|
||||||
|
binary_data_base64: [Buffer.from("jimeng-image").toString("base64")],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const image = await generateImage(
|
||||||
|
"A quiet bamboo forest",
|
||||||
|
"jimeng_t2i_v40",
|
||||||
|
makeArgs({ quality: "normal" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(Buffer.from(image).toString("utf8"), "jimeng-image");
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
calls[0]?.input,
|
||||||
|
"https://visual.volcengineapi.com/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31",
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitBody = JSON.parse(String(calls[0]?.init?.body)) as Record<string, unknown>;
|
||||||
|
assert.equal(submitBody.req_key, "jimeng_t2i_v40");
|
||||||
|
assert.equal(submitBody.prompt, "A quiet bamboo forest");
|
||||||
|
assert.ok(!("prompt_text" in submitBody));
|
||||||
|
assert.equal(submitBody.width, 1024);
|
||||||
|
assert.equal(submitBody.height, 1024);
|
||||||
|
});
|
||||||
+1
-1
@@ -246,7 +246,7 @@ async function submitTask(
|
|||||||
const [width, height] = size.split("x").map(Number);
|
const [width, height] = size.split("x").map(Number);
|
||||||
const bodyObj = {
|
const bodyObj = {
|
||||||
req_key: model,
|
req_key: model,
|
||||||
prompt_text: prompt,
|
prompt,
|
||||||
// Use separate width and height parameters instead of size string
|
// Use separate width and height parameters instead of size string
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
|
import {
|
||||||
|
buildContent,
|
||||||
|
buildRequestBody,
|
||||||
|
extractImageFromResponse,
|
||||||
|
getAspectRatio,
|
||||||
|
getImageSize,
|
||||||
|
validateArgs,
|
||||||
|
} from "./openrouter.ts";
|
||||||
|
|
||||||
|
const GEMINI_MODEL = "google/gemini-3.1-flash-image-preview";
|
||||||
|
const GEMINI_25_MODEL = "google/gemini-2.5-flash-image";
|
||||||
|
const GPT_5_IMAGE_MODEL = "openai/gpt-5-image";
|
||||||
|
const OPENROUTER_AUTO_MODEL = "openrouter/auto";
|
||||||
|
const FLUX_MODEL = "black-forest-labs/flux.2-pro";
|
||||||
|
|
||||||
|
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("OpenRouter request body uses image_config and string content for text-only prompts", () => {
|
||||||
|
const args = makeArgs({ aspectRatio: "16:9", quality: "2k" });
|
||||||
|
const body = buildRequestBody("hello", GEMINI_MODEL, args, []);
|
||||||
|
|
||||||
|
assert.deepEqual(body.image_config, {
|
||||||
|
image_size: "2K",
|
||||||
|
aspect_ratio: "16:9",
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.provider, {
|
||||||
|
require_parameters: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.modalities, ["image", "text"]);
|
||||||
|
assert.equal(body.stream, false);
|
||||||
|
assert.equal(body.messages[0].content, "hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter request body keeps text+image modalities for current text+image models", () => {
|
||||||
|
for (const model of [GEMINI_MODEL, GEMINI_25_MODEL, GPT_5_IMAGE_MODEL, OPENROUTER_AUTO_MODEL]) {
|
||||||
|
const body = buildRequestBody("hello", model, makeArgs({ quality: "2k" }), []);
|
||||||
|
|
||||||
|
assert.deepEqual(body.image_config, {
|
||||||
|
image_size: "2K",
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.provider, {
|
||||||
|
require_parameters: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.modalities, ["image", "text"]);
|
||||||
|
assert.equal(body.messages[0].content, "hello");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter request body uses image-only modalities for image-only models under CLI defaults", () => {
|
||||||
|
const body = buildRequestBody("hello", FLUX_MODEL, makeArgs({ quality: "2k" }), []);
|
||||||
|
|
||||||
|
assert.deepEqual(body.image_config, {
|
||||||
|
image_size: "2K",
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.provider, {
|
||||||
|
require_parameters: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.modalities, ["image"]);
|
||||||
|
assert.equal(body.stream, false);
|
||||||
|
assert.equal(body.messages[0].content, "hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter helper omits image_config when no size or quality is passed", () => {
|
||||||
|
const body = buildRequestBody("hello", FLUX_MODEL, makeArgs(), []);
|
||||||
|
|
||||||
|
assert.equal(body.image_config, undefined);
|
||||||
|
assert.equal(body.provider, undefined);
|
||||||
|
assert.deepEqual(body.modalities, ["image"]);
|
||||||
|
assert.equal(body.stream, false);
|
||||||
|
assert.equal(body.messages[0].content, "hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter request body keeps multimodal array content when references are provided", () => {
|
||||||
|
const content = buildContent("hello", ["data:image/png;base64,abc"]);
|
||||||
|
assert.ok(Array.isArray(content));
|
||||||
|
assert.deepEqual(content[0], { type: "text", text: "hello" });
|
||||||
|
assert.deepEqual(content[1], {
|
||||||
|
type: "image_url",
|
||||||
|
image_url: { url: "data:image/png;base64,abc" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter size and aspect helpers infer supported values", () => {
|
||||||
|
assert.equal(getImageSize(makeArgs()), null);
|
||||||
|
assert.equal(getImageSize(makeArgs({ quality: "normal" })), "1K");
|
||||||
|
assert.equal(getImageSize(makeArgs({ size: "2048x1024" })), "2K");
|
||||||
|
assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1600x900" })), "16:9");
|
||||||
|
assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1024x4096" })), "1:4");
|
||||||
|
assert.equal(getAspectRatio(GEMINI_25_MODEL, makeArgs({ size: "1600x900" })), "16:9");
|
||||||
|
assert.equal(getAspectRatio(FLUX_MODEL, makeArgs({ size: "1024x4096" })), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter validates explicit aspect ratios and inferred size ratios against model support", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
validateArgs(GEMINI_MODEL, makeArgs({ aspectRatio: "1:4" })),
|
||||||
|
);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
validateArgs(GEMINI_MODEL, makeArgs({ size: "1024x4096" })),
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs(GEMINI_25_MODEL, makeArgs({ aspectRatio: "1:4" })),
|
||||||
|
/does not support aspect ratio 1:4/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs(FLUX_MODEL, makeArgs({ aspectRatio: "1:4" })),
|
||||||
|
/does not support aspect ratio 1:4/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs(GEMINI_MODEL, makeArgs({ size: "2048x1024" })),
|
||||||
|
/does not support size 2048x1024 \(aspect ratio 2:1\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter response extraction supports inline image data and finish_reason errors", async () => {
|
||||||
|
const bytes = await extractImageFromResponse({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
image_url: {
|
||||||
|
url: `data:image/png;base64,${Buffer.from("hello").toString("base64")}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(Buffer.from(bytes).toString("utf8"), "hello");
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
extractImageFromResponse({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
finish_reason: "error",
|
||||||
|
native_finish_reason: "MALFORMED_FUNCTION_CALL",
|
||||||
|
message: { content: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/finish_reason=MALFORMED_FUNCTION_CALL/,
|
||||||
|
);
|
||||||
|
});
|
||||||
+145
-31
@@ -3,6 +3,19 @@ import { readFile } from "node:fs/promises";
|
|||||||
import type { CliArgs } from "../types";
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview";
|
const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview";
|
||||||
|
const COMMON_ASPECT_RATIOS = [
|
||||||
|
"1:1",
|
||||||
|
"2:3",
|
||||||
|
"3:2",
|
||||||
|
"3:4",
|
||||||
|
"4:3",
|
||||||
|
"4:5",
|
||||||
|
"5:4",
|
||||||
|
"9:16",
|
||||||
|
"16:9",
|
||||||
|
"21:9",
|
||||||
|
];
|
||||||
|
const GEMINI_EXTENDED_ASPECT_RATIOS = ["1:4", "4:1", "1:8", "8:1"];
|
||||||
|
|
||||||
type OpenRouterImageEntry = {
|
type OpenRouterImageEntry = {
|
||||||
image_url?: string | { url?: string | null } | null;
|
image_url?: string | { url?: string | null } | null;
|
||||||
@@ -18,9 +31,11 @@ type OpenRouterMessagePart = {
|
|||||||
|
|
||||||
type OpenRouterResponse = {
|
type OpenRouterResponse = {
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
|
finish_reason?: string | null;
|
||||||
|
native_finish_reason?: string | null;
|
||||||
message?: {
|
message?: {
|
||||||
images?: OpenRouterImageEntry[];
|
images?: OpenRouterImageEntry[];
|
||||||
content?: string | OpenRouterMessagePart[];
|
content?: string | OpenRouterMessagePart[] | null;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -29,6 +44,36 @@ export function getDefaultModel(): string {
|
|||||||
return process.env.OPENROUTER_IMAGE_MODEL || DEFAULT_MODEL;
|
return process.env.OPENROUTER_IMAGE_MODEL || DEFAULT_MODEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeModelId(model: string): string {
|
||||||
|
return model.trim().toLowerCase().split(":")[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextAndImageModel(model: string): boolean {
|
||||||
|
const normalized = normalizeModelId(model);
|
||||||
|
if (normalized === "openrouter/auto") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("google/gemini-") && normalized.includes("image")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("openai/gpt-") && normalized.includes("image")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupportedAspectRatios(model: string): Set<string> {
|
||||||
|
const normalized = normalizeModelId(model);
|
||||||
|
if (normalized !== "google/gemini-3.1-flash-image-preview") {
|
||||||
|
return new Set(COMMON_ASPECT_RATIOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set([...COMMON_ASPECT_RATIOS, ...GEMINI_EXTENDED_ASPECT_RATIOS]);
|
||||||
|
}
|
||||||
|
|
||||||
function getApiKey(): string | null {
|
function getApiKey(): string | null {
|
||||||
return process.env.OPENROUTER_API_KEY || null;
|
return process.env.OPENROUTER_API_KEY || null;
|
||||||
}
|
}
|
||||||
@@ -103,17 +148,50 @@ function inferImageSize(size: string | null): "1K" | "2K" | "4K" | null {
|
|||||||
return "4K";
|
return "4K";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImageSize(args: CliArgs): "1K" | "2K" | "4K" {
|
export function getImageSize(args: CliArgs): "1K" | "2K" | "4K" | null {
|
||||||
if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";
|
if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";
|
||||||
|
|
||||||
const inferredFromSize = inferImageSize(args.size);
|
const inferredFromSize = inferImageSize(args.size);
|
||||||
if (inferredFromSize) return inferredFromSize;
|
if (inferredFromSize) return inferredFromSize;
|
||||||
|
|
||||||
return args.quality === "normal" ? "1K" : "2K";
|
if (args.quality === "normal") return "1K";
|
||||||
|
if (args.quality === "2k") return "2K";
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAspectRatio(args: CliArgs): string | null {
|
export function getAspectRatio(model: string, args: CliArgs): string | null {
|
||||||
return args.aspectRatio || inferAspectRatio(args.size);
|
if (args.aspectRatio) return args.aspectRatio;
|
||||||
|
|
||||||
|
const inferred = inferAspectRatio(args.size);
|
||||||
|
if (!inferred || !getSupportedAspectRatios(model).has(inferred)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModalities(model: string): string[] {
|
||||||
|
return isTextAndImageModel(model) ? ["image", "text"] : ["image"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArgs(model: string, args: CliArgs): void {
|
||||||
|
const requestedAspectRatio = args.aspectRatio || inferAspectRatio(args.size);
|
||||||
|
if (!requestedAspectRatio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = getSupportedAspectRatios(model);
|
||||||
|
if (supported.has(requestedAspectRatio)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedValue = args.aspectRatio
|
||||||
|
? `aspect ratio ${requestedAspectRatio}`
|
||||||
|
: `size ${args.size} (aspect ratio ${requestedAspectRatio})`;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`OpenRouter model ${model} does not support ${requestedValue}. Supported values: ${Array.from(supported).join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMimeType(filename: string): string {
|
function getMimeType(filename: string): string {
|
||||||
@@ -129,7 +207,14 @@ async function readImageAsDataUrl(filePath: string): Promise<string> {
|
|||||||
return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;
|
return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildContent(prompt: string, referenceImages: string[]): Array<Record<string, unknown>> {
|
export function buildContent(
|
||||||
|
prompt: string,
|
||||||
|
referenceImages: string[],
|
||||||
|
): string | Array<Record<string, unknown>> {
|
||||||
|
if (referenceImages.length === 0) {
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt }];
|
const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt }];
|
||||||
|
|
||||||
for (const imageUrl of referenceImages) {
|
for (const imageUrl of referenceImages) {
|
||||||
@@ -171,8 +256,9 @@ async function downloadImage(value: string): Promise<Uint8Array> {
|
|||||||
return Uint8Array.from(Buffer.from(value, "base64"));
|
return Uint8Array.from(Buffer.from(value, "base64"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uint8Array> {
|
export async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uint8Array> {
|
||||||
const message = result.choices?.[0]?.message;
|
const choice = result.choices?.[0];
|
||||||
|
const message = choice?.message;
|
||||||
|
|
||||||
for (const image of message?.images ?? []) {
|
for (const image of message?.images ?? []) {
|
||||||
const imageUrl = extractImageUrl(image);
|
const imageUrl = extractImageUrl(image);
|
||||||
@@ -194,7 +280,52 @@ async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uin
|
|||||||
if (inline) return inline;
|
if (inline) return inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("No image in OpenRouter response");
|
const finishReason =
|
||||||
|
choice?.native_finish_reason || choice?.finish_reason || "unknown";
|
||||||
|
throw new Error(
|
||||||
|
`No image in OpenRouter response (finish_reason=${finishReason})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRequestBody(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs,
|
||||||
|
referenceImages: string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
validateArgs(model, args);
|
||||||
|
|
||||||
|
const imageConfig: Record<string, string> = {};
|
||||||
|
|
||||||
|
const imageSize = getImageSize(args);
|
||||||
|
if (imageSize) {
|
||||||
|
imageConfig.image_size = imageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aspectRatio = getAspectRatio(model, args);
|
||||||
|
if (aspectRatio) {
|
||||||
|
imageConfig.aspect_ratio = aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: buildContent(prompt, referenceImages),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
modalities: getModalities(model),
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Object.keys(imageConfig).length > 0) {
|
||||||
|
body.image_config = imageConfig;
|
||||||
|
body.provider = {
|
||||||
|
require_parameters: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateImage(
|
export async function generateImage(
|
||||||
@@ -212,32 +343,15 @@ export async function generateImage(
|
|||||||
referenceImages.push(await readImageAsDataUrl(refPath));
|
referenceImages.push(await readImageAsDataUrl(refPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageGenerationOptions: Record<string, string> = {
|
|
||||||
size: getImageSize(args),
|
|
||||||
};
|
|
||||||
|
|
||||||
const aspectRatio = getAspectRatio(args);
|
|
||||||
if (aspectRatio) {
|
|
||||||
imageGenerationOptions.aspect_ratio = aspectRatio;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
model,
|
model,
|
||||||
messages: [
|
...buildRequestBody(prompt, model, args, referenceImages),
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: buildContent(prompt, referenceImages),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
modalities: ["image", "text"],
|
|
||||||
max_tokens: 256,
|
|
||||||
imageGenerationOptions,
|
|
||||||
providerPreferences: {
|
|
||||||
require_parameters: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`Generating image with OpenRouter (${model})...`, imageGenerationOptions);
|
console.log(
|
||||||
|
`Generating image with OpenRouter (${model})...`,
|
||||||
|
(body.image_config as Record<string, string>),
|
||||||
|
);
|
||||||
|
|
||||||
const response = await fetch(`${getBaseUrl()}/chat/completions`, {
|
const response = await fetch(`${getBaseUrl()}/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
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 {
|
||||||
|
buildImageInput,
|
||||||
|
buildRequestBody,
|
||||||
|
generateImage,
|
||||||
|
getDefaultOutputExtension,
|
||||||
|
resolveSeedreamSize,
|
||||||
|
validateArgs,
|
||||||
|
} from "./seedream.ts";
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeTempPng(t: TestContext, name: string): Promise<string> {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "seedream-test-"));
|
||||||
|
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
const filePath = path.join(dir, name);
|
||||||
|
const png1x1 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a7m0AAAAASUVORK5CYII=";
|
||||||
|
await fs.writeFile(filePath, Buffer.from(png1x1, "base64"));
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Seedream request body and default extensions follow official model capabilities", () => {
|
||||||
|
const five = buildRequestBody(
|
||||||
|
"A robot illustrator",
|
||||||
|
"doubao-seedream-5-0-260128",
|
||||||
|
makeArgs(),
|
||||||
|
);
|
||||||
|
assert.equal(five.size, "2K");
|
||||||
|
assert.equal(five.response_format, "url");
|
||||||
|
assert.equal(five.output_format, "png");
|
||||||
|
assert.equal(getDefaultOutputExtension("doubao-seedream-5-0-260128"), ".png");
|
||||||
|
|
||||||
|
const fourFive = buildRequestBody(
|
||||||
|
"A robot illustrator",
|
||||||
|
"doubao-seedream-4-5-251128",
|
||||||
|
makeArgs(),
|
||||||
|
);
|
||||||
|
assert.equal(fourFive.size, "2K");
|
||||||
|
assert.equal(fourFive.response_format, "url");
|
||||||
|
assert.ok(!("output_format" in fourFive));
|
||||||
|
assert.equal(getDefaultOutputExtension("doubao-seedream-4-5-251128"), ".jpg");
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
buildRequestBody(
|
||||||
|
"Change the bubbles into hearts",
|
||||||
|
"doubao-seededit-3-0-i2i-250628",
|
||||||
|
makeArgs({ referenceImages: ["ref.png"] }),
|
||||||
|
"data:image/png;base64,AAAA",
|
||||||
|
),
|
||||||
|
/no longer supported/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Seedream size selection validates model-specific presets", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveSeedreamSize("doubao-seedream-4-0-250828", makeArgs({ quality: "normal" })),
|
||||||
|
"1K",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveSeedreamSize("doubao-seedream-3-0-t2i-250415", makeArgs({ quality: "2k" })),
|
||||||
|
"2048x2048",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveSeedreamSize("doubao-seedream-5-0-260128", makeArgs({ size: "4K" })),
|
||||||
|
/only supports 2K, 3K/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveSeedreamSize("doubao-seedream-3-0-t2i-250415", makeArgs({ imageSize: "2K" })),
|
||||||
|
/only supports explicit WxH sizes/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveSeedreamSize("doubao-seededit-3-0-i2i-250628", makeArgs({ size: "1024x1024" })),
|
||||||
|
/no longer supported/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Seedream reference-image support is model-specific", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
validateArgs(
|
||||||
|
"doubao-seedream-5-0-260128",
|
||||||
|
makeArgs({ referenceImages: ["a.png", "b.png"] }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateArgs(
|
||||||
|
"doubao-seedream-3-0-t2i-250415",
|
||||||
|
makeArgs({ referenceImages: ["a.png"] }),
|
||||||
|
),
|
||||||
|
/does not support reference images/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateArgs(
|
||||||
|
"doubao-seededit-3-0-i2i-250628",
|
||||||
|
makeArgs(),
|
||||||
|
),
|
||||||
|
/no longer supported/,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateArgs(
|
||||||
|
"ep-20260315171508-t8br2",
|
||||||
|
makeArgs({ referenceImages: ["a.png"] }),
|
||||||
|
),
|
||||||
|
/require a known model ID/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Seedream image input encodes local references as data URLs", async (t) => {
|
||||||
|
const refOne = await makeTempPng(t, "one.png");
|
||||||
|
const refTwo = await makeTempPng(t, "two.png");
|
||||||
|
|
||||||
|
const single = await buildImageInput("doubao-seedream-4-5-251128", [refOne]);
|
||||||
|
assert.match(String(single), /^data:image\/png;base64,/);
|
||||||
|
|
||||||
|
const multiple = await buildImageInput("doubao-seedream-5-0-260128", [refOne, refTwo]);
|
||||||
|
assert.ok(Array.isArray(multiple));
|
||||||
|
assert.equal(multiple.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Seedream generateImage posts the documented response_format and downloads the returned URL", async (t) => {
|
||||||
|
useEnv(t, { ARK_API_KEY: "test-key", SEEDREAM_BASE_URL: null });
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const calls: Array<{
|
||||||
|
input: string;
|
||||||
|
init?: RequestInit;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
calls.push({
|
||||||
|
input: String(input),
|
||||||
|
init,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (calls.length === 1) {
|
||||||
|
return Response.json({
|
||||||
|
model: "doubao-seedream-4-5-251128",
|
||||||
|
created: 1740000000,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
url: "https://example.com/generated-image",
|
||||||
|
size: "2048x2048",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
generated_images: 1,
|
||||||
|
output_tokens: 1,
|
||||||
|
total_tokens: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(Uint8Array.from([7, 8, 9]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "image/jpeg" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const image = await generateImage(
|
||||||
|
"A robot illustrator",
|
||||||
|
"doubao-seedream-4-5-251128",
|
||||||
|
makeArgs(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual([...image], [7, 8, 9]);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
calls[0]?.input,
|
||||||
|
"https://ark.cn-beijing.volces.com/api/v3/images/generations",
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestBody = JSON.parse(String(calls[0]?.init?.body)) as Record<string, unknown>;
|
||||||
|
assert.equal(requestBody.model, "doubao-seedream-4-5-251128");
|
||||||
|
assert.equal(requestBody.size, "2K");
|
||||||
|
assert.equal(requestBody.response_format, "url");
|
||||||
|
assert.ok(!("output_format" in requestBody));
|
||||||
|
assert.equal(calls[1]?.input, "https://example.com/generated-image");
|
||||||
|
});
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
|
export type SeedreamModelFamily =
|
||||||
|
| "seedream5"
|
||||||
|
| "seedream45"
|
||||||
|
| "seedream40"
|
||||||
|
| "seedream30"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
type SeedreamRequestImage = string | string[];
|
||||||
|
|
||||||
|
type SeedreamRequestBody = {
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
size: string;
|
||||||
|
response_format: "url";
|
||||||
|
watermark: boolean;
|
||||||
|
image?: SeedreamRequestImage;
|
||||||
|
output_format?: "png";
|
||||||
|
};
|
||||||
|
|
||||||
|
type SeedreamImageResponse = {
|
||||||
|
model?: string;
|
||||||
|
created?: number;
|
||||||
|
data?: Array<{
|
||||||
|
url?: string;
|
||||||
|
b64_json?: string;
|
||||||
|
size?: string;
|
||||||
|
error?: {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
usage?: {
|
||||||
|
generated_images: number;
|
||||||
|
output_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
};
|
||||||
|
error?: {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDefaultModel(): string {
|
||||||
|
return process.env.SEEDREAM_IMAGE_MODEL || "doubao-seedream-5-0-260128";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiKey(): string | null {
|
||||||
|
return process.env.ARK_API_KEY || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
return process.env.SEEDREAM_BASE_URL || "https://ark.cn-beijing.volces.com/api/v3";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePixelSize(value: string): { width: number; height: number } | null {
|
||||||
|
const match = value.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 normalizePixelSize(value: string): string | null {
|
||||||
|
const parsed = parsePixelSize(value);
|
||||||
|
if (!parsed) return null;
|
||||||
|
return `${parsed.width}x${parsed.height}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSizePreset(value: string): string | null {
|
||||||
|
const upper = value.trim().toUpperCase();
|
||||||
|
if (upper === "ADAPTIVE") return "adaptive";
|
||||||
|
if (upper === "1K" || upper === "2K" || upper === "3K" || upper === "4K") return upper;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSizeValue(value: string): string | null {
|
||||||
|
return normalizeSizePreset(value) ?? normalizePixelSize(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMimeType(filename: string): string {
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||||
|
if (ext === ".webp") return "image/webp";
|
||||||
|
if (ext === ".gif") return "image/gif";
|
||||||
|
if (ext === ".bmp") return "image/bmp";
|
||||||
|
if (ext === ".tiff" || ext === ".tif") return "image/tiff";
|
||||||
|
return "image/png";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readImageAsDataUrl(filePath: string): Promise<string> {
|
||||||
|
const bytes = await readFile(filePath);
|
||||||
|
return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModelFamily(model: string): SeedreamModelFamily {
|
||||||
|
const normalized = model.trim();
|
||||||
|
if (/^doubao-seedream-5-0(?:-lite)?-\d+$/.test(normalized)) return "seedream5";
|
||||||
|
if (/^doubao-seedream-4-5-\d+$/.test(normalized)) return "seedream45";
|
||||||
|
if (/^doubao-seedream-4-0-\d+$/.test(normalized)) return "seedream40";
|
||||||
|
if (/^doubao-seedream-3-0-t2i-\d+$/.test(normalized)) return "seedream30";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRemovedSeededitModel(model: string): boolean {
|
||||||
|
return /^doubao-seededit-3-0-i2i-\d+$/.test(model.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSupportedModel(model: string): void {
|
||||||
|
if (isRemovedSeededitModel(model)) {
|
||||||
|
throw new Error(
|
||||||
|
`${model} is no longer supported. SeedEdit 3.0 support has been removed from this tool; use Seedream 5.0/4.5/4.0/3.0 instead.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsReferenceImages(model: string): boolean {
|
||||||
|
const family = getModelFamily(model);
|
||||||
|
return family === "seedream5" || family === "seedream45" || family === "seedream40";
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsOutputFormat(model: string): boolean {
|
||||||
|
return getModelFamily(model) === "seedream5";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultOutputExtension(model: string): ".png" | ".jpg" {
|
||||||
|
assertSupportedModel(model);
|
||||||
|
return supportsOutputFormat(model) ? ".png" : ".jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultSeedreamSize(model: string, args: CliArgs): string {
|
||||||
|
assertSupportedModel(model);
|
||||||
|
const family = getModelFamily(model);
|
||||||
|
|
||||||
|
if (family === "seedream5") return "2K";
|
||||||
|
if (family === "seedream45") return "2K";
|
||||||
|
if (family === "seedream40") return args.quality === "normal" ? "1K" : "2K";
|
||||||
|
if (family === "seedream30") return args.quality === "2k" ? "2048x2048" : "1024x1024";
|
||||||
|
return "2K";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSeedreamSize(model: string, args: CliArgs): string {
|
||||||
|
assertSupportedModel(model);
|
||||||
|
const family = getModelFamily(model);
|
||||||
|
const requested = args.size || args.imageSize || null;
|
||||||
|
const normalized = requested ? normalizeSizeValue(requested) : null;
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return getDefaultSeedreamSize(model, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family === "seedream30") {
|
||||||
|
const pixelSize = normalizePixelSize(normalized);
|
||||||
|
if (!pixelSize) {
|
||||||
|
throw new Error("Seedream 3.0 only supports explicit WxH sizes such as 1024x1024.");
|
||||||
|
}
|
||||||
|
return pixelSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family === "seedream5") {
|
||||||
|
if (normalized === "4K" || normalized === "1K" || normalized === "adaptive") {
|
||||||
|
throw new Error("Seedream 5.0 only supports 2K, 3K, or explicit WxH sizes.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family === "seedream45") {
|
||||||
|
if (normalized === "1K" || normalized === "3K" || normalized === "adaptive") {
|
||||||
|
throw new Error("Seedream 4.5 only supports 2K, 4K, or explicit WxH sizes.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family === "seedream40") {
|
||||||
|
if (normalized === "3K" || normalized === "adaptive") {
|
||||||
|
throw new Error("Seedream 4.0 only supports 1K, 2K, 4K, or explicit WxH sizes.");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "adaptive") {
|
||||||
|
throw new Error("Adaptive size is not supported by Seedream image generation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === "1K" || normalized === "3K" || normalized === "4K") {
|
||||||
|
throw new Error(
|
||||||
|
"Unknown Seedream model ID. Use a documented model ID or pass an explicit WxH size instead of preset imageSize."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArgs(model: string, args: CliArgs): void {
|
||||||
|
assertSupportedModel(model);
|
||||||
|
const family = getModelFamily(model);
|
||||||
|
const refCount = args.referenceImages.length;
|
||||||
|
|
||||||
|
if (refCount === 0) {
|
||||||
|
resolveSeedreamSize(model, args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (family === "unknown") {
|
||||||
|
throw new Error(
|
||||||
|
"Reference images with Seedream require a known model ID. Use Seedream 5.0/4.5/4.0 model IDs instead of an endpoint ID."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportsReferenceImages(model)) {
|
||||||
|
throw new Error(`${model} does not support reference images.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((family === "seedream5" || family === "seedream45" || family === "seedream40") && refCount > 14) {
|
||||||
|
throw new Error(`${model} supports at most 14 reference images.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveSeedreamSize(model, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildImageInput(
|
||||||
|
model: string,
|
||||||
|
referenceImages: string[],
|
||||||
|
): Promise<SeedreamRequestImage | undefined> {
|
||||||
|
if (referenceImages.length === 0) return undefined;
|
||||||
|
assertSupportedModel(model);
|
||||||
|
|
||||||
|
const encoded = await Promise.all(referenceImages.map((refPath) => readImageAsDataUrl(refPath)));
|
||||||
|
|
||||||
|
return encoded.length === 1 ? encoded[0]! : encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRequestBody(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs,
|
||||||
|
imageInput?: SeedreamRequestImage,
|
||||||
|
): SeedreamRequestBody {
|
||||||
|
validateArgs(model, args);
|
||||||
|
|
||||||
|
const requestBody: SeedreamRequestBody = {
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
size: resolveSeedreamSize(model, args),
|
||||||
|
response_format: "url",
|
||||||
|
watermark: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (imageInput) {
|
||||||
|
requestBody.image = imageInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportsOutputFormat(model)) {
|
||||||
|
requestBody.output_format = "png";
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadImage(url: string): Promise<Uint8Array> {
|
||||||
|
const imgResponse = await fetch(url);
|
||||||
|
if (!imgResponse.ok) {
|
||||||
|
throw new Error(`Failed to download image from ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await imgResponse.arrayBuffer();
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractImageFromResponse(result: SeedreamImageResponse): Promise<Uint8Array> {
|
||||||
|
const first = result.data?.find((item) => item.url || item.b64_json || item.error);
|
||||||
|
|
||||||
|
if (!first) {
|
||||||
|
throw new Error("No image data in Seedream response");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first.error) {
|
||||||
|
throw new Error(first.error.message || "Seedream returned an image generation error");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first.b64_json) {
|
||||||
|
return Uint8Array.from(Buffer.from(first.b64_json, "base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first.url) {
|
||||||
|
console.error(`Downloading image from ${first.url}...`);
|
||||||
|
return downloadImage(first.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("No image URL or base64 data in Seedream response");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateImage(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(
|
||||||
|
"ARK_API_KEY is required. " +
|
||||||
|
"Get your API key from https://console.volcengine.com/ark"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateArgs(model, args);
|
||||||
|
const imageInput = await buildImageInput(model, args.referenceImages);
|
||||||
|
const requestBody = buildRequestBody(prompt, model, args, imageInput);
|
||||||
|
|
||||||
|
console.error(`Calling Seedream API (${model}) with size: ${requestBody.size}`);
|
||||||
|
|
||||||
|
const response = await fetch(`${getBaseUrl()}/images/generations`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.text();
|
||||||
|
throw new Error(`Seedream API error (${response.status}): ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as SeedreamImageResponse;
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error.message || "Seedream API returned an error");
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractImageFromResponse(result);
|
||||||
|
}
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
export type Provider = "google" | "openai" | "openrouter" | "dashscope" | "replicate" | "jimeng" | "seedream";
|
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,9 +61,11 @@ 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;
|
||||||
|
azure: string | null;
|
||||||
};
|
};
|
||||||
batch?: {
|
batch?: {
|
||||||
max_workers?: number | null;
|
max_workers?: number | null;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const SCRIPT_PATH = path.join(SCRIPT_DIR, "main.ts");
|
||||||
|
|
||||||
|
async function makeTempDir(prefix: string): Promise<string> {
|
||||||
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("CLI forwards wrapper title and vendor render options", async () => {
|
||||||
|
const root = await makeTempDir("baoyu-markdown-to-html-cli-");
|
||||||
|
const markdownPath = path.join(root, "article.md");
|
||||||
|
await fs.writeFile(markdownPath, "## Section\n\nParagraph with **bold** text.\n", "utf-8");
|
||||||
|
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"--import",
|
||||||
|
"tsx",
|
||||||
|
SCRIPT_PATH,
|
||||||
|
markdownPath,
|
||||||
|
"--theme", "grace",
|
||||||
|
"--color", "red",
|
||||||
|
"--font-family", "mono",
|
||||||
|
"--font-size", "18",
|
||||||
|
"--keep-title",
|
||||||
|
"--title", "Overridden",
|
||||||
|
],
|
||||||
|
{ cwd: SCRIPT_DIR },
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = JSON.parse(stdout.trim()) as {
|
||||||
|
htmlPath: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(result.title, "Overridden");
|
||||||
|
|
||||||
|
const html = await fs.readFile(result.htmlPath, "utf-8");
|
||||||
|
assert.match(html, /<title>Overridden<\/title>/);
|
||||||
|
assert.match(html, /<h2[^>]*style="[^"]*background: #A93226/);
|
||||||
|
assert.match(html, /<strong[^>]*style="[^"]*color: #A93226/);
|
||||||
|
assert.match(
|
||||||
|
html,
|
||||||
|
/<body[^>]*style="[^"]*font-family: Menlo, Monaco, 'Courier New', monospace;[^"]*font-size: 18px/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -4,16 +4,22 @@ import path from "node:path";
|
|||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
COLOR_PRESETS,
|
||||||
|
FONT_FAMILY_MAP,
|
||||||
|
FONT_SIZE_OPTIONS,
|
||||||
|
THEME_NAMES,
|
||||||
extractSummaryFromBody,
|
extractSummaryFromBody,
|
||||||
extractTitleFromMarkdown,
|
extractTitleFromMarkdown,
|
||||||
formatTimestamp,
|
formatTimestamp,
|
||||||
|
parseArgs,
|
||||||
parseFrontmatter,
|
parseFrontmatter,
|
||||||
renderMarkdownDocument,
|
renderMarkdownDocument,
|
||||||
replaceMarkdownImagesWithPlaceholders,
|
replaceMarkdownImagesWithPlaceholders,
|
||||||
resolveContentImages,
|
resolveContentImages,
|
||||||
serializeFrontmatter,
|
serializeFrontmatter,
|
||||||
stripWrappingQuotes,
|
stripWrappingQuotes,
|
||||||
} from "baoyu-md";
|
} from "./vendor/baoyu-md/src/index.ts";
|
||||||
|
import type { CliOptions } from "./vendor/baoyu-md/src/types.ts";
|
||||||
|
|
||||||
interface ImageInfo {
|
interface ImageInfo {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
@@ -30,9 +36,13 @@ interface ParsedResult {
|
|||||||
contentImages: ImageInfo[];
|
contentImages: ImageInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function convertMarkdown(
|
export async function convertMarkdown(
|
||||||
markdownPath: string,
|
markdownPath: string,
|
||||||
options?: { title?: string; theme?: string; keepTitle?: boolean; citeStatus?: boolean },
|
options?: ConvertMarkdownOptions,
|
||||||
): Promise<ParsedResult> {
|
): Promise<ParsedResult> {
|
||||||
const baseDir = path.dirname(markdownPath);
|
const baseDir = path.dirname(markdownPath);
|
||||||
const content = fs.readFileSync(markdownPath, "utf-8");
|
const content = fs.readFileSync(markdownPath, "utf-8");
|
||||||
@@ -56,20 +66,32 @@ export async function convertMarkdown(
|
|||||||
summary = extractSummaryFromBody(body, 120);
|
summary = extractSummaryFromBody(body, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveFrontmatter = options?.title
|
||||||
|
? { ...frontmatter, title }
|
||||||
|
: frontmatter;
|
||||||
|
|
||||||
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
|
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
|
||||||
body,
|
body,
|
||||||
"MDTOHTMLIMGPH_",
|
"MDTOHTMLIMGPH_",
|
||||||
);
|
);
|
||||||
const rewrittenMarkdown = `${serializeFrontmatter(frontmatter)}${rewrittenBody}`;
|
const rewrittenMarkdown = `${serializeFrontmatter(effectiveFrontmatter)}${rewrittenBody}`;
|
||||||
|
|
||||||
console.error(
|
console.error(
|
||||||
`[markdown-to-html] Rendering with theme: ${theme ?? "default"}, keepTitle: ${keepTitle}, citeStatus: ${citeStatus}`,
|
`[markdown-to-html] Rendering with theme: ${theme ?? "default"}, keepTitle: ${keepTitle}, citeStatus: ${citeStatus}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { html } = await renderMarkdownDocument(rewrittenMarkdown, {
|
const { html } = await renderMarkdownDocument(rewrittenMarkdown, {
|
||||||
|
codeTheme: options?.codeTheme,
|
||||||
|
countStatus: options?.countStatus,
|
||||||
citeStatus,
|
citeStatus,
|
||||||
defaultTitle: title,
|
defaultTitle: title,
|
||||||
|
fontFamily: options?.fontFamily,
|
||||||
|
fontSize: options?.fontSize,
|
||||||
|
isMacCodeBlock: options?.isMacCodeBlock,
|
||||||
|
isShowLineNumber: options?.isShowLineNumber,
|
||||||
keepTitle,
|
keepTitle,
|
||||||
|
legend: options?.legend,
|
||||||
|
primaryColor: options?.primaryColor,
|
||||||
theme,
|
theme,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,7 +133,10 @@ export async function convertMarkdown(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function printUsage(): never {
|
function printUsage(exitCode = 0): never {
|
||||||
|
const colorNames = Object.keys(COLOR_PRESETS).join(", ");
|
||||||
|
const fontFamilyNames = Object.keys(FONT_FAMILY_MAP).join(", ");
|
||||||
|
|
||||||
console.log(`Convert Markdown to styled HTML
|
console.log(`Convert Markdown to styled HTML
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@@ -119,8 +144,17 @@ Usage:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--title <title> Override title
|
--title <title> Override title
|
||||||
--theme <name> Theme name (default, grace, simple). Default: default
|
--theme <name> Theme name (${THEME_NAMES.join(", ")}). Default: default
|
||||||
|
--color <name|hex> Primary color: ${colorNames}
|
||||||
|
--font-family <name> Font: ${fontFamilyNames}, or CSS value
|
||||||
|
--font-size <N> Font size: ${FONT_SIZE_OPTIONS.join(", ")} (default: 16px)
|
||||||
|
--code-theme <name> Code highlight theme (default: github)
|
||||||
|
--mac-code-block Show Mac-style code block header
|
||||||
|
--no-mac-code-block Hide Mac-style code block header
|
||||||
|
--line-number Show line numbers in code blocks
|
||||||
--cite Convert ordinary external links to bottom citations. Default: off
|
--cite Convert ordinary external links to bottom citations. Default: off
|
||||||
|
--count Show reading time / word count
|
||||||
|
--legend <value> Image caption: title-alt, alt-title, title, alt, none
|
||||||
--keep-title Keep the first heading in content. Default: false (removed)
|
--keep-title Keep the first heading in content. Default: false (removed)
|
||||||
--help Show this help
|
--help Show this help
|
||||||
|
|
||||||
@@ -142,40 +176,60 @@ Output JSON format:
|
|||||||
Example:
|
Example:
|
||||||
npx -y bun main.ts article.md
|
npx -y bun main.ts article.md
|
||||||
npx -y bun main.ts article.md --theme grace
|
npx -y bun main.ts article.md --theme grace
|
||||||
|
npx -y bun main.ts article.md --theme modern --color red
|
||||||
npx -y bun main.ts article.md --cite
|
npx -y bun main.ts article.md --cite
|
||||||
`);
|
`);
|
||||||
process.exit(0);
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgValue(argv: string[], i: number, flag: string): string | null {
|
||||||
|
const arg = argv[i]!;
|
||||||
|
if (arg.includes("=")) {
|
||||||
|
return arg.slice(flag.length + 1);
|
||||||
|
}
|
||||||
|
const next = argv[i + 1];
|
||||||
|
return next ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTitleArg(argv: string[]): { renderArgs: string[]; title?: string } {
|
||||||
|
let title: string | undefined;
|
||||||
|
const renderArgs: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i]!;
|
||||||
|
if (arg === "--title" || arg.startsWith("--title=")) {
|
||||||
|
const value = parseArgValue(argv, i, "--title");
|
||||||
|
if (!value) {
|
||||||
|
console.error("Missing value for --title");
|
||||||
|
printUsage(1);
|
||||||
|
}
|
||||||
|
title = value;
|
||||||
|
if (!arg.includes("=")) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
renderArgs.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { renderArgs, title };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
||||||
printUsage();
|
printUsage(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let markdownPath: string | undefined;
|
const { renderArgs, title } = extractTitleArg(args);
|
||||||
let title: string | undefined;
|
const options = parseArgs(renderArgs);
|
||||||
let theme: string | undefined;
|
if (!options) {
|
||||||
let citeStatus = false;
|
printUsage(1);
|
||||||
let keepTitle = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
|
||||||
const arg = args[i]!;
|
|
||||||
if (arg === "--title" && args[i + 1]) {
|
|
||||||
title = args[++i];
|
|
||||||
} else if (arg === "--theme" && args[i + 1]) {
|
|
||||||
theme = args[++i];
|
|
||||||
} else if (arg === "--cite") {
|
|
||||||
citeStatus = true;
|
|
||||||
} else if (arg === "--keep-title") {
|
|
||||||
keepTitle = true;
|
|
||||||
} else if (!arg.startsWith("-")) {
|
|
||||||
markdownPath = arg;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!markdownPath) {
|
const markdownPath = path.resolve(process.cwd(), options.inputPath);
|
||||||
console.error("Error: Markdown file path is required");
|
if (!markdownPath.toLowerCase().endsWith(".md")) {
|
||||||
|
console.error("Input file must end with .md");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +238,7 @@ async function main(): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await convertMarkdown(markdownPath, { title, theme, keepTitle, citeStatus });
|
const result = await convertMarkdown(markdownPath, { ...options, title });
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
|
|||||||
import {
|
import {
|
||||||
buildMarkdownDocumentMeta,
|
buildMarkdownDocumentMeta,
|
||||||
formatTimestamp,
|
formatTimestamp,
|
||||||
|
renderMarkdownDocument,
|
||||||
resolveColorToken,
|
resolveColorToken,
|
||||||
resolveFontFamilyToken,
|
resolveFontFamilyToken,
|
||||||
resolveMarkdownStyle,
|
resolveMarkdownStyle,
|
||||||
resolveRenderOptions,
|
resolveRenderOptions,
|
||||||
} from "./document.ts";
|
} from "./document.ts";
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInlineStyle(html: string, tagName: string, text: string): string {
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
|
||||||
|
);
|
||||||
|
const match = html.match(pattern);
|
||||||
|
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
|
||||||
|
return match![1]!;
|
||||||
|
}
|
||||||
|
|
||||||
function useCwd(t: TestContext, cwd: string): void {
|
function useCwd(t: TestContext, cwd: string): void {
|
||||||
const previous = process.cwd();
|
const previous = process.cwd();
|
||||||
process.chdir(cwd);
|
process.chdir(cwd);
|
||||||
@@ -138,3 +152,23 @@ keep_title: true
|
|||||||
assert.equal(explicit.fontSize, "18px");
|
assert.equal(explicit.fontSize, "18px");
|
||||||
assert.equal(explicit.keepTitle, false);
|
assert.equal(explicit.keepTitle, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
|
||||||
|
const { html } = await renderMarkdownDocument(
|
||||||
|
`## Section\n\nParagraph with **bold** text.`,
|
||||||
|
{ keepTitle: true, theme: "grace" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const h2Style = findInlineStyle(html, "h2", "Section");
|
||||||
|
assert.match(h2Style, /background: #92617E/);
|
||||||
|
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
|
||||||
|
|
||||||
|
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
|
||||||
|
assert.ok(pMatch, "Expected inline style on <p> tag");
|
||||||
|
assert.match(pMatch![1]!, /color:/);
|
||||||
|
|
||||||
|
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
|
||||||
|
const strongMatch = html.match(strongPattern);
|
||||||
|
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
|
||||||
|
assert.match(strongMatch![1]!, /font-weight:/);
|
||||||
|
});
|
||||||
|
|||||||
+11
@@ -59,6 +59,17 @@ test("normalizeCssText and normalizeInlineCss replace variables and strip declar
|
|||||||
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
|
||||||
|
const normalizedHtml = normalizeInlineCss(
|
||||||
|
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
|
||||||
|
DEFAULT_STYLE,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
|
||||||
|
assert.doesNotMatch(normalizedHtml, /Courier New/);
|
||||||
|
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
|
||||||
|
});
|
||||||
|
|
||||||
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
||||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
|
|||||||
.replace(/var\(--md-accent-color\)/g, style.accentColor)
|
.replace(/var\(--md-accent-color\)/g, style.accentColor)
|
||||||
.replace(/var\(--md-container-bg\)/g, style.containerBg)
|
.replace(/var\(--md-container-bg\)/g, style.containerBg)
|
||||||
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
||||||
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
.replace(/--md-font-family:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
|
.replace(/--md-font-size:\s*[^;]+;?/g, "")
|
||||||
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
|
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
|
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
|
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
|
||||||
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
.replace(/--foreground:\s*[^;]+;?/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
|
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
|
|||||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
||||||
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
||||||
|
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
|
||||||
|
|
||||||
function stripOutputScope(cssContent: string): string {
|
function stripOutputScope(cssContent: string): string {
|
||||||
let css = cssContent;
|
let css = cssContent;
|
||||||
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
|
|||||||
themeCss: string;
|
themeCss: string;
|
||||||
} {
|
} {
|
||||||
const basePath = path.join(THEME_DIR, "base.css");
|
const basePath = path.join(THEME_DIR, "base.css");
|
||||||
|
const defaultThemePath = path.join(THEME_DIR, "default.css");
|
||||||
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
||||||
|
|
||||||
if (!fs.existsSync(basePath)) {
|
if (!fs.existsSync(basePath)) {
|
||||||
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
|
|||||||
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
|
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const layeredThemeCss: string[] = [];
|
||||||
|
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
|
||||||
|
if (!fs.existsSync(defaultThemePath)) {
|
||||||
|
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
|
||||||
|
}
|
||||||
|
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
|
||||||
|
}
|
||||||
|
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseCss: fs.readFileSync(basePath, "utf-8"),
|
baseCss: fs.readFileSync(basePath, "utf-8"),
|
||||||
themeCss: fs.readFileSync(themePath, "utf-8"),
|
themeCss: layeredThemeCss.join("\n"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,32 +4,108 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "baoyu-post-to-wechat-scripts",
|
"name": "baoyu-post-to-wechat-scripts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@jsquash/webp": "^1.5.0",
|
||||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||||
"baoyu-md": "file:./vendor/baoyu-md",
|
"baoyu-md": "file:./vendor/baoyu-md",
|
||||||
|
"jimp": "^1.6.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="],
|
||||||
|
|
||||||
|
"@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="],
|
||||||
|
|
||||||
|
"@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="],
|
||||||
|
|
||||||
|
"@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="],
|
||||||
|
|
||||||
|
"@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="],
|
||||||
|
|
||||||
|
"@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="],
|
||||||
|
|
||||||
|
"@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="],
|
||||||
|
|
||||||
|
"@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="],
|
||||||
|
|
||||||
|
"@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="],
|
||||||
|
|
||||||
|
"@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="],
|
||||||
|
|
||||||
|
"@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="],
|
||||||
|
|
||||||
|
"@jsquash/webp": ["@jsquash/webp@1.5.0", "", { "dependencies": { "wasm-feature-detect": "^1.2.11" } }, "sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw=="],
|
||||||
|
|
||||||
|
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||||
|
|
||||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||||
|
|
||||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||||
|
|
||||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="],
|
||||||
|
|
||||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||||
|
|
||||||
|
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||||
|
|
||||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||||
|
|
||||||
|
"any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="],
|
||||||
|
|
||||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||||
|
|
||||||
|
"await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="],
|
||||||
|
|
||||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||||
|
|
||||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||||
|
|
||||||
"baoyu-md": ["baoyu-md@file:vendor/baoyu-md", { "dependencies": { "fflate": "^0.8.2", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "juice": "^11.0.1", "marked": "^15.0.6", "reading-time": "^1.5.0", "remark-cjk-friendly": "^1.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5" } }],
|
"baoyu-md": ["baoyu-md@file:vendor/baoyu-md", { "dependencies": { "fflate": "^0.8.2", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "juice": "^11.0.1", "marked": "^15.0.6", "reading-time": "^1.5.0", "remark-cjk-friendly": "^1.1.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.5" } }],
|
||||||
|
|
||||||
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
|
"bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="],
|
||||||
|
|
||||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||||
|
|
||||||
|
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||||
|
|
||||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||||
|
|
||||||
"cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="],
|
"cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="],
|
||||||
@@ -66,22 +142,40 @@
|
|||||||
|
|
||||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||||
|
|
||||||
|
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||||
|
|
||||||
|
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||||
|
|
||||||
|
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
|
||||||
|
|
||||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||||
|
|
||||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||||
|
|
||||||
|
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
|
||||||
|
|
||||||
"front-matter": ["front-matter@4.0.2", "", { "dependencies": { "js-yaml": "^3.13.1" } }, "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg=="],
|
"front-matter": ["front-matter@4.0.2", "", { "dependencies": { "js-yaml": "^3.13.1" } }, "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg=="],
|
||||||
|
|
||||||
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
|
||||||
|
|
||||||
|
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
||||||
|
|
||||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||||
|
|
||||||
"htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
|
"htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
|
||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||||
|
|
||||||
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
|
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
|
||||||
|
|
||||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||||
|
|
||||||
|
"jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="],
|
||||||
|
|
||||||
|
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
|
||||||
|
|
||||||
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||||
|
|
||||||
"juice": ["juice@11.1.1", "", { "dependencies": { "cheerio": "1.0.0", "commander": "^12.1.0", "entities": "^7.0.0", "mensch": "^0.3.4", "slick": "^1.12.2", "web-resource-inliner": "^8.0.0" }, "bin": { "juice": "bin/juice" } }, "sha512-4SBfZqKcc6DrIS+5b/WiGoWaZsdUPBH+e6SbRlNjJpaIRtfoBhYReAtobIEW6mcLeFFDXLBJMuZwkJLkBJjs2w=="],
|
"juice": ["juice@11.1.1", "", { "dependencies": { "cheerio": "1.0.0", "commander": "^12.1.0", "entities": "^7.0.0", "mensch": "^0.3.4", "slick": "^1.12.2", "web-resource-inliner": "^8.0.0" }, "bin": { "juice": "bin/juice" } }, "sha512-4SBfZqKcc6DrIS+5b/WiGoWaZsdUPBH+e6SbRlNjJpaIRtfoBhYReAtobIEW6mcLeFFDXLBJMuZwkJLkBJjs2w=="],
|
||||||
@@ -146,18 +240,40 @@
|
|||||||
|
|
||||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||||
|
|
||||||
"mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||||
|
|
||||||
|
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
|
||||||
|
|
||||||
|
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||||
|
|
||||||
|
"parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="],
|
||||||
|
|
||||||
|
"parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="],
|
||||||
|
|
||||||
|
"parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="],
|
||||||
|
|
||||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||||
|
|
||||||
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
|
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
|
||||||
|
|
||||||
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
|
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
|
||||||
|
|
||||||
|
"peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="],
|
||||||
|
|
||||||
|
"pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="],
|
||||||
|
|
||||||
|
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||||
|
|
||||||
|
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
|
||||||
|
|
||||||
|
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
||||||
|
|
||||||
|
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
|
||||||
|
|
||||||
"reading-time": ["reading-time@1.5.0", "", {}, "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg=="],
|
"reading-time": ["reading-time@1.5.0", "", {}, "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg=="],
|
||||||
|
|
||||||
"remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="],
|
"remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="],
|
||||||
@@ -166,12 +282,26 @@
|
|||||||
|
|
||||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||||
|
|
||||||
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
|
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
|
||||||
|
|
||||||
|
"simple-xml-to-json": ["simple-xml-to-json@1.2.4", "", {}, "sha512-3MY16e0ocMHL7N1ufpdObURGyX+lCo0T/A+y6VCwosLdH1HSda4QZl1Sdt/O+2qWp48WFi26XEp5rF0LoaL0Dg=="],
|
||||||
|
|
||||||
"slick": ["slick@1.12.2", "", {}, "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A=="],
|
"slick": ["slick@1.12.2", "", {}, "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A=="],
|
||||||
|
|
||||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||||
|
|
||||||
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
|
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
|
||||||
|
|
||||||
|
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
|
||||||
|
|
||||||
|
"token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="],
|
||||||
|
|
||||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||||
|
|
||||||
"undici": ["undici@6.24.0", "", {}, "sha512-lVLNosgqo5EkGqh5XUDhGfsMSoO8K0BAN0TyJLvwNRSl4xWGZlCVYsAIpa/OpA3TvmnM01GWcoKmc3ZWo5wKKA=="],
|
"undici": ["undici@6.24.0", "", {}, "sha512-lVLNosgqo5EkGqh5XUDhGfsMSoO8K0BAN0TyJLvwNRSl4xWGZlCVYsAIpa/OpA3TvmnM01GWcoKmc3ZWo5wKKA=="],
|
||||||
@@ -186,18 +316,30 @@
|
|||||||
|
|
||||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||||
|
|
||||||
|
"utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="],
|
||||||
|
|
||||||
"valid-data-url": ["valid-data-url@3.0.1", "", {}, "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA=="],
|
"valid-data-url": ["valid-data-url@3.0.1", "", {}, "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA=="],
|
||||||
|
|
||||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||||
|
|
||||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||||
|
|
||||||
|
"wasm-feature-detect": ["wasm-feature-detect@1.8.0", "", {}, "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ=="],
|
||||||
|
|
||||||
"web-resource-inliner": ["web-resource-inliner@8.0.0", "", { "dependencies": { "ansi-colors": "^4.1.1", "escape-goat": "^3.0.0", "htmlparser2": "^9.1.0", "mime": "^2.4.6", "valid-data-url": "^3.0.0" } }, "sha512-Ezr98sqXW/+OCGoUEXuOKVR+oVFlSdn1tIySEEJdiSAw4IjrW8hQkwARSSBJTSB5Us5dnytDgL0ZDliAYBhaNA=="],
|
"web-resource-inliner": ["web-resource-inliner@8.0.0", "", { "dependencies": { "ansi-colors": "^4.1.1", "escape-goat": "^3.0.0", "htmlparser2": "^9.1.0", "mime": "^2.4.6", "valid-data-url": "^3.0.0" } }, "sha512-Ezr98sqXW/+OCGoUEXuOKVR+oVFlSdn1tIySEEJdiSAw4IjrW8hQkwARSSBJTSB5Us5dnytDgL0ZDliAYBhaNA=="],
|
||||||
|
|
||||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||||
|
|
||||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||||
|
|
||||||
|
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
|
||||||
|
|
||||||
|
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||||
|
|
||||||
|
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||||
|
|
||||||
|
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||||
|
|
||||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
@@ -205,5 +347,9 @@
|
|||||||
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||||
|
|
||||||
|
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
||||||
|
|
||||||
|
"web-resource-inliner/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@jsquash/webp": "^1.5.0",
|
||||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||||
"baoyu-md": "file:./vendor/baoyu-md"
|
"baoyu-md": "file:./vendor/baoyu-md",
|
||||||
|
"jimp": "^1.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
|
|||||||
import {
|
import {
|
||||||
buildMarkdownDocumentMeta,
|
buildMarkdownDocumentMeta,
|
||||||
formatTimestamp,
|
formatTimestamp,
|
||||||
|
renderMarkdownDocument,
|
||||||
resolveColorToken,
|
resolveColorToken,
|
||||||
resolveFontFamilyToken,
|
resolveFontFamilyToken,
|
||||||
resolveMarkdownStyle,
|
resolveMarkdownStyle,
|
||||||
resolveRenderOptions,
|
resolveRenderOptions,
|
||||||
} from "./document.ts";
|
} from "./document.ts";
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInlineStyle(html: string, tagName: string, text: string): string {
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
|
||||||
|
);
|
||||||
|
const match = html.match(pattern);
|
||||||
|
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
|
||||||
|
return match![1]!;
|
||||||
|
}
|
||||||
|
|
||||||
function useCwd(t: TestContext, cwd: string): void {
|
function useCwd(t: TestContext, cwd: string): void {
|
||||||
const previous = process.cwd();
|
const previous = process.cwd();
|
||||||
process.chdir(cwd);
|
process.chdir(cwd);
|
||||||
@@ -138,3 +152,23 @@ keep_title: true
|
|||||||
assert.equal(explicit.fontSize, "18px");
|
assert.equal(explicit.fontSize, "18px");
|
||||||
assert.equal(explicit.keepTitle, false);
|
assert.equal(explicit.keepTitle, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
|
||||||
|
const { html } = await renderMarkdownDocument(
|
||||||
|
`## Section\n\nParagraph with **bold** text.`,
|
||||||
|
{ keepTitle: true, theme: "grace" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const h2Style = findInlineStyle(html, "h2", "Section");
|
||||||
|
assert.match(h2Style, /background: #92617E/);
|
||||||
|
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
|
||||||
|
|
||||||
|
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
|
||||||
|
assert.ok(pMatch, "Expected inline style on <p> tag");
|
||||||
|
assert.match(pMatch![1]!, /color:/);
|
||||||
|
|
||||||
|
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
|
||||||
|
const strongMatch = html.match(strongPattern);
|
||||||
|
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
|
||||||
|
assert.match(strongMatch![1]!, /font-weight:/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ test("normalizeCssText and normalizeInlineCss replace variables and strip declar
|
|||||||
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
|
||||||
|
const normalizedHtml = normalizeInlineCss(
|
||||||
|
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
|
||||||
|
DEFAULT_STYLE,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
|
||||||
|
assert.doesNotMatch(normalizedHtml, /Courier New/);
|
||||||
|
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
|
||||||
|
});
|
||||||
|
|
||||||
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
||||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
|
|||||||
.replace(/var\(--md-accent-color\)/g, style.accentColor)
|
.replace(/var\(--md-accent-color\)/g, style.accentColor)
|
||||||
.replace(/var\(--md-container-bg\)/g, style.containerBg)
|
.replace(/var\(--md-container-bg\)/g, style.containerBg)
|
||||||
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
||||||
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
.replace(/--md-font-family:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
|
.replace(/--md-font-size:\s*[^;]+;?/g, "")
|
||||||
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
|
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
|
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
|
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
|
||||||
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
.replace(/--foreground:\s*[^;]+;?/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
|
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
|
|||||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
||||||
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
||||||
|
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
|
||||||
|
|
||||||
function stripOutputScope(cssContent: string): string {
|
function stripOutputScope(cssContent: string): string {
|
||||||
let css = cssContent;
|
let css = cssContent;
|
||||||
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
|
|||||||
themeCss: string;
|
themeCss: string;
|
||||||
} {
|
} {
|
||||||
const basePath = path.join(THEME_DIR, "base.css");
|
const basePath = path.join(THEME_DIR, "base.css");
|
||||||
|
const defaultThemePath = path.join(THEME_DIR, "default.css");
|
||||||
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
||||||
|
|
||||||
if (!fs.existsSync(basePath)) {
|
if (!fs.existsSync(basePath)) {
|
||||||
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
|
|||||||
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
|
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const layeredThemeCss: string[] = [];
|
||||||
|
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
|
||||||
|
if (!fs.existsSync(defaultThemePath)) {
|
||||||
|
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
|
||||||
|
}
|
||||||
|
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
|
||||||
|
}
|
||||||
|
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseCss: fs.readFileSync(basePath, "utf-8"),
|
baseCss: fs.readFileSync(basePath, "utf-8"),
|
||||||
themeCss: fs.readFileSync(themePath, "utf-8"),
|
themeCss: layeredThemeCss.join("\n"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import path from "node:path";
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { loadWechatExtendConfig, resolveAccount, loadCredentials } from "./wechat-extend-config.ts";
|
import { loadWechatExtendConfig, resolveAccount, loadCredentials } from "./wechat-extend-config.ts";
|
||||||
|
import {
|
||||||
|
type WechatUploadAsset,
|
||||||
|
prepareWechatBodyImageUpload,
|
||||||
|
needsWechatBodyImageProcessing,
|
||||||
|
} from "./wechat-image-processor.ts";
|
||||||
|
|
||||||
interface AccessTokenResponse {
|
interface AccessTokenResponse {
|
||||||
access_token?: string;
|
access_token?: string;
|
||||||
@@ -52,10 +57,10 @@ interface ArticleOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
|
const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";
|
||||||
const UPLOAD_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
|
const UPLOAD_BODY_IMG_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg";
|
||||||
|
const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
|
||||||
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
|
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
|
||||||
|
|
||||||
|
|
||||||
async function fetchAccessToken(appId: string, appSecret: string): Promise<string> {
|
async function fetchAccessToken(appId: string, appSecret: string): Promise<string> {
|
||||||
const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
@@ -72,14 +77,20 @@ async function fetchAccessToken(appId: string, appSecret: string): Promise<strin
|
|||||||
return data.access_token;
|
return data.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadImage(
|
function toHttpsUrl(url: string | undefined): string {
|
||||||
|
if (!url) return "";
|
||||||
|
return url.startsWith("http://") ? url.replace(/^http:\/\//i, "https://") : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUploadAsset(
|
||||||
imagePath: string,
|
imagePath: string,
|
||||||
accessToken: string,
|
baseDir?: string,
|
||||||
baseDir?: string
|
): Promise<WechatUploadAsset> {
|
||||||
): Promise<UploadResponse> {
|
|
||||||
let fileBuffer: Buffer;
|
let fileBuffer: Buffer;
|
||||||
let filename: string;
|
let filename: string;
|
||||||
let contentType: string;
|
let contentType: string;
|
||||||
|
let fileSize = 0;
|
||||||
|
let fileExt = "";
|
||||||
|
|
||||||
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||||
const response = await fetch(imagePath);
|
const response = await fetch(imagePath);
|
||||||
@@ -91,8 +102,10 @@ async function uploadImage(
|
|||||||
throw new Error(`Remote image is empty: ${imagePath}`);
|
throw new Error(`Remote image is empty: ${imagePath}`);
|
||||||
}
|
}
|
||||||
fileBuffer = Buffer.from(buffer);
|
fileBuffer = Buffer.from(buffer);
|
||||||
|
fileSize = buffer.byteLength;
|
||||||
const urlPath = imagePath.split("?")[0];
|
const urlPath = imagePath.split("?")[0];
|
||||||
filename = path.basename(urlPath) || "image.jpg";
|
filename = path.basename(urlPath) || "image.jpg";
|
||||||
|
fileExt = path.extname(filename).toLowerCase();
|
||||||
contentType = response.headers.get("content-type") || "image/jpeg";
|
contentType = response.headers.get("content-type") || "image/jpeg";
|
||||||
} else {
|
} else {
|
||||||
const resolvedPath = path.isAbsolute(imagePath)
|
const resolvedPath = path.isAbsolute(imagePath)
|
||||||
@@ -106,19 +119,85 @@ async function uploadImage(
|
|||||||
if (stats.size === 0) {
|
if (stats.size === 0) {
|
||||||
throw new Error(`Local image is empty: ${resolvedPath}`);
|
throw new Error(`Local image is empty: ${resolvedPath}`);
|
||||||
}
|
}
|
||||||
|
fileSize = stats.size;
|
||||||
fileBuffer = fs.readFileSync(resolvedPath);
|
fileBuffer = fs.readFileSync(resolvedPath);
|
||||||
filename = path.basename(resolvedPath);
|
filename = path.basename(resolvedPath);
|
||||||
const ext = path.extname(filename).toLowerCase();
|
fileExt = path.extname(filename).toLowerCase();
|
||||||
const mimeTypes: Record<string, string> = {
|
const mimeTypes: Record<string, string> = {
|
||||||
".jpg": "image/jpeg",
|
".jpg": "image/jpeg",
|
||||||
".jpeg": "image/jpeg",
|
".jpeg": "image/jpeg",
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
".gif": "image/gif",
|
".gif": "image/gif",
|
||||||
".webp": "image/webp",
|
".webp": "image/webp",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
".tiff": "image/tiff",
|
||||||
|
".tif": "image/tiff",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
};
|
};
|
||||||
contentType = mimeTypes[ext] || "image/jpeg";
|
contentType = mimeTypes[fileExt] || "image/jpeg";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: fileBuffer,
|
||||||
|
filename,
|
||||||
|
contentType,
|
||||||
|
fileExt,
|
||||||
|
fileSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage(
|
||||||
|
imagePath: string,
|
||||||
|
accessToken: string,
|
||||||
|
baseDir?: string,
|
||||||
|
uploadType: "body" | "material" = "body"
|
||||||
|
): Promise<UploadResponse> {
|
||||||
|
const asset = await loadUploadAsset(imagePath, baseDir);
|
||||||
|
let uploadAsset = asset;
|
||||||
|
|
||||||
|
if (uploadType === "body" && needsWechatBodyImageProcessing(asset)) {
|
||||||
|
const prepared = await prepareWechatBodyImageUpload(asset);
|
||||||
|
uploadAsset = {
|
||||||
|
...asset,
|
||||||
|
buffer: prepared.buffer,
|
||||||
|
filename: prepared.filename,
|
||||||
|
contentType: prepared.contentType,
|
||||||
|
fileExt: path.extname(prepared.filename).toLowerCase(),
|
||||||
|
fileSize: prepared.buffer.length,
|
||||||
|
};
|
||||||
|
const note = prepared.processingNotes.join(", ");
|
||||||
|
console.error(`[wechat-api] Processed ${asset.filename} for body upload: ${note}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await uploadToWechat(
|
||||||
|
uploadAsset.buffer,
|
||||||
|
uploadAsset.filename,
|
||||||
|
uploadAsset.contentType,
|
||||||
|
accessToken,
|
||||||
|
uploadType,
|
||||||
|
);
|
||||||
|
|
||||||
|
// media/uploadimg 接口只返回 URL,material/add_material 返回 media_id
|
||||||
|
if (uploadType === "body") {
|
||||||
|
return {
|
||||||
|
url: toHttpsUrl(result.url),
|
||||||
|
media_id: "",
|
||||||
|
} as UploadResponse;
|
||||||
|
} else {
|
||||||
|
result.url = toHttpsUrl(result.url);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实际的微信上传函数
|
||||||
|
async function uploadToWechat(
|
||||||
|
fileBuffer: Buffer,
|
||||||
|
filename: string,
|
||||||
|
contentType: string,
|
||||||
|
accessToken: string,
|
||||||
|
uploadType: "body" | "material"
|
||||||
|
): Promise<UploadResponse> {
|
||||||
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
|
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
|
||||||
const header = [
|
const header = [
|
||||||
`--${boundary}`,
|
`--${boundary}`,
|
||||||
@@ -133,7 +212,8 @@ async function uploadImage(
|
|||||||
const footerBuffer = Buffer.from(footer, "utf-8");
|
const footerBuffer = Buffer.from(footer, "utf-8");
|
||||||
const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]);
|
const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]);
|
||||||
|
|
||||||
const url = `${UPLOAD_URL}?access_token=${accessToken}&type=image`;
|
const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;
|
||||||
|
const url = `${uploadUrl}?type=image&access_token=${accessToken}`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -147,10 +227,6 @@ async function uploadImage(
|
|||||||
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);
|
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.url?.startsWith("http://")) {
|
|
||||||
data.url = data.url.replace(/^http:\/\//i, "https://");
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,17 +235,19 @@ async function uploadImagesInHtml(
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
baseDir: string,
|
baseDir: string,
|
||||||
contentImages: ImageInfo[] = [],
|
contentImages: ImageInfo[] = [],
|
||||||
): Promise<{ html: string; firstMediaId: string; allMediaIds: string[] }> {
|
articleType: ArticleType = "news",
|
||||||
|
collectNewsCoverFallback: boolean = false,
|
||||||
|
): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> {
|
||||||
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
||||||
const matches = [...html.matchAll(imgRegex)];
|
const matches = [...html.matchAll(imgRegex)];
|
||||||
|
|
||||||
if (matches.length === 0 && contentImages.length === 0) {
|
if (matches.length === 0 && contentImages.length === 0) {
|
||||||
return { html, firstMediaId: "", allMediaIds: [] };
|
return { html, firstCoverMediaId: "", imageMediaIds: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
let firstMediaId = "";
|
let firstCoverMediaId = "";
|
||||||
let updatedHtml = html;
|
let updatedHtml = html;
|
||||||
const allMediaIds: string[] = [];
|
const imageMediaIds: string[] = [];
|
||||||
const uploadedBySource = new Map<string, UploadResponse>();
|
const uploadedBySource = new Map<string, UploadResponse>();
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
@@ -177,8 +255,13 @@ async function uploadImagesInHtml(
|
|||||||
if (!src) continue;
|
if (!src) continue;
|
||||||
|
|
||||||
if (src.startsWith("https://mmbiz.qpic.cn")) {
|
if (src.startsWith("https://mmbiz.qpic.cn")) {
|
||||||
if (!firstMediaId) {
|
if (collectNewsCoverFallback && !firstCoverMediaId) {
|
||||||
firstMediaId = src;
|
try {
|
||||||
|
const coverResp = await uploadImage(src, accessToken, baseDir, "material");
|
||||||
|
firstCoverMediaId = coverResp.media_id;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[wechat-api] Failed to reuse existing WeChat image as cover: ${src}`, err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -186,20 +269,31 @@ async function uploadImagesInHtml(
|
|||||||
const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);
|
const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);
|
||||||
const imagePath = localPathMatch ? localPathMatch[1]! : src;
|
const imagePath = localPathMatch ? localPathMatch[1]! : src;
|
||||||
|
|
||||||
console.error(`[wechat-api] Uploading image: ${imagePath}`);
|
console.error(`[wechat-api] Uploading body image: ${imagePath}`);
|
||||||
try {
|
try {
|
||||||
let resp = uploadedBySource.get(imagePath);
|
let resp = uploadedBySource.get(imagePath);
|
||||||
if (!resp) {
|
if (!resp) {
|
||||||
resp = await uploadImage(imagePath, accessToken, baseDir);
|
// 正文图片使用 media/uploadimg 接口获取 URL
|
||||||
|
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
|
||||||
uploadedBySource.set(imagePath, resp);
|
uploadedBySource.set(imagePath, resp);
|
||||||
}
|
}
|
||||||
const newTag = fullTag
|
const newTag = fullTag
|
||||||
.replace(/\ssrc=["'][^"']+["']/, ` src="${resp.url}"`)
|
.replace(/\ssrc=["'][^"']+["']/, ` src="${resp.url}"`)
|
||||||
.replace(/\sdata-local-path=["'][^"']+["']/, "");
|
.replace(/\sdata-local-path=["'][^"']+["']/, "");
|
||||||
updatedHtml = updatedHtml.replace(fullTag, newTag);
|
updatedHtml = updatedHtml.replace(fullTag, newTag);
|
||||||
allMediaIds.push(resp.media_id);
|
const shouldUploadMaterial = articleType === "newspic" || (collectNewsCoverFallback && !firstCoverMediaId);
|
||||||
if (!firstMediaId) {
|
if (shouldUploadMaterial) {
|
||||||
firstMediaId = resp.media_id;
|
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||||
|
if (!materialResp) {
|
||||||
|
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
|
||||||
|
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||||
|
}
|
||||||
|
if (articleType === "newspic" && materialResp.media_id) {
|
||||||
|
imageMediaIds.push(materialResp.media_id);
|
||||||
|
}
|
||||||
|
if (collectNewsCoverFallback && !firstCoverMediaId && materialResp.media_id) {
|
||||||
|
firstCoverMediaId = materialResp.media_id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[wechat-api] Failed to upload ${imagePath}:`, err);
|
console.error(`[wechat-api] Failed to upload ${imagePath}:`, err);
|
||||||
@@ -210,27 +304,38 @@ async function uploadImagesInHtml(
|
|||||||
if (!updatedHtml.includes(image.placeholder)) continue;
|
if (!updatedHtml.includes(image.placeholder)) continue;
|
||||||
|
|
||||||
const imagePath = image.localPath || image.originalPath;
|
const imagePath = image.localPath || image.originalPath;
|
||||||
console.error(`[wechat-api] Uploading placeholder image: ${imagePath}`);
|
console.error(`[wechat-api] Uploading body image: ${imagePath}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let resp = uploadedBySource.get(imagePath);
|
let resp = uploadedBySource.get(imagePath);
|
||||||
if (!resp) {
|
if (!resp) {
|
||||||
resp = await uploadImage(imagePath, accessToken, baseDir);
|
// 正文图片使用 media/uploadimg 接口获取 URL
|
||||||
|
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
|
||||||
uploadedBySource.set(imagePath, resp);
|
uploadedBySource.set(imagePath, resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
const replacementTag = `<img src="${resp.url}" style="display: block; width: 100%; margin: 1.5em auto;">`;
|
const replacementTag = `<img src="${resp.url}" style="display: block; width: 100%; margin: 1.5em auto;">`;
|
||||||
updatedHtml = replaceAllPlaceholders(updatedHtml, image.placeholder, replacementTag);
|
updatedHtml = replaceAllPlaceholders(updatedHtml, image.placeholder, replacementTag);
|
||||||
allMediaIds.push(resp.media_id);
|
const shouldUploadMaterial = articleType === "newspic" || (collectNewsCoverFallback && !firstCoverMediaId);
|
||||||
if (!firstMediaId) {
|
if (shouldUploadMaterial) {
|
||||||
firstMediaId = resp.media_id;
|
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||||
|
if (!materialResp) {
|
||||||
|
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
|
||||||
|
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||||
|
}
|
||||||
|
if (articleType === "newspic" && materialResp.media_id) {
|
||||||
|
imageMediaIds.push(materialResp.media_id);
|
||||||
|
}
|
||||||
|
if (collectNewsCoverFallback && !firstCoverMediaId && materialResp.media_id) {
|
||||||
|
firstCoverMediaId = materialResp.media_id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[wechat-api] Failed to upload placeholder ${image.placeholder}:`, err);
|
console.error(`[wechat-api] Failed to upload placeholder ${image.placeholder}:`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { html: updatedHtml, firstMediaId, allMediaIds };
|
return { html: updatedHtml, firstCoverMediaId, imageMediaIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishToDraft(
|
async function publishToDraft(
|
||||||
@@ -345,7 +450,7 @@ function renderMarkdownWithPlaceholders(
|
|||||||
|
|
||||||
function replaceAllPlaceholders(html: string, placeholder: string, replacement: string): string {
|
function replaceAllPlaceholders(html: string, placeholder: string, replacement: string): string {
|
||||||
const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
return html.replace(new RegExp(escapedPlaceholder, "g"), replacement);
|
return html.replace(new RegExp(escapedPlaceholder + "(?!\\d)", "g"), replacement);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractHtmlContent(htmlPath: string): string {
|
function extractHtmlContent(htmlPath: string): string {
|
||||||
@@ -589,19 +694,13 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const creds = loadCredentials(resolved);
|
const creds = loadCredentials(resolved);
|
||||||
|
for (const skippedSource of creds.skippedSources) {
|
||||||
|
console.error(`[wechat-api] Skipped incomplete credential source: ${skippedSource}`);
|
||||||
|
}
|
||||||
|
console.error(`[wechat-api] Credentials source: ${creds.source}`);
|
||||||
console.error("[wechat-api] Fetching access token...");
|
console.error("[wechat-api] Fetching access token...");
|
||||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
|
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
|
||||||
|
|
||||||
console.error("[wechat-api] Uploading images...");
|
|
||||||
const { html: processedHtml, firstMediaId, allMediaIds } = await uploadImagesInHtml(
|
|
||||||
htmlContent,
|
|
||||||
accessToken,
|
|
||||||
baseDir,
|
|
||||||
contentImages,
|
|
||||||
);
|
|
||||||
htmlContent = processedHtml;
|
|
||||||
|
|
||||||
let thumbMediaId = "";
|
|
||||||
const rawCoverPath = args.cover ||
|
const rawCoverPath = args.cover ||
|
||||||
frontmatter.coverImage ||
|
frontmatter.coverImage ||
|
||||||
frontmatter.featureImage ||
|
frontmatter.featureImage ||
|
||||||
@@ -610,19 +709,31 @@ async function main(): Promise<void> {
|
|||||||
const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover
|
const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover
|
||||||
? path.resolve(process.cwd(), rawCoverPath)
|
? path.resolve(process.cwd(), rawCoverPath)
|
||||||
: rawCoverPath;
|
: rawCoverPath;
|
||||||
|
const needNewsCoverFallback = args.articleType === "news" && !coverPath;
|
||||||
|
|
||||||
|
console.error("[wechat-api] Uploading body images...");
|
||||||
|
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
|
||||||
|
htmlContent,
|
||||||
|
accessToken,
|
||||||
|
baseDir,
|
||||||
|
contentImages,
|
||||||
|
args.articleType,
|
||||||
|
needNewsCoverFallback,
|
||||||
|
);
|
||||||
|
htmlContent = processedHtml;
|
||||||
|
|
||||||
|
let thumbMediaId = "";
|
||||||
|
|
||||||
if (coverPath) {
|
if (coverPath) {
|
||||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir);
|
// 封面图片使用 material/add_material 接口
|
||||||
|
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material");
|
||||||
thumbMediaId = coverResp.media_id;
|
thumbMediaId = coverResp.media_id;
|
||||||
} else if (firstMediaId) {
|
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
|
||||||
if (firstMediaId.startsWith("https://")) {
|
} else if (firstCoverMediaId && args.articleType === "news") {
|
||||||
console.error(`[wechat-api] Uploading first image as cover: ${firstMediaId}`);
|
// news 类型没有封面时,使用第一张正文图的 media_id 作为封面(兜底逻辑)
|
||||||
const coverResp = await uploadImage(firstMediaId, accessToken, baseDir);
|
thumbMediaId = firstCoverMediaId;
|
||||||
thumbMediaId = coverResp.media_id;
|
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
|
||||||
} else {
|
|
||||||
thumbMediaId = firstMediaId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.articleType === "news" && !thumbMediaId) {
|
if (args.articleType === "news" && !thumbMediaId) {
|
||||||
@@ -630,7 +741,7 @@ async function main(): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.articleType === "newspic" && allMediaIds.length === 0) {
|
if (args.articleType === "newspic" && imageMediaIds.length === 0) {
|
||||||
console.error("Error: newspic requires at least one image in content.");
|
console.error("Error: newspic requires at least one image in content.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -643,7 +754,7 @@ async function main(): Promise<void> {
|
|||||||
content: htmlContent,
|
content: htmlContent,
|
||||||
thumbMediaId,
|
thumbMediaId,
|
||||||
articleType: args.articleType,
|
articleType: args.articleType,
|
||||||
imageMediaIds: args.articleType === "newspic" ? allMediaIds : undefined,
|
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
|
||||||
needOpenComment: resolved.need_open_comment,
|
needOpenComment: resolved.need_open_comment,
|
||||||
onlyFansCanComment: resolved.only_fans_can_comment,
|
onlyFansCanComment: resolved.only_fans_can_comment,
|
||||||
}, accessToken);
|
}, accessToken);
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import test, { type TestContext } from "node:test";
|
||||||
|
|
||||||
|
import { loadCredentials } from "./wechat-extend-config.ts";
|
||||||
|
|
||||||
|
function useCwd(t: TestContext, cwd: string): void {
|
||||||
|
const previous = process.cwd();
|
||||||
|
process.chdir(cwd);
|
||||||
|
t.after(() => {
|
||||||
|
process.chdir(previous);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useHome(t: TestContext, home: string): void {
|
||||||
|
const previous = process.env.HOME;
|
||||||
|
process.env.HOME = home;
|
||||||
|
t.after(() => {
|
||||||
|
if (previous === undefined) {
|
||||||
|
delete process.env.HOME;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.env.HOME = previous;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useWechatEnv(
|
||||||
|
t: TestContext,
|
||||||
|
values: Partial<Record<"WECHAT_APP_ID" | "WECHAT_APP_SECRET", string | undefined>>,
|
||||||
|
): void {
|
||||||
|
const previous = {
|
||||||
|
WECHAT_APP_ID: process.env.WECHAT_APP_ID,
|
||||||
|
WECHAT_APP_SECRET: process.env.WECHAT_APP_SECRET,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (values.WECHAT_APP_ID === undefined) {
|
||||||
|
delete process.env.WECHAT_APP_ID;
|
||||||
|
} else {
|
||||||
|
process.env.WECHAT_APP_ID = values.WECHAT_APP_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.WECHAT_APP_SECRET === undefined) {
|
||||||
|
delete process.env.WECHAT_APP_SECRET;
|
||||||
|
} else {
|
||||||
|
process.env.WECHAT_APP_SECRET = values.WECHAT_APP_SECRET;
|
||||||
|
}
|
||||||
|
|
||||||
|
t.after(() => {
|
||||||
|
if (previous.WECHAT_APP_ID === undefined) {
|
||||||
|
delete process.env.WECHAT_APP_ID;
|
||||||
|
} else {
|
||||||
|
process.env.WECHAT_APP_ID = previous.WECHAT_APP_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous.WECHAT_APP_SECRET === undefined) {
|
||||||
|
delete process.env.WECHAT_APP_SECRET;
|
||||||
|
} else {
|
||||||
|
process.env.WECHAT_APP_SECRET = previous.WECHAT_APP_SECRET;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeTempDir(prefix: string): Promise<string> {
|
||||||
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeEnvFile(root: string, content: string): Promise<void> {
|
||||||
|
const envPath = path.join(root, ".baoyu-skills", ".env");
|
||||||
|
await fs.mkdir(path.dirname(envPath), { recursive: true });
|
||||||
|
await fs.writeFile(envPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("loadCredentials selects the first complete source without mixing values across sources", async (t) => {
|
||||||
|
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
||||||
|
const homeRoot = await makeTempDir("wechat-creds-home-");
|
||||||
|
|
||||||
|
useCwd(t, cwdRoot);
|
||||||
|
useHome(t, homeRoot);
|
||||||
|
useWechatEnv(t, {
|
||||||
|
WECHAT_APP_ID: undefined,
|
||||||
|
WECHAT_APP_SECRET: "stale-secret-from-process-env",
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeEnvFile(cwdRoot, "WECHAT_APP_ID=cwd-app-id\nWECHAT_APP_SECRET=cwd-app-secret\n");
|
||||||
|
await writeEnvFile(homeRoot, "WECHAT_APP_ID=home-app-id\nWECHAT_APP_SECRET=home-app-secret\n");
|
||||||
|
|
||||||
|
const credentials = loadCredentials();
|
||||||
|
|
||||||
|
assert.equal(credentials.appId, "cwd-app-id");
|
||||||
|
assert.equal(credentials.appSecret, "cwd-app-secret");
|
||||||
|
assert.equal(credentials.source, "<cwd>/.baoyu-skills/.env");
|
||||||
|
assert.deepEqual(credentials.skippedSources, [
|
||||||
|
"process.env missing WECHAT_APP_ID",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadCredentials prefers a complete process.env pair over lower-priority files", async (t) => {
|
||||||
|
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
||||||
|
const homeRoot = await makeTempDir("wechat-creds-home-");
|
||||||
|
|
||||||
|
useCwd(t, cwdRoot);
|
||||||
|
useHome(t, homeRoot);
|
||||||
|
useWechatEnv(t, {
|
||||||
|
WECHAT_APP_ID: "env-app-id",
|
||||||
|
WECHAT_APP_SECRET: "env-app-secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeEnvFile(cwdRoot, "WECHAT_APP_ID=cwd-app-id\nWECHAT_APP_SECRET=cwd-app-secret\n");
|
||||||
|
await writeEnvFile(homeRoot, "WECHAT_APP_ID=home-app-id\nWECHAT_APP_SECRET=home-app-secret\n");
|
||||||
|
|
||||||
|
const credentials = loadCredentials();
|
||||||
|
|
||||||
|
assert.equal(credentials.appId, "env-app-id");
|
||||||
|
assert.equal(credentials.appSecret, "env-app-secret");
|
||||||
|
assert.equal(credentials.source, "process.env");
|
||||||
|
assert.deepEqual(credentials.skippedSources, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadCredentials reports skipped incomplete sources when no complete pair exists", async (t) => {
|
||||||
|
const cwdRoot = await makeTempDir("wechat-creds-cwd-");
|
||||||
|
const homeRoot = await makeTempDir("wechat-creds-home-");
|
||||||
|
|
||||||
|
useCwd(t, cwdRoot);
|
||||||
|
useHome(t, homeRoot);
|
||||||
|
useWechatEnv(t, {
|
||||||
|
WECHAT_APP_ID: "env-app-id",
|
||||||
|
WECHAT_APP_SECRET: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeEnvFile(cwdRoot, "WECHAT_APP_SECRET=cwd-app-secret\n");
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => loadCredentials(),
|
||||||
|
/Incomplete credential sources skipped:\n- process\.env missing WECHAT_APP_SECRET\n- <cwd>\/\.baoyu-skills\/\.env missing WECHAT_APP_ID/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -196,48 +196,116 @@ function aliasToEnvKey(alias: string): string {
|
|||||||
return alias.toUpperCase().replace(/-/g, "_");
|
return alias.toUpperCase().replace(/-/g, "_");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadCredentials(account?: ResolvedAccount): { appId: string; appSecret: string } {
|
interface CredentialSource {
|
||||||
if (account?.app_id && account?.app_secret) {
|
name: string;
|
||||||
return { appId: account.app_id, appSecret: account.app_secret };
|
appIdKey: string;
|
||||||
|
appSecretKey: string;
|
||||||
|
appId?: string;
|
||||||
|
appSecret?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoadedCredentials {
|
||||||
|
appId: string;
|
||||||
|
appSecret: string;
|
||||||
|
source: string;
|
||||||
|
skippedSources: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCredentialValue(value?: string): string | undefined {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeMissingKeys(source: CredentialSource): string {
|
||||||
|
const missingKeys: string[] = [];
|
||||||
|
if (!source.appId) missingKeys.push(source.appIdKey);
|
||||||
|
if (!source.appSecret) missingKeys.push(source.appSecretKey);
|
||||||
|
return `${source.name} missing ${missingKeys.join(" and ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCredentialSource(
|
||||||
|
name: string,
|
||||||
|
values: Record<string, string | undefined>,
|
||||||
|
appIdKey: string,
|
||||||
|
appSecretKey: string,
|
||||||
|
): CredentialSource {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
appIdKey,
|
||||||
|
appSecretKey,
|
||||||
|
appId: normalizeCredentialValue(values[appIdKey]),
|
||||||
|
appSecret: normalizeCredentialValue(values[appSecretKey]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCredentialSource(
|
||||||
|
sources: CredentialSource[],
|
||||||
|
account?: ResolvedAccount,
|
||||||
|
): LoadedCredentials {
|
||||||
|
const skippedSources: string[] = [];
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
if (source.appId && source.appSecret) {
|
||||||
|
return {
|
||||||
|
appId: source.appId,
|
||||||
|
appSecret: source.appSecret,
|
||||||
|
source: source.name,
|
||||||
|
skippedSources,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.appId || source.appSecret) {
|
||||||
|
skippedSources.push(describeMissingKeys(source));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hint = account?.alias ? ` (account: ${account.alias})` : "";
|
||||||
|
const partialHint = skippedSources.length > 0
|
||||||
|
? `\nIncomplete credential sources skipped:\n- ${skippedSources.join("\n- ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Missing WECHAT_APP_ID or WECHAT_APP_SECRET${hint}.\n` +
|
||||||
|
"Set via EXTEND.md account config, environment variables, or .baoyu-skills/.env file." +
|
||||||
|
partialHint
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCredentials(account?: ResolvedAccount): LoadedCredentials {
|
||||||
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
|
const cwdEnvPath = path.join(process.cwd(), ".baoyu-skills", ".env");
|
||||||
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
|
const homeEnvPath = path.join(os.homedir(), ".baoyu-skills", ".env");
|
||||||
const cwdEnv = loadEnvFile(cwdEnvPath);
|
const cwdEnv = loadEnvFile(cwdEnvPath);
|
||||||
const homeEnv = loadEnvFile(homeEnvPath);
|
const homeEnv = loadEnvFile(homeEnvPath);
|
||||||
|
|
||||||
|
const sources: CredentialSource[] = [];
|
||||||
|
|
||||||
|
if (account?.app_id || account?.app_secret) {
|
||||||
|
sources.push({
|
||||||
|
name: account.alias ? `EXTEND.md account "${account.alias}"` : "EXTEND.md account config",
|
||||||
|
appIdKey: "app_id",
|
||||||
|
appSecretKey: "app_secret",
|
||||||
|
appId: normalizeCredentialValue(account.app_id),
|
||||||
|
appSecret: normalizeCredentialValue(account.app_secret),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const prefix = account?.alias ? `WECHAT_${aliasToEnvKey(account.alias)}_` : "";
|
const prefix = account?.alias ? `WECHAT_${aliasToEnvKey(account.alias)}_` : "";
|
||||||
|
|
||||||
let appId = "";
|
|
||||||
let appSecret = "";
|
|
||||||
|
|
||||||
if (prefix) {
|
if (prefix) {
|
||||||
appId = process.env[`${prefix}APP_ID`]
|
const prefixedKeyLabel = `${prefix}APP_ID/${prefix}APP_SECRET`;
|
||||||
|| cwdEnv[`${prefix}APP_ID`]
|
sources.push(
|
||||||
|| homeEnv[`${prefix}APP_ID`]
|
buildCredentialSource(`process.env (${prefixedKeyLabel})`, process.env, `${prefix}APP_ID`, `${prefix}APP_SECRET`),
|
||||||
|| "";
|
buildCredentialSource(`<cwd>/.baoyu-skills/.env (${prefixedKeyLabel})`, cwdEnv, `${prefix}APP_ID`, `${prefix}APP_SECRET`),
|
||||||
appSecret = process.env[`${prefix}APP_SECRET`]
|
buildCredentialSource(`~/.baoyu-skills/.env (${prefixedKeyLabel})`, homeEnv, `${prefix}APP_ID`, `${prefix}APP_SECRET`),
|
||||||
|| cwdEnv[`${prefix}APP_SECRET`]
|
|
||||||
|| homeEnv[`${prefix}APP_SECRET`]
|
|
||||||
|| "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!appId) {
|
|
||||||
appId = process.env.WECHAT_APP_ID || cwdEnv.WECHAT_APP_ID || homeEnv.WECHAT_APP_ID || "";
|
|
||||||
}
|
|
||||||
if (!appSecret) {
|
|
||||||
appSecret = process.env.WECHAT_APP_SECRET || cwdEnv.WECHAT_APP_SECRET || homeEnv.WECHAT_APP_SECRET || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!appId || !appSecret) {
|
|
||||||
const hint = account?.alias ? ` (account: ${account.alias})` : "";
|
|
||||||
throw new Error(
|
|
||||||
`Missing WECHAT_APP_ID or WECHAT_APP_SECRET${hint}.\n` +
|
|
||||||
"Set via EXTEND.md account config, environment variables, or .baoyu-skills/.env file."
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { appId, appSecret };
|
sources.push(
|
||||||
|
buildCredentialSource("process.env", process.env, "WECHAT_APP_ID", "WECHAT_APP_SECRET"),
|
||||||
|
buildCredentialSource("<cwd>/.baoyu-skills/.env", cwdEnv, "WECHAT_APP_ID", "WECHAT_APP_SECRET"),
|
||||||
|
buildCredentialSource("~/.baoyu-skills/.env", homeEnv, "WECHAT_APP_ID", "WECHAT_APP_SECRET"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return resolveCredentialSource(sources, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAccounts(config: WechatExtendConfig): string[] {
|
export function listAccounts(config: WechatExtendConfig): string[] {
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { Jimp, JimpMime } from "jimp";
|
||||||
|
import decodeWebp, { init as initWebpDecode } from "@jsquash/webp/decode.js";
|
||||||
|
|
||||||
|
export interface WechatUploadAsset {
|
||||||
|
buffer: Buffer;
|
||||||
|
filename: string;
|
||||||
|
contentType: string;
|
||||||
|
fileExt: string;
|
||||||
|
fileSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedWechatUploadAsset {
|
||||||
|
buffer: Buffer;
|
||||||
|
filename: string;
|
||||||
|
contentType: string;
|
||||||
|
wasProcessed: boolean;
|
||||||
|
processingNotes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WECHAT_BODY_IMAGE_MAX_SIZE = 1024 * 1024; // 1MB
|
||||||
|
export const WECHAT_BODY_IMAGE_UNSUPPORTED_FORMATS = new Set([
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
".bmp",
|
||||||
|
".tiff",
|
||||||
|
".tif",
|
||||||
|
".svg",
|
||||||
|
".ico",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BODY_UPLOAD_ALLOWED_MIME_TYPES = new Set([
|
||||||
|
JimpMime.jpeg,
|
||||||
|
JimpMime.png,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MIME_TO_EXT: Record<string, string> = {
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/bmp": ".bmp",
|
||||||
|
"image/x-ms-bmp": ".bmp",
|
||||||
|
"image/tiff": ".tiff",
|
||||||
|
"image/svg+xml": ".svg",
|
||||||
|
"image/x-icon": ".ico",
|
||||||
|
"image/vnd.microsoft.icon": ".ico",
|
||||||
|
};
|
||||||
|
|
||||||
|
const JPEG_QUALITY_STEPS = [82, 74, 66, 58, 50, 42, 34];
|
||||||
|
const MAX_WIDTH_STEPS = [2560, 2048, 1600, 1280, 1024, 800, 640, 480];
|
||||||
|
|
||||||
|
let webpDecoderReady: Promise<void> | undefined;
|
||||||
|
|
||||||
|
type JimpImage = Awaited<ReturnType<typeof Jimp.read>>;
|
||||||
|
|
||||||
|
function normalizeMimeType(contentType: string): string {
|
||||||
|
return contentType.split(";")[0]!.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFromMimeType(contentType: string): string {
|
||||||
|
return MIME_TO_EXT[normalizeMimeType(contentType)] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureFileExt(asset: WechatUploadAsset): string {
|
||||||
|
return asset.fileExt || extFromMimeType(asset.contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function basenameWithoutExt(filename: string): string {
|
||||||
|
const base = path.basename(filename, path.extname(filename));
|
||||||
|
return base || "image";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameWithExt(filename: string, ext: string): string {
|
||||||
|
return `${basenameWithoutExt(filename)}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsWechatBodyImageProcessing(asset: WechatUploadAsset): boolean {
|
||||||
|
if (asset.fileSize > WECHAT_BODY_IMAGE_MAX_SIZE) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedMimeType = normalizeMimeType(asset.contentType);
|
||||||
|
if (BODY_UPLOAD_ALLOWED_MIME_TYPES.has(normalizedMimeType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExt = ensureFileExt(asset);
|
||||||
|
return WECHAT_BODY_IMAGE_UNSUPPORTED_FORMATS.has(fileExt) || !fileExt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWebpDecoder(): Promise<void> {
|
||||||
|
if (!webpDecoderReady) {
|
||||||
|
webpDecoderReady = (async () => {
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const wasmPath = path.resolve(__dirname, "node_modules/@jsquash/webp/codec/dec/webp_dec.wasm");
|
||||||
|
const wasmModule = await WebAssembly.compile(await fs.readFile(wasmPath));
|
||||||
|
await initWebpDecode(wasmModule, {});
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
await webpDecoderReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImageForProcessing(asset: WechatUploadAsset): Promise<JimpImage> {
|
||||||
|
const fileExt = ensureFileExt(asset);
|
||||||
|
const normalizedMimeType = normalizeMimeType(asset.contentType);
|
||||||
|
|
||||||
|
if (fileExt === ".webp" || normalizedMimeType === "image/webp") {
|
||||||
|
await ensureWebpDecoder();
|
||||||
|
const decoded = await decodeWebp(asset.buffer);
|
||||||
|
return new Jimp({
|
||||||
|
data: Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength),
|
||||||
|
width: decoded.width,
|
||||||
|
height: decoded.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileExt === ".svg" || fileExt === ".ico") {
|
||||||
|
throw new Error(`Cannot convert ${fileExt} image for WeChat body upload; provide a PNG or JPG instead.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Jimp.read(asset.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageHasTransparency(image: JimpImage): boolean {
|
||||||
|
const { data } = image.bitmap;
|
||||||
|
for (let i = 3; i < data.length; i += 4) {
|
||||||
|
if (data[i] !== 255) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCandidateWidths(width: number): number[] {
|
||||||
|
const candidates = new Set<number>([width]);
|
||||||
|
|
||||||
|
for (const maxWidth of MAX_WIDTH_STEPS) {
|
||||||
|
if (width > maxWidth) {
|
||||||
|
candidates.add(maxWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...candidates].sort((a, b) => b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeToWidth(image: JimpImage, width: number): JimpImage {
|
||||||
|
const cloned = image.clone();
|
||||||
|
if (width < image.bitmap.width) {
|
||||||
|
cloned.resize({ w: width });
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenOnWhite(image: JimpImage): JimpImage {
|
||||||
|
const flattened = new Jimp({
|
||||||
|
width: image.bitmap.width,
|
||||||
|
height: image.bitmap.height,
|
||||||
|
color: 0xffffffff,
|
||||||
|
});
|
||||||
|
flattened.composite(image, 0, 0);
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encodePng(image: JimpImage): Promise<Buffer> {
|
||||||
|
return image.getBuffer(JimpMime.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encodeJpeg(image: JimpImage, quality: number): Promise<Buffer> {
|
||||||
|
const jpegSource = imageHasTransparency(image) ? flattenOnWhite(image) : image;
|
||||||
|
return jpegSource.getBuffer(JimpMime.jpeg, { quality });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProcessingNotes(asset: WechatUploadAsset): string[] {
|
||||||
|
const notes: string[] = [];
|
||||||
|
const fileExt = ensureFileExt(asset);
|
||||||
|
|
||||||
|
if (fileExt && WECHAT_BODY_IMAGE_UNSUPPORTED_FORMATS.has(fileExt)) {
|
||||||
|
notes.push(`converted unsupported ${fileExt} source`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.fileSize > WECHAT_BODY_IMAGE_MAX_SIZE) {
|
||||||
|
notes.push(`compressed ${(asset.fileSize / 1024 / 1024).toFixed(2)}MB source below 1MB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notes.length === 0) {
|
||||||
|
notes.push("re-encoded for WeChat body upload");
|
||||||
|
}
|
||||||
|
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareWechatBodyImageUpload(
|
||||||
|
asset: WechatUploadAsset,
|
||||||
|
): Promise<PreparedWechatUploadAsset> {
|
||||||
|
if (!needsWechatBodyImageProcessing(asset)) {
|
||||||
|
return {
|
||||||
|
buffer: asset.buffer,
|
||||||
|
filename: asset.filename,
|
||||||
|
contentType: asset.contentType,
|
||||||
|
wasProcessed: false,
|
||||||
|
processingNotes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await loadImageForProcessing(asset);
|
||||||
|
const widths = buildCandidateWidths(image.bitmap.width);
|
||||||
|
const preferPng = imageHasTransparency(image) || ensureFileExt(asset) === ".png";
|
||||||
|
const processingNotes = buildProcessingNotes(asset);
|
||||||
|
|
||||||
|
for (const width of widths) {
|
||||||
|
const resized = resizeToWidth(image, width);
|
||||||
|
|
||||||
|
if (preferPng) {
|
||||||
|
const pngBuffer = await encodePng(resized);
|
||||||
|
if (pngBuffer.length <= WECHAT_BODY_IMAGE_MAX_SIZE) {
|
||||||
|
return {
|
||||||
|
buffer: pngBuffer,
|
||||||
|
filename: renameWithExt(asset.filename, ".png"),
|
||||||
|
contentType: JimpMime.png,
|
||||||
|
wasProcessed: true,
|
||||||
|
processingNotes: width < image.bitmap.width
|
||||||
|
? [...processingNotes, `resized to ${width}px wide`]
|
||||||
|
: processingNotes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const quality of JPEG_QUALITY_STEPS) {
|
||||||
|
const jpegBuffer = await encodeJpeg(resized, quality);
|
||||||
|
if (jpegBuffer.length <= WECHAT_BODY_IMAGE_MAX_SIZE) {
|
||||||
|
const notes = [...processingNotes, `encoded as JPEG (${quality} quality)`];
|
||||||
|
if (width < image.bitmap.width) {
|
||||||
|
notes.push(`resized to ${width}px wide`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
buffer: jpegBuffer,
|
||||||
|
filename: renameWithExt(asset.filename, ".jpg"),
|
||||||
|
contentType: JimpMime.jpeg,
|
||||||
|
wasProcessed: true,
|
||||||
|
processingNotes: notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unable to reduce ${asset.filename} below 1MB for WeChat body upload.`);
|
||||||
|
}
|
||||||
@@ -123,6 +123,8 @@ ${BUN_X} {baseDir}/scripts/weibo-article.ts article.md --cover ./cover.jpg
|
|||||||
- Title: 32 characters max (truncated with warning if longer)
|
- Title: 32 characters max (truncated with warning if longer)
|
||||||
- Summary/导语: 44 characters max (auto-regenerated from content if longer)
|
- Summary/导语: 44 characters max (auto-regenerated from content if longer)
|
||||||
|
|
||||||
|
**Markdown-to-HTML**: Do NOT pass any `--theme` parameter when converting markdown to HTML. Use the default theme (no theme argument).
|
||||||
|
|
||||||
**Article Workflow**:
|
**Article Workflow**:
|
||||||
1. Opens `https://card.weibo.com/article/v3/editor`
|
1. Opens `https://card.weibo.com/article/v3/editor`
|
||||||
2. Clicks "写文章" button, waits for editor to become editable
|
2. Clicks "写文章" button, waits for editor to become editable
|
||||||
|
|||||||
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
|
|||||||
import {
|
import {
|
||||||
buildMarkdownDocumentMeta,
|
buildMarkdownDocumentMeta,
|
||||||
formatTimestamp,
|
formatTimestamp,
|
||||||
|
renderMarkdownDocument,
|
||||||
resolveColorToken,
|
resolveColorToken,
|
||||||
resolveFontFamilyToken,
|
resolveFontFamilyToken,
|
||||||
resolveMarkdownStyle,
|
resolveMarkdownStyle,
|
||||||
resolveRenderOptions,
|
resolveRenderOptions,
|
||||||
} from "./document.ts";
|
} from "./document.ts";
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInlineStyle(html: string, tagName: string, text: string): string {
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
|
||||||
|
);
|
||||||
|
const match = html.match(pattern);
|
||||||
|
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
|
||||||
|
return match![1]!;
|
||||||
|
}
|
||||||
|
|
||||||
function useCwd(t: TestContext, cwd: string): void {
|
function useCwd(t: TestContext, cwd: string): void {
|
||||||
const previous = process.cwd();
|
const previous = process.cwd();
|
||||||
process.chdir(cwd);
|
process.chdir(cwd);
|
||||||
@@ -138,3 +152,23 @@ keep_title: true
|
|||||||
assert.equal(explicit.fontSize, "18px");
|
assert.equal(explicit.fontSize, "18px");
|
||||||
assert.equal(explicit.keepTitle, false);
|
assert.equal(explicit.keepTitle, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
|
||||||
|
const { html } = await renderMarkdownDocument(
|
||||||
|
`## Section\n\nParagraph with **bold** text.`,
|
||||||
|
{ keepTitle: true, theme: "grace" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const h2Style = findInlineStyle(html, "h2", "Section");
|
||||||
|
assert.match(h2Style, /background: #92617E/);
|
||||||
|
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
|
||||||
|
|
||||||
|
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
|
||||||
|
assert.ok(pMatch, "Expected inline style on <p> tag");
|
||||||
|
assert.match(pMatch![1]!, /color:/);
|
||||||
|
|
||||||
|
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
|
||||||
|
const strongMatch = html.match(strongPattern);
|
||||||
|
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
|
||||||
|
assert.match(strongMatch![1]!, /font-weight:/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ test("normalizeCssText and normalizeInlineCss replace variables and strip declar
|
|||||||
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
|
||||||
|
const normalizedHtml = normalizeInlineCss(
|
||||||
|
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
|
||||||
|
DEFAULT_STYLE,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
|
||||||
|
assert.doesNotMatch(normalizedHtml, /Courier New/);
|
||||||
|
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
|
||||||
|
});
|
||||||
|
|
||||||
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
test("HTML structure helpers hoist nested lists and remove the first heading", () => {
|
||||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
|
|||||||
.replace(/var\(--md-accent-color\)/g, style.accentColor)
|
.replace(/var\(--md-accent-color\)/g, style.accentColor)
|
||||||
.replace(/var\(--md-container-bg\)/g, style.containerBg)
|
.replace(/var\(--md-container-bg\)/g, style.containerBg)
|
||||||
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
||||||
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
.replace(/--md-font-family:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
|
.replace(/--md-font-size:\s*[^;]+;?/g, "")
|
||||||
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
|
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
|
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
|
||||||
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
|
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
|
||||||
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
.replace(/--foreground:\s*[^;]+;?/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
|
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
|
|||||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
||||||
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
||||||
|
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
|
||||||
|
|
||||||
function stripOutputScope(cssContent: string): string {
|
function stripOutputScope(cssContent: string): string {
|
||||||
let css = cssContent;
|
let css = cssContent;
|
||||||
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
|
|||||||
themeCss: string;
|
themeCss: string;
|
||||||
} {
|
} {
|
||||||
const basePath = path.join(THEME_DIR, "base.css");
|
const basePath = path.join(THEME_DIR, "base.css");
|
||||||
|
const defaultThemePath = path.join(THEME_DIR, "default.css");
|
||||||
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
||||||
|
|
||||||
if (!fs.existsSync(basePath)) {
|
if (!fs.existsSync(basePath)) {
|
||||||
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
|
|||||||
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
|
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const layeredThemeCss: string[] = [];
|
||||||
|
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
|
||||||
|
if (!fs.existsSync(defaultThemePath)) {
|
||||||
|
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
|
||||||
|
}
|
||||||
|
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
|
||||||
|
}
|
||||||
|
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseCss: fs.readFileSync(basePath, "utf-8"),
|
baseCss: fs.readFileSync(basePath, "utf-8"),
|
||||||
themeCss: fs.readFileSync(themePath, "utf-8"),
|
themeCss: layeredThemeCss.join("\n"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: baoyu-url-to-markdown
|
name: baoyu-url-to-markdown
|
||||||
description: Fetch any URL and convert to markdown using Chrome CDP. Saves the rendered HTML snapshot alongside the markdown, uses an upgraded Defuddle pipeline with better web-component handling and YouTube transcript extraction, and automatically falls back to the pre-Defuddle HTML-to-Markdown pipeline when needed. If local browser capture fails entirely, it can fall back to the hosted defuddle.md API. Supports two modes - auto-capture on page load, or wait for user signal (for pages requiring login). Use when user wants to save a webpage as markdown.
|
description: Fetch any URL and convert to markdown using Chrome CDP. Saves the rendered HTML snapshot alongside the markdown, uses an upgraded Defuddle pipeline with better web-component handling and YouTube transcript extraction, and automatically falls back to the pre-Defuddle HTML-to-Markdown pipeline when needed. If local browser capture fails entirely, it can fall back to the hosted defuddle.md API. Supports two modes - auto-capture on page load, or wait for user signal (for pages requiring login). Use when user wants to save a webpage as markdown.
|
||||||
version: 1.58.1
|
version: 1.59.0
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-url-to-markdown
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-url-to-markdown
|
||||||
@@ -30,6 +30,9 @@ Fetches any URL via Chrome CDP, saves the rendered HTML snapshot, and converts i
|
|||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `scripts/main.ts` | CLI entry point for URL fetching |
|
| `scripts/main.ts` | CLI entry point for URL fetching |
|
||||||
| `scripts/html-to-markdown.ts` | Markdown conversion entry point and converter selection |
|
| `scripts/html-to-markdown.ts` | Markdown conversion entry point and converter selection |
|
||||||
|
| `scripts/parsers/index.ts` | Unified parser entry: dispatches URL-specific rules before generic converters |
|
||||||
|
| `scripts/parsers/types.ts` | Unified parser interface shared by all rule files |
|
||||||
|
| `scripts/parsers/rules/*.ts` | One file per URL rule, for example X status and X article |
|
||||||
| `scripts/defuddle-converter.ts` | Defuddle-based conversion |
|
| `scripts/defuddle-converter.ts` | Defuddle-based conversion |
|
||||||
| `scripts/legacy-converter.ts` | Pre-Defuddle legacy extraction and markdown conversion |
|
| `scripts/legacy-converter.ts` | Pre-Defuddle legacy extraction and markdown conversion |
|
||||||
| `scripts/markdown-conversion-shared.ts` | Shared metadata parsing and markdown document helpers |
|
| `scripts/markdown-conversion-shared.ts` | Shared metadata parsing and markdown document helpers |
|
||||||
@@ -115,10 +118,14 @@ 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
|
||||||
- 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
|
||||||
- Clean markdown output with metadata
|
- Clean markdown output with metadata
|
||||||
- Upgraded Defuddle-first markdown conversion with automatic fallback to the pre-Defuddle extractor from git history
|
- Upgraded Defuddle-first markdown conversion with automatic fallback to the pre-Defuddle extractor from git history
|
||||||
|
- X/Twitter pages can use HTML-specific parsing for Tweets and Articles, which improves title/body/media extraction on `x.com` / `twitter.com`
|
||||||
|
- `archive.ph` / related archive mirrors can restore the original URL from `input[name=q]` and prefer `#CONTENT` before falling back to the page body
|
||||||
- Materializes shadow DOM content before conversion so web-component pages survive serialization better
|
- Materializes shadow DOM content before conversion so web-component pages survive serialization better
|
||||||
- YouTube pages can include transcript/caption text in the markdown when YouTube exposes a caption track
|
- YouTube pages can include transcript/caption text in the markdown when YouTube exposes a caption track
|
||||||
- If local browser capture fails completely, can fall back to `defuddle.md/<url>` and still save markdown
|
- If local browser capture fails completely, can fall back to `defuddle.md/<url>` and still save markdown
|
||||||
@@ -131,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
|
||||||
|
|
||||||
@@ -152,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 |
|
||||||
|
|
||||||
@@ -159,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**:
|
||||||
@@ -167,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:
|
||||||
@@ -201,14 +254,17 @@ When `--download-media` is enabled:
|
|||||||
|
|
||||||
Conversion order:
|
Conversion order:
|
||||||
|
|
||||||
1. Try Defuddle first
|
1. Try the URL-specific parser layer first when a site rule matches
|
||||||
2. For rich pages such as YouTube, prefer Defuddle's extractor-specific output (including transcripts when available) instead of replacing it with the legacy pipeline
|
2. If no specialized parser matches, try Defuddle
|
||||||
3. 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
|
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 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
|
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. The legacy fallback path uses the older Readability/selector/Next.js-data based HTML-to-Markdown implementation recovered from git history
|
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. 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:
|
||||||
|
|
||||||
|
- `Converter: parser:...` when a URL-specific parser succeeded
|
||||||
- `Converter: defuddle` when Defuddle succeeds
|
- `Converter: defuddle` when Defuddle succeeds
|
||||||
- `Converter: legacy:...` plus `Fallback used: ...` when fallback was needed
|
- `Converter: legacy:...` plus `Fallback used: ...` when fallback was needed
|
||||||
- `Converter: defuddle-api` when local browser capture failed and the hosted API was used instead
|
- `Converter: defuddle-api` when local browser capture failed and the hosted API was used instead
|
||||||
|
|||||||
@@ -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\)/);
|
||||||
|
});
|
||||||
@@ -12,10 +12,16 @@ import {
|
|||||||
scoreMarkdownQuality,
|
scoreMarkdownQuality,
|
||||||
shouldCompareWithLegacy,
|
shouldCompareWithLegacy,
|
||||||
} from "./legacy-converter.js";
|
} from "./legacy-converter.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;
|
||||||
@@ -84,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,
|
||||||
|
};
|
||||||
})()
|
})()
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -101,18 +110,36 @@ 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);
|
||||||
|
|
||||||
const defuddleResult = await tryDefuddleConversion(html, url, baseMetadata);
|
const specializedResult = tryUrlRuleParsers(html, url, baseMetadata);
|
||||||
|
if (specializedResult) {
|
||||||
|
return specializedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
@@ -124,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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -521,14 +525,18 @@ turndown.addRule("collapseFigure", {
|
|||||||
|
|
||||||
turndown.addRule("dropInvisibleAnchors", {
|
turndown.addRule("dropInvisibleAnchors", {
|
||||||
filter(node) {
|
filter(node) {
|
||||||
return node.nodeName === "A" && !(node as Element).textContent?.trim();
|
return (
|
||||||
|
node.nodeName === "A" &&
|
||||||
|
!(node as Element).textContent?.trim() &&
|
||||||
|
!(node as Element).querySelector("img, video, picture, source")
|
||||||
|
);
|
||||||
},
|
},
|
||||||
replacement() {
|
replacement() {
|
||||||
return "";
|
return "";
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function convertHtmlToMarkdown(html: string): string {
|
export function convertHtmlFragmentToMarkdown(html: string): string {
|
||||||
if (!html || !html.trim()) return "";
|
if (!html || !html.trim()) return "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -606,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 ? convertHtmlToMarkdown(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;
|
||||||
}
|
}
|
||||||
@@ -52,15 +81,71 @@ function parseArgs(argv: string[]): Args {
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSlug(title: string, url: string): string {
|
const SLUG_STOP_WORDS = new Set([
|
||||||
const text = title || new URL(url).pathname.replace(/\//g, "-");
|
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
|
||||||
return text
|
"have", "has", "had", "do", "does", "did", "will", "would", "shall",
|
||||||
.toLowerCase()
|
"should", "may", "might", "must", "can", "could", "to", "of", "in",
|
||||||
|
"for", "on", "with", "at", "by", "from", "as", "into", "through",
|
||||||
|
"during", "before", "after", "above", "below", "between", "out",
|
||||||
|
"off", "over", "under", "again", "further", "then", "once", "here",
|
||||||
|
"there", "when", "where", "why", "how", "all", "both", "each",
|
||||||
|
"few", "more", "most", "other", "some", "such", "no", "nor", "not",
|
||||||
|
"only", "own", "same", "so", "than", "too", "very", "just", "but",
|
||||||
|
"and", "or", "if", "this", "that", "these", "those", "it", "its",
|
||||||
|
"http", "https", "www", "com", "org", "net", "post", "article",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function extractSlugFromContent(content: string): string | null {
|
||||||
|
const body = content.replace(/^---\n[\s\S]*?\n---\n?/, "").slice(0, 1000);
|
||||||
|
const words = body
|
||||||
.replace(/[^\w\s-]/g, " ")
|
.replace(/[^\w\s-]/g, " ")
|
||||||
.replace(/\s+/g, "-")
|
.split(/\s+/)
|
||||||
|
.filter((w) => /^[a-zA-Z]/.test(w) && w.length >= 2 && !SLUG_STOP_WORDS.has(w.toLowerCase()))
|
||||||
|
.map((w) => w.toLowerCase());
|
||||||
|
|
||||||
|
const unique: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const w of words) {
|
||||||
|
if (!seen.has(w)) {
|
||||||
|
seen.add(w);
|
||||||
|
unique.push(w);
|
||||||
|
if (unique.length >= 6) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unique.length >= 2 ? unique.join("-").slice(0, 50) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSlug(title: string, url: string, content?: string): string {
|
||||||
|
const asciiWords = title
|
||||||
|
.replace(/[^\w\s]/g, " ")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((w) => /[a-zA-Z]/.test(w) && w.length >= 2 && !SLUG_STOP_WORDS.has(w.toLowerCase()))
|
||||||
|
.map((w) => w.toLowerCase());
|
||||||
|
|
||||||
|
if (asciiWords.length >= 2) {
|
||||||
|
return asciiWords.slice(0, 6).join("-").slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
const contentSlug = extractSlugFromContent(content);
|
||||||
|
if (contentSlug) return contentSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENERIC_PATH_SEGMENTS = new Set(["status", "article", "post", "posts", "p", "blog", "news", "articles"]);
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const pathSlug = parsed.pathname
|
||||||
|
.split("/")
|
||||||
|
.filter((s) => s.length > 0 && !/^\d{10,}$/.test(s) && !GENERIC_PATH_SEGMENTS.has(s.toLowerCase()))
|
||||||
|
.join("-")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w-]/g, "-")
|
||||||
.replace(/-+/g, "-")
|
.replace(/-+/g, "-")
|
||||||
.replace(/^-|-$/g, "")
|
.replace(/^-|-$/g, "")
|
||||||
.slice(0, 50) || "page";
|
.slice(0, 40);
|
||||||
|
|
||||||
|
const prefix = asciiWords.slice(0, 2).join("-");
|
||||||
|
const combined = prefix ? `${prefix}-${pathSlug}` : pathSlug;
|
||||||
|
return combined.slice(0, 50) || "page";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(): string {
|
function formatTimestamp(): string {
|
||||||
@@ -124,35 +209,42 @@ async function fetchDefuddleApiMarkdown(targetUrl: string): Promise<{ markdown:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateOutputPath(url: string, title: string, outputDir?: string): Promise<string> {
|
async function generateOutputPath(url: string, title: string, outputDir?: string, content?: string): Promise<string> {
|
||||||
const domain = new URL(url).hostname.replace(/^www\./, "");
|
const domain = new URL(url).hostname.replace(/^www\./, "");
|
||||||
const slug = generateSlug(title, url);
|
const slug = generateSlug(title, url, content);
|
||||||
const dataDir = outputDir ? path.resolve(outputDir) : resolveUrlToMarkdownDataDir();
|
const dataDir = outputDir ? path.resolve(outputDir) : resolveUrlToMarkdownDataDir();
|
||||||
const basePath = path.join(dataDir, domain, `${slug}.md`);
|
const basePath = path.join(dataDir, domain, slug, `${slug}.md`);
|
||||||
|
|
||||||
if (!(await fileExists(basePath))) {
|
if (!(await fileExists(basePath))) {
|
||||||
return basePath;
|
return basePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestampSlug = `${slug}-${formatTimestamp()}`;
|
const timestampSlug = `${slug}-${formatTimestamp()}`;
|
||||||
return path.join(dataDir, domain, `${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;
|
||||||
@@ -179,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([
|
||||||
@@ -195,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) {
|
||||||
@@ -216,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,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()) {
|
||||||
@@ -240,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;
|
||||||
@@ -249,13 +410,12 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await captureUrl(args);
|
const result = await captureUrl(args);
|
||||||
outputPath = args.output || await generateOutputPath(args.url, result.metadata.title, args.outputDir);
|
document = createMarkdownDocument(result);
|
||||||
|
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 });
|
||||||
await writeFile(htmlSnapshotPath, result.rawHtml, "utf-8");
|
await writeFile(htmlSnapshotPath, result.rawHtml, "utf-8");
|
||||||
|
|
||||||
document = createMarkdownDocument(result);
|
|
||||||
conversionMethod = result.conversionMethod;
|
conversionMethod = result.conversionMethod;
|
||||||
fallbackReason = result.fallbackReason;
|
fallbackReason = result.fallbackReason;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -265,10 +425,9 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const remoteResult = await fetchDefuddleApiMarkdown(args.url);
|
const remoteResult = await fetchDefuddleApiMarkdown(args.url);
|
||||||
outputPath = args.output || await generateOutputPath(args.url, remoteResult.title, args.outputDir);
|
|
||||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
||||||
|
|
||||||
document = remoteResult.markdown;
|
document = remoteResult.markdown;
|
||||||
|
outputPath = args.output || await generateOutputPath(args.url, remoteResult.title, args.outputDir, document);
|
||||||
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
conversionMethod = "defuddle-api";
|
conversionMethod = "defuddle-api";
|
||||||
fallbackReason = `Local browser capture failed: ${primaryError}`;
|
fallbackReason = `Local browser capture failed: ${primaryError}`;
|
||||||
} catch (remoteError) {
|
} catch (remoteError) {
|
||||||
|
|||||||
@@ -300,6 +300,24 @@ export function createMarkdownDocument(result: ConversionResult): string {
|
|||||||
const escapedTitle = result.metadata.title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escapedTitle = result.metadata.title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
const titleRegex = new RegExp(`^#\\s+${escapedTitle}\\s*(\\n|$)`, "i");
|
const titleRegex = new RegExp(`^#\\s+${escapedTitle}\\s*(\\n|$)`, "i");
|
||||||
const hasTitle = titleRegex.test(result.markdown.trimStart());
|
const hasTitle = titleRegex.test(result.markdown.trimStart());
|
||||||
const title = result.metadata.title && !hasTitle ? `\n\n# ${result.metadata.title}\n\n` : "\n\n";
|
const firstMeaningfulLine = result.markdown
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line && !/^!?\[[^\]]*\]\([^)]+\)$/.test(line))
|
||||||
|
?.replace(/^>\s*/, "")
|
||||||
|
?.replace(/^#+\s+/, "")
|
||||||
|
?.trim();
|
||||||
|
const comparableTitle = result.metadata.title.toLowerCase().replace(/(?:\.{3}|…)\s*$/, "");
|
||||||
|
const comparableFirstLine = firstMeaningfulLine?.toLowerCase() ?? "";
|
||||||
|
const titleRepeatsContent =
|
||||||
|
comparableTitle !== "" &&
|
||||||
|
comparableFirstLine !== "" &&
|
||||||
|
(comparableFirstLine === comparableTitle ||
|
||||||
|
comparableFirstLine.startsWith(comparableTitle) ||
|
||||||
|
comparableTitle.startsWith(comparableFirstLine));
|
||||||
|
const title = result.metadata.title && !hasTitle && !titleRepeatsContent
|
||||||
|
? `\n\n# ${result.metadata.title}\n\n`
|
||||||
|
: "\n\n";
|
||||||
return yaml + title + result.markdown;
|
return yaml + title + result.markdown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +280,24 @@ export async function localizeMarkdownMedia(
|
|||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
|
let sourceUrl = candidate.url;
|
||||||
|
let contentType = "";
|
||||||
|
let extension: string | undefined;
|
||||||
|
let kind: MediaKind | undefined;
|
||||||
|
let bytes: Buffer | null = null;
|
||||||
|
|
||||||
|
if (candidate.source === "data") {
|
||||||
|
const parsed = parseBase64DataUri(candidate.url);
|
||||||
|
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, {
|
const response = await fetch(candidate.url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
@@ -257,11 +311,14 @@ export async function localizeMarkdownMedia(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceUrl = response.url || candidate.url;
|
sourceUrl = response.url || candidate.url;
|
||||||
const contentType = normalizeContentType(response.headers.get("content-type"));
|
contentType = normalizeContentType(response.headers.get("content-type"));
|
||||||
const extension = resolveExtensionFromUrl(sourceUrl) ?? resolveExtensionFromUrl(candidate.url);
|
extension = resolveExtensionFromUrl(sourceUrl) ?? resolveExtensionFromUrl(candidate.url);
|
||||||
const kind = resolveMediaKind(sourceUrl, contentType, extension, candidate.hint);
|
kind = resolveMediaKind(sourceUrl, contentType, extension, candidate.hint);
|
||||||
if (!kind) {
|
bytes = Buffer.from(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!kind || !bytes) {
|
||||||
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",
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createMarkdownDocument,
|
||||||
|
extractMetadataFromHtml,
|
||||||
|
} from "../markdown-conversion-shared.js";
|
||||||
|
import { tryUrlRuleParsers } from "./index.js";
|
||||||
|
|
||||||
|
const CAPTURED_AT = "2026-03-22T06:00:00.000Z";
|
||||||
|
|
||||||
|
const ARTICLE_HTML = `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<body>
|
||||||
|
<div data-testid="twitterArticleReadView">
|
||||||
|
<a href="/dotey/article/2035141635713941927/media/1">
|
||||||
|
<div data-testid="tweetPhoto">
|
||||||
|
<img src="https://pbs.twimg.com/media/article-cover.jpg" alt="Image">
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div data-testid="twitter-article-title">Karpathy:"写代码"已经不是对的动词了</div>
|
||||||
|
<div data-testid="User-Name">
|
||||||
|
<a href="/dotey">宝玉 Verified account</a>
|
||||||
|
<a href="/dotey">@dotey</a>
|
||||||
|
<time datetime="2026-03-20T23:49:11.000Z">Mar 20</time>
|
||||||
|
</div>
|
||||||
|
<div data-testid="twitterArticleRichTextView">
|
||||||
|
<p>Andrej Karpathy 说他从 2024 年 12 月起就基本没手写过一行代码。</p>
|
||||||
|
<a href="/dotey/article/2035141635713941927/media/2">
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div data-testid="tweetPhoto">
|
||||||
|
<img src="https://pbs.twimg.com/media/article-inline.jpg" alt="Image">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<h2>要点速览</h2>
|
||||||
|
<ul>
|
||||||
|
<li>核心焦虑从 GPU 利用率转向 Token 吞吐量</li>
|
||||||
|
</ul>
|
||||||
|
<blockquote>
|
||||||
|
<p>写代码已经不是对的动词了。</p>
|
||||||
|
</blockquote>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const STATUS_HTML = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<article data-testid="tweet">
|
||||||
|
<div data-testid="User-Name">
|
||||||
|
<a href="/dotey">宝玉 Verified account</a>
|
||||||
|
<a href="/dotey">@dotey</a>
|
||||||
|
<time datetime="2026-03-22T05:33:00.000Z">Mar 22</time>
|
||||||
|
</div>
|
||||||
|
<div data-testid="tweetText">
|
||||||
|
<span>转译:把下面这段加到你的 Codex 自定义指令里,体验会好太多:</span>
|
||||||
|
</div>
|
||||||
|
<div data-testid="tweetPhoto">
|
||||||
|
<img src="https://pbs.twimg.com/media/tweet-main.jpg" alt="Image">
|
||||||
|
</div>
|
||||||
|
<div data-testid="User-Name">
|
||||||
|
<a href="/mattshumer_">Matt Shumer Verified account</a>
|
||||||
|
<a href="/mattshumer_">@mattshumer_</a>
|
||||||
|
<time datetime="2026-03-17T00:00:00.000Z">Mar 17</time>
|
||||||
|
</div>
|
||||||
|
<div data-testid="tweetText">
|
||||||
|
<span>Add this to your Codex custom instructions for a way better experience.</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const ARCHIVE_HTML = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>archive.ph</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
value="https://www.newscientist.com/article/2520204-major-leap-towards-reanimation-after-death-as-mammals-brain-preserved/"
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
<div id="HEADER">
|
||||||
|
Archive shell text that should be ignored when CONTENT exists.
|
||||||
|
</div>
|
||||||
|
<div id="CONTENT">
|
||||||
|
<h1>Major leap towards reanimation after death as mammal brain preserved</h1>
|
||||||
|
<p>
|
||||||
|
Researchers say the preserved structure and activity markers suggest a significant step
|
||||||
|
forward in keeping delicate brain tissue viable after clinical death.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The archive wrapper should not take precedence over the actual article body when the
|
||||||
|
CONTENT container is available for parsing.
|
||||||
|
</p>
|
||||||
|
<img src="https://cdn.example.com/brain.jpg" alt="Brain tissue">
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const ARCHIVE_FALLBACK_HTML = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>archive.ph</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<input type="text" name="q" value="https://example.com/fallback-story">
|
||||||
|
<main>
|
||||||
|
<h1>Fallback body parsing still works</h1>
|
||||||
|
<p>
|
||||||
|
When CONTENT is absent, the parser should fall back to the body content instead of
|
||||||
|
returning null or keeping the archive wrapper as the final URL.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This ensures archived pages with slightly different layouts still produce usable markdown.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
function parse(html: string, url: string) {
|
||||||
|
const baseMetadata = extractMetadataFromHtml(html, url, CAPTURED_AT);
|
||||||
|
return tryUrlRuleParsers(html, url, baseMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("parses archive.ph pages from CONTENT and restores the original URL", () => {
|
||||||
|
const result = parse(ARCHIVE_HTML, "https://archive.ph/SMcX5");
|
||||||
|
|
||||||
|
assert.ok(result);
|
||||||
|
assert.equal(result.conversionMethod, "parser:archive-ph");
|
||||||
|
assert.equal(
|
||||||
|
result.metadata.url,
|
||||||
|
"https://www.newscientist.com/article/2520204-major-leap-towards-reanimation-after-death-as-mammals-brain-preserved/"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
result.metadata.title,
|
||||||
|
"Major leap towards reanimation after death as mammal brain preserved"
|
||||||
|
);
|
||||||
|
assert.equal(result.metadata.coverImage, "https://cdn.example.com/brain.jpg");
|
||||||
|
assert.ok(result.markdown.includes("Researchers say the preserved structure"));
|
||||||
|
assert.ok(result.markdown.includes(""));
|
||||||
|
assert.ok(!result.markdown.includes("Archive shell text that should be ignored"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to body when archive.ph CONTENT is missing", () => {
|
||||||
|
const result = parse(ARCHIVE_FALLBACK_HTML, "https://archive.ph/fallback");
|
||||||
|
|
||||||
|
assert.ok(result);
|
||||||
|
assert.equal(result.conversionMethod, "parser:archive-ph");
|
||||||
|
assert.equal(result.metadata.url, "https://example.com/fallback-story");
|
||||||
|
assert.equal(result.metadata.title, "Fallback body parsing still works");
|
||||||
|
assert.ok(result.markdown.includes("When CONTENT is absent"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses X article pages from HTML", () => {
|
||||||
|
const result = parse(
|
||||||
|
ARTICLE_HTML,
|
||||||
|
"https://x.com/dotey/article/2035141635713941927"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result);
|
||||||
|
assert.equal(result.conversionMethod, "parser:x-article");
|
||||||
|
assert.equal(result.metadata.title, "Karpathy:\"写代码\"已经不是对的动词了");
|
||||||
|
assert.equal(result.metadata.author, "宝玉 (@dotey)");
|
||||||
|
assert.equal(result.metadata.coverImage, "https://pbs.twimg.com/media/article-cover.jpg");
|
||||||
|
assert.equal(result.metadata.published, "2026-03-20T23:49:11.000Z");
|
||||||
|
assert.equal(result.metadata.language, "zh");
|
||||||
|
assert.ok(result.markdown.includes("## 要点速览"));
|
||||||
|
assert.ok(
|
||||||
|
result.markdown.includes(
|
||||||
|
"[](/dotey/article/2035141635713941927/media/2)"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert.ok(result.markdown.includes("写代码已经不是对的动词了。"));
|
||||||
|
|
||||||
|
const document = createMarkdownDocument(result);
|
||||||
|
assert.ok(document.includes("# Karpathy:\"写代码\"已经不是对的动词了"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses X status pages from HTML without duplicating the title heading", () => {
|
||||||
|
const result = parse(
|
||||||
|
STATUS_HTML,
|
||||||
|
"https://x.com/dotey/status/2035590649081196710"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result);
|
||||||
|
assert.equal(result.conversionMethod, "parser:x-status");
|
||||||
|
assert.equal(result.metadata.author, "宝玉 (@dotey)");
|
||||||
|
assert.equal(result.metadata.coverImage, "https://pbs.twimg.com/media/tweet-main.jpg");
|
||||||
|
assert.equal(result.metadata.language, "zh");
|
||||||
|
assert.ok(result.markdown.includes("转译:把下面这段加到你的 Codex 自定义指令里"));
|
||||||
|
assert.ok(result.markdown.includes("> Quote from Matt Shumer (@mattshumer_)"));
|
||||||
|
assert.ok(result.markdown.includes("!["));
|
||||||
|
|
||||||
|
const document = createMarkdownDocument(result);
|
||||||
|
assert.ok(
|
||||||
|
!document.includes("\n\n# 转译:把下面这段加到你的 Codex 自定义指令里,体验会好太多:\n\n")
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
isMarkdownUsable,
|
||||||
|
normalizeMarkdown,
|
||||||
|
parseDocument,
|
||||||
|
type ConversionResult,
|
||||||
|
type PageMetadata,
|
||||||
|
} from "../markdown-conversion-shared.js";
|
||||||
|
import { URL_RULE_PARSERS } from "./rules/index.js";
|
||||||
|
import type { UrlRuleParserContext } from "./types.js";
|
||||||
|
|
||||||
|
export type { UrlRuleParser, UrlRuleParserContext } from "./types.js";
|
||||||
|
|
||||||
|
export function tryUrlRuleParsers(
|
||||||
|
html: string,
|
||||||
|
url: string,
|
||||||
|
baseMetadata: PageMetadata
|
||||||
|
): ConversionResult | null {
|
||||||
|
const document = parseDocument(html);
|
||||||
|
const context: UrlRuleParserContext = {
|
||||||
|
html,
|
||||||
|
url,
|
||||||
|
document,
|
||||||
|
baseMetadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const parser of URL_RULE_PARSERS) {
|
||||||
|
if (!parser.supports(context)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = parser.parse(context);
|
||||||
|
if (!result) continue;
|
||||||
|
|
||||||
|
const markdown = normalizeMarkdown(result.markdown);
|
||||||
|
if (!isMarkdownUsable(markdown, html)) continue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
markdown,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn(`[url-to-markdown] parser ${parser.id} failed: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { convertHtmlFragmentToMarkdown } from "../../legacy-converter.js";
|
||||||
|
import {
|
||||||
|
normalizeMarkdown,
|
||||||
|
pickString,
|
||||||
|
type ConversionResult,
|
||||||
|
} from "../../markdown-conversion-shared.js";
|
||||||
|
import type { UrlRuleParser, UrlRuleParserContext } from "../types.js";
|
||||||
|
|
||||||
|
const ARCHIVE_HOSTS = new Set([
|
||||||
|
"archive.ph",
|
||||||
|
"archive.is",
|
||||||
|
"archive.today",
|
||||||
|
"archive.md",
|
||||||
|
"archive.vn",
|
||||||
|
"archive.li",
|
||||||
|
"archive.fo",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isArchiveHost(url: string): boolean {
|
||||||
|
try {
|
||||||
|
return ARCHIVE_HOSTS.has(new URL(url).hostname.toLowerCase());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOriginalUrl(document: Document): string | undefined {
|
||||||
|
const value = document.querySelector("input[name='q']")?.getAttribute("value")?.trim();
|
||||||
|
if (!value) return undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(value).href;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(text: string, maxLength: number): string | undefined {
|
||||||
|
const normalized = text.replace(/\s+/g, " ").trim();
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
if (normalized.length <= maxLength) return normalized;
|
||||||
|
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickContentRoot(document: Document): Element | null {
|
||||||
|
return (
|
||||||
|
document.querySelector("#CONTENT") ??
|
||||||
|
document.querySelector("#content") ??
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickContentTitle(root: Element, fallbackTitle: string): string {
|
||||||
|
const contentTitle = pickString(
|
||||||
|
root.querySelector("h1")?.textContent,
|
||||||
|
root.querySelector("[itemprop='headline']")?.textContent,
|
||||||
|
root.querySelector("article h2")?.textContent
|
||||||
|
);
|
||||||
|
if (contentTitle) return contentTitle;
|
||||||
|
if (fallbackTitle && !/^archive\./i.test(fallbackTitle.trim())) return fallbackTitle;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArchivePage(context: UrlRuleParserContext): ConversionResult | null {
|
||||||
|
const root = pickContentRoot(context.document);
|
||||||
|
if (!root) return null;
|
||||||
|
|
||||||
|
const markdown = normalizeMarkdown(convertHtmlFragmentToMarkdown(root.innerHTML));
|
||||||
|
if (!markdown) return null;
|
||||||
|
|
||||||
|
const originalUrl = readOriginalUrl(context.document) ?? context.baseMetadata.url;
|
||||||
|
const bodyText = root.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
||||||
|
const published = root.querySelector("time[datetime]")?.getAttribute("datetime") ?? undefined;
|
||||||
|
const coverImage = root.querySelector("img[src]")?.getAttribute("src") ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata: {
|
||||||
|
...context.baseMetadata,
|
||||||
|
url: originalUrl,
|
||||||
|
title: pickContentTitle(root, context.baseMetadata.title),
|
||||||
|
description: summarize(bodyText, 220) ?? context.baseMetadata.description,
|
||||||
|
published: pickString(published, context.baseMetadata.published) ?? undefined,
|
||||||
|
coverImage: pickString(coverImage, context.baseMetadata.coverImage) ?? undefined,
|
||||||
|
},
|
||||||
|
markdown,
|
||||||
|
rawHtml: context.html,
|
||||||
|
conversionMethod: "parser:archive-ph",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const archivePhRuleParser: UrlRuleParser = {
|
||||||
|
id: "archive-ph",
|
||||||
|
supports(context) {
|
||||||
|
return isArchiveHost(context.url);
|
||||||
|
},
|
||||||
|
parse: parseArchivePage,
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { archivePhRuleParser } from "./archive-ph.js";
|
||||||
|
import { xArticleRuleParser } from "./x-article.js";
|
||||||
|
import { xStatusRuleParser } from "./x-status.js";
|
||||||
|
import type { UrlRuleParser } from "../types.js";
|
||||||
|
|
||||||
|
export const URL_RULE_PARSERS: UrlRuleParser[] = [
|
||||||
|
archivePhRuleParser,
|
||||||
|
xArticleRuleParser,
|
||||||
|
xStatusRuleParser,
|
||||||
|
];
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import {
|
||||||
|
normalizeMarkdown,
|
||||||
|
pickString,
|
||||||
|
type ConversionResult,
|
||||||
|
} from "../../markdown-conversion-shared.js";
|
||||||
|
import type { UrlRuleParser, UrlRuleParserContext } from "../types.js";
|
||||||
|
import {
|
||||||
|
cleanText,
|
||||||
|
collectMediaMarkdown,
|
||||||
|
convertXRichTextElementToMarkdown,
|
||||||
|
extractPublishedForCurrentUrl,
|
||||||
|
inferLanguage,
|
||||||
|
isXArticlePath,
|
||||||
|
isXHost,
|
||||||
|
normalizeXMarkdown,
|
||||||
|
parseUrl,
|
||||||
|
pickFirstValidLinkText,
|
||||||
|
sanitizeCoverImage,
|
||||||
|
summarizeText,
|
||||||
|
} from "./x-shared.js";
|
||||||
|
|
||||||
|
function collectArticleMarkdown(root: Element): { markdown: string; mediaUrls: string[] } {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const seenMedia = new Set<string>();
|
||||||
|
const mediaUrls: string[] = [];
|
||||||
|
|
||||||
|
function pushPart(value: string): void {
|
||||||
|
const normalized = normalizeMarkdown(value);
|
||||||
|
if (!normalized) return;
|
||||||
|
parts.push(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(node: Element): void {
|
||||||
|
const testId = node.getAttribute("data-testid");
|
||||||
|
|
||||||
|
if (testId === "twitterArticleRichTextView" || testId === "longformRichTextComponent") {
|
||||||
|
const bodyMedia = collectMediaMarkdown(node, seenMedia);
|
||||||
|
mediaUrls.push(...bodyMedia.urls.filter((url) => !mediaUrls.includes(url)));
|
||||||
|
pushPart(convertXRichTextElementToMarkdown(node));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testId === "tweetPhoto") {
|
||||||
|
const media = collectMediaMarkdown(node, seenMedia);
|
||||||
|
mediaUrls.push(...media.urls.filter((url) => !mediaUrls.includes(url)));
|
||||||
|
for (const line of media.lines) pushPart(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
testId === "twitter-article-title" ||
|
||||||
|
testId === "User-Name" ||
|
||||||
|
testId === "Tweet-User-Avatar" ||
|
||||||
|
testId === "reply" ||
|
||||||
|
testId === "retweet" ||
|
||||||
|
testId === "like" ||
|
||||||
|
testId === "bookmark" ||
|
||||||
|
testId === "caret" ||
|
||||||
|
testId === "app-text-transition-container"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.tagName === "TIME" || node.tagName === "BUTTON") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Array.from(node.children)) {
|
||||||
|
walk(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Array.from(root.children)) {
|
||||||
|
walk(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown: normalizeXMarkdown(parts.join("\n\n")),
|
||||||
|
mediaUrls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXArticle(context: UrlRuleParserContext): ConversionResult | null {
|
||||||
|
const articleRoot = context.document.querySelector("[data-testid='twitterArticleReadView']") as Element | null;
|
||||||
|
if (!articleRoot) return null;
|
||||||
|
|
||||||
|
const title = cleanText(
|
||||||
|
context.document.querySelector("[data-testid='twitter-article-title']")?.textContent
|
||||||
|
);
|
||||||
|
const identity = pickFirstValidLinkText(
|
||||||
|
context.document.querySelector("[data-testid='User-Name']")
|
||||||
|
);
|
||||||
|
const published = extractPublishedForCurrentUrl(articleRoot, context.url);
|
||||||
|
const { markdown, mediaUrls } = collectArticleMarkdown(articleRoot);
|
||||||
|
if (!markdown) return null;
|
||||||
|
|
||||||
|
const bodyText = cleanText(
|
||||||
|
context.document.querySelector("[data-testid='twitterArticleRichTextView']")?.textContent ??
|
||||||
|
context.document.querySelector("[data-testid='longformRichTextComponent']")?.textContent
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata: {
|
||||||
|
...context.baseMetadata,
|
||||||
|
title: pickString(title, context.baseMetadata.title) ?? "",
|
||||||
|
description: summarizeText(bodyText, 220) ?? context.baseMetadata.description,
|
||||||
|
author: pickString(identity.author, context.baseMetadata.author) ?? undefined,
|
||||||
|
published: pickString(published, context.baseMetadata.published) ?? undefined,
|
||||||
|
coverImage: sanitizeCoverImage(mediaUrls[0], context.baseMetadata.coverImage),
|
||||||
|
language: inferLanguage(bodyText, context.baseMetadata.language),
|
||||||
|
},
|
||||||
|
markdown,
|
||||||
|
rawHtml: context.html,
|
||||||
|
conversionMethod: "parser:x-article",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const xArticleRuleParser: UrlRuleParser = {
|
||||||
|
id: "x-article",
|
||||||
|
supports(context) {
|
||||||
|
const parsed = parseUrl(context.url);
|
||||||
|
if (!parsed || !isXHost(parsed.hostname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
isXArticlePath(parsed.pathname) ||
|
||||||
|
Boolean(
|
||||||
|
context.document.querySelector("[data-testid='twitterArticleReadView']") ||
|
||||||
|
context.document.querySelector("[data-testid='twitterArticleRichTextView']")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
parse(context) {
|
||||||
|
return parseXArticle(context);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { convertHtmlFragmentToMarkdown } from "../../legacy-converter.js";
|
||||||
|
import { normalizeMarkdown } from "../../markdown-conversion-shared.js";
|
||||||
|
|
||||||
|
export const DEFAULT_X_OG_IMAGE = "https://abs.twimg.com/rweb/ssr/default/v2/og/image.png";
|
||||||
|
|
||||||
|
export type MediaResult = {
|
||||||
|
lines: string[];
|
||||||
|
urls: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isXHost(hostname: string): boolean {
|
||||||
|
const normalized = hostname.toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized === "x.com" ||
|
||||||
|
normalized === "twitter.com" ||
|
||||||
|
normalized.endsWith(".x.com") ||
|
||||||
|
normalized.endsWith(".twitter.com")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUrl(input: string): URL | null {
|
||||||
|
try {
|
||||||
|
return new URL(input);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isXStatusPath(pathname: string): boolean {
|
||||||
|
return /^\/[^/]+\/status(?:es)?\/\d+$/i.test(pathname) || /^\/i\/web\/status\/\d+$/i.test(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isXArticlePath(pathname: string): boolean {
|
||||||
|
return /^\/[^/]+\/article\/\d+$/i.test(pathname) || /^\/(?:i\/)?article\/\d+$/i.test(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanText(value: string | null | undefined): string {
|
||||||
|
return (value ?? "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanUserLabel(value: string | null | undefined): string {
|
||||||
|
return cleanText(value).replace(/\bVerified account\b/gi, "").replace(/\s{2,}/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeMarkdownAlt(text: string): string {
|
||||||
|
return text.replace(/[\[\]]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAlt(text: string | null | undefined): string {
|
||||||
|
const cleaned = cleanText(text);
|
||||||
|
if (!cleaned || /^(image|photo)$/i.test(cleaned)) return "";
|
||||||
|
return escapeMarkdownAlt(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeText(text: string, maxLength: number): string | undefined {
|
||||||
|
const normalized = cleanText(text);
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
return normalized.length > maxLength
|
||||||
|
? `${normalized.slice(0, maxLength - 3)}...`
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTweetTitle(text: string, fallback: string): string {
|
||||||
|
return summarizeText(text, 80) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeXMarkdown(markdown: string): string {
|
||||||
|
return normalizeMarkdown(markdown.replace(/^(#{1,6})\s*\n+([^\n])/gm, "$1 $2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferLanguage(text: string, fallback?: string): string | undefined {
|
||||||
|
const normalized = cleanText(text);
|
||||||
|
if (!normalized) return fallback;
|
||||||
|
|
||||||
|
const han = (normalized.match(/\p{Script=Han}/gu) || []).length;
|
||||||
|
const hiragana = (normalized.match(/\p{Script=Hiragana}/gu) || []).length;
|
||||||
|
const katakana = (normalized.match(/\p{Script=Katakana}/gu) || []).length;
|
||||||
|
const hangul = (normalized.match(/\p{Script=Hangul}/gu) || []).length;
|
||||||
|
|
||||||
|
if (hangul >= 8) return "ko";
|
||||||
|
if (hiragana + katakana >= 8) return "ja";
|
||||||
|
if (han >= 16) return "zh";
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildQuoteMarkdown(markdown: string, author?: string): string {
|
||||||
|
const normalized = normalizeMarkdown(markdown);
|
||||||
|
if (!normalized) return "";
|
||||||
|
|
||||||
|
const lines = normalized.split("\n");
|
||||||
|
const prefixed = lines.map((line) => (line ? `> ${line}` : ">")).join("\n");
|
||||||
|
const header = author ? `> Quote from ${author}` : "> Quote";
|
||||||
|
return `${header}\n${prefixed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickFirstValidLinkText(userNameEl: Element | null | undefined): {
|
||||||
|
name?: string;
|
||||||
|
username?: string;
|
||||||
|
author?: string;
|
||||||
|
} {
|
||||||
|
if (!userNameEl) return {};
|
||||||
|
|
||||||
|
const linkTexts = Array.from(userNameEl.querySelectorAll("a[href]"))
|
||||||
|
.map((link) => cleanUserLabel(link.textContent))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
let username = linkTexts.find((text) => text.startsWith("@"));
|
||||||
|
let name = linkTexts.find((text) => !text.startsWith("@") && !/^(promote|more)$/i.test(text));
|
||||||
|
|
||||||
|
if (!username || !name) {
|
||||||
|
const text = cleanUserLabel(userNameEl.textContent);
|
||||||
|
const fallbackMatch = text.match(/^(.*?)\s*(@[A-Za-z0-9_]+)(?:\s*·.*)?$/);
|
||||||
|
if (fallbackMatch) {
|
||||||
|
name = name ?? cleanText(fallbackMatch[1]);
|
||||||
|
username = username ?? cleanText(fallbackMatch[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const author = name && username ? `${name} (${username})` : username ?? name;
|
||||||
|
return { name, username, author };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPublishedForCurrentUrl(root: ParentNode, url: string): string | undefined {
|
||||||
|
const parsed = parseUrl(url);
|
||||||
|
if (!parsed) return undefined;
|
||||||
|
const currentPath = parsed.pathname.toLowerCase();
|
||||||
|
|
||||||
|
for (const timeElement of root.querySelectorAll("a[href] time[datetime]")) {
|
||||||
|
const href = timeElement.closest("a")?.getAttribute("href");
|
||||||
|
const hrefUrl = href ? parseUrl(href.startsWith("http") ? href : `${parsed.origin}${href}`) : null;
|
||||||
|
if (hrefUrl?.pathname.toLowerCase() === currentPath) {
|
||||||
|
return timeElement.getAttribute("datetime") ?? undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return root.querySelector("time[datetime]")?.getAttribute("datetime") ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectMediaMarkdown(root: ParentNode, seen: Set<string>): MediaResult {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const urls: string[] = [];
|
||||||
|
const rootElement = root as Element & {
|
||||||
|
getAttribute?: (name: string) => string | null;
|
||||||
|
};
|
||||||
|
const photoNodes = [
|
||||||
|
...(typeof rootElement.getAttribute === "function" &&
|
||||||
|
rootElement.getAttribute("data-testid") === "tweetPhoto"
|
||||||
|
? [rootElement]
|
||||||
|
: []),
|
||||||
|
...Array.from(root.querySelectorAll("[data-testid='tweetPhoto']")),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const node of photoNodes) {
|
||||||
|
const img = node.querySelector("img");
|
||||||
|
const imageUrl = img?.getAttribute("src");
|
||||||
|
if (imageUrl && !seen.has(imageUrl)) {
|
||||||
|
seen.add(imageUrl);
|
||||||
|
urls.push(imageUrl);
|
||||||
|
lines.push(``);
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = node.querySelector("video");
|
||||||
|
const posterUrl = video?.getAttribute("poster");
|
||||||
|
if (posterUrl && !seen.has(posterUrl)) {
|
||||||
|
seen.add(posterUrl);
|
||||||
|
urls.push(posterUrl);
|
||||||
|
lines.push(``);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoUrl = video?.getAttribute("src") ?? video?.querySelector("source")?.getAttribute("src");
|
||||||
|
if (videoUrl && !seen.has(videoUrl)) {
|
||||||
|
seen.add(videoUrl);
|
||||||
|
urls.push(videoUrl);
|
||||||
|
lines.push(`[video](${videoUrl})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lines, urls };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materializeTweetPhotoNodes(root: Element): void {
|
||||||
|
for (const photo of Array.from(root.querySelectorAll("[data-testid='tweetPhoto']"))) {
|
||||||
|
const document = photo.ownerDocument;
|
||||||
|
const container = document.createElement("span");
|
||||||
|
|
||||||
|
const img = photo.querySelector("img");
|
||||||
|
const imageUrl = img?.getAttribute("src");
|
||||||
|
if (imageUrl) {
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.setAttribute("src", imageUrl);
|
||||||
|
const alt = normalizeAlt(img?.getAttribute("alt"));
|
||||||
|
if (alt) {
|
||||||
|
image.setAttribute("alt", alt);
|
||||||
|
}
|
||||||
|
container.appendChild(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = photo.querySelector("video");
|
||||||
|
const posterUrl = video?.getAttribute("poster");
|
||||||
|
if (posterUrl) {
|
||||||
|
const poster = document.createElement("img");
|
||||||
|
poster.setAttribute("src", posterUrl);
|
||||||
|
poster.setAttribute("alt", "video");
|
||||||
|
container.appendChild(poster);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoUrl = video?.getAttribute("src") ?? video?.querySelector("source")?.getAttribute("src");
|
||||||
|
if (videoUrl) {
|
||||||
|
if (container.childNodes.length > 0) {
|
||||||
|
container.appendChild(document.createTextNode(" "));
|
||||||
|
}
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.setAttribute("href", videoUrl);
|
||||||
|
link.textContent = "video";
|
||||||
|
container.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container.childNodes.length === 0) {
|
||||||
|
photo.remove();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
photo.replaceWith(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseLinkedMediaContainers(root: Element): void {
|
||||||
|
for (const anchor of Array.from(root.querySelectorAll("a[href]"))) {
|
||||||
|
const images = Array.from(anchor.querySelectorAll("img"));
|
||||||
|
if (images.length !== 1) continue;
|
||||||
|
if (cleanText(anchor.textContent)) continue;
|
||||||
|
|
||||||
|
const image = images[0].cloneNode(true);
|
||||||
|
anchor.replaceChildren(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertXRichTextElementToMarkdown(node: Element): string {
|
||||||
|
const clone = node.cloneNode(true) as Element;
|
||||||
|
materializeTweetPhotoNodes(clone);
|
||||||
|
collapseLinkedMediaContainers(clone);
|
||||||
|
return normalizeXMarkdown(convertHtmlFragmentToMarkdown(clone.innerHTML));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeCoverImage(primary?: string, fallback?: string): string | undefined {
|
||||||
|
if (primary) return primary;
|
||||||
|
if (!fallback || fallback === DEFAULT_X_OG_IMAGE) return undefined;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { ConversionResult } from "../../markdown-conversion-shared.js";
|
||||||
|
import type { UrlRuleParser, UrlRuleParserContext } from "../types.js";
|
||||||
|
import {
|
||||||
|
buildQuoteMarkdown,
|
||||||
|
buildTweetTitle,
|
||||||
|
cleanText,
|
||||||
|
collectMediaMarkdown,
|
||||||
|
convertXRichTextElementToMarkdown,
|
||||||
|
extractPublishedForCurrentUrl,
|
||||||
|
inferLanguage,
|
||||||
|
isXHost,
|
||||||
|
isXStatusPath,
|
||||||
|
normalizeXMarkdown,
|
||||||
|
parseUrl,
|
||||||
|
pickFirstValidLinkText,
|
||||||
|
sanitizeCoverImage,
|
||||||
|
summarizeText,
|
||||||
|
} from "./x-shared.js";
|
||||||
|
|
||||||
|
function parseXStatus(context: UrlRuleParserContext): ConversionResult | null {
|
||||||
|
const article = context.document.querySelector("article[data-testid='tweet'], article") as Element | null;
|
||||||
|
if (!article) return null;
|
||||||
|
|
||||||
|
const tweetTextElements = Array.from(article.querySelectorAll("[data-testid='tweetText']")) as Element[];
|
||||||
|
if (tweetTextElements.length === 0) return null;
|
||||||
|
|
||||||
|
const userNameElements = Array.from(article.querySelectorAll("[data-testid='User-Name']")) as Element[];
|
||||||
|
const mainTextElement = tweetTextElements[0];
|
||||||
|
const mainIdentity = pickFirstValidLinkText(userNameElements[0]);
|
||||||
|
const published = extractPublishedForCurrentUrl(article, context.url);
|
||||||
|
const mainMarkdown = normalizeXMarkdown(convertXRichTextElementToMarkdown(mainTextElement));
|
||||||
|
if (!mainMarkdown) return null;
|
||||||
|
|
||||||
|
const parts = [mainMarkdown];
|
||||||
|
const quotedTextElements = tweetTextElements.slice(1);
|
||||||
|
const quotedUserNameElements = userNameElements.slice(1);
|
||||||
|
|
||||||
|
quotedTextElements.forEach((element, index) => {
|
||||||
|
const quoteMarkdown = normalizeXMarkdown(convertXRichTextElementToMarkdown(element));
|
||||||
|
if (!quoteMarkdown) return;
|
||||||
|
const quoteIdentity = pickFirstValidLinkText(quotedUserNameElements[index]);
|
||||||
|
parts.push(buildQuoteMarkdown(quoteMarkdown, quoteIdentity.author));
|
||||||
|
});
|
||||||
|
|
||||||
|
const media = collectMediaMarkdown(article, new Set<string>());
|
||||||
|
if (media.lines.length > 0) {
|
||||||
|
parts.push(media.lines.join("\n\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainText = cleanText(mainTextElement.textContent);
|
||||||
|
const markdown = normalizeXMarkdown(parts.join("\n\n"));
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata: {
|
||||||
|
...context.baseMetadata,
|
||||||
|
title: buildTweetTitle(mainText, context.baseMetadata.title),
|
||||||
|
description: summarizeText(mainText, 220) ?? context.baseMetadata.description,
|
||||||
|
author: mainIdentity.author ?? context.baseMetadata.author,
|
||||||
|
published: published ?? context.baseMetadata.published,
|
||||||
|
coverImage: sanitizeCoverImage(media.urls[0], context.baseMetadata.coverImage),
|
||||||
|
language: inferLanguage(mainText, context.baseMetadata.language),
|
||||||
|
},
|
||||||
|
markdown,
|
||||||
|
rawHtml: context.html,
|
||||||
|
conversionMethod: "parser:x-status",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const xStatusRuleParser: UrlRuleParser = {
|
||||||
|
id: "x-status",
|
||||||
|
supports(context) {
|
||||||
|
const parsed = parseUrl(context.url);
|
||||||
|
if (!parsed || !isXHost(parsed.hostname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isXStatusPath(parsed.pathname) && Boolean(context.document.querySelector("[data-testid='tweetText']"));
|
||||||
|
},
|
||||||
|
parse(context): ConversionResult | null {
|
||||||
|
return parseXStatus(context);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ConversionResult, PageMetadata } from "../markdown-conversion-shared.js";
|
||||||
|
|
||||||
|
export interface UrlRuleParserContext {
|
||||||
|
html: string;
|
||||||
|
url: string;
|
||||||
|
document: Document;
|
||||||
|
baseMetadata: PageMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UrlRuleParser {
|
||||||
|
id: string;
|
||||||
|
supports(context: UrlRuleParserContext): boolean;
|
||||||
|
parse(context: UrlRuleParserContext): ConversionResult | null;
|
||||||
|
}
|
||||||
@@ -151,8 +151,7 @@ From outline entry:
|
|||||||
```markdown
|
```markdown
|
||||||
## Watermark
|
## Watermark
|
||||||
|
|
||||||
Include a subtle watermark "{content}" positioned at {position}
|
Include a subtle watermark "{content}" positioned at {position}. The watermark should
|
||||||
with approximately {opacity*100}% visibility. The watermark should
|
|
||||||
be legible but not distracting from the main content.
|
be legible but not distracting from the main content.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
---
|
||||||
|
name: baoyu-youtube-transcript
|
||||||
|
description: Downloads YouTube video transcripts/subtitles and cover images by URL or video ID. Supports multiple languages, translation, chapters, and speaker identification. Caches raw data for fast re-formatting. Use when user asks to "get YouTube transcript", "download subtitles", "get captions", "YouTube字幕", "YouTube封面", "视频封面", "video thumbnail", "video cover image", or provides a YouTube URL and wants the transcript/subtitle text or cover image extracted.
|
||||||
|
version: 1.1.0
|
||||||
|
metadata:
|
||||||
|
openclaw:
|
||||||
|
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-youtube-transcript
|
||||||
|
requires:
|
||||||
|
anyBins:
|
||||||
|
- bun
|
||||||
|
- npx
|
||||||
|
---
|
||||||
|
|
||||||
|
# YouTube Transcript
|
||||||
|
|
||||||
|
Downloads transcripts (subtitles/captions) from YouTube videos. Works with both manually created and auto-generated transcripts. No API key or browser required — uses YouTube's InnerTube API directly and automatically falls back to `yt-dlp` when YouTube blocks the direct API path.
|
||||||
|
|
||||||
|
Fetches video metadata and cover image on first run, caches raw data for fast re-formatting.
|
||||||
|
|
||||||
|
## Script Directory
|
||||||
|
|
||||||
|
Scripts in `scripts/` subdirectory. `{baseDir}` = this SKILL.md's directory path. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun. Replace `{baseDir}` and `${BUN_X}` with actual values.
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `scripts/main.ts` | Transcript download CLI |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default: markdown with timestamps (English)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts <youtube-url-or-id>
|
||||||
|
|
||||||
|
# Specify languages (priority order)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts <url> --languages zh,en,ja
|
||||||
|
|
||||||
|
# Without timestamps
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts <url> --no-timestamps
|
||||||
|
|
||||||
|
# With chapter segmentation
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts <url> --chapters
|
||||||
|
|
||||||
|
# With speaker identification (requires AI post-processing)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts <url> --speakers
|
||||||
|
|
||||||
|
# SRT subtitle file
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts <url> --format srt
|
||||||
|
|
||||||
|
# Translate transcript
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts <url> --translate zh-Hans
|
||||||
|
|
||||||
|
# List available transcripts
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts <url> --list
|
||||||
|
|
||||||
|
# Force re-fetch (ignore cache)
|
||||||
|
${BUN_X} {baseDir}/scripts/main.ts <url> --refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `<url-or-id>` | YouTube URL or video ID (multiple allowed) | Required |
|
||||||
|
| `--languages <codes>` | Language codes, comma-separated, in priority order | `en` |
|
||||||
|
| `--format <fmt>` | Output format: `text`, `srt` | `text` |
|
||||||
|
| `--translate <code>` | Translate to specified language code | |
|
||||||
|
| `--list` | List available transcripts instead of fetching | |
|
||||||
|
| `--timestamps` | Include `[HH:MM:SS → HH:MM:SS]` timestamps per paragraph | on |
|
||||||
|
| `--no-timestamps` | Disable timestamps | |
|
||||||
|
| `--chapters` | Chapter segmentation from video description | |
|
||||||
|
| `--speakers` | Raw transcript with metadata for speaker identification | |
|
||||||
|
| `--exclude-generated` | Skip auto-generated transcripts | |
|
||||||
|
| `--exclude-manually-created` | Skip manually created transcripts | |
|
||||||
|
| `--refresh` | Force re-fetch, ignore cached data | |
|
||||||
|
| `-o, --output <path>` | Save to specific file path | auto-generated |
|
||||||
|
| `--output-dir <dir>` | Base output directory | `youtube-transcript` |
|
||||||
|
|
||||||
|
## Optional Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `YOUTUBE_TRANSCRIPT_COOKIES_FROM_BROWSER` | Passed to `yt-dlp --cookies-from-browser` during fallback, e.g. `chrome`, `safari`, `firefox`, or `chrome:Profile 1` |
|
||||||
|
|
||||||
|
## Input Formats
|
||||||
|
|
||||||
|
Accepts any of these as video input:
|
||||||
|
- Full URL: `https://www.youtube.com/watch?v=dQw4w9WgXcQ`
|
||||||
|
- Short URL: `https://youtu.be/dQw4w9WgXcQ`
|
||||||
|
- Embed URL: `https://www.youtube.com/embed/dQw4w9WgXcQ`
|
||||||
|
- Shorts URL: `https://www.youtube.com/shorts/dQw4w9WgXcQ`
|
||||||
|
- Video ID: `dQw4w9WgXcQ`
|
||||||
|
|
||||||
|
## Output Formats
|
||||||
|
|
||||||
|
| Format | Extension | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `text` | `.md` | Markdown with frontmatter (incl. `description`), title heading, summary, optional TOC/cover/timestamps/chapters/speakers |
|
||||||
|
| `srt` | `.srt` | SubRip subtitle format for video players |
|
||||||
|
|
||||||
|
## Output Directory
|
||||||
|
|
||||||
|
```
|
||||||
|
youtube-transcript/
|
||||||
|
├── .index.json # Video ID → directory path mapping (for cache lookup)
|
||||||
|
└── {channel-slug}/{title-full-slug}/
|
||||||
|
├── meta.json # Video metadata (title, channel, description, duration, chapters, etc.)
|
||||||
|
├── transcript-raw.json # Raw transcript snippets from YouTube API (cached)
|
||||||
|
├── transcript-sentences.json # Sentence-segmented transcript (split by punctuation, merged across snippets)
|
||||||
|
├── imgs/
|
||||||
|
│ └── cover.jpg # Video thumbnail
|
||||||
|
├── transcript.md # Markdown transcript (generated from sentences)
|
||||||
|
└── transcript.srt # SRT subtitle (generated from raw snippets, if --format srt)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `{channel-slug}`: Channel name in kebab-case
|
||||||
|
- `{title-full-slug}`: Full video title in kebab-case
|
||||||
|
|
||||||
|
The `--list` mode outputs to stdout only (no file saved).
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
On first fetch, the script saves:
|
||||||
|
- `meta.json` — video metadata, chapters, cover image path, language info
|
||||||
|
- `transcript-raw.json` — raw transcript snippets from YouTube API (`{ text, start, duration }[]`)
|
||||||
|
- `transcript-sentences.json` — sentence-segmented transcript (`{ text, start: "HH:mm:ss", end: "HH:mm:ss" }[]`), split by sentence-ending punctuation (`.?!…。?!` etc.), timestamps proportionally allocated by character length, CJK-aware text merging
|
||||||
|
- `imgs/cover.jpg` — video thumbnail
|
||||||
|
|
||||||
|
Subsequent runs for the same video use cached data (no network calls). Use `--refresh` to force re-fetch. If a different language is requested, the cache is automatically refreshed.
|
||||||
|
|
||||||
|
When YouTube returns anti-bot / blocked responses on the direct InnerTube path, the script retries with alternate client identities and then falls back to `yt-dlp` if available. If fallback is needed but `yt-dlp` is unavailable, the agent should decide how to make `yt-dlp` available and continue rather than pushing the installation decision to the user.
|
||||||
|
|
||||||
|
SRT output (`--format srt`) is generated from `transcript-raw.json`. Text/markdown output uses `transcript-sentences.json` for natural sentence boundaries.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
When user provides a YouTube URL and wants the transcript:
|
||||||
|
|
||||||
|
1. Run with `--list` first if the user hasn't specified a language, to show available options
|
||||||
|
2. **Always single-quote the URL** when running the script — zsh treats `?` as a glob wildcard, so an unquoted YouTube URL causes "no matches found": use `'https://www.youtube.com/watch?v=ID'`
|
||||||
|
3. Default: run with `--chapters --speakers` for the richest output (chapters + speaker identification)
|
||||||
|
3. The script auto-saves cached data + output file and prints the file path
|
||||||
|
4. For `--speakers` mode: after the script saves the raw file, follow the speaker identification workflow below to post-process with speaker labels
|
||||||
|
|
||||||
|
When user only wants a cover image or metadata, running the script with any option will also cache `meta.json` and `imgs/cover.jpg`.
|
||||||
|
|
||||||
|
When re-formatting the same video (e.g., first text then SRT), the cached data is reused — no re-fetch needed.
|
||||||
|
|
||||||
|
## Chapter & Speaker Workflow
|
||||||
|
|
||||||
|
### Chapters (`--chapters`)
|
||||||
|
|
||||||
|
The script parses chapter timestamps from the video description (e.g., `0:00 Introduction`), segments the transcript by chapter boundaries, groups snippets into readable paragraphs, and saves as `.md` with a Table of Contents. No further processing needed.
|
||||||
|
|
||||||
|
If no chapter timestamps exist in the description, the transcript is output as grouped paragraphs without chapter headings.
|
||||||
|
|
||||||
|
### Speaker Identification (`--speakers`)
|
||||||
|
|
||||||
|
Speaker identification requires AI processing. The script outputs a raw `.md` file containing:
|
||||||
|
- YAML frontmatter with video metadata (title, channel, date, cover, description, language)
|
||||||
|
- Video description (for speaker name extraction)
|
||||||
|
- Chapter list from description (if available)
|
||||||
|
- Raw transcript in SRT format (pre-computed start/end timestamps, token-efficient)
|
||||||
|
|
||||||
|
After the script saves the raw file, spawn a sub-agent (use a cheaper model like Sonnet for cost efficiency) to process speaker identification:
|
||||||
|
|
||||||
|
1. Read the saved `.md` file
|
||||||
|
2. Read the prompt template at `{baseDir}/prompts/speaker-transcript.md`
|
||||||
|
3. Process the raw transcript following the prompt:
|
||||||
|
- Identify speakers using video metadata (title → guest, channel → host, description → names)
|
||||||
|
- Detect speaker turns from conversation flow, question-answer patterns, and contextual cues
|
||||||
|
- Segment into chapters (use description chapters if available, else create from topic shifts)
|
||||||
|
- Format with `**Speaker Name:**` labels, paragraph grouping (2-4 sentences), and `[HH:MM:SS → HH:MM:SS]` timestamps
|
||||||
|
4. Overwrite the `.md` file with the processed transcript (keep the YAML frontmatter)
|
||||||
|
|
||||||
|
When `--speakers` is used, `--chapters` is implied — the processed output always includes chapter segmentation.
|
||||||
|
|
||||||
|
## Error Cases
|
||||||
|
|
||||||
|
| Error | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| Transcripts disabled | Video has no captions at all |
|
||||||
|
| No transcript found | Requested language not available |
|
||||||
|
| Video unavailable | Video deleted, private, or region-locked |
|
||||||
|
| IP blocked | Too many requests, try again later |
|
||||||
|
| Age restricted | Video requires login for age verification |
|
||||||
|
| bot detected | The script retries alternate clients and then `yt-dlp`; if fallback tooling is missing, the agent should resolve that itself, otherwise if it still fails try `YOUTUBE_TRANSCRIPT_COOKIES_FROM_BROWSER=safari` (or your browser) |
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Speaker & Chapter Transcript Processing
|
||||||
|
|
||||||
|
You are an expert transcript specialist. Process the raw transcript file (with YAML frontmatter metadata and SRT-formatted transcript) into a structured, verbatim transcript with speaker identification and chapter segmentation.
|
||||||
|
|
||||||
|
## Output Structure
|
||||||
|
|
||||||
|
Produce a single cohesive markdown file containing:
|
||||||
|
1. YAML frontmatter (keep the original frontmatter from the raw file, which includes `description`)
|
||||||
|
2. `# Title` heading (from frontmatter title)
|
||||||
|
3. Description/summary paragraph (from frontmatter `description`)
|
||||||
|
4. Table of Contents
|
||||||
|
5. Cover image (if `cover` exists in frontmatter): `` — right after the ToC
|
||||||
|
6. Full chapter-segmented transcript with speaker labels
|
||||||
|
|
||||||
|
Use the same language as the transcription for the title and ToC.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
### Transcription Fidelity
|
||||||
|
- Preserve every spoken word exactly, including filler words (`um`, `uh`, `like`) and stutters
|
||||||
|
- **NEVER translate.** If the audio mixes languages (e.g., "这个 feature 很酷"), replicate that mix exactly
|
||||||
|
|
||||||
|
### Speaker Identification
|
||||||
|
- **Priority 1: Use metadata.** Analyze the video's title, channel name, and description to identify speakers
|
||||||
|
- **Priority 2: Use transcript content.** Look for introductions, how speakers address each other, contextual cues
|
||||||
|
- **Fallback:** Use consistent generic labels (`**Speaker 1:**`, `**Host:**`, etc.)
|
||||||
|
- **Consistency:** If a speaker's name is revealed later, update ALL previous labels for that speaker
|
||||||
|
|
||||||
|
### Chapter Generation
|
||||||
|
- If the raw file contains a `# Chapters` section, use those as the primary basis for segmenting
|
||||||
|
- Otherwise, create chapters based on significant topic shifts in the conversation
|
||||||
|
|
||||||
|
### Input Format
|
||||||
|
- The `# Transcript` section contains SRT-formatted subtitles with pre-computed start/end timestamps
|
||||||
|
- Each SRT block has: sequence number, `HH:MM:SS,mmm --> HH:MM:SS,mmm` timestamp line, and text
|
||||||
|
- Use the SRT timestamps directly — no need to calculate paragraph start/end times, just merge adjacent blocks
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
|
||||||
|
**Timestamps:** Use `[HH:MM:SS → HH:MM:SS]` format (start → end) at the end of each paragraph. No milliseconds.
|
||||||
|
|
||||||
|
**Table of Contents:**
|
||||||
|
```
|
||||||
|
## Table of Contents
|
||||||
|
* [HH:MM:SS] Chapter Title
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chapters:**
|
||||||
|
```
|
||||||
|
## Chapter Title [HH:MM:SS]
|
||||||
|
```
|
||||||
|
Two blank lines between chapters.
|
||||||
|
|
||||||
|
**Dialogue Paragraphs:**
|
||||||
|
- First paragraph of a speaker's turn starts with `**Speaker Name:** `
|
||||||
|
- Split long monologues into 2-4 sentence paragraphs separated by blank lines
|
||||||
|
- Subsequent paragraphs from the SAME speaker do NOT repeat the speaker label
|
||||||
|
- Every paragraph ends with exactly ONE timestamp range `[HH:MM:SS → HH:MM:SS]`
|
||||||
|
|
||||||
|
Correct example:
|
||||||
|
```
|
||||||
|
**Jane Doe:** The study focuses on long-term effects of dietary changes. We tracked two groups over five years. [00:00:15 → 00:00:21]
|
||||||
|
|
||||||
|
The first group followed the new regimen, while the second group maintained a traditional diet. [00:00:21 → 00:00:28]
|
||||||
|
|
||||||
|
**Host:** Fascinating. And what did you find? [00:00:28 → 00:00:31]
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrong (multiple timestamps in one paragraph):
|
||||||
|
```
|
||||||
|
**Host:** Welcome back. [00:00:01] Today we have a guest. [00:00:02]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Non-Speech Audio:** On its own line: `[Laughter] [HH:MM:SS]`
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: "Example Interview"
|
||||||
|
channel: "The Show"
|
||||||
|
date: 2024-04-15
|
||||||
|
url: "https://www.youtube.com/watch?v=xxx"
|
||||||
|
cover: imgs/cover.jpg
|
||||||
|
description: "Jane Doe discusses her groundbreaking five-year study on the long-term effects of dietary changes."
|
||||||
|
language: en
|
||||||
|
---
|
||||||
|
|
||||||
|
# Example Interview
|
||||||
|
|
||||||
|
Jane Doe discusses her groundbreaking five-year study on the long-term effects of dietary changes.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
* [00:00:00] Introduction and Welcome
|
||||||
|
* [00:00:12] Overview of the New Research
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## Introduction and Welcome [00:00:00]
|
||||||
|
|
||||||
|
**Host:** Welcome back to the show. Today, we have a, uh, very special guest, Jane Doe. [00:00:00 → 00:00:03]
|
||||||
|
|
||||||
|
**Jane Doe:** Thank you for having me. I'm excited to be here and discuss the findings. [00:00:03 → 00:00:07]
|
||||||
|
|
||||||
|
|
||||||
|
## Overview of the New Research [00:00:12]
|
||||||
|
|
||||||
|
**Host:** So, Jane, before we get into the nitty-gritty, could you, you know, give us a brief overview for our audience? [00:00:12 → 00:00:16]
|
||||||
|
|
||||||
|
**Jane Doe:** Of course. The study focuses on the long-term effects of specific dietary changes. It's a bit complicated but essentially we tracked two large groups over a five-year period. [00:00:16 → 00:00:23]
|
||||||
|
|
||||||
|
The first group followed the new regimen, while the second group, our control, maintained a traditional diet. This allowed us to isolate variables effectively. [00:00:23 → 00:00:30]
|
||||||
|
|
||||||
|
[Laughter] [00:00:30]
|
||||||
|
|
||||||
|
**Host:** Fascinating. And what did you find? [00:00:31 → 00:00:33]
|
||||||
|
```
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { findTranscript, parseTranscriptJson3, parseWebVtt } from "./transcript.ts";
|
||||||
|
import { buildTranscriptListFromYtDlp, resolveVideoSource, selectYtDlpTrack } from "./youtube.ts";
|
||||||
|
|
||||||
|
test("selectYtDlpTrack prefers json3 over xml and vtt", () => {
|
||||||
|
const track = selectYtDlpTrack([
|
||||||
|
{ ext: "vtt", url: "https://example.com/subs.vtt" },
|
||||||
|
{ ext: "srv3", url: "https://example.com/subs.srv3" },
|
||||||
|
{ ext: "json3", url: "https://example.com/subs.json3" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(track?.ext, "json3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildTranscriptListFromYtDlp keeps manual and generated tracks separate", () => {
|
||||||
|
const transcripts = buildTranscriptListFromYtDlp({
|
||||||
|
subtitles: {
|
||||||
|
en: [
|
||||||
|
{ ext: "json3", url: "https://example.com/en.json3", name: "English" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
automatic_captions: {
|
||||||
|
"zh-Hans": [
|
||||||
|
{ ext: "json3", url: "https://example.com/zh.json3", name: "Chinese (Simplified)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(transcripts.length, 2);
|
||||||
|
assert.equal(transcripts[0].isGenerated, false);
|
||||||
|
assert.equal(transcripts[1].isGenerated, true);
|
||||||
|
assert.equal(transcripts[0].translationLanguages[0]?.languageCode, "zh-Hans");
|
||||||
|
|
||||||
|
const translated = findTranscript(transcripts, ["zh-Hans"], false, false);
|
||||||
|
assert.equal(translated.languageCode, "zh-Hans");
|
||||||
|
assert.equal(translated.isGenerated, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseTranscriptJson3 reads youtube timedtext json3 payloads", () => {
|
||||||
|
const snippets = parseTranscriptJson3(JSON.stringify({
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
tStartMs: 80,
|
||||||
|
dDurationMs: 3120,
|
||||||
|
segs: [{ utf8: "hello\nworld" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tStartMs: 4000,
|
||||||
|
dDurationMs: 1800,
|
||||||
|
segs: [{ utf8: "again" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.deepEqual(snippets, [
|
||||||
|
{ text: "hello world", start: 0.08, duration: 3.12 },
|
||||||
|
{ text: "again", start: 4, duration: 1.8 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseWebVtt strips tags and cue settings", () => {
|
||||||
|
const snippets = parseWebVtt(`WEBVTT
|
||||||
|
|
||||||
|
00:00:00.080 --> 00:00:03.200 align:start position:0%
|
||||||
|
<c.colorE5E5E5>Hello</c> world
|
||||||
|
|
||||||
|
00:00:04.000 --> 00:00:05.800
|
||||||
|
Again
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.equal(snippets.length, 2);
|
||||||
|
assert.equal(snippets[0].text, "Hello world");
|
||||||
|
assert.equal(snippets[0].start, 0.08);
|
||||||
|
assert.equal(snippets[0].duration, 3.12);
|
||||||
|
assert.equal(snippets[1].text, "Again");
|
||||||
|
assert.equal(snippets[1].start, 4);
|
||||||
|
assert.equal(Number(snippets[1].duration.toFixed(1)), 1.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveVideoSource prefers primary InnerTube result before fallback", async () => {
|
||||||
|
let fallbackCalled = false;
|
||||||
|
const source = await resolveVideoSource(
|
||||||
|
"video12345ab",
|
||||||
|
async () => ({ kind: "innertube", data: { videoDetails: { title: "Primary" } }, transcripts: [] }),
|
||||||
|
() => {
|
||||||
|
fallbackCalled = true;
|
||||||
|
return {
|
||||||
|
subtitles: {
|
||||||
|
en: [{ ext: "json3", url: "https://example.com/en.json3", name: "English" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(source.kind, "innertube");
|
||||||
|
assert.equal(fallbackCalled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveVideoSource falls back to yt-dlp only after fallback-eligible errors", async () => {
|
||||||
|
let fallbackCalled = false;
|
||||||
|
const source = await resolveVideoSource(
|
||||||
|
"video12345ab",
|
||||||
|
async () => {
|
||||||
|
const error = new Error("Request blocked for video12345ab: bot detected");
|
||||||
|
(error as Error & { code?: string }).code = "BOT_DETECTED";
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
fallbackCalled = true;
|
||||||
|
return {
|
||||||
|
automatic_captions: {
|
||||||
|
en: [{ ext: "json3", url: "https://example.com/en.json3", name: "English (auto-generated)" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(source.kind, "yt-dlp");
|
||||||
|
assert.equal(fallbackCalled, true);
|
||||||
|
assert.equal(source.transcripts[0].languageCode, "en");
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user