Compare commits

...

29 Commits

Author SHA1 Message Date
Jim Liu 宝玉 fbd9f9b622 chore: release v1.76.0 2026-03-21 23:08:21 -05:00
Jim Liu 宝玉 b6e293d059 fix(baoyu-markdown-to-html): use process.execPath and tsx import in test runner 2026-03-21 23:07:46 -05:00
Jim Liu 宝玉 bb78aab095 feat(baoyu-youtube-transcript): add title heading, description summary, and cover image to markdown output 2026-03-21 23:07:44 -05:00
Jim Liu 宝玉 5071a1d0d0 chore: release v1.75.0 2026-03-21 22:44:00 -05:00
Jim Liu 宝玉 e413ade164 feat(baoyu-youtube-transcript): add sentence segmentation and improve caching 2026-03-21 22:42:43 -05:00
Jim Liu 宝玉 e52f92b193 new skill 2026-03-21 21:03:06 -05:00
Jim Liu 宝玉 603cabaef4 chore: release v1.74.1 2026-03-21 00:03:51 -05:00
Jim Liu 宝玉 7d12526e90 fix(baoyu-image-gen): broaden OpenRouter model detection and aspect ratio validation 2026-03-21 00:03:20 -05:00
Jim Liu 宝玉 e7f9764a49 Merge pull request #101 from cwandev/fix/openrouter
fix(baoyu-image-gen): align `OpenRouter` image generation with current API
2026-03-20 23:32:26 -05:00
Jim Liu 宝玉 e55f91b0ea chore: release v1.74.0 2026-03-20 23:17:14 -05:00
Jim Liu 宝玉 fe3b3d9125 feat(baoyu-markdown-to-html): pass through all rendering options from CLI
- CLI now supports --color, --font-family, --font-size, --code-theme, --mac-code-block, --line-number, --count, --legend
- convertMarkdown accepts full CliOptions instead of limited subset
- Dynamic help text showing available theme/color/font options
2026-03-20 23:17:10 -05:00
Jim Liu 宝玉 105339cf3f fix(baoyu-md): fix CSS custom property regex for quoted values and add theme layering
- Remove quotes from CSS custom property regex character class so values containing quotes are fully stripped
- grace/simple themes now layer default CSS before their own rules
- Add tests for quoted property stripping and theme layering
2026-03-20 23:17:04 -05:00
Jim Liu 宝玉 dcfd9033ae chore: release v1.73.3 2026-03-20 18:46:01 -05:00
Jim Liu 宝玉 eb416d174c fix(baoyu-post-to-wechat): prevent placeholder regex from matching longer numbered variants 2026-03-20 18:45:05 -05:00
Jim Liu 宝玉 83afacb00e chore: release v1.73.2 2026-03-20 14:46:06 -05:00
Jim Liu 宝玉 0e6bfbcabd refactor: 提取图片处理模块,本地转换不支持的格式而非回退到material接口
将WeChat正文图片的格式转换和压缩逻辑提取到独立的wechat-image-processor模块,
使用jimp和@jsquash/webp在本地将WebP/BMP/GIF等不支持的格式转换为JPEG/PNG并
压缩到1MB以内,避免依赖material接口的回退策略。同时简化了news类型封面兜底逻辑。
2026-03-20 14:43:49 -05:00
Jim Liu 宝玉 7b67ff44b8 Merge pull request #100 from AICreator-Wind/fix-body-image-upload
fix: 正文图片上传使用 media/uploadimg 接口
2026-03-20 14:00:14 -05:00
浪不能停 79c289ca92 fix: 处理WebP等不支持的格式并优化回退逻辑
- 添加WebP、BMP、TIFF等格式到不支持列表
- 对于需要回退到material接口的情况,同时调用两个接口获取URL和media_id
- 提取uploadToWechat函数避免代码重复
2026-03-20 18:18:01 +08:00
浪不能停 fc5ad4b762 fix: 处理media/uploadimg接口的文件格式和大小限制
- 添加文件大小和格式检查常量
- GIF图片或大于1MB的图片自动回退使用material接口
- 确保向后兼容现有工作流
2026-03-20 14:25:48 +08:00
浪不能停 747977416d fix: 修复newspic类型图片上传和news类型封面兜底逻辑
- uploadImagesInHtml 函数添加 articleType 参数支持
- 为 newspic 类型正文图片额外调用 material 接口收集 media_id
- 返回 firstImageSource 用于 news 类型封面兜底逻辑
- 恢复 news 类型没有显式封面时使用第一张正文图作为封面的逻辑
2026-03-20 13:57:59 +08:00
cwandev e43eec260a fix(baoyu-image-gen): narrow OpenRouter Gemini ratios 2026-03-19 22:09:23 +08:00
cwandev 96ef6e2251 fix(baoyu-image-gen): harden OpenRouter image support 2026-03-19 21:59:41 +08:00
cwandev efb7a1917a fix(baoyu-image-gen): require OpenRouter image parameters 2026-03-19 21:26:19 +08:00
cwandev 1af984a64f fix(baoyu-image-gen): align OpenRouter image generation with current API 2026-03-19 17:36:03 +08:00
Jim Liu 宝玉 60363fc2df chore: release v1.73.1 2026-03-18 22:00:27 -05:00
Jim Liu 宝玉 28ec1053f6 refactor(baoyu-danger-x-to-markdown): migrate tests from bun:test to node:test 2026-03-18 21:59:49 -05:00
Jim Liu 宝玉 5a6afd576f chore: release v1.73.0 2026-03-18 21:56:14 -05:00
Jim Liu 宝玉 ebc74a10ad feat(baoyu-danger-x-to-markdown): add video media support for X articles 2026-03-18 21:54:21 -05:00
浪不能停 e79a42fd94 fix: 正文图片上传使用 media/uploadimg 接口
- 区分正文图片和封面图片的上传接口
- 正文图片使用 media/uploadimg (返回 URL)
- 封面图片使用 material/add_material (返回 media_id)
- 添加 uploadType 参数支持两种上传方式
- 优化错误提示,告知用户 news 类型需要封面图
2026-03-18 17:23:48 +08:00
38 changed files with 2844 additions and 203 deletions
+3 -2
View File
@@ -6,7 +6,7 @@
}, },
"metadata": { "metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency", "description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.72.0" "version": "1.76.0"
}, },
"plugins": [ "plugins": [
{ {
@@ -47,7 +47,8 @@
"./skills/baoyu-url-to-markdown", "./skills/baoyu-url-to-markdown",
"./skills/baoyu-format-markdown", "./skills/baoyu-format-markdown",
"./skills/baoyu-markdown-to-html", "./skills/baoyu-markdown-to-html",
"./skills/baoyu-translate" "./skills/baoyu-translate",
"./skills/baoyu-youtube-transcript"
] ]
} }
] ]
+1
View File
@@ -166,3 +166,4 @@ posts/
.clawdhub/ .clawdhub/
.release-artifacts/ .release-artifacts/
.worktrees/ .worktrees/
youtube-transcript/
+50
View File
@@ -2,6 +2,56 @@
English | [中文](./CHANGELOG.zh.md) English | [中文](./CHANGELOG.zh.md)
## 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 ## 1.72.0 - 2026-03-18
### Features ### Features
+50
View File
@@ -2,6 +2,56 @@
[English](./CHANGELOG.md) | 中文 [English](./CHANGELOG.md) | 中文
## 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 ## 1.72.0 - 2026-03-18
### 新功能 ### 新功能
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md # CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.72.0**. Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.76.0**.
## Architecture ## Architecture
+35 -1
View File
@@ -76,7 +76,7 @@ Simply tell Claude Code:
|--------|-------------|--------| |--------|-------------|--------|
| **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) | | **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) |
| **ai-generation-skills** | AI-powered generation backends | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) | | **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) | | **utility-skills** | Utility tools for content processing | [youtube-transcript](#baoyu-youtube-transcript), [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) |
## Update Skills ## Update Skills
@@ -766,6 +766,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.
+35 -1
View File
@@ -76,7 +76,7 @@ clawhub install baoyu-markdown-to-html
|------|------|----------| |------|------|----------|
| **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) | | **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) |
| **ai-generation-skills** | AI 生成后端 | [image-gen](#baoyu-image-gen), [danger-gemini-web](#baoyu-danger-gemini-web) | | **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) | | **utility-skills** | 内容处理工具 | [youtube-transcript](#baoyu-youtube-transcript), [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) |
## 更新技能 ## 更新技能
@@ -766,6 +766,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 失败时自动回退到旧版提取器。
+34
View File
@@ -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(
+7 -7
View File
@@ -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 {
+12 -1
View File
@@ -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,4 +1,5 @@
import { expect, test } from "bun:test"; import assert from "node:assert/strict";
import test from "node:test";
import { formatArticleMarkdown } from "./markdown.js"; import { formatArticleMarkdown } from "./markdown.js";
@@ -40,10 +41,10 @@ test("formatArticleMarkdown renders MARKDOWN entities from atomic blocks", () =>
const { markdown } = formatArticleMarkdown(article); const { markdown } = formatArticleMarkdown(article);
expect(markdown).toContain("Before the snippet."); assert.ok(markdown.includes("Before the snippet."));
expect(markdown).toContain("```python\nprint('hello from x article')\n```"); assert.ok(markdown.includes("```python\nprint('hello from x article')\n```"));
expect(markdown).toContain("After the snippet."); assert.ok(markdown.includes("After the snippet."));
expect(markdown).toBe(`# Atomic Markdown Example assert.strictEqual(markdown, `# Atomic Markdown Example
Before the snippet. Before the snippet.
@@ -53,3 +54,126 @@ print('hello from x article')
After the snippet.`); 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(`![Demo reel](${posterUrl})`));
assert.ok(markdown.includes(`[video](${videoUrl})`));
assert.ok(!markdown.includes(`![Demo reel](${videoUrl})`));
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(`![video](${posterUrl})`));
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(`![${altText || "video"}](${asset.posterUrl})`);
}
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 [`![${altText}](${asset.url})`];
}
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(`![${altText}](${url})`);
} }
} }
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(`![${altText}](${fallbackUrl})`); lines.push(...renderMediaLines(fallbackAsset, altText, usedUrls));
} }
return lines; return lines;
@@ -346,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>
@@ -602,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);
} }
@@ -633,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(`![](${url})`);
} }
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",
}); });
} }
@@ -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/,
);
});
@@ -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,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/,
);
});
+83 -29
View File
@@ -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:/);
});
@@ -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"),
}; };
} }
+147 -1
View File
@@ -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"),
}; };
} }
+159 -52
View File
@@ -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 接口只返回 URLmaterial/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 {
@@ -592,16 +697,6 @@ async function main(): Promise<void> {
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 +705,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 +737,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 +750,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,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.`);
}
@@ -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"),
}; };
} }
+176
View File
@@ -0,0 +1,176 @@
---
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.
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` |
## 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.
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. 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 |
@@ -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): `![cover](imgs/cover.jpg)` — 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
![cover](imgs/cover.jpg)
## 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,841 @@
#!/usr/bin/env bun
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join, resolve } from "path";
type Format = "text" | "srt";
interface Options {
videoIds: string[];
languages: string[];
format: Format;
translate: string;
list: boolean;
excludeGenerated: boolean;
excludeManual: boolean;
output: string;
outputDir: string;
timestamps: boolean;
chapters: boolean;
speakers: boolean;
refresh: boolean;
}
interface Snippet {
text: string;
start: number;
duration: number;
}
interface Sentence {
text: string;
start: string;
end: string;
}
interface TranscriptInfo {
language: string;
languageCode: string;
isGenerated: boolean;
isTranslatable: boolean;
baseUrl: string;
translationLanguages: { language: string; languageCode: string }[];
}
interface Chapter {
title: string;
start: number;
}
interface VideoMeta {
videoId: string;
title: string;
channel: string;
channelId: string;
description: string;
duration: number;
publishDate: string;
url: string;
coverImage: string;
thumbnailUrl: string;
language: { code: string; name: string; isGenerated: boolean };
chapters: Chapter[];
}
interface VideoResult {
videoId: string;
title?: string;
filePath?: string;
content?: string;
error?: string;
}
const WATCH_URL = "https://www.youtube.com/watch?v=";
const INNERTUBE_URL = "https://www.youtube.com/youtubei/v1/player";
const INNERTUBE_CTX = { client: { clientName: "ANDROID", clientVersion: "20.10.38" } };
function extractVideoId(input: string): string {
input = input.replace(/\\/g, "").trim();
const patterns = [
/(?:youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
/^([a-zA-Z0-9_-]{11})$/,
];
for (const p of patterns) {
const m = input.match(p);
if (m) return m[1];
}
return input;
}
function slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "") || "untitled";
}
function htmlUnescape(s: string): string {
return s
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, "/")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
}
function stripTags(s: string): string {
return s.replace(/<[^>]*>/g, "");
}
function parseTranscriptXml(xml: string): Snippet[] {
const snippets: Snippet[] = [];
const re = /<text\s+start="([^"]*)"(?:\s+dur="([^"]*)")?[^>]*>([\s\S]*?)<\/text>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(xml)) !== null) {
const raw = m[3];
if (!raw) continue;
snippets.push({
text: htmlUnescape(stripTags(raw)),
start: parseFloat(m[1]),
duration: parseFloat(m[2] || "0"),
});
}
return snippets;
}
// --- YouTube API ---
async function fetchHtml(videoId: string): Promise<string> {
const r = await fetch(WATCH_URL + videoId, {
headers: { "Accept-Language": "en-US", "User-Agent": "Mozilla/5.0" },
});
if (!r.ok) throw new Error(`HTTP ${r.status} fetching video page`);
let html = await r.text();
if (html.includes('action="https://consent.youtube.com/s"')) {
const cv = html.match(/name="v" value="(.*?)"/);
if (!cv) throw new Error("Failed to create consent cookie");
const r2 = await fetch(WATCH_URL + videoId, {
headers: {
"Accept-Language": "en-US",
"User-Agent": "Mozilla/5.0",
Cookie: `CONSENT=YES+${cv[1]}`,
},
});
if (!r2.ok) throw new Error(`HTTP ${r2.status} fetching video page (consent)`);
html = await r2.text();
}
return html;
}
function extractApiKey(html: string, videoId: string): string {
const m = html.match(/"INNERTUBE_API_KEY":\s*"([a-zA-Z0-9_-]+)"/);
if (!m) {
if (html.includes('class="g-recaptcha"')) throw new Error(`IP blocked for ${videoId} (reCAPTCHA)`);
throw new Error(`Cannot extract API key for ${videoId}`);
}
return m[1];
}
async function fetchInnertubeData(videoId: string, apiKey: string): Promise<any> {
const r = await fetch(`${INNERTUBE_URL}?key=${apiKey}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ context: INNERTUBE_CTX, videoId }),
});
if (r.status === 429) throw new Error(`IP blocked for ${videoId} (429)`);
if (!r.ok) throw new Error(`HTTP ${r.status} from InnerTube API`);
return r.json();
}
function assertPlayability(data: any, videoId: string) {
const ps = data?.playabilityStatus;
if (!ps) return;
const status = ps.status;
if (status === "OK" || !status) return;
const reason = ps.reason || "";
if (status === "LOGIN_REQUIRED") {
if (reason.includes("bot")) throw new Error(`Request blocked for ${videoId}: bot detected`);
if (reason.includes("inappropriate")) throw new Error(`Age restricted: ${videoId}`);
}
if (status === "ERROR" && reason.includes("unavailable")) {
if (videoId.startsWith("http")) throw new Error(`Invalid video ID: pass the ID, not the URL`);
throw new Error(`Video unavailable: ${videoId}`);
}
const subreasons = ps.errorScreen?.playerErrorMessageRenderer?.subreason?.runs?.map((r: any) => r.text).join("") || "";
throw new Error(`Video unplayable (${videoId}): ${reason} ${subreasons}`.trim());
}
function extractCaptionsJson(data: any, videoId: string): any {
assertPlayability(data, videoId);
const cj = data?.captions?.playerCaptionsTracklistRenderer;
if (!cj || !cj.captionTracks) throw new Error(`Transcripts disabled for ${videoId}`);
return cj;
}
function buildTranscriptList(captionsJson: any): TranscriptInfo[] {
const tlLangs = (captionsJson.translationLanguages || []).map((tl: any) => ({
language: tl.languageName?.runs?.[0]?.text || tl.languageName?.simpleText || "",
languageCode: tl.languageCode,
}));
return (captionsJson.captionTracks || []).map((t: any) => ({
language: t.name?.runs?.[0]?.text || t.name?.simpleText || "",
languageCode: t.languageCode,
isGenerated: t.kind === "asr",
isTranslatable: !!t.isTranslatable,
baseUrl: (t.baseUrl || "").replace(/&fmt=srv3/g, ""),
translationLanguages: t.isTranslatable ? tlLangs : [],
}));
}
function findTranscript(
transcripts: TranscriptInfo[],
languages: string[],
excludeGenerated: boolean,
excludeManual: boolean
): TranscriptInfo {
let filtered = transcripts;
if (excludeGenerated) filtered = filtered.filter((t) => !t.isGenerated);
if (excludeManual) filtered = filtered.filter((t) => t.isGenerated);
for (const lang of languages) {
const found = filtered.find((t) => t.languageCode === lang);
if (found) return found;
}
const available = filtered.map((t) => `${t.languageCode} ("${t.language}")`).join(", ");
throw new Error(`No transcript found for languages [${languages.join(", ")}]. Available: ${available || "none"}`);
}
async function fetchTranscriptSnippets(info: TranscriptInfo, translateTo?: string): Promise<{ snippets: Snippet[]; language: string; languageCode: string }> {
let url = info.baseUrl;
let lang = info.language;
let langCode = info.languageCode;
if (translateTo) {
if (!info.isTranslatable) throw new Error(`Transcript ${info.languageCode} is not translatable`);
const tl = info.translationLanguages.find((t) => t.languageCode === translateTo);
if (!tl) throw new Error(`Translation language ${translateTo} not available`);
url += `&tlang=${translateTo}`;
lang = tl.language;
langCode = translateTo;
}
const r = await fetch(url, { headers: { "Accept-Language": "en-US" } });
if (!r.ok) throw new Error(`HTTP ${r.status} fetching transcript`);
return { snippets: parseTranscriptXml(await r.text()), language: lang, languageCode: langCode };
}
// --- Metadata & chapters ---
function parseChapters(description: string): Chapter[] {
const chapters: Chapter[] = [];
for (const line of description.split("\n")) {
const m = line.trim().match(/^(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\s+(.+)$/);
if (m) {
const h = m[1] ? parseInt(m[1]) : 0;
chapters.push({ title: m[4].trim(), start: h * 3600 + parseInt(m[2]) * 60 + parseInt(m[3]) });
}
}
return chapters.length >= 2 ? chapters : [];
}
function getThumbnailUrls(videoId: string, data: any): string[] {
const urls = [
`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
`https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
];
const thumbnails = data?.videoDetails?.thumbnail?.thumbnails ||
data?.microformat?.playerMicroformatRenderer?.thumbnail?.thumbnails || [];
if (thumbnails.length) {
const sorted = [...thumbnails].sort((a: any, b: any) => (b.width || 0) - (a.width || 0));
for (const t of sorted) if (t.url && !urls.includes(t.url)) urls.push(t.url);
}
return urls;
}
function buildVideoMeta(data: any, videoId: string, langInfo: { code: string; name: string; isGenerated: boolean }, chapters: Chapter[]): VideoMeta {
const vd = data?.videoDetails || {};
const mf = data?.microformat?.playerMicroformatRenderer || {};
return {
videoId,
title: vd.title || mf.title?.simpleText || "",
channel: vd.author || mf.ownerChannelName || "",
channelId: vd.channelId || mf.externalChannelId || "",
description: vd.shortDescription || mf.description?.simpleText || "",
duration: parseInt(vd.lengthSeconds || "0"),
publishDate: mf.publishDate || mf.uploadDate || "",
url: `https://www.youtube.com/watch?v=${videoId}`,
coverImage: "",
thumbnailUrl: getThumbnailUrls(videoId, data)[0],
language: langInfo,
chapters,
};
}
async function downloadCoverImage(urls: string[], outputPath: string): Promise<boolean> {
for (const u of urls) {
try {
const r = await fetch(u);
if (r.ok) {
writeFileSync(outputPath, Buffer.from(await r.arrayBuffer()));
return true;
}
} catch {}
}
return false;
}
function parseSrt(srt: string): Snippet[] {
const blocks = srt.trim().split(/\n\n+/);
const snippets: Snippet[] = [];
for (const block of blocks) {
const lines = block.split("\n");
if (lines.length < 3) continue;
const m = lines[1].match(/(\d{2}):(\d{2}):(\d{2}),(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2}),(\d{3})/);
if (!m) continue;
const start = parseInt(m[1]) * 3600 + parseInt(m[2]) * 60 + parseInt(m[3]) + parseInt(m[4]) / 1000;
const end = parseInt(m[5]) * 3600 + parseInt(m[6]) * 60 + parseInt(m[7]) + parseInt(m[8]) / 1000;
snippets.push({ text: lines.slice(2).join(" "), start, duration: end - start });
}
return snippets;
}
// --- Timestamp formatting ---
function ts(t: number): string {
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = Math.floor(t % 60);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
function tsMs(t: number, sep: string): string {
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = Math.floor(t % 60);
const ms = Math.round((t - Math.floor(t)) * 1000);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}${sep}${String(ms).padStart(3, "0")}`;
}
// --- Paragraph grouping ---
interface Paragraph {
text: string;
start: number;
end: number;
}
function groupIntoParagraphs(snippets: Snippet[]): Paragraph[] {
if (!snippets.length) return [];
const paras: Paragraph[] = [];
let buf: Snippet[] = [];
for (let i = 0; i < snippets.length; i++) {
buf.push(snippets[i]);
const last = i === snippets.length - 1;
const gap = !last && snippets[i + 1].start - (snippets[i].start + snippets[i].duration) > 1.5;
if (last || gap || buf.length >= 8) {
const lastS = buf[buf.length - 1];
paras.push({ text: buf.map(s => s.text).join(" "), start: buf[0].start, end: lastS.start + lastS.duration });
buf = [];
}
}
return paras;
}
// --- Sentence segmentation ---
const SENTENCE_END_RE = /[.?!…。?!⁈⁇‼‽.]/;
function isCJK(ch: string): boolean {
const code = ch.charCodeAt(0);
return (code >= 0x4E00 && code <= 0x9FFF) ||
(code >= 0x3040 && code <= 0x309F) ||
(code >= 0x30A0 && code <= 0x30FF) ||
(code >= 0xAC00 && code <= 0xD7AF) ||
(code >= 0x3400 && code <= 0x4DBF) ||
(code >= 0xF900 && code <= 0xFAFF);
}
function splitSnippetAtPunctuation(s: Snippet): { text: string; start: number; end: number }[] {
const { text, start, duration } = s;
const end = start + duration;
if (!text.length) return [];
const splitPoints: number[] = [];
for (let i = 0; i < text.length; i++) {
if (SENTENCE_END_RE.test(text[i])) {
while (i + 1 < text.length && SENTENCE_END_RE.test(text[i + 1])) i++;
if (i < text.length - 1) splitPoints.push(i);
}
}
if (!splitPoints.length) return [{ text, start, end }];
const parts: { text: string; start: number; end: number }[] = [];
let prev = 0;
for (const pos of splitPoints) {
const partText = text.slice(prev, pos + 1).trim();
if (partText) {
parts.push({
text: partText,
start: start + (prev / text.length) * duration,
end: start + ((pos + 1) / text.length) * duration,
});
}
prev = pos + 1;
}
const remaining = text.slice(prev).trim();
if (remaining) {
parts.push({ text: remaining, start: start + (prev / text.length) * duration, end });
}
return parts;
}
function mergeTexts(texts: string[]): string {
if (!texts.length) return "";
let result = texts[0];
for (let i = 1; i < texts.length; i++) {
const next = texts[i];
if (!next) continue;
const lastChar = result[result.length - 1];
const firstChar = next[0];
if (isCJK(lastChar) || isCJK(firstChar)) {
result += next;
} else {
result = result.trimEnd() + " " + next.trimStart();
}
}
return result.replace(/ {2,}/g, " ");
}
function segmentIntoSentences(snippets: Snippet[]): Sentence[] {
const parts: { text: string; start: number; end: number }[] = [];
for (const s of snippets) parts.push(...splitSnippetAtPunctuation(s));
const sentences: Sentence[] = [];
let buf: { text: string; start: number; end: number }[] = [];
for (const part of parts) {
buf.push(part);
if (SENTENCE_END_RE.test(part.text[part.text.length - 1])) {
sentences.push({
text: mergeTexts(buf.map(b => b.text)),
start: ts(buf[0].start),
end: ts(buf[buf.length - 1].end),
});
buf = [];
}
}
if (buf.length) {
sentences.push({
text: mergeTexts(buf.map(b => b.text)),
start: ts(buf[0].start),
end: ts(buf[buf.length - 1].end),
});
}
return sentences;
}
function parseTs(t: string): number {
const [h, m, s] = t.split(":").map(Number);
return h * 3600 + m * 60 + s;
}
function groupSentenceParas(sentences: Sentence[]): Paragraph[] {
if (!sentences.length) return [];
const paras: Paragraph[] = [];
let buf: Sentence[] = [];
for (let i = 0; i < sentences.length; i++) {
buf.push(sentences[i]);
const last = i === sentences.length - 1;
const gap = !last && parseTs(sentences[i + 1].start) - parseTs(sentences[i].end) > 2;
if (last || gap || buf.length >= 5) {
paras.push({
text: mergeTexts(buf.map(s => s.text)),
start: parseTs(buf[0].start),
end: parseTs(buf[buf.length - 1].end),
});
buf = [];
}
}
return paras;
}
// --- Format functions ---
function formatSrt(snippets: Snippet[]): string {
return snippets
.map((s, i) => {
const end = i < snippets.length - 1 && snippets[i + 1].start < s.start + s.duration
? snippets[i + 1].start
: s.start + s.duration;
return `${i + 1}\n${tsMs(s.start, ",")} --> ${tsMs(end, ",")}\n${s.text}`;
})
.join("\n\n") + "\n";
}
function yamlEscape(s: string): string {
if (/[:"'{}\[\]#&*!|>%@`\n]/.test(s) || s.trim() !== s) return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
return s;
}
function extractSummary(description: string): string {
if (!description) return "";
const firstPara = description.split(/\n\s*\n/)[0].trim();
const lines = firstPara.split("\n").filter(l => !/^\s*(https?:\/\/|#|@|\d+:\d+)/.test(l) && l.trim());
return lines.join(" ").slice(0, 300).trim();
}
function formatMarkdown(sentences: Sentence[], meta: VideoMeta, opts: { timestamps: boolean; chapters: boolean; speakers: boolean }, snippets?: Snippet[]): string {
const summary = extractSummary(meta.description);
let md = "---\n";
md += `title: ${yamlEscape(meta.title)}\n`;
md += `channel: ${yamlEscape(meta.channel)}\n`;
if (meta.publishDate) md += `date: ${meta.publishDate}\n`;
md += `url: ${yamlEscape(meta.url)}\n`;
if (meta.coverImage) md += `cover: ${meta.coverImage}\n`;
if (summary) md += `description: ${yamlEscape(summary)}\n`;
if (meta.language) md += `language: ${meta.language.code}\n`;
md += "---\n\n";
if (opts.speakers) {
md += `# ${meta.title}\n\n`;
if (summary) md += `${summary}\n\n`;
if (meta.description) md += "# Description\n\n" + meta.description.trim() + "\n\n";
if (meta.chapters.length) {
md += "# Chapters\n\n";
for (const ch of meta.chapters) md += `* [${ts(ch.start)}] ${ch.title}\n`;
md += "\n";
}
md += "# Transcript\n\n";
md += snippets ? formatSrt(snippets) : "";
return md;
}
md += `# ${meta.title}\n\n`;
if (summary) md += `${summary}\n\n`;
const chapters = opts.chapters ? meta.chapters : [];
if (chapters.length) {
md += "## Table of Contents\n\n";
for (const ch of chapters) md += opts.timestamps ? `* [${ts(ch.start)}] ${ch.title}\n` : `* ${ch.title}\n`;
md += "\n";
if (meta.coverImage) md += `\n![cover](${meta.coverImage})\n`;
md += "\n";
for (let i = 0; i < chapters.length; i++) {
const nextStart = i < chapters.length - 1 ? chapters[i + 1].start : Infinity;
const chSentences = sentences.filter(s => parseTs(s.start) >= chapters[i].start && parseTs(s.start) < nextStart);
const paras = groupSentenceParas(chSentences);
md += opts.timestamps
? `## [${ts(chapters[i].start)}] ${chapters[i].title}\n\n`
: `## ${chapters[i].title}\n\n`;
for (const p of paras) md += opts.timestamps ? `${p.text} [${ts(p.start)}${ts(p.end)}]\n\n` : `${p.text}\n\n`;
md += "\n";
}
} else {
const paras = groupSentenceParas(sentences);
for (const p of paras) md += opts.timestamps ? `${p.text} [${ts(p.start)}${ts(p.end)}]\n\n` : `${p.text}\n\n`;
}
return md.trimEnd() + "\n";
}
function formatListOutput(videoId: string, title: string, transcripts: TranscriptInfo[]): string {
const manual = transcripts.filter((t) => !t.isGenerated);
const generated = transcripts.filter((t) => t.isGenerated);
const tlLangs = transcripts.find((t) => t.translationLanguages.length > 0)?.translationLanguages || [];
const fmtList = (list: TranscriptInfo[]) =>
list.length ? list.map((t) => ` - ${t.languageCode} ("${t.language}")${t.isTranslatable ? " [TRANSLATABLE]" : ""}`).join("\n") : "None";
const fmtTl = tlLangs.length
? tlLangs.map((t) => ` - ${t.languageCode} ("${t.language}")`).join("\n")
: "None";
return `Transcripts for ${videoId}${title ? ` (${title})` : ""}:\n\n(MANUALLY CREATED)\n${fmtList(manual)}\n\n(GENERATED)\n${fmtList(generated)}\n\n(TRANSLATION LANGUAGES)\n${fmtTl}`;
}
// --- File helpers ---
function ensureDir(p: string) {
const dir = dirname(p);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
function resolveBaseDir(outputDir: string): string {
return resolve(outputDir || "youtube-transcript");
}
function loadIndex(baseDir: string): Record<string, string> {
try { return JSON.parse(readFileSync(join(baseDir, ".index.json"), "utf-8")); } catch { return {}; }
}
function saveIndex(baseDir: string, index: Record<string, string>) {
const p = join(baseDir, ".index.json");
ensureDir(p);
writeFileSync(p, JSON.stringify(index, null, 2));
}
function lookupVideoDir(videoId: string, baseDir: string): string | null {
const rel = loadIndex(baseDir)[videoId];
if (rel) {
const dir = resolve(baseDir, rel);
if (existsSync(dir)) return dir;
}
return null;
}
function registerVideoDir(videoId: string, channelSlug: string, titleSlug: string, baseDir: string): string {
const rel = join(channelSlug, titleSlug);
const index = loadIndex(baseDir);
index[videoId] = rel;
saveIndex(baseDir, index);
return resolve(baseDir, rel);
}
function hasCachedData(videoDir: string): boolean {
return existsSync(join(videoDir, "meta.json")) && existsSync(join(videoDir, "transcript-raw.json"));
}
function loadMeta(videoDir: string): VideoMeta {
return JSON.parse(readFileSync(join(videoDir, "meta.json"), "utf-8"));
}
function loadSnippets(videoDir: string): Snippet[] {
return JSON.parse(readFileSync(join(videoDir, "transcript-raw.json"), "utf-8"));
}
function loadSentences(videoDir: string): Sentence[] {
return JSON.parse(readFileSync(join(videoDir, "transcript-sentences.json"), "utf-8"));
}
// --- Main processing ---
async function fetchAndCache(videoId: string, baseDir: string, opts: Options): Promise<{ meta: VideoMeta; snippets: Snippet[]; sentences: Sentence[]; videoDir: string }> {
const html = await fetchHtml(videoId);
const apiKey = extractApiKey(html, videoId);
const data = await fetchInnertubeData(videoId, apiKey);
const captionsJson = extractCaptionsJson(data, videoId);
const transcripts = buildTranscriptList(captionsJson);
const info = findTranscript(transcripts, opts.languages, opts.excludeGenerated, opts.excludeManual);
const result = await fetchTranscriptSnippets(info, opts.translate || undefined);
const description = data?.videoDetails?.shortDescription || "";
const chapters = parseChapters(description);
const langInfo = { code: result.languageCode, name: result.language, isGenerated: info.isGenerated };
const meta = buildVideoMeta(data, videoId, langInfo, chapters);
const videoDir = registerVideoDir(videoId, slugify(meta.channel), slugify(meta.title), baseDir);
ensureDir(join(videoDir, "meta.json"));
writeFileSync(join(videoDir, "transcript-raw.json"), JSON.stringify(result.snippets, null, 2));
const sentences = segmentIntoSentences(result.snippets);
writeFileSync(join(videoDir, "transcript-sentences.json"), JSON.stringify(sentences, null, 2));
const imgPath = join(videoDir, "imgs", "cover.jpg");
ensureDir(imgPath);
const downloaded = await downloadCoverImage(getThumbnailUrls(videoId, data), imgPath);
meta.coverImage = downloaded ? "imgs/cover.jpg" : "";
writeFileSync(join(videoDir, "meta.json"), JSON.stringify(meta, null, 2));
return { meta, snippets: result.snippets, sentences, videoDir };
}
async function processVideo(videoId: string, opts: Options): Promise<VideoResult> {
const baseDir = resolveBaseDir(opts.outputDir);
// --list: always fetch fresh
if (opts.list) {
const html = await fetchHtml(videoId);
const apiKey = extractApiKey(html, videoId);
const data = await fetchInnertubeData(videoId, apiKey);
const title = data?.videoDetails?.title || "";
const captionsJson = extractCaptionsJson(data, videoId);
const transcripts = buildTranscriptList(captionsJson);
return { videoId, title, content: formatListOutput(videoId, title, transcripts) };
}
let videoDir = lookupVideoDir(videoId, baseDir);
let meta: VideoMeta;
let snippets: Snippet[];
let sentences: Sentence[];
let needsFetch = opts.refresh || !videoDir || !hasCachedData(videoDir);
if (!needsFetch && videoDir) {
meta = loadMeta(videoDir);
snippets = loadSnippets(videoDir);
sentences = loadSentences(videoDir);
const wantLangs = opts.translate ? [opts.translate] : opts.languages;
if (!wantLangs.includes(meta.language.code)) needsFetch = true;
}
if (needsFetch) {
const result = await fetchAndCache(videoId, baseDir, opts);
meta = result.meta;
snippets = result.snippets;
sentences = result.sentences;
videoDir = result.videoDir;
} else {
meta = meta!;
snippets = snippets!;
sentences = sentences!;
}
let content: string;
let ext: string;
if (opts.format === "srt") {
content = formatSrt(snippets);
ext = "srt";
} else {
content = formatMarkdown(sentences, meta, {
timestamps: opts.timestamps,
chapters: opts.chapters,
speakers: opts.speakers,
}, snippets);
ext = "md";
}
const filePath = opts.output ? resolve(opts.output) : join(videoDir!, `transcript.${ext}`);
ensureDir(filePath);
writeFileSync(filePath, content);
return { videoId, title: meta.title, filePath };
}
// --- CLI ---
function printHelp() {
console.log(`Usage: bun main.ts <video-url-or-id> [options]
Options:
--languages <codes> Language codes, comma-separated (default: en)
--format <fmt> Output format: text, srt (default: text)
--translate <code> Translate to language code
--list List available transcripts
--timestamps Include timestamps (default: on)
--no-timestamps Disable timestamps
--chapters Chapter segmentation from 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 cache)
-o, --output <path> Save to specific file path
--output-dir <dir> Base output directory (default: youtube-transcript)
-h, --help Show help`);
}
function parseArgs(argv: string[]): Options | null {
const opts: Options = {
videoIds: [],
languages: ["en"],
format: "text",
translate: "",
list: false,
excludeGenerated: false,
excludeManual: false,
output: "",
outputDir: "",
timestamps: true,
chapters: false,
speakers: false,
refresh: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "-h" || arg === "--help") {
printHelp();
process.exit(0);
} else if (arg === "--languages") {
const v = argv[++i];
if (v) opts.languages = v.split(",").map((s) => s.trim());
} else if (arg === "--format") {
const v = argv[++i]?.toLowerCase();
if (v === "text" || v === "srt") opts.format = v;
else {
console.error(`Invalid format: ${v}. Use: text, srt`);
return null;
}
} else if (arg === "--translate") {
opts.translate = argv[++i] || "";
} else if (arg === "--list" || arg === "--list-transcripts") {
opts.list = true;
} else if (arg === "--timestamps" || arg === "-t") {
opts.timestamps = true;
} else if (arg === "--no-timestamps") {
opts.timestamps = false;
} else if (arg === "--chapters") {
opts.chapters = true;
} else if (arg === "--speakers") {
opts.speakers = true;
} else if (arg === "--exclude-generated") {
opts.excludeGenerated = true;
} else if (arg === "--exclude-manually-created") {
opts.excludeManual = true;
} else if (arg === "--refresh") {
opts.refresh = true;
} else if (arg === "-o" || arg === "--output") {
opts.output = argv[++i] || "";
} else if (arg === "--output-dir") {
opts.outputDir = argv[++i] || "";
} else if (!arg.startsWith("-")) {
opts.videoIds.push(extractVideoId(arg));
}
}
if (opts.videoIds.length === 0) {
console.error("Error: At least one video URL or ID required");
printHelp();
return null;
}
return opts;
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
if (!opts) process.exit(1);
if (opts.excludeGenerated && opts.excludeManual) {
console.error("Error: Cannot exclude both generated and manually created transcripts");
process.exit(1);
}
for (const videoId of opts.videoIds) {
try {
const r = await processVideo(videoId, opts);
if (r.error) console.error(`Error (${r.videoId}): ${r.error}`);
else if (r.filePath) console.log(r.filePath);
else if (r.content) console.log(r.content);
} catch (e) {
console.error(`Error (${videoId}): ${(e as Error).message}`);
}
}
}
main();